From 5086dd16f42eab491bff15c6409fad0512f347c8 Mon Sep 17 00:00:00 2001 From: wei-py Date: Mon, 14 Sep 2026 14:13:29 +0800 Subject: [PATCH] =?UTF-8?q?feat(sdk):=20=E5=90=8C=E6=AD=A5=E4=B8=8E?= =?UTF-8?q?=E6=B5=81=E5=BC=8F=E5=AF=B9=E8=AF=9D=E5=A4=8D=E7=94=A8=E5=90=8C?= =?UTF-8?q?=E6=AC=A1=E5=BC=95=E7=94=A8=E6=9D=A5=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/api.ts | 302 ++++++++++-------------------------- client/src/chat.ts | 251 +++++++++++++----------------- client/src/config.ts | 2 +- client/src/dom.ts | 4 +- client/src/types.ts | 22 ++- client/tests/api.test.ts | 123 +++++++++++++++ client/tests/chat.test.ts | 118 ++++++++++++++ client/tests/config.test.ts | 8 + 8 files changed, 461 insertions(+), 369 deletions(-) create mode 100644 client/tests/api.test.ts create mode 100644 client/tests/chat.test.ts diff --git a/client/src/api.ts b/client/src/api.ts index 118db1d..65632d9 100644 --- a/client/src/api.ts +++ b/client/src/api.ts @@ -6,7 +6,7 @@ * userId → accountId(客户账号 ID) * chatId → 自动管理的对话 ID(从 /ai/sdk/conversation/list 获取或自动生成) */ -import { ResolvedConfig, ApiResponse, CategoryNode, ImageAttachment } from './types'; +import { ResolvedConfig, ApiResponse, CategoryNode, ImageAttachment, ChatResult, RagSource } from './types'; import { logger } from './logger'; import { t } from './i18n'; @@ -81,30 +81,8 @@ function appendImageUrls(params: URLSearchParams, imageUrls?: string[]): void { } } -/** - * 构建同步对话请求 URL - * - integrateId → roleId - * - userId → accountId - * - chatId → 自动管理的对话 ID - */ -function buildChatUrl(message: string, imageUrls?: string[]): string { - const params = new URLSearchParams(); - params.set('message', message); - params.set('chatId', currentConfig!.chatId); - - // integrateId 映射为 roleId - setIfPresent(params, 'roleId', getActiveIntegrateId()); - // userId 映射为 accountId - setIfPresent(params, 'accountId', currentConfig!.userId); - appendImageUrls(params, imageUrls); - - return buildUrl(`/ai/chat?${params.toString()}`); -} - -/** - * 构建 SSE 流式请求 URL - */ -function buildChatSSEUrl(message: string, categoryId?: number, useRag?: boolean, imageUrls?: string[]): string { +/** 同步和流式对话共享参数,确保 RAG、分类与角色语义一致。 */ +function buildChatUrl(path: string, message: string, categoryId?: number, useRag?: boolean, imageUrls?: string[]): string { const params = new URLSearchParams(); params.set('message', message); params.set('chatId', currentConfig!.chatId); @@ -113,31 +91,15 @@ function buildChatSSEUrl(message: string, categoryId?: number, useRag?: boolean, setIfPresent(params, 'accountId', currentConfig!.userId); setIfPresent(params, 'categoryId', categoryId ?? currentConfig!.categoryId); - // RAG 增强:追加 enableRag=true 与查询重写策略,后端按角色绑定的知识库分类自动检索 - if (useRag) { - params.set('enableRag', 'true'); - params.set('rewriteStrategy', currentConfig!.rewriteStrategy || 'REWRITE'); + params.set('enableRag', String(useRag ?? currentConfig!.enableRag)); + if (useRag ?? currentConfig!.enableRag) { + params.set('rewriteStrategy', currentConfig!.rewriteStrategy || 'NONE'); } appendImageUrls(params, imageUrls); - return buildUrl(`/ai/chat/stream?${params.toString()}`); + return buildUrl(`${path}?${params.toString()}`); } -/** - * 构建 RAG 引用来源请求 URL - */ -function buildRagSourcesUrl(message: string, categoryId?: number): string { - const params = new URLSearchParams(); - params.set('message', message); - params.set('chatId', currentConfig!.chatId); - params.set('rewriteStrategy', currentConfig!.rewriteStrategy || 'REWRITE'); - - setIfPresent(params, 'roleId', getActiveIntegrateId()); - setIfPresent(params, 'accountId', currentConfig!.userId); - setIfPresent(params, 'categoryId', categoryId ?? currentConfig!.categoryId); - - return buildUrl(`/ai/chat/sources?${params.toString()}`); -} // ==================== HTTP 基础封装 ==================== @@ -235,21 +197,21 @@ function getHttpErrorMessage(status: number): string { /** * 同步对话请求 */ -export async function chatRequest(message: string, imageUrls?: string[]): Promise { - const url = buildChatUrl(message, imageUrls); +export async function chatRequest(message: string, imageUrls?: string[], categoryId?: number, useRag?: boolean, signal?: AbortSignal): Promise { + const url = buildChatUrl('/ai/chat/result', message, categoryId, useRag, imageUrls); logger.lifecycleSend(getActiveIntegrateId(), message.length); try { - const response = await safeFetch(url); + const response = await safeFetch(url, {}, REQUEST_TIMEOUT, signal); if (!response.ok) { const errorMsg = getHttpErrorMessage(response.status); logger.lifecycleError(getActiveIntegrateId(), String(response.status), errorMsg); throw new CskError(errorMsg, `http_${response.status}`); } - const text = await response.text(); - logger.lifecycleReply(getActiveIntegrateId(), text.length); - return text; + const result: ChatResult = await response.json(); + logger.lifecycleReply(getActiveIntegrateId(), result.text.length); + return result; } catch (err) { if (err instanceof CskError) throw err; logger.lifecycleError(getActiveIntegrateId(), 'unknown', String(err)); @@ -270,194 +232,131 @@ export async function chatSSERequest( categoryId?: number, useRag?: boolean, imageUrls?: string[], - signal?: AbortSignal + signal?: AbortSignal, + onSources?: (sources: RagSource[]) => void ): Promise { - const url = useRag - ? buildChatSSEUrl(message, categoryId, true, imageUrls) - : buildChatSSEUrl(message, categoryId, false, imageUrls); + const url = buildChatUrl('/ai/chat/stream', message, categoryId, useRag, imageUrls); let totalText = ''; - - /** - * 处理单个 SSE 原始块:优先按 OpenAI Chat Completions JSON 解析, - * 解析失败则回退为纯文本整体追加。 - * @param raw 累积的原始事件内容(eventLines 拼接结果) - */ + let completed = false; + const finish = () => { + if (completed) return; + completed = true; + onDone(); + }; const emitRawChunk = (raw: string): void => { - // 尝试按 OpenAI Chat Completions JSON 解析,解析失败则视为纯文本 - let obj: unknown = null; + let obj: unknown; try { obj = JSON.parse(raw); } catch { obj = null; } - if (obj && typeof obj === 'object') { const record = obj as Record; - - // OpenAI 错误 chunk:{ error: { message, type } } if (record.error) { const err = record.error as { message?: string; type?: string }; - onError(new CskError(String(err.message || err.type || 'stream error'), 'stream_error')); - return; + throw new CskError(String(err.message || err.type || 'stream error'), 'stream_error'); } - - // OpenAI Chat Completions chunk:{ choices: [{ delta: { content } }] } if (Array.isArray(record.choices)) { + if (Array.isArray(record.sources)) onSources?.(record.sources as RagSource[]); const first = (record.choices as Array<{ delta?: { content?: unknown } }>)[0]; const content = first?.delta?.content; if (typeof content === 'string' && content.length > 0) { totalText += content; onChunk(content); } - // role / finish_reason 等空 chunk 直接跳过 return; } } - - // 纯文本回退:整体作为文本追加 totalText += raw; onChunk(raw); }; logger.lifecycleSend(getActiveIntegrateId(), message.length); - try { const response = await safeFetch(url, {}, REQUEST_TIMEOUT * 2, signal); - if (!response.ok) { - const errorMsg = getHttpErrorMessage(response.status); - logger.lifecycleError(getActiveIntegrateId(), String(response.status), errorMsg); - onError(new CskError(errorMsg, `http_${response.status}`)); - return; + throw new CskError(getHttpErrorMessage(response.status), `http_${response.status}`); } - const reader = response.body?.getReader(); - if (!reader) { - onError(new CskError(t('error_stream_unsupported'), 'stream_unsupported')); - return; - } + if (!reader) throw new CskError(t('error_stream_unsupported'), 'stream_unsupported'); - const decoder = new TextDecoder('utf-8', { stream: true } as TextDecoderOptions); + const decoder = new TextDecoder(); let buffer = ''; let eventLines: string[] = []; - /** 当前 SSE 事件类型(event:xxx 行声明的类型),空=默认 message 事件 */ let currentEventType = ''; - + let stopped = false; + const flushEvent = () => { + if (eventLines.length && currentEventType !== 'status' && currentEventType !== 'tool_call_result') { + emitRawChunk(eventLines.join('\n')); + } + eventLines = []; + currentEventType = ''; + }; + const consumeLine = (raw: string) => { + const line = raw.endsWith('\r') ? raw.slice(0, -1) : raw; + if (line === '') { + flushEvent(); + return; + } + if (line.startsWith(':')) return; + if (line.startsWith('event:')) { + currentEventType = line.slice(6).trim(); + return; + } + if (line.startsWith('id:') || line.startsWith('retry:')) return; + let content = line; + if (line.startsWith('data:')) { + content = line.slice(5); + if (content.startsWith(' ')) content = content.slice(1); + } + if (content === '[DONE]') { + flushEvent(); + stopped = true; + return; + } + eventLines.push(content); + }; + // Cancellation must unblock a pending read, but cleanup failures must not + // replace the original stream error or delay completion after [DONE]. + const cancelReader = () => { + try { void reader.cancel().catch(() => {}); } catch { /* best-effort cleanup */ } + }; + signal?.addEventListener('abort', cancelReader, { once: true }); try { - while (true) { + while (!stopped && !signal?.aborted) { const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split('\n'); - buffer = lines.pop() || ''; - - for (const line of lines) { - const trimmed = line.trim(); - - // 空行 = SSE 事件边界,刷新当前事件 - if (!trimmed) { - if (eventLines.length > 0) { - // 非内容事件(status 等)不回调 onChunk,避免污染 AI 回复渲染 - if (currentEventType !== 'status' && currentEventType !== 'tool_call_result') { - emitRawChunk(eventLines.join('\n')); - } - eventLines = []; - } - currentEventType = ''; - continue; - } - - // 忽略注释行 (: ...) - if (trimmed.startsWith(':')) { - // TODO: 信号行 ":ok" 或其他 - continue; - } - - // [DONE] 信号(结束整次 SSE 流) - if (trimmed === '[DONE]') { - if (eventLines.length > 0 && currentEventType !== 'status' && currentEventType !== 'tool_call_result') { - emitRawChunk(eventLines.join('\n')); - eventLines = []; - } - currentEventType = ''; - break; - } - - // SSE 协议字段:event / id / retry - if (trimmed.startsWith('event:')) { - // 记录当前事件类型,用于决定是否将后续 data 行传递给 AI 渲染 - currentEventType = trimmed.substring('event:'.length).trim(); - continue; - } - if (trimmed.startsWith('id:') || trimmed.startsWith('retry:')) continue; - - // 提取 content(data: 前缀行去掉前缀,裸行为 Flux 元素内换行的续行) - let content: string; - if (trimmed.startsWith('data:')) { - content = trimmed.substring('data:'.length); - // 去掉 data: 后面紧跟的一个空格(SSE 标准允许) - if (content.startsWith(' ')) content = content.substring(1); - // OpenAI 格式的结束信号:data: [DONE] - if (content === '[DONE]') { - if (eventLines.length > 0 && currentEventType !== 'status' && currentEventType !== 'tool_call_result') { - emitRawChunk(eventLines.join('\n')); - eventLines = []; - } - currentEventType = ''; - break; - } - } else { - content = trimmed; - } - eventLines.push(content); + if (signal?.aborted) break; + buffer += decoder.decode(value, { stream: !done }); + let newline: number; + while (!stopped && (newline = buffer.indexOf('\n')) !== -1) { + const line = buffer.slice(0, newline); + buffer = buffer.slice(newline + 1); + consumeLine(line); } - } - - if (buffer.trim()) { - const trimmed = buffer.trim(); - - if (!trimmed.startsWith(':')) { - if (trimmed.startsWith('data:')) { - let content = trimmed.substring('data:'.length); - if (content.startsWith(' ')) content = content.substring(1); - eventLines.push(content); - } else if (trimmed !== '[DONE]' && !trimmed.startsWith('event:') && !trimmed.startsWith('id:') && !trimmed.startsWith('retry:')) { - eventLines.push(trimmed); - } + if (done) { + if (!stopped && buffer) consumeLine(buffer); + if (!stopped) flushEvent(); + break; } } - // 处理缓冲区剩余 - if (eventLines.length > 0 && currentEventType !== 'status' && currentEventType !== 'tool_call_result') { - emitRawChunk(eventLines.join('\n')); - eventLines = []; - } - } catch (readErr: unknown) { - // 用户主动中断:视为正常结束,保留已生成内容 - if (signal?.aborted) { - logger.info(`流式被用户中断,保留已生成内容 length=${totalText.length}`); - } else if (totalText.length > 0) { - onChunk('\n\n' + t('stream_unstable')); - } else { - throw readErr; - } } finally { - reader.releaseLock(); + signal?.removeEventListener('abort', cancelReader); + cancelReader(); + try { reader.releaseLock(); } catch { /* preserve original outcome */ } } - logger.lifecycleStreamDone(getActiveIntegrateId(), totalText.length); - onDone(); + finish(); } catch (err) { - // 用户主动中断不触发 onError,走 onDone if (signal?.aborted || (err instanceof CskError && err.type === 'aborted')) { - onDone(); + finish(); return; } + if (completed) throw err; if (err instanceof CskError) { onError(err); } else { logger.lifecycleError(getActiveIntegrateId(), 'unknown', String(err)); - onError(new CskError(t('error_network'), 'network')); + onError(new CskError(err instanceof Error ? err.message : t('error_network'), 'network')); } } } @@ -522,37 +421,6 @@ export async function fetchCategoryTree(): Promise { } } -// ==================== P1: RAG 引用来源 ==================== - -/** RAG 来源响应数据项 */ -interface RagSourceData { - documentId?: string; - title?: string; - sourceName?: string; - chunkIndex?: number; - score?: number; - snippet?: string; -} - -/** - * 获取 RAG 引用来源 - */ -export async function fetchRagSources(message: string, categoryId?: number): Promise { - const url = buildRagSourcesUrl(message, categoryId); - try { - const response = await safeFetch(url); - if (!response.ok) throw new CskError(getHttpErrorMessage(response.status), `http_${response.status}`); - const json: ApiResponse = await response.json(); - if (json.success && Array.isArray(json.data)) { - logger.info(`获取引用来源 count=${json.data.length}`); - return json.data; - } - return []; - } catch (err) { - logger.error('获取引用来源失败', err); - return []; - } -} // ==================== P0-002: 消息反馈 ==================== diff --git a/client/src/chat.ts b/client/src/chat.ts index eb08893..4395e02 100644 --- a/client/src/chat.ts +++ b/client/src/chat.ts @@ -11,7 +11,6 @@ import { chatRequest, chatSSERequest, fetchCategoryTree, - fetchRagSources, fetchConversationList, fetchConversationMessages, deleteConversation, @@ -511,145 +510,129 @@ async function produceAIReply(userText: string, images?: ImageAttachment[]): Pro isSending = true; setSendButtonMode('stop'); - - // 确保 chatId 已初始化 - if (!config.chatId) { - await initChatId(); - } - - // 提取图片 URL,仅图片参与多模态对话 + const requestConfig = config; + const requestMessages = messages; + const requestRoleId = getActiveIntegrateId(); + const controller = new AbortController(); + abortController = controller; + const isCurrent = () => config === requestConfig && messages === requestMessages + && getActiveIntegrateId() === requestRoleId; + const aiMsg: ChatMessage = { id: uuid(), role: 'ai', content: '', timestamp: now(), sources: [] }; const imageUrls = (images || []).map(img => img.url); - - const aiTimestamp = now(); - // RAG 启用条件:由 enableRag 控制 const shouldUseRag = useRag; - - // 显示 loading - if (showLoadingFn) showLoadingFn(); - if (messagesContainer) smartScrollToBottom(); - - const aiMsgId = uuid(); - let aiContent = ''; + const categoryId = currentCategoryId; try { - if (config.streaming) { - aiContent = await sendStreamMessage(userText, aiTimestamp, shouldUseRag, aiMsgId, imageUrls); + if (!requestConfig.chatId) await initChatId(); + if (!isCurrent() || controller.signal.aborted) return false; + if (showLoadingFn) showLoadingFn(); + smartScrollToBottom(); + + if (requestConfig.streaming) { + await sendStreamMessage(userText, aiMsg, shouldUseRag, categoryId, controller.signal, isCurrent, imageUrls); } else { - aiContent = await chatRequest(userText, imageUrls); + const result = await chatRequest(userText, imageUrls, categoryId, shouldUseRag, controller.signal); + if (!isCurrent() || controller.signal.aborted) return false; + aiMsg.content = result.text; + aiMsg.sources = result.sources; if (hideLoadingFn) hideLoadingFn(); if (messagesContainer) { - renderAIBubble(messagesContainer, aiContent, aiTimestamp, aiMsgId); + const wrapper = renderAIBubble(messagesContainer, aiMsg.content, aiMsg.timestamp, aiMsg.id); + renderSources(wrapper, aiMsg.sources); } } - const aiMsg: ChatMessage = { id: aiMsgId, role: 'ai', content: aiContent, timestamp: aiTimestamp }; + if (!isCurrent()) return false; + if (controller.signal.aborted && !aiMsg.content) return false; messages.push(aiMsg); - - saveMessages(config.integrateId, messages); + saveMessages(requestRoleId, messages); if (messagesContainer) smartScrollToBottom(); - - // a11y 播报新 AI 消息 - announceMessage(aiContent); - - // 通知 launcher 显示未读徽章(弹窗关闭时生效,由 index.ts 监听) + announceMessage(aiMsg.content); if (messagesContainer) { messagesContainer.dispatchEvent(new CustomEvent('csk:newMessage', { bubbles: true, detail: { msg: aiMsg } })); } - - // RAG 引用来源 - if (shouldUseRag) fetchAndRenderSources(userText, aiMsg); - - // 拉取并展示推荐问题(suggest-message-list) - if (config.suggestions) { - fetchAndShowSuggestions(aiMsgId); - } - - // 发送成功后清除离线横幅(网络已恢复) + if (requestConfig.suggestions && !controller.signal.aborted) fetchAndShowSuggestions(aiMsg.id); hideOfflineBanner(); return true; } catch (err) { - if (hideLoadingFn) hideLoadingFn(); - - const errMsg = err instanceof CskError ? err.message : t('error_send'); - if (messagesContainer) { - renderErrorBubble(messagesContainer, errMsg, now()); - } - logger.error(`发送失败 integrateId=${config.integrateId}`, err); + if (!isCurrent() || controller.signal.aborted) return false; + const errMsg = err instanceof Error ? err.message : t('error_send'); + if (messagesContainer) renderErrorBubble(messagesContainer, errMsg, now()); + logger.error(`发送失败 integrateId=${requestRoleId}`, err); return false; } finally { - isSending = false; - abortController = null; - setSendButtonMode('send'); + if (abortController === controller) { + if (hideLoadingFn) hideLoadingFn(); + isSending = false; + abortController = null; + setSendButtonMode('send'); + } } } -/** 流式发送消息 */ -async function sendStreamMessage(text: string, aiTimestamp: number, shouldUseRag: boolean, aiMsgId: string, imageUrls?: string[]): Promise { - // 创建中断控制器,供"停止生成"使用 - abortController = new AbortController(); - const signal = abortController.signal; - - return new Promise((resolve, reject) => { - let bubbleEl: HTMLElement | null = null; - let wrapperEl: HTMLElement | null = null; - let accumulated = ''; - let streamStarted = false; - - chatSSERequest( - text, - (chunk: string) => { - // 直接拼接:后端每个 SSE 事件是模型一个原始 token, - // token 内的换行已由 api.ts 的 eventLines.join('\n') 还原, - // chunk 之间不能再加 \n,否则会把单词/短句拆成多行、破坏 Markdown 结构 - accumulated += chunk; - if (!streamStarted && messagesContainer) { - if (hideLoadingFn) hideLoadingFn(); - const { wrapper, bubble } = createEmptyAIBubble(messagesContainer, aiTimestamp, aiMsgId); - wrapperEl = wrapper; - bubbleEl = bubble; - streamStarted = true; - } - if (bubbleEl) { - (bubbleEl as unknown as { content: unknown[] }).content = [{ type: 'markdown', data: accumulated }]; - } - if (messagesContainer) smartScrollToBottom(); - }, - () => { - // 流结束 - // 无流内容降级为同步请求(须在 wrapperEl/bubbleEl 判断之外: - // 二者仅在 onChunk 收到首个 token 时才赋值,否则此分支不可达) - if (!streamStarted && accumulated === '') { - chatRequest(text, imageUrls).then(resolve).catch(reject); - return; - } - if (wrapperEl && bubbleEl) { - if (accumulated) { - (bubbleEl as unknown as { content: unknown[] }).content = [{ type: 'markdown', data: accumulated }]; - } - finalizeAIBubble(wrapperEl, bubbleEl); - } - resolve(accumulated); - }, - (error: CskError) => { - // 离线检测:网络不可达时展示横幅提示,下次发送成功自动清除 - if (error.type === 'network' || error.type === 'cors') { - if (messagesContainer) showOfflineBanner(messagesContainer); - } - if (accumulated.length > 0) { - if (bubbleEl) { - (bubbleEl as unknown as { content: unknown[] }).content = [{ type: 'markdown', data: accumulated + '\n\n' + t('stream_interrupted') }]; - if (wrapperEl) finalizeAIBubble(wrapperEl, bubbleEl); - } - resolve(accumulated); - } else { - reject(error); - } - }, - currentCategoryId, - shouldUseRag, - imageUrls, - signal - ); - }); +/** 流式正文和来源始终写入发起请求时创建的消息及其气泡。 */ +async function sendStreamMessage( + text: string, + aiMsg: ChatMessage, + shouldUseRag: boolean, + categoryId: number | undefined, + signal: AbortSignal, + isCurrent: () => boolean, + imageUrls?: string[] +): Promise { + let bubbleEl: HTMLElement | null = null; + let wrapperEl: HTMLElement | null = null; + let streamError: CskError | undefined; + const finalize = () => { + if (!isCurrent()) return; + if (signal.aborted) aiMsg.sources = []; + if (wrapperEl && bubbleEl) { + finalizeAIBubble(wrapperEl, bubbleEl); + renderSources(wrapperEl, aiMsg.sources || []); + } + }; + await chatSSERequest( + text, + (chunk) => { + if (!isCurrent() || signal.aborted) return; + aiMsg.content += chunk; + if (!bubbleEl && messagesContainer) { + if (hideLoadingFn) hideLoadingFn(); + const { wrapper, bubble } = createEmptyAIBubble(messagesContainer, aiMsg.timestamp, aiMsg.id); + wrapperEl = wrapper; + bubbleEl = bubble; + } + if (bubbleEl) { + (bubbleEl as unknown as { content: unknown[] }).content = [{ type: 'markdown', data: aiMsg.content }]; + } + if (messagesContainer) smartScrollToBottom(); + }, + finalize, + (error) => { + if (!isCurrent()) return; + aiMsg.sources = []; + if ((error.type === 'network' || error.type === 'cors') && messagesContainer) { + showOfflineBanner(messagesContainer); + } + if (aiMsg.content && bubbleEl) { + (bubbleEl as unknown as { content: unknown[] }).content = [{ + type: 'markdown', data: aiMsg.content + '\n\n' + t('stream_interrupted') + '\n' + error.message, + }]; + finalize(); + } else { + streamError = error; + } + }, + categoryId, + shouldUseRag, + imageUrls, + signal, + (sources: RagSource[]) => { + if (!isCurrent() || signal.aborted) return; + aiMsg.sources = sources; + if (wrapperEl) renderSources(wrapperEl, sources); + } + ); + if (streamError) throw streamError; } /** @@ -714,30 +697,6 @@ export async function retryFromMessage(msgId: string): Promise { } } -/** 获取并渲染 RAG 引用来源 */ -async function fetchAndRenderSources(message: string, aiMsg: ChatMessage): Promise { - try { - const sources = await fetchRagSources(message, currentCategoryId); - if (sources.length > 0) { - const ragSources: RagSource[] = sources.map(s => ({ - documentId: s.documentId || '', - title: s.title || '', - sourceName: s.sourceName || '', - chunkIndex: s.chunkIndex ?? 0, - score: s.score ?? 0, - snippet: s.snippet || '', - })); - aiMsg.sources = ragSources; - if (messagesContainer) { - const lastAiMsg = messagesContainer.querySelector('.csk-msg--ai:last-of-type'); - if (lastAiMsg) renderSources(lastAiMsg as HTMLElement, ragSources); - } - if (config) saveMessages(config.integrateId, messages); - } - } catch (err) { - logger.warn('获取引用来源失败', err); - } -} /** 加载知识库分类到原生下拉框(选项含缩进前缀表达层级) */ async function loadCategories(): Promise { @@ -807,6 +766,10 @@ function renderHistory(): void { /** 开启新对话(生成新 chatId) */ function handleClear(): void { if (!config) return; + abortController?.abort(); + abortController = null; + isSending = false; + setSendButtonMode('send'); messages = []; if (messagesContainer) { @@ -980,6 +943,10 @@ export async function loadHistoryConversations(): Promise { */ async function switchToConversation(conversationId: string): Promise { if (!config || !messagesContainer) return; + abortController?.abort(); + abortController = null; + isSending = false; + setSendButtonMode('send'); // 找到该会话对应的历史条目,获取其所属角色 const historyItem = historyItems.find( diff --git a/client/src/config.ts b/client/src/config.ts index bdbfbd2..ccddeab 100644 --- a/client/src/config.ts +++ b/client/src/config.ts @@ -84,7 +84,7 @@ export function parseConfig(raw: SDKConfig): ResolvedConfig | null { watermark: (typeof raw.watermark === 'string' && raw.watermark.trim()) || undefined, streaming: raw.streaming ?? true, enableRag: raw.enableRag ?? true, - rewriteStrategy: raw.rewriteStrategy || 'REWRITE', + rewriteStrategy: raw.rewriteStrategy || 'NONE', locale: raw.locale || 'zh-CN', debug: raw.debug ?? true, allowImageUpload: raw.allowImageUpload ?? true, diff --git a/client/src/dom.ts b/client/src/dom.ts index 5a73cc2..52c9314 100644 --- a/client/src/dom.ts +++ b/client/src/dom.ts @@ -1016,8 +1016,8 @@ export function renderSources(wrapper: HTMLElement, sources: RagSource[]): void meta.className = 'csk-source-item__meta'; const metaParts: string[] = []; if (src.sourceName) metaParts.push(src.sourceName); - if (src.chunkIndex !== undefined) metaParts.push(`分块 #${src.chunkIndex}`); - if (src.score !== undefined) metaParts.push(`相关度 ${(src.score * 100).toFixed(0)}%`); + if (src.chunkIndex != null) metaParts.push(`分块 #${src.chunkIndex}`); + if (src.score != null) metaParts.push(`相关度 ${(src.score * 100).toFixed(0)}%`); meta.textContent = metaParts.join(' · '); item.appendChild(name); diff --git a/client/src/types.ts b/client/src/types.ts index 6fc6d1f..2f1588d 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -76,7 +76,7 @@ export interface SDKConfig { * 如果角色未绑定知识库,后端自动降级为普通对话,不会报错。 */ enableRag?: boolean; - /** RAG 查询重写策略,默认 "REWRITE"(在 enableRag=true 时生效) */ + /** RAG 查询重写策略,默认 "NONE"(显式启用其他策略时才调用重写模型) */ rewriteStrategy?: 'NONE' | 'REWRITE' | 'TRANSLATION' | 'COMPRESSION' | 'MULTI_QUERY'; /** 界面语言,默认 "zh-CN" */ locale?: string; @@ -212,12 +212,20 @@ export interface ChatMessage { /** RAG 引用来源 */ export interface RagSource { - documentId: string; - title: string; - sourceName: string; - chunkIndex: number; - score: number; - snippet: string; + documentId: string | null; + title: string | null; + sourceName: string | null; + chunkIndex: number | null; + score: number | null; + snippet: string | null; +} + +/** 同一次答案生成返回的正文与引用;无需额外检索。 */ +export interface ChatResult { + text: string; + mcpEvents: unknown[]; + suggestions: string[]; + sources: RagSource[]; } /** 知识库分类节点(树形结构) */ diff --git a/client/tests/api.test.ts b/client/tests/api.test.ts new file mode 100644 index 0000000..92009e8 --- /dev/null +++ b/client/tests/api.test.ts @@ -0,0 +1,123 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { chatRequest, chatSSERequest, clearApiConfig, setApiConfig } from '../src/api'; +import { parseConfig } from '../src/config'; +import type { RagSource } from '../src/types'; + +const source: RagSource = { + documentId: '1234567890123456789', title: 'manual', sourceName: null, + chunkIndex: 0, score: null, snippet: '实际命中的文档', +}; +const encoder = new TextEncoder(); +const chunk = (content: string) => `data: ${JSON.stringify({ choices: [{ delta: { content } }] })}\r\n\r\n`; +const metadata = `data: ${JSON.stringify({ object: 'chat.completion.chunk', choices: [], sources: [source] })}\r\n\r\n`; + +function response(parts: Uint8Array[], close = true) { + const cancel = vi.fn(); + const stream = new ReadableStream({ + start(controller) { + parts.forEach(part => controller.enqueue(part)); + if (close) controller.close(); + }, + cancel, + }); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(stream))); + return { stream, cancel }; +} +function receive(signal?: AbortSignal) { + const onChunk = vi.fn(); + const onDone = vi.fn(); + const onError = vi.fn(); + const onSources = vi.fn(); + const pending = chatSSERequest('问题', onChunk, onDone, onError, 7, true, undefined, signal, onSources); + return { pending, onChunk, onDone, onError, onSources }; +} + +beforeEach(() => { + const config = parseConfig({ integrateId: '42', requestDomain: 'https://example.test', userId: 'user' })!; + config.chatId = 'conversation'; + setApiConfig(config); +}); +afterEach(() => { clearApiConfig(); vi.unstubAllGlobals(); }); + +describe('same-request answer transport', () => { + it('stops on DONE without EOF, preserving split UTF-8, CRLF and sources outside text', async () => { + const bytes = encoder.encode(chunk('中文 ') + metadata + 'data: [DONE]\r\n\r\n' + chunk('不得出现')); + const { stream, cancel } = response(Array.from(bytes, byte => Uint8Array.of(byte)), false); + const result = receive(); + await result.pending; + expect(result.onChunk.mock.calls.flat().join('')).toBe('中文 '); + expect(result.onSources).toHaveBeenCalledExactlyOnceWith([source]); + expect(result.onDone).toHaveBeenCalledTimes(1); + expect(result.onError).not.toHaveBeenCalled(); + expect(cancel).toHaveBeenCalledTimes(1); + expect(stream.locked).toBe(false); + expect(fetch).toHaveBeenCalledTimes(1); + const url = new URL(vi.mocked(fetch).mock.calls[0]![0] as string); + expect(url.pathname).toBe('/ai/chat/stream'); + expect(url.searchParams.get('rewriteStrategy')).toBe('NONE'); + }); + + it('keeps multiline data whitespace and flushes the last unterminated event at EOF', async () => { + response([encoder.encode('event: status\ndata: 不显示\n\ndata: first \r\ndata: second\r\n\r\ndata: last ')]); + const result = receive(); + await result.pending; + expect(result.onChunk.mock.calls.flat()).toEqual([' first \nsecond', 'last ']); + expect(result.onDone).toHaveBeenCalledTimes(1); + }); + + it('recognizes EOF remainder DONE rather than rendering it', async () => { + response([encoder.encode(chunk('answer') + 'data: [DONE]')]); + const result = receive(); + await result.pending; + expect(result.onChunk).toHaveBeenCalledExactlyOnceWith('answer'); + expect(result.onDone).toHaveBeenCalledTimes(1); + }); + + it('delivers original server error once even when reader cleanup rejects', async () => { + const { cancel, stream } = response([encoder.encode('data: {"error":{"message":"权限不足"}}\n\n')], false); + cancel.mockRejectedValue(new Error('cleanup failed')); + const result = receive(); + await result.pending; + expect(result.onError).toHaveBeenCalledTimes(1); + expect(result.onError.mock.calls[0]![0].message).toBe('权限不足'); + expect(result.onDone).not.toHaveBeenCalled(); + expect(stream.locked).toBe(false); + }); + + it('cancels a pending read once without consuming later metadata', async () => { + const { stream } = response([], false); + const controller = new AbortController(); + const result = receive(controller.signal); + await Promise.resolve(); + await Promise.resolve(); + controller.abort(); + await result.pending; + expect(result.onDone).toHaveBeenCalledTimes(1); + expect(result.onError).not.toHaveBeenCalled(); + expect(result.onSources).not.toHaveBeenCalled(); + expect(stream.locked).toBe(false); + }); + + it('uses one synchronous JSON request with the same RAG, role and category parameters', async () => { + const answer = { text: 'answer', mcpEvents: [], suggestions: [], sources: [source] }; + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(Response.json(answer))); + expect(await chatRequest('问题', ['https://example.test/photo.png'], 7, true)).toEqual(answer); + expect(fetch).toHaveBeenCalledTimes(1); + const url = new URL(vi.mocked(fetch).mock.calls[0]![0] as string); + expect(url.pathname).toBe('/ai/chat/result'); + expect(Object.fromEntries(url.searchParams)).toMatchObject({ + roleId: '42', accountId: 'user', chatId: 'conversation', categoryId: '7', enableRag: 'true', rewriteStrategy: 'NONE', + }); + }); + + it('retains explicit rewrite configuration and disabled RAG', async () => { + const config = parseConfig({ integrateId: '42', requestDomain: 'https://example.test', rewriteStrategy: 'MULTI_QUERY' })!; + setApiConfig(config); + vi.stubGlobal('fetch', vi.fn().mockImplementation(() => Promise.resolve(Response.json({ text: '', mcpEvents: [], suggestions: [], sources: [] })))); + await chatRequest('问题', undefined, undefined, true); + await chatRequest('问题', undefined, undefined, false); + const urls = vi.mocked(fetch).mock.calls.map(([url]) => new URL(url as string)); + expect(urls[0]!.searchParams.get('rewriteStrategy')).toBe('MULTI_QUERY'); + expect(urls[1]!.searchParams.get('enableRag')).toBe('false'); + }); +}); diff --git a/client/tests/chat.test.ts b/client/tests/chat.test.ts new file mode 100644 index 0000000..4b2a6e6 --- /dev/null +++ b/client/tests/chat.test.ts @@ -0,0 +1,118 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { initChat, sendQuickReply } from '../src/chat'; +import { setApiConfig, clearApiConfig } from '../src/api'; +import { parseConfig } from '../src/config'; +import { renderAIBubble, createEmptyAIBubble, renderSources } from '../src/dom'; +import { saveMessages } from '../src/storage'; + +vi.mock('../src/dom', () => ({ + renderUserBubble: vi.fn(), renderAIBubble: vi.fn(() => ({ id: 'sync-wrapper' })), + createEmptyAIBubble: vi.fn(() => ({ wrapper: { id: 'stream-wrapper' }, bubble: {} })), + renderSources: vi.fn(), finalizeAIBubble: vi.fn(), scrollToBottom: vi.fn(), + hideOfflineBanner: vi.fn(), showOfflineBanner: vi.fn(), renderErrorBubble: vi.fn(), +})); +vi.mock('../src/storage', () => ({ saveMessages: vi.fn(), clearMessages: vi.fn() })); + +class ElementStub extends EventTarget { + style: Record = {}; + classList = { add: vi.fn(), remove: vi.fn() }; + setAttribute() {} + removeAttribute() {} + querySelector() { return null; } + querySelectorAll() { return []; } +} +const encoder = new TextEncoder(); +const source = { documentId: '1234567890123456789', title: 'same answer', sourceName: null, chunkIndex: null, score: null, snippet: null }; +const event = (value: unknown) => `data: ${JSON.stringify(value)}\n\n`; +const answer = event({ choices: [{ delta: { content: 'answer' } }] }); +const sources = event({ choices: [], sources: [source] }); +let clearButton: ElementStub; +let sender: ElementStub; + +function setup(streaming: boolean) { + const config = parseConfig({ integrateId: '42', requestDomain: 'https://example.test', streaming, suggestions: false })!; + config.chatId = 'conversation'; + setApiConfig(config); + clearButton = new ElementStub(); + sender = new ElementStub(); + initChat(config, { + messagesContainer: new ElementStub(), inputEl: sender, clearBtn: clearButton, + categorySelect: null, roleSelect: null, historyPanel: new ElementStub(), + welcomeEl: new ElementStub(), newMsgBtn: new ElementStub(), searchInput: null, + ariaLiveEl: new ElementStub(), showLoading: vi.fn(), hideLoading: vi.fn(), + } as unknown as Parameters[1]); + clearButton.dispatchEvent(new Event('click')); + vi.clearAllMocks(); +} +function stream(body: string) { + return new Response(new ReadableStream({ start(c) { c.enqueue(encoder.encode(body)); c.close(); } })); +} + +beforeEach(() => { vi.clearAllMocks(); }); +afterEach(() => { clearApiConfig(); vi.unstubAllGlobals(); }); + +describe('SDK message references belong to the producing request', () => { + it.each([true, false])('makes one answer request and persists/renders its sources (streaming=%s)', async streaming => { + setup(streaming); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(streaming + ? stream(answer + sources + 'data: [DONE]\n\n') + : Response.json({ text: 'answer', mcpEvents: [], suggestions: [], sources: [source] }))); + await sendQuickReply('question'); + expect(fetch).toHaveBeenCalledTimes(1); + expect(new URL(vi.mocked(fetch).mock.calls[0]![0] as string).pathname).toBe(streaming ? '/ai/chat/stream' : '/ai/chat/result'); + const saved = vi.mocked(saveMessages).mock.calls.at(-1)![1]; + expect(saved.at(-1)).toMatchObject({ role: 'ai', content: 'answer', sources: [source] }); + const wrapper = streaming + ? vi.mocked(createEmptyAIBubble).mock.results[0]!.value.wrapper + : vi.mocked(renderAIBubble).mock.results[0]!.value; + expect(renderSources).toHaveBeenLastCalledWith(wrapper, [source]); + }); + + it('does not turn an empty completed stream into a second answer request', async () => { + setup(true); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(stream('data: [DONE]\n\n'))); + await sendQuickReply('question'); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('ignores a delayed synchronous answer after a new conversation begins', async () => { + setup(false); + let reply!: (response: Response) => void; + vi.stubGlobal('fetch', vi.fn(() => new Promise(resolve => { reply = resolve; }))); + const pending = sendQuickReply('old question'); + clearButton.dispatchEvent(new Event('click')); + reply(Response.json({ text: 'old answer', mcpEvents: [], suggestions: [], sources: [source] })); + await pending; + expect(saveMessages).not.toHaveBeenCalled(); + expect(renderSources).not.toHaveBeenCalled(); + expect(renderAIBubble).not.toHaveBeenCalled(); + }); + + it('cancels in-flight old streams without adding their sources to a new conversation', async () => { + setup(true); + const cancel = vi.fn(); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(new ReadableStream({ + start(controller) { controller.enqueue(encoder.encode(answer)); }, cancel, + })))); + const pending = sendQuickReply('old question'); + await vi.waitFor(() => expect(createEmptyAIBubble).toHaveBeenCalledTimes(1)); + clearButton.dispatchEvent(new Event('click')); + await pending; + expect(cancel).toHaveBeenCalledTimes(1); + expect(saveMessages).not.toHaveBeenCalled(); + expect(renderSources).not.toHaveBeenCalled(); + }); + + it('retains partial text but removes references on user cancellation', async () => { + setup(true); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(new ReadableStream({ + start(controller) { controller.enqueue(encoder.encode(answer + sources)); }, + })))); + const pending = sendQuickReply('question'); + await vi.waitFor(() => expect(renderSources).toHaveBeenCalled()); + sender.dispatchEvent(new Event('stop')); + await pending; + expect(vi.mocked(saveMessages).mock.calls.at(-1)![1].at(-1)).toMatchObject({ content: 'answer', sources: [] }); + expect(renderSources).toHaveBeenLastCalledWith(expect.anything(), []); + }); +}); diff --git a/client/tests/config.test.ts b/client/tests/config.test.ts index c888645..77b2a72 100644 --- a/client/tests/config.test.ts +++ b/client/tests/config.test.ts @@ -75,6 +75,14 @@ describe('parseConfig - 默认值填充', () => { expect(result!.streaming).toBe(true); }); + it('默认不执行查询重写,显式策略和同步选项保持不变', () => { + expect(parseConfig(validConfig)!.rewriteStrategy).toBe('NONE'); + for (const rewriteStrategy of ['REWRITE', 'TRANSLATION', 'COMPRESSION', 'MULTI_QUERY'] as const) { + expect(parseConfig({ ...validConfig, rewriteStrategy })!.rewriteStrategy).toBe(rewriteStrategy); + } + expect(parseConfig({ ...validConfig, streaming: false })!.streaming).toBe(false); + }); + it('debug 默认 true', () => { const result = parseConfig(validConfig); expect(result!.debug).toBe(true);