From 4f536d9b39be2b79045d301f1428c89484be7f98 Mon Sep 17 00:00:00 2001 From: fengxiangdebaozi <10845145+fengxiangdebaozi@user.noreply.gitee.com> Date: Thu, 13 Aug 2026 21:03:18 +0800 Subject: [PATCH] feat(frontend): add workspace single and multi round rag --- .../components/workspace/chats/chat-box.tsx | 53 +- .../workspace/citations/citation-link.tsx | 14 +- .../src/components/workspace/input-box.tsx | 133 +++-- .../workspace/messages/message-list-item.tsx | 392 ++++++++++++-- .../workspace/messages/message-list.tsx | 7 +- .../src/core/models/single-round-rag.ts | 15 + frontend-web/src/core/models/types.ts | 2 +- .../skill-display/inline-citations.test.ts | 6 + frontend-web/src/core/threads/types.ts | 4 +- .../src/core/threads/use-multi-round-rag.ts | 483 ++++++++++++++++++ .../src/core/threads/use-single-round-rag.ts | 58 ++- frontend-web/src/pages/ChatPage.tsx | 74 ++- 12 files changed, 1122 insertions(+), 119 deletions(-) create mode 100644 frontend-web/src/core/threads/use-multi-round-rag.ts diff --git a/frontend-web/src/components/workspace/chats/chat-box.tsx b/frontend-web/src/components/workspace/chats/chat-box.tsx index 342abb5e..f8021559 100644 --- a/frontend-web/src/components/workspace/chats/chat-box.tsx +++ b/frontend-web/src/components/workspace/chats/chat-box.tsx @@ -16,6 +16,7 @@ import { apiFetch } from "@/core/api"; import { getBackendBaseURL } from "@/core/config"; import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings"; import { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag"; +import { isMultiRoundRagMessage } from "@/core/threads/use-multi-round-rag"; import { useSkills } from "@/core/skills/hooks"; import type { SkillDisplayConfig } from "@/core/skills/type"; import { @@ -93,13 +94,20 @@ const ChatBox: React.FC<{ const [settings] = useLocalSettings(); const ragModeEnabled = settings.display.ragModeEnabled || isThreadRagModeLocked(threadId); - // Frontend single-round RAG writes its own source tool message, so it should - // use the same reference panel without changing the user's global RAG mode. - const hasSingleRoundRag = useMemo( - () => (thread.values.messages ?? []).some(isSingleRoundRagMessage), + // Frontend RAG modes write their own hidden source tool messages. They use + // the existing reference panel, but must not request or write reference + // batches through DeerFlow's backend APIs. + const hasFrontendRag = useMemo( + () => + (thread.values.messages ?? []).some( + (message) => + isSingleRoundRagMessage(message) || isMultiRoundRagMessage(message), + ), [thread.values.messages], ); - const referencesEnabled = ragModeEnabled || hasSingleRoundRag; + const referencesEnabled = ragModeEnabled || hasFrontendRag; + const shouldFetchBackendReferenceBatches = + referencesEnabled && !hasFrontendRag; const { skills } = useSkills(); const { @@ -128,7 +136,7 @@ const ChatBox: React.FC<{ ); }, [referencesEnabled, skillDisplayMap, thread.isLoading, thread.values.messages]); useEffect(() => { - if (!referencesEnabled) { + if (!shouldFetchBackendReferenceBatches) { setBackendReferenceBatches([]); return; } @@ -164,7 +172,12 @@ const ChatBox: React.FC<{ } }); return () => controller.abort(); - }, [referencesEnabled, thread.isLoading, threadId, thread.values.messages]); + }, [ + shouldFetchBackendReferenceBatches, + thread.isLoading, + threadId, + thread.values.messages, + ]); const adaptedBackendReferenceBatches = useMemo( () => adaptBackendReferenceBatches( @@ -175,15 +188,22 @@ const ChatBox: React.FC<{ ); const referenceBatches = useMemo( () => { - if (adaptedBackendReferenceBatches.length > 0) { + if ( + shouldFetchBackendReferenceBatches && + adaptedBackendReferenceBatches.length > 0 + ) { return adaptedBackendReferenceBatches; } return fallbackReferenceBatches; }, - [adaptedBackendReferenceBatches, fallbackReferenceBatches], + [ + adaptedBackendReferenceBatches, + fallbackReferenceBatches, + shouldFetchBackendReferenceBatches, + ], ); useEffect(() => { - if (!referencesEnabled) { + if (!shouldFetchBackendReferenceBatches) { return; } const signature = JSON.stringify({ @@ -210,7 +230,14 @@ const ChatBox: React.FC<{ fallbackBatches: debugReferenceBatches(fallbackReferenceBatches), finalBatches: debugReferenceBatches(referenceBatches), }); - }, [adaptedBackendReferenceBatches, backendReferenceBatches, fallbackReferenceBatches, referencesEnabled, referenceBatches, threadId]); + }, [ + adaptedBackendReferenceBatches, + backendReferenceBatches, + fallbackReferenceBatches, + referenceBatches, + shouldFetchBackendReferenceBatches, + threadId, + ]); const latestReferenceBatch = referenceBatches.length > 0 ? referenceBatches[referenceBatches.length - 1]! @@ -221,7 +248,7 @@ const ChatBox: React.FC<{ const referenceSources = activeReferenceBatch?.sources ?? []; const hasCitationReferences = referenceSources.length > 0; useEffect(() => { - if (!referencesEnabled || !activeReferenceBatch) { + if (!shouldFetchBackendReferenceBatches || !activeReferenceBatch) { return; } logReferenceDebug("frontend-active-reference-batch-payload", threadId, { @@ -230,7 +257,7 @@ const ChatBox: React.FC<{ sourceCount: activeReferenceBatch.sources.length, sources: activeReferenceBatch.sources.map(debugReferenceSource), }); - }, [activeReferenceBatch, referencesEnabled, threadId]); + }, [activeReferenceBatch, shouldFetchBackendReferenceBatches, threadId]); useEffect(() => { if (hasCitationReferences) { setReferencesOpen(true); diff --git a/frontend-web/src/components/workspace/citations/citation-link.tsx b/frontend-web/src/components/workspace/citations/citation-link.tsx index 438cbc99..e69918c4 100644 --- a/frontend-web/src/components/workspace/citations/citation-link.tsx +++ b/frontend-web/src/components/workspace/citations/citation-link.tsx @@ -8,8 +8,13 @@ export function CitationLink({ href, children, className, + onReferenceClick, + onClick, ...props -}: ComponentProps<"a">) { +}: ComponentProps<"a"> & { + /** Lets a message renderer show an in-place source preview instead of scrolling. */ + onReferenceClick?: (referenceId: string) => void; +}) { const childrenText = normalizeCitationText(children); const displayText = childrenText ?? ""; const referenceId = href?.startsWith("#reference-") @@ -28,6 +33,11 @@ export function CitationLink({ )} onClick={(event) => { event.preventDefault(); + if (onReferenceClick) { + onReferenceClick(referenceId); + onClick?.(event); + return; + } const batchId = event.currentTarget.closest("[data-reference-batch-id]") ?.dataset.referenceBatchId; @@ -41,6 +51,7 @@ export function CitationLink({ target?.scrollIntoView({ behavior: "smooth", block: "center" }); }; window.setTimeout(scroll, 240); + onClick?.(event); }} {...props} > @@ -58,6 +69,7 @@ export function CitationLink({ "text-primary decoration-primary/30 hover:decoration-primary/60 underline underline-offset-2 transition-colors", className, )} + onClick={onClick} {...props} > {displayText || children} diff --git a/frontend-web/src/components/workspace/input-box.tsx b/frontend-web/src/components/workspace/input-box.tsx index da77009e..6b9f815d 100644 --- a/frontend-web/src/components/workspace/input-box.tsx +++ b/frontend-web/src/components/workspace/input-box.tsx @@ -210,6 +210,9 @@ export function InputBox({ const { models } = useModels(); const singleRoundRagActive = context.execution_profile === "single_round_rag"; + const multiRoundRagActive = + context.execution_profile === "multi_round_rag"; + const frontendRagActive = singleRoundRagActive || multiRoundRagActive; const modelOptions = useMemo(() => { return singleRoundRagActive ? extraModels : models; }, [extraModels, models, singleRoundRagActive]); @@ -235,7 +238,12 @@ export function InputBox({ !userPreferences.hide_admin_prompt_prefix && recommendedQuestions.length > 0; - useEffect(() => { + useEffect(() => { + // Multi-round RAG is a frontend execution profile, not a model option. + // Do not let the normal model fallback clear it on every render. + if (multiRoundRagActive) { + return; + } if (modelOptions.length === 0) { return; } @@ -259,7 +267,7 @@ export function InputBox({ mode: nextMode, execution_profile: fallbackModel.execution_profile, }); - }, [context, modelOptions, onContextChange]); + }, [context, modelOptions, multiRoundRagActive, onContextChange]); const selectedModel = useMemo(() => { if (modelOptions.length === 0) { @@ -342,6 +350,13 @@ export function InputBox({ execution_profile: "single_round_rag", }); }, [context, extraModels, onContextChange]); + + const handleMultiRoundRagSelect = useCallback(() => { + onContextChange?.({ + ...context, + execution_profile: "multi_round_rag", + }); + }, [context, onContextChange]); const handleReasoningEffortSelect = useCallback( (effort: "minimal" | "low" | "medium" | "high") => { @@ -712,14 +727,16 @@ export function InputBox({ /> */} - - + )} + {!frontendRagActive && 写作 - - } + {!frontendRagActive && 记忆 - - } + {!frontendRagActive && 参考文献 - - } + {!frontendRagActive && 上下文压缩 - + }
- {selectedSingleRoundRag ? ( + {frontendRagActive ? ( ) : context.mode === "flash" ? ( @@ -881,8 +900,10 @@ export function InputBox({ context.mode === "ultra" ? "golden-text" : "", )} > - {selectedSingleRoundRag - ? "单轮 RAG" + {frontendRagActive + ? multiRoundRagActive + ? "多轮 RAG" + : "单轮 RAG" : (context.mode === "flash" && t.inputBox.flashMode) || (context.mode === "thinking" && t.inputBox.reasoningMode) || @@ -899,7 +920,7 @@ export function InputBox({ @@ -920,7 +941,7 @@ export function InputBox({ {t.inputBox.flashModeDescription}
- {!selectedSingleRoundRag && context.mode === "flash" ? ( + {!frontendRagActive && context.mode === "flash" ? ( ) : (
@@ -929,7 +950,7 @@ export function InputBox({ {supportThinking && ( @@ -950,7 +971,7 @@ export function InputBox({ {t.inputBox.reasoningModeDescription}
- {!selectedSingleRoundRag && context.mode === "thinking" ? ( + {!frontendRagActive && context.mode === "thinking" ? ( ) : (
@@ -959,7 +980,7 @@ export function InputBox({ )} @@ -980,7 +1001,7 @@ export function InputBox({ {t.inputBox.proModeDescription}
- {!selectedSingleRoundRag && context.mode === "pro" ? ( + {!frontendRagActive && context.mode === "pro" ? ( ) : (
@@ -988,7 +1009,7 @@ export function InputBox({
- {!selectedSingleRoundRag && context.mode === "ultra" ? ( + {!frontendRagActive && context.mode === "ultra" ? ( ) : (
@@ -1057,11 +1078,39 @@ export function InputBox({ )} + +
+
+ + 多轮 RAG 问答 +
+
+ 多次检索、展示过程与参考资料,适合复杂问题 +
+
+ {multiRoundRagActive ? ( + + ) : ( +
+ )} + - {supportReasoningEffort && context.mode !== "flash" && ( + {!frontendRagActive && supportReasoningEffort && context.mode !== "flash" && (
@@ -1179,8 +1228,8 @@ export function InputBox({ )} - {knowledgeSelector} - {agentSelector && ( + {!frontendRagActive && agentSelector && ( + {!frontendRagActive && knowledgeSelector} )} - {promptPrefix && ( + {!frontendRagActive && promptPrefix && ( )} - @@ -1266,7 +1315,7 @@ export function InputBox({ ))} - + } (null); + const resolveArtifact = useArtifactResolver(threadId); + const handleCitationClick = useCallback( + (referenceId: string) => { + const referenceIndex = Number(referenceId); + if (!Number.isInteger(referenceIndex) || referenceIndex < 1) return; + const source = + displaySources.find((item) => item.index === referenceIndex) ?? + displaySources[referenceIndex - 1]; + if (source) setSelectedCitation(source); + }, + [displaySources], + ); + const components = useMemo( () => ({ img: (props: ImgHTMLAttributes) => ( ), - a: ({ href, ...props }: AnchorHTMLAttributes) => { - if (href?.startsWith('#reference-')) { - return ; - } + a: ({ href, ...props }: AnchorHTMLAttributes) => { + if (href?.startsWith('#reference-')) { + return ( + + ); + } if ( typeof props.children === 'string' && /^citation:/i.test(props.children) @@ -593,8 +643,14 @@ function MessageContent_({ return ; }, }), - [threadId, resolveArtifact], - ); + [ + handleCitationClick, + isMultiRoundRag, + isSingleRoundRag, + resolveArtifact, + threadId, + ], + ); // Question carries its own `client_ts` (round-trips through the checkpoint). // The AI answer has no timestamp of its own, so it inherits the turn's ask @@ -661,7 +717,7 @@ function MessageContent_({ } // Reasoning-only AI message (no main response content yet) - if (!isHuman && reasoningContent && !rawContent) { + if (!isHuman && reasoningContent && !rawContent && !multiRoundTrace) { return ( @@ -707,32 +763,284 @@ function MessageContent_({ ); } - return ( - - {filesList} -
- -
- - {!isLoading && contentToDisplay ? ( - - ) : null} -
- ); -} - -function EmbeddedPageContextCard({ + return ( + <> + + {filesList} +
+ {multiRoundTrace ? ( + + ) : ( + + )} +
+ + {!isLoading && contentToDisplay ? ( + + ) : null} +
+ {(isSingleRoundRag || isMultiRoundRag) && ( + { + if (!open) setSelectedCitation(null); + }} + /> + )} + + ); +} + +function SingleRoundRagCitationDialog({ + source, + onOpenChange, +}: { + source: RuntimeDisplaySource | null; + onOpenChange: (open: boolean) => void; +}) { + const sourceUrl = resolveSingleRoundRagSourceUrl(source); + return ( + + + + + {source?.title || "引用资料"} + + + 来源:{source?.source || source?.type || "未知来源"} + {source?.time && 时间:{source.time}} + + + {sourceUrl && ( +
+ + 查看原文链接 + + )} +
+ {source?.content || source?.snippet || "暂无引用内容"} +
+ + + ); +} + +function MultiRoundRagContent({ + trace, + content, + displaySources, + isLoading, + rehypePlugins, + components, +}: { + trace: NonNullable>; + content: string; + displaySources: RuntimeDisplaySource[]; + isLoading: boolean; + rehypePlugins: MarkdownContentProps['rehypePlugins']; + components: MarkdownContentProps['components']; +}) { + const [open, setOpen] = useState(!trace.isComplete); + useEffect(() => { + if (!trace.isComplete) setOpen(true); + }, [trace.isComplete]); + + return ( +
+
+ + {open && ( +
+ {trace.rounds.map((round, index) => ( + + ))} +
+ )} +
+
+
最终回答
+ {content ? ( + + ) : ( +
+ + 正在生成最终回答… +
+ )} +
+
+ ); +} + +function MultiRoundRagRoundCard({ + index, + round, + isLoading, + rehypePlugins, + components, +}: { + index: number; + round: MultiRoundRagRound; + isLoading: boolean; + rehypePlugins: MarkdownContentProps['rehypePlugins']; + components: MarkdownContentProps['components']; +}) { + return ( +
+
+ + {index} + + {round.title} + {round.isSearching && } +
+ {round.thought && ( +
+
思考内容
+ +
+ )} + {round.body && ( +
+
本轮正文
+ +
+ )} + {round.query && ( +
+ + {round.query} +
+ )} + {round.retrievals.length > 0 && ( +
+
+ 检索内容 · {round.retrievals.length} 条 +
+
+ {round.retrievals.map((item, resultIndex) => ( +
+ + {resultIndex + 1}. + {item.title} + +
+ {item.content || '暂无资料摘要'} +
+ {(item.source || item.time) && ( +
+ {[item.source, item.time].filter(Boolean).join(' · ')} +
+ )} +
+ ))} +
+
+ )} +
+ ); +} + +function resolveSingleRoundRagSourceUrl( + source: RuntimeDisplaySource | null, +): string | undefined { + if (!source) return undefined; + if (source.url && /^https?:\/\//i.test(source.url)) return source.url; + + const raw = source.raw ?? {}; + const recordId = + source.id ?? + readString(raw.recUuid) ?? + readString(raw.rec_uuid) ?? + readString(raw.uuid); + const knowledgeUrl = readRuntimeEnv("VITE_AIPI_KNOWLEDGE_URL")?.replace(/\/$/, ""); + if (!knowledgeUrl || !recordId) return undefined; + return `${knowledgeUrl}/#/knowledge/mid-detail?recUuid=${encodeURIComponent(recordId)}`; +} + +function readString(value: unknown): string | undefined { + if (typeof value === "string" && value.trim()) return value; + if (typeof value === "number" && Number.isFinite(value)) return String(value); + return undefined; +} + +function EmbeddedPageContextCard({ pageContext, }: { pageContext: EmbeddedPageContext; diff --git a/frontend-web/src/components/workspace/messages/message-list.tsx b/frontend-web/src/components/workspace/messages/message-list.tsx index d96ce5fa..ee5a2147 100644 --- a/frontend-web/src/components/workspace/messages/message-list.tsx +++ b/frontend-web/src/components/workspace/messages/message-list.tsx @@ -35,6 +35,7 @@ import type { Subtask } from "@/core/tasks"; import { useUpdateSubtask } from "@/core/tasks/context"; import type { AgentThreadState } from "@/core/threads"; import { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag"; +import { isMultiRoundRagMessage } from "@/core/threads/use-multi-round-rag"; import { cn } from "@/lib/utils"; import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification"; @@ -239,7 +240,11 @@ export function MessageList({ const ragModeEnabled = settings.display.ragModeEnabled || isThreadRagModeLocked(threadId); const hasSingleRoundRag = useMemo( - () => messages.some(isSingleRoundRagMessage), + () => + messages.some( + (message) => + isSingleRoundRagMessage(message) || isMultiRoundRagMessage(message), + ), [messages], ); const referencesEnabled = ragModeEnabled || hasSingleRoundRag; diff --git a/frontend-web/src/core/models/single-round-rag.ts b/frontend-web/src/core/models/single-round-rag.ts index 7591a2da..99696517 100644 --- a/frontend-web/src/core/models/single-round-rag.ts +++ b/frontend-web/src/core/models/single-round-rag.ts @@ -19,3 +19,18 @@ export function isSingleRoundRagProfile( "single_round_rag" ); } + +export function isMultiRoundRagProfile( + context: unknown, +): boolean { + return ( + typeof context === "object" && + context !== null && + (context as { execution_profile?: unknown }).execution_profile === + "multi_round_rag" + ); +} + +export function isFrontendRagProfile(context: unknown): boolean { + return isSingleRoundRagProfile(context) || isMultiRoundRagProfile(context); +} diff --git a/frontend-web/src/core/models/types.ts b/frontend-web/src/core/models/types.ts index a470b2e8..47410d7d 100644 --- a/frontend-web/src/core/models/types.ts +++ b/frontend-web/src/core/models/types.ts @@ -7,7 +7,7 @@ export interface Model { supports_thinking?: boolean; supports_reasoning_effort?: boolean; /** Execution profiles handled by the frontend instead of DeerFlow. */ - execution_profile?: "single_round_rag"; + execution_profile?: "single_round_rag" | "multi_round_rag"; } export interface TokenUsageSettings { diff --git a/frontend-web/src/core/skill-display/inline-citations.test.ts b/frontend-web/src/core/skill-display/inline-citations.test.ts index db6bf1d2..0b4f3cc1 100644 --- a/frontend-web/src/core/skill-display/inline-citations.test.ts +++ b/frontend-web/src/core/skill-display/inline-citations.test.ts @@ -71,6 +71,12 @@ test("keeps half-width syntax working", () => { assert.equal(out.includes(citation(3)), true); }); +test("converts double-bracket RAG references", () => { + const out = applyInlineCitations("RAG answer [[1]] and [[2]].", makeSources(2)); + assert.equal(out.includes(citation(1)), true); + assert.equal(out.includes(citation(2)), true); +}); + test("accepts spaced and full-width square brackets", () => { const out = applyInlineCitations("参考[ 1 ]和[2]。", makeSources(2)); assert.equal(out.includes(citation(1)), true); diff --git a/frontend-web/src/core/threads/types.ts b/frontend-web/src/core/threads/types.ts index 49dc31ac..9639a63e 100644 --- a/frontend-web/src/core/threads/types.ts +++ b/frontend-web/src/core/threads/types.ts @@ -27,8 +27,8 @@ export interface AgentThreadContext extends Record { knowledge_rag_enabled?: boolean; /** DeerFlow LLMWiki mapping ids selected for this conversation. */ llmwiki_knowledge_base_ids?: string[]; - /** Direct knowledge-base RAG mode; this request never enters Lead Agent. */ - execution_profile?: "single_round_rag"; + /** Direct RAG modes handled in the frontend; these requests never enter Lead Agent. */ + execution_profile?: "single_round_rag" | "multi_round_rag"; agent_id?: string; /** @deprecated kept for reading legacy thread metadata; prefer agent_id */ agent_name?: string; diff --git a/frontend-web/src/core/threads/use-multi-round-rag.ts b/frontend-web/src/core/threads/use-multi-round-rag.ts new file mode 100644 index 00000000..ee8ec224 --- /dev/null +++ b/frontend-web/src/core/threads/use-multi-round-rag.ts @@ -0,0 +1,483 @@ +import type { Message } from "@langchain/langgraph-sdk"; +import { useCallback, useEffect, useRef, useState } from "react"; + +import { readRuntimeEnv } from "@/env"; + +const STORAGE_PREFIX = "workspace.multi-round-rag."; + +export type MultiRoundRagRetrieval = { + id?: string; + title: string; + content: string; + source?: string; + time?: string; + recUuid?: string; + url?: string; +}; + +export type MultiRoundRagRound = { + id: string; + title: string; + thought: string; + /** Model text emitted for this round, before its retrieval results. */ + body: string; + query: string; + isSearching: boolean; + retrievals: MultiRoundRagRetrieval[]; +}; + +export type MultiRoundRagTrace = { + rounds: MultiRoundRagRound[]; + answer: string; + isComplete: boolean; +}; + +type MultiRoundRagSource = { + id?: string; + title: string; + type: string; + snippet: string; + content: string; + source?: string; + time?: string; + url?: string; + raw: Record; +}; + +type MultiRoundEvent = { + event?: unknown; + data?: unknown; +}; + +function storageKey(threadId: string) { + return `${STORAGE_PREFIX}${threadId}`; +} + +function readStoredMessages(threadId: string): Message[] { + if (typeof window === "undefined" || !threadId) return []; + try { + const raw = window.sessionStorage.getItem(storageKey(threadId)); + const parsed: unknown = raw ? JSON.parse(raw) : []; + return Array.isArray(parsed) ? (parsed as Message[]) : []; + } catch { + return []; + } +} + +function writeStoredMessages(threadId: string, messages: Message[]) { + if (typeof window === "undefined" || !threadId) return; + try { + window.sessionStorage.setItem(storageKey(threadId), JSON.stringify(messages)); + } catch { + // Storage quota failures must not interrupt a running answer. + } +} + +function createId(prefix: string) { + return `${prefix}-${crypto.randomUUID()}`; +} + +function isAbortError(error: unknown) { + return error instanceof DOMException && error.name === "AbortError"; +} + +function asRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function asString(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +function parseJson(value: unknown): unknown { + if (typeof value !== "string") return value; + try { + return JSON.parse(value) as unknown; + } catch { + return value; + } +} + +function parseEventLine(line: string): MultiRoundEvent | null { + const trimmed = line.trim(); + if (!trimmed.startsWith("data:")) return null; + try { + return JSON.parse(trimmed.slice("data:".length).trim()) as MultiRoundEvent; + } catch { + return null; + } +} + +function initialTrace(): MultiRoundRagTrace { + return { + rounds: [ + { + id: createId("multi-rag-round"), + title: "正在准备检索", + thought: "", + body: "", + query: "", + isSearching: true, + retrievals: [], + }, + ], + answer: "", + isComplete: false, + }; +} + +function latestRound(trace: MultiRoundRagTrace): MultiRoundRagRound { + const current = trace.rounds.at(-1); + if (current) return current; + const next = initialTrace().rounds[0]!; + trace.rounds.push(next); + return next; +} + +function addThinkingRound(trace: MultiRoundRagTrace, thought: string) { + const current = latestRound(trace); + if (current.title === "正在准备检索" && !current.thought && !current.query) { + current.title = "思考过程"; + current.thought = thought; + return; + } + current.isSearching = false; + trace.rounds.push({ + id: createId("multi-rag-round"), + title: "思考过程", + thought, + body: "", + query: "", + isSearching: true, + retrievals: [], + }); +} + +function appendRoundBody(trace: MultiRoundRagTrace, body: string) { + if (!body) return; + const current = latestRound(trace); + // Stored traces created before `body` was introduced remain readable. + current.body = `${current.body ?? ""}${body}`; +} + +function mapRetrievals(value: unknown): MultiRoundRagRetrieval[] { + const root = asRecord(parseJson(value)); + const list = + (Array.isArray(root?.results) && root.results) || + (Array.isArray(root?.organic) && root.organic) || + []; + return list.flatMap((item, index) => { + const record = asRecord(item); + if (!record) return []; + const recUuid = asString(record.recUuid ?? record.rec_uuid); + const url = asString(record.url ?? record.link ?? record.href); + return [ + { + id: asString(record.id) || recUuid || undefined, + title: asString(record.title) || `检索资料 ${index + 1}`, + content: asString(record.content ?? record.snippet ?? record.text), + source: asString(record.source ?? record.source1 ?? url) || undefined, + time: asString(record.publish_time ?? record.m_publish ?? record.date) || undefined, + recUuid: recUuid || undefined, + url: /^https?:\/\//i.test(url) ? url : undefined, + }, + ]; + }); +} + +function mapReferences(value: unknown): MultiRoundRagSource[] { + const record = asRecord(value); + const list = Array.isArray(record?.references) ? record.references : []; + return list.flatMap((item, index) => { + const source = asRecord(item); + if (!source) return []; + const content = asString(source.content ?? source.snippet); + const rawUrl = asString(source.url ?? source.link ?? source.href); + const recUuid = asString(source.recUuid ?? source.rec_uuid ?? source.uuid); + return [ + { + id: asString(source.id) || recUuid || undefined, + title: asString(source.title) || `参考资料 ${index + 1}`, + type: asString(source.type) || "参考资料", + snippet: asString(source.snippet) || content, + content, + source: asString(source.source ?? source.source1) || undefined, + time: asString(source.time ?? source.m_publish ?? source.publish_time) || undefined, + url: /^https?:\/\//i.test(rawUrl) ? rawUrl : undefined, + raw: source, + }, + ]; + }); +} + +function cloneTrace(trace: MultiRoundRagTrace): MultiRoundRagTrace { + return { + ...trace, + rounds: trace.rounds.map((round) => ({ + ...round, + retrievals: [...round.retrievals], + })), + }; +} + +function isSingleRoundRagMessage(message: Message) { + return message.additional_kwargs?.single_round_rag === true; +} + +export function isMultiRoundRagMessage(message: Message): boolean { + return message.additional_kwargs?.multi_round_rag === true; +} + +export function getMultiRoundRagTrace( + message: Message, +): MultiRoundRagTrace | null { + const trace = message.additional_kwargs?.multi_round_rag_trace; + return asRecord(trace) + ? (trace as unknown as MultiRoundRagTrace) + : null; +} + +/** + * Frontend adapter for GeneralQA's multi-round endpoint. It keeps the external + * SSE protocol outside DeerFlow and stores only its UI messages in sessionStorage. + */ +export function useMultiRoundRagMessages(threadId: string) { + const [messages, setMessages] = useState(() => readStoredMessages(threadId)); + const messagesRef = useRef(messages); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const controllerRef = useRef(null); + + useEffect(() => { + const stored = readStoredMessages(threadId); + messagesRef.current = stored; + setMessages(stored); + setError(null); + }, [threadId]); + + useEffect(() => { + messagesRef.current = messages; + }, [messages]); + + useEffect(() => () => controllerRef.current?.abort(), []); + + const updateMessages = useCallback( + (targetThreadId: string, updater: (current: Message[]) => Message[]) => { + setMessages((current) => { + const next = updater(current); + messagesRef.current = next; + writeStoredMessages(targetThreadId, next); + return next; + }); + }, + [], + ); + + const stop = useCallback(() => controllerRef.current?.abort(), []); + + const submit = useCallback( + async ({ targetThreadId, query }: { targetThreadId: string; query: string }) => { + if (isLoading) return; + const question = query.trim(); + if (!question) return; + + const humanId = createId("multi-rag-human"); + const assistantId = createId("multi-rag-ai"); + const sourceId = createId("multi-rag-sources"); + const trace = initialTrace(); + let sources: MultiRoundRagSource[] = []; + const clientTs = new Date().toISOString(); + const userMessage: Message = { + id: humanId, + type: "human", + content: [{ type: "text", text: question }], + additional_kwargs: { client_ts: clientTs, multi_round_rag: true }, + }; + const assistantMessage: Message = { + id: assistantId, + type: "ai", + content: "", + additional_kwargs: { + multi_round_rag: true, + multi_round_rag_trace: trace, + }, + }; + updateMessages(targetThreadId, (current) => [ + ...current, + userMessage, + assistantMessage, + ]); + setError(null); + setIsLoading(true); + + const controller = new AbortController(); + controllerRef.current = controller; + const refreshAssistant = () => { + const traceSnapshot = cloneTrace(trace); + updateMessages(targetThreadId, (current) => { + const next = current.filter((message) => message.id !== sourceId); + const assistantIndex = next.findIndex((message) => message.id === assistantId); + if (assistantIndex < 0) return current; + next[assistantIndex] = { + ...next[assistantIndex], + content: traceSnapshot.answer, + additional_kwargs: { + ...next[assistantIndex]!.additional_kwargs, + multi_round_rag: true, + multi_round_rag_trace: traceSnapshot, + reasoning_content: traceSnapshot.rounds + .map((round) => round.thought) + .filter(Boolean) + .join("\n\n") || null, + }, + } as Message; + if (sources.length > 0) { + next.splice(assistantIndex, 0, { + id: sourceId, + type: "tool", + name: "multi_round_rag_references", + content: { results: sources }, + additional_kwargs: { + hide_from_ui: true, + multi_round_rag: true, + }, + } as unknown as Message); + } + return next; + }); + }; + + try { + const baseUrl = readRuntimeEnv("VITE_AIPI_GENERATE_CHAT_URL") || ""; + if (!baseUrl) { + throw new Error("未配置 VITE_AIPI_GENERATE_CHAT_URL,无法发起多轮 RAG 问答。"); + } + const response = await fetch(`${baseUrl.replace(/\/$/, "")}/generate`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + signal: controller.signal, + body: JSON.stringify({ prompt: question }), + }); + if (!response.ok) { + throw new Error(`多轮 RAG 服务请求失败(${response.status})。`); + } + if (!response.body) { + throw new Error("多轮 RAG 服务未返回流式响应。"); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split(/\r?\n/); + buffer = lines.pop() ?? ""; + for (const line of lines) { + const event = parseEventLine(line); + if (!event) continue; + const eventType = asString(event.event); + const data = event.data; + if (eventType === "tool_call") { + const tool = asRecord(data); + const toolName = asString(tool?.tool_name); + const input = asRecord(tool?.tool_input) ?? {}; + const current = latestRound(trace); + if (toolName === "show_text") { + const text = asString(input.text); + if (text.includes("")) { + const thinkEnd = text.indexOf(""); + addThinkingRound( + trace, + (thinkEnd >= 0 + ? text.slice(0, thinkEnd + "".length) + : text + ) + .replace(/<\/?think>/g, "") + .trim(), + ); + if (thinkEnd >= 0) { + const roundBody = text + .slice(thinkEnd + "".length) + .trim(); + if (roundBody) { + appendRoundBody(trace, roundBody); + } + } + } else { + // A non-thinking `show_text` immediately after a thought is + // this round's narrative; the terminal `message` event is + // still rendered only as the final answer below all rounds. + current.isSearching = false; + trace.answer += text; + trace.isComplete = true; + } + } else if ( + toolName === "local_search" || + toolName === "tavily_search" || + toolName === "google_search" + ) { + const queryData = asRecord(parseJson(input.result)); + current.title = + toolName === "local_search" ? "本地知识检索" : "联网检索"; + current.query = + asString(input.q ?? input.query) || + asString(queryData?.query) || + current.query; + const retrievals = mapRetrievals(input.result); + if (retrievals.length > 0) current.retrievals = retrievals; + current.isSearching = false; + } else if (toolName === "get_textid_content") { + const article = asRecord(parseJson(input.result)); + const body = + asString(article?.content ?? article?.text) || + asString(input.content); + if (body) appendRoundBody(trace, body); + current.isSearching = false; + } + refreshAssistant(); + } else if (eventType === "message" && typeof data === "string") { + latestRound(trace).isSearching = false; + trace.answer += data; + trace.isComplete = true; + refreshAssistant(); + } else if (eventType === "references") { + sources = mapReferences(data); + refreshAssistant(); + } else if (eventType === "done") { + trace.isComplete = true; + for (const round of trace.rounds) round.isSearching = false; + refreshAssistant(); + } + } + } + trace.isComplete = true; + for (const round of trace.rounds) round.isSearching = false; + refreshAssistant(); + } catch (caught) { + trace.isComplete = true; + for (const round of trace.rounds) round.isSearching = false; + if (!isAbortError(caught)) { + const message = + caught instanceof Error ? caught.message : "多轮 RAG 问答失败。"; + setError(message); + if (!trace.answer) trace.answer = message; + } + refreshAssistant(); + } finally { + if (controllerRef.current === controller) controllerRef.current = null; + setIsLoading(false); + } + }, + [isLoading, updateMessages], + ); + + return { messages, isLoading, error, submit, stop }; +} + +export function isFrontendRagMessage(message: Message): boolean { + return isSingleRoundRagMessage(message) || isMultiRoundRagMessage(message); +} diff --git a/frontend-web/src/core/threads/use-single-round-rag.ts b/frontend-web/src/core/threads/use-single-round-rag.ts index 7287a315..e90d5fc8 100644 --- a/frontend-web/src/core/threads/use-single-round-rag.ts +++ b/frontend-web/src/core/threads/use-single-round-rag.ts @@ -22,6 +22,13 @@ type RagSource = { raw: Record; }; +type RagHistoryEntry = { + role: "user" | "assistant"; + content: string; +}; + +const MAX_HISTORY_MESSAGES = 12; + /** Same JSON-object framing used by the established GeneralQA single-RAG flow. */ function extractJsonObjects(raw: string): { objects: string[]; rest: string } { const objects: string[] = []; @@ -199,6 +206,44 @@ function isAbortError(error: unknown) { return error instanceof DOMException && error.name === "AbortError"; } +function textFromMessage(message: Message): string { + if (typeof message.content === "string") { + return message.content.trim(); + } + if (!Array.isArray(message.content)) { + return ""; + } + return message.content + .map((part) => { + if (typeof part === "string") return part; + if (!part || typeof part !== "object") return ""; + const text = (part as { text?: unknown }).text; + return typeof text === "string" ? text : ""; + }) + .join("\n") + .trim(); +} + +/** + * The general-QA endpoint accepts the usual `{ role, content }` history + * format. Keep it scoped to completed single-round-RAG turns so a normal + * DeerFlow conversation never leaks into this external endpoint. + */ +function buildRagHistory(messages: Message[]): RagHistoryEntry[] { + return messages + .filter( + (message) => + message.additional_kwargs?.single_round_rag === true && + (message.type === "human" || message.type === "ai"), + ) + .map((message): RagHistoryEntry => ({ + role: message.type === "human" ? "user" : "assistant", + content: textFromMessage(message), + })) + .filter((entry) => entry.content.length > 0) + .slice(-MAX_HISTORY_MESSAGES); +} + export function isSingleRoundRagMessage(message: Message): boolean { return message.additional_kwargs?.single_round_rag === true; } @@ -209,21 +254,29 @@ export function isSingleRoundRagMessage(message: Message): boolean { */ export function useSingleRoundRagMessages(threadId: string) { const [messages, setMessages] = useState(() => readStoredMessages(threadId)); + const messagesRef = useRef(messages); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const controllerRef = useRef(null); useEffect(() => { - setMessages(readStoredMessages(threadId)); + const stored = readStoredMessages(threadId); + messagesRef.current = stored; + setMessages(stored); setError(null); }, [threadId]); + useEffect(() => { + messagesRef.current = messages; + }, [messages]); + useEffect(() => () => controllerRef.current?.abort(), []); const updateMessages = useCallback( (targetThreadId: string, updater: (current: Message[]) => Message[]) => { setMessages((current) => { const next = updater(current); + messagesRef.current = next; writeStoredMessages(targetThreadId, next); return next; }); @@ -251,6 +304,7 @@ export function useSingleRoundRagMessages(threadId: string) { const assistantId = createId("single-rag-ai"); const sourceId = createId("single-rag-sources"); const clientTs = new Date().toISOString(); + const history = buildRagHistory(messagesRef.current); const userMessage: Message = { id: humanId, type: "human", @@ -319,7 +373,7 @@ export function useSingleRoundRagMessages(threadId: string) { knowledge_base_name: [], top_k: 5, score_threshold: 0.5, - history: [], + history, stream: true, model_name: modelName, temperature: 0.3, diff --git a/frontend-web/src/pages/ChatPage.tsx b/frontend-web/src/pages/ChatPage.tsx index 9d136ad0..ee708752 100644 --- a/frontend-web/src/pages/ChatPage.tsx +++ b/frontend-web/src/pages/ChatPage.tsx @@ -46,6 +46,8 @@ import { useAgentSelector, useAgentsPaginated } from "@/core/agents"; import { useI18n } from "@/core/i18n/hooks"; import { useModels } from "@/core/models/hooks"; import { + isFrontendRagProfile, + isMultiRoundRagProfile, isSingleRoundRagProfile, SINGLE_ROUND_RAG_MODELS, } from "@/core/models/single-round-rag"; @@ -59,6 +61,7 @@ import { import { useThreadStream } from "@/core/threads/hooks"; import { queueLlmWikiConversationDeposit } from "@/core/llmwiki/conversation-deposit"; import { useSingleRoundRagMessages } from "@/core/threads/use-single-round-rag"; +import { useMultiRoundRagMessages } from "@/core/threads/use-multi-round-rag"; import { useLayoutMode } from "@/core/system-settings/hooks"; import { useRouteBase } from "@/core/route-base"; import { textOfMessage } from "@/core/threads/utils"; @@ -310,19 +313,38 @@ export default function ChatPage() { }, }); const singleRoundRag = useSingleRoundRagMessages(threadId); + const multiRoundRag = useMultiRoundRagMessages(threadId); const singleRoundRagSelected = isSingleRoundRagProfile(settings.context); + const multiRoundRagSelected = isMultiRoundRagProfile(settings.context); + const frontendRagSelected = isFrontendRagProfile(settings.context); const displayThread = useMemo(() => { - if (singleRoundRag.messages.length === 0 && !singleRoundRag.isLoading) { + if ( + singleRoundRag.messages.length === 0 && + multiRoundRag.messages.length === 0 && + !singleRoundRag.isLoading && + !multiRoundRag.isLoading + ) { return thread; } - const messages = [...thread.messages, ...singleRoundRag.messages]; + const messages = [ + ...thread.messages, + ...singleRoundRag.messages, + ...multiRoundRag.messages, + ]; return { ...thread, values: { ...thread.values, messages }, messages, - isLoading: thread.isLoading || singleRoundRag.isLoading, + isLoading: + thread.isLoading || singleRoundRag.isLoading || multiRoundRag.isLoading, } as typeof thread; - }, [singleRoundRag.isLoading, singleRoundRag.messages, thread]); + }, [ + multiRoundRag.isLoading, + multiRoundRag.messages, + singleRoundRag.isLoading, + singleRoundRag.messages, + thread, + ]); const writingArtifactPath = useMemo(() => { const isMarkdown = (path: string | null | undefined) => @@ -337,9 +359,9 @@ export default function ChatPage() { const handleSubmit = useCallback( (message: PromptInputMessage) => { - if (singleRoundRagSelected) { + if (frontendRagSelected) { if (message.files && message.files.length > 0) { - toast.error("单轮 RAG 问答暂不支持附件,请移除附件后重试。"); + toast.error("RAG 问答暂不支持附件,请移除附件后重试。"); return; } setHasStartedConversation(true); @@ -366,13 +388,23 @@ export default function ChatPage() { .catch(() => undefined); } } - await singleRoundRag.submit({ - targetThreadId, - query: message.text, - modelName: settings.context.model_name ?? "OpenAI3", - }); + if (multiRoundRagSelected) { + await multiRoundRag.submit({ targetThreadId, query: message.text }); + } else { + await singleRoundRag.submit({ + targetThreadId, + query: message.text, + modelName: settings.context.model_name ?? "OpenAI3", + }); + } } catch (error) { - toast.error(error instanceof Error ? error.message : "单轮 RAG 问答失败。"); + toast.error( + error instanceof Error + ? error.message + : multiRoundRagSelected + ? "多轮 RAG 问答失败。" + : "单轮 RAG 问答失败。", + ); } })(); return; @@ -483,6 +515,9 @@ export default function ChatPage() { writingArtifactPath, singleRoundRag, singleRoundRagSelected, + multiRoundRag, + multiRoundRagSelected, + frontendRagSelected, routeBase, setIsNewThread, setThreadId, @@ -580,8 +615,12 @@ export default function ChatPage() { singleRoundRag.stop(); return; } + if (multiRoundRag.isLoading) { + multiRoundRag.stop(); + return; + } await thread.stop(); - }, [singleRoundRag, thread]); + }, [multiRoundRag, singleRoundRag, thread]); const promptPrefixProp = useMemo(() => { if (!promptPrefixSettings?.enabled) return undefined; @@ -776,13 +815,18 @@ export default function ChatPage() { : "ready" } context={settings.context} - extraModels={SINGLE_ROUND_RAG_MODELS} + extraModels={ + singleRoundRagSelected + ? SINGLE_ROUND_RAG_MODELS + : undefined + } disabled={ env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" || isUploading || agentHandoffPending || knowledgeSelectionLoading || - singleRoundRag.isLoading + singleRoundRag.isLoading || + multiRoundRag.isLoading } onContextChange={(context) => setSettings("context", context)