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

2455 lines
100 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,
} from 'lucide-react';
import { Dialog, Select, Slider } from 'tdesign-react';
import { useTheme } from '../../contexts/ThemeContext';
import { PageType } from '../../components/Sidebar';
import { ChatMessage, EmptyChatState, QAAvatar, QAComposer, QASingleRagTrace, QAThinkingRound, QAThinkingTrace, RetrievalThinkingTrace, SingleRagAnswer, TypingIndicator } from './QACommon';
import GeneralQAHeader from './GeneralQAHeader';
import GeneralQAConfigSidebar from './GeneralQAConfigSidebar';
import type { ComparePane, CompareResult, KnowledgeBaseChunk, Message, QAMode, SourceItem, ToolCallData, ViewMode } from './GeneralQA.types';
import {
chatDataJSON,
createComparePane,
createMockSingleRagMessages,
createMultiRoundStreamTrace,
createThinkingTrace,
extractJsonObjects,
mapDocsToSources,
mapReferenceToSource,
parseSingleRagStream,
} from './GeneralQA.mock';
/**
* 通用问答组件
* 支持单轮RAG、多轮RAG、深度检索及结果对比功能
* @param onNavigate - 页面导航函数
* @param initialMode - 初始模式(可选)
*/
const zncmUrl = import.meta.env.VITE_API_BASE_URL || '';
const chatUrl = import.meta.env.VITE_AIPI_CHAT_URL || '';
const generateUrl = import.meta.env.VITE_AIPI_GENERATE_CHAT_URL || '';
const knowledgeUrl = import.meta.env.VITE_AIPI_KNOWLEDGE_URL
const GeneralQA: React.FC<{ onNavigate: (page: PageType) => void; initialMode?: string }> = ({ onNavigate }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
// 状态管理
// 视图模式:单一模式或对比模式
const [viewMode, setViewMode] = useState<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 | null>(null);
// 对比模式下用户输入的问题内容,用于左右对比时的统一输入
const [compareUserInput, setCompareUserInput] = useState('');
// 是否折叠左侧配置面板,true 为折叠,false 为展开
const [isLeftSidebarCollapsed, setIsLeftSidebarCollapsed] = useState(false);
// 是否启用自动滚动,当用户手动滚动时会自动关闭
const [isAutoScrollEnabled, setIsAutoScrollEnabled] = useState(true);
// 是否显示"滚动到底部"按钮,当有未读消息且用户已滚动时显示
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
// 当前选中的引用来源,用于显示引用详情弹窗
const [selectedCitation, setSelectedCitation] = useState<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: ReadableStreamDefaultReader<Uint8Array> | null;
right: ReadableStreamDefaultReader<Uint8Array> | null;
}>({ 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 [searchProcess, setSearchProcess] = useState({
currentMode: '单轮RAG',
rounds: 1,
hits: 5,
queryRewrite: '',
});
/**
* 处理单轮 RAG 问答请求
*
* 单轮 RAG 的处理流程:
* 1. 向知识库问答 API 发送 POST 请求,携带用户问题和检索参数
* 2. API 返回 SSE 流式响应,包含多个 JSON 对象
* 3. 逐个解析流式 JSON,提取 answer 和 docs 字段
* 4. 通过 applyAnswerToken 将流式 token 追加到消息的 thinkingMarkdown/answerMarkdown
* 5. 实时更新消息来源(sources)和检索统计(searchProcess)
*
* @param query - 用户输入的问题文本
*/
const handleSingleRoundRun = async (query: string) => {
// 清空之前的错误信息
setError(null);
// 标记开始加载,禁用提交按钮防止重复点击
setIsLoading(true);
// 标记"连接中"状态,显示加载占位符
setIsPreConnectLoading(true);
// 用于存储当前 assistant 消息的 ID,以便后续更新该消息内容
let assistantId = '';
// 标志位:当前处理的 token 是否处于 <think>... 标签内(解析思考过程)
let inThink = false;
// 标志位:是否已经遇到过 <think> 标签开始解析思考内容
let thinkStarted = false;
/**
* 将流式接收到的 token 应用到对应消息的 singleRagTrace 中
* 该函数会解析 token 中的 <think>... 标签,将思考过程和回答分开存储
* @param token - 从流中接收到的文本片段
*/
const applyAnswerToken = (token: string) => {
setMessages((prev) => prev.map((msg) => {
// 跳过不是当前 assistant 消息或没有 singleRagTrace 的消息
if (msg.id !== assistantId || !msg.singleRagTrace) return msg;
// 获取当前消息的思考和回答内容
let thinkingMarkdown = msg.singleRagTrace.thinkingMarkdown;
let answerMarkdown = msg.singleRagTrace.answerMarkdown;
let thinkingComplete = msg.singleRagTrace.thinkingComplete || false;
// 剩余未处理的 token 内容
let remaining = token;
// 循环处理所有 token,直到处理完毕
while (remaining) {
// 当前处于 <think> 标签内(解析思考过程),查找结束标签
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;
}
}
}
// 返回更新后的消息对象,保留 singleRagTrace 中的所有字段
return {
...msg,
singleRagTrace: {
...msg.singleRagTrace,
// 更新思考和回答的 markdown 内容
thinkingMarkdown,
answerMarkdown,
// 只有当已经开始解析思考内容时才更新 thinkingComplete 标志
thinkingComplete: thinkStarted ? thinkingComplete : true,
},
};
}));
};
try {
// 向知识库问答 API 发送 POST 请求,携带用户问题和检索参数
const response = await fetch(`${chatUrl}/chat/knowledge_base_chat_tiaoshi`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
// 请求体包含查询内容、知识库名称、检索参数、模型配置等
body: JSON.stringify({
query, // 用户输入的问题
knowledge_base_name: [], // 知识库名称列表,空数组表示使用所有知识库
top_k: 5, // 返回最相似的 5 条结果
score_threshold: scoreThreshold, // 相似度阈值,过滤低质量检索结果
history: [], // 对话历史,用于多轮对话
stream: true, // 启用流式响应
model_name: modelName, // 使用的模型名称
temperature, // 生成温度,控制随机性
prompt_name: 'default', // 使用的提示词模板
local_doc_url: false, // 是否返回本地文档 URL
}),
});
// 检查 HTTP 响应状态
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
// 确保响应体存在(SSE 流需要 body)
if (!response.body) {
throw new Error('Response body is null');
}
// 收到有效响应后,关闭"连接中"状态,开始显示实际内容
setIsPreConnectLoading(false);
// 生成唯一 ID 用于标识这条 assistant 消息
assistantId = `assistant-${Date.now()}`;
// 初始化思考过程追踪对象
const initialTrace: QASingleRagTrace = {
title: '单轮 RAG 思考过程',
thinkingMarkdown: '', // 思考过程内容
answerMarkdown: '', // 最终回答内容
thinkingComplete: false, // 思考是否完成
};
// 将 assistant 消息添加到消息列表中
setMessages((prev) => [...prev, {
id: assistantId,
role: 'assistant',
content: '',
timestamp: new Date(),
singleRagTrace: initialTrace,
sources: [], // 初始为空,随着检索结果更新
}]);
// 获取响应体的流式读取器
const reader = response.body.getReader();
// 保存 reader 引用,用于"停止生成"功能
readerRef.current = reader;
// 创建文本解码器,将二进制数据转换为字符串
const decoder = new TextDecoder();
// buffer 用于暂存未完成的 JSON 片段
let buffer = '';
let docsList: any[] = [];
try {
// 持续读取流数据,直到流结束
while (true) {
// 读取下一个数据块
const { done, value } = await reader.read();
// done 为 true 时表示流已结束
if (done) break;
// 将二进制数据解码并追加到 buffer
buffer += decoder.decode(value, { stream: true });
// 从 buffer 中提取完整的 JSON 对象
const extracted = extractJsonObjects(buffer);
if (buffer.includes('docs')) {
try {
docsList = chatDataJSON(buffer).docsList || [];
} catch (error: any) {
console.log(error);
}
}
// 剩余无法解析的内容保留到下次处理
buffer = extracted.rest;
// 遍历所有提取到的 JSON 对象
for (const jsonText of extracted.objects) {
try {
// 解析知识库返回的 JSON 片段
const parsed = parseKnowledgeBaseChunk(jsonText);
// 如果有回答内容,追加到消息的 answerMarkdown
if (typeof parsed.answer === 'string') {
applyAnswerToken(parsed.answer);
}
// 如果有检索文档,更新消息的来源信息
if (parsed.docs) {
// 将 docs 转换为 sources 格式
const mappedDocs = docsList.map((item: any) => ({
title: item.m_title,
type: item.source1,
snippet: item.m_content,
source: item.source1,
time: item.m_publish,
url: item.recUuid,
content: item.m_content || '',
}));
// 更新检索统计信息
setSearchProcess((prev) => ({ ...prev, hits: prev.hits + mappedDocs.length }));
// 更新消息的来源列表
setMessages((prev) => prev.map((msg) => (
msg.id === assistantId ? { ...msg, sources: mappedDocs } : msg
)));
}
} catch (parseError) {
// 解析错误不影响主流程,记录日志继续处理
console.error('单轮RAG流式JSON解析错误:', parseError);
}
}
}
} finally {
// 流读取完成后,释放 reader 锁并清空引用
if (readerRef.current) {
readerRef.current.releaseLock();
readerRef.current = null;
}
}
} catch (runError) {
// 请求失败处理
console.error('单轮RAG请求失败:', runError);
setError(runError instanceof Error ? runError.message : '发生未知错误,请稍后重试');
} finally {
// 无论成功还是失败,都要重置加载状态和解锁提交
setIsPreConnectLoading(false);
setIsLoading(false);
submitLockRef.current = false;
}
};
/**
* 处理单轮 RAG 问答请求
*
* 单轮 RAG 的处理流程:
* 1. 向知识库问答 API 发送 POST 请求,携带用户问题和检索参数
* 2. API 返回 SSE 流式响应,包含多个 JSON 对象
* 3. 逐个解析流式 JSON,提取 answer 和 docs 字段
* 4. 通过 applyAnswerToken 将流式 token 追加到消息的 thinkingMarkdown/answerMarkdown
* 5. 实时更新消息来源(sources)和检索统计(searchProcess)
*
* @param query - 用户输入的问题文本
*/
const chatRun = async (query: string) => {
// 清空之前的错误信息
setError(null);
// 标记开始加载,禁用提交按钮防止重复点击
setIsLoading(true);
// 标记"连接中"状态,显示加载占位符
setIsPreConnectLoading(true);
// 用于存储当前 assistant 消息的 ID,以便后续更新该消息内容
let assistantId = '';
// 标志位:当前处理的 token 是否处于 <think>... 标签内(解析思考过程)
let inThink = false;
// 标志位:是否已经遇到过 <think> 标签开始解析思考内容
let thinkStarted = false;
/**
* 将流式接收到的 token 应用到对应消息的 singleRagTrace 中
* 该函数会解析 token 中的 <think>... 标签,将思考过程和回答分开存储
* @param token - 从流中接收到的文本片段
*/
const applyAnswerToken = (token: string) => {
setMessages((prev) => prev.map((msg) => {
// 跳过不是当前 assistant 消息或没有 singleRagTrace 的消息
if (msg.id !== assistantId || !msg.singleRagTrace) return msg;
// 获取当前消息的思考和回答内容
let thinkingMarkdown = msg.singleRagTrace.thinkingMarkdown;
let answerMarkdown = msg.singleRagTrace.answerMarkdown;
let thinkingComplete = msg.singleRagTrace.thinkingComplete || false;
// 剩余未处理的 token 内容
let remaining = token;
console.log('remaining', remaining)
// 当前处于 <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;
}
}
}
// 返回更新后的消息对象,保留 singleRagTrace 中的所有字段
return {
...msg,
singleRagTrace: {
...msg.singleRagTrace,
// 更新思考和回答的 markdown 内容
thinkingMarkdown,
answerMarkdown,
// 只有当已经开始解析思考内容时才更新 thinkingComplete 标志
thinkingComplete: thinkStarted ? thinkingComplete : true,
},
};
}));
};
try {
// 向知识库问答 API 发送 POST 请求,携带用户问题和检索参数
const response = await fetch(`${chatUrl}/chat/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
// 请求体包含查询内容、知识库名称、检索参数、模型配置等
body: JSON.stringify({
query, // 用户输入的问题
history: [], // 对话历史,用于多轮对话
stream: true, // 启用流式响应
model_name: modelName, // 使用的模型名称
temperature, // 生成温度,控制随机性
prompt_name: 'default', // 使用的提示词模板
max_tokens: 1000
}),
});
// 检查 HTTP 响应状态
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
// 确保响应体存在(SSE 流需要 body)
if (!response.body) {
throw new Error('Response body is null');
}
// 收到有效响应后,关闭"连接中"状态,开始显示实际内容
setIsPreConnectLoading(false);
// 生成唯一 ID 用于标识这条 assistant 消息
assistantId = `assistant-${Date.now()}`;
// 初始化思考过程追踪对象
const initialTrace: QASingleRagTrace = {
title: '大模型思考过程',
thinkingMarkdown: '', // 思考过程内容
answerMarkdown: '', // 最终回答内容
thinkingComplete: false, // 思考是否完成
};
// 将 assistant 消息添加到消息列表中
setMessages((prev) => [...prev, {
id: assistantId,
role: 'assistant',
content: '',
timestamp: new Date(),
singleRagTrace: initialTrace,
sources: [], // 初始为空,随着检索结果更新
}]);
// 获取响应体的流式读取器
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
// 保存 reader 引用,用于"停止生成"功能
readerRef.current = reader;
// 创建文本解码器,将二进制数据转换为字符串
const decoder = new TextDecoder();
// buffer 用于暂存未完成的 JSON 片段
try {
// 持续读取流数据,直到流结束
while (true) {
// 读取下一个数据块
const { done, value } = await reader.read();
// done 为 true 时表示流已结束
if (done) break;
applyAnswerToken(value);
}
} finally {
// 流读取完成后,释放 reader 锁并清空引用
if (readerRef.current) {
// readerRef.current.releaseLock();
readerRef.current = null;
}
}
} catch (runError) {
// 请求失败处理
console.error('单轮RAG请求失败:', runError);
setError(runError instanceof Error ? runError.message : '发生未知错误,请稍后重试');
} finally {
// 无论成功还是失败,都要重置加载状态和解锁提交
setIsPreConnectLoading(false);
setIsLoading(false);
submitLockRef.current = false;
}
};
/**
* 解析类 JavaScript 对象字符串中的字符串值。
*
* 后端单轮 RAG 流有时不是严格 JSON,而是 `{answer: '...'}` 这种对象片段。
* 该函数只负责把单/双引号包裹的 answer 内容还原成普通字符串,保留换行和转义字符。
*/
const parseLooseStringValue = (rawValue: string): string => {
try {
return JSON.parse(rawValue) as string;
} catch {
const trimmedValue = rawValue.trim();
if (trimmedValue.startsWith("'") && trimmedValue.endsWith("'")) {
return trimmedValue
.slice(1, -1)
.replace(/\\n/g, '\n')
.replace(/\\r/g, '\r')
.replace(/\\t/g, '\t')
.replace(/\\'/g, "'")
.replace(/\\\\/g, '\\');
}
throw new Error(`无法解析字符串值: ${rawValue}`);
}
};
const parseLooseValue = (rawValue: string): unknown => {
try {
return JSON.parse(rawValue);
} catch {
return parseLooseStringValue(rawValue);
}
};
/**
* 将单轮知识库流中的一个对象片段转换为统一数据结构。
*
* 兼容两类返回:
* - 严格 JSON:`{"answer":"..."}` / `{"docs":[...]}`
* - 宽松对象:`{answer: '...'}` / `{docs: [...]}`
*
* 真实接口和本地模拟都走同一个解析函数,确保联调时只需要替换数据源,不需要改 UI 更新逻辑。
*/
const parseKnowledgeBaseChunk = (jsonText: string): KnowledgeBaseChunk => {
try {
return JSON.parse(jsonText) as KnowledgeBaseChunk;
} catch {
const objectText = jsonText.trim();
const answerMatch = objectText.match(/^\{\s*answer\s*:\s*([\s\S]*)\s*\}$/);
if (answerMatch) {
const rawValue = answerMatch[1].trim();
return { answer: parseLooseStringValue(rawValue) };
}
const docsMatch = objectText.match(/^\{\s*docs\s*:\s*([\s\S]*)\s*\}$/);
if (docsMatch) {
return { docs: parseLooseValue(docsMatch[1]) as KnowledgeBaseChunk['docs'] };
}
const normalizedText = objectText.replace(/([{,]\s*)(answer|docs)(\s*:)/g, '$1"$2"$3');
return JSON.parse(normalizedText) as KnowledgeBaseChunk;
}
};
const sleep = (ms: number) => new Promise((resolve) => {
window.setTimeout(resolve, ms);
});
/**
* 生成单轮 RAG 的本地模拟原始流。
*
* 返回值刻意模拟接口的“对象字符串片段”形态,例如:
* `{answer: ',并附'}{answer: '带引用'}` 和 `{docs: [...]}`。
* 调用方仍通过 `extractJsonObjects` 拆包,再通过 `parseKnowledgeBaseChunk` 解析,
* 这样可以覆盖粘包、连续对象和 docs 延迟到达等真实联调场景。
*/
const createMockSingleRoundStream = (query: string): string[] => {
const answer = `<think>用户正在使用单轮 RAG 提问:“${query}”。\n\n先从知识库里定位与问题最相关的片段,再把证据组织成可追溯的回答。\n</think>我已基于模拟知识库完成检索,并按单轮 RAG 的方式生成回答。\n\n### 回答要点\n\n1. **先检索**:系统会围绕用户问题召回最相关的知识片段。[[1]]\n2. **再增强**:把召回内容注入上下文,约束模型只围绕证据回答。[[2]]\n3. **最后生成**:模型输出结论,并附带引用编号,方便用户核验来源。[[3]]\n\n当前内容是本地模拟的流式返回,用来验证一个一个 \`{answer: '模型输出'}\` 推送时的打字机效果。`;
const docs: KnowledgeBaseChunk['docs'] = [
{
title: '单轮 RAG 接入说明',
content: '单轮 RAG 通常包含问题输入、TopK 检索、Prompt 组装、模型生成和引用返回几个步骤。',
source: '知识库 / RAG 接入说明.md',
snippet: '单轮 RAG 通常包含问题输入、TopK 检索、Prompt 组装、模型生成和引用返回几个步骤。',
},
{
title: '知识库问答接口协议',
content: "流式响应会持续返回 JSON 对象,每个对象可包含 answer 字段;结束或中间节点可携带 docs 参考资料。",
source: '接口文档 / knowledge_base_chat.json',
snippet: '流式响应会持续返回 JSON 对象,每个对象可包含 answer 字段。',
},
{
title: '引用展示规范',
content: '回答中的引用编号应与 docs 顺序保持一致,前端可将 docs 映射为来源卡片供用户查看。',
source: '产品规范 / 引用展示规范.docx',
snippet: '回答中的引用编号应与 docs 顺序保持一致。',
},
];
const rawChunks: string[] = [];
const toSingleQuotedValue = (value: string) => `'${value
.replace(/\\/g, '\\\\')
.replace(/\n/g, '\\n')
.replace(/\r/g, '\\r')
.replace(/\t/g, '\\t')
.replace(/'/g, "\\'")}'`;
let pendingObjectText = '';
const pushAnswerObject = (answerToken: string) => {
const objectText = `{answer: ${toSingleQuotedValue(answerToken)}}`;
if (pendingObjectText) {
rawChunks.push(`${pendingObjectText}${objectText}`);
pendingObjectText = '';
return;
}
if (answerToken === ',并附') {
pendingObjectText = objectText;
return;
}
rawChunks.push(objectText);
};
const thinkStart = '<think>';
const thinkEnd = '</think>';
const startIndex = answer.indexOf(thinkStart);
const endIndex = answer.indexOf(thinkEnd);
if (startIndex === -1 || endIndex === -1) {
for (let index = 0; index < answer.length; index += 3) {
pushAnswerObject(answer.slice(index, index + 3));
}
} else {
const beforeThink = answer.slice(0, startIndex);
const thinkingText = answer.slice(startIndex + thinkStart.length, endIndex);
if (beforeThink) pushAnswerObject(beforeThink);
pushAnswerObject(thinkStart);
for (let index = 0; index < thinkingText.length; index += 3) {
pushAnswerObject(thinkingText.slice(index, index + 3));
}
pushAnswerObject(thinkEnd);
const finalAnswerTokens = [
'我已基于模拟知识库完成检索',
',并附',
'带引用',
'带引用',
'。\n\n### 回答要点\n\n',
'1. **先检索**:系统会围绕用户问题召回最相关的知识片段。[[1]]\n',
'2. **再增强**:把召回内容注入上下文,约束模型只围绕证据回答。[[2]]\n',
'3. **最后生成**:模型输出结论,并附带引用编号,方便用户核验来源。[[3]]\n\n',
'当前内容是本地模拟的对象字符串流式返回,用来验证连续多个 `{answer: \'模型输出\'}` 和 `{docs: ...}` 的处理效果。',
];
finalAnswerTokens.forEach(pushAnswerObject);
}
if (pendingObjectText) {
rawChunks.push(pendingObjectText);
}
rawChunks.push(`{docs: ${JSON.stringify(docs)}}`);
return rawChunks;
};
/**
* 执行单轮 RAG 本地模拟。
*
* 模拟过程与真实接口保持一致:先创建空 assistant 消息,再持续解析对象字符串流,
* answer 片段进入打字机效果,docs 片段映射为引用来源。
*/
const handleMockSingleRoundRun = async (query: string) => {
setError(null);
setIsLoading(true);
setIsPreConnectLoading(true);
mockSingleRoundStoppedRef.current = false;
const assistantId = `assistant-${Date.now()}`;
let inThink = false;
let thinkStarted = false;
const applyAnswerToken = (token: string) => {
setMessages((prev) => prev.map((msg) => {
if (msg.id !== assistantId || !msg.singleRagTrace) return msg;
let thinkingMarkdown = msg.singleRagTrace.thinkingMarkdown;
let answerMarkdown = msg.singleRagTrace.answerMarkdown;
let thinkingComplete = msg.singleRagTrace.thinkingComplete || false;
let remaining = token;
console.log(remaining);
while (remaining) {
if (inThink) {
const endIdx = remaining.indexOf('</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;
}
}
}
return {
...msg,
singleRagTrace: {
...msg.singleRagTrace,
thinkingMarkdown,
answerMarkdown,
thinkingComplete: thinkStarted ? thinkingComplete : true,
},
};
}));
};
try {
await sleep(300);
if (mockSingleRoundStoppedRef.current) return;
setIsPreConnectLoading(false);
const initialTrace: QASingleRagTrace = {
title: '单轮 RAG 思考过程',
thinkingMarkdown: '',
answerMarkdown: '',
thinkingComplete: false,
};
setMessages((prev) => [...prev, {
id: assistantId,
role: 'assistant',
content: '',
timestamp: new Date(),
singleRagTrace: initialTrace,
sources: [],
}]);
const streamChunks = createMockSingleRoundStream(query);
let buffer = '';
for (const rawChunk of streamChunks) {
if (mockSingleRoundStoppedRef.current) break;
buffer += rawChunk;
const extracted = extractJsonObjects(buffer);
buffer = extracted.rest;
for (const jsonText of extracted.objects) {
try {
const chunk = parseKnowledgeBaseChunk(jsonText);
if (typeof chunk.answer === 'string') {
applyAnswerToken(chunk.answer);
await sleep(45);
}
if (chunk.docs) {
const mappedDocs = mapDocsToSources(chunk.docs);
setSearchProcess((prev) => ({ ...prev, hits: prev.hits + mappedDocs.length }));
setMessages((prev) => prev.map((msg) => (
msg.id === assistantId ? { ...msg, sources: [...(msg.sources || []), ...mappedDocs] } : msg
)));
}
} catch (parseError) {
console.error('单轮RAG模拟流式JSON解析错误:', parseError);
}
}
}
} catch (runError) {
console.error('单轮RAG模拟流失败:', runError);
setError(runError instanceof Error ? runError.message : '发生未知错误,请稍后重试');
} finally {
setIsPreConnectLoading(false);
setIsLoading(false);
mockSingleRoundStoppedRef.current = false;
submitLockRef.current = false;
}
};
/**
* 处理多轮真实接口里的工具调用事件。
*
* `show_text` 用于追加思考或最终回答;`local_search` / `tavily_search` 用于把检索结果填入最近一轮。
* 这里保持事件到 UI trace 的单向映射,避免请求层和展示层互相耦合。
*/
const handleToolCall = (toolData: ToolCallData, assistantId: string) => {
setMessages((prevMessages) => prevMessages.map((msg) => {
if (msg.id !== assistantId || msg.role !== 'assistant' || !msg.thinkingTrace) return msg;
const trace = { ...msg.thinkingTrace, rounds: [...msg.thinkingTrace.rounds] };
const lastRound = trace.rounds[trace.rounds.length - 1];
switch (toolData.tool_name) {
case 'show_text': {
const chatText = toolData.tool_input.text || '';
if (chatText.includes('<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;
}
break;
}
case 'tavily_search':
case 'local_search': {
if (toolData.tool_input.result && lastRound) {
try {
const result = JSON.parse(toolData.tool_input.result) as {
query?: string;
results?: Array<{ title?: string; content?: string; url?: string }>;
};
lastRound.query = result.query || lastRound.query;
lastRound.queryLoading = false;
lastRound.title = toolData.tool_name === 'local_search' ? '本地知识检索' : '联网检索';
lastRound.retrievals = (result.results || []).map((item, idx) => ({
id: `${toolData.tool_name}-${idx}`,
title: item.title || '未命名结果',
content: item.content || '',
source: item.url || '',
}));
} catch (parseError) {
console.error('搜索结果JSON解析错误:', parseError);
}
}
break;
}
case 'google_search': {
if (toolData.tool_input.result && lastRound) {
try {
const result = JSON.parse(toolData.tool_input.result) as {
organic: Array<{ title?: string; content?: string; url?: string; recUuid?: string; publish_time?:string }>;
};
lastRound.queryLoading = false;
lastRound.title = '思考过程';
lastRound.retrievals = (result.organic || []).map((item, idx) => ({
id: item.recUuid,
title: item.title || '未命名结果',
content: item.content || '',
source: item.recUuid || '',
time: item.publish_time,
recUuid: item.recUuid,
type: 'more'
}));
} catch (parseError) {
console.error('搜索结果JSON解析错误:', parseError);
}
} else if(toolData.tool_input.q) {
lastRound.query = toolData.tool_input.q;
}
break;
}
default:
break;
}
return {
...msg,
thinkingTrace: trace,
};
}));
};
/**
* 构造多轮 RAG 模拟 trace 的初始骨架。
*
* 每一轮先展示 thought/query/loading,随后 `handleMockMultiRoundRun` 再按轮次填充 retrievals,
* 用来模拟“检索过程逐步展开”的体验。
*/
const createMockMultiRoundTrace = (query: string): QAThinkingTrace => ({
title: '多轮检索思考过程',
rounds: [
{
id: `mock-round-1-${Date.now()}`,
title: '第一轮:理解问题并确定主检索方向',
thoughtMarkdown: `用户问题是:“${query}”。\n\n先判断问题核心意图,并抽取关键词:\n\n- 业务对象\n- 关键约束\n- 需要引用的制度或知识片段\n\n第一轮先用原始问题做宽召回,尽量覆盖相关资料。`,
query: `${query} 相关制度 知识库 处理流程`,
queryLoading: true,
retrievals: [],
queryShow: true,
},
{
id: `mock-round-2-${Date.now()}`,
title: '第二轮:基于首轮结果改写 Query',
thoughtMarkdown: '首轮检索获得了基础资料,但仍需要补充更精确的执行口径。\n\n本轮将问题改写为更适合检索的短查询,并优先召回流程、规范和案例类资料。',
query: `${query} 操作规范 适用条件 案例`,
queryLoading: true,
retrievals: [],
queryShow: true,
},
{
id: `mock-round-3-${Date.now()}`,
title: '第三轮:补充边界条件并交叉验证',
thoughtMarkdown: '前两轮已经覆盖主要结论,最后继续检索边界条件,避免回答过于绝对。\n\n重点确认:\n\n1. 是否存在例外场景\n2. 是否需要人工复核\n3. 引用资料之间是否一致',
query: `${query} 例外场景 风险点 人工复核`,
queryLoading: true,
retrievals: [],
queryShow: true,
},
],
finalAnswerMarkdown: '',
});
// 多轮 RAG 每一轮对应一组检索结果;数组下标与 trace.rounds 的下标保持一致。
const mockMultiRoundRetrievals: QAThinkingRound['retrievals'][] = [
[
{
id: 'mock-multi-r1-1',
title: '业务知识库问答配置指南',
content: '多轮检索适合复杂问题,系统会先宽召回,再根据首轮结果继续改写查询并补充证据。',
source: '知识库 / 业务知识库问答配置指南.md',
},
{
id: 'mock-multi-r1-2',
title: '智能问答流程说明',
content: '当用户问题包含多个约束条件时,需要保留原始问题,同时拆分出检索关键词用于后续召回。',
source: '流程库 / 智能问答流程说明.pdf',
},
],
[
{
id: 'mock-multi-r2-1',
title: 'RAG Query 改写最佳实践',
content: 'Query 改写应减少口语表达,突出实体、动作、条件和目标资料类型,以提升命中率。',
source: '技术资料 / RAG Query 改写最佳实践.docx',
},
{
id: 'mock-multi-r2-2',
title: '知识库召回与重排策略',
content: '建议先召回 Top50,再通过重排模型筛选 Top5 至 Top8 作为最终上下文。',
source: '算法组 / 知识库召回与重排策略.pptx',
},
{
id: 'mock-multi-r2-3',
title: '企业知识问答案例集',
content: '复杂业务问答通常需要结合制度、FAQ、历史案例三类资料综合判断。',
source: '案例库 / 企业知识问答案例集.xlsx',
},
],
[
{
id: 'mock-multi-r3-1',
title: '回答可信度与引用规范',
content: '生成答案时应明确结论依据,并在涉及例外条件时给出风险提示或人工复核建议。',
source: '规范库 / 回答可信度与引用规范.md',
},
{
id: 'mock-multi-r3-2',
title: '知识库答案质检规则',
content: '当多个资料结论不完全一致时,应优先采用发布时间较新、来源更权威的资料。',
source: '质检库 / 知识库答案质检规则.pdf',
},
],
];
/**
* 执行多轮 RAG 本地模拟。
*
* 该方法按轮次延迟填充检索结果,最后分段追加最终回答,模拟真实多轮检索的“思考-召回-总结”节奏。
* 完成后把所有 retrievals 汇总为 sources,供引用来源折叠面板展示。
*/
const handleMockMultiRoundRun = async (query: string) => {
setError(null);
setIsLoading(true);
setIsPreConnectLoading(true);
mockMultiRoundStoppedRef.current = false;
const assistantId = `assistant-${Date.now()}`;
try {
await sleep(350);
if (mockMultiRoundStoppedRef.current) return;
setIsPreConnectLoading(false);
const trace = createMockMultiRoundTrace(query);
setMessages((prev) => [...prev, {
id: assistantId,
role: 'assistant',
content: '',
timestamp: new Date(),
thinkingTrace: trace,
sources: [],
}]);
for (let roundIndex = 0; roundIndex < trace.rounds.length; roundIndex += 1) {
if (mockMultiRoundStoppedRef.current) break;
await sleep(700);
setMessages((prev) => prev.map((msg) => {
if (msg.id !== assistantId || !msg.thinkingTrace) return msg;
const rounds = msg.thinkingTrace.rounds.map((round, idx) => (
idx === roundIndex
? {
...round,
queryLoading: false,
retrievals: mockMultiRoundRetrievals[roundIndex],
}
: round
));
return {
...msg,
thinkingTrace: {
...msg.thinkingTrace,
rounds,
},
};
}));
}
const finalAnswerTokens = [
`基于多轮检索,我对“${query}”形成如下结论:\n\n`,
'### 综合结论\n\n',
'1. **先做宽召回**:第一轮保留原始问题语义,覆盖相关制度、流程和 FAQ。\n',
'2. **再做 Query 改写**:第二轮把问题拆成实体、动作、条件,提升命中文档质量。\n',
'3. **最后做交叉验证**:第三轮补充边界条件和风险点,避免只依据单一资料下结论。\n\n',
'### 建议\n\n',
'如果是生产环境,可以把多轮检索结果按“相关性、权威性、时效性”排序,再把 TopK 证据注入模型生成最终回答。',
];
for (const token of finalAnswerTokens) {
if (mockMultiRoundStoppedRef.current) break;
setMessages((prev) => prev.map((msg) => {
if (msg.id !== assistantId || !msg.thinkingTrace) return msg;
return {
...msg,
thinkingTrace: {
...msg.thinkingTrace,
finalAnswerMarkdown: `${msg.thinkingTrace.finalAnswerMarkdown}${token}`,
},
};
}));
await sleep(180);
}
const sources = mockMultiRoundRetrievals.flat().map((item) => ({
title: item.title,
type: '参考资料',
snippet: item.content,
source: item.source,
content: item.content,
}));
setSearchProcess((prev) => ({
...prev,
rounds: trace.rounds.length,
hits: sources.length,
queryRewrite: '原问题 → 宽召回 → Query 改写 → 边界条件补充 → 综合回答',
}));
setMessages((prev) => prev.map((msg) => (
msg.id === assistantId ? { ...msg, sources } : msg
)));
} catch (runError) {
console.error('多轮RAG模拟失败:', runError);
setError(runError instanceof Error ? runError.message : '发生未知错误,请稍后重试');
} finally {
setIsPreConnectLoading(false);
setIsLoading(false);
mockMultiRoundStoppedRef.current = false;
submitLockRef.current = false;
}
};
/**
* 处理真实多轮 RAG 请求。
*
* 多轮接口采用 SSE:每行 `data:` 都描述一个事件。组件根据事件类型分别更新 taskId、工具调用、
* 最终回答和引用来源。当前用户入口暂时走本地模拟,此方法保留给接口连通后切换使用。
*/
const handleRun = async (query: string) => {
setError(null);
setIsLoading(true);
setIsPreConnectLoading(true);
try {
const response = await fetch(`${generateUrl}/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt: query }),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
if (!response.body) {
throw new Error('Response body is null');
}
setIsPreConnectLoading(false);
const assistantId = `assistant-${Date.now()}`;
const assistantMessage: Message = {
id: assistantId,
role: 'assistant',
content: '',
timestamp: new Date(),
thinkingTrace: createMultiRoundStreamTrace(),
};
// 接口连通后再展示多轮检索主题与过程
setMessages((prev) => [...prev, assistantMessage]);
const reader = response.body.getReader();
readerRef.current = reader;
const decoder = new TextDecoder();
let buffer = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
const lastLine = lines.pop();
buffer = lastLine || '';
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
try {
const parsedData = JSON.parse(line.slice(6)) as {
event: string;
data: any;
};
const { event, data } = parsedData;
switch (event) {
case 'task_created': {
taskIdRef.current = data?.task_id || null;
break;
}
case 'tool_call': {
handleToolCall(data as ToolCallData, assistantId);
break;
}
case 'message': {
if (typeof data === 'string') {
setMessages((prevMessages) => prevMessages.map((msg) => {
if (msg.id !== assistantId || !msg.thinkingTrace) return msg;
return {
...msg,
thinkingTrace: {
...msg.thinkingTrace,
finalAnswerMarkdown: `${msg.thinkingTrace.finalAnswerMarkdown}${data}`,
},
};
}));
}
break;
}
case 'references': {
const refs = Array.isArray(data?.references) ? data.references : [];
const mappedRefs = refs.map(mapReferenceToSource);
setSearchProcess((prev) => ({
...prev,
hits: mappedRefs.length,
}));
setMessages((prevMessages) => prevMessages.map((msg) => (
msg.id === assistantId
? { ...msg, sources: mappedRefs }
: msg
)));
break;
}
case 'done': {
setIsLoading(false);
break;
}
default:
console.log(`未知事件类型: ${event}`, data);
}
} catch (parseError) {
console.error('JSON解析错误:', parseError);
console.error('原始数据:', line.slice(6));
}
}
}
} finally {
if (readerRef.current) {
readerRef.current.releaseLock();
readerRef.current = null;
}
}
} catch (runError) {
console.error('处理请求时发生错误:', runError);
setIsLoading(false);
setError(runError instanceof Error ? runError.message : '发生未知错误,请稍后重试');
setIsPreConnectLoading(false);
} finally {
// 无论成功还是失败,都要重置加载状态和解锁提交
setIsPreConnectLoading(false);
setIsLoading(false);
submitLockRef.current = false;
}
};
/**
* 单一模式下的发送入口。
*
* 这里只负责创建用户消息、重置输入框和按当前 qaMode 分发到对应执行器;
* 具体的真实请求、模拟流和状态回填分别在各自 handler 内维护。
*/
const handleSend = () => {
if (submitLockRef.current || isLoading || isPreConnectLoading) return;
if (!inputValue.trim()) return;
submitLockRef.current = true;
const query = inputValue.trim();
const userMessage: Message = {
id: Date.now().toString(),
role: 'user',
content: query,
timestamp: new Date(),
};
if (qaMode === 'multi-round') { // 多轮检索
setMessages((prev) => [...prev, userMessage]);
setInputValue('');
setSearchProcess({
currentMode: '多轮RAG',
rounds: 0,
hits: 0,
queryRewrite: '',
});
void handleRun(query);
return;
}
if (qaMode === 'single-round') { // 单轮RAG
setMessages((prev) => [...prev, userMessage]);
setInputValue('');
setSearchProcess({
currentMode: '单轮RAG',
rounds: 1,
hits: 0,
queryRewrite: '',
});
void handleSingleRoundRun(query);
return;
}
if (qaMode === 'single-chat') { // 单轮RAG
console.log('进入了这里')
setMessages((prev) => [...prev, userMessage]);
setInputValue('');
setSearchProcess({
currentMode: '单轮RAG',
rounds: 1,
hits: 0,
queryRewrite: '',
});
void chatRun(query);
// handleMockSingleRoundRun(query);
return;
}
setIsLoading(true);
setMessages((prev) => [...prev, userMessage]);
setInputValue('');
// 模拟AI响应
// setTimeout(() => {
// setMessages(prev => [...prev, {
// id: `assistant-${Date.now()}`,
// role: 'assistant',
// content: '已完成深度检索和综合回答。',
// timestamp: new Date(),
// thinkingTrace: createThinkingTrace(qaMode),
// }]);
// setIsLoading(false);
// submitLockRef.current = false;
// setSearchProcess({
// currentMode: '深度检索',
// rounds: 1,
// hits: 5,
// queryRewrite: qaMode === 'deep-search' ? '原问题→分解子问题→独立检索→综合回答' : '',
// });
// }, 50000);
};
/**
* 对比模式发送入口。
*
* 左右面板共享同一个用户问题,但各自按选择的模式独立运行、独立 loading、独立 sources。
* 通过 `updatePane` 将左右面板更新收敛到一个函数,避免并发请求互相覆盖状态。
*/
const handleCompare = () => {
if (submitLockRef.current || isLoading || isPreConnectLoading) return;
if (!inputValue.trim()) return;
submitLockRef.current = true;
const query = inputValue.trim();
setInputValue('');
setCompareUserInput(query);
setError(null);
setIsLoading(true);
setCompareResult({
left: createComparePane(compareLeftMode),
right: createComparePane(compareRightMode),
});
const updatePane = (side: 'left' | 'right', updater: (pane: ComparePane) => ComparePane) => {
setCompareResult((prev) => {
if (!prev) return prev;
return {
...prev,
[side]: updater(prev[side]),
};
});
};
const runSingleForCompare = async (side: 'left' | 'right') => {
let inThink = false;
let thinkStarted = false;
const response = await fetch('http://10.26.128.82:7863/chat/knowledge_base_chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query,
knowledge_base_name: [],
top_k: 5,
score_threshold: scoreThreshold,
history: [],
stream: true,
model_name: modelName,
temperature,
prompt_name: 'default',
local_doc_url: false,
}),
});
if (!response.ok || !response.body) throw new Error(`单轮RAG接口异常: ${response.status}`);
const reader = response.body.getReader();
compareReaderRef.current[side] = reader;
const decoder = new TextDecoder();
let buffer = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const extracted = extractJsonObjects(buffer);
buffer = extracted.rest;
for (const jsonText of extracted.objects) {
const parsed = parseKnowledgeBaseChunk(jsonText);
if (typeof parsed.answer === 'string') {
const token = parsed.answer;
updatePane(side, (pane) => {
const trace = pane.singleRagTrace || {
title: '单轮 RAG 思考过程',
thinkingMarkdown: '',
answerMarkdown: '',
thinkingComplete: false,
};
let thinkingMarkdown = trace.thinkingMarkdown;
let answerMarkdown = trace.answerMarkdown;
let thinkingComplete = trace.thinkingComplete || false;
let remaining = token;
while (remaining) {
if (inThink) {
const endIdx = remaining.indexOf('</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;
}
}
}
return {
...pane,
singleRagTrace: {
...trace,
thinkingMarkdown,
answerMarkdown,
thinkingComplete: thinkStarted ? thinkingComplete : true,
},
};
});
}
if (parsed.docs) {
updatePane(side, (pane) => ({ ...pane, sources: [...pane.sources, ...mapDocsToSources(parsed.docs)] }));
}
}
}
} finally {
reader.releaseLock();
if (compareReaderRef.current[side] === reader) {
compareReaderRef.current[side] = null;
}
}
};
const runMultiForCompare = async (side: 'left' | 'right') => {
const response = await fetch('http://10.26.128.38:8049/api/run', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query }),
});
if (!response.ok || !response.body) throw new Error(`多轮检索接口异常: ${response.status}`);
const reader = response.body.getReader();
compareReaderRef.current[side] = reader;
const decoder = new TextDecoder();
let buffer = '';
const applyToolCall = (toolData: ToolCallData) => {
updatePane(side, (pane) => {
const trace = pane.thinkingTrace || createMultiRoundStreamTrace();
const rounds = [...trace.rounds];
const lastRound = rounds[rounds.length - 1];
switch (toolData.tool_name) {
case 'show_text': {
const chatText = toolData.tool_input.text || '';
if (chatText.includes('<think>')) {
const thinkContent = chatText.replace('<think>', '').replace('</think>', '').trim();
rounds.push({
id: `round-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
title: '思考中',
thoughtMarkdown: thinkContent,
query: '',
queryLoading: true,
queryShow: true,
retrievals: [],
});
} else {
trace.finalAnswerMarkdown += chatText;
}
break;
}
case 'tavily_search':
case 'local_search': {
if (toolData.tool_input.result && lastRound) {
const result = JSON.parse(toolData.tool_input.result) as {
query?: string;
results?: Array<{ title?: string; content?: string; url?: string }>;
};
lastRound.query = result.query || lastRound.query;
lastRound.queryLoading = false;
lastRound.title = toolData.tool_name === 'local_search' ? '本地知识检索' : '联网检索';
lastRound.retrievals = (result.results || []).map((item, idx) => ({
id: `${toolData.tool_name}-${idx}`,
title: item.title || '未命名结果',
content: item.content || '',
source: item.url || '',
}));
}
break;
}
default:
break;
}
return {
...pane,
thinkingTrace: { ...trace, rounds },
};
});
};
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
const lastLine = lines.pop();
buffer = lastLine || '';
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const parsedData = JSON.parse(line.slice(6)) as { event: string; data: any };
const { event, data } = parsedData;
if (event === 'tool_call') applyToolCall(data as ToolCallData);
if (event === 'message' && typeof data === 'string') {
updatePane(side, (pane) => {
const trace = pane.thinkingTrace || createMultiRoundStreamTrace();
return {
...pane,
thinkingTrace: {
...trace,
finalAnswerMarkdown: `${trace.finalAnswerMarkdown}${data}`,
},
};
});
}
if (event === 'references') {
const refs = Array.isArray(data?.references) ? data.references : [];
updatePane(side, (pane) => ({ ...pane, sources: refs.map(mapReferenceToSource) }));
}
}
}
} finally {
reader.releaseLock();
if (compareReaderRef.current[side] === reader) {
compareReaderRef.current[side] = null;
}
}
};
const runByMode = (side: 'left' | 'right', mode: QAMode) => {
if (mode === 'single-round') return runSingleForCompare(side);
return runMultiForCompare(side);
};
Promise.allSettled([
runByMode('left', compareLeftMode).finally(() => updatePane('left', (pane) => ({ ...pane, loading: false }))),
runByMode('right', compareRightMode).finally(() => updatePane('right', (pane) => ({ ...pane, loading: false }))),
]).finally(() => {
setIsLoading(false);
submitLockRef.current = false;
});
};
const handleStopCompareSide = (side: 'left' | 'right') => {
const reader = compareReaderRef.current[side];
if (reader) {
void reader.cancel(`用户停止${side === 'left' ? '左侧' : '右侧'}生成`);
compareReaderRef.current[side] = null;
}
setCompareResult((prev) => {
if (!prev) return prev;
return {
...prev,
[side]: {
...prev[side],
loading: false,
},
};
});
};
/**
* 停止当前生成。
*
* 真实请求通过 reader.cancel() 中断读取;本地模拟通过 ref 标记让异步循环自行退出。
* 对比模式需要同时停止左右两侧,单一模式只处理当前主 reader。
*/
const handleStop = () => {
if (viewMode === 'compare') {
handleStopCompareSide('left');
handleStopCompareSide('right');
setIsLoading(false);
submitLockRef.current = false;
return;
}
mockSingleRoundStoppedRef.current = true;
mockMultiRoundStoppedRef.current = true;
if (readerRef.current) {
void readerRef.current.cancel('用户手动停止');
readerRef.current = null;
}
setIsPreConnectLoading(false);
setIsLoading(false);
submitLockRef.current = false;
};
/**
* 对“生成中切换页面/模式/布局”进行统一保护。
*
* 如果当前没有生成任务,直接执行目标操作;否则弹出确认框。
* 用户确认后先停止生成,再执行原操作,避免生成流继续写入已经切换的 UI 状态。
*/
const runAfterGenerationConfirm = (action: () => void) => {
if (!isLoading) {
action();
return;
}
pendingActionRef.current = action;
// Dialog.confirm({
// header: '模型正在生成',
// body: '当前模型仍在生成内容。确认后将停止生成,并继续执行你的操作。',
// confirmBtn: '确认停止并继续',
// cancelBtn: '取消',
// onConfirm: () => {
// handleStop();
// pendingActionRef.current?.();
// pendingActionRef.current = null;
// },
// onClose: () => {
// pendingActionRef.current = null;
// },
// });
};
const handleSafeViewModeChange = (mode: ViewMode) => {
if (mode === viewMode) return;
runAfterGenerationConfirm(() => setViewMode(mode));
};
const handleSafeNavigate = (page: PageType) => {
runAfterGenerationConfirm(() => onNavigate(page));
};
const handleSafeReset = () => {
runAfterGenerationConfirm(() => {
setMessages([]);
setCompareResult(null);
});
};
const handleSafeModeChange = (mode: QAMode) => {
runAfterGenerationConfirm(() => setQaMode(mode));
};
const handleSafeCompareLeftModeChange = (mode: QAMode) => {
runAfterGenerationConfirm(() => setCompareLeftMode(mode));
};
const handleSafeCompareRightModeChange = (mode: QAMode) => {
runAfterGenerationConfirm(() => setCompareRightMode(mode));
};
const handleSafeCompareLayoutChange = (layout: 'horizontal' | 'vertical') => {
runAfterGenerationConfirm(() => setCompareLayout(layout));
};
const handleSaveRecord = () => {
alert('问答记录已保存');
};
const handleCollect = () => {
alert('已添加到收藏');
};
const handleKeyDown = (e) => {
// if (e.key === 'Enter' && !e.shiftKey) {
// console.log('回车了')
// e.preventDefault();
// if (e.repeat || submitLockRef.current || isLoading || isPreConnectLoading) return;
// if (viewMode === 'compare') {
// handleCompare();
// } else {
// handleSend();
// }
// }
// console.log('e',e)
// if ((e.metaKey || e.ctrlKey || e.shiftKey) && e.key === "Enter") {
// e.preventDefault();
// } else if (e.keyCode === 13) {
// e.preventDefault();
// console.log('回车了')
// } else if (e.key === "/") {
// e.preventDefault();
// }
// if (e.repeat || submitLockRef.current || isLoading || isPreConnectLoading) return;
console.log(e,'e')
console.log('da')
if (viewMode === 'compare') {
handleCompare();
} else {
handleSend();
}
scrollToBottom();
};
// 获取模式标签
const getModeLabel = (mode: QAMode) => {
switch (mode) {
case 'single-chat': return '大模型';
case 'single-round': return '单轮RAG';
case 'multi-round': return '多轮RAG';
case 'deep-search': return '深度检索';
case 'hx-search': return '画像检索';
}
};
const activeAssistantMessageId = isLoading
? [...messages].reverse().find((msg) => msg.role === 'assistant')?.id
: undefined;
useEffect(() => {
setQaMode('single-round');
setMessages(createMockSingleRagMessages());
setSearchProcess({
currentMode: '单轮RAG',
rounds: 1,
hits: 3,
queryRewrite: '',
});
}, []);
/**
* 滚动到底部。
*
* 聊天区默认跟随新消息自动滚动;当用户手动上滑查看历史时会暂停自动滚动,避免打断阅读。
*/
const scrollToBottom = (smooth = false) => {
if (!chatListRef.current) return;
chatListRef.current.scrollTo({
top: chatListRef.current.scrollHeight,
behavior: smooth ? 'smooth' : 'auto',
});
};
useEffect(() => {
if (isAutoScrollEnabled) {
scrollToBottom(false);
}
}, [messages, isLoading, isAutoScrollEnabled]);
const handleChatScroll = () => {
if (!chatListRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = chatListRef.current;
const isNearBottom = scrollHeight - (scrollTop + clientHeight) < 80;
setIsAutoScrollEnabled(isNearBottom);
setShowScrollToBottom(!isNearBottom);
};
const getAssistantAnswerText = (message: Message) => (
message.singleRagTrace?.answerMarkdown || message.thinkingTrace?.finalAnswerMarkdown || message.content
);
const getAssistantThinkingText = (message: Message) => {
if (message.singleRagTrace) return message.singleRagTrace.thinkingMarkdown;
if (message.thinkingTrace) {
return message.thinkingTrace.rounds.map((round, index) => (
`## 第 ${index + 1} 轮:${round.title}\n${round.thoughtMarkdown}`
)).join('\n\n');
}
return '';
};
const handleCopyAssistantMessage = async (message: Message) => {
const answerText = getAssistantAnswerText(message);
try {
await navigator.clipboard.writeText(answerText);
} catch (copyError) {
console.error('复制模型回答失败:', copyError);
}
};
const handleDeleteAssistantMessage = (message: Message) => {
const messageIndex = messages.findIndex((msg) => msg.id === message.id);
const previousUserMessage = [...messages.slice(0, messageIndex)].reverse().find((msg) => msg.role === 'user');
setMessages((prev) => prev.filter((msg) => msg.id !== message.id && msg.id !== previousUserMessage?.id));
};
const handleEditAssistantMessage = (message: Message) => {
setEditingMessageId(message.id);
setEditingThinking(getAssistantThinkingText(message));
setEditingAnswer(getAssistantAnswerText(message));
};
const handleCancelAssistantEdit = () => {
setEditingMessageId(null);
setEditingThinking('');
setEditingAnswer('');
};
const handleSaveAssistantEdit = () => {
if (!editingMessageId) return;
setMessages((prev) => prev.map((msg) => {
if (msg.id !== editingMessageId) return msg;
if (msg.singleRagTrace) {
return {
...msg,
content: editingAnswer,
singleRagTrace: {
...msg.singleRagTrace,
thinkingMarkdown: editingThinking,
answerMarkdown: editingAnswer,
thinkingComplete: true,
},
};
}
if (msg.thinkingTrace) {
const rounds = msg.thinkingTrace.rounds.length > 0
? msg.thinkingTrace.rounds.map((round, index) => (
index === 0 ? { ...round, thoughtMarkdown: editingThinking } : round
))
: msg.thinkingTrace.rounds;
return {
...msg,
content: editingAnswer,
thinkingTrace: {
...msg.thinkingTrace,
rounds,
finalAnswerMarkdown: editingAnswer,
},
};
}
return { ...msg, content: editingAnswer };
}));
handleCancelAssistantEdit();
};
const handleResendAssistantMessage = (message: Message) => {
if (submitLockRef.current || isLoading || isPreConnectLoading) return;
const messageIndex = messages.findIndex((msg) => msg.id === message.id);
const previousUserMessage = [...messages.slice(0, messageIndex)].reverse().find((msg) => msg.role === 'user');
if (!previousUserMessage) return;
submitLockRef.current = true;
setMessages((prev) => prev.filter((msg) => msg.id !== message.id));
setInputValue('');
if (message.singleRagTrace) {
setSearchProcess({ currentMode: '单轮RAG', rounds: 1, hits: 0, queryRewrite: '' });
void handleSingleRoundRun(previousUserMessage.content);
return;
}
setSearchProcess({ currentMode: '多轮RAG', rounds: 0, hits: 0, queryRewrite: '' });
void handleRun(previousUserMessage.content);
};
/**
* 点击答案中的引用编号后打开引用详情弹窗。
*
* Markdown 中的 `[[1]]` 与 sources 数组采用 1-based 对齐,因此这里需要减 1 取数组下标。
*/
const handleCitationClick = (message: Message, citationIndex: number) => {
const source = message.sources?.[citationIndex - 1];
if (!source) return;
setSelectedCitation(source);
};
const handleCompareCitationClick = (side: 'left' | 'right', citationIndex: number) => {
const source = compareResult?.[side]?.sources?.[citationIndex - 1];
if (!source) return;
setSelectedCitation(source);
};
useEffect(() => {
if (viewMode === 'compare') {
setCompareLeftMode('single-round');
setCompareRightMode('multi-round');
}
}, [viewMode]);
useEffect(() => {
if (viewMode !== 'compare' || !compareResult) return;
setIsLoading(compareResult.left.loading || compareResult.right.loading);
}, [viewMode, compareResult]);
// 离开浏览器页面时无法展示自定义 Dialog,因此使用原生 beforeunload 提醒用户生成会被中断。
useEffect(() => {
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
if (!isLoading) return;
event.preventDefault();
event.returnValue = '模型正在生成,离开页面将停止当前生成。';
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
}, [isLoading]);
return (
<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
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}
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 className="flex-1 overflow-y-auto p-4">
{compareUserInput && (
<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">{compareUserInput}</p>
</div>
</div>
)}
{compareResult ? (
compareLayout === 'horizontal' ? (
<div className="grid grid-cols-2 gap-4 h-full min-w-0">
{/* 左侧结果 */}
<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={compareResult.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(compareResult.left.mode)}
</span>
</div>
{compareResult.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">
{compareResult.left.mode === 'single-round' ? (
<SingleRagAnswer trace={compareResult.left.singleRagTrace || parseSingleRagStream([])} onCitationClick={(idx) => handleCompareCitationClick('left', idx)} />
) : (
<RetrievalThinkingTrace trace={compareResult.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={compareResult.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(compareResult.right.mode)}
</span>
</div>
{compareResult.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">
{compareResult.right.mode === 'single-round' ? (
<SingleRagAnswer trace={compareResult.right.singleRagTrace || parseSingleRagStream([])} onCitationClick={(idx) => handleCompareCitationClick('right', idx)} />
) : (
<RetrievalThinkingTrace trace={compareResult.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={compareResult.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(compareResult.left.mode)}
</span>
</div>
{compareResult.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">
{compareResult.left.mode === 'single-round' ? (
<SingleRagAnswer trace={compareResult.left.singleRagTrace || parseSingleRagStream([])} onCitationClick={(idx) => handleCompareCitationClick('left', idx)} />
) : (
<RetrievalThinkingTrace trace={compareResult.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={compareResult.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(compareResult.right.mode)}
</span>
</div>
{compareResult.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">
{compareResult.right.mode === 'single-round' ? (
<SingleRagAnswer trace={compareResult.right.singleRagTrace || parseSingleRagStream([])} onCitationClick={(idx) => handleCompareCitationClick('right', idx)} />
) : (
<RetrievalThinkingTrace trace={compareResult.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>
</>
)}
{/* 底部输入区 */}
<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 GeneralQA;