Python 64.6%
TypeScript 33.7%
CSS 0.8%
1/** Chat state: messages per conversation, streaming via SSE, tool timeline. */2import { create } from 'zustand';3import { fetchEventSource } from '@microsoft/fetch-event-source';4import { API_URL, api, authHeaders } from '@/lib/api';5import type { Artifact, Conversation, Message, ToolCallView } from '@/lib/types';67interface ChatState {8 conversations: Conversation[];9 messages: Record<string, Message[]>;10 streaming: Record<string, boolean>;11 warnings: Record<string, string | undefined>;12 loadConversations: (q?: string) => Promise<void>;13 loadMessages: (convId: string) => Promise<void>;14 createConversation: (course: string) => Promise<Conversation>;15 updateConversation: (id: string, patch: Partial<Pick<Conversation, 'title' | 'pinned' | 'archived'>>) => Promise<void>;16 deleteConversation: (id: string) => Promise<void>;17 send: (convId: string, content: string, attachments: string[], deep: boolean) => Promise<void>;18 regenerate: (convId: string, messageId: string, deep: boolean) => Promise<void>;19 stop: (convId: string) => Promise<void>;20 feedback: (convId: string, messageId: string, fb: 'up' | 'down' | null) => Promise<void>;21}2223const controllers = new Map<string, AbortController>();2425function patchLast(list: Message[], fn: (m: Message) => Message): Message[] {26 if (!list.length) return list;27 const copy = list.slice();28 copy[copy.length - 1] = fn(copy[copy.length - 1]);29 return copy;30}3132export const useChat = create<ChatState>((set, get) => ({33 conversations: [],34 messages: {},35 streaming: {},36 warnings: {},3738 loadConversations: async (q) => {39 const list = await api<Conversation[]>(`/conversations${q ? `?q=${encodeURIComponent(q)}` : ''}`);40 set({ conversations: list });41 },4243 loadMessages: async (convId) => {44 const data = await api<Conversation & { messages: Message[] }>(`/conversations/${convId}`);45 const msgs = data.messages.map((m) => ({46 ...m,47 tool_calls: (m.tool_calls || []).map((t) => ({ ...t, status: (t.status as ToolCallView['status']) || 'ok' })),48 }));49 set((s) => ({ messages: { ...s.messages, [convId]: msgs } }));50 },5152 createConversation: async (course) => {53 const c = await api<Conversation>('/conversations', { method: 'POST', body: JSON.stringify({ course }) });54 set((s) => ({ conversations: [c, ...s.conversations], messages: { ...s.messages, [c.id]: [] } }));55 return c;56 },5758 updateConversation: async (id, patch) => {59 const c = await api<Conversation>(`/conversations/${id}`, { method: 'PATCH', body: JSON.stringify(patch) });60 set((s) => ({ conversations: s.conversations.map((x) => (x.id === id ? c : x)) }));61 },6263 deleteConversation: async (id) => {64 await api(`/conversations/${id}`, { method: 'DELETE' });65 set((s) => {66 const messages = { ...s.messages };67 delete messages[id];68 return { conversations: s.conversations.filter((x) => x.id !== id), messages };69 });70 },7172 send: async (convId, content, attachments, deep) => {73 const userMsg: Message = { id: `tmp-${Date.now()}`, role: 'user', content, attachments, created_at: new Date().toISOString() };74 const assistant: Message = { id: `pending-${Date.now()}`, role: 'assistant', content: '', created_at: new Date().toISOString(), tool_calls: [], streaming: true };75 set((s) => ({76 messages: { ...s.messages, [convId]: [...(s.messages[convId] || []), userMsg, assistant] },77 streaming: { ...s.streaming, [convId]: true },78 warnings: { ...s.warnings, [convId]: undefined },79 }));80 await stream(convId, `${API_URL}/chat/${convId}/messages`, { content, attachments, deep }, set, get);81 },8283 regenerate: async (convId, messageId, deep) => {84 set((s) => {85 const list = s.messages[convId] || [];86 const idx = list.findIndex((m) => m.id === messageId);87 const kept = idx >= 0 ? list.slice(0, idx) : list;88 const assistant: Message = { id: `pending-${Date.now()}`, role: 'assistant', content: '', created_at: new Date().toISOString(), tool_calls: [], streaming: true };89 return { messages: { ...s.messages, [convId]: [...kept, assistant] }, streaming: { ...s.streaming, [convId]: true } };90 });91 await stream(convId, `${API_URL}/chat/${convId}/messages/${messageId}/regenerate`, { deep }, set, get);92 },9394 stop: async (convId) => {95 controllers.get(convId)?.abort();96 try {97 await api(`/chat/${convId}/stop`, { method: 'POST' });98 } catch {99 /* ignore */100 }101 set((s) => ({102 streaming: { ...s.streaming, [convId]: false },103 messages: { ...s.messages, [convId]: patchLast(s.messages[convId] || [], (m) => ({ ...m, streaming: false })) },104 }));105 },106107 feedback: async (convId, messageId, fb) => {108 await api(`/conversations/${convId}/messages/${messageId}/feedback`, { method: 'POST', body: JSON.stringify({ feedback: fb }) });109 set((s) => ({110 messages: { ...s.messages, [convId]: (s.messages[convId] || []).map((m) => (m.id === messageId ? { ...m, feedback: fb } : m)) },111 }));112 },113}));114115type Set = (fn: (s: ChatState) => Partial<ChatState>) => void;116type Get = () => ChatState;117118async function stream(convId: string, url: string, body: unknown, set: Set, get: Get): Promise<void> {119 const ctrl = new AbortController();120 controllers.set(convId, ctrl);121 const update = (fn: (m: Message) => Message) =>122 set((s) => ({ messages: { ...s.messages, [convId]: patchLast(s.messages[convId] || [], fn) } }));123 const updateTool = (id: string, fn: (t: ToolCallView) => ToolCallView) =>124 update((m) => ({ ...m, tool_calls: (m.tool_calls || []).map((t) => (t.id === id ? fn(t) : t)) }));125126 try {127 await fetchEventSource(url, {128 method: 'POST',129 headers: { 'Content-Type': 'application/json', ...authHeaders() },130 body: JSON.stringify(body),131 credentials: 'include',132 signal: ctrl.signal,133 openWhenHidden: true,134 async onopen(res) {135 if (!res.ok) {136 let detail = 'Impossible de contacter le tuteur.';137 try {138 detail = (await res.json()).detail || detail;139 } catch {140 /* ignore */141 }142 throw new Error(detail);143 }144 },145 onmessage(ev) {146 if (!ev.event) return;147 const d = JSON.parse(ev.data || '{}');148 switch (ev.event) {149 case 'message_start':150 update((m) => ({ ...m, id: d.message_id, model: d.model }));151 break;152 case 'text_delta':153 update((m) => ({ ...m, content: m.content + d.delta }));154 break;155 case 'tool_call':156 update((m) => ({157 ...m,158 tool_calls: [159 ...(m.tool_calls || []),160 { id: d.id, name: d.name, arguments: d.arguments || {}, args_preview: d.args_preview, summary: '', payload: {}, status: 'running', duration_ms: 0 },161 ],162 }));163 break;164 case 'tool_progress':165 updateTool(d.id, (t) => ({ ...t, progress: d.detail }));166 break;167 case 'tool_result':168 updateTool(d.id, (t) => ({169 ...t,170 summary: d.summary,171 payload: d.payload || {},172 artifacts: (d.artifacts || []) as Artifact[],173 status: d.status === 'error' ? 'error' : 'ok',174 duration_ms: d.duration_ms || 0,175 progress: undefined,176 }));177 break;178 case 'warning':179 set((s) => ({ warnings: { ...s.warnings, [convId]: d.message_fr } }));180 break;181 case 'error':182 update((m) => ({ ...m, error: d.message_fr }));183 break;184 case 'usage':185 update((m) => ({ ...m, cost_usd: d.cost_usd, model: d.model_used || m.model, latency_ms: d.latency_ms }));186 break;187 case 'title':188 set((s) => ({ conversations: s.conversations.map((c) => (c.id === d.conversation_id ? { ...c, title: d.title } : c)) }));189 break;190 case 'done':191 update((m) => ({ ...m, id: d.message_id || m.id, streaming: false }));192 set((s) => ({ streaming: { ...s.streaming, [convId]: false } }));193 break;194 }195 },196 onclose() {197 set((s) => ({ streaming: { ...s.streaming, [convId]: false } }));198 update((m) => ({ ...m, streaming: false }));199 },200 onerror(err) {201 throw err; // no automatic retry: a retry would re-send the message202 },203 });204 } catch (err) {205 const msg = err instanceof Error ? err.message : 'Connexion interrompue.';206 if (!ctrl.signal.aborted) update((m) => ({ ...m, streaming: false, error: m.error || msg }));207 set((s) => ({ streaming: { ...s.streaming, [convId]: false } }));208 } finally {209 controllers.delete(convId);210 // refresh conversation list ordering/title quietly211 get().loadConversations().catch(() => undefined);212 }213}214