9 changed files with 460 additions and 355 deletions
-
28frontend/src/api/chat.ts
-
15frontend/src/components/MessageSources.vue
-
137frontend/src/sdk-test/SdkTestPanel.vue
-
18frontend/src/types/sse.ts
-
3frontend/src/utils/chatAdapter.ts
-
273frontend/src/utils/sse.ts
-
105frontend/src/views/ChatPanel.vue
-
34frontend/src/views/PipelineFlow.vue
-
164frontend/tests/chat-protocol.test.mjs
@ -1,206 +1,139 @@ |
|||||
/** |
|
||||
* SSE 流式读取工具 —— 从 utils.js 原封不动搬移 |
|
||||
* |
|
||||
* 统一处理 Flux<String> / ServerSentEvent / SseEmitter 三种 SSE 接口。 |
|
||||
* 此文件使用原生 fetch + ReadableStream API,零框架依赖。 |
|
||||
*/ |
|
||||
import type { SSECallbacks } from '@/types/sse' |
|
||||
|
/** Shared SSE reader for plain text, OpenAI chunks and tool events. */ |
||||
|
import type { SSECallbacks, SourceReference } from '@/types/sse' |
||||
import { getToken } from '@/utils/token' |
import { getToken } from '@/utils/token' |
||||
|
|
||||
/** 构建带认证的请求头 */ |
|
||||
|
/** Preserve explicit SDK authorization instead of replacing it with the admin token. */ |
||||
function authHeaders(extra?: Record<string, string>): Record<string, string> { |
function authHeaders(extra?: Record<string, string>): Record<string, string> { |
||||
const token = getToken() |
const token = getToken() |
||||
const base: Record<string, string> = extra ? { ...extra } : {} |
|
||||
// 仅当调用方未显式提供 Authorization 时才注入管理后台 token,避免覆盖调用方传入的鉴权头(如测试面板的 SDK Token)
|
|
||||
if (token && !base['Authorization']) base['Authorization'] = `Bearer ${token}` |
|
||||
return base |
|
||||
|
const headers = { ...extra } |
||||
|
if (token && !headers['Authorization']) headers['Authorization'] = `Bearer ${token}` |
||||
|
return headers |
||||
} |
} |
||||
|
|
||||
/** 尝试从 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 形状 → 回退纯文本
|
|
||||
|
function isSourceReference(value: unknown): value is SourceReference { |
||||
|
if (!value || typeof value !== 'object') return false |
||||
|
const nullableString = (v: unknown) => v === null || typeof v === 'string' |
||||
|
const nullableNumber = (v: unknown) => v === null || typeof v === 'number' |
||||
|
return 'documentId' in value && nullableString(value.documentId) |
||||
|
&& 'title' in value && nullableString(value.title) |
||||
|
&& 'sourceName' in value && nullableString(value.sourceName) |
||||
|
&& 'chunkIndex' in value && nullableNumber(value.chunkIndex) |
||||
|
&& 'score' in value && nullableNumber(value.score) |
||||
|
&& 'snippet' in value && nullableString(value.snippet) |
||||
} |
} |
||||
|
|
||||
/** |
|
||||
* 通用 SSE 流式读取 —— 统一处理 Flux<String> / ServerSentEvent / SseEmitter 三种 SSE 接口 |
|
||||
* |
|
||||
* @param url 请求地址 |
|
||||
* @param onChunk 每收到一段文本的回调 |
|
||||
* @param onDone 流结束的回调 |
|
||||
* @param headers 额外请求头 |
|
||||
* @param signal AbortSignal 用于取消请求(组件卸载时必须传入以释放网络资源) |
|
||||
*/ |
|
||||
export async function readSSEStream( |
|
||||
|
/** Returns false only for legacy plain-text payloads. Metadata never enters message text. */ |
||||
|
function dispatchOpenAI(text: string, handlers: SSECallbacks): boolean { |
||||
|
let value: unknown |
||||
|
try { value = JSON.parse(text) } catch { return false } |
||||
|
if (!value || typeof value !== 'object') return false |
||||
|
if ('error' in value && value.error && typeof value.error === 'object') { |
||||
|
const error = value.error |
||||
|
const message = 'message' in error ? error.message : 'type' in error ? error.type : undefined |
||||
|
handlers.onMessage?.(typeof message === 'string' && message ? message : '服务异常') |
||||
|
return true |
||||
|
} |
||||
|
if (!('choices' in value) || !Array.isArray(value.choices)) return false |
||||
|
if ('sources' in value && Array.isArray(value.sources)) { |
||||
|
if (!value.sources.every(isSourceReference)) throw new Error('无效的引用来源数据') |
||||
|
handlers.onSources?.(value.sources) |
||||
|
} |
||||
|
const content = value.choices[0]?.delta?.content |
||||
|
if (typeof content === 'string' && content) handlers.onMessage?.(content) |
||||
|
return true |
||||
|
} |
||||
|
|
||||
|
/** Text-only callers share the same framing, completion and cleanup semantics. */ |
||||
|
export function readSSEStream( |
||||
url: string, |
url: string, |
||||
onChunk: (text: string) => void, |
onChunk: (text: string) => void, |
||||
onDone?: () => void, |
onDone?: () => void, |
||||
headers?: Record<string, string>, |
headers?: Record<string, string>, |
||||
signal?: AbortSignal |
|
||||
|
signal?: AbortSignal, |
||||
): Promise<void> { |
): 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') |
|
||||
|
return readSSEStreamWithEvents(url, { onMessage: onChunk, onDone }, headers, signal) |
||||
} |
} |
||||
|
|
||||
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 用于取消请求(组件卸载时必须传入以释放网络资源) |
|
||||
*/ |
|
||||
|
/** Read complete SSE events; [DONE] terminates immediately without waiting for network EOF. */ |
||||
export async function readSSEStreamWithEvents( |
export async function readSSEStreamWithEvents( |
||||
url: string, |
url: string, |
||||
handlers: SSECallbacks, |
handlers: SSECallbacks, |
||||
headers?: Record<string, string>, |
headers?: Record<string, string>, |
||||
signal?: AbortSignal |
|
||||
|
signal?: AbortSignal, |
||||
): Promise<void> { |
): Promise<void> { |
||||
const { onMessage, onToolCallStart, onToolCallResult, onError, onDone } = handlers |
|
||||
const res = await fetch(url, { headers: authHeaders(headers), signal, credentials: 'include' }) |
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() |
|
||||
|
if (!res.ok) throw new Error((await res.text()) || 'HTTP ' + res.status) |
||||
|
if (!res.body) throw new Error('响应流为空') |
||||
|
const reader = res.body.getReader() |
||||
const decoder = new TextDecoder() |
const decoder = new TextDecoder() |
||||
let buffer = '' |
let buffer = '' |
||||
let currentEvent = 'message' |
|
||||
// SSE 规范:同一事件内的多行 data: 字段用 \n 拼接
|
|
||||
let eventDataLines: string[] = [] |
|
||||
|
let event = 'message' |
||||
|
let data: string[] = [] |
||||
|
let completed = false |
||||
|
let eof = false |
||||
|
|
||||
const flushEvent = () => { |
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 |
|
||||
|
const currentEvent = event |
||||
|
event = 'message' |
||||
|
if (!data.length) return |
||||
|
const raw = data.join('\n') |
||||
|
data = [] |
||||
|
if (raw === '[DONE]') { |
||||
|
completed = true |
||||
|
return |
||||
} |
} |
||||
|
switch (currentEvent) { |
||||
|
case 'status': return |
||||
|
case 'tool_call_start': handlers.onToolCallStart?.(JSON.parse(raw)); return |
||||
|
case 'tool_call_result': handlers.onToolCallResult?.(JSON.parse(raw)); return |
||||
|
case 'error': handlers.onError?.(JSON.parse(raw)); return |
||||
|
default: |
||||
|
if (!dispatchOpenAI(raw, handlers)) handlers.onMessage?.(raw || '\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) { |
|
||||
if (line.endsWith('\r')) line = line.slice(0, -1) |
|
||||
if (line === '') { |
|
||||
// 空行 = SSE 事件边界
|
|
||||
|
const consumeLine = (raw: string) => { |
||||
|
const line = raw.endsWith('\r') ? raw.slice(0, -1) : raw |
||||
|
if (!line) { |
||||
flushEvent() |
flushEvent() |
||||
} else if (line.startsWith('event:')) { |
} 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)
|
|
||||
|
event = line.slice(6).trim() |
||||
|
} else if (line === 'data' || line.startsWith('data:')) { |
||||
|
const value = line === 'data' ? '' : line.slice(5) |
||||
|
data.push(value.startsWith(' ') ? value.slice(1) : value) |
||||
|
} else if (!line.startsWith(':') && !line.startsWith('id:') && !line.startsWith('retry:')) { |
||||
|
// Legacy Flux<String> bodies may contain unframed lines.
|
||||
flushEvent() |
flushEvent() |
||||
if (line.trim() && onMessage) onMessage(line) |
|
||||
|
if (!completed && line.trim()) handlers.onMessage?.(line) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
while (!completed) { |
||||
|
signal?.throwIfAborted() |
||||
|
const result = await reader.read() |
||||
|
signal?.throwIfAborted() |
||||
|
eof = result.done |
||||
|
buffer += eof ? decoder.decode() : decoder.decode(result.value, { stream: true }) |
||||
|
let start = 0 |
||||
|
let end: number |
||||
|
while (!completed && (end = buffer.indexOf('\n', start)) !== -1) { |
||||
|
consumeLine(buffer.slice(start, end)) |
||||
|
start = end + 1 |
||||
|
} |
||||
|
buffer = buffer.slice(start) |
||||
|
if (eof) { |
||||
|
if (!completed && buffer) consumeLine(buffer) |
||||
|
if (!completed) flushEvent() |
||||
|
break |
||||
} |
} |
||||
} |
} |
||||
|
handlers.onDone?.() |
||||
|
} finally { |
||||
|
// Cancellation may itself reject or stall; it must not delay DONE or mask the original error.
|
||||
|
if (!eof) { |
||||
|
try { void reader.cancel().catch(() => {}) } catch { /* Preserve the stream outcome. */ } |
||||
|
} |
||||
|
try { reader.releaseLock() } catch { /* Preserve the stream outcome. */ } |
||||
} |
} |
||||
flushEvent() |
|
||||
if (onDone) onDone() |
|
||||
} |
} |
||||
@ -0,0 +1,164 @@ |
|||||
|
import assert from 'node:assert/strict' |
||||
|
import { afterEach, beforeEach, test } from 'node:test' |
||||
|
import { fileURLToPath } from 'node:url' |
||||
|
import { build } from 'esbuild' |
||||
|
|
||||
|
// Exercise the production TypeScript with Vite's existing esbuild dependency, no test framework. |
||||
|
const root = fileURLToPath(new URL('../', import.meta.url)) |
||||
|
async function loadModule(entry) { |
||||
|
const result = await build({ |
||||
|
entryPoints: [root + entry], bundle: true, write: false, format: 'esm', platform: 'node', |
||||
|
alias: { '@': root + 'src' }, |
||||
|
}) |
||||
|
return import('data:text/javascript;base64,' + Buffer.from(result.outputFiles[0].text).toString('base64')) |
||||
|
} |
||||
|
const { readSSEStream, readSSEStreamWithEvents } = await loadModule('src/utils/sse.ts') |
||||
|
const { chatSync, fetchChatResult } = await loadModule('src/api/chat.ts') |
||||
|
const originalFetch = globalThis.fetch |
||||
|
const originalStorage = Object.getOwnPropertyDescriptor(globalThis, 'localStorage') |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
Object.defineProperty(globalThis, 'localStorage', { |
||||
|
configurable: true, value: { getItem: () => 'admin-token' }, |
||||
|
}) |
||||
|
}) |
||||
|
afterEach(() => { |
||||
|
globalThis.fetch = originalFetch |
||||
|
if (originalStorage) Object.defineProperty(globalThis, 'localStorage', originalStorage) |
||||
|
else delete globalThis.localStorage |
||||
|
}) |
||||
|
|
||||
|
const sources = [{ |
||||
|
documentId: '9223372036854775806', title: '报销制度', sourceName: 'policy.pdf', |
||||
|
chunkIndex: 2, score: 0.125, snippet: '申请应在三十天内提交。', |
||||
|
}] |
||||
|
const metadata = JSON.stringify({ object: 'chat.completion.chunk', choices: [], sources }) |
||||
|
const answer = JSON.stringify({ choices: [{ delta: { content: '答复。' } }] }) |
||||
|
|
||||
|
function responseFor(text, { open = false, fragment = false, cancel } = {}) { |
||||
|
const bytes = new TextEncoder().encode(text) |
||||
|
const body = new ReadableStream({ |
||||
|
start(controller) { |
||||
|
if (fragment) for (const byte of bytes) controller.enqueue(Uint8Array.of(byte)) |
||||
|
else controller.enqueue(bytes) |
||||
|
if (!open) controller.close() |
||||
|
}, |
||||
|
cancel, |
||||
|
}) |
||||
|
return new Response(body) |
||||
|
} |
||||
|
|
||||
|
test('fragmented CRLF metadata preserves ID/snippet and DONE completes without EOF or cancellation settlement', { timeout: 1000 }, async () => { |
||||
|
let cancelled = 0 |
||||
|
const response = responseFor( |
||||
|
`data: ${answer}\r\n\r\ndata: ${metadata}\r\n\r\ndata: [DONE]\r\n\r\ndata: ignored\r\n\r\n`, |
||||
|
{ open: true, fragment: true, cancel() { cancelled++; return new Promise(() => {}) } }, |
||||
|
) |
||||
|
const requests = [] |
||||
|
globalThis.fetch = async (...args) => { requests.push(args); return response } |
||||
|
const events = [] |
||||
|
await readSSEStreamWithEvents('/ai/chat/stream', { |
||||
|
onMessage: text => events.push(['text', text]), |
||||
|
onSources: value => events.push(['sources', value]), |
||||
|
onDone: () => events.push(['done']), |
||||
|
}, { Authorization: 'Bearer sdk-token' }) |
||||
|
assert.deepEqual(events, [['text', '答复。'], ['sources', sources], ['done']]) |
||||
|
assert.equal(requests.length, 1) |
||||
|
assert.equal(requests[0][1].headers.Authorization, 'Bearer sdk-token') |
||||
|
assert.equal(cancelled, 1) |
||||
|
assert.equal(response.body.locked, false) |
||||
|
}) |
||||
|
|
||||
|
test('text-only facade uses identical DONE handling and never leaks empty choices or sources', { timeout: 1000 }, async () => { |
||||
|
globalThis.fetch = async () => responseFor( |
||||
|
`data: ${metadata}\n\ndata: ${answer}\n\ndata: [DONE]\n\ndata: [DONE]\n\ndata: trailing\n\n`, |
||||
|
{ open: true }, |
||||
|
) |
||||
|
const text = [] |
||||
|
let completed = 0 |
||||
|
await readSSEStream('/ai/chat/stream', value => text.push(value), () => completed++) |
||||
|
assert.deepEqual(text, ['答复。']) |
||||
|
assert.equal(completed, 1) |
||||
|
}) |
||||
|
|
||||
|
test('EOF residuals, multiline data, tool events, status and legacy text retain their semantics', async () => { |
||||
|
globalThis.fetch = async () => responseFor( |
||||
|
': heartbeat\r\nid: event-1\r\nretry: 1000\r\nevent: status\r\ndata: generating\r\n\r\n' |
||||
|
+ 'event: tool_call_start\r\ndata: {"tool":"lookup"}\r\n\r\n' |
||||
|
+ 'event: tool_call_result\r\ndata: {"tool":"lookup","result":"ok"}\r\n\r\n' |
||||
|
+ 'event: error\r\ndata: {"message":"tool unavailable"}\r\n\r\n' |
||||
|
+ 'data: first\r\ndata: indented\r\n\r\ndata:\r\n\r\nlegacy\r\ndata: 尾部', |
||||
|
{ fragment: true }, |
||||
|
) |
||||
|
const events = [] |
||||
|
await readSSEStreamWithEvents('/stream', { |
||||
|
onMessage: text => events.push(text), |
||||
|
onToolCallStart: value => events.push(value), |
||||
|
onToolCallResult: value => events.push(value), |
||||
|
onError: value => events.push(value), |
||||
|
onDone: () => events.push('done'), |
||||
|
}) |
||||
|
assert.deepEqual(events, [ |
||||
|
{ tool: 'lookup' }, { tool: 'lookup', result: 'ok' }, { message: 'tool unavailable' }, |
||||
|
'first\n indented', '\n', 'legacy', '尾部', 'done', |
||||
|
]) |
||||
|
}) |
||||
|
|
||||
|
test('EOF flushes an unterminated plain-text line and an unterminated DONE event', async () => { |
||||
|
for (const body of ['legacy tail', 'data: [DONE]']) { |
||||
|
globalThis.fetch = async () => responseFor(body) |
||||
|
const text = [] |
||||
|
let done = 0 |
||||
|
await readSSEStream('/stream', value => text.push(value), () => done++) |
||||
|
assert.deepEqual(text, body.startsWith('data:') ? [] : ['legacy tail']) |
||||
|
assert.equal(done, 1) |
||||
|
} |
||||
|
}) |
||||
|
|
||||
|
test('cleanup failure cannot replace callback errors or call onDone on an error', async () => { |
||||
|
const original = new Error('consumer failed') |
||||
|
const response = responseFor(`data: ${answer}\n\n`, { |
||||
|
open: true, cancel() { throw new Error('cleanup failed') }, |
||||
|
}) |
||||
|
globalThis.fetch = async () => response |
||||
|
let done = 0 |
||||
|
await assert.rejects(readSSEStream('/stream', () => { throw original }, () => done++), error => error === original) |
||||
|
assert.equal(done, 0) |
||||
|
assert.equal(response.body.locked, false) |
||||
|
}) |
||||
|
|
||||
|
test('empty sources for ordinary answers are delivered separately from content', async () => { |
||||
|
globalThis.fetch = async () => responseFor('data: {"choices":[],"sources":[]}\n\ndata: [DONE]\n\n') |
||||
|
const values = [] |
||||
|
await readSSEStreamWithEvents('/stream', { |
||||
|
onMessage: () => assert.fail('metadata is not text'), |
||||
|
onSources: value => values.push(value), |
||||
|
}) |
||||
|
assert.deepEqual(values, [[]]) |
||||
|
}) |
||||
|
|
||||
|
test('synchronous chat makes one result request and retains direct answer, sources, explicit strategy and signal', async () => { |
||||
|
const result = { text: '答复。', mcpEvents: [], suggestions: [], sources } |
||||
|
const calls = [] |
||||
|
globalThis.fetch = async (...args) => { calls.push(args); return Response.json(result) } |
||||
|
const controller = new AbortController() |
||||
|
assert.deepEqual(await chatSync('费用?', 'conversation-1', { |
||||
|
enableRag: true, roleId: '9223372036854775806', rewriteStrategy: 'MULTI_QUERY', categoryIds: ['123'], |
||||
|
}, controller.signal), result) |
||||
|
assert.equal(calls.length, 1) |
||||
|
const url = new URL(calls[0][0], 'https://test.invalid') |
||||
|
assert.equal(url.pathname, '/ai/chat/result') |
||||
|
assert.equal(url.searchParams.get('rewriteStrategy'), 'MULTI_QUERY') |
||||
|
assert.equal(url.searchParams.get('roleId'), '9223372036854775806') |
||||
|
assert.equal(calls[0][1].signal, controller.signal) |
||||
|
assert.equal(calls[0][1].headers.Authorization, 'Bearer admin-token') |
||||
|
}) |
||||
|
|
||||
|
test('synchronous SDK response uses the same direct contract and surfaces server errors', async () => { |
||||
|
const result = { text: '普通回答', mcpEvents: [], suggestions: [], sources: [] } |
||||
|
globalThis.fetch = async () => Response.json(result) |
||||
|
assert.deepEqual(await fetchChatResult('https://sdk.invalid/ai/chat/result', {}), result) |
||||
|
globalThis.fetch = async () => new Response('角色无访问权限', { status: 403 }) |
||||
|
await assert.rejects(fetchChatResult('/ai/chat/result', {}), /角色无访问权限/) |
||||
|
await assert.rejects(readSSEStream('/ai/chat/stream', () => {}), /角色无访问权限/) |
||||
|
}) |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue