Browse Source

feat(sdk): 同步与流式对话复用同次引用来源

feature/test
wei-py 3 weeks ago
parent
commit
5086dd16f4
  1. 288
      client/src/api.ts
  2. 209
      client/src/chat.ts
  3. 2
      client/src/config.ts
  4. 4
      client/src/dom.ts
  5. 22
      client/src/types.ts
  6. 123
      client/tests/api.test.ts
  7. 118
      client/tests/chat.test.ts
  8. 8
      client/tests/config.test.ts

288
client/src/api.ts

@ -6,7 +6,7 @@
* userId → accountId(客户账号 ID) * userId → accountId(客户账号 ID)
* chatId → 自动管理的对话 ID(从 /ai/sdk/conversation/list 获取或自动生成) * 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 { logger } from './logger';
import { t } from './i18n'; 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(); const params = new URLSearchParams();
params.set('message', message); params.set('message', message);
params.set('chatId', currentConfig!.chatId); params.set('chatId', currentConfig!.chatId);
@ -113,31 +91,15 @@ function buildChatSSEUrl(message: string, categoryId?: number, useRag?: boolean,
setIfPresent(params, 'accountId', currentConfig!.userId); setIfPresent(params, 'accountId', currentConfig!.userId);
setIfPresent(params, 'categoryId', categoryId ?? currentConfig!.categoryId); 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); 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 基础封装 ==================== // ==================== HTTP 基础封装 ====================
@ -235,21 +197,21 @@ function getHttpErrorMessage(status: number): string {
/** /**
* 同步对话请求 * 同步对话请求
*/ */
export async function chatRequest(message: string, imageUrls?: string[]): Promise<string> {
const url = buildChatUrl(message, imageUrls);
export async function chatRequest(message: string, imageUrls?: string[], categoryId?: number, useRag?: boolean, signal?: AbortSignal): Promise<ChatResult> {
const url = buildChatUrl('/ai/chat/result', message, categoryId, useRag, imageUrls);
logger.lifecycleSend(getActiveIntegrateId(), message.length); logger.lifecycleSend(getActiveIntegrateId(), message.length);
try { try {
const response = await safeFetch(url);
const response = await safeFetch(url, {}, REQUEST_TIMEOUT, signal);
if (!response.ok) { if (!response.ok) {
const errorMsg = getHttpErrorMessage(response.status); const errorMsg = getHttpErrorMessage(response.status);
logger.lifecycleError(getActiveIntegrateId(), String(response.status), errorMsg); logger.lifecycleError(getActiveIntegrateId(), String(response.status), errorMsg);
throw new CskError(errorMsg, `http_${response.status}`); 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) { } catch (err) {
if (err instanceof CskError) throw err; if (err instanceof CskError) throw err;
logger.lifecycleError(getActiveIntegrateId(), 'unknown', String(err)); logger.lifecycleError(getActiveIntegrateId(), 'unknown', String(err));
@ -270,194 +232,131 @@ export async function chatSSERequest(
categoryId?: number, categoryId?: number,
useRag?: boolean, useRag?: boolean,
imageUrls?: string[], imageUrls?: string[],
signal?: AbortSignal
signal?: AbortSignal,
onSources?: (sources: RagSource[]) => void
): Promise<void> { ): Promise<void> {
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 = ''; 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 => { const emitRawChunk = (raw: string): void => {
// 尝试按 OpenAI Chat Completions JSON 解析,解析失败则视为纯文本
let obj: unknown = null;
let obj: unknown;
try { try {
obj = JSON.parse(raw); obj = JSON.parse(raw);
} catch { } catch {
obj = null; obj = null;
} }
if (obj && typeof obj === 'object') { if (obj && typeof obj === 'object') {
const record = obj as Record<string, unknown>; const record = obj as Record<string, unknown>;
// OpenAI 错误 chunk:{ error: { message, type } }
if (record.error) { if (record.error) {
const err = record.error as { message?: string; type?: string }; 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.choices)) {
if (Array.isArray(record.sources)) onSources?.(record.sources as RagSource[]);
const first = (record.choices as Array<{ delta?: { content?: unknown } }>)[0]; const first = (record.choices as Array<{ delta?: { content?: unknown } }>)[0];
const content = first?.delta?.content; const content = first?.delta?.content;
if (typeof content === 'string' && content.length > 0) { if (typeof content === 'string' && content.length > 0) {
totalText += content; totalText += content;
onChunk(content); onChunk(content);
} }
// role / finish_reason 等空 chunk 直接跳过
return; return;
} }
} }
// 纯文本回退:整体作为文本追加
totalText += raw; totalText += raw;
onChunk(raw); onChunk(raw);
}; };
logger.lifecycleSend(getActiveIntegrateId(), message.length); logger.lifecycleSend(getActiveIntegrateId(), message.length);
try { try {
const response = await safeFetch(url, {}, REQUEST_TIMEOUT * 2, signal); const response = await safeFetch(url, {}, REQUEST_TIMEOUT * 2, signal);
if (!response.ok) { 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(); 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 buffer = '';
let eventLines: string[] = []; let eventLines: string[] = [];
/** 当前 SSE 事件类型(event:xxx 行声明的类型),空=默认 message 事件 */
let currentEventType = ''; let currentEventType = '';
try {
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 (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') {
let stopped = false;
const flushEvent = () => {
if (eventLines.length && currentEventType !== 'status' && currentEventType !== 'tool_call_result') {
emitRawChunk(eventLines.join('\n')); emitRawChunk(eventLines.join('\n'));
} }
eventLines = []; eventLines = [];
}
currentEventType = ''; 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 = [];
};
const consumeLine = (raw: string) => {
const line = raw.endsWith('\r') ? raw.slice(0, -1) : raw;
if (line === '') {
flushEvent();
return;
} }
currentEventType = '';
break;
if (line.startsWith(':')) return;
if (line.startsWith('event:')) {
currentEventType = line.slice(6).trim();
return;
} }
// SSE 协议字段:event / id / retry
if (trimmed.startsWith('event:')) {
// 记录当前事件类型,用于决定是否将后续 data 行传递给 AI 渲染
currentEventType = trimmed.substring('event:'.length).trim();
continue;
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 (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 (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);
}
flushEvent();
stopped = true;
return;
} }
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); eventLines.push(content);
} else if (trimmed !== '[DONE]' && !trimmed.startsWith('event:') && !trimmed.startsWith('id:') && !trimmed.startsWith('retry:')) {
eventLines.push(trimmed);
}
}
}
// 处理缓冲区剩余
if (eventLines.length > 0 && currentEventType !== 'status' && currentEventType !== 'tool_call_result') {
emitRawChunk(eventLines.join('\n'));
eventLines = [];
};
// 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 (!stopped && !signal?.aborted) {
const { done, value } = await reader.read();
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 (done) {
if (!stopped && buffer) consumeLine(buffer);
if (!stopped) flushEvent();
break;
} }
} 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 { } finally {
reader.releaseLock();
signal?.removeEventListener('abort', cancelReader);
cancelReader();
try { reader.releaseLock(); } catch { /* preserve original outcome */ }
} }
logger.lifecycleStreamDone(getActiveIntegrateId(), totalText.length); logger.lifecycleStreamDone(getActiveIntegrateId(), totalText.length);
onDone();
finish();
} catch (err) { } catch (err) {
// 用户主动中断不触发 onError,走 onDone
if (signal?.aborted || (err instanceof CskError && err.type === 'aborted')) { if (signal?.aborted || (err instanceof CskError && err.type === 'aborted')) {
onDone();
finish();
return; return;
} }
if (completed) throw err;
if (err instanceof CskError) { if (err instanceof CskError) {
onError(err); onError(err);
} else { } else {
logger.lifecycleError(getActiveIntegrateId(), 'unknown', String(err)); 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<CategoryNode[]> {
} }
} }
// ==================== 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<RagSourceData[]> {
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<RagSourceData[]> = 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: 消息反馈 ==================== // ==================== P0-002: 消息反馈 ====================

209
client/src/chat.ts

@ -11,7 +11,6 @@ import {
chatRequest, chatRequest,
chatSSERequest, chatSSERequest,
fetchCategoryTree, fetchCategoryTree,
fetchRagSources,
fetchConversationList, fetchConversationList,
fetchConversationMessages, fetchConversationMessages,
deleteConversation, deleteConversation,
@ -511,145 +510,129 @@ async function produceAIReply(userText: string, images?: ImageAttachment[]): Pro
isSending = true; isSending = true;
setSendButtonMode('stop'); 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 imageUrls = (images || []).map(img => img.url);
const aiTimestamp = now();
// RAG 启用条件:由 enableRag 控制
const shouldUseRag = useRag; const shouldUseRag = useRag;
const categoryId = currentCategoryId;
// 显示 loading
try {
if (!requestConfig.chatId) await initChatId();
if (!isCurrent() || controller.signal.aborted) return false;
if (showLoadingFn) showLoadingFn(); if (showLoadingFn) showLoadingFn();
if (messagesContainer) smartScrollToBottom();
smartScrollToBottom();
const aiMsgId = uuid();
let aiContent = '';
try {
if (config.streaming) {
aiContent = await sendStreamMessage(userText, aiTimestamp, shouldUseRag, aiMsgId, imageUrls);
if (requestConfig.streaming) {
await sendStreamMessage(userText, aiMsg, shouldUseRag, categoryId, controller.signal, isCurrent, imageUrls);
} else { } 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 (hideLoadingFn) hideLoadingFn();
if (messagesContainer) { 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); messages.push(aiMsg);
saveMessages(config.integrateId, messages);
saveMessages(requestRoleId, messages);
if (messagesContainer) smartScrollToBottom(); if (messagesContainer) smartScrollToBottom();
// a11y 播报新 AI 消息
announceMessage(aiContent);
// 通知 launcher 显示未读徽章(弹窗关闭时生效,由 index.ts 监听)
announceMessage(aiMsg.content);
if (messagesContainer) { if (messagesContainer) {
messagesContainer.dispatchEvent(new CustomEvent('csk:newMessage', { bubbles: true, detail: { msg: aiMsg } })); 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(); hideOfflineBanner();
return true; return true;
} catch (err) { } 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; return false;
} finally { } finally {
if (abortController === controller) {
if (hideLoadingFn) hideLoadingFn();
isSending = false; isSending = false;
abortController = null; abortController = null;
setSendButtonMode('send'); setSendButtonMode('send');
} }
} }
}
/** 流式发送消息 */
async function sendStreamMessage(text: string, aiTimestamp: number, shouldUseRag: boolean, aiMsgId: string, imageUrls?: string[]): Promise<string> {
// 创建中断控制器,供"停止生成"使用
abortController = new AbortController();
const signal = abortController.signal;
return new Promise((resolve, reject) => {
/** 流式正文和来源始终写入发起请求时创建的消息及其气泡。 */
async function sendStreamMessage(
text: string,
aiMsg: ChatMessage,
shouldUseRag: boolean,
categoryId: number | undefined,
signal: AbortSignal,
isCurrent: () => boolean,
imageUrls?: string[]
): Promise<void> {
let bubbleEl: HTMLElement | null = null; let bubbleEl: HTMLElement | null = null;
let wrapperEl: HTMLElement | null = null; let wrapperEl: HTMLElement | null = null;
let accumulated = '';
let streamStarted = false;
chatSSERequest(
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, text,
(chunk: string) => {
// 直接拼接:后端每个 SSE 事件是模型一个原始 token,
// token 内的换行已由 api.ts 的 eventLines.join('\n') 还原,
// chunk 之间不能再加 \n,否则会把单词/短句拆成多行、破坏 Markdown 结构
accumulated += chunk;
if (!streamStarted && messagesContainer) {
(chunk) => {
if (!isCurrent() || signal.aborted) return;
aiMsg.content += chunk;
if (!bubbleEl && messagesContainer) {
if (hideLoadingFn) hideLoadingFn(); if (hideLoadingFn) hideLoadingFn();
const { wrapper, bubble } = createEmptyAIBubble(messagesContainer, aiTimestamp, aiMsgId);
const { wrapper, bubble } = createEmptyAIBubble(messagesContainer, aiMsg.timestamp, aiMsg.id);
wrapperEl = wrapper; wrapperEl = wrapper;
bubbleEl = bubble; bubbleEl = bubble;
streamStarted = true;
} }
if (bubbleEl) { if (bubbleEl) {
(bubbleEl as unknown as { content: unknown[] }).content = [{ type: 'markdown', data: accumulated }];
(bubbleEl as unknown as { content: unknown[] }).content = [{ type: 'markdown', data: aiMsg.content }];
} }
if (messagesContainer) smartScrollToBottom(); 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);
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 { } else {
reject(error);
streamError = error;
} }
}, },
currentCategoryId,
categoryId,
shouldUseRag, shouldUseRag,
imageUrls, imageUrls,
signal
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<void> {
} }
} }
/** 获取并渲染 RAG 引用来源 */
async function fetchAndRenderSources(message: string, aiMsg: ChatMessage): Promise<void> {
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<void> { async function loadCategories(): Promise<void> {
@ -807,6 +766,10 @@ function renderHistory(): void {
/** 开启新对话(生成新 chatId) */ /** 开启新对话(生成新 chatId) */
function handleClear(): void { function handleClear(): void {
if (!config) return; if (!config) return;
abortController?.abort();
abortController = null;
isSending = false;
setSendButtonMode('send');
messages = []; messages = [];
if (messagesContainer) { if (messagesContainer) {
@ -980,6 +943,10 @@ export async function loadHistoryConversations(): Promise<void> {
*/ */
async function switchToConversation(conversationId: string): Promise<void> { async function switchToConversation(conversationId: string): Promise<void> {
if (!config || !messagesContainer) return; if (!config || !messagesContainer) return;
abortController?.abort();
abortController = null;
isSending = false;
setSendButtonMode('send');
// 找到该会话对应的历史条目,获取其所属角色 // 找到该会话对应的历史条目,获取其所属角色
const historyItem = historyItems.find( const historyItem = historyItems.find(

2
client/src/config.ts

@ -84,7 +84,7 @@ export function parseConfig(raw: SDKConfig): ResolvedConfig | null {
watermark: (typeof raw.watermark === 'string' && raw.watermark.trim()) || undefined, watermark: (typeof raw.watermark === 'string' && raw.watermark.trim()) || undefined,
streaming: raw.streaming ?? true, streaming: raw.streaming ?? true,
enableRag: raw.enableRag ?? true, enableRag: raw.enableRag ?? true,
rewriteStrategy: raw.rewriteStrategy || 'REWRITE',
rewriteStrategy: raw.rewriteStrategy || 'NONE',
locale: raw.locale || 'zh-CN', locale: raw.locale || 'zh-CN',
debug: raw.debug ?? true, debug: raw.debug ?? true,
allowImageUpload: raw.allowImageUpload ?? true, allowImageUpload: raw.allowImageUpload ?? true,

4
client/src/dom.ts

@ -1016,8 +1016,8 @@ export function renderSources(wrapper: HTMLElement, sources: RagSource[]): void
meta.className = 'csk-source-item__meta'; meta.className = 'csk-source-item__meta';
const metaParts: string[] = []; const metaParts: string[] = [];
if (src.sourceName) metaParts.push(src.sourceName); 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(' · '); meta.textContent = metaParts.join(' · ');
item.appendChild(name); item.appendChild(name);

22
client/src/types.ts

@ -76,7 +76,7 @@ export interface SDKConfig {
* 如果角色未绑定知识库,后端自动降级为普通对话,不会报错。 * 如果角色未绑定知识库,后端自动降级为普通对话,不会报错。
*/ */
enableRag?: boolean; enableRag?: boolean;
/** RAG 查询重写策略,默认 "REWRITE"(在 enableRag=true 时生效) */
/** RAG 查询重写策略,默认 "NONE"(显式启用其他策略时才调用重写模型) */
rewriteStrategy?: 'NONE' | 'REWRITE' | 'TRANSLATION' | 'COMPRESSION' | 'MULTI_QUERY'; rewriteStrategy?: 'NONE' | 'REWRITE' | 'TRANSLATION' | 'COMPRESSION' | 'MULTI_QUERY';
/** 界面语言,默认 "zh-CN" */ /** 界面语言,默认 "zh-CN" */
locale?: string; locale?: string;
@ -212,12 +212,20 @@ export interface ChatMessage {
/** RAG 引用来源 */ /** RAG 引用来源 */
export interface RagSource { 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[];
} }
/** 知识库分类节点(树形结构) */ /** 知识库分类节点(树形结构) */

123
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<Uint8Array>({
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');
});
});

118
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<string, string> = {};
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<typeof initChat>[1]);
clearButton.dispatchEvent(new Event('click'));
vi.clearAllMocks();
}
function stream(body: string) {
return new Response(new ReadableStream<Uint8Array>({ 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<Response>(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<Uint8Array>({
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<Uint8Array>({
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(), []);
});
});

8
client/tests/config.test.ts

@ -75,6 +75,14 @@ describe('parseConfig - 默认值填充', () => {
expect(result!.streaming).toBe(true); 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', () => { it('debug 默认 true', () => {
const result = parseConfig(validConfig); const result = parseConfig(validConfig);
expect(result!.debug).toBe(true); expect(result!.debug).toBe(true);

Loading…
Cancel
Save