diff --git a/client/src/api.ts b/client/src/api.ts index ca84c86..adb7743 100644 --- a/client/src/api.ts +++ b/client/src/api.ts @@ -88,13 +88,13 @@ function buildChatUrl(message: string): string { // userId 映射为 accountId setIfPresent(params, 'accountId', currentConfig!.userId); - return buildUrl(`/ai/assistant_app/chat/sync?${params.toString()}`); + return buildUrl(`/ai/chat?${params.toString()}`); } /** * 构建 SSE 流式请求 URL */ -function buildChatSSEUrl(message: string, categoryId?: number): string { +function buildChatSSEUrl(message: string, categoryId?: number, useRag?: boolean): string { const params = new URLSearchParams(); params.set('message', message); params.set('chatId', currentConfig!.chatId); @@ -103,23 +103,13 @@ function buildChatSSEUrl(message: string, categoryId?: number): string { setIfPresent(params, 'accountId', currentConfig!.userId); setIfPresent(params, 'categoryId', categoryId ?? currentConfig!.categoryId); - return buildUrl(`/ai/assistant_app/chat/sse?${params.toString()}`); -} - -/** - * 构建 RAG 增强流式请求 URL - */ -function buildChatRAGSSEUrl(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); + // RAG 增强:追加 enableRag=true 与查询重写策略,后端按角色绑定的知识库分类自动检索 + if (useRag) { + params.set('enableRag', 'true'); + params.set('rewriteStrategy', currentConfig!.rewriteStrategy || 'REWRITE'); + } - return buildUrl(`/ai/assistant_app/chat/rag/sse?${params.toString()}`); + return buildUrl(`/ai/chat/stream?${params.toString()}`); } /** @@ -135,7 +125,7 @@ function buildRagSourcesUrl(message: string, categoryId?: number): string { setIfPresent(params, 'accountId', currentConfig!.userId); setIfPresent(params, 'categoryId', categoryId ?? currentConfig!.categoryId); - return buildUrl(`/ai/assistant_app/rag/sources?${params.toString()}`); + return buildUrl(`/ai/chat/sources?${params.toString()}`); } // ==================== HTTP 基础封装 ==================== @@ -270,10 +260,52 @@ export async function chatSSERequest( signal?: AbortSignal ): Promise { const url = useRag - ? buildChatRAGSSEUrl(message, categoryId) - : buildChatSSEUrl(message, categoryId); + ? buildChatSSEUrl(message, categoryId, true) + : buildChatSSEUrl(message, categoryId, false); let totalText = ''; + /** + * 处理单个 SSE 原始块:优先按 OpenAI Chat Completions JSON 解析, + * 解析失败则回退为纯文本整体追加。 + * @param raw 累积的原始事件内容(eventLines 拼接结果) + */ + const emitRawChunk = (raw: string): void => { + // 尝试按 OpenAI Chat Completions JSON 解析,解析失败则视为纯文本 + let obj: unknown = null; + 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; + } + + // OpenAI Chat Completions chunk:{ choices: [{ delta: { content } }] } + if (Array.isArray(record.choices)) { + 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 { @@ -315,9 +347,7 @@ export async function chatSSERequest( if (eventLines.length > 0) { // 非内容事件(status 等)不回调 onChunk,避免污染 AI 回复渲染 if (currentEventType !== 'status' && currentEventType !== 'tool_call_result') { - const chunk = eventLines.join('\n'); - totalText += chunk; - onChunk(chunk); + emitRawChunk(eventLines.join('\n')); } eventLines = []; } @@ -334,9 +364,7 @@ export async function chatSSERequest( // [DONE] 信号(结束整次 SSE 流) if (trimmed === '[DONE]') { if (eventLines.length > 0 && currentEventType !== 'status' && currentEventType !== 'tool_call_result') { - const chunk = eventLines.join('\n'); - totalText += chunk; - onChunk(chunk); + emitRawChunk(eventLines.join('\n')); eventLines = []; } currentEventType = ''; @@ -357,6 +385,15 @@ export async function chatSSERequest( 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; } @@ -379,9 +416,7 @@ export async function chatSSERequest( } // 处理缓冲区剩余 if (eventLines.length > 0 && currentEventType !== 'status' && currentEventType !== 'tool_call_result') { - const chunk = eventLines.join('\n'); - totalText += chunk; - onChunk(chunk); + emitRawChunk(eventLines.join('\n')); eventLines = []; } } catch (readErr: unknown) { diff --git a/client/src/types.ts b/client/src/types.ts index a2fde0c..9c86ae2 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -72,7 +72,7 @@ export interface SDKConfig { streaming?: boolean; /** * 是否启用 RAG 知识库检索对话,默认 true。 - * 开启后对话将使用 /chat/rag/sse 接口,后端会根据角色绑定的知识库分类自动检索。 + * 开启后对话将在 /ai/chat/stream 上追加 enableRag=true,后端按角色绑定的知识库分类自动检索。 * 如果角色未绑定知识库,后端自动降级为普通对话,不会报错。 */ enableRag?: boolean; diff --git a/frontend/src/api/chat.ts b/frontend/src/api/chat.ts index 27c3bee..8628d53 100644 --- a/frontend/src/api/chat.ts +++ b/frontend/src/api/chat.ts @@ -12,43 +12,57 @@ function authHeaders(): Record { return headers } -/** 同步对话 */ -export function chatSync(message: string, chatId: string, roleId?: string, accountId?: string): Promise { - let url = `${API_BASE}/ai/assistant_app/chat/sync?message=${encodeURIComponent(message)}&chatId=${encodeURIComponent(chatId)}` - if (roleId) url += `&roleId=${encodeURIComponent(roleId)}` - if (accountId) url += `&accountId=${encodeURIComponent(accountId)}` - return fetch(url, { headers: authHeaders() }).then(res => res.text()) +/** 对话请求可选参数 */ +export interface ChatOptions { + roleId?: string + accountId?: string + enableRag?: boolean + rewriteStrategy?: string + categoryId?: string | number + categoryIds?: string[] } -/** RAG 同步对话 */ -export function chatRagSync(message: string, chatId: string, strategy: string, roleId?: string, accountId?: string): Promise { - let url = `${API_BASE}/ai/assistant_app/chat/rag/sync?message=${encodeURIComponent(message)}&chatId=${encodeURIComponent(chatId)}&rewriteStrategy=${encodeURIComponent(strategy)}` - if (roleId) url += `&roleId=${encodeURIComponent(roleId)}` - if (accountId) url += `&accountId=${encodeURIComponent(accountId)}` - return fetch(url, { headers: authHeaders() }).then(res => res.text()) +/** + * 统一构建对话请求查询参数。 + * @param message 用户消息 + * @param chatId 会话 ID + * @param options 可选参数 + * @param includeEnableRag 是否写入 enableRag 参数(sources 接口无意义,需排除) + */ +function buildChatQueryParams( + message: string, + chatId: string, + options?: ChatOptions, + includeEnableRag = true, +): URLSearchParams { + const params = new URLSearchParams() + params.set('message', message) + params.set('chatId', chatId) + if (options) { + if (options.roleId) params.set('roleId', options.roleId) + if (options.accountId) params.set('accountId', options.accountId) + if (includeEnableRag && options.enableRag === true) params.set('enableRag', 'true') + if (options.rewriteStrategy) params.set('rewriteStrategy', options.rewriteStrategy) + if (options.categoryId !== undefined && options.categoryId !== '') params.set('categoryId', String(options.categoryId)) + if (options.categoryIds && options.categoryIds.length > 0) params.set('categoryIds', options.categoryIds.join(',')) + } + return params } -/** 获取普通 SSE 流式对话 URL */ -export function chatSSEUrl(message: string, chatId: string, roleId?: string, accountId?: string): string { - let url = `${API_BASE}/ai/assistant_app/chat/sse?message=${encodeURIComponent(message)}&chatId=${encodeURIComponent(chatId)}` - if (roleId) url += `&roleId=${encodeURIComponent(roleId)}` - if (accountId) url += `&accountId=${encodeURIComponent(accountId)}` - return url +/** 同步对话:GET /ai/chat */ +export function chatSync(message: string, chatId: string, options?: ChatOptions): Promise { + const url = `${API_BASE}/ai/chat?${buildChatQueryParams(message, chatId, options).toString()}` + return fetch(url, { headers: authHeaders() }).then(res => res.text()) } -/** 获取 RAG 流式对话 URL */ -export function chatRagSSEUrl(message: string, chatId: string, strategy: string, roleId?: string, accountId?: string): string { - let url = `${API_BASE}/ai/assistant_app/chat/rag/sse?message=${encodeURIComponent(message)}&chatId=${encodeURIComponent(chatId)}&rewriteStrategy=${encodeURIComponent(strategy)}` - if (roleId) url += `&roleId=${encodeURIComponent(roleId)}` - if (accountId) url += `&accountId=${encodeURIComponent(accountId)}` - return url +/** 获取 SSE 流式对话 URL:GET /ai/chat/stream */ +export function chatSSEUrl(message: string, chatId: string, options?: ChatOptions): string { + return `${API_BASE}/ai/chat/stream?${buildChatQueryParams(message, chatId, options).toString()}` } -/** 获取 RAG 回答命中的知识库片段 */ -export function ragSources(message: string, chatId: string, strategy: string, roleId?: string, accountId?: string): Promise { - let path = `/ai/assistant_app/rag/sources?message=${encodeURIComponent(message)}&chatId=${encodeURIComponent(chatId)}&rewriteStrategy=${encodeURIComponent(strategy)}` - if (roleId) path += `&roleId=${encodeURIComponent(roleId)}` - if (accountId) path += `&accountId=${encodeURIComponent(accountId)}` +/** 获取 RAG 引用来源:GET /ai/chat/sources(不传 enableRag,其余参数照传) */ +export function ragSources(message: string, chatId: string, options?: ChatOptions): Promise { + const path = `/ai/chat/sources?${buildChatQueryParams(message, chatId, options, false).toString()}` return request.get(path).then(r => r.data) } diff --git a/frontend/src/utils/sse.ts b/frontend/src/utils/sse.ts index 6c6d490..3c5e4e5 100644 --- a/frontend/src/utils/sse.ts +++ b/frontend/src/utils/sse.ts @@ -15,6 +15,23 @@ function authHeaders(extra?: Record): Record { return base } +/** 尝试从 OpenAI Chat Completions chunk 中提取 delta.content;返回 undefined 表示非 OpenAI 格式(回退纯文本) */ +function extractOpenAIDelta(text: string): string | undefined { + let obj: any + try { obj = JSON.parse(text) } catch { return undefined } + // OpenAI 错误 chunk({"error":{"message":...}}):提取错误信息作为内容,避免原始 JSON 泄漏到对话框 + if (obj && obj.error) { + const msg = obj.error.message || obj.error.type + return typeof msg === 'string' && msg ? msg : '服务异常' + } + if (obj && Array.isArray(obj.choices)) { + const delta = obj.choices[0]?.delta + if (delta && typeof delta.content === 'string') return delta.content + return '' // OpenAI 形状但无 content(role/finish_reason 空 chunk)→ 跳过 + } + return undefined // 是 JSON 但非 OpenAI 形状 → 回退纯文本 +} + /** * 通用 SSE 流式读取 —— 统一处理 Flux / ServerSentEvent / SseEmitter 三种 SSE 接口 * @@ -49,6 +66,12 @@ export async function readSSEStream( 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') } @@ -139,9 +162,16 @@ export async function readSSEStreamWithEvents( // 系统状态事件(generating / faq_hit 等),不显示在对话框中 break case 'message': - default: - if (onMessage) onMessage(text) + 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 + } } } diff --git a/frontend/src/views/ChatPanel.vue b/frontend/src/views/ChatPanel.vue index 68fc8ab..225a9ad 100644 --- a/frontend/src/views/ChatPanel.vue +++ b/frontend/src/views/ChatPanel.vue @@ -201,7 +201,7 @@ import { ChatActionbar, } from '@tdesign-vue-next/chat' import '@tdesign-vue-next/chat/es/style/index.css' -import { chatSync, chatRagSync, chatSSEUrl, chatRagSSEUrl, ragSources, fetchSuggestions } from '@/api/chat' +import { chatSync, chatSSEUrl, ragSources, fetchSuggestions, type ChatOptions } from '@/api/chat' import { getRoleList } from '@/api/role' import { getActiveModelConfig } from '@/api/model-config' import { truncateConversation } from '@/api/conversation' @@ -432,7 +432,6 @@ async function send(): Promise { const cid = chatId.value || ('web_' + Date.now()) chatId.value = cid - const roleId = currentRoleId() try { // 取消上一个 SSE 请求并创建新的 AbortController @@ -440,51 +439,56 @@ async function send(): Promise { sseAbortController = new AbortController() const signal = sseAbortController.signal - if (isRagMode.value && mode.value === 'sync') { - // RAG 同步 - assistantMsg.content = await chatRagSync(text, cid, ragStrategy.value, roleId) - } else if (isRagMode.value) { - // RAG SSE 流式 - const url = chatRagSSEUrl(text, cid, ragStrategy.value, roleId) - await readSSEStreamWithEvents(url, { - onMessage: async (chunk: string) => { + // 统一对话请求参数(RAG 与普通对话共用同一接口,由 enableRag 参数区分) + const chatOptions: ChatOptions = { + roleId: currentRoleId(), + enableRag: isRagMode.value, + rewriteStrategy: isRagMode.value ? ragStrategy.value : undefined, + categoryIds: selectedCategoryIds.value, + } + + if (mode.value === 'sync') { + // 同步调用 + assistantMsg.content = await chatSync(text, cid, chatOptions) + } else { + // SSE 流式 + const url = chatSSEUrl(text, cid, chatOptions) + if (isRagMode.value) { + await readSSEStreamWithEvents(url, { + onMessage: async (chunk: string) => { + assistantMsg.content += chunk + messages.value = [...messages.value] + await scrollToBottom() + }, + onToolCallStart: (data: any) => { + assistantMsg.toolCalls!.push({ tool: data.tool, input: data.input, status: 'running', result: null }) + scrollToBottom() + }, + onToolCallResult: (data: any) => { + const tc = assistantMsg.toolCalls!.find(t => t.tool === data.tool && t.status === 'running') + if (tc) { tc.status = 'done'; tc.result = data.result; tc.latencyMs = data.latencyMs } + messages.value = [...messages.value] + scrollToBottom() + }, + onError: (data: any) => { + assistantMsg.content += '\n\n⚠️ ' + (data.message || '工具调用出错') + messages.value = [...messages.value] + }, + onDone: () => {}, + }, undefined, signal) + } else { + await readSSEStream(url, async (chunk: string) => { assistantMsg.content += chunk messages.value = [...messages.value] await scrollToBottom() - }, - onToolCallStart: (data: any) => { - assistantMsg.toolCalls!.push({ tool: data.tool, input: data.input, status: 'running', result: null }) - scrollToBottom() - }, - onToolCallResult: (data: any) => { - const tc = assistantMsg.toolCalls!.find(t => t.tool === data.tool && t.status === 'running') - if (tc) { tc.status = 'done'; tc.result = data.result; tc.latencyMs = data.latencyMs } - messages.value = [...messages.value] - scrollToBottom() - }, - onError: (data: any) => { - assistantMsg.content += '\n\n⚠️ ' + (data.message || '工具调用出错') - messages.value = [...messages.value] - }, - onDone: () => {}, - }, undefined, signal) - } else if (mode.value === 'sync') { - // 普通同步 - assistantMsg.content = await chatSync(text, cid, roleId) - } else { - // 普通 SSE 流式 - const url = chatSSEUrl(text, cid, roleId) - await readSSEStream(url, async (chunk: string) => { - assistantMsg.content += chunk - messages.value = [...messages.value] - await scrollToBottom() - }, () => {}, undefined, signal) + }, () => {}, undefined, signal) + } } // RAG 模式下,拉取引用来源 if (isRagMode.value) { try { - const sj = await ragSources(text, cid, ragStrategy.value, roleId) + const sj = await ragSources(text, cid, chatOptions) if (sj && sj.success) assistantMsg.sources = sj.data || [] } catch (_) { /* 来源获取失败不影响主回答 */ } }