Files
deerflow-code/web-code/components/qa/ActionChat.tsx
T
2026-05-27 19:43:15 +08:00

2886 lines
116 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 = [
{ value: 'hx-search' as const, label: '画像检索', icon: User2Icon, desc: '检索相关账号' },
];
const ActionChat: React.FC<{ onNavigate: (page: PageType) => void; initialMode?: string }> = ({ onNavigate }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
// 状态管理
// 视图模式:单一模式或对比模式
const [viewMode, setViewMode] = useState<ViewMode>('single');
// 问答模式:单轮RAG、多轮RAG、深度检索
const [qaMode, setQaMode] = useState<QAMode>('single-round');
// 对比模式下的左侧模式
const [compareLeftMode, setCompareLeftMode] = useState<QAMode>('single-round');
// 对比模式下的右侧模式
const [compareRightMode, setCompareRightMode] = useState<QAMode>('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<Message[]>([]);
// 用户当前输入的文本内容,用于绑定输入框
const [inputValue, setInputValue] = useState('');
// 是否正在处理请求(包含真实请求和模拟请求)
const [isLoading, setIsLoading] = useState(false);
// 是否处于"连接中"状态,表示已发起请求但还未收到可展示的响应
const [isPreConnectLoading, setIsPreConnectLoading] = useState(false);
// 存储发生的错误信息,用于显示错误提示
const [error, setError] = useState<string | null>(null);
// 对比模式下的结果数据,包含左右两侧的回答和来源信息
const [compareResult, setCompareResult] = useState<CompareResult[]>([]);
// 对比模式下用户输入的问题内容,用于左右对比时的统一输入
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<SourceItem | null>(null);
const [editingMessageId, setEditingMessageId] = useState<string | null>(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<any>(null);
// 对比模式会同时启动左右两路请求,因此分别保存 reader,保证可以单独停止某一侧生成。
const compareReaderRef = useRef<{
left: any;
right: any;
}>({ left: null, right: null });
// 多轮接口创建任务后会返回 task_id,这里预留给后续“按任务取消/查询状态”等能力。
const taskIdRef = useRef<string | null>(null);
// 本地模拟流没有真实 reader,因此用停止标记在异步循环中主动中断。
const mockSingleRoundStoppedRef = useRef(false);
const mockMultiRoundStoppedRef = useRef(false);
const chatListRef = useRef<HTMLDivElement | null>(null);
const userId = useRef(387); // 用户id
const qaType = useRef('ACTION'); // 问答类型
const chatDataId = useRef(); // 聊天窗口id
const compareChatId = useRef(); // 对比对话记录id
// 检索过程展示状态
const [searchProcess, setSearchProcess] = useState({
currentMode: '单轮RAG',
rounds: 1,
hits: 5,
queryRewrite: '',
});
const state = useRef<stateItem>({
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 是否处于 <think>... 标签内(解析思考过程)
let inThink = false;
// 标志位:是否已经遇到过 <think> 标签开始解析思考内容
let thinkStarted = false;
/**
* 将流式接收到的 token 应用到对应消息的 singleRagTrace 中
* 该函数会解析 token 中的 <think>... 标签,将思考过程和回答分开存储
* @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)
// 当前处于 <think> 标签内(解析思考过程),查找结束标签
while (remaining) {
if (inThink) {
const endIdx = remaining.indexOf('</think>');
if (endIdx === -1) {
thinkingMarkdown += remaining;
remaining = '';
} else {
thinkingMarkdown += remaining.slice(0, endIdx);
remaining = remaining.slice(endIdx + '</think>'.length);
inThink = false;
thinkingComplete = true;
}
} else {
const startIdx = remaining.indexOf('<think>');
console.log('startIdx', startIdx)
if (startIdx === -1) {
answerMarkdown += remaining;
remaining = '';
} else {
answerMarkdown += remaining.slice(0, startIdx);
remaining = remaining.slice(startIdx + '<think>'.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 是否处于 <think>... 标签内(解析思考过程)
let inThink = false;
// 标志位:是否已经遇到过 <think> 标签开始解析思考内容
let thinkStarted = false;
/**
* 将流式接收到的 token 应用到对应消息的 singleRagTrace 中
* 该函数会解析 token 中的 <think>... 标签,将思考过程和回答分开存储
* @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)
// 当前处于 <think> 标签内(解析思考过程),查找结束标签
while (remaining) {
if (inThink) {
const endIdx = remaining.indexOf('</think>');
if (endIdx === -1) {
thinkingMarkdown += remaining;
remaining = '';
} else {
thinkingMarkdown += remaining.slice(0, endIdx);
remaining = remaining.slice(endIdx + '</think>'.length);
inThink = false;
thinkingComplete = true;
}
} else {
const startIdx = remaining.indexOf('<think>');
if (startIdx === -1) {
answerMarkdown += remaining;
remaining = '';
} else {
answerMarkdown += remaining.slice(0, startIdx);
remaining = remaining.slice(startIdx + '<think>'.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('<think>')) {
const thinkContent = chatText.replace('<think>', '').replace('</think>', '').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('hx-search');
// 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 (
<div className={`h-[calc(100vh-120px)] flex flex-col theme-bg rounded-xl ${isDark ? 'bg-slate-800/50' : 'bg-white'} border ${isDark ? 'border-slate-700/50' : 'border-red-100'}`}>
<GeneralQAHeader
chatHeadTitle={'课题研究'}
chatDesign={'支持任务检索,画像检索,新闻报告生成'}
compareShow={false}
isDark={isDark}
viewMode={viewMode}
setViewMode={handleSafeViewModeChange}
onNavigate={handleSafeNavigate}
/>
{/* 主体内容区 */}
<div className="flex-1 flex overflow-hidden">
<GeneralQAConfigSidebar
isDark={isDark}
viewMode={viewMode}
qaMode={qaMode}
compareLeftMode={compareLeftMode}
compareRightMode={compareRightMode}
compareLayout={compareLayout}
knowledgeScope={knowledgeScope}
answerStyle={answerStyle}
showSources={showSources}
showProcess={showProcess}
autoSummarizeDiff={autoSummarizeDiff}
recommendBetter={recommendBetter}
isCollapsed={isLeftSidebarCollapsed}
qaModeOptions={qaModeList}
setQaMode={handleSafeModeChange}
setCompareLeftMode={handleSafeCompareLeftModeChange}
setCompareRightMode={handleSafeCompareRightModeChange}
setCompareLayout={handleSafeCompareLayoutChange}
setKnowledgeScope={setKnowledgeScope}
setAnswerStyle={setAnswerStyle}
setShowSources={setShowSources}
setShowProcess={setShowProcess}
setAutoSummarizeDiff={setAutoSummarizeDiff}
setRecommendBetter={setRecommendBetter}
setIsCollapsed={setIsLeftSidebarCollapsed}
/>
{/* 中间结果区 */}
<div className="flex-1 min-w-0 flex flex-col overflow-hidden">
{viewMode === 'single' ? (
<>
{/* 聊天消息区 */}
<div
ref={chatListRef}
onScroll={handleChatScroll}
className="relative flex-1 overflow-y-auto p-4 space-y-4"
>
{messages.length === 0 ? (
<EmptyChatState
icon={<MessageSquare className="w-11 h-11 opacity-60" />}
title="开始你的问答"
description="输入问题,获取基于知识库的智能回答"
/>
) : (
messages.map((msg) => (
<ChatMessage
key={msg.id}
message={msg}
showSources={showSources}
assistantLabel="知识库助手"
assistantActive={msg.id === activeAssistantMessageId}
onCitationClick={handleCitationClick}
onCopyAssistant={handleCopyAssistantMessage}
onDeleteAssistant={handleDeleteAssistantMessage}
onEditAssistant={handleEditAssistantMessage}
onResendAssistant={handleResendAssistantMessage}
/>
))
)}
{isPreConnectLoading && (
<TypingIndicator
label={qaMode === 'single-round' ? '正在流式生成单轮 RAG 回复' : '正在生成思考过程与检索内容'}
assistantLabel="知识库助手"
/>
)}
{error && (
<div className={`ml-12 max-w-[70%] rounded-xl border px-4 py-3 text-sm ${isDark
? 'bg-red-900/20 border-red-700/50 text-red-300'
: 'bg-red-50 border-red-200 text-red-700'
}`}>
{error}
</div>
)}
{showScrollToBottom && (
<button
type="button"
onClick={() => {
setIsAutoScrollEnabled(true);
setShowScrollToBottom(false);
scrollToBottom(true);
}}
className={`fixed left-1/2 -translate-x-1/2 bottom-36 z-20 w-11 h-11 rounded-full backdrop-blur-sm border shadow-lg transition-all ${isDark
? 'bg-slate-800/65 border-slate-500/60 text-slate-100 hover:bg-slate-700/70'
: 'bg-white/70 border-gray-300 text-gray-700 hover:bg-white/85'
}`}
title="滚动到底部"
>
<ArrowDown className="w-5 h-5 mx-auto" />
</button>
)}
</div>
</>
) : (
<div ref={chatListRef} onScroll={handleChatScroll} className="flex-1 overflow-y-auto p-4">
{/* 对比结果区 */}
{
compareResult.length === 0 && <div className={`h-full flex flex-col items-center justify-center ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>
<GitCompare className="w-16 h-16 mb-4 opacity-50" />
<p className="text-lg">对比模式</p>
<p className="text-sm mt-2">输入问题,对比不同模式的回答效果</p>
</div>
}
{
compareResult.map((compare: CompareResult, index: number) => (
<div className="mb-[10px]" key={index}>
<div className="mb-4 flex justify-end">
<div className={`max-w-[72%] rounded-2xl px-4 py-3 shadow-sm ${isDark
? 'bg-blue-600/70 text-white'
: 'bg-gradient-to-r from-red-500 to-rose-500 text-white'
}`}>
<p className="whitespace-pre-wrap leading-relaxed">{compare.compareUserInput}</p>
</div>
</div>
{compareResult ? (
compareLayout === 'horizontal' ? (
<div className="grid grid-cols-2 gap-4 min-w-0">
{/* 左侧结果 */}
<div className={`min-w-0 rounded-xl border ${isDark ? 'border-slate-700/50' : 'border-red-200'}`}>
<div className={`px-4 py-3 border-b ${isDark ? 'border-slate-700/50 bg-slate-800/30' : 'border-red-100 bg-red-50/50'}`}>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<QAAvatar role="assistant" variant="blue" isActive={compare.left.loading} label="知识库助手" />
<span className={`px-2 py-1 rounded text-xs font-medium ${isDark ? 'bg-blue-600/30 text-blue-400' : 'bg-blue-100 text-blue-600'
}`}>
{getModeLabel(compare.left.mode)}
</span>
</div>
{compare.left.loading && (
<button
type="button"
onClick={() => handleStopCompareSide('left')}
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs ${isDark ? 'bg-rose-600/80 hover:bg-rose-600 text-white' : 'bg-red-500 hover:bg-red-600 text-white'
}`}
>
<Square className="w-3 h-3 fill-current" />
停止
</button>
)}
</div>
</div>
<div className="p-4 min-w-0">
<div className="flex-1 min-w-0">
{(compare.left.mode === 'single-round' || compare.left.mode === 'single-chat')? (
<SingleRagAnswer trace={compare.left.singleRagTrace || parseSingleRagStream([])} onCitationClick={(idx) => handleCompareCitationClick('left', idx)} />
) : (
<RetrievalThinkingTrace trace={compare.left.thinkingTrace || createMultiRoundStreamTrace()} onCitationClick={(idx) => handleCompareCitationClick('left', idx)} />
)}
</div>
</div>
</div>
{/* 右侧结果 */}
<div className={`min-w-0 overflow-hidden rounded-xl border ${isDark ? 'border-slate-700/50' : 'border-red-200'}`}>
<div className={`px-4 py-3 border-b ${isDark ? 'border-slate-700/50 bg-slate-800/30' : 'border-red-100 bg-red-50/50'}`}>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<QAAvatar role="assistant" variant="purple" isActive={compare.right.loading} label="知识库助手" />
<span className={`px-2 py-1 rounded text-xs font-medium ${isDark ? 'bg-purple-600/30 text-purple-400' : 'bg-purple-100 text-purple-600'
}`}>
{getModeLabel(compare.right.mode)}
</span>
</div>
{compare.right.loading && (
<button
type="button"
onClick={() => handleStopCompareSide('right')}
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs ${isDark ? 'bg-rose-600/80 hover:bg-rose-600 text-white' : 'bg-red-500 hover:bg-red-600 text-white'
}`}
>
<Square className="w-3 h-3 fill-current" />
停止
</button>
)}
</div>
</div>
<div className="p-4 min-w-0 overflow-hidden">
<div className="flex-1 min-w-0">
{(compare.right.mode === 'single-round' || compare.right.mode === 'single-chat') ? (
<SingleRagAnswer trace={compare.right.singleRagTrace || parseSingleRagStream([])} onCitationClick={(idx) => handleCompareCitationClick('right', idx)} />
) : (
<RetrievalThinkingTrace trace={compare.right.thinkingTrace || createMultiRoundStreamTrace()} onCitationClick={(idx) => handleCompareCitationClick('right', idx)} />
)}
</div>
</div>
</div>
</div>
) : (
<div className="space-y-4">
{/* 上方结果 */}
<div className={`min-w-0 overflow-hidden rounded-xl border ${isDark ? 'border-slate-700/50' : 'border-red-200'}`}>
<div className={`px-4 py-3 border-b ${isDark ? 'border-slate-700/50 bg-slate-800/30' : 'border-red-100 bg-red-50/50'}`}>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<QAAvatar role="assistant" variant="blue" isActive={compare.left.loading} label="知识库助手" />
<span className={`px-2 py-1 rounded text-xs font-medium ${isDark ? 'bg-blue-600/30 text-blue-400' : 'bg-blue-100 text-blue-600'
}`}>
{getModeLabel(compare.left.mode)}
</span>
</div>
{compare.left.loading && (
<button
type="button"
onClick={() => handleStopCompareSide('left')}
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs ${isDark ? 'bg-rose-600/80 hover:bg-rose-600 text-white' : 'bg-red-500 hover:bg-red-600 text-white'
}`}
>
<Square className="w-3 h-3 fill-current" />
停止
</button>
)}
</div>
</div>
<div className="p-4 min-w-0 overflow-hidden">
<div className="flex-1 min-w-0">
{(compare.left.mode === 'single-round' || compare.left.mode === 'single-chat') ? (
<SingleRagAnswer trace={compare.left.singleRagTrace || parseSingleRagStream([])} onCitationClick={(idx) => handleCompareCitationClick('left', idx)} />
) : (
<RetrievalThinkingTrace trace={compare.left.thinkingTrace || createMultiRoundStreamTrace()} onCitationClick={(idx) => handleCompareCitationClick('left', idx)} />
)}
</div>
</div>
</div>
{/* 下方结果 */}
<div className={`min-w-0 overflow-hidden rounded-xl border ${isDark ? 'border-slate-700/50' : 'border-red-200'}`}>
<div className={`px-4 py-3 border-b ${isDark ? 'border-slate-700/50 bg-slate-800/30' : 'border-red-100 bg-red-50/50'}`}>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<QAAvatar role="assistant" variant="purple" isActive={compare.right.loading} label="知识库助手" />
<span className={`px-2 py-1 rounded text-xs font-medium ${isDark ? 'bg-purple-600/30 text-purple-400' : 'bg-purple-100 text-purple-600'
}`}>
{getModeLabel(compare.right.mode)}
</span>
</div>
{compare.right.loading && (
<button
type="button"
onClick={() => handleStopCompareSide('right')}
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs ${isDark ? 'bg-rose-600/80 hover:bg-rose-600 text-white' : 'bg-red-500 hover:bg-red-600 text-white'
}`}
>
<Square className="w-3 h-3 fill-current" />
停止
</button>
)}
</div>
</div>
<div className="p-4 min-w-0 overflow-hidden">
<div className="flex-1 min-w-0">
{(compare.right.mode === 'single-round' || compare.right.mode === 'single-chat') ? (
<SingleRagAnswer trace={compare.right.singleRagTrace || parseSingleRagStream([])} onCitationClick={(idx) => handleCompareCitationClick('right', idx)} />
) : (
<RetrievalThinkingTrace trace={compare.right.thinkingTrace || createMultiRoundStreamTrace()} onCitationClick={(idx) => handleCompareCitationClick('right', idx)} />
)}
</div>
</div>
</div>
</div>
)
) : (
<div className={`h-full flex flex-col items-center justify-center ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>
<GitCompare className="w-16 h-16 mb-4 opacity-50" />
<p className="text-lg">对比模式</p>
<p className="text-sm mt-2">输入问题,对比不同模式的回答效果</p>
</div>
)}
</div>
))
}
{showScrollToBottom && (
<button
type="button"
onClick={() => {
setIsAutoScrollEnabled(true);
setShowScrollToBottom(false);
scrollToBottom(true);
}}
className={`fixed left-1/2 -translate-x-1/2 bottom-36 z-20 w-11 h-11 rounded-full backdrop-blur-sm border shadow-lg transition-all ${isDark
? 'bg-slate-800/65 border-slate-500/60 text-slate-100 hover:bg-slate-700/70'
: 'bg-white/70 border-gray-300 text-gray-700 hover:bg-white/85'
}`}
title="滚动到底部"
>
<ArrowDown className="w-5 h-5 mx-auto" />
</button>
)}
</div>
)}
{/* 底部输入区 */}
<QAComposer
value={inputValue}
onChange={setInputValue}
onKeyDown={handleKeyDown}
onSubmit={viewMode === 'compare' ? handleCompare : handleSend}
onStop={handleStop}
canSubmit={!isLoading && Boolean(inputValue.trim())}
onReset={handleSafeReset}
onSave={handleSaveRecord}
onCollect={handleCollect}
onOpenSettings={() => setIsModelSettingsOpen(true)}
isLoading={isLoading}
placeholder={viewMode === 'compare' ? '输入问题进行对比分析...' : '输入你的问题...'}
showReset
/>
</div>
{/* 右侧辅助区 */}
{viewMode === 'single' && showProcess && (
<div className={`w-72 border-l flex-shrink-0 overflow-y-auto p-4 ${isDark ? 'border-slate-700/50 bg-slate-800/30' : 'border-red-100 bg-gray-50/50'
}`}>
{/* 检索过程 */}
<div className="mb-6">
<h3 className={`text-sm font-medium mb-3 ${isDark ? 'text-slate-300' : 'text-gray-700'}`}>
检索过程
</h3>
<div className={`p-4 rounded-xl ${isDark ? 'bg-slate-700/50' : 'bg-white'}`}>
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className={`text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>当前模式</span>
<span className={`text-sm font-medium ${isDark ? 'text-blue-400' : 'text-blue-600'}`}>
{searchProcess.currentMode}
</span>
</div>
<div className="flex items-center justify-between">
<span className={`text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>检索轮次</span>
<span className={`text-sm ${isDark ? 'text-slate-300' : 'text-gray-700'}`}>
{searchProcess.rounds}
</span>
</div>
<div className="flex items-center justify-between">
<span className={`text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>命中数</span>
<span className={`text-sm ${isDark ? 'text-slate-300' : 'text-gray-700'}`}>
{searchProcess.hits}
</span>
</div>
{searchProcess.queryRewrite && (
<div>
<span className={`text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>Query改写</span>
<p className={`text-xs mt-1 ${isDark ? 'text-slate-300' : 'text-gray-600'}`}>
{searchProcess.queryRewrite}
</p>
</div>
)}
</div>
</div>
</div>
</div>
)}
{/* 对比模式的差异总结 */}
{/* {viewMode === 'compare' && compareResult?.differences && (
<div className={`w-72 border-l flex-shrink-0 overflow-y-auto p-4 ${isDark ? 'border-slate-700/50 bg-slate-800/30' : 'border-red-100 bg-gray-50/50'
}`}>
<h3 className={`text-sm font-medium mb-3 ${isDark ? 'text-slate-300' : 'text-gray-700'}`}>
差异总结
</h3>
<div className={`p-4 rounded-xl space-y-4 ${isDark ? 'bg-slate-700/50' : 'bg-white'}`}>
<div>
<span className={`text-xs flex items-center gap-1 ${isDark ? 'text-green-400' : 'text-green-600'}`}>
<CheckCircle className="w-3 h-3" />
一致观点
</span>
<p className={`text-xs mt-1 ${isDark ? 'text-slate-300' : 'text-gray-600'}`}>
{compareResult.differences.consensus}
</p>
</div>
<div>
<span className={`text-xs flex items-center gap-1 ${isDark ? 'text-yellow-400' : 'text-yellow-600'}`}>
<AlertCircle className="w-3 h-3" />
差异观点
</span>
<p className={`text-xs mt-1 ${isDark ? 'text-slate-300' : 'text-gray-600'}`}>
{compareResult.differences.differences}
</p>
</div>
<div>
<span className={`text-xs ${isDark ? 'text-blue-400' : 'text-blue-600'}`}>
{getModeLabel(compareResult.left.mode)}优势
</span>
<p className={`text-xs mt-1 ${isDark ? 'text-slate-300' : 'text-gray-600'}`}>
{compareResult.differences.leftAdvantage}
</p>
</div>
<div>
<span className={`text-xs ${isDark ? 'text-purple-400' : 'text-purple-600'}`}>
{getModeLabel(compareResult.right.mode)}优势
</span>
<p className={`text-xs mt-1 ${isDark ? 'text-slate-300' : 'text-gray-600'}`}>
{compareResult.differences.rightAdvantage}
</p>
</div>
</div>
</div>
)} */}
</div>
{editingMessageId && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/45 backdrop-blur-[1px]" onClick={handleCancelAssistantEdit} />
<div className={`relative w-full max-w-3xl rounded-2xl border shadow-2xl p-5 ${isDark ? 'bg-slate-900 border-slate-700 text-slate-100' : 'bg-white border-gray-200 text-gray-900'
}`}>
<button
type="button"
onClick={handleCancelAssistantEdit}
className={`absolute top-3 right-3 p-1.5 rounded-lg transition-all ${isDark ? 'hover:bg-slate-700 text-slate-300' : 'hover:bg-gray-100 text-gray-500'
}`}
title="关闭"
>
<X className="w-4 h-4" />
</button>
<div className="pr-9">
<h3 className="text-base font-semibold">编辑模型生成内容</h3>
<p className={`mt-1 text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
可同时修改模型回答和思考过程,多轮检索会把思考过程写回第一轮思考内容。
</p>
</div>
<div className="mt-4 grid gap-4 md:grid-cols-2">
<label className="space-y-2">
<span className={`text-sm font-medium ${isDark ? 'text-slate-200' : 'text-gray-700'}`}>思考过程</span>
<textarea
value={editingThinking}
onChange={(event) => setEditingThinking(event.target.value)}
className={`h-80 w-full resize-none rounded-xl border p-3 text-sm focus:outline-none ${isDark ? 'border-slate-700 bg-slate-800 text-slate-100' : 'border-gray-200 bg-gray-50 text-gray-800'
}`}
/>
</label>
<label className="space-y-2">
<span className={`text-sm font-medium ${isDark ? 'text-slate-200' : 'text-gray-700'}`}>最终回答</span>
<textarea
value={editingAnswer}
onChange={(event) => setEditingAnswer(event.target.value)}
className={`h-80 w-full resize-none rounded-xl border p-3 text-sm focus:outline-none ${isDark ? 'border-slate-700 bg-slate-800 text-slate-100' : 'border-gray-200 bg-gray-50 text-gray-800'
}`}
/>
</label>
</div>
<div className="mt-4 flex justify-end gap-2">
<button type="button" onClick={handleCancelAssistantEdit} className={`px-4 py-2 rounded-xl text-sm ${isDark ? 'bg-slate-800 hover:bg-slate-700 text-slate-200' : 'bg-gray-100 hover:bg-gray-200 text-gray-700'}`}>
取消
</button>
<button type="button" onClick={handleSaveAssistantEdit} className="px-4 py-2 rounded-xl text-sm bg-red-500 hover:bg-red-600 text-white">
保存
</button>
</div>
</div>
</div>
)}
{selectedCitation && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div
className="absolute inset-0 bg-black/45 backdrop-blur-[1px]"
onClick={() => setSelectedCitation(null)}
/>
<div className={`relative w-full max-w-xl rounded-2xl border shadow-2xl p-5 ${isDark
? 'bg-slate-900 border-slate-700 text-slate-100'
: 'bg-white border-gray-200 text-gray-900'
}`}>
<button
type="button"
onClick={() => setSelectedCitation(null)}
className={`absolute top-3 right-3 p-1.5 rounded-lg transition-all ${isDark ? 'hover:bg-slate-700 text-slate-300' : 'hover:bg-gray-100 text-gray-500'
}`}
title="关闭"
>
<X className="w-4 h-4" />
</button>
<div className="pr-9">
<h3 className="text-base font-semibold">{selectedCitation.title || '引用内容'}</h3>
<div className={`mt-2 text-xs space-y-1 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
<div>来源:{selectedCitation.source || selectedCitation.type || '未知来源'}</div>
<div>时间:{selectedCitation.time || '未知'}</div>
</div>
</div>
{selectedCitation.url && (
<div className="mt-4">
<a
href={`${knowledgeUrl}/#/knowledge/mid-detail?recUuid=${selectedCitation.url}`}
target="_blank"
rel="noreferrer"
className={`inline-flex items-center gap-2 px-3 py-1.5 rounded-lg border text-sm transition-all ${isDark
? 'border-slate-600 text-slate-200 hover:bg-slate-800'
: 'border-gray-300 text-gray-700 hover:bg-gray-50'
}`}
title="打开原始链接"
>
<ExternalLink className="w-4 h-4" />
查看原文链接
</a>
</div>
)}
<div className={`mt-4 rounded-xl p-3 text-sm leading-relaxed max-h-[500px] overflow-auto pr-[10px] whitespace-pre-wrap ${isDark ? 'bg-slate-800/70 text-slate-200' : 'bg-gray-50 text-gray-700'
}`}>
{selectedCitation.content || selectedCitation.snippet || '暂无引用内容'}
</div>
</div>
</div>
)}
<Dialog
visible={isModelSettingsOpen}
header="模型参数设置"
width="520px"
confirmBtn="保存"
cancelBtn="取消"
onConfirm={() => setIsModelSettingsOpen(false)}
onClose={() => setIsModelSettingsOpen(false)}
>
<div className="space-y-6 py-2">
<div>
<label className={`block text-sm font-medium mb-2 ${isDark ? 'text-slate-200' : 'text-gray-700'}`}>
模型
</label>
<Select
value={modelName}
onChange={(value) => setModelName(String(value))}
options={[
{ label: 'Qwen3-32B: 效果与速度均衡(推荐)', value: 'OpenAI3' },
{ label: 'Qwen3-235B:效果与速度均衡(推荐)', value: 'OpenAI4' },
{ label: 'GLM-5:逻辑性强,长上下文', value: 'OpenAI5' },
{ label: 'Qwen3.5-27B:深度思考过程长', value: 'OpenAI6' },
{ label: 'Qwen3-8B:速度快,效果较差', value: 'OpenAI7' },
// { label: 'Qwen2.5-72B:老模型,发散性差', value: 'OpenAI8' },
{ label: '四季7B:速度快,效果较差', value: 'OpenAI9' },
]}
style={{ width: '100%' }}
/>
</div>
<div>
<div className={`text-sm font-medium mb-3 ${isDark ? 'text-slate-200' : 'text-gray-700'}`}>
相似度阈值 score_threshold:{scoreThreshold.toFixed(2)}
</div>
<Slider
label
layout="horizontal"
min={0}
max={1}
step={0.01}
value={scoreThreshold}
onChange={(value) => setScoreThreshold(Number(value))}
/>
</div>
<div>
<div className={`text-sm font-medium mb-3 ${isDark ? 'text-slate-200' : 'text-gray-700'}`}>
温度 temperature:{temperature.toFixed(2)}
</div>
<Slider
label
layout="horizontal"
min={0}
max={1}
step={0.01}
value={temperature}
onChange={(value) => setTemperature(Number(value))}
/>
</div>
</div>
</Dialog>
</div>
);
};
export default ActionChat;