2455 lines
100 KiB
TypeScript
2455 lines
100 KiB
TypeScript
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;
|