From 043a1aab21d5f0caa0503608ae71709e7d306cba Mon Sep 17 00:00:00 2001 From: ZyGuo Date: Tue, 19 May 2026 16:12:12 +0800 Subject: [PATCH 01/78] update git ignore --- .gitignore | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 1b033763..22ab8329 100644 --- a/.gitignore +++ b/.gitignore @@ -75,4 +75,7 @@ offline-backend-20260512/backend/skill_evolution.json skills/ hermes-agent-main/ .gitignore -offline-backend-20260512/backend/tests/* \ No newline at end of file +offline-backend-20260512/backend/tests/* + +.codegraph +codegraph.mdc \ No newline at end of file From ffee030548d2faa170a0794ce6f6db0376966163 Mon Sep 17 00:00:00 2001 From: ZyGuo Date: Wed, 27 May 2026 10:01:15 +0800 Subject: [PATCH 02/78] chore: ignore --- .gitignore | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 846eca50..5cc03e44 100644 --- a/.gitignore +++ b/.gitignore @@ -84,4 +84,6 @@ deploy .codegraph -codegraph.mdc \ No newline at end of file +codegraph.mdc + +docs/aidev-docs \ No newline at end of file From 36311e6cef034a49ea45707ddd23898698f1a55d Mon Sep 17 00:00:00 2001 From: xuhd Date: Wed, 27 May 2026 19:30:54 +0800 Subject: [PATCH 03/78] 999 --- .../src/components/workspace/workspace-nav-chat-list.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx b/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx index 7fdff4a3..e3a18641 100644 --- a/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx +++ b/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx @@ -149,7 +149,7 @@ export function WorkspaceNavChatList({ )} - {!showCollapseTrigger && } + {/* {!showCollapseTrigger && } */} ); } From e2b676caaa28b94e2cd5d678df4fbbfbbad8a94b Mon Sep 17 00:00:00 2001 From: xuhd Date: Thu, 28 May 2026 10:00:40 +0800 Subject: [PATCH 04/78] =?UTF-8?q?=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../harness/deerflow/tools/builtins/notebook_search_tool.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/notebook_search_tool.py b/offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/notebook_search_tool.py index 9405429b..22c10f86 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/notebook_search_tool.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/notebook_search_tool.py @@ -56,7 +56,7 @@ def notebook_search_tool(query: str, top_k: int = 10) -> str: headers={"Authorization": f"Bearer {token}"}, json={ "query": query, - "source_ids": [source_ids], + "source_ids": source_ids, "search_space_id": search_space_id, "top_k": top_k, }, From 96b0bae6ca90a2ea8bac9e9ba88a43ef953cdc19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=89=E9=91=AB=E5=8D=87?= Date: Mon, 25 May 2026 14:17:39 +0800 Subject: [PATCH 05/78] =?UTF-8?q?=E6=8A=80=E8=83=BD=E8=AF=A6=E6=83=85?= =?UTF-8?q?=E5=AF=B9=E8=AF=9D=E6=A1=86=E6=96=B0=E5=A2=9E=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E6=B5=8F=E8=A7=88=E9=9D=A2=E6=9D=BF:=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E9=A2=84=E8=A7=88=E3=80=81=E7=BC=96=E8=BE=91=E3=80=81=E4=B8=8B?= =?UTF-8?q?=E8=BD=BD=E3=80=81=E5=A4=8D=E5=88=B6=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- .../workspace/skills/skill-detail-dialog.tsx | 64 ++- frontend-web/src/core/i18n/locales/en-US.ts | 14 + frontend-web/src/core/i18n/locales/types.ts | 14 + frontend-web/src/core/i18n/locales/zh-CN.ts | 14 + frontend-web/src/core/skills/api.ts | 86 ++++ frontend-web/src/core/skills/hooks.ts | 43 ++ .../backend/app/gateway/routers/skills.py | 428 ++++++++++++++++++ offline-backend-20260512/backend/config.yaml | 2 +- 8 files changed, 661 insertions(+), 4 deletions(-) diff --git a/frontend-web/src/components/workspace/skills/skill-detail-dialog.tsx b/frontend-web/src/components/workspace/skills/skill-detail-dialog.tsx index c53db3cc..8f2e75b1 100644 --- a/frontend-web/src/components/workspace/skills/skill-detail-dialog.tsx +++ b/frontend-web/src/components/workspace/skills/skill-detail-dialog.tsx @@ -4,6 +4,7 @@ import { CopyIcon, EyeIcon, FileCode2Icon, + FilesIcon, FileTextIcon, GlobeIcon, Loader2, @@ -25,12 +26,15 @@ import { import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; import { MarkdownContent } from "@/components/workspace/messages/markdown-content"; +import { SkillFilePreviewDialog } from "@/components/workspace/skills/skill-file-preview-dialog"; +import { SkillFileTree } from "@/components/workspace/skills/skill-file-tree"; import { getStoredAuth } from "@/core/auth"; import { useI18n } from "@/core/i18n/hooks"; import { useSetSkillDetail, useSkill, useSkillContent, + useSkillFiles, } from "@/core/skills/hooks"; import type { Skill } from "@/core/skills/type"; import { reasoningPlugins } from "@/core/streamdown"; @@ -83,10 +87,11 @@ export function SkillDetailDialog({ const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(""); - const [activeTab, setActiveTab] = useState<"detail" | "source" | null>( - null, - ); + const [activeTab, setActiveTab] = useState< + "detail" | "source" | "files" | null + >(null); const [showRawSource, setShowRawSource] = useState(false); + const [openedFilePath, setOpenedFilePath] = useState(null); const canManage = useMemo(() => canManageSkill(skill), [skill]); const { @@ -95,10 +100,18 @@ export function SkillDetailDialog({ error: contentError, } = useSkillContent(open && canManage ? skillName : null); + const filesEnabled = open && canManage && activeTab === "files"; + const { + data: filesData, + isLoading: isFilesLoading, + error: filesError, + } = useSkillFiles(filesEnabled ? skillName : null); + useEffect(() => { setEditing(false); setActiveTab(null); setShowRawSource(false); + setOpenedFilePath(null); }, [open, skillName]); const skillBody = useMemo(() => { @@ -131,6 +144,7 @@ export function SkillDetailDialog({ } return ( + <> {tx.detailSectionTitle} + {canManage && ( + + + {tx.filesSectionTitle} + + )} {canManage && ( @@ -310,6 +330,34 @@ export function SkillDetailDialog({ )} + {canManage && ( + + {isFilesLoading ? ( +
+ + {t.common.loading} +
+ ) : filesError ? ( +
+ {filesError instanceof Error + ? filesError.message + : String(filesError)} +
+ ) : filesData ? ( + setOpenedFilePath(path)} + emptyText={tx.filesEmpty} + /> + ) : null} +
+ )} + {canManage && (
+ { + if (!next) setOpenedFilePath(null); + }} + /> + ); } diff --git a/frontend-web/src/core/i18n/locales/en-US.ts b/frontend-web/src/core/i18n/locales/en-US.ts index 210dd65d..8b602073 100644 --- a/frontend-web/src/core/i18n/locales/en-US.ts +++ b/frontend-web/src/core/i18n/locales/en-US.ts @@ -501,6 +501,20 @@ export const enUS: Translations = { detailAdminOnlyHint: "Only an admin or the skill publisher can edit the details.", contentSectionTitle: "SKILL.md source", + filesSectionTitle: "Files", + filesEmpty: "This skill has no files to display.", + filesLoadError: "Failed to load files", + filesPreviewBinary: "Binary file cannot be previewed inline — use Download.", + filesPreviewUnsupported: "This file type is not supported for inline preview.", + filesPreviewTooLarge: "File is too large for inline preview.", + filesEditAction: "Edit", + filesSaveAction: "Save", + filesCancelEditAction: "Cancel", + filesDownloadAction: "Download", + filesCopyPathAction: "Copy path", + filesDiscardConfirm: "Discard unsaved changes?", + filesSaveSuccess: "File saved", + filesReadOnlyHint: "Built-in skills are read-only.", viewDetailAction: "View skill details", stateSheet: { archivedTitle: "Archived skills", diff --git a/frontend-web/src/core/i18n/locales/types.ts b/frontend-web/src/core/i18n/locales/types.ts index 532692d4..5051292c 100644 --- a/frontend-web/src/core/i18n/locales/types.ts +++ b/frontend-web/src/core/i18n/locales/types.ts @@ -432,6 +432,20 @@ export interface Translations { detailSaveSuccess: string; detailAdminOnlyHint: string; contentSectionTitle: string; + filesSectionTitle: string; + filesEmpty: string; + filesLoadError: string; + filesPreviewBinary: string; + filesPreviewUnsupported: string; + filesPreviewTooLarge: string; + filesEditAction: string; + filesSaveAction: string; + filesCancelEditAction: string; + filesDownloadAction: string; + filesCopyPathAction: string; + filesDiscardConfirm: string; + filesSaveSuccess: string; + filesReadOnlyHint: string; viewDetailAction: string; stateSheet: { archivedTitle: string; diff --git a/frontend-web/src/core/i18n/locales/zh-CN.ts b/frontend-web/src/core/i18n/locales/zh-CN.ts index 5e1e0688..b58d38b8 100644 --- a/frontend-web/src/core/i18n/locales/zh-CN.ts +++ b/frontend-web/src/core/i18n/locales/zh-CN.ts @@ -479,6 +479,20 @@ export const zhCN: Translations = { detailSaveSuccess: "详情已更新", detailAdminOnlyHint: "仅管理员或技能发布者可编辑详情。", contentSectionTitle: "SKILL.md 原文", + filesSectionTitle: "文件", + filesEmpty: "该技能目录下没有可显示的文件。", + filesLoadError: "加载文件失败", + filesPreviewBinary: "二进制文件无法预览,可点击下载查看。", + filesPreviewUnsupported: "该文件类型暂不支持预览。", + filesPreviewTooLarge: "文件过大,无法在线预览。", + filesEditAction: "编辑", + filesSaveAction: "保存", + filesCancelEditAction: "取消", + filesDownloadAction: "下载", + filesCopyPathAction: "复制路径", + filesDiscardConfirm: "有未保存的修改,确定放弃吗?", + filesSaveSuccess: "文件已保存", + filesReadOnlyHint: "内置技能为只读,仅可查看。", viewDetailAction: "查看技能详情", stateSheet: { archivedTitle: "归档的技能", diff --git a/frontend-web/src/core/skills/api.ts b/frontend-web/src/core/skills/api.ts index 25428dcb..3b31cde1 100644 --- a/frontend-web/src/core/skills/api.ts +++ b/frontend-web/src/core/skills/api.ts @@ -239,6 +239,92 @@ export async function deleteCustomSkill(skillName: string): Promise { } } +export interface SkillFileNode { + name: string; + type: "file" | "dir"; + path: string; + size?: number | null; + mtime?: number | null; + children?: SkillFileNode[] | null; +} + +export interface SkillFilesResponse { + name: string; + category: "public" | "custom"; + editable: boolean; + root: SkillFileNode; +} + +export interface SkillFileContentResponse { + name: string; + category: "public" | "custom"; + path: string; + mime: string; + size: number; + mtime: number; + is_text: boolean; + editable: boolean; + content?: string | null; +} + +export interface SkillFileSaveResponse { + name: string; + path: string; + size: number; + mtime: number; +} + +export async function getSkillFiles(skillName: string): Promise { + const response = await apiFetch( + `${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/files`, + ); + if (!response.ok) { + throw await parseDetailedError(response, "Failed to load skill files"); + } + return response.json() as Promise; +} + +export async function getSkillFileContent( + skillName: string, + path: string, +): Promise { + const url = + `${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/files/content` + + `?path=${encodeURIComponent(path)}`; + const response = await apiFetch(url); + if (!response.ok) { + throw await parseDetailedError(response, "Failed to read skill file"); + } + return response.json() as Promise; +} + +/** URL suitable for direct `` / ` + + ); +} +export default GAFromQA diff --git a/web-code/components/qa/GAFromNew.tsx b/web-code/components/qa/GAFromNew.tsx new file mode 100644 index 00000000..5159535b --- /dev/null +++ b/web-code/components/qa/GAFromNew.tsx @@ -0,0 +1,29 @@ +import { Outlet, useNavigate } from 'react-router-dom'; +import React, { useState, useEffect, useRef } from 'react'; +import { useSearchParams } from 'react-router-dom'; + +const zncmUrl = import.meta.env.VITE_AIPI_CONSUMER_URL || ''; +import { requestCrudService } from '@/service/request-axios-service'; +const service = requestCrudService; +const userInfo = localStorage.getItem('userInfo'); +const deerFlowUrl = 'https://ch1.b.uat.4.cn/xwtweb/#/login/'; +interface TaskPgFromProps { + gopath: string +} +const GAFromNewQA: React.FC = ({gopath}) => { + const [deerFlow, setDeerFlow] = useState('https://ch1.b.uat.4.cn/xwtweb/#/login/'); // http://localhost:5174/#/ + useEffect(() => { + if(userInfo) { + const userName = JSON.parse(userInfo).userName; + const gaUrl = deerFlowUrl + userName + `?pathTo=${gopath}` + console.log('gaUrl', gaUrl) + setDeerFlow(gaUrl); + } + }, [gopath]); + return ( +
+ +
+ ); +} +export default GAFromNewQA diff --git a/web-code/components/qa/GeneralQA-v1.tsx b/web-code/components/qa/GeneralQA-v1.tsx new file mode 100644 index 00000000..7a3a9484 --- /dev/null +++ b/web-code/components/qa/GeneralQA-v1.tsx @@ -0,0 +1,2454 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { + GitCompare, + MessageSquare, + CheckCircle, + AlertCircle, + ArrowDown, + ExternalLink, + Square, + X, +} from 'lucide-react'; +import { Dialog, Select, Slider } from 'tdesign-react'; +import { useTheme } from '../../contexts/ThemeContext'; +import { PageType } from '../../components/Sidebar'; +import { ChatMessage, EmptyChatState, QAAvatar, QAComposer, QASingleRagTrace, QAThinkingRound, QAThinkingTrace, RetrievalThinkingTrace, SingleRagAnswer, TypingIndicator } from './QACommon'; +import GeneralQAHeader from './GeneralQAHeader'; +import GeneralQAConfigSidebar from './GeneralQAConfigSidebar'; +import type { ComparePane, CompareResult, KnowledgeBaseChunk, Message, QAMode, SourceItem, ToolCallData, ViewMode } from './GeneralQA.types'; +import { + chatDataJSON, + createComparePane, + createMockSingleRagMessages, + createMultiRoundStreamTrace, + createThinkingTrace, + extractJsonObjects, + mapDocsToSources, + mapReferenceToSource, + parseSingleRagStream, +} from './GeneralQA.mock'; + +/** + * 通用问答组件 + * 支持单轮RAG、多轮RAG、深度检索及结果对比功能 + * @param onNavigate - 页面导航函数 + * @param initialMode - 初始模式(可选) + */ + +const zncmUrl = import.meta.env.VITE_API_BASE_URL || ''; +const chatUrl = import.meta.env.VITE_AIPI_CHAT_URL || ''; +const generateUrl = import.meta.env.VITE_AIPI_GENERATE_CHAT_URL || ''; +const knowledgeUrl = import.meta.env.VITE_AIPI_KNOWLEDGE_URL +const GeneralQA: React.FC<{ onNavigate: (page: PageType) => void; initialMode?: string }> = ({ onNavigate }) => { + const { theme } = useTheme(); + const isDark = theme === 'dark'; + + // 状态管理 + // 视图模式:单一模式或对比模式 + const [viewMode, setViewMode] = useState('single'); + // 问答模式:单轮RAG、多轮RAG、深度检索 + const [qaMode, setQaMode] = useState('single-round'); + // 对比模式下的左侧模式 + const [compareLeftMode, setCompareLeftMode] = useState('single-round'); + // 对比模式下的右侧模式 + const [compareRightMode, setCompareRightMode] = useState('multi-round'); + + // 知识范围设置 + const [knowledgeScope, setKnowledgeScope] = useState<'all' | 'specific-kb' | 'specific-domain'>('all'); + + // 回答偏好设置 + const [answerStyle, setAnswerStyle] = useState<'concise' | 'detailed'>('detailed'); + const [showSources, setShowSources] = useState(true); + const [showProcess, setShowProcess] = useState(false); + + // 对比设置 + const [compareLayout, setCompareLayout] = useState<'horizontal' | 'vertical'>('horizontal'); + const [autoSummarizeDiff, setAutoSummarizeDiff] = useState(true); + const [recommendBetter, setRecommendBetter] = useState(true); + + // 消息和结果状态 + // `messages` 驱动单一模式聊天区;`compareResult` 驱动对比模式左右面板。 + // `isPreConnectLoading` 表示请求已发起但还没拿到可展示的 assistant 消息,用于显示“正在连接/生成”的占位状态。 + const [messages, setMessages] = useState([]); + // 用户当前输入的文本内容,用于绑定输入框 + const [inputValue, setInputValue] = useState(''); + // 是否正在处理请求(包含真实请求和模拟请求) + const [isLoading, setIsLoading] = useState(false); + // 是否处于"连接中"状态,表示已发起请求但还未收到可展示的响应 + const [isPreConnectLoading, setIsPreConnectLoading] = useState(false); + // 存储发生的错误信息,用于显示错误提示 + const [error, setError] = useState(null); + // 对比模式下的结果数据,包含左右两侧的回答和来源信息 + const [compareResult, setCompareResult] = useState(null); + // 对比模式下用户输入的问题内容,用于左右对比时的统一输入 + const [compareUserInput, setCompareUserInput] = useState(''); + // 是否折叠左侧配置面板,true 为折叠,false 为展开 + const [isLeftSidebarCollapsed, setIsLeftSidebarCollapsed] = useState(false); + // 是否启用自动滚动,当用户手动滚动时会自动关闭 + const [isAutoScrollEnabled, setIsAutoScrollEnabled] = useState(true); + // 是否显示"滚动到底部"按钮,当有未读消息且用户已滚动时显示 + const [showScrollToBottom, setShowScrollToBottom] = useState(false); + // 当前选中的引用来源,用于显示引用详情弹窗 + const [selectedCitation, setSelectedCitation] = useState(null); + const [editingMessageId, setEditingMessageId] = useState(null); + const [editingThinking, setEditingThinking] = useState(''); + const [editingAnswer, setEditingAnswer] = useState(''); + // 模型参数设置弹窗状态。 + // 这些值会进入单轮 RAG 的真实请求体;模拟流仍复用同一套 UI,方便接口恢复后无缝切换。 + const [isModelSettingsOpen, setIsModelSettingsOpen] = useState(false); + const [modelName, setModelName] = useState('OpenAI3'); + const [scoreThreshold, setScoreThreshold] = useState(0.5); + const [temperature, setTemperature] = useState(0.3); + + // 当模型正在生成时,如果用户切换模式或跳转页面,先把原动作暂存到 ref,待用户确认停止后再执行。 + // 使用 ref 而不是 state 可以避免弹窗确认过程触发额外渲染,也不会被闭包中的旧状态影响。 + const pendingActionRef = useRef<(() => void) | null>(null); + + // 防止 Enter keydown 和按钮 click 在 React 状态更新前重复触发提交。 + const submitLockRef = useRef(false); + + // 真实流式请求的 reader 句柄,用于“停止生成”时取消浏览器流读取。 + const readerRef = useRef(null); + // 对比模式会同时启动左右两路请求,因此分别保存 reader,保证可以单独停止某一侧生成。 + const compareReaderRef = useRef<{ + left: ReadableStreamDefaultReader | null; + right: ReadableStreamDefaultReader | null; + }>({ left: null, right: null }); + // 多轮接口创建任务后会返回 task_id,这里预留给后续“按任务取消/查询状态”等能力。 + const taskIdRef = useRef(null); + + // 本地模拟流没有真实 reader,因此用停止标记在异步循环中主动中断。 + const mockSingleRoundStoppedRef = useRef(false); + const mockMultiRoundStoppedRef = useRef(false); + const chatListRef = useRef(null); + + // 检索过程展示状态 + const [searchProcess, setSearchProcess] = useState({ + currentMode: '单轮RAG', + rounds: 1, + hits: 5, + queryRewrite: '', + }); + + /** + * 处理单轮 RAG 问答请求 + * + * 单轮 RAG 的处理流程: + * 1. 向知识库问答 API 发送 POST 请求,携带用户问题和检索参数 + * 2. API 返回 SSE 流式响应,包含多个 JSON 对象 + * 3. 逐个解析流式 JSON,提取 answer 和 docs 字段 + * 4. 通过 applyAnswerToken 将流式 token 追加到消息的 thinkingMarkdown/answerMarkdown + * 5. 实时更新消息来源(sources)和检索统计(searchProcess) + * + * @param query - 用户输入的问题文本 + */ + const handleSingleRoundRun = async (query: string) => { + // 清空之前的错误信息 + setError(null); + // 标记开始加载,禁用提交按钮防止重复点击 + setIsLoading(true); + // 标记"连接中"状态,显示加载占位符 + setIsPreConnectLoading(true); + // 用于存储当前 assistant 消息的 ID,以便后续更新该消息内容 + let assistantId = ''; + + // 标志位:当前处理的 token 是否处于 ... 标签内(解析思考过程) + let inThink = false; + // 标志位:是否已经遇到过 标签开始解析思考内容 + let thinkStarted = false; + + /** + * 将流式接收到的 token 应用到对应消息的 singleRagTrace 中 + * 该函数会解析 token 中的 ... 标签,将思考过程和回答分开存储 + * @param token - 从流中接收到的文本片段 + */ + const applyAnswerToken = (token: string) => { + setMessages((prev) => prev.map((msg) => { + // 跳过不是当前 assistant 消息或没有 singleRagTrace 的消息 + if (msg.id !== assistantId || !msg.singleRagTrace) return msg; + + // 获取当前消息的思考和回答内容 + let thinkingMarkdown = msg.singleRagTrace.thinkingMarkdown; + let answerMarkdown = msg.singleRagTrace.answerMarkdown; + let thinkingComplete = msg.singleRagTrace.thinkingComplete || false; + // 剩余未处理的 token 内容 + let remaining = token; + + // 循环处理所有 token,直到处理完毕 + while (remaining) { + // 当前处于 标签内(解析思考过程),查找结束标签 + if (inThink) { + const endIdx = remaining.indexOf(''); + if (endIdx === -1) { + thinkingMarkdown += remaining; + remaining = ''; + } else { + thinkingMarkdown += remaining.slice(0, endIdx); + remaining = remaining.slice(endIdx + ''.length); + inThink = false; + thinkingComplete = true; + } + } else { + const startIdx = remaining.indexOf(''); + if (startIdx === -1) { + answerMarkdown += remaining; + remaining = ''; + } else { + answerMarkdown += remaining.slice(0, startIdx); + remaining = remaining.slice(startIdx + ''.length); + inThink = true; + thinkStarted = true; + } + } + } + + // 返回更新后的消息对象,保留 singleRagTrace 中的所有字段 + return { + ...msg, + singleRagTrace: { + ...msg.singleRagTrace, + // 更新思考和回答的 markdown 内容 + thinkingMarkdown, + answerMarkdown, + // 只有当已经开始解析思考内容时才更新 thinkingComplete 标志 + thinkingComplete: thinkStarted ? thinkingComplete : true, + }, + }; + })); + }; + + try { + // 向知识库问答 API 发送 POST 请求,携带用户问题和检索参数 + const response = await fetch(`${chatUrl}/chat/knowledge_base_chat_tiaoshi`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + // 请求体包含查询内容、知识库名称、检索参数、模型配置等 + body: JSON.stringify({ + query, // 用户输入的问题 + knowledge_base_name: [], // 知识库名称列表,空数组表示使用所有知识库 + top_k: 5, // 返回最相似的 5 条结果 + score_threshold: scoreThreshold, // 相似度阈值,过滤低质量检索结果 + history: [], // 对话历史,用于多轮对话 + stream: true, // 启用流式响应 + model_name: modelName, // 使用的模型名称 + temperature, // 生成温度,控制随机性 + prompt_name: 'default', // 使用的提示词模板 + local_doc_url: false, // 是否返回本地文档 URL + }), + }); + + // 检查 HTTP 响应状态 + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + // 确保响应体存在(SSE 流需要 body) + if (!response.body) { + throw new Error('Response body is null'); + } + // 收到有效响应后,关闭"连接中"状态,开始显示实际内容 + setIsPreConnectLoading(false); + + // 生成唯一 ID 用于标识这条 assistant 消息 + assistantId = `assistant-${Date.now()}`; + // 初始化思考过程追踪对象 + const initialTrace: QASingleRagTrace = { + title: '单轮 RAG 思考过程', + thinkingMarkdown: '', // 思考过程内容 + answerMarkdown: '', // 最终回答内容 + thinkingComplete: false, // 思考是否完成 + }; + // 将 assistant 消息添加到消息列表中 + setMessages((prev) => [...prev, { + id: assistantId, + role: 'assistant', + content: '', + timestamp: new Date(), + singleRagTrace: initialTrace, + sources: [], // 初始为空,随着检索结果更新 + }]); + + // 获取响应体的流式读取器 + const reader = response.body.getReader(); + // 保存 reader 引用,用于"停止生成"功能 + readerRef.current = reader; + // 创建文本解码器,将二进制数据转换为字符串 + const decoder = new TextDecoder(); + // buffer 用于暂存未完成的 JSON 片段 + let buffer = ''; + let docsList: any[] = []; + try { + // 持续读取流数据,直到流结束 + while (true) { + // 读取下一个数据块 + const { done, value } = await reader.read(); + // done 为 true 时表示流已结束 + if (done) break; + + // 将二进制数据解码并追加到 buffer + buffer += decoder.decode(value, { stream: true }); + // 从 buffer 中提取完整的 JSON 对象 + const extracted = extractJsonObjects(buffer); + if (buffer.includes('docs')) { + try { + docsList = chatDataJSON(buffer).docsList || []; + } catch (error: any) { + console.log(error); + } + } + // 剩余无法解析的内容保留到下次处理 + buffer = extracted.rest; + + // 遍历所有提取到的 JSON 对象 + for (const jsonText of extracted.objects) { + try { + // 解析知识库返回的 JSON 片段 + const parsed = parseKnowledgeBaseChunk(jsonText); + // 如果有回答内容,追加到消息的 answerMarkdown + if (typeof parsed.answer === 'string') { + applyAnswerToken(parsed.answer); + } + // 如果有检索文档,更新消息的来源信息 + if (parsed.docs) { + // 将 docs 转换为 sources 格式 + const mappedDocs = docsList.map((item: any) => ({ + title: item.m_title, + type: item.source1, + snippet: item.m_content, + source: item.source1, + time: item.m_publish, + url: item.recUuid, + content: item.m_content || '', + })); + // 更新检索统计信息 + setSearchProcess((prev) => ({ ...prev, hits: prev.hits + mappedDocs.length })); + // 更新消息的来源列表 + setMessages((prev) => prev.map((msg) => ( + msg.id === assistantId ? { ...msg, sources: mappedDocs } : msg + ))); + } + } catch (parseError) { + // 解析错误不影响主流程,记录日志继续处理 + console.error('单轮RAG流式JSON解析错误:', parseError); + } + } + } + } finally { + // 流读取完成后,释放 reader 锁并清空引用 + if (readerRef.current) { + readerRef.current.releaseLock(); + readerRef.current = null; + } + } + } catch (runError) { + // 请求失败处理 + console.error('单轮RAG请求失败:', runError); + setError(runError instanceof Error ? runError.message : '发生未知错误,请稍后重试'); + } finally { + // 无论成功还是失败,都要重置加载状态和解锁提交 + setIsPreConnectLoading(false); + setIsLoading(false); + submitLockRef.current = false; + } + }; + + /** + * 处理单轮 RAG 问答请求 + * + * 单轮 RAG 的处理流程: + * 1. 向知识库问答 API 发送 POST 请求,携带用户问题和检索参数 + * 2. API 返回 SSE 流式响应,包含多个 JSON 对象 + * 3. 逐个解析流式 JSON,提取 answer 和 docs 字段 + * 4. 通过 applyAnswerToken 将流式 token 追加到消息的 thinkingMarkdown/answerMarkdown + * 5. 实时更新消息来源(sources)和检索统计(searchProcess) + * + * @param query - 用户输入的问题文本 + */ + const chatRun = async (query: string) => { + // 清空之前的错误信息 + setError(null); + // 标记开始加载,禁用提交按钮防止重复点击 + setIsLoading(true); + // 标记"连接中"状态,显示加载占位符 + setIsPreConnectLoading(true); + // 用于存储当前 assistant 消息的 ID,以便后续更新该消息内容 + let assistantId = ''; + + // 标志位:当前处理的 token 是否处于 ... 标签内(解析思考过程) + let inThink = false; + // 标志位:是否已经遇到过 标签开始解析思考内容 + let thinkStarted = false; + + /** + * 将流式接收到的 token 应用到对应消息的 singleRagTrace 中 + * 该函数会解析 token 中的 ... 标签,将思考过程和回答分开存储 + * @param token - 从流中接收到的文本片段 + */ + const applyAnswerToken = (token: string) => { + setMessages((prev) => prev.map((msg) => { + // 跳过不是当前 assistant 消息或没有 singleRagTrace 的消息 + if (msg.id !== assistantId || !msg.singleRagTrace) return msg; + + // 获取当前消息的思考和回答内容 + let thinkingMarkdown = msg.singleRagTrace.thinkingMarkdown; + let answerMarkdown = msg.singleRagTrace.answerMarkdown; + let thinkingComplete = msg.singleRagTrace.thinkingComplete || false; + // 剩余未处理的 token 内容 + let remaining = token; + console.log('remaining', remaining) + // 当前处于 标签内(解析思考过程),查找结束标签 + while (remaining) { + if (inThink) { + const endIdx = remaining.indexOf(''); + if (endIdx === -1) { + thinkingMarkdown += remaining; + remaining = ''; + } else { + thinkingMarkdown += remaining.slice(0, endIdx); + remaining = remaining.slice(endIdx + ''.length); + inThink = false; + thinkingComplete = true; + } + } else { + const startIdx = remaining.indexOf(''); + console.log('startIdx', startIdx) + if (startIdx === -1) { + answerMarkdown += remaining; + remaining = ''; + } else { + answerMarkdown += remaining.slice(0, startIdx); + remaining = remaining.slice(startIdx + ''.length); + inThink = true; + thinkStarted = true; + } + } + } + + // 返回更新后的消息对象,保留 singleRagTrace 中的所有字段 + return { + ...msg, + singleRagTrace: { + ...msg.singleRagTrace, + // 更新思考和回答的 markdown 内容 + thinkingMarkdown, + answerMarkdown, + // 只有当已经开始解析思考内容时才更新 thinkingComplete 标志 + thinkingComplete: thinkStarted ? thinkingComplete : true, + }, + }; + })); + }; + + try { + // 向知识库问答 API 发送 POST 请求,携带用户问题和检索参数 + const response = await fetch(`${chatUrl}/chat/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + // 请求体包含查询内容、知识库名称、检索参数、模型配置等 + body: JSON.stringify({ + query, // 用户输入的问题 + history: [], // 对话历史,用于多轮对话 + stream: true, // 启用流式响应 + model_name: modelName, // 使用的模型名称 + temperature, // 生成温度,控制随机性 + prompt_name: 'default', // 使用的提示词模板 + max_tokens: 1000 + }), + }); + + // 检查 HTTP 响应状态 + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + // 确保响应体存在(SSE 流需要 body) + if (!response.body) { + throw new Error('Response body is null'); + } + // 收到有效响应后,关闭"连接中"状态,开始显示实际内容 + setIsPreConnectLoading(false); + + // 生成唯一 ID 用于标识这条 assistant 消息 + assistantId = `assistant-${Date.now()}`; + // 初始化思考过程追踪对象 + const initialTrace: QASingleRagTrace = { + title: '大模型思考过程', + thinkingMarkdown: '', // 思考过程内容 + answerMarkdown: '', // 最终回答内容 + thinkingComplete: false, // 思考是否完成 + }; + // 将 assistant 消息添加到消息列表中 + setMessages((prev) => [...prev, { + id: assistantId, + role: 'assistant', + content: '', + timestamp: new Date(), + singleRagTrace: initialTrace, + sources: [], // 初始为空,随着检索结果更新 + }]); + + // 获取响应体的流式读取器 + const reader = response.body.pipeThrough(new TextDecoderStream()).getReader(); + // 保存 reader 引用,用于"停止生成"功能 + readerRef.current = reader; + // 创建文本解码器,将二进制数据转换为字符串 + const decoder = new TextDecoder(); + // buffer 用于暂存未完成的 JSON 片段 + + try { + // 持续读取流数据,直到流结束 + while (true) { + // 读取下一个数据块 + const { done, value } = await reader.read(); + // done 为 true 时表示流已结束 + if (done) break; + applyAnswerToken(value); + } + } finally { + // 流读取完成后,释放 reader 锁并清空引用 + if (readerRef.current) { + // readerRef.current.releaseLock(); + readerRef.current = null; + } + } + } catch (runError) { + // 请求失败处理 + console.error('单轮RAG请求失败:', runError); + setError(runError instanceof Error ? runError.message : '发生未知错误,请稍后重试'); + } finally { + // 无论成功还是失败,都要重置加载状态和解锁提交 + setIsPreConnectLoading(false); + setIsLoading(false); + submitLockRef.current = false; + } + }; + + /** + * 解析类 JavaScript 对象字符串中的字符串值。 + * + * 后端单轮 RAG 流有时不是严格 JSON,而是 `{answer: '...'}` 这种对象片段。 + * 该函数只负责把单/双引号包裹的 answer 内容还原成普通字符串,保留换行和转义字符。 + */ + const parseLooseStringValue = (rawValue: string): string => { + try { + return JSON.parse(rawValue) as string; + } catch { + const trimmedValue = rawValue.trim(); + if (trimmedValue.startsWith("'") && trimmedValue.endsWith("'")) { + return trimmedValue + .slice(1, -1) + .replace(/\\n/g, '\n') + .replace(/\\r/g, '\r') + .replace(/\\t/g, '\t') + .replace(/\\'/g, "'") + .replace(/\\\\/g, '\\'); + } + throw new Error(`无法解析字符串值: ${rawValue}`); + } + }; + + const parseLooseValue = (rawValue: string): unknown => { + try { + return JSON.parse(rawValue); + } catch { + return parseLooseStringValue(rawValue); + } + }; + + /** + * 将单轮知识库流中的一个对象片段转换为统一数据结构。 + * + * 兼容两类返回: + * - 严格 JSON:`{"answer":"..."}` / `{"docs":[...]}` + * - 宽松对象:`{answer: '...'}` / `{docs: [...]}` + * + * 真实接口和本地模拟都走同一个解析函数,确保联调时只需要替换数据源,不需要改 UI 更新逻辑。 + */ + const parseKnowledgeBaseChunk = (jsonText: string): KnowledgeBaseChunk => { + try { + return JSON.parse(jsonText) as KnowledgeBaseChunk; + } catch { + const objectText = jsonText.trim(); + const answerMatch = objectText.match(/^\{\s*answer\s*:\s*([\s\S]*)\s*\}$/); + if (answerMatch) { + const rawValue = answerMatch[1].trim(); + return { answer: parseLooseStringValue(rawValue) }; + } + + const docsMatch = objectText.match(/^\{\s*docs\s*:\s*([\s\S]*)\s*\}$/); + if (docsMatch) { + return { docs: parseLooseValue(docsMatch[1]) as KnowledgeBaseChunk['docs'] }; + } + + const normalizedText = objectText.replace(/([{,]\s*)(answer|docs)(\s*:)/g, '$1"$2"$3'); + return JSON.parse(normalizedText) as KnowledgeBaseChunk; + } + }; + + const sleep = (ms: number) => new Promise((resolve) => { + window.setTimeout(resolve, ms); + }); + + /** + * 生成单轮 RAG 的本地模拟原始流。 + * + * 返回值刻意模拟接口的“对象字符串片段”形态,例如: + * `{answer: ',并附'}{answer: '带引用'}` 和 `{docs: [...]}`。 + * 调用方仍通过 `extractJsonObjects` 拆包,再通过 `parseKnowledgeBaseChunk` 解析, + * 这样可以覆盖粘包、连续对象和 docs 延迟到达等真实联调场景。 + */ + const createMockSingleRoundStream = (query: string): string[] => { + const answer = `用户正在使用单轮 RAG 提问:“${query}”。\n\n先从知识库里定位与问题最相关的片段,再把证据组织成可追溯的回答。\n我已基于模拟知识库完成检索,并按单轮 RAG 的方式生成回答。\n\n### 回答要点\n\n1. **先检索**:系统会围绕用户问题召回最相关的知识片段。[[1]]\n2. **再增强**:把召回内容注入上下文,约束模型只围绕证据回答。[[2]]\n3. **最后生成**:模型输出结论,并附带引用编号,方便用户核验来源。[[3]]\n\n当前内容是本地模拟的流式返回,用来验证一个一个 \`{answer: '模型输出'}\` 推送时的打字机效果。`; + + const docs: KnowledgeBaseChunk['docs'] = [ + { + title: '单轮 RAG 接入说明', + content: '单轮 RAG 通常包含问题输入、TopK 检索、Prompt 组装、模型生成和引用返回几个步骤。', + source: '知识库 / RAG 接入说明.md', + snippet: '单轮 RAG 通常包含问题输入、TopK 检索、Prompt 组装、模型生成和引用返回几个步骤。', + }, + { + title: '知识库问答接口协议', + content: "流式响应会持续返回 JSON 对象,每个对象可包含 answer 字段;结束或中间节点可携带 docs 参考资料。", + source: '接口文档 / knowledge_base_chat.json', + snippet: '流式响应会持续返回 JSON 对象,每个对象可包含 answer 字段。', + }, + { + title: '引用展示规范', + content: '回答中的引用编号应与 docs 顺序保持一致,前端可将 docs 映射为来源卡片供用户查看。', + source: '产品规范 / 引用展示规范.docx', + snippet: '回答中的引用编号应与 docs 顺序保持一致。', + }, + ]; + + const rawChunks: string[] = []; + const toSingleQuotedValue = (value: string) => `'${value + .replace(/\\/g, '\\\\') + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + .replace(/\t/g, '\\t') + .replace(/'/g, "\\'")}'`; + let pendingObjectText = ''; + const pushAnswerObject = (answerToken: string) => { + const objectText = `{answer: ${toSingleQuotedValue(answerToken)}}`; + if (pendingObjectText) { + rawChunks.push(`${pendingObjectText}${objectText}`); + pendingObjectText = ''; + return; + } + if (answerToken === ',并附') { + pendingObjectText = objectText; + return; + } + rawChunks.push(objectText); + }; + const thinkStart = ''; + const thinkEnd = ''; + const startIndex = answer.indexOf(thinkStart); + const endIndex = answer.indexOf(thinkEnd); + + if (startIndex === -1 || endIndex === -1) { + for (let index = 0; index < answer.length; index += 3) { + pushAnswerObject(answer.slice(index, index + 3)); + } + } else { + const beforeThink = answer.slice(0, startIndex); + const thinkingText = answer.slice(startIndex + thinkStart.length, endIndex); + + if (beforeThink) pushAnswerObject(beforeThink); + pushAnswerObject(thinkStart); + for (let index = 0; index < thinkingText.length; index += 3) { + pushAnswerObject(thinkingText.slice(index, index + 3)); + } + pushAnswerObject(thinkEnd); + const finalAnswerTokens = [ + '我已基于模拟知识库完成检索', + ',并附', + '带引用', + '带引用', + '。\n\n### 回答要点\n\n', + '1. **先检索**:系统会围绕用户问题召回最相关的知识片段。[[1]]\n', + '2. **再增强**:把召回内容注入上下文,约束模型只围绕证据回答。[[2]]\n', + '3. **最后生成**:模型输出结论,并附带引用编号,方便用户核验来源。[[3]]\n\n', + '当前内容是本地模拟的对象字符串流式返回,用来验证连续多个 `{answer: \'模型输出\'}` 和 `{docs: ...}` 的处理效果。', + ]; + finalAnswerTokens.forEach(pushAnswerObject); + } + + if (pendingObjectText) { + rawChunks.push(pendingObjectText); + } + rawChunks.push(`{docs: ${JSON.stringify(docs)}}`); + return rawChunks; + }; + + /** + * 执行单轮 RAG 本地模拟。 + * + * 模拟过程与真实接口保持一致:先创建空 assistant 消息,再持续解析对象字符串流, + * answer 片段进入打字机效果,docs 片段映射为引用来源。 + */ + const handleMockSingleRoundRun = async (query: string) => { + setError(null); + setIsLoading(true); + setIsPreConnectLoading(true); + mockSingleRoundStoppedRef.current = false; + + const assistantId = `assistant-${Date.now()}`; + let inThink = false; + let thinkStarted = false; + + const applyAnswerToken = (token: string) => { + setMessages((prev) => prev.map((msg) => { + if (msg.id !== assistantId || !msg.singleRagTrace) return msg; + + let thinkingMarkdown = msg.singleRagTrace.thinkingMarkdown; + let answerMarkdown = msg.singleRagTrace.answerMarkdown; + let thinkingComplete = msg.singleRagTrace.thinkingComplete || false; + let remaining = token; + console.log(remaining); + while (remaining) { + if (inThink) { + const endIdx = remaining.indexOf(''); + if (endIdx === -1) { + thinkingMarkdown += remaining; + remaining = ''; + } else { + thinkingMarkdown += remaining.slice(0, endIdx); + remaining = remaining.slice(endIdx + ''.length); + inThink = false; + thinkingComplete = true; + } + } else { + const startIdx = remaining.indexOf(''); + if (startIdx === -1) { + answerMarkdown += remaining; + remaining = ''; + } else { + answerMarkdown += remaining.slice(0, startIdx); + remaining = remaining.slice(startIdx + ''.length); + inThink = true; + thinkStarted = true; + } + } + } + + return { + ...msg, + singleRagTrace: { + ...msg.singleRagTrace, + thinkingMarkdown, + answerMarkdown, + thinkingComplete: thinkStarted ? thinkingComplete : true, + }, + }; + })); + }; + + try { + await sleep(300); + if (mockSingleRoundStoppedRef.current) return; + setIsPreConnectLoading(false); + + const initialTrace: QASingleRagTrace = { + title: '单轮 RAG 思考过程', + thinkingMarkdown: '', + answerMarkdown: '', + thinkingComplete: false, + }; + setMessages((prev) => [...prev, { + id: assistantId, + role: 'assistant', + content: '', + timestamp: new Date(), + singleRagTrace: initialTrace, + sources: [], + }]); + + const streamChunks = createMockSingleRoundStream(query); + let buffer = ''; + for (const rawChunk of streamChunks) { + if (mockSingleRoundStoppedRef.current) break; + + buffer += rawChunk; + const extracted = extractJsonObjects(buffer); + buffer = extracted.rest; + + for (const jsonText of extracted.objects) { + try { + const chunk = parseKnowledgeBaseChunk(jsonText); + if (typeof chunk.answer === 'string') { + applyAnswerToken(chunk.answer); + await sleep(45); + } + if (chunk.docs) { + const mappedDocs = mapDocsToSources(chunk.docs); + setSearchProcess((prev) => ({ ...prev, hits: prev.hits + mappedDocs.length })); + setMessages((prev) => prev.map((msg) => ( + msg.id === assistantId ? { ...msg, sources: [...(msg.sources || []), ...mappedDocs] } : msg + ))); + } + } catch (parseError) { + console.error('单轮RAG模拟流式JSON解析错误:', parseError); + } + } + } + } catch (runError) { + console.error('单轮RAG模拟流失败:', runError); + setError(runError instanceof Error ? runError.message : '发生未知错误,请稍后重试'); + } finally { + setIsPreConnectLoading(false); + setIsLoading(false); + mockSingleRoundStoppedRef.current = false; + submitLockRef.current = false; + } + }; + + /** + * 处理多轮真实接口里的工具调用事件。 + * + * `show_text` 用于追加思考或最终回答;`local_search` / `tavily_search` 用于把检索结果填入最近一轮。 + * 这里保持事件到 UI trace 的单向映射,避免请求层和展示层互相耦合。 + */ + const handleToolCall = (toolData: ToolCallData, assistantId: string) => { + setMessages((prevMessages) => prevMessages.map((msg) => { + if (msg.id !== assistantId || msg.role !== 'assistant' || !msg.thinkingTrace) return msg; + + const trace = { ...msg.thinkingTrace, rounds: [...msg.thinkingTrace.rounds] }; + const lastRound = trace.rounds[trace.rounds.length - 1]; + + switch (toolData.tool_name) { + case 'show_text': { + const chatText = toolData.tool_input.text || ''; + if (chatText.includes('')) { + const thinkContent = chatText.replace('', '').replace('', '').trim(); + if (trace.rounds[0].title !== '正在准备检索') { + lastRound.queryLoading = false; + // lastRound.queryShow = lastRound.retrievals.length > 0; + trace.rounds.push({ + id: `round-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + title: '思考中', + thoughtMarkdown: thinkContent, + query: '', + queryLoading: true, + queryShow: true, + retrievals: [], + }); + } else { + // lastRound.queryLoading = false; + trace.rounds[0].title = '思考过程' + trace.rounds[0].thoughtMarkdown = thinkContent; + } + } else { + lastRound.queryLoading = false; + trace.finalAnswerMarkdown += chatText; + } + break; + } + case 'tavily_search': + case 'local_search': { + if (toolData.tool_input.result && lastRound) { + try { + const result = JSON.parse(toolData.tool_input.result) as { + query?: string; + results?: Array<{ title?: string; content?: string; url?: string }>; + }; + + lastRound.query = result.query || lastRound.query; + lastRound.queryLoading = false; + lastRound.title = toolData.tool_name === 'local_search' ? '本地知识检索' : '联网检索'; + lastRound.retrievals = (result.results || []).map((item, idx) => ({ + id: `${toolData.tool_name}-${idx}`, + title: item.title || '未命名结果', + content: item.content || '', + source: item.url || '', + })); + } catch (parseError) { + console.error('搜索结果JSON解析错误:', parseError); + } + } + break; + } + case 'google_search': { + if (toolData.tool_input.result && lastRound) { + try { + const result = JSON.parse(toolData.tool_input.result) as { + organic: Array<{ title?: string; content?: string; url?: string; recUuid?: string; publish_time?:string }>; + }; + lastRound.queryLoading = false; + lastRound.title = '思考过程'; + lastRound.retrievals = (result.organic || []).map((item, idx) => ({ + id: item.recUuid, + title: item.title || '未命名结果', + content: item.content || '', + source: item.recUuid || '', + time: item.publish_time, + recUuid: item.recUuid, + type: 'more' + })); + } catch (parseError) { + console.error('搜索结果JSON解析错误:', parseError); + } + } else if(toolData.tool_input.q) { + lastRound.query = toolData.tool_input.q; + } + break; + } + default: + break; + } + + return { + ...msg, + thinkingTrace: trace, + }; + })); + }; + + /** + * 构造多轮 RAG 模拟 trace 的初始骨架。 + * + * 每一轮先展示 thought/query/loading,随后 `handleMockMultiRoundRun` 再按轮次填充 retrievals, + * 用来模拟“检索过程逐步展开”的体验。 + */ + const createMockMultiRoundTrace = (query: string): QAThinkingTrace => ({ + title: '多轮检索思考过程', + rounds: [ + { + id: `mock-round-1-${Date.now()}`, + title: '第一轮:理解问题并确定主检索方向', + thoughtMarkdown: `用户问题是:“${query}”。\n\n先判断问题核心意图,并抽取关键词:\n\n- 业务对象\n- 关键约束\n- 需要引用的制度或知识片段\n\n第一轮先用原始问题做宽召回,尽量覆盖相关资料。`, + query: `${query} 相关制度 知识库 处理流程`, + queryLoading: true, + retrievals: [], + queryShow: true, + }, + { + id: `mock-round-2-${Date.now()}`, + title: '第二轮:基于首轮结果改写 Query', + thoughtMarkdown: '首轮检索获得了基础资料,但仍需要补充更精确的执行口径。\n\n本轮将问题改写为更适合检索的短查询,并优先召回流程、规范和案例类资料。', + query: `${query} 操作规范 适用条件 案例`, + queryLoading: true, + retrievals: [], + queryShow: true, + }, + { + id: `mock-round-3-${Date.now()}`, + title: '第三轮:补充边界条件并交叉验证', + thoughtMarkdown: '前两轮已经覆盖主要结论,最后继续检索边界条件,避免回答过于绝对。\n\n重点确认:\n\n1. 是否存在例外场景\n2. 是否需要人工复核\n3. 引用资料之间是否一致', + query: `${query} 例外场景 风险点 人工复核`, + queryLoading: true, + retrievals: [], + queryShow: true, + }, + ], + finalAnswerMarkdown: '', + }); + + // 多轮 RAG 每一轮对应一组检索结果;数组下标与 trace.rounds 的下标保持一致。 + const mockMultiRoundRetrievals: QAThinkingRound['retrievals'][] = [ + [ + { + id: 'mock-multi-r1-1', + title: '业务知识库问答配置指南', + content: '多轮检索适合复杂问题,系统会先宽召回,再根据首轮结果继续改写查询并补充证据。', + source: '知识库 / 业务知识库问答配置指南.md', + }, + { + id: 'mock-multi-r1-2', + title: '智能问答流程说明', + content: '当用户问题包含多个约束条件时,需要保留原始问题,同时拆分出检索关键词用于后续召回。', + source: '流程库 / 智能问答流程说明.pdf', + }, + ], + [ + { + id: 'mock-multi-r2-1', + title: 'RAG Query 改写最佳实践', + content: 'Query 改写应减少口语表达,突出实体、动作、条件和目标资料类型,以提升命中率。', + source: '技术资料 / RAG Query 改写最佳实践.docx', + }, + { + id: 'mock-multi-r2-2', + title: '知识库召回与重排策略', + content: '建议先召回 Top50,再通过重排模型筛选 Top5 至 Top8 作为最终上下文。', + source: '算法组 / 知识库召回与重排策略.pptx', + }, + { + id: 'mock-multi-r2-3', + title: '企业知识问答案例集', + content: '复杂业务问答通常需要结合制度、FAQ、历史案例三类资料综合判断。', + source: '案例库 / 企业知识问答案例集.xlsx', + }, + ], + [ + { + id: 'mock-multi-r3-1', + title: '回答可信度与引用规范', + content: '生成答案时应明确结论依据,并在涉及例外条件时给出风险提示或人工复核建议。', + source: '规范库 / 回答可信度与引用规范.md', + }, + { + id: 'mock-multi-r3-2', + title: '知识库答案质检规则', + content: '当多个资料结论不完全一致时,应优先采用发布时间较新、来源更权威的资料。', + source: '质检库 / 知识库答案质检规则.pdf', + }, + ], + ]; + + /** + * 执行多轮 RAG 本地模拟。 + * + * 该方法按轮次延迟填充检索结果,最后分段追加最终回答,模拟真实多轮检索的“思考-召回-总结”节奏。 + * 完成后把所有 retrievals 汇总为 sources,供引用来源折叠面板展示。 + */ + const handleMockMultiRoundRun = async (query: string) => { + setError(null); + setIsLoading(true); + setIsPreConnectLoading(true); + mockMultiRoundStoppedRef.current = false; + + const assistantId = `assistant-${Date.now()}`; + + try { + await sleep(350); + if (mockMultiRoundStoppedRef.current) return; + setIsPreConnectLoading(false); + + const trace = createMockMultiRoundTrace(query); + setMessages((prev) => [...prev, { + id: assistantId, + role: 'assistant', + content: '', + timestamp: new Date(), + thinkingTrace: trace, + sources: [], + }]); + + for (let roundIndex = 0; roundIndex < trace.rounds.length; roundIndex += 1) { + if (mockMultiRoundStoppedRef.current) break; + await sleep(700); + setMessages((prev) => prev.map((msg) => { + if (msg.id !== assistantId || !msg.thinkingTrace) return msg; + const rounds = msg.thinkingTrace.rounds.map((round, idx) => ( + idx === roundIndex + ? { + ...round, + queryLoading: false, + retrievals: mockMultiRoundRetrievals[roundIndex], + } + : round + )); + return { + ...msg, + thinkingTrace: { + ...msg.thinkingTrace, + rounds, + }, + }; + })); + } + + const finalAnswerTokens = [ + `基于多轮检索,我对“${query}”形成如下结论:\n\n`, + '### 综合结论\n\n', + '1. **先做宽召回**:第一轮保留原始问题语义,覆盖相关制度、流程和 FAQ。\n', + '2. **再做 Query 改写**:第二轮把问题拆成实体、动作、条件,提升命中文档质量。\n', + '3. **最后做交叉验证**:第三轮补充边界条件和风险点,避免只依据单一资料下结论。\n\n', + '### 建议\n\n', + '如果是生产环境,可以把多轮检索结果按“相关性、权威性、时效性”排序,再把 TopK 证据注入模型生成最终回答。', + ]; + + for (const token of finalAnswerTokens) { + if (mockMultiRoundStoppedRef.current) break; + setMessages((prev) => prev.map((msg) => { + if (msg.id !== assistantId || !msg.thinkingTrace) return msg; + return { + ...msg, + thinkingTrace: { + ...msg.thinkingTrace, + finalAnswerMarkdown: `${msg.thinkingTrace.finalAnswerMarkdown}${token}`, + }, + }; + })); + await sleep(180); + } + + const sources = mockMultiRoundRetrievals.flat().map((item) => ({ + title: item.title, + type: '参考资料', + snippet: item.content, + source: item.source, + content: item.content, + })); + setSearchProcess((prev) => ({ + ...prev, + rounds: trace.rounds.length, + hits: sources.length, + queryRewrite: '原问题 → 宽召回 → Query 改写 → 边界条件补充 → 综合回答', + })); + setMessages((prev) => prev.map((msg) => ( + msg.id === assistantId ? { ...msg, sources } : msg + ))); + } catch (runError) { + console.error('多轮RAG模拟失败:', runError); + setError(runError instanceof Error ? runError.message : '发生未知错误,请稍后重试'); + } finally { + setIsPreConnectLoading(false); + setIsLoading(false); + mockMultiRoundStoppedRef.current = false; + submitLockRef.current = false; + } + }; + + /** + * 处理真实多轮 RAG 请求。 + * + * 多轮接口采用 SSE:每行 `data:` 都描述一个事件。组件根据事件类型分别更新 taskId、工具调用、 + * 最终回答和引用来源。当前用户入口暂时走本地模拟,此方法保留给接口连通后切换使用。 + */ + const handleRun = async (query: string) => { + setError(null); + setIsLoading(true); + setIsPreConnectLoading(true); + + try { + const response = await fetch(`${generateUrl}/generate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt: query }), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + if (!response.body) { + throw new Error('Response body is null'); + } + setIsPreConnectLoading(false); + + const assistantId = `assistant-${Date.now()}`; + const assistantMessage: Message = { + id: assistantId, + role: 'assistant', + content: '', + timestamp: new Date(), + thinkingTrace: createMultiRoundStreamTrace(), + }; + // 接口连通后再展示多轮检索主题与过程 + setMessages((prev) => [...prev, assistantMessage]); + + const reader = response.body.getReader(); + readerRef.current = reader; + const decoder = new TextDecoder(); + let buffer = ''; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + const lastLine = lines.pop(); + buffer = lastLine || ''; + + for (const line of lines) { + if (!line.startsWith('data: ')) continue; + + try { + const parsedData = JSON.parse(line.slice(6)) as { + event: string; + data: any; + }; + const { event, data } = parsedData; + + switch (event) { + case 'task_created': { + taskIdRef.current = data?.task_id || null; + break; + } + case 'tool_call': { + handleToolCall(data as ToolCallData, assistantId); + break; + } + case 'message': { + if (typeof data === 'string') { + setMessages((prevMessages) => prevMessages.map((msg) => { + if (msg.id !== assistantId || !msg.thinkingTrace) return msg; + return { + ...msg, + thinkingTrace: { + ...msg.thinkingTrace, + finalAnswerMarkdown: `${msg.thinkingTrace.finalAnswerMarkdown}${data}`, + }, + }; + })); + } + break; + } + case 'references': { + const refs = Array.isArray(data?.references) ? data.references : []; + const mappedRefs = refs.map(mapReferenceToSource); + setSearchProcess((prev) => ({ + ...prev, + hits: mappedRefs.length, + })); + setMessages((prevMessages) => prevMessages.map((msg) => ( + msg.id === assistantId + ? { ...msg, sources: mappedRefs } + : msg + ))); + break; + } + case 'done': { + setIsLoading(false); + break; + } + default: + console.log(`未知事件类型: ${event}`, data); + } + } catch (parseError) { + console.error('JSON解析错误:', parseError); + console.error('原始数据:', line.slice(6)); + } + } + } + } finally { + if (readerRef.current) { + readerRef.current.releaseLock(); + readerRef.current = null; + } + } + } catch (runError) { + console.error('处理请求时发生错误:', runError); + setIsLoading(false); + setError(runError instanceof Error ? runError.message : '发生未知错误,请稍后重试'); + setIsPreConnectLoading(false); + } finally { + // 无论成功还是失败,都要重置加载状态和解锁提交 + setIsPreConnectLoading(false); + setIsLoading(false); + submitLockRef.current = false; + } + }; + + /** + * 单一模式下的发送入口。 + * + * 这里只负责创建用户消息、重置输入框和按当前 qaMode 分发到对应执行器; + * 具体的真实请求、模拟流和状态回填分别在各自 handler 内维护。 + */ + const handleSend = () => { + if (submitLockRef.current || isLoading || isPreConnectLoading) return; + if (!inputValue.trim()) return; + submitLockRef.current = true; + const query = inputValue.trim(); + + const userMessage: Message = { + id: Date.now().toString(), + role: 'user', + content: query, + timestamp: new Date(), + }; + + if (qaMode === 'multi-round') { // 多轮检索 + setMessages((prev) => [...prev, userMessage]); + setInputValue(''); + setSearchProcess({ + currentMode: '多轮RAG', + rounds: 0, + hits: 0, + queryRewrite: '', + }); + void handleRun(query); + return; + } + + if (qaMode === 'single-round') { // 单轮RAG + setMessages((prev) => [...prev, userMessage]); + setInputValue(''); + setSearchProcess({ + currentMode: '单轮RAG', + rounds: 1, + hits: 0, + queryRewrite: '', + }); + void handleSingleRoundRun(query); + return; + } + + if (qaMode === 'single-chat') { // 单轮RAG + console.log('进入了这里') + setMessages((prev) => [...prev, userMessage]); + setInputValue(''); + setSearchProcess({ + currentMode: '单轮RAG', + rounds: 1, + hits: 0, + queryRewrite: '', + }); + void chatRun(query); + // handleMockSingleRoundRun(query); + return; + } + + setIsLoading(true); + setMessages((prev) => [...prev, userMessage]); + setInputValue(''); + + // 模拟AI响应 + // setTimeout(() => { + // setMessages(prev => [...prev, { + // id: `assistant-${Date.now()}`, + // role: 'assistant', + // content: '已完成深度检索和综合回答。', + // timestamp: new Date(), + // thinkingTrace: createThinkingTrace(qaMode), + // }]); + // setIsLoading(false); + // submitLockRef.current = false; + // setSearchProcess({ + // currentMode: '深度检索', + // rounds: 1, + // hits: 5, + // queryRewrite: qaMode === 'deep-search' ? '原问题→分解子问题→独立检索→综合回答' : '', + // }); + // }, 50000); + }; + + /** + * 对比模式发送入口。 + * + * 左右面板共享同一个用户问题,但各自按选择的模式独立运行、独立 loading、独立 sources。 + * 通过 `updatePane` 将左右面板更新收敛到一个函数,避免并发请求互相覆盖状态。 + */ + const handleCompare = () => { + if (submitLockRef.current || isLoading || isPreConnectLoading) return; + if (!inputValue.trim()) return; + submitLockRef.current = true; + const query = inputValue.trim(); + setInputValue(''); + setCompareUserInput(query); + setError(null); + setIsLoading(true); + setCompareResult({ + left: createComparePane(compareLeftMode), + right: createComparePane(compareRightMode), + }); + + const updatePane = (side: 'left' | 'right', updater: (pane: ComparePane) => ComparePane) => { + setCompareResult((prev) => { + if (!prev) return prev; + return { + ...prev, + [side]: updater(prev[side]), + }; + }); + }; + + const runSingleForCompare = async (side: 'left' | 'right') => { + let inThink = false; + let thinkStarted = false; + + const response = await fetch('http://10.26.128.82:7863/chat/knowledge_base_chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query, + knowledge_base_name: [], + top_k: 5, + score_threshold: scoreThreshold, + history: [], + stream: true, + model_name: modelName, + temperature, + prompt_name: 'default', + local_doc_url: false, + }), + }); + if (!response.ok || !response.body) throw new Error(`单轮RAG接口异常: ${response.status}`); + + const reader = response.body.getReader(); + compareReaderRef.current[side] = reader; + const decoder = new TextDecoder(); + let buffer = ''; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const extracted = extractJsonObjects(buffer); + buffer = extracted.rest; + for (const jsonText of extracted.objects) { + const parsed = parseKnowledgeBaseChunk(jsonText); + if (typeof parsed.answer === 'string') { + const token = parsed.answer; + updatePane(side, (pane) => { + const trace = pane.singleRagTrace || { + title: '单轮 RAG 思考过程', + thinkingMarkdown: '', + answerMarkdown: '', + thinkingComplete: false, + }; + let thinkingMarkdown = trace.thinkingMarkdown; + let answerMarkdown = trace.answerMarkdown; + let thinkingComplete = trace.thinkingComplete || false; + let remaining = token; + while (remaining) { + if (inThink) { + const endIdx = remaining.indexOf(''); + if (endIdx === -1) { + thinkingMarkdown += remaining; + remaining = ''; + } else { + thinkingMarkdown += remaining.slice(0, endIdx); + remaining = remaining.slice(endIdx + ''.length); + inThink = false; + thinkingComplete = true; + } + } else { + const startIdx = remaining.indexOf(''); + if (startIdx === -1) { + answerMarkdown += remaining; + remaining = ''; + } else { + answerMarkdown += remaining.slice(0, startIdx); + remaining = remaining.slice(startIdx + ''.length); + inThink = true; + thinkStarted = true; + } + } + } + return { + ...pane, + singleRagTrace: { + ...trace, + thinkingMarkdown, + answerMarkdown, + thinkingComplete: thinkStarted ? thinkingComplete : true, + }, + }; + }); + } + if (parsed.docs) { + updatePane(side, (pane) => ({ ...pane, sources: [...pane.sources, ...mapDocsToSources(parsed.docs)] })); + } + } + } + } finally { + reader.releaseLock(); + if (compareReaderRef.current[side] === reader) { + compareReaderRef.current[side] = null; + } + } + }; + + const runMultiForCompare = async (side: 'left' | 'right') => { + const response = await fetch('http://10.26.128.38:8049/api/run', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query }), + }); + if (!response.ok || !response.body) throw new Error(`多轮检索接口异常: ${response.status}`); + + const reader = response.body.getReader(); + compareReaderRef.current[side] = reader; + const decoder = new TextDecoder(); + let buffer = ''; + + const applyToolCall = (toolData: ToolCallData) => { + updatePane(side, (pane) => { + const trace = pane.thinkingTrace || createMultiRoundStreamTrace(); + const rounds = [...trace.rounds]; + const lastRound = rounds[rounds.length - 1]; + switch (toolData.tool_name) { + case 'show_text': { + const chatText = toolData.tool_input.text || ''; + if (chatText.includes('')) { + const thinkContent = chatText.replace('', '').replace('', '').trim(); + rounds.push({ + id: `round-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + title: '思考中', + thoughtMarkdown: thinkContent, + query: '', + queryLoading: true, + queryShow: true, + retrievals: [], + }); + } else { + trace.finalAnswerMarkdown += chatText; + } + break; + } + case 'tavily_search': + case 'local_search': { + if (toolData.tool_input.result && lastRound) { + const result = JSON.parse(toolData.tool_input.result) as { + query?: string; + results?: Array<{ title?: string; content?: string; url?: string }>; + }; + lastRound.query = result.query || lastRound.query; + lastRound.queryLoading = false; + lastRound.title = toolData.tool_name === 'local_search' ? '本地知识检索' : '联网检索'; + lastRound.retrievals = (result.results || []).map((item, idx) => ({ + id: `${toolData.tool_name}-${idx}`, + title: item.title || '未命名结果', + content: item.content || '', + source: item.url || '', + })); + } + break; + } + default: + break; + } + return { + ...pane, + thinkingTrace: { ...trace, rounds }, + }; + }); + }; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + const lastLine = lines.pop(); + buffer = lastLine || ''; + for (const line of lines) { + if (!line.startsWith('data: ')) continue; + const parsedData = JSON.parse(line.slice(6)) as { event: string; data: any }; + const { event, data } = parsedData; + if (event === 'tool_call') applyToolCall(data as ToolCallData); + if (event === 'message' && typeof data === 'string') { + updatePane(side, (pane) => { + const trace = pane.thinkingTrace || createMultiRoundStreamTrace(); + return { + ...pane, + thinkingTrace: { + ...trace, + finalAnswerMarkdown: `${trace.finalAnswerMarkdown}${data}`, + }, + }; + }); + } + if (event === 'references') { + const refs = Array.isArray(data?.references) ? data.references : []; + updatePane(side, (pane) => ({ ...pane, sources: refs.map(mapReferenceToSource) })); + } + } + } + } finally { + reader.releaseLock(); + if (compareReaderRef.current[side] === reader) { + compareReaderRef.current[side] = null; + } + } + }; + + const runByMode = (side: 'left' | 'right', mode: QAMode) => { + if (mode === 'single-round') return runSingleForCompare(side); + return runMultiForCompare(side); + }; + + Promise.allSettled([ + runByMode('left', compareLeftMode).finally(() => updatePane('left', (pane) => ({ ...pane, loading: false }))), + runByMode('right', compareRightMode).finally(() => updatePane('right', (pane) => ({ ...pane, loading: false }))), + ]).finally(() => { + setIsLoading(false); + submitLockRef.current = false; + }); + }; + + const handleStopCompareSide = (side: 'left' | 'right') => { + const reader = compareReaderRef.current[side]; + if (reader) { + void reader.cancel(`用户停止${side === 'left' ? '左侧' : '右侧'}生成`); + compareReaderRef.current[side] = null; + } + setCompareResult((prev) => { + if (!prev) return prev; + return { + ...prev, + [side]: { + ...prev[side], + loading: false, + }, + }; + }); + }; + + /** + * 停止当前生成。 + * + * 真实请求通过 reader.cancel() 中断读取;本地模拟通过 ref 标记让异步循环自行退出。 + * 对比模式需要同时停止左右两侧,单一模式只处理当前主 reader。 + */ + const handleStop = () => { + if (viewMode === 'compare') { + handleStopCompareSide('left'); + handleStopCompareSide('right'); + setIsLoading(false); + submitLockRef.current = false; + return; + } + mockSingleRoundStoppedRef.current = true; + mockMultiRoundStoppedRef.current = true; + if (readerRef.current) { + void readerRef.current.cancel('用户手动停止'); + readerRef.current = null; + } + setIsPreConnectLoading(false); + setIsLoading(false); + submitLockRef.current = false; + }; + + /** + * 对“生成中切换页面/模式/布局”进行统一保护。 + * + * 如果当前没有生成任务,直接执行目标操作;否则弹出确认框。 + * 用户确认后先停止生成,再执行原操作,避免生成流继续写入已经切换的 UI 状态。 + */ + const runAfterGenerationConfirm = (action: () => void) => { + if (!isLoading) { + action(); + return; + } + pendingActionRef.current = action; + // Dialog.confirm({ + // header: '模型正在生成', + // body: '当前模型仍在生成内容。确认后将停止生成,并继续执行你的操作。', + // confirmBtn: '确认停止并继续', + // cancelBtn: '取消', + // onConfirm: () => { + // handleStop(); + // pendingActionRef.current?.(); + // pendingActionRef.current = null; + // }, + // onClose: () => { + // pendingActionRef.current = null; + // }, + // }); + }; + + const handleSafeViewModeChange = (mode: ViewMode) => { + if (mode === viewMode) return; + runAfterGenerationConfirm(() => setViewMode(mode)); + }; + + const handleSafeNavigate = (page: PageType) => { + runAfterGenerationConfirm(() => onNavigate(page)); + }; + + const handleSafeReset = () => { + runAfterGenerationConfirm(() => { + setMessages([]); + setCompareResult(null); + }); + }; + + const handleSafeModeChange = (mode: QAMode) => { + runAfterGenerationConfirm(() => setQaMode(mode)); + }; + + const handleSafeCompareLeftModeChange = (mode: QAMode) => { + runAfterGenerationConfirm(() => setCompareLeftMode(mode)); + }; + + const handleSafeCompareRightModeChange = (mode: QAMode) => { + runAfterGenerationConfirm(() => setCompareRightMode(mode)); + }; + + const handleSafeCompareLayoutChange = (layout: 'horizontal' | 'vertical') => { + runAfterGenerationConfirm(() => setCompareLayout(layout)); + }; + + const handleSaveRecord = () => { + alert('问答记录已保存'); + }; + + const handleCollect = () => { + alert('已添加到收藏'); + }; + + const handleKeyDown = (e) => { + // if (e.key === 'Enter' && !e.shiftKey) { + // console.log('回车了') + // e.preventDefault(); + // if (e.repeat || submitLockRef.current || isLoading || isPreConnectLoading) return; + // if (viewMode === 'compare') { + // handleCompare(); + // } else { + // handleSend(); + // } + // } + // console.log('e',e) + // if ((e.metaKey || e.ctrlKey || e.shiftKey) && e.key === "Enter") { + // e.preventDefault(); + // } else if (e.keyCode === 13) { + // e.preventDefault(); + // console.log('回车了') + // } else if (e.key === "/") { + // e.preventDefault(); + // } + // if (e.repeat || submitLockRef.current || isLoading || isPreConnectLoading) return; + console.log(e,'e') + console.log('da') + if (viewMode === 'compare') { + handleCompare(); + } else { + handleSend(); + } + scrollToBottom(); + }; + + // 获取模式标签 + const getModeLabel = (mode: QAMode) => { + switch (mode) { + case 'single-chat': return '大模型'; + case 'single-round': return '单轮RAG'; + case 'multi-round': return '多轮RAG'; + case 'deep-search': return '深度检索'; + case 'hx-search': return '画像检索'; + } + }; + + const activeAssistantMessageId = isLoading + ? [...messages].reverse().find((msg) => msg.role === 'assistant')?.id + : undefined; + + useEffect(() => { + setQaMode('single-round'); + setMessages(createMockSingleRagMessages()); + setSearchProcess({ + currentMode: '单轮RAG', + rounds: 1, + hits: 3, + queryRewrite: '', + }); + }, []); + + /** + * 滚动到底部。 + * + * 聊天区默认跟随新消息自动滚动;当用户手动上滑查看历史时会暂停自动滚动,避免打断阅读。 + */ + const scrollToBottom = (smooth = false) => { + if (!chatListRef.current) return; + chatListRef.current.scrollTo({ + top: chatListRef.current.scrollHeight, + behavior: smooth ? 'smooth' : 'auto', + }); + }; + + useEffect(() => { + if (isAutoScrollEnabled) { + scrollToBottom(false); + } + }, [messages, isLoading, isAutoScrollEnabled]); + + const handleChatScroll = () => { + if (!chatListRef.current) return; + const { scrollTop, scrollHeight, clientHeight } = chatListRef.current; + const isNearBottom = scrollHeight - (scrollTop + clientHeight) < 80; + setIsAutoScrollEnabled(isNearBottom); + setShowScrollToBottom(!isNearBottom); + }; + + const getAssistantAnswerText = (message: Message) => ( + message.singleRagTrace?.answerMarkdown || message.thinkingTrace?.finalAnswerMarkdown || message.content + ); + + const getAssistantThinkingText = (message: Message) => { + if (message.singleRagTrace) return message.singleRagTrace.thinkingMarkdown; + if (message.thinkingTrace) { + return message.thinkingTrace.rounds.map((round, index) => ( + `## 第 ${index + 1} 轮:${round.title}\n${round.thoughtMarkdown}` + )).join('\n\n'); + } + return ''; + }; + + const handleCopyAssistantMessage = async (message: Message) => { + const answerText = getAssistantAnswerText(message); + try { + await navigator.clipboard.writeText(answerText); + } catch (copyError) { + console.error('复制模型回答失败:', copyError); + } + }; + + const handleDeleteAssistantMessage = (message: Message) => { + const messageIndex = messages.findIndex((msg) => msg.id === message.id); + const previousUserMessage = [...messages.slice(0, messageIndex)].reverse().find((msg) => msg.role === 'user'); + setMessages((prev) => prev.filter((msg) => msg.id !== message.id && msg.id !== previousUserMessage?.id)); + }; + + const handleEditAssistantMessage = (message: Message) => { + setEditingMessageId(message.id); + setEditingThinking(getAssistantThinkingText(message)); + setEditingAnswer(getAssistantAnswerText(message)); + }; + + const handleCancelAssistantEdit = () => { + setEditingMessageId(null); + setEditingThinking(''); + setEditingAnswer(''); + }; + + const handleSaveAssistantEdit = () => { + if (!editingMessageId) return; + setMessages((prev) => prev.map((msg) => { + if (msg.id !== editingMessageId) return msg; + if (msg.singleRagTrace) { + return { + ...msg, + content: editingAnswer, + singleRagTrace: { + ...msg.singleRagTrace, + thinkingMarkdown: editingThinking, + answerMarkdown: editingAnswer, + thinkingComplete: true, + }, + }; + } + if (msg.thinkingTrace) { + const rounds = msg.thinkingTrace.rounds.length > 0 + ? msg.thinkingTrace.rounds.map((round, index) => ( + index === 0 ? { ...round, thoughtMarkdown: editingThinking } : round + )) + : msg.thinkingTrace.rounds; + return { + ...msg, + content: editingAnswer, + thinkingTrace: { + ...msg.thinkingTrace, + rounds, + finalAnswerMarkdown: editingAnswer, + }, + }; + } + return { ...msg, content: editingAnswer }; + })); + handleCancelAssistantEdit(); + }; + + const handleResendAssistantMessage = (message: Message) => { + if (submitLockRef.current || isLoading || isPreConnectLoading) return; + const messageIndex = messages.findIndex((msg) => msg.id === message.id); + const previousUserMessage = [...messages.slice(0, messageIndex)].reverse().find((msg) => msg.role === 'user'); + if (!previousUserMessage) return; + + submitLockRef.current = true; + setMessages((prev) => prev.filter((msg) => msg.id !== message.id)); + setInputValue(''); + + if (message.singleRagTrace) { + setSearchProcess({ currentMode: '单轮RAG', rounds: 1, hits: 0, queryRewrite: '' }); + void handleSingleRoundRun(previousUserMessage.content); + return; + } + + setSearchProcess({ currentMode: '多轮RAG', rounds: 0, hits: 0, queryRewrite: '' }); + void handleRun(previousUserMessage.content); + }; + + /** + * 点击答案中的引用编号后打开引用详情弹窗。 + * + * Markdown 中的 `[[1]]` 与 sources 数组采用 1-based 对齐,因此这里需要减 1 取数组下标。 + */ + const handleCitationClick = (message: Message, citationIndex: number) => { + const source = message.sources?.[citationIndex - 1]; + if (!source) return; + setSelectedCitation(source); + }; + + const handleCompareCitationClick = (side: 'left' | 'right', citationIndex: number) => { + const source = compareResult?.[side]?.sources?.[citationIndex - 1]; + if (!source) return; + setSelectedCitation(source); + }; + + useEffect(() => { + if (viewMode === 'compare') { + setCompareLeftMode('single-round'); + setCompareRightMode('multi-round'); + } + }, [viewMode]); + + useEffect(() => { + if (viewMode !== 'compare' || !compareResult) return; + setIsLoading(compareResult.left.loading || compareResult.right.loading); + }, [viewMode, compareResult]); + + // 离开浏览器页面时无法展示自定义 Dialog,因此使用原生 beforeunload 提醒用户生成会被中断。 + useEffect(() => { + const handleBeforeUnload = (event: BeforeUnloadEvent) => { + if (!isLoading) return; + event.preventDefault(); + event.returnValue = '模型正在生成,离开页面将停止当前生成。'; + }; + + window.addEventListener('beforeunload', handleBeforeUnload); + return () => window.removeEventListener('beforeunload', handleBeforeUnload); + }, [isLoading]); + + return ( +
+ + + {/* 主体内容区 */} +
+ + + {/* 中间结果区 */} +
+ {viewMode === 'single' ? ( + <> + {/* 聊天消息区 */} +
+ {messages.length === 0 ? ( + } + title="开始你的问答" + description="输入问题,获取基于知识库的智能回答" + /> + ) : ( + messages.map((msg) => ( + + )) + )} + + {isPreConnectLoading && ( + + )} + + {error && ( +
+ {error} +
+ )} + + {showScrollToBottom && ( + + )} +
+ + ) : ( + <> + {/* 对比结果区 */} +
+ {compareUserInput && ( +
+
+

{compareUserInput}

+
+
+ )} + {compareResult ? ( + compareLayout === 'horizontal' ? ( +
+ {/* 左侧结果 */} +
+
+
+
+ + + {getModeLabel(compareResult.left.mode)} + +
+ {compareResult.left.loading && ( + + )} +
+
+
+
+ {compareResult.left.mode === 'single-round' ? ( + handleCompareCitationClick('left', idx)} /> + ) : ( + handleCompareCitationClick('left', idx)} /> + )} +
+
+
+ + {/* 右侧结果 */} +
+
+
+
+ + + {getModeLabel(compareResult.right.mode)} + +
+ {compareResult.right.loading && ( + + )} +
+
+
+
+ {compareResult.right.mode === 'single-round' ? ( + handleCompareCitationClick('right', idx)} /> + ) : ( + handleCompareCitationClick('right', idx)} /> + )} +
+
+
+
+ ) : ( +
+ {/* 上方结果 */} +
+
+
+
+ + + {getModeLabel(compareResult.left.mode)} + +
+ {compareResult.left.loading && ( + + )} +
+
+
+
+ {compareResult.left.mode === 'single-round' ? ( + handleCompareCitationClick('left', idx)} /> + ) : ( + handleCompareCitationClick('left', idx)} /> + )} +
+
+
+ + {/* 下方结果 */} +
+
+
+
+ + + {getModeLabel(compareResult.right.mode)} + +
+ {compareResult.right.loading && ( + + )} +
+
+
+
+ {compareResult.right.mode === 'single-round' ? ( + handleCompareCitationClick('right', idx)} /> + ) : ( + handleCompareCitationClick('right', idx)} /> + )} +
+
+
+
+ ) + ) : ( +
+ +

对比模式

+

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

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

+ 检索过程 +

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

+ {searchProcess.queryRewrite} +

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

+ 差异总结 +

+
+
+ + + 一致观点 + +

+ {compareResult.differences.consensus} +

+
+
+ + + 差异观点 + +

+ {compareResult.differences.differences} +

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

+ {compareResult.differences.leftAdvantage} +

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

+ {compareResult.differences.rightAdvantage} +

+
+
+
+ )} +
+ + {editingMessageId && ( +
+
+
+ +
+

编辑模型生成内容

+

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

+
+
+