You've already forked agentic-coding-workflow
feat(web): chat pinia store + useChatStream (fetch+ReadableStream SSE)
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user