IA Assistant version initiale

This commit is contained in:
ocroguennec committed 2026-09-05 10:54:50 +02:00
1 parent 3e8508d48b
commit 662003fdd3
29 files changed
+4253 -10

No files matched your search

+181
View File
@@ -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>
);
}