feat(frontend): add workspace single and multi round rag

This commit is contained in:
fengxiangdebaozi committed 2026-08-13 21:05:31 +08:00
1 parent 5a4881053d
commit 4f536d9b39
12 files changed
+1122 -119

No files matched your search

@@ -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);
@@ -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<HTMLElement>("[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}
@@ -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({
/>
</PromptInputActionMenuContent>
</PromptInputActionMenu> */}
<AddAttachmentsButton
className={cn(
"rounded-full border-0 bg-transparent px-2.5! shadow-none text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
compact &&
"gap-1.5! rounded-lg px-2.5! py-1! text-[11px] font-medium",
)}
/>
<Tooltip
{!frontendRagActive && (
<AddAttachmentsButton
className={cn(
"rounded-full border-0 bg-transparent px-2.5! shadow-none text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
compact &&
"gap-1.5! rounded-lg px-2.5! py-1! text-[11px] font-medium",
)}
/>
)}
{!frontendRagActive && <Tooltip
content={
context.writing_mode
? "写作模式已开启:最终输出会强制保存为 Markdown 文件"
@@ -744,8 +761,8 @@ export function InputBox({
<FileTextIcon className="size-3" />
<span className="text-xs font-normal">写作</span>
</PromptInputButton>
</Tooltip>
<Tooltip
</Tooltip>}
{!frontendRagActive && <Tooltip
content={
memoryInjectionEnabled
? "记忆注入已开启:对话前会注入历史记忆"
@@ -770,8 +787,8 @@ export function InputBox({
<BrainCircuitIcon className="size-3" />
<span className="text-xs font-normal">记忆</span>
</PromptInputButton>
</Tooltip>
<Tooltip
</Tooltip>}
{!frontendRagActive && <Tooltip
content={
ragModeEnabled
? "参考文献模式已开启:检索类结果会按编号引用展示"
@@ -800,8 +817,8 @@ export function InputBox({
<BookMarkedIcon className="size-3" />
<span className="text-xs font-normal">参考文献</span>
</PromptInputButton>
</Tooltip>
<Tooltip
</Tooltip>}
{!frontendRagActive && <Tooltip
content={
contextCompressionEnabled
? "上下文压缩已开启:超长对话会自动压缩历史以节省 token"
@@ -829,7 +846,7 @@ export function InputBox({
<ShrinkIcon className="size-3" />
<span className="text-xs font-normal">上下文压缩</span>
</PromptInputButton>
</Tooltip>
</Tooltip>}
<PromptInputActionMenu>
<ModeHoverGuide
mode={
@@ -841,9 +858,11 @@ export function InputBox({
: "flash"
}
tooltipContent={
selectedSingleRoundRag
singleRoundRagActive
? "单轮 RAG 问答:基于知识库检索生成答案,并展示参考资料"
: undefined
: multiRoundRagActive
? "多轮 RAG 问答:多次检索并展示检索过程与参考资料"
: undefined
}
>
<PromptInputActionMenuTrigger
@@ -851,7 +870,7 @@ export function InputBox({
"gap-1.5! rounded-full border-0 bg-transparent px-3! text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
compact && "rounded-lg border px-2.5! py-1! text-[11px] font-medium",
compact &&
(selectedSingleRoundRag
(frontendRagActive
? "border-emerald-200 bg-emerald-50/60 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-200"
: context.mode === "thinking"
? "border-indigo-200 bg-indigo-50/60 text-indigo-700 hover:bg-indigo-100 dark:border-indigo-700 dark:bg-indigo-950/40 dark:text-indigo-200"
@@ -863,7 +882,7 @@ export function InputBox({
)}
>
<div>
{selectedSingleRoundRag ? (
{frontendRagActive ? (
<BookMarkedIcon className="size-3" />
) : context.mode === "flash" ? (
<ZapIcon className="size-3" />
@@ -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({
<PromptInputActionMenu>
<PromptInputActionMenuItem
className={cn(
!selectedSingleRoundRag && context.mode === "flash"
!frontendRagActive && context.mode === "flash"
? "text-accent-foreground"
: "text-muted-foreground/65",
)}
@@ -910,7 +931,7 @@ export function InputBox({
<ZapIcon
className={cn(
"mr-2 size-4",
!selectedSingleRoundRag && context.mode === "flash" &&
!frontendRagActive && context.mode === "flash" &&
"text-accent-foreground",
)}
/>
@@ -920,7 +941,7 @@ export function InputBox({
{t.inputBox.flashModeDescription}
</div>
</div>
{!selectedSingleRoundRag && context.mode === "flash" ? (
{!frontendRagActive && context.mode === "flash" ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
@@ -929,7 +950,7 @@ export function InputBox({
{supportThinking && (
<PromptInputActionMenuItem
className={cn(
!selectedSingleRoundRag && context.mode === "thinking"
!frontendRagActive && context.mode === "thinking"
? "text-accent-foreground"
: "text-muted-foreground/65",
)}
@@ -940,7 +961,7 @@ export function InputBox({
<LightbulbIcon
className={cn(
"mr-2 size-4",
!selectedSingleRoundRag && context.mode === "thinking" &&
!frontendRagActive && context.mode === "thinking" &&
"text-accent-foreground",
)}
/>
@@ -950,7 +971,7 @@ export function InputBox({
{t.inputBox.reasoningModeDescription}
</div>
</div>
{!selectedSingleRoundRag && context.mode === "thinking" ? (
{!frontendRagActive && context.mode === "thinking" ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
@@ -959,7 +980,7 @@ export function InputBox({
)}
<PromptInputActionMenuItem
className={cn(
!selectedSingleRoundRag && context.mode === "pro"
!frontendRagActive && context.mode === "pro"
? "text-accent-foreground"
: "text-muted-foreground/65",
)}
@@ -970,7 +991,7 @@ export function InputBox({
<GraduationCapIcon
className={cn(
"mr-2 size-4",
!selectedSingleRoundRag && context.mode === "pro" &&
!frontendRagActive && context.mode === "pro" &&
"text-accent-foreground",
)}
/>
@@ -980,7 +1001,7 @@ export function InputBox({
{t.inputBox.proModeDescription}
</div>
</div>
{!selectedSingleRoundRag && context.mode === "pro" ? (
{!frontendRagActive && context.mode === "pro" ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
@@ -988,7 +1009,7 @@ export function InputBox({
</PromptInputActionMenuItem>
<PromptInputActionMenuItem
className={cn(
!selectedSingleRoundRag && context.mode === "ultra"
!frontendRagActive && context.mode === "ultra"
? "text-accent-foreground"
: "text-muted-foreground/65",
)}
@@ -999,14 +1020,14 @@ export function InputBox({
<RocketIcon
className={cn(
"mr-2 size-4",
!selectedSingleRoundRag &&
!frontendRagActive &&
context.mode === "ultra" &&
"text-[#dabb5e]",
)}
/>
<div
className={cn(
!selectedSingleRoundRag &&
!frontendRagActive &&
context.mode === "ultra" &&
"golden-text",
)}
@@ -1018,7 +1039,7 @@ export function InputBox({
{t.inputBox.ultraModeDescription}
</div>
</div>
{!selectedSingleRoundRag && context.mode === "ultra" ? (
{!frontendRagActive && context.mode === "ultra" ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
@@ -1057,11 +1078,39 @@ export function InputBox({
</PromptInputActionMenuItem>
</>
)}
<PromptInputActionMenuItem
className={cn(
multiRoundRagActive
? "bg-emerald-50 text-emerald-800 hover:bg-emerald-100 focus:bg-emerald-100 dark:bg-emerald-950/40 dark:text-emerald-200 dark:hover:bg-emerald-950/60 dark:focus:bg-emerald-950/60"
: "text-muted-foreground/65",
)}
onSelect={handleMultiRoundRagSelect}
>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1 font-bold">
<BookMarkedIcon
className={cn(
"mr-2 size-4",
multiRoundRagActive && "text-emerald-700 dark:text-emerald-200",
)}
/>
多轮 RAG 问答
</div>
<div className="pl-7 text-xs">
多次检索、展示过程与参考资料,适合复杂问题
</div>
</div>
{multiRoundRagActive ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
)}
</PromptInputActionMenuItem>
</PromptInputActionMenu>
</DropdownMenuGroup>
</PromptInputActionMenuContent>
</PromptInputActionMenu>
{supportReasoningEffort && context.mode !== "flash" && (
{!frontendRagActive && supportReasoningEffort && context.mode !== "flash" && (
<PromptInputActionMenu>
<PromptInputActionMenuTrigger className={cn("gap-1.5! rounded-full border-0 bg-transparent px-3! text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100", compact && "rounded-lg border border-border px-2.5! py-1! text-[13px]")}>
<div className="text-xs font-normal">
@@ -1179,8 +1228,8 @@ export function InputBox({
)}
</PromptInputTools>
<PromptInputTools className={cn("gap-1.5", compact && "gap-2")}>
{knowledgeSelector}
{agentSelector && (
{!frontendRagActive && agentSelector && (
{!frontendRagActive && knowledgeSelector}
<AgentSelector
selectedAgentId={agentSelector.selectedAgentId}
onAgentChange={agentSelector.onAgentChange}
@@ -1188,7 +1237,7 @@ export function InputBox({
compact={compact}
/>
)}
{promptPrefix && (
{!frontendRagActive && promptPrefix && (
<Tooltip
content={
promptPrefix.on
@@ -1216,7 +1265,7 @@ export function InputBox({
</PromptInputButton>
</Tooltip>
)}
<ModelSelector
{!multiRoundRagActive && <ModelSelector
open={modelDialogOpen}
onOpenChange={setModelDialogOpen}
>
@@ -1266,7 +1315,7 @@ export function InputBox({
))}
</ModelSelectorList>
</ModelSelectorContent>
</ModelSelector>
</ModelSelector>}
<PromptInputSubmit
className={cn(
"rounded-full border-border bg-background shadow-sm hover:bg-muted dark:bg-blue-500 dark:border-blue-500 dark:text-white dark:hover:bg-blue-400 dark:shadow-none",
@@ -1,5 +1,13 @@
import type { Message } from '@langchain/langgraph-sdk';
import { ClockIcon, FileIcon, Loader2Icon, PencilIcon } from 'lucide-react';
import {
ChevronDownIcon,
ClockIcon,
ExternalLinkIcon,
FileIcon,
Loader2Icon,
PencilIcon,
SearchIcon,
} from 'lucide-react';
import {
memo,
useCallback,
@@ -25,8 +33,15 @@ import {
ReasoningTrigger,
} from '@/components/ai-elements/reasoning';
import { Task, TaskTrigger } from '@/components/ai-elements/task';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Textarea } from '@/components/ui/textarea';
import {
UploadedFilePreviewDialog,
@@ -34,7 +49,8 @@ import {
} from '@/components/workspace/messages/uploaded-file-preview-dialog';
import { resolveArtifactURL } from '@/core/artifacts/utils';
import { useChatAskTimes } from '@/core/chat-timestamps';
import { getBackendBaseURL } from '@/core/config';
import { getBackendBaseURL } from '@/core/config';
import { readRuntimeEnv } from '@/env';
import { useI18n } from '@/core/i18n/hooks';
import {
extractContentFromMessage,
@@ -49,7 +65,12 @@ import {
applyInlineCitations,
type RuntimeDisplaySource,
} from '@/core/skill-display/runtime';
import { humanMessagePlugins } from '@/core/streamdown';
import { humanMessagePlugins } from '@/core/streamdown';
import {
getMultiRoundRagTrace,
isMultiRoundRagMessage,
type MultiRoundRagRound,
} from '@/core/threads/use-multi-round-rag';
import { cn } from '@/lib/utils';
import { CitationLink } from '../citations/citation-link';
@@ -561,18 +582,47 @@ function MessageContent_({
: baseRehypePlugins,
[baseRehypePlugins, extraRehypePlugins],
);
const askTimes = useChatAskTimes();
const isHuman = message.type === 'human';
const resolveArtifact = useArtifactResolver(threadId);
const components = useMemo(
const askTimes = useChatAskTimes();
const isHuman = message.type === 'human';
const isSingleRoundRag =
message.additional_kwargs?.single_round_rag === true;
const isMultiRoundRag = isMultiRoundRagMessage(message);
const multiRoundTrace = isMultiRoundRag
? getMultiRoundRagTrace(message)
: null;
const [selectedCitation, setSelectedCitation] =
useState<RuntimeDisplaySource | null>(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<HTMLImageElement>) => (
<MessageImage {...props} threadId={threadId} maxWidth="90%" />
),
a: ({ href, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => {
if (href?.startsWith('#reference-')) {
return <CitationLink href={href} {...props} />;
}
a: ({ href, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => {
if (href?.startsWith('#reference-')) {
return (
<CitationLink
href={href}
{...props}
onReferenceClick={
isSingleRoundRag || isMultiRoundRag
? handleCitationClick
: undefined
}
/>
);
}
if (
typeof props.children === 'string' &&
/^citation:/i.test(props.children)
@@ -593,8 +643,14 @@ function MessageContent_({
return <a {...props} href={href} />;
},
}),
[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 (
<AIElementMessageContent className={className}>
<Reasoning isStreaming={isLoading}>
@@ -707,32 +763,284 @@ function MessageContent_({
);
}
return (
<AIElementMessageContent className={className}>
{filesList}
<div data-reference-batch-id={message.id} className="contents">
<AssistantDisplayContent
content={contentToDisplay}
displaySources={displaySources}
isLoading={isLoading}
renderSkillDisplayBlocks={false}
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: 'html' }]]}
components={components}
/>
</div>
<MessageTokenUsage
enabled={tokenUsageEnabled}
isLoading={isLoading}
message={message}
/>
{!isLoading && contentToDisplay ? (
<MessageTimeStamp time={messageTime} align="left" />
) : null}
</AIElementMessageContent>
);
}
function EmbeddedPageContextCard({
return (
<>
<AIElementMessageContent className={className}>
{filesList}
<div data-reference-batch-id={message.id} className="contents">
{multiRoundTrace ? (
<MultiRoundRagContent
trace={multiRoundTrace}
content={contentToDisplay}
displaySources={displaySources}
isLoading={isLoading}
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: 'html' }]]}
components={components}
/>
) : (
<AssistantDisplayContent
content={contentToDisplay}
displaySources={displaySources}
isLoading={isLoading}
renderSkillDisplayBlocks={false}
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: 'html' }]]}
components={components}
/>
)}
</div>
<MessageTokenUsage
enabled={tokenUsageEnabled}
isLoading={isLoading}
message={message}
/>
{!isLoading && contentToDisplay ? (
<MessageTimeStamp time={messageTime} align="left" />
) : null}
</AIElementMessageContent>
{(isSingleRoundRag || isMultiRoundRag) && (
<SingleRoundRagCitationDialog
source={selectedCitation}
onOpenChange={(open) => {
if (!open) setSelectedCitation(null);
}}
/>
)}
</>
);
}
function SingleRoundRagCitationDialog({
source,
onOpenChange,
}: {
source: RuntimeDisplaySource | null;
onOpenChange: (open: boolean) => void;
}) {
const sourceUrl = resolveSingleRoundRagSourceUrl(source);
return (
<Dialog open={!!source} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[min(42rem,calc(100vh-2rem))] max-w-2xl overflow-hidden p-5 sm:max-w-2xl">
<DialogHeader className="pr-8">
<DialogTitle className="leading-relaxed">
{source?.title || "引用资料"}
</DialogTitle>
<DialogDescription className="space-y-1 text-xs">
<span className="block">来源:{source?.source || source?.type || "未知来源"}</span>
{source?.time && <span className="block">时间:{source.time}</span>}
</DialogDescription>
</DialogHeader>
{sourceUrl && (
<a
href={sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="border-input hover:bg-accent inline-flex w-fit items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors"
>
<ExternalLinkIcon className="size-4" />
查看原文链接
</a>
)}
<div className="bg-muted/50 max-h-[min(26rem,calc(100vh-16rem))] overflow-auto rounded-lg p-3 text-sm leading-7 whitespace-pre-wrap">
{source?.content || source?.snippet || "暂无引用内容"}
</div>
</DialogContent>
</Dialog>
);
}
function MultiRoundRagContent({
trace,
content,
displaySources,
isLoading,
rehypePlugins,
components,
}: {
trace: NonNullable<ReturnType<typeof getMultiRoundRagTrace>>;
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 (
<div className="space-y-3">
<section className="border-border/70 overflow-hidden rounded-xl border bg-muted/20">
<button
type="button"
className="hover:bg-muted/50 flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition-colors"
onClick={() => setOpen((value) => !value)}
>
<div className="flex min-w-0 items-center gap-2">
{trace.isComplete ? (
<SearchIcon className="text-primary size-4 shrink-0" />
) : (
<Loader2Icon className="text-primary size-4 shrink-0 animate-spin" />
)}
<div>
<div className="text-sm font-medium">多轮检索过程</div>
<div className="text-muted-foreground text-xs">
{trace.isComplete
? `已完成 ${trace.rounds.length} 轮检索`
: '正在分析问题并检索资料'}
</div>
</div>
</div>
<ChevronDownIcon
className={cn('text-muted-foreground size-4 transition-transform', !open && '-rotate-90')}
/>
</button>
{open && (
<div className="border-border/60 space-y-3 border-t px-4 py-3">
{trace.rounds.map((round, index) => (
<MultiRoundRagRoundCard
key={round.id}
index={index + 1}
round={round}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
components={components}
/>
))}
</div>
)}
</section>
<section className="border-border/70 rounded-xl border p-4">
<div className="mb-3 text-sm font-semibold">最终回答</div>
{content ? (
<AssistantDisplayContent
content={content}
displaySources={displaySources}
isLoading={isLoading}
renderSkillDisplayBlocks={false}
rehypePlugins={rehypePlugins}
components={components}
/>
) : (
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Loader2Icon className="size-4 animate-spin" />
正在生成最终回答…
</div>
)}
</section>
</div>
);
}
function MultiRoundRagRoundCard({
index,
round,
isLoading,
rehypePlugins,
components,
}: {
index: number;
round: MultiRoundRagRound;
isLoading: boolean;
rehypePlugins: MarkdownContentProps['rehypePlugins'];
components: MarkdownContentProps['components'];
}) {
return (
<div className="border-border/60 rounded-lg border bg-background/70 p-3">
<div className="flex items-center gap-2 text-sm font-medium">
<span className="bg-primary/10 text-primary flex size-5 shrink-0 items-center justify-center rounded-full text-xs">
{index}
</span>
<span>{round.title}</span>
{round.isSearching && <Loader2Icon className="text-primary size-3.5 animate-spin" />}
</div>
{round.thought && (
<section className="border-border/50 bg-muted/30 mt-3 rounded-md border p-3">
<div className="text-muted-foreground mb-2 text-xs font-medium">思考内容</div>
<MarkdownContent
content={round.thought}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
components={components}
className="text-muted-foreground text-sm"
/>
</section>
)}
{round.body && (
<section className="border-border/50 mt-3 rounded-md border p-3">
<div className="text-muted-foreground mb-2 text-xs font-medium">本轮正文</div>
<MarkdownContent
content={round.body}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
components={components}
className="text-sm"
/>
</section>
)}
{round.query && (
<div className="border-primary/20 bg-primary/5 text-primary mt-2 inline-flex max-w-full items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs">
<SearchIcon className="size-3 shrink-0" />
<span className="truncate">{round.query}</span>
</div>
)}
{round.retrievals.length > 0 && (
<section className="border-border/50 mt-3 rounded-md border p-3">
<div className="text-muted-foreground mb-2 text-xs font-medium">
检索内容 · {round.retrievals.length} 条
</div>
<div className="space-y-2">
{round.retrievals.map((item, resultIndex) => (
<details
key={`${item.id ?? item.title}-${resultIndex}`}
className="border-border/60 rounded-md border px-3 py-2"
open={resultIndex === 0}
>
<summary className="cursor-pointer list-none text-sm font-medium">
<span className="mr-2 text-xs text-muted-foreground">{resultIndex + 1}.</span>
{item.title}
</summary>
<div className="text-muted-foreground mt-2 text-sm leading-6 whitespace-pre-wrap">
{item.content || '暂无资料摘要'}
</div>
{(item.source || item.time) && (
<div className="text-muted-foreground mt-2 text-xs">
{[item.source, item.time].filter(Boolean).join(' · ')}
</div>
)}
</details>
))}
</div>
</section>
)}
</div>
);
}
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;
@@ -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;
@@ -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);
}
+1 -1
View File
@@ -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 {
@@ -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);
+2 -2
View File
@@ -27,8 +27,8 @@ export interface AgentThreadContext extends Record<string, unknown> {
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;
@@ -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<string, unknown>;
};
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<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: 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<Message[]>(() => readStoredMessages(threadId));
const messagesRef = useRef(messages);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const controllerRef = useRef<AbortController | null>(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("<think>")) {
const thinkEnd = text.indexOf("</think>");
addThinkingRound(
trace,
(thinkEnd >= 0
? text.slice(0, thinkEnd + "</think>".length)
: text
)
.replace(/<\/?think>/g, "")
.trim(),
);
if (thinkEnd >= 0) {
const roundBody = text
.slice(thinkEnd + "</think>".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);
}
@@ -22,6 +22,13 @@ type RagSource = {
raw: Record<string, unknown>;
};
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<Message[]>(() => readStoredMessages(threadId));
const messagesRef = useRef(messages);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const controllerRef = useRef<AbortController | null>(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,
+59 -15
View File
@@ -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)