feat(frontend): add workspace single and multi round rag
This commit is contained in:
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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user