import React, { useEffect, useRef, useState } from 'react'; import { GitCompare, MessageSquare, CheckCircle, AlertCircle, ArrowDown, ExternalLink, Square, X, BookOpen, User2Icon, } 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, qaModeItem, SourceItem, stateItem, ToolCallData, ViewMode } from './GeneralQA.types'; import { chatDataJSON, createComparePane, createMockSingleRagMessages, createMultiRoundStreamTrace, createThinkingTrace, extractJsonObjects, mapDocsToSources, mapReferenceToSource, parseSingleRagStream, platformTypeChange, } from './GeneralQA.mock'; import { requestCrudService } from '@/service/request-axios-service'; /** * 通用问答组件 * 支持单轮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 taskChatUrl = import.meta.env.VITE_AIPI_TASK_CHAT_URL || ''; const knowledgeUrl = import.meta.env.VITE_AIPI_KNOWLEDGE_URL; const service = requestCrudService; const qaModeList: qaModeItem[] = [ { value: 'deep-search' as const, label: '任务检索', icon: BookOpen, desc: '检索相关任务' }, { value: 'hx-search' as const, label: '画像检索', icon: User2Icon, desc: '检索相关账号' }, { value: 'news-report' as const, label: '新闻报告生成', icon: User2Icon, desc: '检索相关新闻' }, ]; const TopicResearch: 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([]); // 对比模式下用户输入的问题内容,用于左右对比时的统一输入 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: any; right: any; }>({ left: null, right: null }); // 多轮接口创建任务后会返回 task_id,这里预留给后续“按任务取消/查询状态”等能力。 const taskIdRef = useRef(null); // 本地模拟流没有真实 reader,因此用停止标记在异步循环中主动中断。 const mockSingleRoundStoppedRef = useRef(false); const mockMultiRoundStoppedRef = useRef(false); const chatListRef = useRef(null); const userId = useRef(387); // 用户id const qaType = useRef('TOPICResearch'); // 问答类型 const chatDataId = useRef(); // 聊天窗口id const compareChatId = useRef(); // 对比对话记录id // 检索过程展示状态 const [searchProcess, setSearchProcess] = useState({ currentMode: '单轮RAG', rounds: 1, hits: 5, queryRewrite: '', }); const state = useRef({ compareIndex: 0, compareId: '', messageList: [], compareResult: [] }) /** * 处理单轮 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, 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) => { 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 { 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, }, }; }); } }; const updatePane = (side: 'left' | 'right', updater: (pane: ComparePane) => ComparePane) => { setCompareResult((prev) => { if (!prev) return prev; return { ...prev, [side]: updater(prev[side]), }; }); }; 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, // 思考是否完成 }; 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 引用,用于"停止生成"功能 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) { 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; // 遍历所有提取到的 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 })); state.current.messageList[1].sources = mappedDocs; // 更新消息的来源列表 if (!side) { setMessages((prev) => prev.map((msg) => ( msg.id === assistantId ? { ...msg, sources: mappedDocs } : msg ))); } else { updatePane(side, (pane) => ({ ...pane, sources: mappedDocs })); } } } 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; 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 = []; } }; /** * 解析类 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); }); 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]), }; })); }; /** * 处理多轮真实接口里的工具调用事件。 * * `show_text` 用于追加思考或最终回答;`local_search` / `tavily_search` 用于把检索结果填入最近一轮。 * 这里保持事件到 UI trace 的单向映射,避免请求层和展示层互相耦合。 */ 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 { // lastRound.queryLoading = false; trace.rounds[0].title = '思考过程' trace.rounds[0].thoughtMarkdown = thinkContent; } } else { lastRound.queryLoading = false; trace.finalAnswerMarkdown += chatText; trace.thinkingComplete = true; } 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; } 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); } } else if (toolData.tool_input.keyword) { lastRound.query = toolData.tool_input.keyword; } break; } 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, } setMessages((prevMessages) => prevMessages.map((msg) => { if (msg.id !== assistantId || msg.role !== 'assistant' || !msg.thinkingTrace) return msg; return { ...msg, thinkingTrace: trace, }; })); } else { state.current.compareResult[state.current.compareIndex][side].thinkingTrace = trace; updatePane(side, (pane) => { return { ...pane, thinkingTrace: trace, }; }); } }; /** * 处理真实多轮 RAG 请求。 * * 多轮接口采用 SSE:每行 `data:` 都描述一个事件。组件根据事件类型分别更新 taskId、工具调用、 * 最终回答和引用来源。当前用户入口暂时走本地模拟,此方法保留给接口连通后切换使用。 */ const handleRun = async (query: string, side: 'left' | 'right' | null = null) => { 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(), }; 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(); // 保存 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) { 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'); 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); } }; /** * 处理画像检索 请求。 * * 多轮接口采用 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); } }; /** * 单一模式下的发送入口。 * * 这里只负责创建用户消息、重置输入框和按当前 qaMode 分发到对应执行器; * 具体的真实请求、模拟流和状态回填分别在各自 handler 内维护。 */ const handleSend = () => { if (submitLockRef.current || isLoading || isPreConnectLoading) return; 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(), }; state.current.messageList.push(userMessage); if (qaMode === 'multi-round') { // 多轮检索 setMessages((prev) => [...prev, userMessage]); setInputValue(''); setSearchProcess({ currentMode: '多轮RAG', rounds: 0, hits: 0, queryRewrite: '', }); void handleRun(query); return; } 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({ currentMode: '单轮RAG', rounds: 1, hits: 0, queryRewrite: '', }); void handleSingleRoundRun(query); 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(''); }; /** * 对比模式发送入口。 * * 左右面板共享同一个用户问题,但各自按选择的模式独立运行、独立 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); const compareId = Date.now().toString(); console.log('compareLeftMode',compareLeftMode) const compareItem = { id: compareId, compareUserInput: query, left: createComparePane(compareLeftMode), right: createComparePane(compareRightMode), } 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-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([ 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; chatHistoryAddSearch(); }); }; const handleStopCompareSide = (side: 'left' | 'right') => { const reader = compareReaderRef.current[side]; if (reader) { void reader.cancel(`用户停止${side === 'left' ? '左侧' : '右侧'}生成`); compareReaderRef.current[side] = null; } updatePane(side, (pane) => ({ ...pane, 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; 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 'hx-search': return '画像检索'; case 'news-report': return '新闻报告生成'; } }; const activeAssistantMessageId = isLoading ? [...messages].reverse().find((msg) => msg.role === 'assistant')?.id : undefined; useEffect(() => { setQaMode('news-report'); // 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); } /** * 滚动到底部。 * * 聊天区默认跟随新消息自动滚动;当用户手动上滑查看历史时会暂停自动滚动,避免打断阅读。 */ 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, compareResult]); 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 && ( )}
) : (
{/* 对比结果区 */} { 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 && ( )}
)} {/* 底部输入区 */} 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 && (

编辑模型生成内容

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