SPB Git forge

spb/uqo-chat

Public
14commits 1branches 0releases
1.4 MBsize
maindefault branch
17 days agolast push
Python 64.6% TypeScript 33.7% CSS 0.8%
8.7 KB · 214 lines typescript
Raw Blame History
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