feat(web): chat pinia store + useChatStream (fetch+ReadableStream SSE)

This commit is contained in:
2026-05-04 20:02:58 +08:00
parent 00daad07a9
commit d06ff1bf8c
2 changed files with 319 additions and 0 deletions
+107
View File
@@ -0,0 +1,107 @@
import { ref } from 'vue'
import { useAuthStore } from '@/stores/auth'
export type ChatStreamEventType =
| 'message_meta'
| 'text_delta'
| 'thinking_delta'
| 'usage'
| 'done'
| 'error'
export interface ChatStreamEvent {
id: number
event: ChatStreamEventType
data: unknown
}
export interface ChatStreamHandlers {
onEvent: (e: ChatStreamEvent) => void
onClose?: () => void
onError?: (err: Error) => void
}
export interface ChatStreamHandle {
stop: () => void
}
/**
* useChatStream 通过 fetch + ReadableStream 自实现 SSE 解析,
* 以支持 Authorization Bearer 头(EventSource 不支持自定义 header)。
* start() 立即返回 stop 句柄,读流在后台 fire-and-forget。
*/
export function useChatStream() {
const auth = useAuthStore()
const reading = ref(false)
function start(
streamUrl: string,
since: number,
h: ChatStreamHandlers,
): ChatStreamHandle {
const url = new URL(streamUrl, window.location.origin)
url.searchParams.set('since', String(since))
const ctrl = new AbortController()
reading.value = true
void (async () => {
try {
const headers: Record<string, string> = { Accept: 'text/event-stream' }
if (auth.token) headers['Authorization'] = `Bearer ${auth.token}`
const resp = await fetch(url.toString(), {
headers,
signal: ctrl.signal,
})
if (!resp.ok || !resp.body) {
throw new Error(`SSE HTTP ${resp.status}`)
}
const reader = resp.body.getReader()
const decoder = new TextDecoder()
let buf = ''
for (;;) {
const { value, done } = await reader.read()
if (done) break
buf += decoder.decode(value, { stream: true })
let idx = buf.indexOf('\n\n')
while (idx >= 0) {
const raw = buf.slice(0, idx)
buf = buf.slice(idx + 2)
const ev = parseSSE(raw)
if (ev) h.onEvent(ev)
idx = buf.indexOf('\n\n')
}
}
h.onClose?.()
} catch (err) {
const e = err as { name?: string }
if (e?.name === 'AbortError') return
h.onError?.(err instanceof Error ? err : new Error(String(err)))
} finally {
reading.value = false
}
})()
return { stop: () => ctrl.abort() }
}
return { start, reading }
}
function parseSSE(raw: string): ChatStreamEvent | null {
let id = 0
let event = ''
const dataLines: string[] = []
for (const line of raw.split('\n')) {
if (line.startsWith('id:')) id = Number(line.slice(3).trim())
else if (line.startsWith('event:')) event = line.slice(6).trim()
else if (line.startsWith('data:')) dataLines.push(line.slice(5).trim())
}
if (!event) return null
let data: unknown
try {
data = JSON.parse(dataLines.join('\n'))
} catch {
data = dataLines.join('\n')
}
return { id, event: event as ChatStreamEventType, data }
}