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,157 @@
|
||||
/**
|
||||
* AiAssistant.jsx — Bulle flottante de l'assistant IA, disponible sur
|
||||
* toutes les pages (sauf /assistant-ia, où le chat est déjà affiché en
|
||||
* grand — voir pages/AssistantIA.jsx). Partage sa logique de chat
|
||||
* (chargement, envoi en streaming, etc.) avec la page dédiée via
|
||||
* components/ai/aiChatShared.jsx ; les deux surfaces affichent la même
|
||||
* conversation, synchronisée par son id (cl_ai_conversation_id en
|
||||
* localStorage ici, paramètre ?conversation= sur la page dédiée).
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { api } from '../api.js';
|
||||
import {
|
||||
IconSparkle, IconNewChat, IconExpand,
|
||||
MessageThread, Composer, useAiChat,
|
||||
} from './ai/aiChatShared.jsx';
|
||||
|
||||
const STORAGE_KEY_OPEN = 'cl_ui_ai_chat_open';
|
||||
const STORAGE_KEY_CONVERSATION = 'cl_ai_conversation_id';
|
||||
// Fermeture automatique du panneau (pas de la conversation, qui reste en historique
|
||||
// côté serveur) après inactivité, pour ne pas laisser la fenêtre ouverte indéfiniment.
|
||||
const INACTIVITY_CLOSE_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
function IconClose() {
|
||||
return (
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
strokeWidth="2.2" strokeLinecap="round" aria-hidden="true">
|
||||
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AiAssistant() {
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [open, setOpen] = useState(() => {
|
||||
try { return localStorage.getItem(STORAGE_KEY_OPEN) === '1'; } catch { return false; }
|
||||
});
|
||||
const [input, setInput] = useState('');
|
||||
const [initialised, setInitialised] = useState(false);
|
||||
const inactivityTimerRef = useRef(null);
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const lastPathRef = useRef(location.pathname);
|
||||
|
||||
const persistConversationId = useCallback((id) => {
|
||||
try {
|
||||
if (id) localStorage.setItem(STORAGE_KEY_CONVERSATION, String(id));
|
||||
else localStorage.removeItem(STORAGE_KEY_CONVERSATION);
|
||||
} catch { /* ignore */ }
|
||||
}, []);
|
||||
|
||||
const chat = useAiChat({ onConversationChange: persistConversationId });
|
||||
const { conversationId, messages, sending, selectConversation, startNewConversation, sendMessage, regenerateMessage } = chat;
|
||||
|
||||
/* ── Statut : l'utilisateur courant a-t-il accès à l'assistant ? ──────── */
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api.get('/ai/status')
|
||||
.then((r) => { if (!cancelled) setEnabled(!!r.enabled); })
|
||||
.catch(() => { if (!cancelled) setEnabled(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
/* ── Reprend la conversation en cours (si connue) dès que l'accès est
|
||||
* confirmé — au plus une fois. */
|
||||
useEffect(() => {
|
||||
if (!enabled || initialised) return;
|
||||
setInitialised(true);
|
||||
let storedId = null;
|
||||
try { storedId = localStorage.getItem(STORAGE_KEY_CONVERSATION); } catch { /* ignore */ }
|
||||
if (storedId) selectConversation(Number(storedId));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [enabled, initialised]);
|
||||
|
||||
useEffect(() => {
|
||||
try { localStorage.setItem(STORAGE_KEY_OPEN, open ? '1' : '0'); } catch { /* ignore */ }
|
||||
}, [open]);
|
||||
|
||||
/* ── Fermeture automatique en changeant de page ────────────────────────
|
||||
* La conversation reste disponible (persistée côté serveur) ; seul le
|
||||
* panneau se replie, pour ne pas rester affiché par-dessus la nouvelle page. */
|
||||
useEffect(() => {
|
||||
if (location.pathname !== lastPathRef.current) {
|
||||
lastPathRef.current = location.pathname;
|
||||
setOpen(false);
|
||||
}
|
||||
}, [location.pathname]);
|
||||
|
||||
/* ── Fermeture automatique après inactivité ────────────────────────────
|
||||
* Le minuteur repart à chaque frappe, envoi, ou réception de contenu
|
||||
* (donc jamais pendant qu'une réponse est en cours de génération). */
|
||||
useEffect(() => {
|
||||
if (inactivityTimerRef.current) clearTimeout(inactivityTimerRef.current);
|
||||
if (!open) return undefined;
|
||||
inactivityTimerRef.current = setTimeout(() => setOpen(false), INACTIVITY_CLOSE_MS);
|
||||
return () => clearTimeout(inactivityTimerRef.current);
|
||||
}, [open, input, messages]);
|
||||
|
||||
const handleSend = useCallback(() => {
|
||||
const text = input;
|
||||
setInput('');
|
||||
sendMessage(text);
|
||||
}, [input, sendMessage]);
|
||||
|
||||
const expand = useCallback(() => {
|
||||
navigate(conversationId ? `/assistant-ia?conversation=${conversationId}` : '/assistant-ia');
|
||||
}, [conversationId, navigate]);
|
||||
|
||||
// Masquée sur la page dédiée : le chat y est déjà affiché en grand, pas
|
||||
// besoin d'une seconde interface de chat par-dessus.
|
||||
if (!enabled || location.pathname === '/assistant-ia') return null;
|
||||
|
||||
const panel = open && createPortal(
|
||||
<div className="ai-panel" role="dialog" aria-label="Assistant IA">
|
||||
<div className="ai-panel-header">
|
||||
<IconSparkle />
|
||||
<span className="ai-panel-title">Assistant IA</span>
|
||||
<button className="ai-panel-close" onClick={expand} aria-label="Agrandir" title="Agrandir dans une page dédiée">
|
||||
<IconExpand />
|
||||
</button>
|
||||
<button className="ai-panel-close" onClick={startNewConversation} disabled={sending || messages.length === 0}
|
||||
aria-label="Nouvelle conversation" title="Nouvelle conversation">
|
||||
<IconNewChat />
|
||||
</button>
|
||||
<button className="ai-panel-close" onClick={() => setOpen(false)} aria-label="Fermer">
|
||||
<IconClose />
|
||||
</button>
|
||||
</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={open} />
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className="ai-fab"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
title="Assistant IA"
|
||||
aria-label="Ouvrir l'assistant IA"
|
||||
>
|
||||
<IconSparkle />
|
||||
</button>
|
||||
{panel}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { useUi } from '../context/UiContext.jsx';
|
||||
import Logo from './Logo.jsx';
|
||||
import UserMenu from './UserMenu.jsx';
|
||||
import NotificationBell from './NotificationBell.jsx';
|
||||
import AiAssistant from './AiAssistant.jsx';
|
||||
|
||||
/* ── Icônes nav ─────────────────────────────────────────────── */
|
||||
const ICONS_BASE = '/api/icons-files/';
|
||||
@@ -25,6 +26,7 @@ const IconInvestments = () => <I><polyline points="1,15 5,9 9,11 15,3"/><polyli
|
||||
const IconRepayments = () => <I><path d="M15 9A6 6 0 1 1 9 3"/><polyline points="15,3 15,9 9,9"/></I>;
|
||||
const IconFlatTax = () => <I><circle cx="9" cy="9" r="7.5"/><path d="M9 1.5a11 11 0 0 1 3.5 7.5 11 11 0 0 1-3.5 7.5 11 11 0 0 1-3.5-7.5 11 11 0 0 1 3.5-7.5z"/><line x1="1.5" y1="9" x2="16.5" y2="9"/></I>;
|
||||
const IconFrais = () => <I><circle cx="5.5" cy="5.5" r="2"/><circle cx="12.5" cy="12.5" r="2"/><line x1="13.5" y1="3.5" x2="3.5" y2="13.5"/></I>;
|
||||
const IconAssistantIa = () => <I><path d="M9 2l1.3 4.2L14.5 7.5l-4.2 1.3L9 13l-1.3-4.2L3.5 7.5l4.2-1.3L9 2z"/><path d="M14.5 11.5l.5 1.5 1.5.5-1.5.5-.5 1.5-.5-1.5-1.5-.5 1.5-.5.5-1.5z"/></I>;
|
||||
|
||||
/* Icône nav hybride : bibliothèque si dispo, sinon fallback SVG inline */
|
||||
function NavIcon({ libFilename, Fallback }) {
|
||||
@@ -253,6 +255,7 @@ export default function Layout() {
|
||||
const { hasFrais } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [navIcons, setNavIcons] = useState({});
|
||||
const [aiEnabled, setAiEnabled] = useState(false);
|
||||
const [burgerOpen, setBurgerOpen] = useState(false);
|
||||
const burgerRef = useRef(null);
|
||||
|
||||
@@ -283,6 +286,10 @@ export default function Layout() {
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/ai/status').then(r => setAiEnabled(!!r.enabled)).catch(() => setAiEnabled(false));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={`app-shell${sidebarCollapsed ? ' sidebar-collapsed' : ''}`}>
|
||||
<aside className="sidebar">
|
||||
@@ -340,6 +347,11 @@ export default function Layout() {
|
||||
<NavLink to="/taxreport" title="Fiscalité">
|
||||
<NavIcon libFilename={navIcons.tax} Fallback={IconFlatTax} /><span className="nav-label">Fiscalité</span>
|
||||
</NavLink>
|
||||
{aiEnabled && (
|
||||
<NavLink to="/assistant-ia" title="Assistant IA">
|
||||
<NavIcon libFilename={navIcons.assistantIa} Fallback={IconAssistantIa} /><span className="nav-label">Assistant IA</span>
|
||||
</NavLink>
|
||||
)}
|
||||
|
||||
</nav>
|
||||
|
||||
@@ -423,6 +435,8 @@ export default function Layout() {
|
||||
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
<AiAssistant />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
/**
|
||||
* aiChatShared.jsx — Logique et rendu du chat de l'assistant IA, partagés
|
||||
* entre la bulle flottante (components/AiAssistant.jsx) et la page dédiée
|
||||
* (pages/AssistantIA.jsx), qui affichent la même conversation en cours
|
||||
* (synchronisée via son id — voir useAiChat) dans deux tailles de fenêtre
|
||||
* différentes.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { api } from '../../api.js';
|
||||
|
||||
let _uid = 0;
|
||||
export const nextId = () => `m${++_uid}`;
|
||||
|
||||
/* ── Icônes réutilisées par le widget et la page ───────────────────────── */
|
||||
export function IconSparkle() {
|
||||
return (
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8L12 3z" />
|
||||
<path d="M19 15l.7 2 2 .7-2 .7-.7 2-.7-2-2-.7 2-.7.7-2z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
export function IconSparkleSmall() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8L12 3z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
export function IconSend() {
|
||||
return (
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
strokeWidth="2.1" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<line x1="22" y1="2" x2="11" y2="13" /><polygon points="22 2 15 22 11 13 2 9 22 2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
export function IconTool() {
|
||||
return (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
export function IconNewChat() {
|
||||
return (
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
<line x1="12" y1="7" x2="12" y2="13" /><line x1="9" y1="10" x2="15" y2="10" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
export function IconExpand() {
|
||||
return (
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<polyline points="15 3 21 3 21 9" /><polyline points="9 21 3 21 3 15" />
|
||||
<line x1="21" y1="3" x2="14" y2="10" /><line x1="3" y1="21" x2="10" y2="14" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
export function IconTrash() {
|
||||
return (
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||||
<path d="M10 11v6" /><path d="M14 11v6" /><path d="M9 6V4h6v2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
export function IconCopy() {
|
||||
return (
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" />
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
export function IconCheck() {
|
||||
return (
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
export function IconRegenerate() {
|
||||
return (
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<polyline points="1 4 1 10 7 10" /><polyline points="23 20 23 14 17 14" />
|
||||
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const TOOL_LABELS = {
|
||||
crowdlending_get_investisseur: 'profil investisseur',
|
||||
crowdlending_get_dashboard: 'synthèse du portefeuille',
|
||||
crowdlending_list_investissements:'liste des investissements',
|
||||
crowdlending_get_investissement: "détail d'un investissement",
|
||||
crowdlending_list_remboursements: 'liste des remboursements',
|
||||
crowdlending_list_frais_operations:'liste des frais',
|
||||
crowdlending_list_depots_retraits:'dépôts / retraits',
|
||||
crowdlending_list_objectifs: 'objectifs',
|
||||
crowdlending_get_taxreport: 'synthèse fiscale',
|
||||
};
|
||||
const toolLabel = (name) => TOOL_LABELS[name] || name;
|
||||
|
||||
/** Horodatage compact pour les messages et la liste des conversations :
|
||||
* heure si aujourd'hui, « Hier » si hier, sinon jj/mm (jj/mm/aaaa si année
|
||||
* différente). Partagé entre le fil de discussion (MessageThread) et la
|
||||
* liste des conversations de la page dédiée. */
|
||||
export function relativeDate(iso) {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso.includes('T') || iso.endsWith('Z') ? iso : iso.replace(' ', 'T') + 'Z');
|
||||
const now = new Date();
|
||||
const sameDay = d.toDateString() === now.toDateString();
|
||||
if (sameDay) return d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
|
||||
const yesterday = new Date(now); yesterday.setDate(now.getDate() - 1);
|
||||
if (d.toDateString() === yesterday.toDateString()) return 'Hier';
|
||||
const opts = d.getFullYear() === now.getFullYear() ? { day: '2-digit', month: '2-digit' } : { day: '2-digit', month: '2-digit', year: 'numeric' };
|
||||
return d.toLocaleDateString('fr-FR', opts);
|
||||
}
|
||||
|
||||
/** Badges des outils consultés pour un message assistant donné. */
|
||||
export function ToolBadges({ tools }) {
|
||||
if (!tools || tools.length === 0) return null;
|
||||
return (
|
||||
<div className="ai-tool-badges">
|
||||
{tools.map((t, i) => (
|
||||
<span key={i} className={`ai-tool-badge ${t.status}`}>
|
||||
<IconTool />
|
||||
{toolLabel(t.name)}
|
||||
{t.status === 'calling' && '…'}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Convertit un message persisté (GET /ai/conversations/:id/messages —
|
||||
* {role, content, tools:string[]}) au format interne du composant
|
||||
* ({id, role, content, tools:[{name,status}], ...}). */
|
||||
function fromPersisted(m) {
|
||||
return {
|
||||
id: nextId(),
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
tools: (m.tools || []).map((name) => ({ name, status: 'done' })),
|
||||
pending: false,
|
||||
error: null,
|
||||
timestamp: m.createdAt || null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* useAiChat — état et logique du fil de discussion courant : chargement
|
||||
* d'une conversation existante, envoi de message en streaming (SSE),
|
||||
* démarrage d'une nouvelle conversation (déconnexion locale seulement —
|
||||
* rien n'est supprimé côté serveur, la nouvelle conversation n'est créée
|
||||
* qu'à l'envoi du premier message, voir routes/ai.js POST /chat).
|
||||
*
|
||||
* onConversationChange(id, titre) est appelé chaque fois que l'id de
|
||||
* conversation change, quelle qu'en soit la raison (sélection explicite,
|
||||
* "Nouvelle conversation", ou création à la volée par le serveur au premier
|
||||
* message) — le widget s'en sert pour retenir l'id (localStorage), la page
|
||||
* dédiée pour synchroniser l'URL. onTurnComplete() est appelé à la fin de
|
||||
* chaque tour (succès ou échec) — la page dédiée s'en sert pour rafraîchir
|
||||
* la liste des conversations (nouvel élément, ou position remontée par
|
||||
* récence d'activité).
|
||||
*/
|
||||
export function useAiChat({ onConversationChange, onTurnComplete } = {}) {
|
||||
const [conversationId, setConversationId] = useState(null);
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [loadingHistory, setLoadingHistory] = useState(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
const updateMessage = useCallback((id, updater) => {
|
||||
setMessages((prev) => prev.map((m) => (m.id === id ? updater(m) : m)));
|
||||
}, []);
|
||||
|
||||
const applyConversationId = useCallback((id, titre) => {
|
||||
setConversationId(id);
|
||||
onConversationChange?.(id, titre ?? null);
|
||||
}, [onConversationChange]);
|
||||
|
||||
/** Charge le fil d'une conversation existante ; id falsy = vide l'affichage
|
||||
* (ne supprime rien côté serveur). */
|
||||
const selectConversation = useCallback(async (id) => {
|
||||
if (!id) {
|
||||
setMessages([]);
|
||||
applyConversationId(null, null);
|
||||
return;
|
||||
}
|
||||
setLoadingHistory(true);
|
||||
try {
|
||||
const r = await api.get(`/ai/conversations/${id}/messages`);
|
||||
setMessages((r.messages || []).map(fromPersisted));
|
||||
applyConversationId(id, r.conversation?.titre);
|
||||
} catch {
|
||||
setMessages([]);
|
||||
applyConversationId(null, null);
|
||||
} finally {
|
||||
setLoadingHistory(false);
|
||||
}
|
||||
}, [applyConversationId]);
|
||||
|
||||
const startNewConversation = useCallback(() => {
|
||||
if (sending) return;
|
||||
setMessages([]);
|
||||
applyConversationId(null, null);
|
||||
}, [sending, applyConversationId]);
|
||||
|
||||
/** Logique d'envoi commune à sendMessage() et regenerateMessage() : ajoute
|
||||
* la paire utilisateur/assistant et consomme le flux SSE de la réponse. */
|
||||
const runTurn = useCallback(async (trimmed) => {
|
||||
const now = new Date().toISOString();
|
||||
const userMsg = { id: nextId(), role: 'user', content: trimmed, timestamp: now };
|
||||
const assistantId = nextId();
|
||||
const assistantMsg = { id: assistantId, role: 'assistant', content: '', tools: [], pending: true, error: null, timestamp: now };
|
||||
setMessages((prev) => [...prev, userMsg, assistantMsg]);
|
||||
setSending(true);
|
||||
|
||||
try {
|
||||
for await (const { data } of api.stream('/ai/chat', { conversationId, message: trimmed })) {
|
||||
if (!data || typeof data !== 'object') continue;
|
||||
|
||||
if (data.type === 'conversation') {
|
||||
if (data.id !== conversationId) applyConversationId(data.id, data.titre);
|
||||
} else if (data.type === 'text_delta') {
|
||||
updateMessage(assistantId, (m) => ({ ...m, content: m.content + data.text }));
|
||||
} else if (data.type === 'tool_call') {
|
||||
updateMessage(assistantId, (m) => ({
|
||||
...m,
|
||||
tools: [...m.tools, { name: data.name, status: 'calling' }],
|
||||
}));
|
||||
} else if (data.type === 'tool_result') {
|
||||
updateMessage(assistantId, (m) => ({
|
||||
...m,
|
||||
tools: m.tools.map((t) =>
|
||||
t.name === data.name && t.status === 'calling'
|
||||
? { ...t, status: data.isError ? 'error' : 'done' }
|
||||
: t
|
||||
),
|
||||
}));
|
||||
} else if (data.type === 'done') {
|
||||
updateMessage(assistantId, (m) => ({ ...m, pending: false }));
|
||||
} else if (data.type === 'error') {
|
||||
updateMessage(assistantId, (m) => ({ ...m, pending: false, error: data.message }));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
updateMessage(assistantId, (m) => ({ ...m, pending: false, error: e.message || 'Erreur de connexion' }));
|
||||
} finally {
|
||||
setSending(false);
|
||||
onTurnComplete?.();
|
||||
}
|
||||
}, [conversationId, updateMessage, applyConversationId, onTurnComplete]);
|
||||
|
||||
const sendMessage = useCallback((text) => {
|
||||
const trimmed = (text || '').trim();
|
||||
if (!trimmed || sending) return;
|
||||
return runTurn(trimmed);
|
||||
}, [sending, runTurn]);
|
||||
|
||||
/** Rejoue une demande : reprend le texte du message utilisateur associé
|
||||
* (celui-ci s'il en est un, sinon celui qui précède immédiatement le
|
||||
* message assistant visé) et l'envoie à nouveau comme un nouveau tour —
|
||||
* l'historique existant n'est ni modifié ni supprimé, la nouvelle réponse
|
||||
* s'ajoute simplement à la suite (cohérent avec le modèle d'historique
|
||||
* "append only" côté serveur). */
|
||||
const regenerateMessage = useCallback((messageId) => {
|
||||
if (sending) return;
|
||||
const idx = messages.findIndex((m) => m.id === messageId);
|
||||
if (idx === -1) return;
|
||||
const target = messages[idx];
|
||||
const userMessage = target.role === 'user' ? target : messages[idx - 1];
|
||||
if (!userMessage || userMessage.role !== 'user') return;
|
||||
runTurn(userMessage.content);
|
||||
}, [sending, messages, runTurn]);
|
||||
|
||||
return {
|
||||
conversationId, messages, sending, loadingHistory,
|
||||
selectConversation, startNewConversation, sendMessage, regenerateMessage,
|
||||
};
|
||||
}
|
||||
|
||||
/** Fil de messages (bulles + badges d'outils + indicateur de saisie),
|
||||
* auto-scrollé vers le bas à chaque nouveau contenu. Utilisé tel quel
|
||||
* dans le panneau flottant et dans la page dédiée (seule la taille du
|
||||
* conteneur parent diffère, via CSS). */
|
||||
export function MessageThread({ messages, emptyHint, sending, onRegenerate }) {
|
||||
const threadRef = useRef(null);
|
||||
const [copiedId, setCopiedId] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (threadRef.current) threadRef.current.scrollTop = threadRef.current.scrollHeight;
|
||||
}, [messages]);
|
||||
|
||||
const handleCopy = useCallback(async (m) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(m.content);
|
||||
} catch {
|
||||
// Repli si l'API Clipboard est indisponible (contexte non sécurisé, etc.).
|
||||
try {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = m.content;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.opacity = '0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
setCopiedId(m.id);
|
||||
setTimeout(() => setCopiedId((cur) => (cur === m.id ? null : cur)), 1500);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="ai-thread" ref={threadRef}>
|
||||
{messages.length === 0 && <div className="ai-empty">{emptyHint}</div>}
|
||||
{messages.map((m) => {
|
||||
const anyToolCalling = (m.tools || []).some((t) => t.status === 'calling');
|
||||
// "Réfléchit" (points animés) tant qu'il n'y a ni texte ni outil en
|
||||
// cours d'appel (le badge d'outil affiche déjà son propre "…") ;
|
||||
// curseur clignotant en fin de texte une fois la réponse démarrée —
|
||||
// pour qu'il y ait toujours un signe visible que ça travaille encore.
|
||||
const showThinking = m.pending && m.content === '' && !anyToolCalling;
|
||||
const showCursor = m.pending && m.content !== '';
|
||||
const canAct = !m.pending && !m.error && !!m.content;
|
||||
return (
|
||||
<div key={m.id} className={`ai-message from-${m.role === 'assistant' ? 'ai' : 'user'}`}>
|
||||
<div className="ai-message-avatar">
|
||||
{m.role === 'assistant' ? <IconSparkleSmall /> : 'Moi'}
|
||||
</div>
|
||||
<div className="ai-message-content">
|
||||
<ToolBadges tools={m.tools} />
|
||||
{m.content}
|
||||
{showCursor && <span className="ai-cursor" aria-hidden="true" />}
|
||||
{showThinking && (
|
||||
<span className="ai-typing" aria-label="L'assistant réfléchit">
|
||||
<span /><span /><span />
|
||||
</span>
|
||||
)}
|
||||
{m.error && <div className="ai-message-error">{m.error}</div>}
|
||||
<div className="ai-message-meta">
|
||||
{m.timestamp && <span className="ai-message-time">{relativeDate(m.timestamp)}</span>}
|
||||
{canAct && (
|
||||
<button
|
||||
type="button"
|
||||
className="ai-message-action"
|
||||
onClick={() => handleCopy(m)}
|
||||
aria-label="Copier le message"
|
||||
title="Copier"
|
||||
>
|
||||
{copiedId === m.id ? <IconCheck /> : <IconCopy />}
|
||||
</button>
|
||||
)}
|
||||
{canAct && m.role === 'assistant' && onRegenerate && (
|
||||
<button
|
||||
type="button"
|
||||
className="ai-message-action"
|
||||
onClick={() => onRegenerate(m.id)}
|
||||
disabled={sending}
|
||||
aria-label="Rejouer cette demande"
|
||||
title="Rejouer cette demande"
|
||||
>
|
||||
<IconRegenerate />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Zone de saisie (textarea + bouton d'envoi), Entrée pour envoyer /
|
||||
* Maj+Entrée pour un saut de ligne. */
|
||||
export function Composer({ value, onChange, onSend, disabled, autoFocusKey }) {
|
||||
const textareaRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFocusKey !== undefined) setTimeout(() => textareaRef.current?.focus(), 50);
|
||||
}, [autoFocusKey]);
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
onSend();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ai-input-form">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="ai-textarea"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Votre question…"
|
||||
rows={1}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<button
|
||||
className="ai-send-btn"
|
||||
onClick={onSend}
|
||||
disabled={disabled || !value.trim()}
|
||||
aria-label="Envoyer"
|
||||
title="Envoyer (Entrée)"
|
||||
>
|
||||
<IconSend />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user