You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
206 lines
7.7 KiB
206 lines
7.7 KiB
/**
|
|
* SSE 流式读取工具 —— 从 utils.js 原封不动搬移
|
|
*
|
|
* 统一处理 Flux<String> / ServerSentEvent / SseEmitter 三种 SSE 接口。
|
|
* 此文件使用原生 fetch + ReadableStream API,零框架依赖。
|
|
*/
|
|
import type { SSECallbacks } from '@/types/sse'
|
|
import { getToken } from '@/utils/token'
|
|
|
|
/** 构建带认证的请求头 */
|
|
function authHeaders(extra?: Record<string, string>): Record<string, string> {
|
|
const token = getToken()
|
|
const base: Record<string, string> = extra ? { ...extra } : {}
|
|
// 仅当调用方未显式提供 Authorization 时才注入管理后台 token,避免覆盖调用方传入的鉴权头(如测试面板的 SDK Token)
|
|
if (token && !base['Authorization']) base['Authorization'] = `Bearer ${token}`
|
|
return base
|
|
}
|
|
|
|
/** 尝试从 OpenAI Chat Completions chunk 中提取 delta.content;返回 undefined 表示非 OpenAI 格式(回退纯文本) */
|
|
function extractOpenAIDelta(text: string): string | undefined {
|
|
let obj: any
|
|
try { obj = JSON.parse(text) } catch { return undefined }
|
|
// OpenAI 错误 chunk({"error":{"message":...}}):提取错误信息作为内容,避免原始 JSON 泄漏到对话框
|
|
if (obj && obj.error) {
|
|
const msg = obj.error.message || obj.error.type
|
|
return typeof msg === 'string' && msg ? msg : '服务异常'
|
|
}
|
|
if (obj && Array.isArray(obj.choices)) {
|
|
const delta = obj.choices[0]?.delta
|
|
if (delta && typeof delta.content === 'string') return delta.content
|
|
return '' // OpenAI 形状但无 content(role/finish_reason 空 chunk)→ 跳过
|
|
}
|
|
return undefined // 是 JSON 但非 OpenAI 形状 → 回退纯文本
|
|
}
|
|
|
|
/**
|
|
* 通用 SSE 流式读取 —— 统一处理 Flux<String> / ServerSentEvent / SseEmitter 三种 SSE 接口
|
|
*
|
|
* @param url 请求地址
|
|
* @param onChunk 每收到一段文本的回调
|
|
* @param onDone 流结束的回调
|
|
* @param headers 额外请求头
|
|
* @param signal AbortSignal 用于取消请求(组件卸载时必须传入以释放网络资源)
|
|
*/
|
|
export async function readSSEStream(
|
|
url: string,
|
|
onChunk: (text: string) => void,
|
|
onDone?: () => void,
|
|
headers?: Record<string, string>,
|
|
signal?: AbortSignal
|
|
): Promise<void> {
|
|
const res = await fetch(url, { headers: authHeaders(headers), signal, credentials: 'include' })
|
|
if (!res.ok) throw new Error('HTTP ' + res.status)
|
|
const reader = res.body!.getReader()
|
|
const decoder = new TextDecoder()
|
|
let buffer = ''
|
|
// SSE 规范:同一事件内的多行 data: 字段用 \n 拼接,事件间用空行分隔
|
|
let eventDataLines: string[] = []
|
|
let currentEvent = 'message'
|
|
|
|
const flushEvent = () => {
|
|
if (eventDataLines.length === 0) return
|
|
const text = eventDataLines.join('\n')
|
|
eventDataLines = []
|
|
const ev = currentEvent
|
|
currentEvent = 'message'
|
|
if (text === '[DONE]') return
|
|
// 跳过 status / faq 等系统事件,不显示在对话框中
|
|
if (ev === 'status') return
|
|
// OpenAI Chat Completions 格式:提取 delta.content;无 content 的空 chunk 跳过
|
|
const extracted = extractOpenAIDelta(text)
|
|
if (extracted !== undefined) {
|
|
if (extracted) onChunk(extracted)
|
|
return
|
|
}
|
|
// 空事件视为 LLM 流式输出的换行符(Spring 将 "\n" 编码为单条空 data: 事件)
|
|
onChunk(text || '\n')
|
|
}
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read()
|
|
if (done) break
|
|
buffer += decoder.decode(value, { stream: true })
|
|
const lines = buffer.split('\n')
|
|
buffer = lines.pop() || ''
|
|
for (let line of lines) {
|
|
// 兼容 \r\n 行结束符
|
|
if (line.endsWith('\r')) line = line.slice(0, -1)
|
|
if (line === '') {
|
|
// 空行 = SSE 事件边界
|
|
flushEvent()
|
|
} else if (line.startsWith('event:')) {
|
|
// 记录事件类型,用于 flushEvent 时过滤系统事件
|
|
currentEvent = line.slice(6).trim()
|
|
} else if (line.startsWith('data:')) {
|
|
// 累积 data 字段;仅剥离 data: 后的一个可选空格,保留 markdown 列表缩进
|
|
let data = line.slice(5)
|
|
if (data.startsWith(' ')) data = data.slice(1)
|
|
eventDataLines.push(data)
|
|
} else if (!line.startsWith(':')) {
|
|
// Flux<String> 模式(非标准 SSE),先把已累积的 SSE 事件 flush 再处理
|
|
flushEvent()
|
|
if (line.trim()) onChunk(line)
|
|
}
|
|
}
|
|
}
|
|
// 流结束,flush 末尾未以空行收尾的事件
|
|
flushEvent()
|
|
if (onDone) onDone()
|
|
}
|
|
|
|
/**
|
|
* 增强版 SSE 流式读取(支持事件类型分发)
|
|
* 解析 SSE 标准的 event: 字段,将不同类型事件分发到对应回调。
|
|
*
|
|
* @param url 请求地址
|
|
* @param handlers 回调对象:
|
|
* - onMessage(chunk): 普通文本内容(event: message 或无 event 的 data)
|
|
* - onToolCallStart(data): 工具调用开始(event: tool_call_start)
|
|
* - onToolCallResult(data): 工具调用结果(event: tool_call_result)
|
|
* - onError(data): 错误事件(event: error)
|
|
* - onDone(): 流结束
|
|
* @param headers 额外请求头
|
|
* @param signal AbortSignal 用于取消请求(组件卸载时必须传入以释放网络资源)
|
|
*/
|
|
export async function readSSEStreamWithEvents(
|
|
url: string,
|
|
handlers: SSECallbacks,
|
|
headers?: Record<string, string>,
|
|
signal?: AbortSignal
|
|
): Promise<void> {
|
|
const { onMessage, onToolCallStart, onToolCallResult, onError, onDone } = handlers
|
|
const res = await fetch(url, { headers: authHeaders(headers), signal, credentials: 'include' })
|
|
if (!res.ok) throw new Error('HTTP ' + res.status)
|
|
const reader = res.body!.getReader()
|
|
const decoder = new TextDecoder()
|
|
let buffer = ''
|
|
let currentEvent = 'message'
|
|
// SSE 规范:同一事件内的多行 data: 字段用 \n 拼接
|
|
let eventDataLines: string[] = []
|
|
|
|
const flushEvent = () => {
|
|
if (eventDataLines.length === 0) return
|
|
const ev = currentEvent
|
|
currentEvent = 'message'
|
|
const raw = eventDataLines.join('\n')
|
|
eventDataLines = []
|
|
if (raw === '[DONE]') return
|
|
// 空事件视为换行符(仅 message 事件);JSON 事件(工具调用等)空内容不应出现
|
|
const text = raw || '\n'
|
|
|
|
switch (ev) {
|
|
case 'tool_call_start':
|
|
if (onToolCallStart) onToolCallStart(JSON.parse(text))
|
|
break
|
|
case 'tool_call_result':
|
|
if (onToolCallResult) onToolCallResult(JSON.parse(text))
|
|
break
|
|
case 'error':
|
|
if (onError) onError(JSON.parse(text))
|
|
break
|
|
case 'status':
|
|
// 系统状态事件(generating / faq_hit 等),不显示在对话框中
|
|
break
|
|
case 'message':
|
|
default: {
|
|
// OpenAI Chat Completions 格式:提取 delta.content;无 content 的空 chunk 跳过
|
|
const extracted = extractOpenAIDelta(text)
|
|
if (extracted !== undefined) {
|
|
if (extracted && onMessage) onMessage(extracted)
|
|
} else if (onMessage) {
|
|
onMessage(text)
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read()
|
|
if (done) break
|
|
buffer += decoder.decode(value, { stream: true })
|
|
const lines = buffer.split('\n')
|
|
buffer = lines.pop() || ''
|
|
|
|
for (let line of lines) {
|
|
if (line.endsWith('\r')) line = line.slice(0, -1)
|
|
if (line === '') {
|
|
// 空行 = SSE 事件边界
|
|
flushEvent()
|
|
} else if (line.startsWith('event:')) {
|
|
currentEvent = line.slice(6).trim()
|
|
} else if (line.startsWith('data:')) {
|
|
let data = line.slice(5)
|
|
if (data.startsWith(' ')) data = data.slice(1)
|
|
eventDataLines.push(data)
|
|
} else if (!line.startsWith(':')) {
|
|
// Flux<String> 模式(非标准 SSE)
|
|
flushEvent()
|
|
if (line.trim() && onMessage) onMessage(line)
|
|
}
|
|
}
|
|
}
|
|
flushEvent()
|
|
if (onDone) onDone()
|
|
}
|