- 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
117 lines
3.1 KiB
TypeScript
117 lines
3.1 KiB
TypeScript
// 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)
|
|
}
|
|
}
|