182 lines
6.8 KiB
React
182 lines
6.8 KiB
React
/**
|
|
* AssistantIA.jsx — Page dédiée de l'assistant IA (menu de gauche, sous
|
|
* Fiscalité) : le même chat que la bulle flottante (components/AiAssistant.jsx),
|
|
* en grand, avec la liste des conversations passées dans une colonne latérale
|
|
* (voir components/ai/aiChatShared.jsx pour la logique et le rendu partagés).
|
|
*
|
|
* La conversation affichée est pilotée par le paramètre d'URL ?conversation=,
|
|
* pour qu'un lien direct (ex. le bouton « Agrandir » du widget flottant)
|
|
* l'ouvre directement, et que le bouton retour du navigateur fonctionne.
|
|
*/
|
|
|
|
import { useCallback, useEffect, useState } from 'react';
|
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
|
import { api } from '../api.js';
|
|
import ConfirmModal from '../components/ConfirmModal.jsx';
|
|
import {
|
|
IconSparkle, IconNewChat, IconTrash,
|
|
MessageThread, Composer, useAiChat, relativeDate,
|
|
} from '../components/ai/aiChatShared.jsx';
|
|
|
|
export default function AssistantIA() {
|
|
const [enabled, setEnabled] = useState(null); // null = pas encore su
|
|
const [conversations, setConversations] = useState([]);
|
|
const [loadingList, setLoadingList] = useState(true);
|
|
const [input, setInput] = useState('');
|
|
const [confirmDeleteId, setConfirmDeleteId] = useState(null);
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
const navigate = useNavigate();
|
|
|
|
const syncUrl = useCallback((id) => {
|
|
setSearchParams(id ? { conversation: String(id) } : {}, { replace: true });
|
|
}, [setSearchParams]);
|
|
|
|
const loadConversations = useCallback(async () => {
|
|
try {
|
|
const r = await api.get('/ai/conversations');
|
|
setConversations(r.conversations || []);
|
|
return r.conversations || [];
|
|
} catch {
|
|
setConversations([]);
|
|
return [];
|
|
} finally {
|
|
setLoadingList(false);
|
|
}
|
|
}, []);
|
|
|
|
const chat = useAiChat({
|
|
onConversationChange: syncUrl,
|
|
onTurnComplete: loadConversations,
|
|
});
|
|
const { conversationId, messages, sending, loadingHistory, selectConversation, startNewConversation, sendMessage, regenerateMessage } = chat;
|
|
|
|
/* ── Statut d'accès ─────────────────────────────────────────────────── */
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
api.get('/ai/status').then((r) => { if (!cancelled) setEnabled(!!r.enabled); }).catch(() => { if (!cancelled) setEnabled(false); });
|
|
return () => { cancelled = true; };
|
|
}, []);
|
|
|
|
/* ── Chargement initial : conversation demandée dans l'URL, sinon la plus
|
|
* récente de la liste (reprendre là où on s'était arrêté). ────────────── */
|
|
useEffect(() => {
|
|
if (enabled !== true) return;
|
|
let cancelled = false;
|
|
(async () => {
|
|
const list = await loadConversations();
|
|
if (cancelled) return;
|
|
const fromUrl = searchParams.get('conversation');
|
|
if (fromUrl) {
|
|
selectConversation(Number(fromUrl));
|
|
} else if (list.length > 0) {
|
|
selectConversation(list[0].id);
|
|
}
|
|
})();
|
|
return () => { cancelled = true; };
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [enabled]);
|
|
|
|
const handleSend = useCallback(() => {
|
|
const text = input;
|
|
setInput('');
|
|
sendMessage(text);
|
|
}, [input, sendMessage]);
|
|
|
|
const handleNewConversation = useCallback(() => {
|
|
if (sending) return;
|
|
startNewConversation();
|
|
}, [sending, startNewConversation]);
|
|
|
|
const handleSelect = useCallback((id) => {
|
|
if (sending || id === conversationId) return;
|
|
selectConversation(id);
|
|
}, [sending, conversationId, selectConversation]);
|
|
|
|
const handleDelete = useCallback(async () => {
|
|
const id = confirmDeleteId;
|
|
if (id == null) return;
|
|
try {
|
|
await api.del(`/ai/conversations/${id}`);
|
|
if (id === conversationId) startNewConversation();
|
|
await loadConversations();
|
|
} catch { /* best-effort */ }
|
|
finally { setConfirmDeleteId(null); }
|
|
}, [confirmDeleteId, conversationId, startNewConversation, loadConversations]);
|
|
|
|
if (enabled === false) {
|
|
return (
|
|
<div className="ai-page">
|
|
<div className="ai-page-disabled">
|
|
<IconSparkle />
|
|
<p>L'assistant IA n'est pas activé pour votre compte.</p>
|
|
<p className="text-muted">Contactez un administrateur si vous pensez qu'il s'agit d'une erreur.</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const currentTitle = conversations.find((c) => c.id === conversationId)?.titre || 'Nouvelle conversation';
|
|
|
|
return (
|
|
<div className="ai-page">
|
|
<aside className="ai-page-sidebar">
|
|
<button className="ai-page-new-btn" onClick={handleNewConversation} disabled={sending}>
|
|
<IconNewChat /> Nouvelle conversation
|
|
</button>
|
|
<div className="ai-page-conv-list">
|
|
{loadingList ? (
|
|
<div className="ai-page-conv-empty">Chargement…</div>
|
|
) : conversations.length === 0 ? (
|
|
<div className="ai-page-conv-empty">Aucune conversation pour l'instant.</div>
|
|
) : (
|
|
conversations.map((c) => (
|
|
<div key={c.id} className={`ai-page-conv-item${c.id === conversationId ? ' active' : ''}`}>
|
|
<button className="ai-page-conv-select" onClick={() => handleSelect(c.id)} title={c.titre}>
|
|
<span className="ai-page-conv-title">{c.titre}</span>
|
|
<span className="ai-page-conv-date">{relativeDate(c.updatedAt)}</span>
|
|
</button>
|
|
<button
|
|
className="ai-page-conv-delete"
|
|
onClick={() => setConfirmDeleteId(c.id)}
|
|
aria-label="Supprimer la conversation"
|
|
title="Supprimer la conversation"
|
|
>
|
|
<IconTrash />
|
|
</button>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</aside>
|
|
|
|
<div className="ai-page-main">
|
|
<div className="ai-page-header">
|
|
<IconSparkle />
|
|
<span className="ai-page-header-title">{currentTitle}</span>
|
|
</div>
|
|
|
|
{loadingHistory ? (
|
|
<div className="ai-page-conv-empty" style={{ flex: 1 }}>Chargement de la conversation…</div>
|
|
) : (
|
|
<MessageThread
|
|
messages={messages}
|
|
emptyHint="Posez une question sur votre portefeuille — ex. « Quels sont mes intérêts nets perçus cette année ? »"
|
|
sending={sending}
|
|
onRegenerate={regenerateMessage}
|
|
/>
|
|
)}
|
|
|
|
<Composer value={input} onChange={setInput} onSend={handleSend} disabled={sending} autoFocusKey={conversationId} />
|
|
</div>
|
|
|
|
<ConfirmModal
|
|
open={confirmDeleteId != null}
|
|
title="Supprimer la conversation"
|
|
message="Supprimer cette conversation et tous ses messages ? Cette action est irréversible."
|
|
onConfirm={handleDelete}
|
|
onCancel={() => setConfirmDeleteId(null)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|