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

+157
View File
@@ -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}
</>
);
}
+14
View File
@@ -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>
);
}
+429
View File
@@ -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>
);
}