IA Assistant version initiale
This commit is contained in:
1 parent
3e8508d48b
commit
662003fdd3
29 files changed
+4253
-10
No files matched your search
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user