From 811223ddf788a9ead7c0549fed247161637472fd Mon Sep 17 00:00:00 2001 From: Mikkel Georgsen Date: Fri, 10 Apr 2026 07:39:50 +0000 Subject: [PATCH] feat(06-03): add advisor API wrappers (streamChat, fetchConversations, fetchConversation) - Types: ChatMessage, ConversationSummary, Conversation - streamChat: POST /api/advisor/chat with ReadableStream SSE parsing - fetchConversations: GET with 404-tolerant empty array fallback - fetchConversation: GET by id --- web/src/api/advisor.ts | 117 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 web/src/api/advisor.ts diff --git a/web/src/api/advisor.ts b/web/src/api/advisor.ts new file mode 100644 index 0000000..fa34657 --- /dev/null +++ b/web/src/api/advisor.ts @@ -0,0 +1,117 @@ +// Typed API wrappers for the HWLab advisor endpoints + +export interface ChatMessage { + id: string + role: 'user' | 'assistant' | 'system' + content: string + created_at: string +} + +export interface ConversationSummary { + id: string + started_at: string + model: string + message_count: number +} + +export interface Conversation { + id: string + started_at: string + model: string + messages: ChatMessage[] +} + +export async function fetchConversations(): Promise { + const res = await fetch('/api/advisor/conversations') + if (res.status === 404) return [] + if (!res.ok) { + const body = await res.json().catch(() => ({ error: res.statusText })) + throw new Error((body as { error?: string }).error ?? `HTTP ${res.status}`) + } + return res.json() as Promise +} + +export async function fetchConversation(id: string): Promise { + const res = await fetch(`/api/advisor/conversations/${id}`) + if (!res.ok) { + const body = await res.json().catch(() => ({ error: res.statusText })) + throw new Error((body as { error?: string }).error ?? `HTTP ${res.status}`) + } + return res.json() as Promise +} + +export async function streamChat( + params: { conversationId?: string; message: string; model: string }, + onToken: (token: string, conversationId: string) => void, + onDone: () => void, + onError: (err: string) => void, +): Promise { + let res: Response + try { + res = await fetch('/api/advisor/chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + conversation_id: params.conversationId, + message: params.message, + model: params.model, + }), + }) + } catch (e) { + onError((e as Error).message) + return + } + + if (!res.ok) { + const body = await res.json().catch(() => ({ error: res.statusText })) + onError((body as { error?: string }).error ?? `HTTP ${res.status}`) + return + } + + if (!res.body) { + onError('No response body from server') + return + } + + const reader = res.body.getReader() + const decoder = new TextDecoder() + let buffer = '' + + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + + buffer += decoder.decode(value, { stream: true }) + + // Split on double newline (SSE event delimiter) + const events = buffer.split('\n\n') + // Keep any incomplete trailing fragment in buffer + buffer = events.pop() ?? '' + + for (const event of events) { + const dataLine = event + .split('\n') + .find((line) => line.startsWith('data: ')) + if (!dataLine) continue + + const data = dataLine.slice('data: '.length).trim() + + if (data === '[DONE]') { + onDone() + return + } + + try { + const parsed = JSON.parse(data) as { token: string; conversation_id: string } + onToken(parsed.token, parsed.conversation_id) + } catch { + onError(`Failed to parse SSE event: ${data}`) + return + } + } + } + } catch (e) { + onError((e as Error).message) + } +}