Browse Source

feat(client): 前端与 SDK 迁移新对话接口并支持 OpenAI SSE 解析

- 前端 chat.ts 合并为 chatSync/chatSSEUrl/ragSources 三个函数,统一走 /ai/chat、/ai/chat/stream、/ai/chat/sources,用 ChatOptions + enableRag 布尔参数统一普通/RAG 路径
- 前端 sse.ts 新增 extractOpenAIDelta 解析 choices[0].delta.content,识别 error chunk,JSON 解析失败回退纯文本,旧纯文本流向后兼容
- ChatPanel.vue 将 RAG sync/SSE/普通 sync/SSE 四分支合并为 sync/SSE 两分支
- SDK api.ts 迁移 URL 至 /ai/chat 系列,chatSSERequest 抽取 emitRawChunk 解析 OpenAI delta 与 error,正确处理 data: [DONE]
master
wanghanlin 6 days ago
parent
commit
b0319392a2
  1. 95
      client/src/api.ts
  2. 2
      client/src/types.ts
  3. 72
      frontend/src/api/chat.ts
  4. 34
      frontend/src/utils/sse.ts
  5. 82
      frontend/src/views/ChatPanel.vue

95
client/src/api.ts

@ -88,13 +88,13 @@ function buildChatUrl(message: string): string {
// userId 映射为 accountId // userId 映射为 accountId
setIfPresent(params, 'accountId', currentConfig!.userId); setIfPresent(params, 'accountId', currentConfig!.userId);
return buildUrl(`/ai/assistant_app/chat/sync?${params.toString()}`);
return buildUrl(`/ai/chat?${params.toString()}`);
} }
/** /**
* SSE URL * SSE URL
*/ */
function buildChatSSEUrl(message: string, categoryId?: number): string {
function buildChatSSEUrl(message: string, categoryId?: number, useRag?: boolean): 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);
@ -103,23 +103,13 @@ function buildChatSSEUrl(message: string, categoryId?: number): string {
setIfPresent(params, 'accountId', currentConfig!.userId); setIfPresent(params, 'accountId', currentConfig!.userId);
setIfPresent(params, 'categoryId', categoryId ?? currentConfig!.categoryId); 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, 'accountId', currentConfig!.userId);
setIfPresent(params, 'categoryId', categoryId ?? currentConfig!.categoryId); setIfPresent(params, 'categoryId', categoryId ?? currentConfig!.categoryId);
return buildUrl(`/ai/assistant_app/rag/sources?${params.toString()}`);
return buildUrl(`/ai/chat/sources?${params.toString()}`);
} }
// ==================== HTTP 基础封装 ==================== // ==================== HTTP 基础封装 ====================
@ -270,10 +260,52 @@ export async function chatSSERequest(
signal?: AbortSignal signal?: AbortSignal
): Promise<void> { ): Promise<void> {
const url = useRag const url = useRag
? buildChatRAGSSEUrl(message, categoryId)
: buildChatSSEUrl(message, categoryId);
? buildChatSSEUrl(message, categoryId, true)
: buildChatSSEUrl(message, categoryId, false);
let totalText = ''; 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<string, unknown>;
// 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); logger.lifecycleSend(getActiveIntegrateId(), message.length);
try { try {
@ -315,9 +347,7 @@ export async function chatSSERequest(
if (eventLines.length > 0) { if (eventLines.length > 0) {
// 非内容事件(status 等)不回调 onChunk,避免污染 AI 回复渲染 // 非内容事件(status 等)不回调 onChunk,避免污染 AI 回复渲染
if (currentEventType !== 'status' && currentEventType !== 'tool_call_result') { if (currentEventType !== 'status' && currentEventType !== 'tool_call_result') {
const chunk = eventLines.join('\n');
totalText += chunk;
onChunk(chunk);
emitRawChunk(eventLines.join('\n'));
} }
eventLines = []; eventLines = [];
} }
@ -334,9 +364,7 @@ export async function chatSSERequest(
// [DONE] 信号(结束整次 SSE 流) // [DONE] 信号(结束整次 SSE 流)
if (trimmed === '[DONE]') { if (trimmed === '[DONE]') {
if (eventLines.length > 0 && currentEventType !== 'status' && currentEventType !== 'tool_call_result') { if (eventLines.length > 0 && currentEventType !== 'status' && currentEventType !== 'tool_call_result') {
const chunk = eventLines.join('\n');
totalText += chunk;
onChunk(chunk);
emitRawChunk(eventLines.join('\n'));
eventLines = []; eventLines = [];
} }
currentEventType = ''; currentEventType = '';
@ -357,6 +385,15 @@ export async function chatSSERequest(
content = trimmed.substring('data:'.length); content = trimmed.substring('data:'.length);
// 去掉 data: 后面紧跟的一个空格(SSE 标准允许) // 去掉 data: 后面紧跟的一个空格(SSE 标准允许)
if (content.startsWith(' ')) content = content.substring(1); 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 { } else {
content = trimmed; content = trimmed;
} }
@ -379,9 +416,7 @@ export async function chatSSERequest(
} }
// 处理缓冲区剩余 // 处理缓冲区剩余
if (eventLines.length > 0 && currentEventType !== 'status' && currentEventType !== 'tool_call_result') { if (eventLines.length > 0 && currentEventType !== 'status' && currentEventType !== 'tool_call_result') {
const chunk = eventLines.join('\n');
totalText += chunk;
onChunk(chunk);
emitRawChunk(eventLines.join('\n'));
eventLines = []; eventLines = [];
} }
} catch (readErr: unknown) { } catch (readErr: unknown) {

2
client/src/types.ts

@ -72,7 +72,7 @@ export interface SDKConfig {
streaming?: boolean; streaming?: boolean;
/** /**
* RAG true * RAG true
* 使 /chat/rag/sse
* /ai/chat/stream enableRag=true
* *
*/ */
enableRag?: boolean; enableRag?: boolean;

72
frontend/src/api/chat.ts

@ -12,43 +12,57 @@ function authHeaders(): Record<string, string> {
return headers return headers
} }
/** 同步对话 */
export function chatSync(message: string, chatId: string, roleId?: string, accountId?: string): Promise<string> {
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<string> {
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<string> {
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<ApiResponse> {
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<ApiResponse> {
const path = `/ai/chat/sources?${buildChatQueryParams(message, chatId, options, false).toString()}`
return request.get(path).then(r => r.data) return request.get(path).then(r => r.data)
} }

34
frontend/src/utils/sse.ts

@ -15,6 +15,23 @@ function authHeaders(extra?: Record<string, string>): Record<string, string> {
return base return base
} }
/** 尝试从 OpenAI Chat Completions chunk 中提取 delta.content;返回 undefined 表示非 OpenAI 格式(回退纯文本) */
function extractOpenAIDelta(text: string): string | undefined {
let obj: any
try { obj = JSON.parse(text) } catch { return undefined }
// OpenAI 错误 chunk({"error":{"message":...}}):提取错误信息作为内容,避免原始 JSON 泄漏到对话框
if (obj && obj.error) {
const msg = obj.error.message || obj.error.type
return typeof msg === 'string' && msg ? msg : '服务异常'
}
if (obj && Array.isArray(obj.choices)) {
const delta = obj.choices[0]?.delta
if (delta && typeof delta.content === 'string') return delta.content
return '' // OpenAI 形状但无 content(role/finish_reason 空 chunk)→ 跳过
}
return undefined // 是 JSON 但非 OpenAI 形状 → 回退纯文本
}
/** /**
* SSE Flux<String> / ServerSentEvent / SseEmitter SSE * SSE Flux<String> / ServerSentEvent / SseEmitter SSE
* *
@ -49,6 +66,12 @@ export async function readSSEStream(
if (text === '[DONE]') return if (text === '[DONE]') return
// 跳过 status / faq 等系统事件,不显示在对话框中 // 跳过 status / faq 等系统事件,不显示在对话框中
if (ev === 'status') return 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: 事件) // 空事件视为 LLM 流式输出的换行符(Spring 将 "\n" 编码为单条空 data: 事件)
onChunk(text || '\n') onChunk(text || '\n')
} }
@ -139,9 +162,16 @@ export async function readSSEStreamWithEvents(
// 系统状态事件(generating / faq_hit 等),不显示在对话框中 // 系统状态事件(generating / faq_hit 等),不显示在对话框中
break break
case 'message': 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 break
}
} }
} }

82
frontend/src/views/ChatPanel.vue

@ -201,7 +201,7 @@ import {
ChatActionbar, ChatActionbar,
} from '@tdesign-vue-next/chat' } from '@tdesign-vue-next/chat'
import '@tdesign-vue-next/chat/es/style/index.css' 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 { getRoleList } from '@/api/role'
import { getActiveModelConfig } from '@/api/model-config' import { getActiveModelConfig } from '@/api/model-config'
import { truncateConversation } from '@/api/conversation' import { truncateConversation } from '@/api/conversation'
@ -432,7 +432,6 @@ async function send(): Promise<void> {
const cid = chatId.value || ('web_' + Date.now()) const cid = chatId.value || ('web_' + Date.now())
chatId.value = cid chatId.value = cid
const roleId = currentRoleId()
try { try {
// SSE AbortController // SSE AbortController
@ -440,51 +439,56 @@ async function send(): Promise<void> {
sseAbortController = new AbortController() sseAbortController = new AbortController()
const signal = sseAbortController.signal 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 assistantMsg.content += chunk
messages.value = [...messages.value] messages.value = [...messages.value]
await scrollToBottom() 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 // RAG
if (isRagMode.value) { if (isRagMode.value) {
try { 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 || [] if (sj && sj.success) assistantMsg.sources = sj.data || []
} catch (_) { /* 来源获取失败不影响主回答 */ } } catch (_) { /* 来源获取失败不影响主回答 */ }
} }

Loading…
Cancel
Save