From 783e698b6e033e154739f1e389a6e65f21b30542 Mon Sep 17 00:00:00 2001 From: fengxiangdebaozi <10845145+fengxiangdebaozi@user.noreply.gitee.com> Date: Wed, 12 Aug 2026 14:32:47 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BB=A3=E7=A0=81=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/workspace/chats/chat-box.tsx | 28 +- .../src/components/workspace/input-box.tsx | 340 ++++++++++------ .../workspace/messages/message-list.tsx | 22 +- .../components/workspace/mode-hover-guide.tsx | 31 +- .../workspace/workspace-nav-chat-list.tsx | 4 +- .../src/core/models/single-round-rag.ts | 21 + frontend-web/src/core/models/types.ts | 8 +- frontend-web/src/core/threads/types.ts | 8 +- .../src/core/threads/use-single-round-rag.ts | 378 ++++++++++++++++++ frontend-web/src/pages/ChatPage.tsx | 91 ++++- 10 files changed, 758 insertions(+), 173 deletions(-) create mode 100644 frontend-web/src/core/models/single-round-rag.ts create mode 100644 frontend-web/src/core/threads/use-single-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 295d047a..342abb5e 100644 --- a/frontend-web/src/components/workspace/chats/chat-box.tsx +++ b/frontend-web/src/components/workspace/chats/chat-box.tsx @@ -15,6 +15,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; 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 { useSkills } from "@/core/skills/hooks"; import type { SkillDisplayConfig } from "@/core/skills/type"; import { @@ -92,6 +93,13 @@ 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), + [thread.values.messages], + ); + const referencesEnabled = ragModeEnabled || hasSingleRoundRag; const { skills } = useSkills(); const { @@ -106,11 +114,11 @@ const ChatBox: React.FC<{ const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true); const skillDisplayMap = useMemo( - () => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()), - [ragModeEnabled, skills], + () => (referencesEnabled ? buildSkillDisplayMap(skills) : new Map()), + [referencesEnabled, skills], ); const fallbackReferenceBatches = useMemo(() => { - if (!ragModeEnabled) { + if (!referencesEnabled) { return []; } return buildTurnReferenceBatches( @@ -118,9 +126,9 @@ const ChatBox: React.FC<{ skillDisplayMap, thread.isLoading, ); - }, [ragModeEnabled, skillDisplayMap, thread.isLoading, thread.values.messages]); + }, [referencesEnabled, skillDisplayMap, thread.isLoading, thread.values.messages]); useEffect(() => { - if (!ragModeEnabled) { + if (!referencesEnabled) { setBackendReferenceBatches([]); return; } @@ -156,7 +164,7 @@ const ChatBox: React.FC<{ } }); return () => controller.abort(); - }, [ragModeEnabled, thread.isLoading, threadId, thread.values.messages]); + }, [referencesEnabled, thread.isLoading, threadId, thread.values.messages]); const adaptedBackendReferenceBatches = useMemo( () => adaptBackendReferenceBatches( @@ -175,7 +183,7 @@ const ChatBox: React.FC<{ [adaptedBackendReferenceBatches, fallbackReferenceBatches], ); useEffect(() => { - if (!ragModeEnabled) { + if (!referencesEnabled) { return; } const signature = JSON.stringify({ @@ -202,7 +210,7 @@ const ChatBox: React.FC<{ fallbackBatches: debugReferenceBatches(fallbackReferenceBatches), finalBatches: debugReferenceBatches(referenceBatches), }); - }, [adaptedBackendReferenceBatches, backendReferenceBatches, fallbackReferenceBatches, ragModeEnabled, referenceBatches, threadId]); + }, [adaptedBackendReferenceBatches, backendReferenceBatches, fallbackReferenceBatches, referencesEnabled, referenceBatches, threadId]); const latestReferenceBatch = referenceBatches.length > 0 ? referenceBatches[referenceBatches.length - 1]! @@ -213,7 +221,7 @@ const ChatBox: React.FC<{ const referenceSources = activeReferenceBatch?.sources ?? []; const hasCitationReferences = referenceSources.length > 0; useEffect(() => { - if (!ragModeEnabled || !activeReferenceBatch) { + if (!referencesEnabled || !activeReferenceBatch) { return; } logReferenceDebug("frontend-active-reference-batch-payload", threadId, { @@ -222,7 +230,7 @@ const ChatBox: React.FC<{ sourceCount: activeReferenceBatch.sources.length, sources: activeReferenceBatch.sources.map(debugReferenceSource), }); - }, [activeReferenceBatch, ragModeEnabled, threadId]); + }, [activeReferenceBatch, referencesEnabled, threadId]); useEffect(() => { if (hasCitationReferences) { setReferencesOpen(true); diff --git a/frontend-web/src/components/workspace/input-box.tsx b/frontend-web/src/components/workspace/input-box.tsx index b1e1d7e7..be779809 100644 --- a/frontend-web/src/components/workspace/input-box.tsx +++ b/frontend-web/src/components/workspace/input-box.tsx @@ -64,6 +64,7 @@ import { getBackendBaseURL } from "@/core/config"; import { useI18n } from "@/core/i18n/hooks"; import { isHiddenFromUIMessage } from "@/core/messages/utils"; import { useModels } from "@/core/models/hooks"; +import type { Model } from "@/core/models/types"; import type { AgentThreadContext } from "@/core/threads"; import { textOfMessage } from "@/core/threads/utils"; import { desensitize } from "@/lib/desensitize"; @@ -133,8 +134,9 @@ export function InputBox({ onSubmit, onStop, promptPrefix, - agentSelector, - assistantId, + agentSelector, + extraModels = [], + assistantId, hideAdminRecommendedQuestions, compact, ...props @@ -190,16 +192,23 @@ export function InputBox({ * homepage (``ChatPage``) passes this; ``AgentChatPage`` omits it because the * thread is already bound to one agent. */ - agentSelector?: { - selectedAgentId: string | null; - onAgentChange: (agentId: string | null) => void; - }; -}) { + agentSelector?: { + selectedAgentId: string | null; + onAgentChange: (agentId: string | null) => void; + }; + /** Page-scoped model choices implemented outside the DeerFlow model API. */ + extraModels?: Model[]; +}) { const { t } = useI18n(); const [localSettings, setLocalSettings] = useLocalSettings(); const searchParams = useSearchParams(); - const [modelDialogOpen, setModelDialogOpen] = useState(false); - const { models } = useModels(); + const [modelDialogOpen, setModelDialogOpen] = useState(false); + const { models } = useModels(); + const singleRoundRagActive = + context.execution_profile === "single_round_rag"; + const modelOptions = useMemo(() => { + return singleRoundRagActive ? extraModels : models; + }, [extraModels, models, singleRoundRagActive]); const { thread, isMock } = useThread(); const { textInput } = usePromptInputController(); const promptRootRef = useRef(null); @@ -223,34 +232,40 @@ export function InputBox({ recommendedQuestions.length > 0; useEffect(() => { - if (models.length === 0) { - return; - } - const currentModel = models.find((m) => m.name === context.model_name); - const fallbackModel = currentModel ?? models[0]!; + if (modelOptions.length === 0) { + return; + } + const currentModel = modelOptions.find((m) => m.name === context.model_name); + const fallbackModel = currentModel ?? modelOptions[0]!; const supportsThinking = fallbackModel.supports_thinking ?? false; const nextModelName = fallbackModel.name; const nextMode = getResolvedMode(context.mode, supportsThinking); - if (context.model_name === nextModelName && context.mode === nextMode) { - return; - } + if ( + context.model_name === nextModelName && + context.mode === nextMode && + context.execution_profile === fallbackModel.execution_profile + ) { + return; + } onContextChange?.({ - ...context, - model_name: nextModelName, - mode: nextMode, - }); - }, [context, models, onContextChange]); + ...context, + model_name: nextModelName, + mode: nextMode, + execution_profile: fallbackModel.execution_profile, + }); + }, [context, modelOptions, onContextChange]); + + const selectedModel = useMemo(() => { + if (modelOptions.length === 0) { + return undefined; + } + return modelOptions.find((m) => m.name === context.model_name) ?? modelOptions[0]; + }, [context.model_name, modelOptions]); - const selectedModel = useMemo(() => { - if (models.length === 0) { - return undefined; - } - return models.find((m) => m.name === context.model_name) ?? models[0]; - }, [context.model_name, models]); - - const resolvedModelName = selectedModel?.name; + const resolvedModelName = selectedModel?.name; + const selectedSingleRoundRag = singleRoundRagActive; const supportThinking = useMemo( () => selectedModel?.supports_thinking ?? false, @@ -264,38 +279,65 @@ export function InputBox({ const handleModelSelect = useCallback( (model_name: string) => { - const model = models.find((m) => m.name === model_name); + const model = modelOptions.find((m) => m.name === model_name); if (!model) { return; } onContextChange?.({ ...context, model_name, - mode: getResolvedMode(context.mode, model.supports_thinking ?? false), - reasoning_effort: context.reasoning_effort, - }); - setModelDialogOpen(false); - }, - [onContextChange, context, models], + mode: getResolvedMode(context.mode, model.supports_thinking ?? false), + reasoning_effort: context.reasoning_effort, + execution_profile: model.execution_profile, + }); + setModelDialogOpen(false); + }, + [onContextChange, context, modelOptions], ); - const handleModeSelect = useCallback( - (mode: InputMode) => { - onContextChange?.({ - ...context, - mode: getResolvedMode(mode, supportThinking), - reasoning_effort: - mode === "ultra" - ? "high" + const handleModeSelect = useCallback( + (mode: InputMode) => { + const ordinaryModel = + models.find((model) => model.name === context.model_name) ?? models[0]; + onContextChange?.({ + ...context, + model_name: ordinaryModel?.name ?? context.model_name, + mode: getResolvedMode( + mode, + ordinaryModel?.supports_thinking ?? supportThinking, + ), + reasoning_effort: + mode === "ultra" + ? "high" : mode === "pro" ? "medium" : mode === "thinking" - ? "low" - : "minimal", - }); - }, - [onContextChange, context, supportThinking], - ); + ? "low" + : "minimal", + execution_profile: undefined, + }); + }, + [onContextChange, context, models, supportThinking], + ); + + const handleSingleRoundRagSelect = useCallback(() => { + const ragModel = + extraModels.find((model) => model.name === context.model_name) ?? + extraModels[0]; + if (!ragModel) { + return; + } + + onContextChange?.({ + ...context, + model_name: ragModel.name, + mode: getResolvedMode( + context.mode, + ragModel.supports_thinking ?? false, + ), + execution_profile: "single_round_rag", + }); + }, [context, extraModels, onContextChange]); const handleReasoningEffortSelect = useCallback( (effort: "minimal" | "low" | "medium" | "high") => { @@ -361,10 +403,11 @@ export function InputBox({ onContextChange?.({ ...context, model_name: resolvedModelName, - mode: getResolvedMode( - context.mode, - selectedModel?.supports_thinking ?? false, - ), + mode: getResolvedMode( + context.mode, + selectedModel?.supports_thinking ?? false, + ), + execution_profile: selectedModel?.execution_profile, }); setTimeout(() => onSubmit?.(message), 0); return; @@ -379,7 +422,8 @@ export function InputBox({ onStop, resolvedModelName, ragModeEnabled, - selectedModel?.supports_thinking, + selectedModel?.supports_thinking, + selectedModel?.execution_profile, status, threadId, ], @@ -437,8 +481,9 @@ export function InputBox({ }, [pendingSuggestion, requestFormSubmit, textInput]); const showFollowups = - !disabled && - !isNewThread && + !disabled && + !isNewThread && + !selectedSingleRoundRag && !followupsHidden && (followupsLoading || followups.length > 0); @@ -464,7 +509,7 @@ export function InputBox({ return; } - if (disabled || isMock) { + if (disabled || isMock || selectedSingleRoundRag) { return; } @@ -526,7 +571,7 @@ export function InputBox({ }); return () => controller.abort(); - }, [context.model_name, disabled, isMock, status, thread.messages, threadId]); + }, [context.model_name, disabled, isMock, selectedSingleRoundRag, status, thread.messages, threadId]); return (
@@ -782,54 +827,64 @@ export function InputBox({ - + ? context.mode + : "flash" + } + tooltipContent={ + selectedSingleRoundRag + ? "单轮 RAG 问答:基于知识库检索生成答案,并展示参考资料" + : undefined + } + > -
- {context.mode === "flash" && } - {context.mode === "thinking" && ( - - )} - {context.mode === "pro" && ( - - )} - {context.mode === "ultra" && ( - - )} -
+ > +
+ {selectedSingleRoundRag ? ( + + ) : context.mode === "flash" ? ( + + ) : context.mode === "thinking" ? ( + + ) : context.mode === "pro" ? ( + + ) : context.mode === "ultra" ? ( + + ) : null} +
- {(context.mode === "flash" && t.inputBox.flashMode) || - (context.mode === "thinking" && - t.inputBox.reasoningMode) || - (context.mode === "pro" && t.inputBox.proMode) || - (context.mode === "ultra" && t.inputBox.ultraMode)} -
+ {selectedSingleRoundRag + ? "单轮 RAG" + : (context.mode === "flash" && t.inputBox.flashMode) || + (context.mode === "thinking" && + t.inputBox.reasoningMode) || + (context.mode === "pro" && t.inputBox.proMode) || + (context.mode === "ultra" && t.inputBox.ultraMode)} +
@@ -838,11 +893,11 @@ export function InputBox({ {t.inputBox.mode} - handleModeSelect("flash")} > @@ -850,9 +905,9 @@ export function InputBox({
{t.inputBox.flashMode} @@ -861,7 +916,7 @@ export function InputBox({ {t.inputBox.flashModeDescription}
- {context.mode === "flash" ? ( + {!selectedSingleRoundRag && context.mode === "flash" ? ( ) : (
@@ -869,9 +924,9 @@ export function InputBox({ {supportThinking && ( handleModeSelect("thinking")} @@ -880,9 +935,9 @@ export function InputBox({
{t.inputBox.reasoningMode} @@ -891,7 +946,7 @@ export function InputBox({ {t.inputBox.reasoningModeDescription}
- {context.mode === "thinking" ? ( + {!selectedSingleRoundRag && context.mode === "thinking" ? ( ) : (
@@ -899,8 +954,8 @@ export function InputBox({ )} @@ -921,15 +976,15 @@ export function InputBox({ {t.inputBox.proModeDescription}
- {context.mode === "pro" ? ( + {!selectedSingleRoundRag && context.mode === "pro" ? ( ) : (
)}
{t.inputBox.ultraMode} @@ -955,13 +1014,46 @@ export function InputBox({ {t.inputBox.ultraModeDescription}
- {context.mode === "ultra" ? ( + {!selectedSingleRoundRag && context.mode === "ultra" ? ( ) : (
)} - - + + {extraModels.length > 0 && ( + <> + + +
+
+ + 单轮 RAG 问答 +
+
+ 基于知识库检索生成答案,并展示思考内容与参考资料 +
+
+ {selectedSingleRoundRag ? ( + + ) : ( +
+ )} + + + )} + @@ -1146,7 +1238,7 @@ export function InputBox({ - {models.map((m) => ( + {modelOptions.map((m) => (
{m.display_name} - - {m.model} - + + {selectedSingleRoundRag + ? `单轮知识库问答 · ${m.name}` + : m.model} +
{m.name === context.model_name ? ( @@ -1164,8 +1258,8 @@ export function InputBox({
)} - ))} - + ))} + (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()), - [ragModeEnabled, skills], + const ragModeEnabled = + settings.display.ragModeEnabled || isThreadRagModeLocked(threadId); + const hasSingleRoundRag = useMemo( + () => messages.some(isSingleRoundRagMessage), + [messages], + ); + const referencesEnabled = ragModeEnabled || hasSingleRoundRag; + const { skills } = useSkills(); + const skillDisplayMap = useMemo( + () => (referencesEnabled ? buildSkillDisplayMap(skills) : new Map()), + [referencesEnabled, skills], ); // Same per-turn reference batches the right-side panel renders (backend // batches preferred). Inline [n] tokens must resolve against these so badge @@ -373,7 +379,7 @@ export function MessageList({ if (group.type === "human" || group.type === "assistant") { return group.messages.map((msg) => { const isAssistantMessage = - ragModeEnabled && + referencesEnabled && group.type === "assistant" && msg.type === "ai"; const batchSources = isAssistantMessage diff --git a/frontend-web/src/components/workspace/mode-hover-guide.tsx b/frontend-web/src/components/workspace/mode-hover-guide.tsx index 6a76eb5a..bb9bd24d 100644 --- a/frontend-web/src/components/workspace/mode-hover-guide.tsx +++ b/frontend-web/src/components/workspace/mode-hover-guide.tsx @@ -46,20 +46,23 @@ function getModeDescriptionKey( } } -export function ModeHoverGuide({ - mode, - children, - showTitle = true, -}: { - mode: AgentMode; - children: React.ReactNode; - /** When true, tooltip shows "ModeName: Description". When false, only description. */ - showTitle?: boolean; -}) { - const { t } = useI18n(); - const label = t.inputBox[getModeLabelKey(mode)]; - const description = t.inputBox[getModeDescriptionKey(mode)]; - const content = showTitle ? `${label}: ${description}` : description; +export function ModeHoverGuide({ + mode, + children, + showTitle = true, + tooltipContent, +}: { + mode: AgentMode; + children: React.ReactNode; + /** When true, tooltip shows "ModeName: Description". When false, only description. */ + showTitle?: boolean; + /** Overrides the standard mode tooltip for page-specific execution modes. */ + tooltipContent?: string; +}) { + const { t } = useI18n(); + const label = t.inputBox[getModeLabelKey(mode)]; + const description = t.inputBox[getModeDescriptionKey(mode)]; + const content = tooltipContent ?? (showTitle ? `${label}: ${description}` : description); return {children}; } 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 1c93ecec..9957103c 100644 --- a/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx +++ b/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx @@ -184,7 +184,7 @@ export function WorkspaceNavChatList({ - + {/* 岗位会商 - + */} { rag_mode_enabled?: boolean; /** Per-request switch for context compression (上下文压缩). Default off. */ summarization_enabled?: boolean; - /** Per-conversation switch for knowledge-base RAG injection (phase 2). */ - knowledge_rag_enabled?: boolean; - agent_id?: string; + /** Per-conversation switch for knowledge-base RAG injection (phase 2). */ + knowledge_rag_enabled?: boolean; + /** Direct knowledge-base RAG mode; this request never enters Lead Agent. */ + execution_profile?: "single_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-single-round-rag.ts b/frontend-web/src/core/threads/use-single-round-rag.ts new file mode 100644 index 00000000..7287a315 --- /dev/null +++ b/frontend-web/src/core/threads/use-single-round-rag.ts @@ -0,0 +1,378 @@ +import type { Message } from "@langchain/langgraph-sdk"; +import { useCallback, useEffect, useRef, useState } from "react"; + +import { readRuntimeEnv } from "@/env"; + +const STORAGE_PREFIX = "workspace.single-round-rag."; + +type KnowledgeBaseChunk = { + answer?: unknown; + docs?: unknown; +}; + +type RagSource = { + id?: string; + title: string; + type: string; + snippet: string; + content: string; + source?: string; + time?: string; + url?: string; + raw: Record; +}; + +/** Same JSON-object framing used by the established GeneralQA single-RAG flow. */ +function extractJsonObjects(raw: string): { objects: string[]; rest: string } { + const objects: string[] = []; + let depth = 0; + let stringQuote: '"' | "'" | null = null; + let escaped = false; + let start = -1; + for (let index = 0; index < raw.length; index += 1) { + const char = raw[index]!; + if (stringQuote) { + if (escaped) escaped = false; + else if (char === "\\") escaped = true; + else if (char === stringQuote) stringQuote = null; + continue; + } + if (char === '"' || char === "'") { + stringQuote = char; + continue; + } + if (char === "{") { + if (depth === 0) start = index; + depth += 1; + } else if (char === "}") { + depth -= 1; + if (depth === 0 && start !== -1) { + objects.push(raw.slice(start, index + 1)); + start = -1; + } + } + } + return depth > 0 && start !== -1 + ? { objects, rest: raw.slice(start) } + : { objects, rest: "" }; +} + +/** Same `` split semantics as GeneralQA, kept local to avoid loading its demo data. */ +function parseSingleRagStream(chunks: { answer: string }[]) { + let thinkingMarkdown = ""; + let answerMarkdown = ""; + let inThink = false; + let thinkingComplete = false; + for (const { answer } of chunks) { + let remaining = answer; + while (remaining) { + if (inThink) { + const endIndex = remaining.indexOf(""); + if (endIndex === -1) { + thinkingMarkdown += remaining; + remaining = ""; + } else { + thinkingMarkdown += remaining.slice(0, endIndex); + remaining = remaining.slice(endIndex + "".length); + inThink = false; + thinkingComplete = true; + } + } else { + const startIndex = remaining.indexOf(""); + if (startIndex === -1) { + answerMarkdown += remaining; + remaining = ""; + } else { + answerMarkdown += remaining.slice(0, startIndex); + remaining = remaining.slice(startIndex + "".length); + inThink = true; + } + } + } + } + return { thinkingMarkdown, answerMarkdown, thinkingComplete }; +} + +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 { + // Quota errors must never interrupt an already-running answer. + } +} + +function parseLooseStringValue(rawValue: string): string { + try { + return JSON.parse(rawValue) as string; + } catch { + const value = rawValue.trim(); + if (value.startsWith("'") && value.endsWith("'")) { + return value + .slice(1, -1) + .replace(/\\n/g, "\n") + .replace(/\\r/g, "\r") + .replace(/\\t/g, "\t") + .replace(/\\'/g, "'") + .replace(/\\\\/g, "\\"); + } + throw new Error(`无法解析知识库流片段:${rawValue}`); + } +} + +function 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) { + return { answer: parseLooseStringValue(answerMatch[1]!.trim()) }; + } + const docsMatch = objectText.match(/^\{\s*docs\s*:\s*([\s\S]*)\s*\}$/); + if (docsMatch) { + const rawValue = docsMatch[1]!.trim(); + try { + return { docs: JSON.parse(rawValue) }; + } catch { + return { docs: parseLooseStringValue(rawValue) }; + } + } + const normalizedText = objectText.replace( + /([{,]\s*)(answer|docs)(\s*:)/g, + '$1"$2"$3', + ); + return JSON.parse(normalizedText) as KnowledgeBaseChunk; + } +} + +function flattenDocs(value: unknown): Record[] { + if (Array.isArray(value)) { + return value.flatMap((item) => flattenDocs(item)); + } + if (!value || typeof value !== "object") return []; + const record = value as Record; + if ("docs" in record) return flattenDocs(record.docs); + return [record]; +} + +function normalizeSources(value: unknown): RagSource[] { + return flattenDocs(value).map((item, index) => { + const title = String(item.m_title ?? item.title ?? item.source ?? `参考资料 ${index + 1}`); + const content = String(item.m_content ?? item.content ?? item.snippet ?? ""); + const source = item.source1 ?? item.source; + const rawUrl = item.url ?? item.link ?? item.href; + return { + id: String(item.recUuid ?? item.rec_uuid ?? item.uuid ?? item.id ?? "") || undefined, + title, + type: String(item.source1 ?? item.type ?? "知识库"), + snippet: content, + content, + source: typeof source === "string" ? source : undefined, + time: typeof item.m_publish === "string" ? item.m_publish : undefined, + url: typeof rawUrl === "string" && /^https?:\/\//i.test(rawUrl) ? rawUrl : undefined, + raw: item, + }; + }); +} + +function createId(prefix: string) { + return `${prefix}-${crypto.randomUUID()}`; +} + +function isAbortError(error: unknown) { + return error instanceof DOMException && error.name === "AbortError"; +} + +export function isSingleRoundRagMessage(message: Message): boolean { + return message.additional_kwargs?.single_round_rag === true; +} + +/** + * Frontend-only adapter for the existing general-QA single-round endpoint. + * It deliberately never submits a run to DeerFlow or mutates the Lead Agent. + */ +export function useSingleRoundRagMessages(threadId: string) { + const [messages, setMessages] = useState(() => readStoredMessages(threadId)); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const controllerRef = useRef(null); + + useEffect(() => { + setMessages(readStoredMessages(threadId)); + setError(null); + }, [threadId]); + + useEffect(() => () => controllerRef.current?.abort(), []); + + const updateMessages = useCallback( + (targetThreadId: string, updater: (current: Message[]) => Message[]) => { + setMessages((current) => { + const next = updater(current); + writeStoredMessages(targetThreadId, next); + return next; + }); + }, + [], + ); + + const stop = useCallback(() => controllerRef.current?.abort(), []); + + const submit = useCallback( + async ({ + targetThreadId, + query, + modelName, + }: { + targetThreadId: string; + query: string; + modelName: string; + }) => { + if (isLoading) return; + const question = query.trim(); + if (!question) return; + + const humanId = createId("single-rag-human"); + const assistantId = createId("single-rag-ai"); + const sourceId = createId("single-rag-sources"); + const clientTs = new Date().toISOString(); + const userMessage: Message = { + id: humanId, + type: "human", + content: [{ type: "text", text: question }], + additional_kwargs: { client_ts: clientTs, single_round_rag: true }, + }; + const initialAssistant: Message = { + id: assistantId, + type: "ai", + content: "", + additional_kwargs: { single_round_rag: true }, + }; + updateMessages(targetThreadId, (current) => [...current, userMessage, initialAssistant]); + setError(null); + setIsLoading(true); + + const controller = new AbortController(); + controllerRef.current = controller; + const answerChunks: { answer: string }[] = []; + let sources: RagSource[] = []; + + const refreshAssistant = () => { + const trace = parseSingleRagStream(answerChunks); + 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: trace.answerMarkdown, + additional_kwargs: { + ...next[assistantIndex]!.additional_kwargs, + single_round_rag: true, + reasoning_content: trace.thinkingMarkdown || null, + }, + } as Message; + if (sources.length > 0) { + next.splice(assistantIndex, 0, { + id: sourceId, + type: "tool", + name: "knowledge_base_search", + content: { results: sources }, + additional_kwargs: { + hide_from_ui: true, + single_round_rag: true, + }, + } as unknown as Message); + } + return next; + }); + }; + + try { + const chatUrl = readRuntimeEnv("VITE_AIPI_CHAT_URL") || ""; + if (!chatUrl) { + throw new Error("未配置 VITE_AIPI_CHAT_URL,无法发起单轮 RAG 问答。"); + } + const response = await fetch( + `${chatUrl.replace(/\/$/, "")}/chat/knowledge_base_chat_tiaoshi`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + signal: controller.signal, + body: JSON.stringify({ + query: question, + knowledge_base_name: [], + top_k: 5, + score_threshold: 0.5, + history: [], + stream: true, + model_name: modelName, + temperature: 0.3, + prompt_name: "default", + local_doc_url: false, + }), + }, + ); + if (!response.ok) throw new Error(`知识库服务请求失败(${response.status})`); + if (!response.body) throw new Error("知识库服务未返回流式响应。"); + + 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 extracted = extractJsonObjects(buffer); + buffer = extracted.rest; + for (const jsonText of extracted.objects) { + const chunk = parseKnowledgeBaseChunk(jsonText); + if (typeof chunk.answer === "string") answerChunks.push({ answer: chunk.answer }); + if (chunk.docs !== undefined) sources = normalizeSources(chunk.docs); + refreshAssistant(); + } + } + } catch (caught) { + if (!isAbortError(caught)) { + const message = caught instanceof Error ? caught.message : "单轮 RAG 问答失败。"; + setError(message); + updateMessages(targetThreadId, (current) => + current.map((item) => + item.id === assistantId + ? ({ + ...item, + content: answerChunks.length > 0 ? item.content : message, + additional_kwargs: { + ...item.additional_kwargs, + single_round_rag: true, + }, + } as Message) + : item, + ), + ); + } + } finally { + if (controllerRef.current === controller) controllerRef.current = null; + setIsLoading(false); + } + }, + [isLoading, updateMessages], + ); + + return { messages, isLoading, error, submit, stop }; +} diff --git a/frontend-web/src/pages/ChatPage.tsx b/frontend-web/src/pages/ChatPage.tsx index 2bec4781..4e558404 100644 --- a/frontend-web/src/pages/ChatPage.tsx +++ b/frontend-web/src/pages/ChatPage.tsx @@ -44,6 +44,10 @@ import { PanelLeftOpen } from "lucide-react"; import { useAgentSelector, useAgentsPaginated } from "@/core/agents"; import { useI18n } from "@/core/i18n/hooks"; import { useModels } from "@/core/models/hooks"; +import { + isSingleRoundRagProfile, + SINGLE_ROUND_RAG_MODELS, +} from "@/core/models/single-round-rag"; import { useNotification } from "@/core/notification/hooks"; import { getLastAgentId, @@ -52,6 +56,7 @@ import { useThreadSettings, } from "@/core/settings"; import { useThreadStream } from "@/core/threads/hooks"; +import { useSingleRoundRagMessages } from "@/core/threads/use-single-round-rag"; import { useLayoutMode } from "@/core/system-settings/hooks"; import { useRouteBase } from "@/core/route-base"; import { textOfMessage } from "@/core/threads/utils"; @@ -226,6 +231,20 @@ export default function ChatPage() { } }, }); + const singleRoundRag = useSingleRoundRagMessages(threadId); + const singleRoundRagSelected = isSingleRoundRagProfile(settings.context); + const displayThread = useMemo(() => { + if (singleRoundRag.messages.length === 0 && !singleRoundRag.isLoading) { + return thread; + } + const messages = [...thread.messages, ...singleRoundRag.messages]; + return { + ...thread, + values: { ...thread.values, messages }, + messages, + isLoading: thread.isLoading || singleRoundRag.isLoading, + } as typeof thread; + }, [singleRoundRag.isLoading, singleRoundRag.messages, thread]); const writingArtifactPath = useMemo(() => { const isMarkdown = (path: string | null | undefined) => @@ -240,6 +259,46 @@ export default function ChatPage() { const handleSubmit = useCallback( (message: PromptInputMessage) => { + if (singleRoundRagSelected) { + if (message.files && message.files.length > 0) { + toast.error("单轮 RAG 问答暂不支持附件,请移除附件后重试。"); + return; + } + setHasStartedConversation(true); + window.dispatchEvent( + new CustomEvent("workspace:sandbox-resume-autoscroll"), + ); + void (async () => { + try { + let targetThreadId = threadId; + if (isNewThread) { + const created = await getAPIClient(isMock).threads.create({}); + targetThreadId = created.thread_id; + setThreadId(targetThreadId); + setIsNewThread(false); + history.replaceState( + null, + "", + `${window.location.pathname}${window.location.search}#${routeBase}/chats/${targetThreadId}`, + ); + const title = message.text.trim().replace(/\s+/g, " ").slice(0, 60); + if (title) { + void getAPIClient(isMock).threads + .updateState(targetThreadId, { values: { title } }) + .catch(() => undefined); + } + } + await singleRoundRag.submit({ + targetThreadId, + query: message.text, + modelName: settings.context.model_name ?? "OpenAI3", + }); + } catch (error) { + toast.error(error instanceof Error ? error.message : "单轮 RAG 问答失败。"); + } + })(); + return; + } // If the user picked an agent before sending the opening turn, hand off // to AgentChatPage so the conversation runs against that agent. We stash // the pending text in sessionStorage because the next/navigation shim @@ -332,7 +391,13 @@ export default function ChatPage() { isMock, router, settings.context.writing_mode, + settings.context.model_name, writingArtifactPath, + singleRoundRag, + singleRoundRagSelected, + routeBase, + setIsNewThread, + setThreadId, ], ); @@ -421,8 +486,12 @@ export default function ChatPage() { saveLastAgentId(agentId); }, []); const handleStop = useCallback(async () => { + if (singleRoundRag.isLoading) { + singleRoundRag.stop(); + return; + } await thread.stop(); - }, [thread]); + }, [singleRoundRag, thread]); const promptPrefixProp = useMemo(() => { if (!promptPrefixSettings?.enabled) return undefined; @@ -468,7 +537,7 @@ export default function ChatPage() { const compactActionsHidden = isHideChromeActive(); return ( - +
- +
{/* 仅在收起态显示「展开历史」钮;展开后由侧栏内(新对话右侧)的收起钮负责收起。 */} @@ -505,7 +574,7 @@ export default function ChatPage() { )} @@ -543,7 +612,7 @@ export default function ChatPage() {
@@ -612,15 +681,17 @@ export default function ChatPage() { status={ thread.error ? "error" - : thread.isLoading + : displayThread.isLoading ? "streaming" : "ready" } context={settings.context} + extraModels={SINGLE_ROUND_RAG_MODELS} disabled={ env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" || isUploading || - agentHandoffPending + agentHandoffPending || + singleRoundRag.isLoading } onContextChange={(context) => setSettings("context", context) @@ -664,7 +735,7 @@ export default function ChatPage() { */} {!isLandingState && ( )}