import React, { useEffect, useRef, useState } from 'react'; import { GitCompare, MessageSquare, CheckCircle, AlertCircle, ArrowDown, ExternalLink, Square, X, } from 'lucide-react'; import { Dialog, Select, Slider } from 'tdesign-react'; import { useTheme } from '../../contexts/ThemeContext'; import { PageType } from '../../components/Sidebar'; 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 { chatDataJSON, createComparePane, createMockSingleRagMessages, createMultiRoundStreamTrace, createThinkingTrace, extractJsonObjects, mapDocsToSources, mapReferenceToSource, parseSingleRagStream, } from './GeneralQA.mock'; /** * 通用问答组件 * 支持单轮RAG、多轮RAG、深度检索及结果对比功能 * @param onNavigate - 页面导航函数 * @param initialMode - 初始模式(可选) */ 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 knowledgeUrl = import.meta.env.VITE_AIPI_KNOWLEDGE_URL const GeneralQA: React.FC<{ onNavigate: (page: PageType) => void; initialMode?: string }> = ({ onNavigate }) => { const { theme } = useTheme(); const isDark = theme === 'dark'; // 状态管理 // 视图模式:单一模式或对比模式 const [viewMode, setViewMode] = useState('single'); // 问答模式:单轮RAG、多轮RAG、深度检索 const [qaMode, setQaMode] = useState('single-round'); // 对比模式下的左侧模式 const [compareLeftMode, setCompareLeftMode] = useState('single-round'); // 对比模式下的右侧模式 const [compareRightMode, setCompareRightMode] = useState('multi-round'); // 知识范围设置 const [knowledgeScope, setKnowledgeScope] = useState<'all' | 'specific-kb' | 'specific-domain'>('all'); // 回答偏好设置 const [answerStyle, setAnswerStyle] = useState<'concise' | 'detailed'>('detailed'); const [showSources, setShowSources] = useState(true); const [showProcess, setShowProcess] = useState(false); // 对比设置 const [compareLayout, setCompareLayout] = useState<'horizontal' | 'vertical'>('horizontal'); const [autoSummarizeDiff, setAutoSummarizeDiff] = useState(true); const [recommendBetter, setRecommendBetter] = useState(true); // 消息和结果状态 // `messages` 驱动单一模式聊天区;`compareResult` 驱动对比模式左右面板。 // `isPreConnectLoading` 表示请求已发起但还没拿到可展示的 assistant 消息,用于显示“正在连接/生成”的占位状态。 const [messages, setMessages] = useState([]); // 用户当前输入的文本内容,用于绑定输入框 const [inputValue, setInputValue] = useState(''); // 是否正在处理请求(包含真实请求和模拟请求) const [isLoading, setIsLoading] = useState(false); // 是否处于"连接中"状态,表示已发起请求但还未收到可展示的响应 const [isPreConnectLoading, setIsPreConnectLoading] = useState(false); // 存储发生的错误信息,用于显示错误提示 const [error, setError] = useState(null); // 对比模式下的结果数据,包含左右两侧的回答和来源信息 const [compareResult, setCompareResult] = useState(null); // 对比模式下用户输入的问题内容,用于左右对比时的统一输入 const [compareUserInput, setCompareUserInput] = useState(''); // 是否折叠左侧配置面板,true 为折叠,false 为展开 const [isLeftSidebarCollapsed, setIsLeftSidebarCollapsed] = useState(false); // 是否启用自动滚动,当用户手动滚动时会自动关闭 const [isAutoScrollEnabled, setIsAutoScrollEnabled] = useState(true); // 是否显示"滚动到底部"按钮,当有未读消息且用户已滚动时显示 const [showScrollToBottom, setShowScrollToBottom] = useState(false); // 当前选中的引用来源,用于显示引用详情弹窗 const [selectedCitation, setSelectedCitation] = useState(null); const [editingMessageId, setEditingMessageId] = useState(null); const [editingThinking, setEditingThinking] = useState(''); const [editingAnswer, setEditingAnswer] = useState(''); // 模型参数设置弹窗状态。 // 这些值会进入单轮 RAG 的真实请求体;模拟流仍复用同一套 UI,方便接口恢复后无缝切换。 const [isModelSettingsOpen, setIsModelSettingsOpen] = useState(false); const [modelName, setModelName] = useState('OpenAI3'); const [scoreThreshold, setScoreThreshold] = useState(0.5); const [temperature, setTemperature] = useState(0.3); // 当模型正在生成时,如果用户切换模式或跳转页面,先把原动作暂存到 ref,待用户确认停止后再执行。 // 使用 ref 而不是 state 可以避免弹窗确认过程触发额外渲染,也不会被闭包中的旧状态影响。 const pendingActionRef = useRef<(() => void) | null>(null); // 防止 Enter keydown 和按钮 click 在 React 状态更新前重复触发提交。 const submitLockRef = useRef(false); // 真实流式请求的 reader 句柄,用于“停止生成”时取消浏览器流读取。 const readerRef = useRef(null); // 对比模式会同时启动左右两路请求,因此分别保存 reader,保证可以单独停止某一侧生成。 const compareReaderRef = useRef<{ left: ReadableStreamDefaultReader | null; right: ReadableStreamDefaultReader | null; }>({ left: null, right: null }); // 多轮接口创建任务后会返回 task_id,这里预留给后续“按任务取消/查询状态”等能力。 const taskIdRef = useRef(null); // 本地模拟流没有真实 reader,因此用停止标记在异步循环中主动中断。 const mockSingleRoundStoppedRef = useRef(false); const mockMultiRoundStoppedRef = useRef(false); const chatListRef = useRef(null); // 检索过程展示状态 const [searchProcess, setSearchProcess] = useState({ currentMode: '单轮RAG', rounds: 1, hits: 5, queryRewrite: '', }); /** * 处理单轮 RAG 问答请求 * * 单轮 RAG 的处理流程: * 1. 向知识库问答 API 发送 POST 请求,携带用户问题和检索参数 * 2. API 返回 SSE 流式响应,包含多个 JSON 对象 * 3. 逐个解析流式 JSON,提取 answer 和 docs 字段 * 4. 通过 applyAnswerToken 将流式 token 追加到消息的 thinkingMarkdown/answerMarkdown * 5. 实时更新消息来源(sources)和检索统计(searchProcess) * * @param query - 用户输入的问题文本 */ const handleSingleRoundRun = async (query: string) => { // 清空之前的错误信息 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) => { 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; } } 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; } } } // 返回更新后的消息对象,保留 singleRagTrace 中的所有字段 return { ...msg, singleRagTrace: { ...msg.singleRagTrace, // 更新思考和回答的 markdown 内容 thinkingMarkdown, answerMarkdown, // 只有当已经开始解析思考内容时才更新 thinkingComplete 标志 thinkingComplete: thinkStarted ? thinkingComplete : true, }, }; })); }; try { // 向知识库问答 API 发送 POST 请求,携带用户问题和检索参数 const response = await fetch(`${chatUrl}/chat/knowledge_base_chat_tiaoshi`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, // 请求体包含查询内容、知识库名称、检索参数、模型配置等 body: JSON.stringify({ query, // 用户输入的问题 knowledge_base_name: [], // 知识库名称列表,空数组表示使用所有知识库 top_k: 5, // 返回最相似的 5 条结果 score_threshold: scoreThreshold, // 相似度阈值,过滤低质量检索结果 history: [], // 对话历史,用于多轮对话 stream: true, // 启用流式响应 model_name: modelName, // 使用的模型名称 temperature, // 生成温度,控制随机性 prompt_name: 'default', // 使用的提示词模板 local_doc_url: false, // 是否返回本地文档 URL }), }); // 检查 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: '单轮 RAG 思考过程', thinkingMarkdown: '', // 思考过程内容 answerMarkdown: '', // 最终回答内容 thinkingComplete: false, // 思考是否完成 }; // 将 assistant 消息添加到消息列表中 setMessages((prev) => [...prev, { id: assistantId, role: 'assistant', content: '', timestamp: new Date(), singleRagTrace: initialTrace, sources: [], // 初始为空,随着检索结果更新 }]); // 获取响应体的流式读取器 const reader = response.body.getReader(); // 保存 reader 引用,用于"停止生成"功能 readerRef.current = 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; // 将二进制数据解码并追加到 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; // 遍历所有提取到的 JSON 对象 for (const jsonText of extracted.objects) { try { // 解析知识库返回的 JSON 片段 const parsed = parseKnowledgeBaseChunk(jsonText); // 如果有回答内容,追加到消息的 answerMarkdown if (typeof parsed.answer === 'string') { applyAnswerToken(parsed.answer); } // 如果有检索文档,更新消息的来源信息 if (parsed.docs) { // 将 docs 转换为 sources 格式 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 })); // 更新消息的来源列表 setMessages((prev) => prev.map((msg) => ( msg.id === assistantId ? { ...msg, sources: mappedDocs } : msg ))); } } catch (parseError) { // 解析错误不影响主流程,记录日志继续处理 console.error('单轮RAG流式JSON解析错误:', parseError); } } } } 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; } }; /** * 处理单轮 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) => { // 清空之前的错误信息 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) => { 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; 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(''); 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; } } } // 返回更新后的消息对象,保留 singleRagTrace 中的所有字段 return { ...msg, singleRagTrace: { ...msg.singleRagTrace, // 更新思考和回答的 markdown 内容 thinkingMarkdown, answerMarkdown, // 只有当已经开始解析思考内容时才更新 thinkingComplete 标志 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, // 思考是否完成 }; // 将 assistant 消息添加到消息列表中 setMessages((prev) => [...prev, { id: assistantId, role: 'assistant', content: '', timestamp: new Date(), singleRagTrace: initialTrace, sources: [], // 初始为空,随着检索结果更新 }]); // 获取响应体的流式读取器 const reader = response.body.pipeThrough(new TextDecoderStream()).getReader(); // 保存 reader 引用,用于"停止生成"功能 readerRef.current = reader; // 创建文本解码器,将二进制数据转换为字符串 const decoder = new TextDecoder(); // buffer 用于暂存未完成的 JSON 片段 try { // 持续读取流数据,直到流结束 while (true) { // 读取下一个数据块 const { done, value } = await reader.read(); // done 为 true 时表示流已结束 if (done) break; applyAnswerToken(value); } } 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; } }; /** * 解析类 JavaScript 对象字符串中的字符串值。 * * 后端单轮 RAG 流有时不是严格 JSON,而是 `{answer: '...'}` 这种对象片段。 * 该函数只负责把单/双引号包裹的 answer 内容还原成普通字符串,保留换行和转义字符。 */ const parseLooseStringValue = (rawValue: string): string => { try { return JSON.parse(rawValue) as string; } catch { const trimmedValue = rawValue.trim(); if (trimmedValue.startsWith("'") && trimmedValue.endsWith("'")) { return trimmedValue .slice(1, -1) .replace(/\\n/g, '\n') .replace(/\\r/g, '\r') .replace(/\\t/g, '\t') .replace(/\\'/g, "'") .replace(/\\\\/g, '\\'); } throw new Error(`无法解析字符串值: ${rawValue}`); } }; const parseLooseValue = (rawValue: string): unknown => { try { return JSON.parse(rawValue); } catch { return parseLooseStringValue(rawValue); } }; /** * 将单轮知识库流中的一个对象片段转换为统一数据结构。 * * 兼容两类返回: * - 严格 JSON:`{"answer":"..."}` / `{"docs":[...]}` * - 宽松对象:`{answer: '...'}` / `{docs: [...]}` * * 真实接口和本地模拟都走同一个解析函数,确保联调时只需要替换数据源,不需要改 UI 更新逻辑。 */ const parseKnowledgeBaseChunk = (jsonText: string): KnowledgeBaseChunk => { try { return JSON.parse(jsonText) as KnowledgeBaseChunk; } catch { const objectText = jsonText.trim(); const answerMatch = objectText.match(/^\{\s*answer\s*:\s*([\s\S]*)\s*\}$/); if (answerMatch) { const rawValue = answerMatch[1].trim(); return { answer: parseLooseStringValue(rawValue) }; } const docsMatch = objectText.match(/^\{\s*docs\s*:\s*([\s\S]*)\s*\}$/); if (docsMatch) { return { docs: parseLooseValue(docsMatch[1]) as KnowledgeBaseChunk['docs'] }; } const normalizedText = objectText.replace(/([{,]\s*)(answer|docs)(\s*:)/g, '$1"$2"$3'); return JSON.parse(normalizedText) as KnowledgeBaseChunk; } }; 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; console.log(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; } } } 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, }; 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(); 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 { // lastRound.queryLoading = false; trace.rounds[0].title = '思考过程' trace.rounds[0].thoughtMarkdown = thinkContent; } } else { lastRound.queryLoading = false; trace.finalAnswerMarkdown += chatText; } 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); } } 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; } default: break; } return { ...msg, 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: [], queryShow: true, }, { id: `mock-round-2-${Date.now()}`, title: '第二轮:基于首轮结果改写 Query', thoughtMarkdown: '首轮检索获得了基础资料,但仍需要补充更精确的执行口径。\n\n本轮将问题改写为更适合检索的短查询,并优先召回流程、规范和案例类资料。', query: `${query} 操作规范 适用条件 案例`, queryLoading: true, retrievals: [], queryShow: true, }, { id: `mock-round-3-${Date.now()}`, title: '第三轮:补充边界条件并交叉验证', thoughtMarkdown: '前两轮已经覆盖主要结论,最后继续检索边界条件,避免回答过于绝对。\n\n重点确认:\n\n1. 是否存在例外场景\n2. 是否需要人工复核\n3. 引用资料之间是否一致', query: `${query} 例外场景 风险点 人工复核`, queryLoading: true, retrievals: [], queryShow: true, }, ], 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, })); 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; } }; /** * 处理真实多轮 RAG 请求。 * * 多轮接口采用 SSE:每行 `data:` 都描述一个事件。组件根据事件类型分别更新 taskId、工具调用、 * 最终回答和引用来源。当前用户入口暂时走本地模拟,此方法保留给接口连通后切换使用。 */ const handleRun = async (query: string) => { setError(null); setIsLoading(true); setIsPreConnectLoading(true); try { const response = await fetch(`${generateUrl}/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(), }; // 接口连通后再展示多轮检索主题与过程 setMessages((prev) => [...prev, assistantMessage]); const reader = response.body.getReader(); readerRef.current = 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 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; } }; /** * 单一模式下的发送入口。 * * 这里只负责创建用户消息、重置输入框和按当前 qaMode 分发到对应执行器; * 具体的真实请求、模拟流和状态回填分别在各自 handler 内维护。 */ const handleSend = () => { if (submitLockRef.current || isLoading || isPreConnectLoading) return; if (!inputValue.trim()) return; submitLockRef.current = true; const query = inputValue.trim(); const userMessage: Message = { id: Date.now().toString(), role: 'user', content: query, timestamp: new Date(), }; if (qaMode === 'multi-round') { // 多轮检索 setMessages((prev) => [...prev, userMessage]); setInputValue(''); setSearchProcess({ currentMode: '多轮RAG', rounds: 0, hits: 0, queryRewrite: '', }); void handleRun(query); return; } if (qaMode === 'single-round') { // 单轮RAG setMessages((prev) => [...prev, userMessage]); setInputValue(''); setSearchProcess({ currentMode: '单轮RAG', rounds: 1, hits: 0, queryRewrite: '', }); void handleSingleRoundRun(query); return; } if (qaMode === 'single-chat') { // 单轮RAG console.log('进入了这里') setMessages((prev) => [...prev, userMessage]); setInputValue(''); setSearchProcess({ currentMode: '单轮RAG', 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); }; /** * 对比模式发送入口。 * * 左右面板共享同一个用户问题,但各自按选择的模式独立运行、独立 loading、独立 sources。 * 通过 `updatePane` 将左右面板更新收敛到一个函数,避免并发请求互相覆盖状态。 */ const handleCompare = () => { if (submitLockRef.current || isLoading || isPreConnectLoading) return; if (!inputValue.trim()) return; submitLockRef.current = true; const query = inputValue.trim(); setInputValue(''); setCompareUserInput(query); setError(null); setIsLoading(true); setCompareResult({ 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, queryShow: 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; } } }; const runByMode = (side: 'left' | 'right', mode: QAMode) => { if (mode === 'single-round') return runSingleForCompare(side); return runMultiForCompare(side); }; Promise.allSettled([ runByMode('left', compareLeftMode).finally(() => updatePane('left', (pane) => ({ ...pane, loading: false }))), runByMode('right', compareRightMode).finally(() => updatePane('right', (pane) => ({ ...pane, loading: false }))), ]).finally(() => { setIsLoading(false); submitLockRef.current = false; }); }; const handleStopCompareSide = (side: 'left' | 'right') => { const reader = compareReaderRef.current[side]; if (reader) { void reader.cancel(`用户停止${side === 'left' ? '左侧' : '右侧'}生成`); compareReaderRef.current[side] = null; } setCompareResult((prev) => { if (!prev) return prev; return { ...prev, [side]: { ...prev[side], loading: false, }, }; }); }; /** * 停止当前生成。 * * 真实请求通过 reader.cancel() 中断读取;本地模拟通过 ref 标记让异步循环自行退出。 * 对比模式需要同时停止左右两侧,单一模式只处理当前主 reader。 */ const handleStop = () => { if (viewMode === 'compare') { handleStopCompareSide('left'); handleStopCompareSide('right'); setIsLoading(false); submitLockRef.current = false; return; } mockSingleRoundStoppedRef.current = true; mockMultiRoundStoppedRef.current = true; if (readerRef.current) { void readerRef.current.cancel('用户手动停止'); readerRef.current = null; } setIsPreConnectLoading(false); setIsLoading(false); submitLockRef.current = false; }; /** * 对“生成中切换页面/模式/布局”进行统一保护。 * * 如果当前没有生成任务,直接执行目标操作;否则弹出确认框。 * 用户确认后先停止生成,再执行原操作,避免生成流继续写入已经切换的 UI 状态。 */ const runAfterGenerationConfirm = (action: () => void) => { if (!isLoading) { action(); return; } pendingActionRef.current = action; // Dialog.confirm({ // header: '模型正在生成', // body: '当前模型仍在生成内容。确认后将停止生成,并继续执行你的操作。', // confirmBtn: '确认停止并继续', // cancelBtn: '取消', // onConfirm: () => { // handleStop(); // pendingActionRef.current?.(); // pendingActionRef.current = null; // }, // onClose: () => { // pendingActionRef.current = null; // }, // }); }; const handleSafeViewModeChange = (mode: ViewMode) => { if (mode === viewMode) return; runAfterGenerationConfirm(() => setViewMode(mode)); }; const handleSafeNavigate = (page: PageType) => { runAfterGenerationConfirm(() => onNavigate(page)); }; const handleSafeReset = () => { runAfterGenerationConfirm(() => { setMessages([]); setCompareResult(null); }); }; const handleSafeModeChange = (mode: QAMode) => { runAfterGenerationConfirm(() => setQaMode(mode)); }; const handleSafeCompareLeftModeChange = (mode: QAMode) => { runAfterGenerationConfirm(() => setCompareLeftMode(mode)); }; const handleSafeCompareRightModeChange = (mode: QAMode) => { runAfterGenerationConfirm(() => setCompareRightMode(mode)); }; const handleSafeCompareLayoutChange = (layout: 'horizontal' | 'vertical') => { runAfterGenerationConfirm(() => setCompareLayout(layout)); }; const handleSaveRecord = () => { alert('问答记录已保存'); }; const handleCollect = () => { alert('已添加到收藏'); }; 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; console.log(e,'e') console.log('da') if (viewMode === 'compare') { handleCompare(); } else { handleSend(); } scrollToBottom(); }; // 获取模式标签 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 'hx-search': return '画像检索'; } }; const activeAssistantMessageId = isLoading ? [...messages].reverse().find((msg) => msg.role === 'assistant')?.id : undefined; useEffect(() => { setQaMode('single-round'); setMessages(createMockSingleRagMessages()); setSearchProcess({ currentMode: '单轮RAG', rounds: 1, hits: 3, queryRewrite: '', }); }, []); /** * 滚动到底部。 * * 聊天区默认跟随新消息自动滚动;当用户手动上滑查看历史时会暂停自动滚动,避免打断阅读。 */ const scrollToBottom = (smooth = false) => { if (!chatListRef.current) return; chatListRef.current.scrollTo({ top: chatListRef.current.scrollHeight, behavior: smooth ? 'smooth' : 'auto', }); }; useEffect(() => { if (isAutoScrollEnabled) { scrollToBottom(false); } }, [messages, isLoading, isAutoScrollEnabled]); const handleChatScroll = () => { if (!chatListRef.current) return; const { scrollTop, scrollHeight, clientHeight } = chatListRef.current; const isNearBottom = scrollHeight - (scrollTop + clientHeight) < 80; setIsAutoScrollEnabled(isNearBottom); setShowScrollToBottom(!isNearBottom); }; const getAssistantAnswerText = (message: Message) => ( message.singleRagTrace?.answerMarkdown || message.thinkingTrace?.finalAnswerMarkdown || message.content ); const getAssistantThinkingText = (message: Message) => { if (message.singleRagTrace) return message.singleRagTrace.thinkingMarkdown; if (message.thinkingTrace) { return message.thinkingTrace.rounds.map((round, index) => ( `## 第 ${index + 1} 轮:${round.title}\n${round.thoughtMarkdown}` )).join('\n\n'); } return ''; }; const handleCopyAssistantMessage = async (message: Message) => { const answerText = getAssistantAnswerText(message); try { await navigator.clipboard.writeText(answerText); } catch (copyError) { console.error('复制模型回答失败:', copyError); } }; const handleDeleteAssistantMessage = (message: Message) => { const messageIndex = messages.findIndex((msg) => msg.id === message.id); const previousUserMessage = [...messages.slice(0, messageIndex)].reverse().find((msg) => msg.role === 'user'); setMessages((prev) => prev.filter((msg) => msg.id !== message.id && msg.id !== previousUserMessage?.id)); }; const handleEditAssistantMessage = (message: Message) => { setEditingMessageId(message.id); setEditingThinking(getAssistantThinkingText(message)); setEditingAnswer(getAssistantAnswerText(message)); }; const handleCancelAssistantEdit = () => { setEditingMessageId(null); setEditingThinking(''); setEditingAnswer(''); }; const handleSaveAssistantEdit = () => { if (!editingMessageId) return; setMessages((prev) => prev.map((msg) => { if (msg.id !== editingMessageId) return msg; if (msg.singleRagTrace) { return { ...msg, content: editingAnswer, singleRagTrace: { ...msg.singleRagTrace, thinkingMarkdown: editingThinking, answerMarkdown: editingAnswer, thinkingComplete: true, }, }; } if (msg.thinkingTrace) { const rounds = msg.thinkingTrace.rounds.length > 0 ? msg.thinkingTrace.rounds.map((round, index) => ( index === 0 ? { ...round, thoughtMarkdown: editingThinking } : round )) : msg.thinkingTrace.rounds; return { ...msg, content: editingAnswer, thinkingTrace: { ...msg.thinkingTrace, rounds, finalAnswerMarkdown: editingAnswer, }, }; } return { ...msg, content: editingAnswer }; })); handleCancelAssistantEdit(); }; const handleResendAssistantMessage = (message: Message) => { if (submitLockRef.current || isLoading || isPreConnectLoading) return; const messageIndex = messages.findIndex((msg) => msg.id === message.id); const previousUserMessage = [...messages.slice(0, messageIndex)].reverse().find((msg) => msg.role === 'user'); if (!previousUserMessage) return; submitLockRef.current = true; setMessages((prev) => prev.filter((msg) => msg.id !== message.id)); setInputValue(''); if (message.singleRagTrace) { setSearchProcess({ currentMode: '单轮RAG', rounds: 1, hits: 0, queryRewrite: '' }); void handleSingleRoundRun(previousUserMessage.content); return; } setSearchProcess({ currentMode: '多轮RAG', rounds: 0, hits: 0, queryRewrite: '' }); void handleRun(previousUserMessage.content); }; /** * 点击答案中的引用编号后打开引用详情弹窗。 * * Markdown 中的 `[[1]]` 与 sources 数组采用 1-based 对齐,因此这里需要减 1 取数组下标。 */ const handleCitationClick = (message: Message, citationIndex: number) => { const source = message.sources?.[citationIndex - 1]; if (!source) return; setSelectedCitation(source); }; const handleCompareCitationClick = (side: 'left' | 'right', citationIndex: number) => { const source = compareResult?.[side]?.sources?.[citationIndex - 1]; if (!source) return; setSelectedCitation(source); }; useEffect(() => { if (viewMode === 'compare') { setCompareLeftMode('single-round'); setCompareRightMode('multi-round'); } }, [viewMode]); useEffect(() => { if (viewMode !== 'compare' || !compareResult) return; setIsLoading(compareResult.left.loading || compareResult.right.loading); }, [viewMode, compareResult]); // 离开浏览器页面时无法展示自定义 Dialog,因此使用原生 beforeunload 提醒用户生成会被中断。 useEffect(() => { const handleBeforeUnload = (event: BeforeUnloadEvent) => { if (!isLoading) return; event.preventDefault(); event.returnValue = '模型正在生成,离开页面将停止当前生成。'; }; window.addEventListener('beforeunload', handleBeforeUnload); return () => window.removeEventListener('beforeunload', handleBeforeUnload); }, [isLoading]); return (
{/* 主体内容区 */}
{/* 中间结果区 */}
{viewMode === 'single' ? ( <> {/* 聊天消息区 */}
{messages.length === 0 ? ( } title="开始你的问答" description="输入问题,获取基于知识库的智能回答" /> ) : ( messages.map((msg) => ( )) )} {isPreConnectLoading && ( )} {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)} /> )}
) ) : (

对比模式

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

)}
)} {/* 底部输入区 */} setIsModelSettingsOpen(true)} isLoading={isLoading} placeholder={viewMode === 'compare' ? '输入问题进行对比分析...' : '输入你的问题...'} showReset />
{/* 右侧辅助区 */} {viewMode === 'single' && showProcess && (
{/* 检索过程 */}

检索过程

当前模式 {searchProcess.currentMode}
检索轮次 {searchProcess.rounds}
命中数 {searchProcess.hits}
{searchProcess.queryRewrite && (
Query改写

{searchProcess.queryRewrite}

)}
)} {/* 对比模式的差异总结 */} {viewMode === 'compare' && compareResult?.differences && (

差异总结

一致观点

{compareResult.differences.consensus}

差异观点

{compareResult.differences.differences}

{getModeLabel(compareResult.left.mode)}优势

{compareResult.differences.leftAdvantage}

{getModeLabel(compareResult.right.mode)}优势

{compareResult.differences.rightAdvantage}

)}
{editingMessageId && (

编辑模型生成内容

可同时修改模型回答和思考过程,多轮检索会把思考过程写回第一轮思考内容。