From ab902f948a344517bea27201c375bff82b510739 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Thu, 28 May 2026 21:22:49 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BB=A3=E7=A0=81=E5=90=88=E5=B9=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend-web/src/pages/StrategyRoutes.tsx | 5 +- .../components/chat-qa/GeneralQA.mock.ts | 248 +- .../components/chat-qa/GeneralQA.tsx | 2820 ++++++++++------- .../components/chat-qa/GeneralQA.types.ts | 30 +- .../chat-qa/GeneralQAConfigSidebar.tsx | 53 +- .../components/chat-qa/GeneralQAHeader.tsx | 89 +- .../components/chat-qa/QACommon.tsx | 933 ++++++ .../components/qa/QACommon.tsx | 1 + .../components/qa/qa-animations.css | 171 + 9 files changed, 3021 insertions(+), 1329 deletions(-) create mode 100644 frontend-web/src/strategy-components/components/chat-qa/QACommon.tsx create mode 100644 frontend-web/src/strategy-components/components/qa/qa-animations.css diff --git a/frontend-web/src/pages/StrategyRoutes.tsx b/frontend-web/src/pages/StrategyRoutes.tsx index 8e9781c9..4ed27e5a 100644 --- a/frontend-web/src/pages/StrategyRoutes.tsx +++ b/frontend-web/src/pages/StrategyRoutes.tsx @@ -54,9 +54,8 @@ export default function StrategyRoutes() { path="qa/expert-qa" element={ } /> diff --git a/frontend-web/src/strategy-components/components/chat-qa/GeneralQA.mock.ts b/frontend-web/src/strategy-components/components/chat-qa/GeneralQA.mock.ts index 6a590092..d6f235bf 100644 --- a/frontend-web/src/strategy-components/components/chat-qa/GeneralQA.mock.ts +++ b/frontend-web/src/strategy-components/components/chat-qa/GeneralQA.mock.ts @@ -1,4 +1,4 @@ -import type { QASingleRagTrace, QAThinkingTrace } from '../qa/QACommon'; +import type { QASingleRagTrace, QAThinkingTrace } from './QACommon'; import type { ComparePane, KnowledgeBaseChunk, Message, QAMode, SourceItem } from './GeneralQA.types'; /** @@ -88,6 +88,7 @@ export const createThinkingTrace = (mode: QAMode): QAThinkingTrace => { title: '理解问题并拆解检索方向', // 本轮主题:理解问题并确定检索方向 thoughtMarkdown: '用户询问 **RAG 技术工作原理**。需要先明确核心链路:\n\n- 问题理解与向量化\n- 知识库召回\n- 上下文增强\n- 大模型生成\n\n第一轮优先检索基础定义和主流程。', // 模型思考过程,展示分析步骤 query: 'RAG 检索增强生成 工作原理 基础流程', // 发送给检索系统的查询词 + queryShow: true, retrievals: [ // 第一轮检索返回的资料列表 { id: 'r1-1', @@ -114,6 +115,7 @@ export const createThinkingTrace = (mode: QAMode): QAThinkingTrace => { thoughtMarkdown: '基础链路已经明确,继续检索 **多轮 RAG** 和 **深度检索** 的差异:\n\n1. 是否需要 Query 改写\n2. 是否需要对子问题分别召回\n3. 是否需要结果重排与证据融合', query: '多轮 RAG Query 改写 子问题检索 结果重排', // 本轮发送给检索系统的查询词 // 本轮检索返回的资料列表 + queryShow: true, retrievals: [ { id: 'r2-1', @@ -144,6 +146,7 @@ export const createThinkingTrace = (mode: QAMode): QAThinkingTrace => { title: '综合证据并形成回答结构', // 本轮的主题:整合前面检索到的所有证据,组织成完整的回答 thoughtMarkdown: '当前证据已覆盖定义、流程和优化机制。最后需要组织成清晰结构:\n\n- 一句话定义\n- 分阶段流程\n- 技术价值\n- 适用场景与注意事项', query: 'RAG 技术价值 幻觉 时效性 可追溯性 应用场景', // 本轮查询:关于 RAG 价值和应用场景的信息 + queryShow: true, retrievals: [ { id: 'r3-1', @@ -181,9 +184,11 @@ export const createMultiRoundStreamTrace = (): QAThinkingTrace => ({ title: '正在准备检索', thoughtMarkdown: '', query: '', + queryShow: true, queryLoading: true, retrievals: [], }], + thinkingComplete: false, finalAnswerMarkdown: '', }); @@ -196,7 +201,7 @@ export const createMultiRoundStreamTrace = (): QAThinkingTrace => ({ export const createComparePane = (mode: QAMode): ComparePane => ({ mode, loading: true, - singleRagTrace: mode === 'single-round' + singleRagTrace: (mode === 'single-round' || mode === 'single-chat') ? { title: '单轮 RAG 思考过程', thinkingMarkdown: '', @@ -204,7 +209,7 @@ export const createComparePane = (mode: QAMode): ComparePane => ({ thinkingComplete: false, } : undefined, - thinkingTrace: mode === 'single-round' ? undefined : createMultiRoundStreamTrace(), + thinkingTrace: (mode === 'single-round' || mode === 'single-chat') ? undefined : createMultiRoundStreamTrace(), sources: [], }); @@ -215,88 +220,6 @@ export const createComparePane = (mode: QAMode): ComparePane => ({ * 拆分时完整保留原始 mock 数据,确保首屏体验不变。 */ export const createMockSingleRagMessages = (): Message[] => ([ - { - id: 'mock-user-code-preview', - role: 'user', - content: '帮我看一下 Markdown 代码块的展示效果', - timestamp: new Date(), - }, - { - id: 'mock-assistant-code-preview', - role: 'assistant', - content: '', - timestamp: new Date(), - singleRagTrace: { - title: '代码块渲染预览', - thinkingMarkdown: '这条模拟数据用于检查 MarkdownRenderer 的代码块样式:多行代码应该展示为带语言标签和复制按钮的彩色代码块,短单行代码例如 `const ok = true` 应该保持行内展示。', - answerMarkdown: [ - '下面是几段模拟代码,用来预览不同语言的渲染效果。', - '', - '短单行代码:`const count = 1`,这一段应该显示为行内代码,而不是大代码块。', - '', - '### TSX 示例', - '', - '```tsx', - 'type UserCardProps = {', - ' name: string;', - ' role: string;', - '};', - '', - 'export function UserCard({ name, role }: UserCardProps) {', - ' return (', - '
', - '

{name}

', - '

{role}

', - '
', - ' );', - '}', - '```', - '', - '### Vue 示例', - '', - '```vue', - '', - '', - '', - '```', - '', - '### Java 示例', - '', - '```java', - 'public class StrategyScore {', - ' public static int normalize(int value) {', - ' return Math.max(0, Math.min(100, value));', - ' }', - '', - ' public static void main(String[] args) {', - ' System.out.println(normalize(128));', - ' }', - '}', - '```', - '', - '引用按钮也顺便保留测试:[[1]]', - ].join('\n'), - thinkingComplete: true, - }, - sources: [ - { - title: 'MarkdownRenderer mock preview', - type: '本地模拟数据', - snippet: '用于预览 tsx、vue、java 代码块高亮,以及短单行代码的行内展示。', - source: 'GeneralQA.mock.ts', - time: '2026-04-30', - content: '这是一条本地模拟引用数据,用于测试 [[1]] 引用按钮点击后的详情展示。', - }, - ], - }, { id: 'mock-user-1', role: 'user', @@ -382,111 +305,6 @@ export const createMockSingleRagMessages = (): Message[] => ([ }, ], }, - { - id: 'mock-user-multi-1', - role: 'user', - content: '我想了解多轮检索的工作机制,以及它在大规模企业知识库中的实际表现如何?', - timestamp: new Date(), - }, - { - id: 'mock-assistant-multi-1', - role: 'assistant', - content: '', - timestamp: new Date(), - thinkingTrace: createThinkingTrace('multi-round'), - sources: [ - { - title: '多轮RAG检索白皮书', - type: '知识库', - snippet: '多轮检索通过逐轮改写查询、逐步聚焦,能在复杂问题上比单轮检索提升30%以上的召回覆盖。', - source: '内部架构组', - time: '2026-04-15', - content: '多轮检索的核心在于每轮检索后根据已召回内容改写查询,逐步缩小语义范围,最终实现精准覆盖。', - }, - { - title: '企业知识库检索时延对比', - type: '技术报告', - snippet: '两轮检索在10万级文档库中平均增加40%延迟,但答案完整度提升显著。', - source: 'NLP团队', - time: '2026-03-22', - content: '实测数据:单轮检索平均时延1.2s,两轮检索平均时延1.7s,三轮检索平均时延2.3s。建议根据SLA选择合适轮次。', - }, - ], - }, - { - id: 'mock-user-multi-2', - role: 'user', - content: '企业知识问答系统的多轮检索需要做哪些工程化优化?', - timestamp: new Date(), - }, - { - id: 'mock-assistant-multi-2', - role: 'assistant', - content: '', - timestamp: new Date(), - thinkingTrace: { - title: '多轮检索工程化优化思考过程', - rounds: [ - { - id: 'round-opt-1', - title: '分析工程化优化方向', - thoughtMarkdown: '用户关心的是工程层面的优化,需要从以下几个维度回答:\n\n1. 查询改写策略的缓存与复用\n2. 检索结果去重与增量更新\n3. 并发检索与超时控制\n4. 检索质量的在线评估机制', - query: '多轮检索 工程化优化 查询改写 缓存 去重 并发', - retrievals: [ - { - id: 'ro1-1', - title: '查询改写缓存策略实践', - content: '对于相似查询,可通过向量相似度匹配复用上一轮的改写结果,减少调用大模型的次数和延迟。', - source: '知识库 / 查询优化实践指南.md', - }, - { - id: 'ro1-2', - title: '检索结果去重方案', - content: '多轮检索容易出现重复文档,需基于文档ID或内容哈希进行去重,并在每轮检索时排除已召回文档。', - source: '知识库 / 检索去重技术方案.pdf', - }, - ], - }, - { - id: 'round-opt-2', - title: '检索性能与质量保障', - thoughtMarkdown: '第二轮聚焦性能优化:\n\n1. 并发检索多个知识库以降低延迟\n2. 设置合理的检索超时防止长尾请求\n3. 引入在线相关性评分作为质量护栏', - query: '并发检索 超时控制 在线评估 知识库评分', - retrievals: [ - { - id: 'ro2-1', - title: '知识库并发检索架构设计', - content: '采用异步并发查询多个索引,配合全局超时(建议3-5s),保证用户体验的同时最大化召回。', - source: '架构库 / 并发检索设计文档.docx', - }, - { - id: 'ro2-2', - title: '检索质量在线评估方法', - content: '通过小模型对每轮召回结果做相关性打分,低于阈值时自动触发查询改写或切换检索策略。', - source: '评测库 / 在线质量评估方案.md', - }, - { - id: 'ro2-3', - title: '多轮检索时延优化总结', - content: '工程实践中建议两轮为上限,超过时需评估能否合并为单轮。首批结果可先展示,第二轮做增量补充。', - source: '性能库 / 时延优化白皮书.pdf', - }, - ], - }, - ], - finalAnswerMarkdown: '多轮检索的工程化优化主要围绕四个方向:\n\n### 1. 查询改写优化\n- 缓存相似查询的改写结果,通过向量相似度匹配复用\n- 降低大模型调用频次,减少时延和成本\n\n### 2. 结果去重与增量更新\n- 基于文档ID或内容哈希去重\n- 每轮检索时排除已召回文档,避免重复返回\n- 增量模式:先返回首批结果,后续轮次做补充\n\n### 3. 并发与超时控制\n- 异步并发查询多个知识库索引\n- 设置全局超时(建议3-5秒),避免长尾请求\n- 首批结果到达后立即展示,后续增量追加\n\n### 4. 在线质量评估\n- 用小模型对召回结果做相关性打分\n- 低于阈值时自动触发查询改写或切换策略\n- 持续监控命中率和引用准确率\n\n> 建议:实际工程中两轮检索已能覆盖大多数复杂场景,超过两轮应重新评估是否可以通过更好的查询改写来合并。', - }, - sources: [ - { - title: '多轮检索工程化最佳实践', - type: '技术方案', - snippet: '两轮检索配合增量更新,可在延迟和完整度之间取得最优平衡。', - source: '工程团队', - time: '2026-05-01', - content: '总结了过去一年的多轮检索优化经验,涵盖查询改写缓存、结果去重、并发检索和在线评估四大方向。', - }, - ], - }, ]); /** @@ -506,9 +324,10 @@ export const mapDocsToSources = (docs: KnowledgeBaseChunk['docs']): SourceItem[] const normalizedDocs = Array.isArray(docs) ? docs : docs ? [docs] : []; return normalizedDocs.map((item, idx) => { + console.log(item,'检索资料') if (typeof item === 'string') { return { - title: `参考资料 ${idx + 1}`, + title: '', type: '知识库', snippet: item, source: '', @@ -529,6 +348,34 @@ export const mapDocsToSources = (docs: KnowledgeBaseChunk['docs']): SourceItem[] }); }; +export const chatDataJSON = (chatJSON: string) => { + let chatJSONText = chatJSON.replace(/}{"/g, '},{"'); + chatJSONText = '[' + chatJSONText + ']'; + // console.log('解析chat',chatJSONText); + let chatData = []; + let chatTitle: any = ''; + const docsList: any[] = []; + try { + chatData = JSON.parse(chatJSONText); + chatData.forEach((item: any) => { + if (item['answer']) { + chatTitle += item['answer']; + } else if (item['docs']) { + docsList.push(item); + } + }); + return { + chatTitle, + docsList, + }; + } catch (error) { + return { + chatTitle, + docsList, + }; + } + }; + /** * 从流式文本中提取完整 JSON 对象。 * @@ -582,3 +429,22 @@ export const extractJsonObjects = (raw: string): { objects: string[]; rest: stri return { objects, rest: '' }; }; + +// 处理平台数据 +export const platformTypeChange = (platform: string) => { + let platformType: string = ''; + if (platform.includes('fb')) { + platformType = 'facebook'; + } else if (platform.includes('x')) { + platformType = 'x'; + } else if (platform.includes('in')) { + platformType = 'instagram'; + } else if (platform.includes('re')) { + platformType = 'reddit'; + } else if (platform.includes('th')) { + platformType = 'threads'; + } else { + platformType = 'facebook'; + } + return platformType; + } \ No newline at end of file diff --git a/frontend-web/src/strategy-components/components/chat-qa/GeneralQA.tsx b/frontend-web/src/strategy-components/components/chat-qa/GeneralQA.tsx index 20557ba6..c6b3cb5c 100644 --- a/frontend-web/src/strategy-components/components/chat-qa/GeneralQA.tsx +++ b/frontend-web/src/strategy-components/components/chat-qa/GeneralQA.tsx @@ -9,13 +9,14 @@ import { Square, X, } from 'lucide-react'; -import { Dialog, Select, Slider } from 'tdesign-react'; +import { Dialog, DialogPlugin, Select, Slider } from 'tdesign-react'; import { useTheme } from '@/contexts/ThemeContext'; -import { ChatMessage, EmptyChatState, QAAvatar, QAComposer, QASingleRagTrace, QAThinkingRound, QAThinkingTrace, RetrievalThinkingTrace, SingleRagAnswer, TypingIndicator } from '../qa/QACommon'; +import { ChatMessage, EmptyChatState, QAAvatar, QAComposer, QASingleRagTrace, QAThinkingRound, QAThinkingTrace, RetrievalThinkingTrace, SingleRagAnswer, TypingIndicator } from './QACommon'; import GeneralQAHeader from './GeneralQAHeader'; import GeneralQAConfigSidebar from './GeneralQAConfigSidebar'; -import type { ComparePane, CompareResult, KnowledgeBaseChunk, Message, QAMode, SourceItem, ToolCallData, ViewMode } from './GeneralQA.types'; +import type { ComparePane, CompareResult, KnowledgeBaseChunk, Message, QAMode, SourceItem, stateItem, ToolCallData, ViewMode } from './GeneralQA.types'; import { + chatDataJSON, createComparePane, createMockSingleRagMessages, createMultiRoundStreamTrace, @@ -24,22 +25,37 @@ import { mapDocsToSources, mapReferenceToSource, parseSingleRagStream, + platformTypeChange, } from './GeneralQA.mock'; - -type GeneralQAProps = { - pageTitle?: string; - pageSubtitle?: string; - initialMode?: 'general' | 'new'; -}; +import { requestCrudService } from '@/service/request-axios-service'; /** - * 通用/专家问答组件 + * 通用问答组件 * 支持单轮RAG、多轮RAG、深度检索及结果对比功能 + * @param onNavigate - 页面导航函数 + * @param initialMode - 初始模式(可选) */ -const GeneralQA: React.FC = ({ - pageTitle = '通用问答', - pageSubtitle = '支持单轮RAG、多轮RAG、深度检索及结果对比', + +const zncmUrl = import.meta.env.VITE_API_BASE_URL || ''; +const chatUrl = import.meta.env.VITE_AIPI_CHAT_URL || ''; +const generateUrl = import.meta.env.VITE_AIPI_GENERATE_CHAT_URL || ''; +const taskChatUrl = import.meta.env.VITE_AIPI_TASK_CHAT_URL || ''; +const knowledgeUrl = import.meta.env.VITE_AIPI_KNOWLEDGE_URL; +const service = requestCrudService; +const GeneralQA: React.FC<{ + onNavigate?: (page: string) => void; + initialMode?: string; + pageTitle?: string; + pageSubtitle?: string; + chatHeadTitle?: string; + chatDesign?: string; +}> = ({ + onNavigate, initialMode: _initialMode = 'general', + pageTitle, + pageSubtitle, + chatHeadTitle, + chatDesign, }) => { const { theme } = useTheme(); const isDark = theme === 'dark'; @@ -80,7 +96,7 @@ const GeneralQA: React.FC = ({ // 存储发生的错误信息,用于显示错误提示 const [error, setError] = useState(null); // 对比模式下的结果数据,包含左右两侧的回答和来源信息 - const [compareResult, setCompareResult] = useState(null); + const [compareResult, setCompareResult] = useState([]); // 对比模式下用户输入的问题内容,用于左右对比时的统一输入 const [compareUserInput, setCompareUserInput] = useState(''); // 是否折叠左侧配置面板,true 为折叠,false 为展开 @@ -97,7 +113,7 @@ const GeneralQA: React.FC = ({ // 模型参数设置弹窗状态。 // 这些值会进入单轮 RAG 的真实请求体;模拟流仍复用同一套 UI,方便接口恢复后无缝切换。 const [isModelSettingsOpen, setIsModelSettingsOpen] = useState(false); - const [modelName, setModelName] = useState('OpenAI'); + const [modelName, setModelName] = useState('OpenAI3'); const [scoreThreshold, setScoreThreshold] = useState(0.5); const [temperature, setTemperature] = useState(0.3); @@ -109,11 +125,11 @@ const GeneralQA: React.FC = ({ const submitLockRef = useRef(false); // 真实流式请求的 reader 句柄,用于“停止生成”时取消浏览器流读取。 - const readerRef = useRef | null>(null); + const readerRef = useRef(null); // 对比模式会同时启动左右两路请求,因此分别保存 reader,保证可以单独停止某一侧生成。 const compareReaderRef = useRef<{ - left: ReadableStreamDefaultReader | null; - right: ReadableStreamDefaultReader | null; + left: any; + right: any; }>({ left: null, right: null }); // 多轮接口创建任务后会返回 task_id,这里预留给后续“按任务取消/查询状态”等能力。 const taskIdRef = useRef(null); @@ -122,6 +138,10 @@ const GeneralQA: React.FC = ({ const mockSingleRoundStoppedRef = useRef(false); const mockMultiRoundStoppedRef = useRef(false); const chatListRef = useRef(null); + const userId = useRef(localStorage.getItem('userId') || '387'); // 用户id + const qaType = useRef('GENERAL'); // 问答类型 + const chatDataId = useRef(); // 聊天窗口id + const compareChatId = useRef(); // 对比对话记录id // 检索过程展示状态 const [searchProcess, setSearchProcess] = useState({ @@ -131,6 +151,13 @@ const GeneralQA: React.FC = ({ queryRewrite: '', }); + const state = useRef({ + compareIndex: 0, + compareId: '', + messageList: [], + compareResult: [] + }) + /** * 处理单轮 RAG 问答请求 * @@ -143,7 +170,7 @@ const GeneralQA: React.FC = ({ * * @param query - 用户输入的问题文本 */ - const handleSingleRoundRun = async (query: string) => { + const handleSingleRoundRun = async (query: string, side: 'left' | 'right' | null = null) => { // 清空之前的错误信息 setError(null); // 标记开始加载,禁用提交按钮防止重复点击 @@ -164,63 +191,89 @@ const GeneralQA: React.FC = ({ * @param token - 从流中接收到的文本片段 */ const applyAnswerToken = (token: string) => { - setMessages((prev) => prev.map((msg) => { - // 跳过不是当前 assistant 消息或没有 singleRagTrace 的消息 - if (msg.id !== assistantId || !msg.singleRagTrace) return msg; - - // 获取当前消息的思考和回答内容 - let thinkingMarkdown = msg.singleRagTrace.thinkingMarkdown; - let answerMarkdown = msg.singleRagTrace.answerMarkdown; - let thinkingComplete = msg.singleRagTrace.thinkingComplete || false; - // 剩余未处理的 token 内容 - let remaining = token; - - // 循环处理所有 token,直到处理完毕 - while (remaining) { - // 当前处于 标签内(解析思考过程),查找结束标签 - if (inThink) { - const endIdx = remaining.indexOf(''); - if (endIdx === -1) { - thinkingMarkdown += remaining; - remaining = ''; - } else { - thinkingMarkdown += remaining.slice(0, endIdx); - remaining = remaining.slice(endIdx + ''.length); - inThink = false; - thinkingComplete = true; - } + const mesItem = state.current.messageList[1]; + // 获取当前消息的思考和回答内容 + let thinkingMarkdown = mesItem.singleRagTrace.thinkingMarkdown; + let answerMarkdown = mesItem.singleRagTrace.answerMarkdown; + let thinkingComplete = mesItem.singleRagTrace.thinkingComplete || false; + // 剩余未处理的 token 内容 + let remaining = token; + console.log('remaining', remaining) + // 当前处于 标签内(解析思考过程),查找结束标签 + while (remaining) { + if (inThink) { + const endIdx = remaining.indexOf(''); + if (endIdx === -1) { + thinkingMarkdown += remaining; + remaining = ''; } else { - const startIdx = remaining.indexOf(''); - if (startIdx === -1) { - answerMarkdown += remaining; - remaining = ''; - } else { - answerMarkdown += remaining.slice(0, startIdx); - remaining = remaining.slice(startIdx + ''.length); - inThink = true; - thinkStarted = true; - } + thinkingMarkdown += remaining.slice(0, endIdx); + remaining = remaining.slice(endIdx + ''.length); + inThink = false; + thinkingComplete = true; + } + } else { + const startIdx = remaining.indexOf(''); + console.log('startIdx', startIdx) + if (startIdx === -1) { + answerMarkdown += remaining; + remaining = ''; + } else { + answerMarkdown += remaining.slice(0, startIdx); + remaining = remaining.slice(startIdx + ''.length); + inThink = true; + thinkStarted = true; } } + } + state.current.messageList[1] = { + ...mesItem, + singleRagTrace: { + ...mesItem.singleRagTrace, + // 更新思考和回答的 markdown 内容 + thinkingMarkdown, + answerMarkdown, + // 只有当已经开始解析思考内容时才更新 thinkingComplete 标志 + thinkingComplete: thinkStarted ? thinkingComplete : true, + }, + } + if (!side) { + setMessages((prev) => prev.map((msg) => { + // 跳过不是当前 assistant 消息或没有 singleRagTrace 的消息 + if (msg.id !== assistantId || !msg.singleRagTrace) return msg; + // 返回更新后的消息对象,保留 singleRagTrace 中的所有字段 + return { + ...state.current.messageList[1], + }; + })); + } else { + updatePane(side, (pane) => { + return { + ...pane, + singleRagTrace: { + title: '大模型', + thinkingMarkdown, + answerMarkdown, + thinkingComplete: thinkStarted ? thinkingComplete : true, + }, + }; + }); + } + }; - // 返回更新后的消息对象,保留 singleRagTrace 中的所有字段 + const updatePane = (side: 'left' | 'right', updater: (pane: ComparePane) => ComparePane) => { + setCompareResult((prev) => { + if (!prev) return prev; return { - ...msg, - singleRagTrace: { - ...msg.singleRagTrace, - // 更新思考和回答的 markdown 内容 - thinkingMarkdown, - answerMarkdown, - // 只有当已经开始解析思考内容时才更新 thinkingComplete 标志 - thinkingComplete: thinkStarted ? thinkingComplete : true, - }, + ...prev, + [side]: updater(prev[side]), }; - })); + }); }; try { // 向知识库问答 API 发送 POST 请求,携带用户问题和检索参数 - const response = await fetch('http://10.26.128.82:7863/chat/knowledge_base_chat', { + const response = await fetch(`${chatUrl}/chat/knowledge_base_chat_tiaoshi`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, // 请求体包含查询内容、知识库名称、检索参数、模型配置等 @@ -258,37 +311,54 @@ const GeneralQA: React.FC = ({ answerMarkdown: '', // 最终回答内容 thinkingComplete: false, // 思考是否完成 }; - // 将 assistant 消息添加到消息列表中 - setMessages((prev) => [...prev, { + const messageItem: Message = { id: assistantId, role: 'assistant', content: '', timestamp: new Date(), singleRagTrace: initialTrace, sources: [], // 初始为空,随着检索结果更新 - }]); + } + state.current.messageList.push(messageItem); // 数据存储 + // 将 assistant 消息添加到消息列表中 + setMessages((prev) => [...prev, messageItem]); // 获取响应体的流式读取器 const reader = response.body.getReader(); // 保存 reader 引用,用于"停止生成"功能 - readerRef.current = reader; + if (!side) { + readerRef.current = reader; + } else { + compareReaderRef.current[side] = reader; + } // 创建文本解码器,将二进制数据转换为字符串 const decoder = new TextDecoder(); // buffer 用于暂存未完成的 JSON 片段 let buffer = ''; - + let docsList: any[] = []; try { // 持续读取流数据,直到流结束 while (true) { // 读取下一个数据块 const { done, value } = await reader.read(); // done 为 true 时表示流已结束 - if (done) break; + if (done) { + console.log('聊天记录', state.current.messageList); + chatHistoryAddSearch(); + break; + } // 将二进制数据解码并追加到 buffer buffer += decoder.decode(value, { stream: true }); // 从 buffer 中提取完整的 JSON 对象 const extracted = extractJsonObjects(buffer); + if (buffer.includes('docs')) { + try { + docsList = chatDataJSON(buffer).docsList || []; + } catch (error: any) { + console.log(error); + } + } // 剩余无法解析的内容保留到下次处理 buffer = extracted.rest; @@ -304,13 +374,26 @@ const GeneralQA: React.FC = ({ // 如果有检索文档,更新消息的来源信息 if (parsed.docs) { // 将 docs 转换为 sources 格式 - const mappedDocs = mapDocsToSources(parsed.docs); + const mappedDocs = docsList.map((item: any) => ({ + title: item.m_title, + type: item.source1, + snippet: item.m_content, + source: item.source1, + time: item.m_publish, + url: item.recUuid, + content: item.m_content || '', + })); // 更新检索统计信息 setSearchProcess((prev) => ({ ...prev, hits: prev.hits + mappedDocs.length })); + state.current.messageList[1].sources = mappedDocs; // 更新消息的来源列表 - setMessages((prev) => prev.map((msg) => ( - msg.id === assistantId ? { ...msg, sources: [...(msg.sources || []), ...mappedDocs] } : msg - ))); + if (!side) { + setMessages((prev) => prev.map((msg) => ( + msg.id === assistantId ? { ...msg, sources: mappedDocs } : msg + ))); + } else { + updatePane(side, (pane) => ({ ...pane, sources: mappedDocs })); + } } } catch (parseError) { // 解析错误不影响主流程,记录日志继续处理 @@ -334,6 +417,226 @@ const GeneralQA: React.FC = ({ setIsPreConnectLoading(false); setIsLoading(false); submitLockRef.current = false; + state.current.messageList = []; + } + }; + + /** + * 处理单轮 RAG 问答请求 + * + * 单轮 RAG 的处理流程: + * 1. 向知识库问答 API 发送 POST 请求,携带用户问题和检索参数 + * 2. API 返回 SSE 流式响应,包含多个 JSON 对象 + * 3. 逐个解析流式 JSON,提取 answer 和 docs 字段 + * 4. 通过 applyAnswerToken 将流式 token 追加到消息的 thinkingMarkdown/answerMarkdown + * 5. 实时更新消息来源(sources)和检索统计(searchProcess) + * + * @param query - 用户输入的问题文本 + */ + const chatRun = async (query: string, side: 'left' | 'right' | null = null) => { + // 清空之前的错误信息 + setError(null); + // 标记开始加载,禁用提交按钮防止重复点击 + setIsLoading(true); + // 标记"连接中"状态,显示加载占位符 + setIsPreConnectLoading(true); + // 用于存储当前 assistant 消息的 ID,以便后续更新该消息内容 + let assistantId = ''; + + // 标志位:当前处理的 token 是否处于 ... 标签内(解析思考过程) + let inThink = false; + // 标志位:是否已经遇到过 标签开始解析思考内容 + let thinkStarted = false; + + /** + * 将流式接收到的 token 应用到对应消息的 singleRagTrace 中 + * 该函数会解析 token 中的 ... 标签,将思考过程和回答分开存储 + * @param token - 从流中接收到的文本片段 + */ + const applyAnswerToken = (token: string, side: 'left' | 'right' | null = null) => { + let mesItem: any = state.current.messageList[1]; + if (side === 'left' || side === 'right') { + mesItem = state.current.compareResult[state.current.compareIndex][side]; + } + // 获取当前消息的思考和回答内容 + let thinkingMarkdown = mesItem.singleRagTrace.thinkingMarkdown; + let answerMarkdown = mesItem.singleRagTrace.answerMarkdown; + let thinkingComplete = mesItem.singleRagTrace.thinkingComplete || false; + // 剩余未处理的 token 内容 + let remaining = token; + // console.log('remaining', remaining) + // 当前处于 标签内(解析思考过程),查找结束标签 + while (remaining) { + if (inThink) { + const endIdx = remaining.indexOf(''); + if (endIdx === -1) { + thinkingMarkdown += remaining; + remaining = ''; + } else { + thinkingMarkdown += remaining.slice(0, endIdx); + remaining = remaining.slice(endIdx + ''.length); + inThink = false; + thinkingComplete = true; + } + } else { + const startIdx = remaining.indexOf(''); + if (startIdx === -1) { + answerMarkdown += remaining; + remaining = ''; + } else { + answerMarkdown += remaining.slice(0, startIdx); + remaining = remaining.slice(startIdx + ''.length); + inThink = true; + thinkStarted = true; + } + } + } + if (!side) { + state.current.messageList[1] = { + ...mesItem, + singleRagTrace: { + ...mesItem.singleRagTrace, + // 更新思考和回答的 markdown 内容 + thinkingMarkdown, + answerMarkdown, + // 只有当已经开始解析思考内容时才更新 thinkingComplete 标志 + thinkingComplete: thinkStarted ? thinkingComplete : true, + }, + } + setMessages((prev) => prev.map((msg) => { + // 跳过不是当前 assistant 消息或没有 singleRagTrace 的消息 + if (msg.id !== assistantId || !msg.singleRagTrace) return msg; + // 返回更新后的消息对象,保留 singleRagTrace 中的所有字段 + return { + ...state.current.messageList[1], + }; + })); + } else { + state.current.compareResult[state.current.compareIndex][side].singleRagTrace = { + ...mesItem.singleRagTrace, + // 更新思考和回答的 markdown 内容 + thinkingMarkdown, + answerMarkdown, + // 只有当已经开始解析思考内容时才更新 thinkingComplete 标志 + thinkingComplete: thinkStarted ? thinkingComplete : true, + } + updatePane(side, (pane) => { + return { + ...pane, + singleRagTrace: { + title: '大模型', + thinkingMarkdown, + answerMarkdown, + thinkingComplete: thinkStarted ? thinkingComplete : true, + }, + }; + }); + } + }; + + try { + // 向知识库问答 API 发送 POST 请求,携带用户问题和检索参数 + const response = await fetch(`${chatUrl}/chat/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + // 请求体包含查询内容、知识库名称、检索参数、模型配置等 + body: JSON.stringify({ + query, // 用户输入的问题 + history: [], // 对话历史,用于多轮对话 + stream: true, // 启用流式响应 + model_name: modelName, // 使用的模型名称 + temperature, // 生成温度,控制随机性 + prompt_name: 'default', // 使用的提示词模板 + max_tokens: 1000 + }), + }); + + // 检查 HTTP 响应状态 + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + // 确保响应体存在(SSE 流需要 body) + if (!response.body) { + throw new Error('Response body is null'); + } + // 收到有效响应后,关闭"连接中"状态,开始显示实际内容 + setIsPreConnectLoading(false); + + // 生成唯一 ID 用于标识这条 assistant 消息 + assistantId = `assistant-${Date.now()}`; + // 初始化思考过程追踪对象 + const initialTrace: QASingleRagTrace = { + title: '大模型思考过程', + thinkingMarkdown: '', // 思考过程内容 + answerMarkdown: '', // 最终回答内容 + thinkingComplete: false, // 思考是否完成 + }; + const messageItem: Message = { + id: assistantId, + role: 'assistant', + content: '', + timestamp: new Date(), + singleRagTrace: initialTrace, + sources: [], // 初始为空,随着检索结果更新 + } + if (!side) { + state.current.messageList.push(messageItem); + // 将 assistant 消息添加到消息列表中 + setMessages((prev) => [...prev, messageItem]); + } else if (side === 'left' || side === 'right') { + state.current.compareResult[state.current.compareIndex][side].singleRagTrace = initialTrace; + updatePane(side, (pane) => { + return { + ...pane, + singleRagTrace: initialTrace, + }; + }); + } + + // 获取响应体的流式读取器 + const reader = response.body.pipeThrough(new TextDecoderStream()).getReader(); + // 保存 reader 引用,用于"停止生成"功能 + if (!side) { + readerRef.current = reader; + } else { + compareReaderRef.current[side] = reader; + } + // 创建文本解码器,将二进制数据转换为字符串 + const decoder = new TextDecoder(); + // buffer 用于暂存未完成的 JSON 片段 + + try { + // 持续读取流数据,直到流结束 + while (true) { + // 读取下一个数据块 + const { done, value } = await reader.read(); + // done 为 true 时表示流已结束 + if (done) { + console.log('聊天记录', state.current.messageList); + if (!side) { + chatHistoryAddSearch(); + } + break; + } + applyAnswerToken(value, side); + } + } finally { + // 流读取完成后,释放 reader 锁并清空引用 + if (readerRef.current) { + // readerRef.current.releaseLock(); + readerRef.current = null; + } + } + } catch (runError) { + // 请求失败处理 + console.error('单轮RAG请求失败:', runError); + setError(runError instanceof Error ? runError.message : '发生未知错误,请稍后重试'); + } finally { + // 无论成功还是失败,都要重置加载状态和解锁提交 + setIsPreConnectLoading(false); + setIsLoading(false); + submitLockRef.current = false; + state.current.messageList = []; } }; @@ -402,479 +705,371 @@ const GeneralQA: React.FC = ({ const sleep = (ms: number) => new Promise((resolve) => { window.setTimeout(resolve, ms); }); - - /** - * 生成单轮 RAG 的本地模拟原始流。 - * - * 返回值刻意模拟接口的“对象字符串片段”形态,例如: - * `{answer: ',并附'}{answer: '带引用'}` 和 `{docs: [...]}`。 - * 调用方仍通过 `extractJsonObjects` 拆包,再通过 `parseKnowledgeBaseChunk` 解析, - * 这样可以覆盖粘包、连续对象和 docs 延迟到达等真实联调场景。 - */ - const createMockSingleRoundStream = (query: string): string[] => { - const answer = `用户正在使用单轮 RAG 提问:“${query}”。\n\n先从知识库里定位与问题最相关的片段,再把证据组织成可追溯的回答。\n我已基于模拟知识库完成检索,并按单轮 RAG 的方式生成回答。\n\n### 回答要点\n\n1. **先检索**:系统会围绕用户问题召回最相关的知识片段。[[1]]\n2. **再增强**:把召回内容注入上下文,约束模型只围绕证据回答。[[2]]\n3. **最后生成**:模型输出结论,并附带引用编号,方便用户核验来源。[[3]]\n\n当前内容是本地模拟的流式返回,用来验证一个一个 \`{answer: '模型输出'}\` 推送时的打字机效果。`; - - const docs: KnowledgeBaseChunk['docs'] = [ - { - title: '单轮 RAG 接入说明', - content: '单轮 RAG 通常包含问题输入、TopK 检索、Prompt 组装、模型生成和引用返回几个步骤。', - source: '知识库 / RAG 接入说明.md', - snippet: '单轮 RAG 通常包含问题输入、TopK 检索、Prompt 组装、模型生成和引用返回几个步骤。', - }, - { - title: '知识库问答接口协议', - content: "流式响应会持续返回 JSON 对象,每个对象可包含 answer 字段;结束或中间节点可携带 docs 参考资料。", - source: '接口文档 / knowledge_base_chat.json', - snippet: '流式响应会持续返回 JSON 对象,每个对象可包含 answer 字段。', - }, - { - title: '引用展示规范', - content: '回答中的引用编号应与 docs 顺序保持一致,前端可将 docs 映射为来源卡片供用户查看。', - source: '产品规范 / 引用展示规范.docx', - snippet: '回答中的引用编号应与 docs 顺序保持一致。', - }, - ]; - - const rawChunks: string[] = []; - const toSingleQuotedValue = (value: string) => `'${value - .replace(/\\/g, '\\\\') - .replace(/\n/g, '\\n') - .replace(/\r/g, '\\r') - .replace(/\t/g, '\\t') - .replace(/'/g, "\\'")}'`; - let pendingObjectText = ''; - const pushAnswerObject = (answerToken: string) => { - const objectText = `{answer: ${toSingleQuotedValue(answerToken)}}`; - if (pendingObjectText) { - rawChunks.push(`${pendingObjectText}${objectText}`); - pendingObjectText = ''; - return; - } - if (answerToken === ',并附') { - pendingObjectText = objectText; - return; - } - rawChunks.push(objectText); - }; - const thinkStart = ''; - const thinkEnd = ''; - const startIndex = answer.indexOf(thinkStart); - const endIndex = answer.indexOf(thinkEnd); - - if (startIndex === -1 || endIndex === -1) { - for (let index = 0; index < answer.length; index += 3) { - pushAnswerObject(answer.slice(index, index + 3)); - } - } else { - const beforeThink = answer.slice(0, startIndex); - const thinkingText = answer.slice(startIndex + thinkStart.length, endIndex); - - if (beforeThink) pushAnswerObject(beforeThink); - pushAnswerObject(thinkStart); - for (let index = 0; index < thinkingText.length; index += 3) { - pushAnswerObject(thinkingText.slice(index, index + 3)); - } - pushAnswerObject(thinkEnd); - const finalAnswerTokens = [ - '我已基于模拟知识库完成检索', - ',并附', - '带引用', - '带引用', - '。\n\n### 回答要点\n\n', - '1. **先检索**:系统会围绕用户问题召回最相关的知识片段。[[1]]\n', - '2. **再增强**:把召回内容注入上下文,约束模型只围绕证据回答。[[2]]\n', - '3. **最后生成**:模型输出结论,并附带引用编号,方便用户核验来源。[[3]]\n\n', - '当前内容是本地模拟的对象字符串流式返回,用来验证连续多个 `{answer: \'模型输出\'}` 和 `{docs: ...}` 的处理效果。', - ]; - finalAnswerTokens.forEach(pushAnswerObject); - } - - if (pendingObjectText) { - rawChunks.push(pendingObjectText); - } - rawChunks.push(`{docs: ${JSON.stringify(docs)}}`); - return rawChunks; - }; - - /** - * 执行单轮 RAG 本地模拟。 - * - * 模拟过程与真实接口保持一致:先创建空 assistant 消息,再持续解析对象字符串流, - * answer 片段进入打字机效果,docs 片段映射为引用来源。 - */ - const handleMockSingleRoundRun = async (query: string) => { - setError(null); - setIsLoading(true); - setIsPreConnectLoading(true); - mockSingleRoundStoppedRef.current = false; - - const assistantId = `assistant-${Date.now()}`; - let inThink = false; - let thinkStarted = false; - - const applyAnswerToken = (token: string) => { - setMessages((prev) => prev.map((msg) => { - if (msg.id !== assistantId || !msg.singleRagTrace) return msg; - - let thinkingMarkdown = msg.singleRagTrace.thinkingMarkdown; - let answerMarkdown = msg.singleRagTrace.answerMarkdown; - let thinkingComplete = msg.singleRagTrace.thinkingComplete || false; - let remaining = token; - - while (remaining) { - if (inThink) { - const endIdx = remaining.indexOf(''); - if (endIdx === -1) { - thinkingMarkdown += remaining; - remaining = ''; - } else { - thinkingMarkdown += remaining.slice(0, endIdx); - remaining = remaining.slice(endIdx + ''.length); - inThink = false; - thinkingComplete = true; - } - } else { - const startIdx = remaining.indexOf(''); - if (startIdx === -1) { - answerMarkdown += remaining; - remaining = ''; - } else { - answerMarkdown += remaining.slice(0, startIdx); - remaining = remaining.slice(startIdx + ''.length); - inThink = true; - thinkStarted = true; - } - } - } - - return { - ...msg, - singleRagTrace: { - ...msg.singleRagTrace, - thinkingMarkdown, - answerMarkdown, - thinkingComplete: thinkStarted ? thinkingComplete : true, - }, - }; - })); - }; - - try { - await sleep(300); - if (mockSingleRoundStoppedRef.current) return; - setIsPreConnectLoading(false); - - const initialTrace: QASingleRagTrace = { - title: '单轮 RAG 思考过程', - thinkingMarkdown: '', - answerMarkdown: '', - thinkingComplete: false, + const updatePane = (side: 'left' | 'right', updater: (pane: ComparePane) => ComparePane) => { + setCompareResult((prev) => prev.map((msg) => { + if (msg.id !== state.current.compareId) return msg + return { + ...msg, + [side]: updater(msg[side]), }; - setMessages((prev) => [...prev, { - id: assistantId, - role: 'assistant', - content: '', - timestamp: new Date(), - singleRagTrace: initialTrace, - sources: [], - }]); - - const streamChunks = createMockSingleRoundStream(query); - let buffer = ''; - for (const rawChunk of streamChunks) { - if (mockSingleRoundStoppedRef.current) break; - - buffer += rawChunk; - const extracted = extractJsonObjects(buffer); - buffer = extracted.rest; - - for (const jsonText of extracted.objects) { - try { - const chunk = parseKnowledgeBaseChunk(jsonText); - if (typeof chunk.answer === 'string') { - applyAnswerToken(chunk.answer); - await sleep(45); - } - if (chunk.docs) { - const mappedDocs = mapDocsToSources(chunk.docs); - setSearchProcess((prev) => ({ ...prev, hits: prev.hits + mappedDocs.length })); - setMessages((prev) => prev.map((msg) => ( - msg.id === assistantId ? { ...msg, sources: [...(msg.sources || []), ...mappedDocs] } : msg - ))); - } - } catch (parseError) { - console.error('单轮RAG模拟流式JSON解析错误:', parseError); - } - } - } - } catch (runError) { - console.error('单轮RAG模拟流失败:', runError); - setError(runError instanceof Error ? runError.message : '发生未知错误,请稍后重试'); - } finally { - setIsPreConnectLoading(false); - setIsLoading(false); - mockSingleRoundStoppedRef.current = false; - submitLockRef.current = false; - } + })); }; - /** * 处理多轮真实接口里的工具调用事件。 * * `show_text` 用于追加思考或最终回答;`local_search` / `tavily_search` 用于把检索结果填入最近一轮。 * 这里保持事件到 UI trace 的单向映射,避免请求层和展示层互相耦合。 */ - const handleToolCall = (toolData: ToolCallData, assistantId: string) => { - setMessages((prevMessages) => prevMessages.map((msg) => { - if (msg.id !== assistantId || msg.role !== 'assistant' || !msg.thinkingTrace) return msg; - - const trace = { ...msg.thinkingTrace, rounds: [...msg.thinkingTrace.rounds] }; - const lastRound = trace.rounds[trace.rounds.length - 1]; - - switch (toolData.tool_name) { - case 'show_text': { - const chatText = toolData.tool_input.text || ''; - if (chatText.includes('')) { - const thinkContent = chatText.replace('', '').replace('', '').trim(); + const handleToolCall = (toolData: ToolCallData, assistantId: string, side: 'left' | 'right' | null = null) => { + let mesItem: any = state.current.messageList[1]; + if (side === 'left' || side === 'right') { + mesItem = state.current.compareResult[state.current.compareIndex][side]; + console.log('mesItem', mesItem) + } + const trace = { ...mesItem.thinkingTrace, rounds: [...mesItem.thinkingTrace.rounds] }; + const lastRound = trace.rounds[trace.rounds.length - 1]; + switch (toolData.tool_name) { + case 'show_text': { + const chatText = toolData.tool_input.text || ''; + if (chatText.includes('')) { + const thinkContent = chatText.replace('', '').replace('', '').trim(); + if (trace.rounds[0].title !== '正在准备检索') { + lastRound.queryLoading = false; + lastRound.queryShow = lastRound.retrievals.length > 0; trace.rounds.push({ id: `round-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, title: '思考中', thoughtMarkdown: thinkContent, query: '', queryLoading: true, + queryShow: true, retrievals: [], }); } else { - trace.finalAnswerMarkdown += chatText; + // lastRound.queryLoading = false; + trace.rounds[0].title = '思考过程' + trace.rounds[0].thoughtMarkdown = thinkContent; } - break; + } else { + lastRound.queryLoading = false; + trace.finalAnswerMarkdown += chatText; + trace.thinkingComplete = true; } - case 'tavily_search': - case 'local_search': { - if (toolData.tool_input.result && lastRound) { - try { - const result = JSON.parse(toolData.tool_input.result) as { - query?: string; - results?: Array<{ title?: string; content?: string; url?: string }>; - }; + break; + } + case 'tavily_search': + case 'local_search': { + if (toolData.tool_input.result && lastRound) { + try { + const result = JSON.parse(toolData.tool_input.result) as { + query?: string; + results?: Array<{ title?: string; content?: string; url?: string }>; + }; - lastRound.query = result.query || lastRound.query; - lastRound.queryLoading = false; - lastRound.title = toolData.tool_name === 'local_search' ? '本地知识检索' : '联网检索'; - lastRound.retrievals = (result.results || []).map((item, idx) => ({ - id: `${toolData.tool_name}-${idx}`, - title: item.title || '未命名结果', - content: item.content || '', - source: item.url || '', - })); - } catch (parseError) { - console.error('搜索结果JSON解析错误:', parseError); + lastRound.query = result.query || lastRound.query; + lastRound.queryLoading = false; + lastRound.title = toolData.tool_name === 'local_search' ? '本地知识检索' : '联网检索'; + lastRound.retrievals = (result.results || []).map((item, idx) => ({ + id: `${toolData.tool_name}-${idx}`, + title: item.title || '未命名结果', + content: item.content || '', + source: item.url || '', + })); + } catch (parseError) { + console.error('搜索结果JSON解析错误:', parseError); + } + } + break; + } + case 'google_search': { // 处理多轮检索 + if (toolData.tool_input.result && lastRound) { + try { + const result = JSON.parse(toolData.tool_input.result) as { + organic: Array<{ title?: string; content?: string; url?: string; recUuid?: string; publish_time?: string }>; + }; + lastRound.queryLoading = false; + lastRound.title = '思考过程'; + lastRound.retrievals = (result.organic || []).map((item, idx) => ({ + id: item.recUuid, + title: item.title || '未命名结果', + content: item.content || '', + source: item.recUuid || '', + time: item.publish_time, + recUuid: item.recUuid, + type: 'more' + })); + } catch (parseError) { + console.error('搜索结果JSON解析错误:', parseError); + } + } else if (toolData.tool_input.q) { + lastRound.query = toolData.tool_input.q; + } + break; + } + case 'getTaskLikeTop': { // 任务检索 + if (toolData.tool_input.result && lastRound) { + try { + const result = JSON.parse(toolData.tool_input.result) as { + data: Array<{ taskName?: string; overview?: string; id?: number; keywords?: string; taskDirection?: string; taskLevel?: number; taskStartDate: string; taskEndDate: string }>; } + console.log('result', result); + lastRound.queryLoading = false; + lastRound.title = '思考过程'; + lastRound.retrievals = (result.data || []).map((item, idx) => ({ + id: String(item.id), + title: item.taskName || '未命名结果', + content: item.overview || '', + keywords: item.keywords, + taskDirection: item.taskDirection, + time: `${item.taskStartDate}-${item.taskEndDate}`, + type: 'task' + })); + } catch (parseError) { + console.error('搜索结果JSON解析错误:', parseError); } - break; + } else if (toolData.tool_input.keyword) { + lastRound.query = toolData.tool_input.keyword; } - default: - break; + break; } - - return { - ...msg, + case 'search_user_profile': { // 画像检索 + lastRound.title = '靶标平台画像库'; + if (toolData.tool_input.result && lastRound) { + try { + const result = JSON.parse(toolData.tool_input.result) as Array<{ user_name?: string; user_id?: string; page_content?: string; 平台?: string; 类型?: string; 粉丝数?: number; }>; + console.log('result', result); + lastRound.queryLoading = false; + lastRound.retrievals = (result || []).map((item, idx) => ({ + id: item.user_id, + title: item.user_name || item.user_id || '-', + content: item.page_content || '', + platform: platformTypeChange(item.平台) || '', + fans: item.粉丝数 || 0, + type: 'hx' + })); + } catch (parseError) { + console.error('搜索结果JSON解析错误:', parseError); + } + } else if (toolData.tool_input.keyword || toolData.tool_input.query) { + lastRound.queryTitle = '检索词'; + lastRound.query = toolData.tool_input.keyword || toolData.tool_input.query || toolData.tool_input.user_fig; + } + break; + } + case 'get_taiwan_news': { // 查找台湾热点新闻 + lastRound.title = '台湾热点新闻'; + if (toolData.tool_input.result && lastRound) { + try { + const result = JSON.parse(toolData.tool_input.result) as Array<{ user_name?: string; user_id?: string; content?: string; publish_time?: string; message_id?: string; platform?: string; comment_count?: number; }> + console.log('get_taiwan_news', result); + lastRound.queryLoading = false; + lastRound.retrievals = (result || []).map((item, idx) => ({ + id: item.user_id, + title: item.user_name || item.user_id || '-', + content: item.content || '', + platform: platformTypeChange(item.platform) || '', + comment_count: item.comment_count, + message_id: item.message_id, + time: item.publish_time, + type: 'taiwan_news' + })); + } catch (parseError) { + console.error('搜索结果JSON解析错误:', parseError); + } + } else if (toolData.tool_input.keyword || toolData.tool_input.query) { + lastRound.queryTitle = '搜索台湾新闻'; + lastRound.query = toolData.tool_input.keyword || toolData.tool_input.query; + } + break; + } + case 'search_keyword_taiwan_news': { // 查找台湾热点新闻 + lastRound.title = '台湾热点新闻'; + if (toolData.tool_input.result && lastRound) { + try { + const result = JSON.parse(toolData.tool_input.result) as Array<{ user_name?: string; user_id?: string; content?: string; publish_time?: string; message_id?: string; platform?: string; comment_count?: number; }> + console.log('get_taiwan_news', result); + lastRound.queryLoading = false; + lastRound.retrievals = (result || []).map((item, idx) => ({ + id: item.user_id, + title: item.user_name || item.user_id || '-', + content: item.content || '', + platform: platformTypeChange(item.platform) || '', + comment_count: item.comment_count, + message_id: item.message_id, + time: item.publish_time, + type: 'taiwan_news' + })); + } catch (parseError) { + console.error('搜索结果JSON解析错误:', parseError); + } + } else if (toolData.tool_input.keyword || toolData.tool_input.query) { + lastRound.queryTitle = '搜索台湾新闻'; + lastRound.query = toolData.tool_input.keyword || toolData.tool_input.query; + } + break; + } + case 'get_world_news': { + lastRound.title = '世界热点新闻'; + if (toolData.tool_input.result && lastRound) { + try { + const result = JSON.parse(toolData.tool_input.result) as Array<{ user_name?: string; user_id?: string; content?: string; publish_time?: string; message_id?: string; platform?: string; comment_count?: number; }> + console.log('get_taiwan_news', result); + lastRound.queryLoading = false; + lastRound.retrievals = (result || []).map((item, idx) => ({ + id: item.user_id, + title: item.user_name || item.user_id || '-', + content: item.content || '', + platform: platformTypeChange(item.platform) || '', + comment_count: item.comment_count, + message_id: item.message_id, + time: item.publish_time, + type: 'world_news' + })); + } catch (parseError) { + console.error('搜索结果JSON解析错误:', parseError); + } + } else if (toolData.tool_input.keyword || toolData.tool_input.query) { + lastRound.queryTitle = '搜索世界新闻'; + lastRound.query = toolData.tool_input.keyword || toolData.tool_input.query; + } + break; + } + case 'get_root_messaage_comment': { // 获取某条消息的全部评论 + lastRound.title = '获取该消息的全部评论'; + if (toolData.tool_input.result && lastRound) { + try { + const result = JSON.parse(toolData.tool_input.result) as Array<{ user_name?: string; user_id?: string; content?: string; publish_time?: string; message_id?: string; platform?: string; comment_count?: number; }> + console.log('get_taiwan_news', result); + lastRound.queryLoading = false; + lastRound.retrievals = (result || []).map((item, idx) => ({ + id: item.user_id, + title: item.user_name || item.user_id || '-', + content: item.content || '', + platform: platformTypeChange(item.platform) || '', + comment_count: item.comment_count, + message_id: item.message_id, + time: item.publish_time, + type: 'messaage_comment' + })); + } catch (parseError) { + console.error('搜索结果JSON解析错误:', parseError); + } + } else if (toolData.tool_input.keyword || toolData.tool_input.query || toolData.tool_input.platform) { + lastRound.queryTitle = '查找平台'; + lastRound.query = toolData.tool_input.keyword || toolData.tool_input.query || platformTypeChange(toolData.tool_input.platform); + } + break; + } + case 'search_user_behavior': { // 查询该用户的历史行为 + lastRound.title = '查询该用户的历史行为'; + if (toolData.tool_input.result && lastRound) { + try { + const result = JSON.parse(toolData.tool_input.result); + console.log('search_user_behavior', result); + lastRound.queryLoading = false; + lastRound.retrievals = (result.slice(0, 10) || []).map((item, idx) => ({ + id: item, + title: item, + content: item, + type: 'user_behavior' + })); + } catch (parseError) { + console.error('搜索结果JSON解析错误:', parseError); + } + } else if (toolData.tool_input.keyword || toolData.tool_input.query || toolData.tool_input.platform) { + lastRound.queryTitle = '查找平台'; + lastRound.query = toolData.tool_input.keyword || toolData.tool_input.query || platformTypeChange(toolData.tool_input.platform); + } + break; + } + case 'get_america_news': { // 根据关键词获取美国热点新闻 + lastRound.title = '美国热点新闻'; + if (toolData.tool_input.result && lastRound) { + try { + const result = JSON.parse(toolData.tool_input.result) as Array<{ user_name?: string; user_id?: string; content?: string; publish_time?: string; message_id?: string; platform?: string; comment_count?: number; }> + console.log('get_taiwan_news', result); + lastRound.queryLoading = false; + lastRound.retrievals = (result || []).map((item, idx) => ({ + id: item.user_id, + title: item.user_name || item.user_id || '-', + content: item.content || '', + platform: platformTypeChange(item.platform) || '', + comment_count: item.comment_count, + message_id: item.message_id, + time: item.publish_time, + type: 'america_news' + })); + } catch (parseError) { + console.error('搜索结果JSON解析错误:', parseError); + } + } else if (toolData.tool_input.keyword || toolData.tool_input.query) { + lastRound.queryTitle = '查询美国热点新闻'; + lastRound.query = toolData.tool_input.keyword || toolData.tool_input.query; + } + break; + } + case 'get_time_data': { // 态势新闻搜索 + lastRound.title = '态势新闻搜索'; + console.log('get_time_data', toolData.tool_input) + if (toolData.tool_input.result && lastRound) { + try { + const result = JSON.parse(toolData.tool_input.result) as Array<{ title?: string; textid?: string; hot_num?: number; date?: string; content?: string }> + console.log('get_time_data', result); + lastRound.queryLoading = false; + lastRound.queryTitle = '查询态势新闻'; + lastRound.retrievals = (result || []).map((item, idx) => ({ + id: item.textid, + title: item.title, + content: item.content || '', + time: item.date, + hot_num: item.hot_num, + type: 'get_time_data' + })); + } catch (parseError) { + console.error('搜索结果JSON解析错误:', parseError); + } + } else if (toolData.tool_input.keyword || toolData.tool_input.query || toolData.tool_input.s || toolData.tool_input.e) { + lastRound.query = `${toolData.tool_input.s} - ${toolData.tool_input.e}`; + } + break; + } + case 'get_textid_content': { // 获取新闻详细内容 + lastRound.title = '获取新闻详细内容'; + if (toolData.tool_input.result && lastRound) { + try { + const result = JSON.parse(toolData.tool_input.result) + console.log('get_textid_content', result); + lastRound.queryLoading = false; + lastRound.textidContent = result.content; + } catch (parseError) { + console.error('搜索结果JSON解析错误:', parseError); + } + } + break; + } + default: + break; + } + if (!side) { + state.current.messageList[1] = { + ...mesItem, thinkingTrace: trace, - }; - })); - }; - - /** - * 构造多轮 RAG 模拟 trace 的初始骨架。 - * - * 每一轮先展示 thought/query/loading,随后 `handleMockMultiRoundRun` 再按轮次填充 retrievals, - * 用来模拟“检索过程逐步展开”的体验。 - */ - const createMockMultiRoundTrace = (query: string): QAThinkingTrace => ({ - title: '多轮检索思考过程', - rounds: [ - { - id: `mock-round-1-${Date.now()}`, - title: '第一轮:理解问题并确定主检索方向', - thoughtMarkdown: `用户问题是:“${query}”。\n\n先判断问题核心意图,并抽取关键词:\n\n- 业务对象\n- 关键约束\n- 需要引用的制度或知识片段\n\n第一轮先用原始问题做宽召回,尽量覆盖相关资料。`, - query: `${query} 相关制度 知识库 处理流程`, - queryLoading: true, - retrievals: [], - }, - { - id: `mock-round-2-${Date.now()}`, - title: '第二轮:基于首轮结果改写 Query', - thoughtMarkdown: '首轮检索获得了基础资料,但仍需要补充更精确的执行口径。\n\n本轮将问题改写为更适合检索的短查询,并优先召回流程、规范和案例类资料。', - query: `${query} 操作规范 适用条件 案例`, - queryLoading: true, - retrievals: [], - }, - { - id: `mock-round-3-${Date.now()}`, - title: '第三轮:补充边界条件并交叉验证', - thoughtMarkdown: '前两轮已经覆盖主要结论,最后继续检索边界条件,避免回答过于绝对。\n\n重点确认:\n\n1. 是否存在例外场景\n2. 是否需要人工复核\n3. 引用资料之间是否一致', - query: `${query} 例外场景 风险点 人工复核`, - queryLoading: true, - retrievals: [], - }, - ], - finalAnswerMarkdown: '', - }); - - // 多轮 RAG 每一轮对应一组检索结果;数组下标与 trace.rounds 的下标保持一致。 - const mockMultiRoundRetrievals: QAThinkingRound['retrievals'][] = [ - [ - { - id: 'mock-multi-r1-1', - title: '业务知识库问答配置指南', - content: '多轮检索适合复杂问题,系统会先宽召回,再根据首轮结果继续改写查询并补充证据。', - source: '知识库 / 业务知识库问答配置指南.md', - }, - { - id: 'mock-multi-r1-2', - title: '智能问答流程说明', - content: '当用户问题包含多个约束条件时,需要保留原始问题,同时拆分出检索关键词用于后续召回。', - source: '流程库 / 智能问答流程说明.pdf', - }, - ], - [ - { - id: 'mock-multi-r2-1', - title: 'RAG Query 改写最佳实践', - content: 'Query 改写应减少口语表达,突出实体、动作、条件和目标资料类型,以提升命中率。', - source: '技术资料 / RAG Query 改写最佳实践.docx', - }, - { - id: 'mock-multi-r2-2', - title: '知识库召回与重排策略', - content: '建议先召回 Top50,再通过重排模型筛选 Top5 至 Top8 作为最终上下文。', - source: '算法组 / 知识库召回与重排策略.pptx', - }, - { - id: 'mock-multi-r2-3', - title: '企业知识问答案例集', - content: '复杂业务问答通常需要结合制度、FAQ、历史案例三类资料综合判断。', - source: '案例库 / 企业知识问答案例集.xlsx', - }, - ], - [ - { - id: 'mock-multi-r3-1', - title: '回答可信度与引用规范', - content: '生成答案时应明确结论依据,并在涉及例外条件时给出风险提示或人工复核建议。', - source: '规范库 / 回答可信度与引用规范.md', - }, - { - id: 'mock-multi-r3-2', - title: '知识库答案质检规则', - content: '当多个资料结论不完全一致时,应优先采用发布时间较新、来源更权威的资料。', - source: '质检库 / 知识库答案质检规则.pdf', - }, - ], - ]; - - /** - * 执行多轮 RAG 本地模拟。 - * - * 该方法按轮次延迟填充检索结果,最后分段追加最终回答,模拟真实多轮检索的“思考-召回-总结”节奏。 - * 完成后把所有 retrievals 汇总为 sources,供引用来源折叠面板展示。 - */ - const handleMockMultiRoundRun = async (query: string) => { - setError(null); - setIsLoading(true); - setIsPreConnectLoading(true); - mockMultiRoundStoppedRef.current = false; - - const assistantId = `assistant-${Date.now()}`; - - try { - await sleep(350); - if (mockMultiRoundStoppedRef.current) return; - setIsPreConnectLoading(false); - - const trace = createMockMultiRoundTrace(query); - setMessages((prev) => [...prev, { - id: assistantId, - role: 'assistant', - content: '', - timestamp: new Date(), - thinkingTrace: trace, - sources: [], - }]); - - for (let roundIndex = 0; roundIndex < trace.rounds.length; roundIndex += 1) { - if (mockMultiRoundStoppedRef.current) break; - await sleep(700); - setMessages((prev) => prev.map((msg) => { - if (msg.id !== assistantId || !msg.thinkingTrace) return msg; - const rounds = msg.thinkingTrace.rounds.map((round, idx) => ( - idx === roundIndex - ? { - ...round, - queryLoading: false, - retrievals: mockMultiRoundRetrievals[roundIndex], - } - : round - )); - return { - ...msg, - thinkingTrace: { - ...msg.thinkingTrace, - rounds, - }, - }; - })); } - - const finalAnswerTokens = [ - `基于多轮检索,我对“${query}”形成如下结论:\n\n`, - '### 综合结论\n\n', - '1. **先做宽召回**:第一轮保留原始问题语义,覆盖相关制度、流程和 FAQ。\n', - '2. **再做 Query 改写**:第二轮把问题拆成实体、动作、条件,提升命中文档质量。\n', - '3. **最后做交叉验证**:第三轮补充边界条件和风险点,避免只依据单一资料下结论。\n\n', - '### 建议\n\n', - '如果是生产环境,可以把多轮检索结果按“相关性、权威性、时效性”排序,再把 TopK 证据注入模型生成最终回答。', - ]; - - for (const token of finalAnswerTokens) { - if (mockMultiRoundStoppedRef.current) break; - setMessages((prev) => prev.map((msg) => { - if (msg.id !== assistantId || !msg.thinkingTrace) return msg; - return { - ...msg, - thinkingTrace: { - ...msg.thinkingTrace, - finalAnswerMarkdown: `${msg.thinkingTrace.finalAnswerMarkdown}${token}`, - }, - }; - })); - await sleep(180); - } - - const sources = mockMultiRoundRetrievals.flat().map((item) => ({ - title: item.title, - type: '参考资料', - snippet: item.content, - source: item.source, - content: item.content, + setMessages((prevMessages) => prevMessages.map((msg) => { + if (msg.id !== assistantId || msg.role !== 'assistant' || !msg.thinkingTrace) return msg; + return { + ...msg, + thinkingTrace: trace, + }; })); - setSearchProcess((prev) => ({ - ...prev, - rounds: trace.rounds.length, - hits: sources.length, - queryRewrite: '原问题 → 宽召回 → Query 改写 → 边界条件补充 → 综合回答', - })); - setMessages((prev) => prev.map((msg) => ( - msg.id === assistantId ? { ...msg, sources } : msg - ))); - } catch (runError) { - console.error('多轮RAG模拟失败:', runError); - setError(runError instanceof Error ? runError.message : '发生未知错误,请稍后重试'); - } finally { - setIsPreConnectLoading(false); - setIsLoading(false); - mockMultiRoundStoppedRef.current = false; - submitLockRef.current = false; + } else { + state.current.compareResult[state.current.compareIndex][side].thinkingTrace = trace; + updatePane(side, (pane) => { + return { + ...pane, + thinkingTrace: trace, + }; + }); } }; @@ -884,16 +1079,15 @@ const GeneralQA: React.FC = ({ * 多轮接口采用 SSE:每行 `data:` 都描述一个事件。组件根据事件类型分别更新 taskId、工具调用、 * 最终回答和引用来源。当前用户入口暂时走本地模拟,此方法保留给接口连通后切换使用。 */ - const handleRun = async (query: string) => { + const handleRun = async (query: string, side: 'left' | 'right' | null = null) => { setError(null); setIsLoading(true); setIsPreConnectLoading(true); - try { - const response = await fetch('http://10.26.128.38:8049/api/run', { + const response = await fetch(`${generateUrl}/generate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ query }), + body: JSON.stringify({ prompt: query }), }); if (!response.ok) { @@ -913,18 +1107,346 @@ const GeneralQA: React.FC = ({ timestamp: new Date(), thinkingTrace: createMultiRoundStreamTrace(), }; - // 接口连通后再展示多轮检索主题与过程 - setMessages((prev) => [...prev, assistantMessage]); + if (!side) { + state.current.messageList.push(assistantMessage); // 数据存储 + // 接口连通后再展示多轮检索主题与过程 + setMessages((prev) => [...prev, assistantMessage]); + } else if (side === 'left' || side === 'right') { + state.current.compareResult[state.current.compareIndex][side].thinkingTrace = createMultiRoundStreamTrace(); + updatePane(side, (pane) => { + return { + ...pane, + thinkingTrace: createMultiRoundStreamTrace(), + }; + }); + } const reader = response.body.getReader(); - readerRef.current = reader; + // 保存 reader 引用,用于"停止生成"功能 + if (!side) { + readerRef.current = reader; + } else { + compareReaderRef.current[side] = reader; + } const decoder = new TextDecoder(); let buffer = ''; try { while (true) { const { done, value } = await reader.read(); - if (done) break; + if (done) { + console.log('聊天记录', state.current.compareResult); + if (!side) { + chatHistoryAddSearch(); + } + break; + } + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + const lastLine = lines.pop(); + buffer = lastLine || ''; + + for (const line of lines) { + if (!line.startsWith('data: ')) continue; + + try { + const parsedData = JSON.parse(line.slice(6)) as { + event: string; + data: any; + }; + const { event, data } = parsedData; + + switch (event) { + case 'task_created': { + taskIdRef.current = data?.task_id || null; + break; + } + case 'tool_call': { + handleToolCall(data as ToolCallData, assistantId, side); + break; + } + case 'message': { + if (typeof data === 'string') { + setMessages((prevMessages) => prevMessages.map((msg) => { + if (msg.id !== assistantId || !msg.thinkingTrace) return msg; + return { + ...msg, + thinkingTrace: { + ...msg.thinkingTrace, + finalAnswerMarkdown: `${msg.thinkingTrace.finalAnswerMarkdown}${data}`, + }, + }; + })); + } + break; + } + case 'references': { + const refs = Array.isArray(data?.references) ? data.references : []; + const mappedRefs = refs.map(mapReferenceToSource); + setSearchProcess((prev) => ({ + ...prev, + hits: mappedRefs.length, + })); + setMessages((prevMessages) => prevMessages.map((msg) => ( + msg.id === assistantId + ? { ...msg, sources: mappedRefs } + : msg + ))); + break; + } + case 'done': { + setIsLoading(false); + break; + } + default: + console.log(`未知事件类型: ${event}`, data); + } + } catch (parseError) { + console.error('JSON解析错误:', parseError); + console.error('原始数据:', line.slice(6)); + } + } + } + } finally { + if (readerRef.current) { + readerRef.current.releaseLock(); + readerRef.current = null; + } + } + } catch (runError) { + console.error('处理请求时发生错误:', runError); + setIsLoading(false); + setError(runError instanceof Error ? runError.message : '发生未知错误,请稍后重试'); + setIsPreConnectLoading(false); + } finally { + // 无论成功还是失败,都要重置加载状态和解锁提交 + setIsPreConnectLoading(false); + setIsLoading(false); + submitLockRef.current = false; + handleStopCompareSide(side); + } + }; + + /** + * 处理任务检索 请求。 + * + * 多轮接口采用 SSE:每行 `data:` 都描述一个事件。组件根据事件类型分别更新 taskId、工具调用、 + * 最终回答和引用来源。当前用户入口暂时走本地模拟,此方法保留给接口连通后切换使用。 + */ + const handleTaskRun = async (query: string, side: 'left' | 'right' | null = null) => { + setError(null); + setIsLoading(true); + setIsPreConnectLoading(true); + + try { + const response = await fetch(`${taskChatUrl}/generate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt: query }), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + if (!response.body) { + throw new Error('Response body is null'); + } + setIsPreConnectLoading(false); + + const assistantId = `assistant-${Date.now()}`; + const assistantMessage: Message = { + id: assistantId, + role: 'assistant', + content: '', + timestamp: new Date(), + thinkingTrace: createMultiRoundStreamTrace(), + }; + if (!side) { + state.current.messageList.push(assistantMessage); // 数据存储 + // 接口连通后再展示多轮检索主题与过程 + setMessages((prev) => [...prev, assistantMessage]); + } else if (side === 'left' || side === 'right') { + state.current.compareResult[state.current.compareIndex][side].thinkingTrace = createMultiRoundStreamTrace(); + updatePane(side, (pane) => { + return { + ...pane, + thinkingTrace: createMultiRoundStreamTrace(), + }; + }); + } + + const reader = response.body.getReader(); + if (!side) { + readerRef.current = reader; + } else { + compareReaderRef.current[side] = reader; + } + const decoder = new TextDecoder(); + let buffer = ''; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { + console.log('聊天记录', state.current.messageList); + chatHistoryAddSearch(); + break; + } + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + const lastLine = lines.pop(); + buffer = lastLine || ''; + + for (const line of lines) { + if (!line.startsWith('data: ')) continue; + + try { + const parsedData = JSON.parse(line.slice(6)) as { + event: string; + data: any; + }; + const { event, data } = parsedData; + + switch (event) { + case 'task_created': { + taskIdRef.current = data?.task_id || null; + break; + } + case 'tool_call': { + handleToolCall(data as ToolCallData, assistantId, side); + break; + } + case 'message': { + if (typeof data === 'string') { + setMessages((prevMessages) => prevMessages.map((msg) => { + if (msg.id !== assistantId || !msg.thinkingTrace) return msg; + return { + ...msg, + thinkingTrace: { + ...msg.thinkingTrace, + finalAnswerMarkdown: `${msg.thinkingTrace.finalAnswerMarkdown}${data}`, + }, + }; + })); + } + break; + } + case 'references': { + const refs = Array.isArray(data?.references) ? data.references : []; + const mappedRefs = refs.map(mapReferenceToSource); + setSearchProcess((prev) => ({ + ...prev, + hits: mappedRefs.length, + })); + setMessages((prevMessages) => prevMessages.map((msg) => ( + msg.id === assistantId + ? { ...msg, sources: mappedRefs } + : msg + ))); + break; + } + case 'done': { + setIsLoading(false); + break; + } + default: + console.log(`未知事件类型: ${event}`, data); + } + } catch (parseError) { + console.error('JSON解析错误:', parseError); + console.error('原始数据:', line.slice(6)); + } + } + } + } finally { + if (readerRef.current) { + readerRef.current.releaseLock(); + readerRef.current = null; + } + } + } catch (runError) { + console.error('处理请求时发生错误:', runError); + setIsLoading(false); + setError(runError instanceof Error ? runError.message : '发生未知错误,请稍后重试'); + setIsPreConnectLoading(false); + } finally { + // 无论成功还是失败,都要重置加载状态和解锁提交 + setIsPreConnectLoading(false); + setIsLoading(false); + submitLockRef.current = false; + handleStopCompareSide(side); + } + }; + + /** +* 处理画像检索 请求。 +* +* 多轮接口采用 SSE:每行 `data:` 都描述一个事件。组件根据事件类型分别更新 taskId、工具调用、 +* 最终回答和引用来源。当前用户入口暂时走本地模拟,此方法保留给接口连通后切换使用。 +*/ + const handleHxRun = async (query: string, side: 'left' | 'right' | null = null) => { + setError(null); + setIsLoading(true); + setIsPreConnectLoading(true); + + try { + const response = await fetch(`${taskChatUrl}/generate_b2`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt: query }), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + if (!response.body) { + throw new Error('Response body is null'); + } + setIsPreConnectLoading(false); + + const assistantId = `assistant-${Date.now()}`; + const assistantMessage: Message = { + id: assistantId, + role: 'assistant', + content: '', + timestamp: new Date(), + thinkingTrace: createMultiRoundStreamTrace(), + }; + if (!side) { + state.current.messageList.push(assistantMessage); // 数据存储 + // 接口连通后再展示多轮检索主题与过程 + setMessages((prev) => [...prev, assistantMessage]); + } else if (side === 'left' || side === 'right') { + state.current.compareResult[state.current.compareIndex][side].thinkingTrace = createMultiRoundStreamTrace(); + updatePane(side, (pane) => { + return { + ...pane, + thinkingTrace: createMultiRoundStreamTrace(), + }; + }); + } + + const reader = response.body.getReader(); + if (!side) { + readerRef.current = reader; + } else { + compareReaderRef.current[side] = reader; + } + const decoder = new TextDecoder(); + let buffer = ''; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { + console.log('聊天记录', state.current.messageList); + chatHistoryAddSearch(); + break; + } buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); @@ -1004,7 +1526,164 @@ const GeneralQA: React.FC = ({ setError(runError instanceof Error ? runError.message : '发生未知错误,请稍后重试'); setIsPreConnectLoading(false); } finally { + // 无论成功还是失败,都要重置加载状态和解锁提交 + setIsPreConnectLoading(false); + setIsLoading(false); submitLockRef.current = false; + handleStopCompareSide(side); + } + }; + + /** +* 处理画像检索 请求。 +* +* 多轮接口采用 SSE:每行 `data:` 都描述一个事件。组件根据事件类型分别更新 taskId、工具调用、 +* 最终回答和引用来源。当前用户入口暂时走本地模拟,此方法保留给接口连通后切换使用。 +*/ + const newsReportRun = async (query: string, side: 'left' | 'right' | null = null) => { + setError(null); + setIsLoading(true); + setIsPreConnectLoading(true); + + try { + const response = await fetch(`${taskChatUrl}/generate_report`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt: query }), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + if (!response.body) { + throw new Error('Response body is null'); + } + setIsPreConnectLoading(false); + + const assistantId = `assistant-${Date.now()}`; + const assistantMessage: Message = { + id: assistantId, + role: 'assistant', + content: '', + timestamp: new Date(), + thinkingTrace: createMultiRoundStreamTrace(), + }; + if (!side) { + state.current.messageList.push(assistantMessage); // 数据存储 + // 接口连通后再展示多轮检索主题与过程 + setMessages((prev) => [...prev, assistantMessage]); + } else if (side === 'left' || side === 'right') { + state.current.compareResult[state.current.compareIndex][side].thinkingTrace = createMultiRoundStreamTrace(); + updatePane(side, (pane) => { + return { + ...pane, + thinkingTrace: createMultiRoundStreamTrace(), + }; + }); + } + + const reader = response.body.getReader(); + if (!side) { + readerRef.current = reader; + } else { + compareReaderRef.current[side] = reader; + } + const decoder = new TextDecoder(); + let buffer = ''; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { + console.log('聊天记录', state.current.messageList); + chatHistoryAddSearch(); + break; + } + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + const lastLine = lines.pop(); + buffer = lastLine || ''; + + for (const line of lines) { + if (!line.startsWith('data: ')) continue; + + try { + const parsedData = JSON.parse(line.slice(6)) as { + event: string; + data: any; + }; + const { event, data } = parsedData; + + switch (event) { + case 'task_created': { + taskIdRef.current = data?.task_id || null; + break; + } + case 'tool_call': { + handleToolCall(data as ToolCallData, assistantId); + break; + } + case 'message': { + if (typeof data === 'string') { + setMessages((prevMessages) => prevMessages.map((msg) => { + if (msg.id !== assistantId || !msg.thinkingTrace) return msg; + return { + ...msg, + thinkingTrace: { + ...msg.thinkingTrace, + finalAnswerMarkdown: `${msg.thinkingTrace.finalAnswerMarkdown}${data}`, + }, + }; + })); + } + break; + } + case 'references': { + const refs = Array.isArray(data?.references) ? data.references : []; + const mappedRefs = refs.map(mapReferenceToSource); + setSearchProcess((prev) => ({ + ...prev, + hits: mappedRefs.length, + })); + setMessages((prevMessages) => prevMessages.map((msg) => ( + msg.id === assistantId + ? { ...msg, sources: mappedRefs } + : msg + ))); + break; + } + case 'done': { + setIsLoading(false); + break; + } + default: + console.log(`未知事件类型: ${event}`, data); + } + } catch (parseError) { + console.error('JSON解析错误:', parseError); + console.error('原始数据:', line.slice(6)); + } + } + } + } finally { + if (readerRef.current) { + readerRef.current.releaseLock(); + readerRef.current = null; + } + } + } catch (runError) { + console.error('处理请求时发生错误:', runError); + setIsLoading(false); + setError(runError instanceof Error ? runError.message : '发生未知错误,请稍后重试'); + setIsPreConnectLoading(false); + } finally { + // 无论成功还是失败,都要重置加载状态和解锁提交 + setIsPreConnectLoading(false); + setIsLoading(false); + submitLockRef.current = false; + handleStopCompareSide(side); } }; @@ -1019,15 +1698,15 @@ const GeneralQA: React.FC = ({ if (!inputValue.trim()) return; submitLockRef.current = true; const query = inputValue.trim(); - + state.current.messageList = []; const userMessage: Message = { id: Date.now().toString(), role: 'user', content: query, timestamp: new Date(), }; - - if (qaMode === 'multi-round') { + state.current.messageList.push(userMessage); + if (qaMode === 'multi-round') { // 多轮检索 setMessages((prev) => [...prev, userMessage]); setInputValue(''); setSearchProcess({ @@ -1040,7 +1719,46 @@ const GeneralQA: React.FC = ({ return; } - if (qaMode === 'single-round') { + if (qaMode === 'deep-search') { // 任务检索 + setMessages((prev) => [...prev, userMessage]); + setInputValue(''); + setSearchProcess({ + currentMode: '任务检索', + rounds: 0, + hits: 0, + queryRewrite: '', + }); + void handleTaskRun(query); + return; + } + + if (qaMode === 'hx-search') { // 任务检索 + setMessages((prev) => [...prev, userMessage]); + setInputValue(''); + setSearchProcess({ + currentMode: '画像检索', + rounds: 0, + hits: 0, + queryRewrite: '', + }); + void handleHxRun(query); + return; + } + + if (qaMode === 'news-report') { // 新闻报告生成 + setMessages((prev) => [...prev, userMessage]); + setInputValue(''); + setSearchProcess({ + currentMode: '新闻报告生成', + rounds: 0, + hits: 0, + queryRewrite: '', + }); + void newsReportRun(query); + return; + } + + if (qaMode === 'single-round') { // 单轮RAG setMessages((prev) => [...prev, userMessage]); setInputValue(''); setSearchProcess({ @@ -1053,28 +1771,24 @@ const GeneralQA: React.FC = ({ return; } + if (qaMode === 'single-chat') { // 大模型问答 + console.log('进入了这里') + setMessages((prev) => [...prev, userMessage]); + setInputValue(''); + setSearchProcess({ + currentMode: '大模型问答', + rounds: 1, + hits: 0, + queryRewrite: '', + }); + void chatRun(query); + // handleMockSingleRoundRun(query); + return; + } + setIsLoading(true); setMessages((prev) => [...prev, userMessage]); setInputValue(''); - - // 模拟AI响应 - setTimeout(() => { - setMessages(prev => [...prev, { - id: `assistant-${Date.now()}`, - role: 'assistant', - content: '已完成深度检索和综合回答。', - timestamp: new Date(), - thinkingTrace: createThinkingTrace(qaMode), - }]); - setIsLoading(false); - submitLockRef.current = false; - setSearchProcess({ - currentMode: '深度检索', - rounds: 1, - hits: 5, - queryRewrite: qaMode === 'deep-search' ? '原问题→分解子问题→独立检索→综合回答' : '', - }); - }, 50000); }; /** @@ -1092,225 +1806,34 @@ const GeneralQA: React.FC = ({ setCompareUserInput(query); setError(null); setIsLoading(true); - setCompareResult({ + const compareId = Date.now().toString(); + console.log('compareLeftMode',compareLeftMode) + const compareItem = { + id: compareId, + compareUserInput: query, left: createComparePane(compareLeftMode), right: createComparePane(compareRightMode), - }); - - const updatePane = (side: 'left' | 'right', updater: (pane: ComparePane) => ComparePane) => { - setCompareResult((prev) => { - if (!prev) return prev; - return { - ...prev, - [side]: updater(prev[side]), - }; - }); - }; - - const runSingleForCompare = async (side: 'left' | 'right') => { - let inThink = false; - let thinkStarted = false; - - const response = await fetch('http://10.26.128.82:7863/chat/knowledge_base_chat', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - query, - knowledge_base_name: [], - top_k: 5, - score_threshold: scoreThreshold, - history: [], - stream: true, - model_name: modelName, - temperature, - prompt_name: 'default', - local_doc_url: false, - }), - }); - if (!response.ok || !response.body) throw new Error(`单轮RAG接口异常: ${response.status}`); - - const reader = response.body.getReader(); - compareReaderRef.current[side] = reader; - const decoder = new TextDecoder(); - let buffer = ''; - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - const extracted = extractJsonObjects(buffer); - buffer = extracted.rest; - for (const jsonText of extracted.objects) { - const parsed = parseKnowledgeBaseChunk(jsonText); - if (typeof parsed.answer === 'string') { - const token = parsed.answer; - updatePane(side, (pane) => { - const trace = pane.singleRagTrace || { - title: '单轮 RAG 思考过程', - thinkingMarkdown: '', - answerMarkdown: '', - thinkingComplete: false, - }; - let thinkingMarkdown = trace.thinkingMarkdown; - let answerMarkdown = trace.answerMarkdown; - let thinkingComplete = trace.thinkingComplete || false; - let remaining = token; - while (remaining) { - if (inThink) { - const endIdx = remaining.indexOf(''); - if (endIdx === -1) { - thinkingMarkdown += remaining; - remaining = ''; - } else { - thinkingMarkdown += remaining.slice(0, endIdx); - remaining = remaining.slice(endIdx + ''.length); - inThink = false; - thinkingComplete = true; - } - } else { - const startIdx = remaining.indexOf(''); - if (startIdx === -1) { - answerMarkdown += remaining; - remaining = ''; - } else { - answerMarkdown += remaining.slice(0, startIdx); - remaining = remaining.slice(startIdx + ''.length); - inThink = true; - thinkStarted = true; - } - } - } - return { - ...pane, - singleRagTrace: { - ...trace, - thinkingMarkdown, - answerMarkdown, - thinkingComplete: thinkStarted ? thinkingComplete : true, - }, - }; - }); - } - if (parsed.docs) { - updatePane(side, (pane) => ({ ...pane, sources: [...pane.sources, ...mapDocsToSources(parsed.docs)] })); - } - } - } - } finally { - reader.releaseLock(); - if (compareReaderRef.current[side] === reader) { - compareReaderRef.current[side] = null; - } - } - }; - - const runMultiForCompare = async (side: 'left' | 'right') => { - const response = await fetch('http://10.26.128.38:8049/api/run', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ query }), - }); - if (!response.ok || !response.body) throw new Error(`多轮检索接口异常: ${response.status}`); - - const reader = response.body.getReader(); - compareReaderRef.current[side] = reader; - const decoder = new TextDecoder(); - let buffer = ''; - - const applyToolCall = (toolData: ToolCallData) => { - updatePane(side, (pane) => { - const trace = pane.thinkingTrace || createMultiRoundStreamTrace(); - const rounds = [...trace.rounds]; - const lastRound = rounds[rounds.length - 1]; - switch (toolData.tool_name) { - case 'show_text': { - const chatText = toolData.tool_input.text || ''; - if (chatText.includes('')) { - const thinkContent = chatText.replace('', '').replace('', '').trim(); - rounds.push({ - id: `round-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, - title: '思考中', - thoughtMarkdown: thinkContent, - query: '', - queryLoading: true, - retrievals: [], - }); - } else { - trace.finalAnswerMarkdown += chatText; - } - break; - } - case 'tavily_search': - case 'local_search': { - if (toolData.tool_input.result && lastRound) { - const result = JSON.parse(toolData.tool_input.result) as { - query?: string; - results?: Array<{ title?: string; content?: string; url?: string }>; - }; - lastRound.query = result.query || lastRound.query; - lastRound.queryLoading = false; - lastRound.title = toolData.tool_name === 'local_search' ? '本地知识检索' : '联网检索'; - lastRound.retrievals = (result.results || []).map((item, idx) => ({ - id: `${toolData.tool_name}-${idx}`, - title: item.title || '未命名结果', - content: item.content || '', - source: item.url || '', - })); - } - break; - } - default: - break; - } - return { - ...pane, - thinkingTrace: { ...trace, rounds }, - }; - }); - }; - - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split('\n'); - const lastLine = lines.pop(); - buffer = lastLine || ''; - for (const line of lines) { - if (!line.startsWith('data: ')) continue; - const parsedData = JSON.parse(line.slice(6)) as { event: string; data: any }; - const { event, data } = parsedData; - if (event === 'tool_call') applyToolCall(data as ToolCallData); - if (event === 'message' && typeof data === 'string') { - updatePane(side, (pane) => { - const trace = pane.thinkingTrace || createMultiRoundStreamTrace(); - return { - ...pane, - thinkingTrace: { - ...trace, - finalAnswerMarkdown: `${trace.finalAnswerMarkdown}${data}`, - }, - }; - }); - } - if (event === 'references') { - const refs = Array.isArray(data?.references) ? data.references : []; - updatePane(side, (pane) => ({ ...pane, sources: refs.map(mapReferenceToSource) })); - } - } - } - } finally { - reader.releaseLock(); - if (compareReaderRef.current[side] === reader) { - compareReaderRef.current[side] = null; - } - } - }; - + } + setCompareResult((pev) => [...pev, compareItem]); + state.current.compareResult = []; + state.current.compareId = compareId; + state.current.compareResult.push(compareItem); + state.current.compareIndex = state.current.compareResult.length - 1; + console.log('compareItem', compareItem) const runByMode = (side: 'left' | 'right', mode: QAMode) => { - if (mode === 'single-round') return runSingleForCompare(side); - return runMultiForCompare(side); + if (mode === 'single-chat') { // 大模型问答 + return chatRun(query, side); + } else if (mode === 'single-round') { // 单轮rag检索 + return handleSingleRoundRun(query, side); + } else if (mode === 'multi-round') { // 多轮检索 + return handleRun(query, side); + } else if (mode === 'deep-search') { // 任务检索 + return handleTaskRun(query, side); + } else if (mode === 'hx-search') { // 画像检索 + return handleHxRun(query, side); + } else if (mode === 'news-report') { // 新闻检索 + return handleHxRun(query, side); + } }; Promise.allSettled([ @@ -1319,6 +1842,7 @@ const GeneralQA: React.FC = ({ ]).finally(() => { setIsLoading(false); submitLockRef.current = false; + chatHistoryAddSearch(); }); }; @@ -1328,16 +1852,7 @@ const GeneralQA: React.FC = ({ void reader.cancel(`用户停止${side === 'left' ? '左侧' : '右侧'}生成`); compareReaderRef.current[side] = null; } - setCompareResult((prev) => { - if (!prev) return prev; - return { - ...prev, - [side]: { - ...prev[side], - loading: false, - }, - }; - }); + updatePane(side, (pane) => ({ ...pane, loading: false })) }; /** @@ -1377,20 +1892,20 @@ const GeneralQA: React.FC = ({ return; } pendingActionRef.current = action; - Dialog.confirm({ - header: '模型正在生成', - body: '当前模型仍在生成内容。确认后将停止生成,并继续执行你的操作。', - confirmBtn: '确认停止并继续', - cancelBtn: '取消', - onConfirm: () => { - handleStop(); - pendingActionRef.current?.(); - pendingActionRef.current = null; - }, - onClose: () => { - pendingActionRef.current = null; - }, - }); + // Dialog.confirm({ + // header: '模型正在生成', + // body: '当前模型仍在生成内容。确认后将停止生成,并继续执行你的操作。', + // confirmBtn: '确认停止并继续', + // cancelBtn: '取消', + // onConfirm: () => { + // handleStop(); + // pendingActionRef.current?.(); + // pendingActionRef.current = null; + // }, + // onClose: () => { + // pendingActionRef.current = null; + // }, + // }); }; const handleSafeViewModeChange = (mode: ViewMode) => { @@ -1398,6 +1913,10 @@ const GeneralQA: React.FC = ({ runAfterGenerationConfirm(() => setViewMode(mode)); }; + const handleSafeNavigate = (page: string) => { + runAfterGenerationConfirm(() => onNavigate?.(page)); + }; + const handleSafeReset = () => { runAfterGenerationConfirm(() => { setMessages([]); @@ -1429,24 +1948,47 @@ const GeneralQA: React.FC = ({ alert('已添加到收藏'); }; - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - if (e.repeat || submitLockRef.current || isLoading || isPreConnectLoading) return; - if (viewMode === 'compare') { - handleCompare(); - } else { - handleSend(); - } + const handleKeyDown = (e) => { + // if (e.key === 'Enter' && !e.shiftKey) { + // console.log('回车了') + // e.preventDefault(); + // if (e.repeat || submitLockRef.current || isLoading || isPreConnectLoading) return; + // if (viewMode === 'compare') { + // handleCompare(); + // } else { + // handleSend(); + // } + // } + // console.log('e',e) + // if ((e.metaKey || e.ctrlKey || e.shiftKey) && e.key === "Enter") { + // e.preventDefault(); + // } else if (e.keyCode === 13) { + // e.preventDefault(); + // console.log('回车了') + // } else if (e.key === "/") { + // e.preventDefault(); + // } + // if (e.repeat || submitLockRef.current || isLoading || isPreConnectLoading) return; + state.current.messageList = []; + if (viewMode === 'compare') { + handleCompare(); + } else { + handleSend(); } + setIsAutoScrollEnabled(true); + setShowScrollToBottom(false); + scrollToBottom(true); }; // 获取模式标签 const getModeLabel = (mode: QAMode) => { switch (mode) { + case 'single-chat': return '大模型'; case 'single-round': return '单轮RAG'; case 'multi-round': return '多轮RAG'; - case 'deep-search': return '深度检索'; + case 'deep-search': return '任务检索'; + case 'hx-search': return '画像检索'; + case 'news-report': return '新闻报告生成'; } }; @@ -1456,14 +1998,147 @@ const GeneralQA: React.FC = ({ useEffect(() => { setQaMode('single-round'); - setMessages(createMockSingleRagMessages()); - setSearchProcess({ - currentMode: '单轮RAG', - rounds: 1, - hits: 3, - queryRewrite: '', - }); - }, []); + // setMessages(createMockSingleRagMessages()); + // setSearchProcess({ + // currentMode: '单轮RAG', + // rounds: 1, + // hits: 3, + // queryRewrite: '', + // }); + chatDataSearch(); + }, [viewMode]); + + // 创建聊天窗口 + const addChatDataChange = async () => { + let qaMode = 'SINGLE_RAG'; + if (viewMode === 'compare') { + qaMode = 'MULTI_RAG' + } + const parmy = { + userId: userId.current, + qaType: qaType.current, + questionTitle: '新建问答', + qaMode: qaMode, + } + const res = await service.post(`${zncmUrl}/llm/qa/history/create`, parmy); + console.log(res); + const result = res.data; + chatDataId.current = result.id; + if (viewMode === 'compare') { + compareChatId.current = result.id; + } else { + chatDataId.current = result.id; + } + } + + // 查找聊天记录窗口 + const chatDataSearch = async () => { + let qaMode = 'SINGLE_RAG'; + console.log('viewMode', viewMode) + if (viewMode === 'compare') { + qaMode = 'MULTI_RAG' + } + + const parmy = { + userId: userId.current, + qaType: qaType.current, + qaMode: qaMode, + current: 1, + size: 10 + } + const res = await service.post(`${zncmUrl}/llm/qa/history/page`, parmy); + console.log('res', res); + const result = res.data; + if (result.length === 0) { + await addChatDataChange(); + } else if (result.length > 0) { + if (viewMode === 'compare') { + compareChatId.current = result[0].id; + } else { + chatDataId.current = result[0].id; + } + } + await chatHistorySearch(); + console.log('chatDataId', chatDataId.current, compareChatId.current) + } + + // 根据对话记录窗口查找对话记录 + const chatHistorySearch = async () => { + let chatHistoryId = chatDataId.current; + if (viewMode === 'compare') { + chatHistoryId = compareChatId.current; + } + const res = await service.get(`${zncmUrl}/llm/qa/history/pairs/${chatHistoryId}`, ''); + console.log('问答记录', res); + // 1. 修正变量名 result + const result = res.data; + if (viewMode !== 'compare') { + // 2. 使用 flatMap 将二维数组拍平,并生成新的消息列表 + const messageList: Message[] = result.flatMap((item, itemIndex) => { + // 3. 安全解析 JSON,使用 ?? 提供空数组兜底 + const parsedAnswers = JSON.parse(item.answer ?? '[]'); + + // 4. map 处理每一条 answer,生成新的对象(不修改原对象) + return parsedAnswers.map((aItem: any, aIndex: number) => { + return { + ...aItem, + // 优化 ID 赋值逻辑:建议不管是不是索引 1,都补全 ID,防止遗漏 + // 如果必须只针对第二条,保留原逻辑,但增加安全性 + id: aIndex === 1 ? String(item.id) : (aItem.id || `temp-${itemIndex}-${aIndex}`), + + // 如果后端返回的数据缺少 timestamp 或 role,最好在这里补全默认值 + timestamp: aItem.timestamp ? new Date(aItem.timestamp) : new Date(), + role: aItem.role || 'assistant', + } as Message; + }); + }); + console.log('result', messageList); // 此时 result 保持纯净,未被修改 + setMessages(messageList); + } else { + console.log(result); + const messageList = result.flatMap(item => { + const parsedAnswers = JSON.parse(item.answer ?? '[]'); + return parsedAnswers.map(aItem => ({ + ...aItem, + left: { + ...aItem.left, + loading: false, + }, + right: { + ...aItem.right, + loading: false, + }, + id: item.id, + })); + }); + console.log('messageList', messageList) + setCompareResult(messageList); + } + // state.current.messageList = reuslt || []; + } + + // 创建记录窗口 + const chatHistoryAddSearch = async () => { + let historyId = chatDataId.current; + let answer = ''; + let question = ''; + if (viewMode === 'compare') { + historyId = compareChatId.current; + answer = JSON.stringify(state.current.compareResult); + question = state.current.compareResult[0].compareUserInput; + } else { + answer = JSON.stringify(state.current.messageList); + question = state.current.messageList[0].content; + } + const param = { + historyId: historyId, + userId: userId.current, + answer: answer, + question: question, + } + const res = await service.post(`${zncmUrl}/llm/qa/history/pair/add`, param); + console.log('添加聊天记录窗口', res); + } /** * 滚动到底部。 @@ -1482,7 +2157,7 @@ const GeneralQA: React.FC = ({ if (isAutoScrollEnabled) { scrollToBottom(false); } - }, [messages, isLoading, isAutoScrollEnabled]); + }, [messages, isLoading, isAutoScrollEnabled, compareResult]); const handleChatScroll = () => { if (!chatListRef.current) return; @@ -1616,29 +2291,30 @@ const GeneralQA: React.FC = ({ useEffect(() => { if (viewMode !== 'compare' || !compareResult) return; - setIsLoading(compareResult.left.loading || compareResult.right.loading); + // setIsLoading(compareResult.left.loading || compareResult.right.loading); }, [viewMode, compareResult]); // 离开浏览器页面时无法展示自定义 Dialog,因此使用原生 beforeunload 提醒用户生成会被中断。 - useEffect(() => { - const handleBeforeUnload = (event: BeforeUnloadEvent) => { - if (!isLoading) return; - event.preventDefault(); - event.returnValue = '模型正在生成,离开页面将停止当前生成。'; - }; + // useEffect(() => { + // const handleBeforeUnload = (event: BeforeUnloadEvent) => { + // if (!isLoading) return; + // event.preventDefault(); + // event.returnValue = '模型正在生成,离开页面将停止当前生成。'; + // }; - window.addEventListener('beforeunload', handleBeforeUnload); - return () => window.removeEventListener('beforeunload', handleBeforeUnload); - }, [isLoading]); + // window.addEventListener('beforeunload', handleBeforeUnload); + // return () => window.removeEventListener('beforeunload', handleBeforeUnload); + // }, [isLoading]); return ( -
+
{/* 主体内容区 */} @@ -1673,12 +2349,12 @@ const GeneralQA: React.FC = ({ {/* 中间结果区 */}
{viewMode === 'single' ? ( -
+ <> {/* 聊天消息区 */}
{messages.length === 0 ? ( = ({ )} {error && ( -
+
{error}
)} -
- {showScrollToBottom && ( - - )} -
- ) : ( - <> - {/* 对比结果区 */} -
- {compareUserInput && ( -
-
-

{compareUserInput}

-
-
- )} - {compareResult ? ( - compareLayout === 'horizontal' ? ( -
- {/* 左侧结果 */} -
-
-
-
- - - {getModeLabel(compareResult.left.mode)} - -
- {compareResult.left.loading && ( - - )} -
-
-
-
- {compareResult.left.mode === 'single-round' ? ( - handleCompareCitationClick('left', idx)} /> - ) : ( - handleCompareCitationClick('left', idx)} /> - )} -
-
-
- - {/* 右侧结果 */} -
-
-
-
- - - {getModeLabel(compareResult.right.mode)} - -
- {compareResult.right.loading && ( - - )} -
-
-
-
- {compareResult.right.mode === 'single-round' ? ( - handleCompareCitationClick('right', idx)} /> - ) : ( - handleCompareCitationClick('right', idx)} /> - )} -
-
-
-
- ) : ( -
- {/* 上方结果 */} -
-
-
-
- - - {getModeLabel(compareResult.left.mode)} - -
- {compareResult.left.loading && ( - - )} -
-
-
-
- {compareResult.left.mode === 'single-round' ? ( - handleCompareCitationClick('left', idx)} /> - ) : ( - handleCompareCitationClick('left', idx)} /> - )} -
-
-
- - {/* 下方结果 */} -
-
-
-
- - - {getModeLabel(compareResult.right.mode)} - -
- {compareResult.right.loading && ( - - )} -
-
-
-
- {compareResult.right.mode === 'single-round' ? ( - handleCompareCitationClick('right', idx)} /> - ) : ( - handleCompareCitationClick('right', idx)} /> - )} -
-
-
-
- ) - ) : ( -
- -

对比模式

-

输入问题,对比不同模式的回答效果

-
+ {showScrollToBottom && ( + )}
+ ) : ( +
+ {/* 对比结果区 */} + { + compareResult.length === 0 &&
+ +

对比模式

+

输入问题,对比不同模式的回答效果

+
+ } + { + compareResult.map((compare: CompareResult, index: number) => ( +
+
+
+

{compare.compareUserInput}

+
+
+ {compareResult ? ( + compareLayout === 'horizontal' ? ( +
+ {/* 左侧结果 */} +
+
+
+
+ + + {getModeLabel(compare.left.mode)} + +
+ {compare.left.loading && ( + + )} +
+
+
+
+ {(compare.left.mode === 'single-round' || compare.left.mode === 'single-chat')? ( + handleCompareCitationClick('left', idx)} /> + ) : ( + handleCompareCitationClick('left', idx)} /> + )} +
+
+
+ + {/* 右侧结果 */} +
+
+
+
+ + + {getModeLabel(compare.right.mode)} + +
+ {compare.right.loading && ( + + )} +
+
+
+
+ {(compare.right.mode === 'single-round' || compare.right.mode === 'single-chat') ? ( + handleCompareCitationClick('right', idx)} /> + ) : ( + handleCompareCitationClick('right', idx)} /> + )} +
+
+
+
+ ) : ( +
+ {/* 上方结果 */} +
+
+
+
+ + + {getModeLabel(compare.left.mode)} + +
+ {compare.left.loading && ( + + )} +
+
+
+
+ {(compare.left.mode === 'single-round' || compare.left.mode === 'single-chat') ? ( + handleCompareCitationClick('left', idx)} /> + ) : ( + handleCompareCitationClick('left', idx)} /> + )} +
+
+
+ + {/* 下方结果 */} +
+
+
+
+ + + {getModeLabel(compare.right.mode)} + +
+ {compare.right.loading && ( + + )} +
+
+
+
+ {(compare.right.mode === 'single-round' || compare.right.mode === 'single-chat') ? ( + handleCompareCitationClick('right', idx)} /> + ) : ( + handleCompareCitationClick('right', idx)} /> + )} +
+
+
+
+ ) + ) : ( +
+ +

对比模式

+

输入问题,对比不同模式的回答效果

+
+ )} +
+ )) + } + {showScrollToBottom && ( + + )} +
)} {/* 底部输入区 */} @@ -1940,9 +2631,8 @@ const GeneralQA: React.FC = ({ {/* 右侧辅助区 */} {viewMode === 'single' && showProcess && ( -
+
{/* 检索过程 */}

@@ -1983,10 +2673,9 @@ const GeneralQA: React.FC = ({ )} {/* 对比模式的差异总结 */} - {viewMode === 'compare' && compareResult?.differences && ( -
+ {/* {viewMode === 'compare' && compareResult?.differences && ( +

差异总结

@@ -2027,21 +2716,19 @@ const GeneralQA: React.FC = ({

- )} + )} */}
{editingMessageId && (
-
+