/** Chat state: messages per conversation, streaming via SSE, tool timeline. */ import { create } from 'zustand'; import { fetchEventSource } from '@microsoft/fetch-event-source'; import { API_URL, api, authHeaders } from '@/lib/api'; import type { Artifact, Conversation, Message, ToolCallView } from '@/lib/types'; interface ChatState { conversations: Conversation[]; messages: Record; streaming: Record; warnings: Record; loadConversations: (q?: string) => Promise; loadMessages: (convId: string) => Promise; createConversation: (course: string) => Promise; updateConversation: (id: string, patch: Partial>) => Promise; deleteConversation: (id: string) => Promise; send: (convId: string, content: string, attachments: string[], deep: boolean) => Promise; regenerate: (convId: string, messageId: string, deep: boolean) => Promise; stop: (convId: string) => Promise; feedback: (convId: string, messageId: string, fb: 'up' | 'down' | null) => Promise; } const controllers = new Map(); function patchLast(list: Message[], fn: (m: Message) => Message): Message[] { if (!list.length) return list; const copy = list.slice(); copy[copy.length - 1] = fn(copy[copy.length - 1]); return copy; } export const useChat = create((set, get) => ({ conversations: [], messages: {}, streaming: {}, warnings: {}, loadConversations: async (q) => { const list = await api(`/conversations${q ? `?q=${encodeURIComponent(q)}` : ''}`); set({ conversations: list }); }, loadMessages: async (convId) => { const data = await api(`/conversations/${convId}`); const msgs = data.messages.map((m) => ({ ...m, tool_calls: (m.tool_calls || []).map((t) => ({ ...t, status: (t.status as ToolCallView['status']) || 'ok' })), })); set((s) => ({ messages: { ...s.messages, [convId]: msgs } })); }, createConversation: async (course) => { const c = await api('/conversations', { method: 'POST', body: JSON.stringify({ course }) }); set((s) => ({ conversations: [c, ...s.conversations], messages: { ...s.messages, [c.id]: [] } })); return c; }, updateConversation: async (id, patch) => { const c = await api(`/conversations/${id}`, { method: 'PATCH', body: JSON.stringify(patch) }); set((s) => ({ conversations: s.conversations.map((x) => (x.id === id ? c : x)) })); }, deleteConversation: async (id) => { await api(`/conversations/${id}`, { method: 'DELETE' }); set((s) => { const messages = { ...s.messages }; delete messages[id]; return { conversations: s.conversations.filter((x) => x.id !== id), messages }; }); }, send: async (convId, content, attachments, deep) => { const userMsg: Message = { id: `tmp-${Date.now()}`, role: 'user', content, attachments, created_at: new Date().toISOString() }; const assistant: Message = { id: `pending-${Date.now()}`, role: 'assistant', content: '', created_at: new Date().toISOString(), tool_calls: [], streaming: true }; set((s) => ({ messages: { ...s.messages, [convId]: [...(s.messages[convId] || []), userMsg, assistant] }, streaming: { ...s.streaming, [convId]: true }, warnings: { ...s.warnings, [convId]: undefined }, })); await stream(convId, `${API_URL}/chat/${convId}/messages`, { content, attachments, deep }, set, get); }, regenerate: async (convId, messageId, deep) => { set((s) => { const list = s.messages[convId] || []; const idx = list.findIndex((m) => m.id === messageId); const kept = idx >= 0 ? list.slice(0, idx) : list; const assistant: Message = { id: `pending-${Date.now()}`, role: 'assistant', content: '', created_at: new Date().toISOString(), tool_calls: [], streaming: true }; return { messages: { ...s.messages, [convId]: [...kept, assistant] }, streaming: { ...s.streaming, [convId]: true } }; }); await stream(convId, `${API_URL}/chat/${convId}/messages/${messageId}/regenerate`, { deep }, set, get); }, stop: async (convId) => { controllers.get(convId)?.abort(); try { await api(`/chat/${convId}/stop`, { method: 'POST' }); } catch { /* ignore */ } set((s) => ({ streaming: { ...s.streaming, [convId]: false }, messages: { ...s.messages, [convId]: patchLast(s.messages[convId] || [], (m) => ({ ...m, streaming: false })) }, })); }, feedback: async (convId, messageId, fb) => { await api(`/conversations/${convId}/messages/${messageId}/feedback`, { method: 'POST', body: JSON.stringify({ feedback: fb }) }); set((s) => ({ messages: { ...s.messages, [convId]: (s.messages[convId] || []).map((m) => (m.id === messageId ? { ...m, feedback: fb } : m)) }, })); }, })); type Set = (fn: (s: ChatState) => Partial) => void; type Get = () => ChatState; async function stream(convId: string, url: string, body: unknown, set: Set, get: Get): Promise { const ctrl = new AbortController(); controllers.set(convId, ctrl); const update = (fn: (m: Message) => Message) => set((s) => ({ messages: { ...s.messages, [convId]: patchLast(s.messages[convId] || [], fn) } })); const updateTool = (id: string, fn: (t: ToolCallView) => ToolCallView) => update((m) => ({ ...m, tool_calls: (m.tool_calls || []).map((t) => (t.id === id ? fn(t) : t)) })); try { await fetchEventSource(url, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders() }, body: JSON.stringify(body), credentials: 'include', signal: ctrl.signal, openWhenHidden: true, async onopen(res) { if (!res.ok) { let detail = 'Impossible de contacter le tuteur.'; try { detail = (await res.json()).detail || detail; } catch { /* ignore */ } throw new Error(detail); } }, onmessage(ev) { if (!ev.event) return; const d = JSON.parse(ev.data || '{}'); switch (ev.event) { case 'message_start': update((m) => ({ ...m, id: d.message_id, model: d.model })); break; case 'text_delta': update((m) => ({ ...m, content: m.content + d.delta })); break; case 'tool_call': update((m) => ({ ...m, tool_calls: [ ...(m.tool_calls || []), { id: d.id, name: d.name, arguments: d.arguments || {}, args_preview: d.args_preview, summary: '', payload: {}, status: 'running', duration_ms: 0 }, ], })); break; case 'tool_progress': updateTool(d.id, (t) => ({ ...t, progress: d.detail })); break; case 'tool_result': updateTool(d.id, (t) => ({ ...t, summary: d.summary, payload: d.payload || {}, artifacts: (d.artifacts || []) as Artifact[], status: d.status === 'error' ? 'error' : 'ok', duration_ms: d.duration_ms || 0, progress: undefined, })); break; case 'warning': set((s) => ({ warnings: { ...s.warnings, [convId]: d.message_fr } })); break; case 'error': update((m) => ({ ...m, error: d.message_fr })); break; case 'usage': update((m) => ({ ...m, cost_usd: d.cost_usd, model: d.model_used || m.model, latency_ms: d.latency_ms })); break; case 'title': set((s) => ({ conversations: s.conversations.map((c) => (c.id === d.conversation_id ? { ...c, title: d.title } : c)) })); break; case 'done': update((m) => ({ ...m, id: d.message_id || m.id, streaming: false })); set((s) => ({ streaming: { ...s.streaming, [convId]: false } })); break; } }, onclose() { set((s) => ({ streaming: { ...s.streaming, [convId]: false } })); update((m) => ({ ...m, streaming: false })); }, onerror(err) { throw err; // no automatic retry: a retry would re-send the message }, }); } catch (err) { const msg = err instanceof Error ? err.message : 'Connexion interrompue.'; if (!ctrl.signal.aborted) update((m) => ({ ...m, streaming: false, error: m.error || msg })); set((s) => ({ streaming: { ...s.streaming, [convId]: false } })); } finally { controllers.delete(convId); // refresh conversation list ordering/title quietly get().loadConversations().catch(() => undefined); } }