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
This commit is contained in:
Mikkel Georgsen 2026-04-10 07:39:50 +00:00
parent ae34bc73fe
commit 811223ddf7

117
web/src/api/advisor.ts Normal file
View file

@ -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<ConversationSummary[]> {
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<ConversationSummary[]>
}
export async function fetchConversation(id: string): Promise<Conversation> {
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<Conversation>
}
export async function streamChat(
params: { conversationId?: string; message: string; model: string },
onToken: (token: string, conversationId: string) => void,
onDone: () => void,
onError: (err: string) => void,
): Promise<void> {
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)
}
}