代码提交
This commit is contained in:
1 parent
17a12bb7b6
commit
783e698b6e
10 files changed
+758
-173
No files matched your search
@@ -15,6 +15,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { apiFetch } from "@/core/api";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
|
||||
import { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import type { SkillDisplayConfig } from "@/core/skills/type";
|
||||
import {
|
||||
@@ -92,6 +93,13 @@ const ChatBox: React.FC<{
|
||||
const [settings] = useLocalSettings();
|
||||
const ragModeEnabled =
|
||||
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
|
||||
// Frontend single-round RAG writes its own source tool message, so it should
|
||||
// use the same reference panel without changing the user's global RAG mode.
|
||||
const hasSingleRoundRag = useMemo(
|
||||
() => (thread.values.messages ?? []).some(isSingleRoundRagMessage),
|
||||
[thread.values.messages],
|
||||
);
|
||||
const referencesEnabled = ragModeEnabled || hasSingleRoundRag;
|
||||
const { skills } = useSkills();
|
||||
|
||||
const {
|
||||
@@ -106,11 +114,11 @@ const ChatBox: React.FC<{
|
||||
|
||||
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
|
||||
const skillDisplayMap = useMemo(
|
||||
() => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
||||
[ragModeEnabled, skills],
|
||||
() => (referencesEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
||||
[referencesEnabled, skills],
|
||||
);
|
||||
const fallbackReferenceBatches = useMemo(() => {
|
||||
if (!ragModeEnabled) {
|
||||
if (!referencesEnabled) {
|
||||
return [];
|
||||
}
|
||||
return buildTurnReferenceBatches(
|
||||
@@ -118,9 +126,9 @@ const ChatBox: React.FC<{
|
||||
skillDisplayMap,
|
||||
thread.isLoading,
|
||||
);
|
||||
}, [ragModeEnabled, skillDisplayMap, thread.isLoading, thread.values.messages]);
|
||||
}, [referencesEnabled, skillDisplayMap, thread.isLoading, thread.values.messages]);
|
||||
useEffect(() => {
|
||||
if (!ragModeEnabled) {
|
||||
if (!referencesEnabled) {
|
||||
setBackendReferenceBatches([]);
|
||||
return;
|
||||
}
|
||||
@@ -156,7 +164,7 @@ const ChatBox: React.FC<{
|
||||
}
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [ragModeEnabled, thread.isLoading, threadId, thread.values.messages]);
|
||||
}, [referencesEnabled, thread.isLoading, threadId, thread.values.messages]);
|
||||
const adaptedBackendReferenceBatches = useMemo(
|
||||
() =>
|
||||
adaptBackendReferenceBatches(
|
||||
@@ -175,7 +183,7 @@ const ChatBox: React.FC<{
|
||||
[adaptedBackendReferenceBatches, fallbackReferenceBatches],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!ragModeEnabled) {
|
||||
if (!referencesEnabled) {
|
||||
return;
|
||||
}
|
||||
const signature = JSON.stringify({
|
||||
@@ -202,7 +210,7 @@ const ChatBox: React.FC<{
|
||||
fallbackBatches: debugReferenceBatches(fallbackReferenceBatches),
|
||||
finalBatches: debugReferenceBatches(referenceBatches),
|
||||
});
|
||||
}, [adaptedBackendReferenceBatches, backendReferenceBatches, fallbackReferenceBatches, ragModeEnabled, referenceBatches, threadId]);
|
||||
}, [adaptedBackendReferenceBatches, backendReferenceBatches, fallbackReferenceBatches, referencesEnabled, referenceBatches, threadId]);
|
||||
const latestReferenceBatch =
|
||||
referenceBatches.length > 0
|
||||
? referenceBatches[referenceBatches.length - 1]!
|
||||
@@ -213,7 +221,7 @@ const ChatBox: React.FC<{
|
||||
const referenceSources = activeReferenceBatch?.sources ?? [];
|
||||
const hasCitationReferences = referenceSources.length > 0;
|
||||
useEffect(() => {
|
||||
if (!ragModeEnabled || !activeReferenceBatch) {
|
||||
if (!referencesEnabled || !activeReferenceBatch) {
|
||||
return;
|
||||
}
|
||||
logReferenceDebug("frontend-active-reference-batch-payload", threadId, {
|
||||
@@ -222,7 +230,7 @@ const ChatBox: React.FC<{
|
||||
sourceCount: activeReferenceBatch.sources.length,
|
||||
sources: activeReferenceBatch.sources.map(debugReferenceSource),
|
||||
});
|
||||
}, [activeReferenceBatch, ragModeEnabled, threadId]);
|
||||
}, [activeReferenceBatch, referencesEnabled, threadId]);
|
||||
useEffect(() => {
|
||||
if (hasCitationReferences) {
|
||||
setReferencesOpen(true);
|
||||
|
||||
@@ -64,6 +64,7 @@ import { getBackendBaseURL } from "@/core/config";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { isHiddenFromUIMessage } from "@/core/messages/utils";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import type { Model } from "@/core/models/types";
|
||||
import type { AgentThreadContext } from "@/core/threads";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
@@ -133,8 +134,9 @@ export function InputBox({
|
||||
onSubmit,
|
||||
onStop,
|
||||
promptPrefix,
|
||||
agentSelector,
|
||||
assistantId,
|
||||
agentSelector,
|
||||
extraModels = [],
|
||||
assistantId,
|
||||
hideAdminRecommendedQuestions,
|
||||
compact,
|
||||
...props
|
||||
@@ -190,16 +192,23 @@ export function InputBox({
|
||||
* homepage (``ChatPage``) passes this; ``AgentChatPage`` omits it because the
|
||||
* thread is already bound to one agent.
|
||||
*/
|
||||
agentSelector?: {
|
||||
selectedAgentId: string | null;
|
||||
onAgentChange: (agentId: string | null) => void;
|
||||
};
|
||||
}) {
|
||||
agentSelector?: {
|
||||
selectedAgentId: string | null;
|
||||
onAgentChange: (agentId: string | null) => void;
|
||||
};
|
||||
/** Page-scoped model choices implemented outside the DeerFlow model API. */
|
||||
extraModels?: Model[];
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [localSettings, setLocalSettings] = useLocalSettings();
|
||||
const searchParams = useSearchParams();
|
||||
const [modelDialogOpen, setModelDialogOpen] = useState(false);
|
||||
const { models } = useModels();
|
||||
const [modelDialogOpen, setModelDialogOpen] = useState(false);
|
||||
const { models } = useModels();
|
||||
const singleRoundRagActive =
|
||||
context.execution_profile === "single_round_rag";
|
||||
const modelOptions = useMemo(() => {
|
||||
return singleRoundRagActive ? extraModels : models;
|
||||
}, [extraModels, models, singleRoundRagActive]);
|
||||
const { thread, isMock } = useThread();
|
||||
const { textInput } = usePromptInputController();
|
||||
const promptRootRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -223,34 +232,40 @@ export function InputBox({
|
||||
recommendedQuestions.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (models.length === 0) {
|
||||
return;
|
||||
}
|
||||
const currentModel = models.find((m) => m.name === context.model_name);
|
||||
const fallbackModel = currentModel ?? models[0]!;
|
||||
if (modelOptions.length === 0) {
|
||||
return;
|
||||
}
|
||||
const currentModel = modelOptions.find((m) => m.name === context.model_name);
|
||||
const fallbackModel = currentModel ?? modelOptions[0]!;
|
||||
const supportsThinking = fallbackModel.supports_thinking ?? false;
|
||||
const nextModelName = fallbackModel.name;
|
||||
const nextMode = getResolvedMode(context.mode, supportsThinking);
|
||||
|
||||
if (context.model_name === nextModelName && context.mode === nextMode) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
context.model_name === nextModelName &&
|
||||
context.mode === nextMode &&
|
||||
context.execution_profile === fallbackModel.execution_profile
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
onContextChange?.({
|
||||
...context,
|
||||
model_name: nextModelName,
|
||||
mode: nextMode,
|
||||
});
|
||||
}, [context, models, onContextChange]);
|
||||
...context,
|
||||
model_name: nextModelName,
|
||||
mode: nextMode,
|
||||
execution_profile: fallbackModel.execution_profile,
|
||||
});
|
||||
}, [context, modelOptions, onContextChange]);
|
||||
|
||||
const selectedModel = useMemo(() => {
|
||||
if (modelOptions.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return modelOptions.find((m) => m.name === context.model_name) ?? modelOptions[0];
|
||||
}, [context.model_name, modelOptions]);
|
||||
|
||||
const selectedModel = useMemo(() => {
|
||||
if (models.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return models.find((m) => m.name === context.model_name) ?? models[0];
|
||||
}, [context.model_name, models]);
|
||||
|
||||
const resolvedModelName = selectedModel?.name;
|
||||
const resolvedModelName = selectedModel?.name;
|
||||
const selectedSingleRoundRag = singleRoundRagActive;
|
||||
|
||||
const supportThinking = useMemo(
|
||||
() => selectedModel?.supports_thinking ?? false,
|
||||
@@ -264,38 +279,65 @@ export function InputBox({
|
||||
|
||||
const handleModelSelect = useCallback(
|
||||
(model_name: string) => {
|
||||
const model = models.find((m) => m.name === model_name);
|
||||
const model = modelOptions.find((m) => m.name === model_name);
|
||||
if (!model) {
|
||||
return;
|
||||
}
|
||||
onContextChange?.({
|
||||
...context,
|
||||
model_name,
|
||||
mode: getResolvedMode(context.mode, model.supports_thinking ?? false),
|
||||
reasoning_effort: context.reasoning_effort,
|
||||
});
|
||||
setModelDialogOpen(false);
|
||||
},
|
||||
[onContextChange, context, models],
|
||||
mode: getResolvedMode(context.mode, model.supports_thinking ?? false),
|
||||
reasoning_effort: context.reasoning_effort,
|
||||
execution_profile: model.execution_profile,
|
||||
});
|
||||
setModelDialogOpen(false);
|
||||
},
|
||||
[onContextChange, context, modelOptions],
|
||||
);
|
||||
|
||||
const handleModeSelect = useCallback(
|
||||
(mode: InputMode) => {
|
||||
onContextChange?.({
|
||||
...context,
|
||||
mode: getResolvedMode(mode, supportThinking),
|
||||
reasoning_effort:
|
||||
mode === "ultra"
|
||||
? "high"
|
||||
const handleModeSelect = useCallback(
|
||||
(mode: InputMode) => {
|
||||
const ordinaryModel =
|
||||
models.find((model) => model.name === context.model_name) ?? models[0];
|
||||
onContextChange?.({
|
||||
...context,
|
||||
model_name: ordinaryModel?.name ?? context.model_name,
|
||||
mode: getResolvedMode(
|
||||
mode,
|
||||
ordinaryModel?.supports_thinking ?? supportThinking,
|
||||
),
|
||||
reasoning_effort:
|
||||
mode === "ultra"
|
||||
? "high"
|
||||
: mode === "pro"
|
||||
? "medium"
|
||||
: mode === "thinking"
|
||||
? "low"
|
||||
: "minimal",
|
||||
});
|
||||
},
|
||||
[onContextChange, context, supportThinking],
|
||||
);
|
||||
? "low"
|
||||
: "minimal",
|
||||
execution_profile: undefined,
|
||||
});
|
||||
},
|
||||
[onContextChange, context, models, supportThinking],
|
||||
);
|
||||
|
||||
const handleSingleRoundRagSelect = useCallback(() => {
|
||||
const ragModel =
|
||||
extraModels.find((model) => model.name === context.model_name) ??
|
||||
extraModels[0];
|
||||
if (!ragModel) {
|
||||
return;
|
||||
}
|
||||
|
||||
onContextChange?.({
|
||||
...context,
|
||||
model_name: ragModel.name,
|
||||
mode: getResolvedMode(
|
||||
context.mode,
|
||||
ragModel.supports_thinking ?? false,
|
||||
),
|
||||
execution_profile: "single_round_rag",
|
||||
});
|
||||
}, [context, extraModels, onContextChange]);
|
||||
|
||||
const handleReasoningEffortSelect = useCallback(
|
||||
(effort: "minimal" | "low" | "medium" | "high") => {
|
||||
@@ -361,10 +403,11 @@ export function InputBox({
|
||||
onContextChange?.({
|
||||
...context,
|
||||
model_name: resolvedModelName,
|
||||
mode: getResolvedMode(
|
||||
context.mode,
|
||||
selectedModel?.supports_thinking ?? false,
|
||||
),
|
||||
mode: getResolvedMode(
|
||||
context.mode,
|
||||
selectedModel?.supports_thinking ?? false,
|
||||
),
|
||||
execution_profile: selectedModel?.execution_profile,
|
||||
});
|
||||
setTimeout(() => onSubmit?.(message), 0);
|
||||
return;
|
||||
@@ -379,7 +422,8 @@ export function InputBox({
|
||||
onStop,
|
||||
resolvedModelName,
|
||||
ragModeEnabled,
|
||||
selectedModel?.supports_thinking,
|
||||
selectedModel?.supports_thinking,
|
||||
selectedModel?.execution_profile,
|
||||
status,
|
||||
threadId,
|
||||
],
|
||||
@@ -437,8 +481,9 @@ export function InputBox({
|
||||
}, [pendingSuggestion, requestFormSubmit, textInput]);
|
||||
|
||||
const showFollowups =
|
||||
!disabled &&
|
||||
!isNewThread &&
|
||||
!disabled &&
|
||||
!isNewThread &&
|
||||
!selectedSingleRoundRag &&
|
||||
!followupsHidden &&
|
||||
(followupsLoading || followups.length > 0);
|
||||
|
||||
@@ -464,7 +509,7 @@ export function InputBox({
|
||||
return;
|
||||
}
|
||||
|
||||
if (disabled || isMock) {
|
||||
if (disabled || isMock || selectedSingleRoundRag) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -526,7 +571,7 @@ export function InputBox({
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
}, [context.model_name, disabled, isMock, status, thread.messages, threadId]);
|
||||
}, [context.model_name, disabled, isMock, selectedSingleRoundRag, status, thread.messages, threadId]);
|
||||
|
||||
return (
|
||||
<div ref={promptRootRef} className="relative flex flex-col gap-6">
|
||||
@@ -782,54 +827,64 @@ export function InputBox({
|
||||
</PromptInputButton>
|
||||
</Tooltip>
|
||||
<PromptInputActionMenu>
|
||||
<ModeHoverGuide
|
||||
mode={
|
||||
<ModeHoverGuide
|
||||
mode={
|
||||
context.mode === "flash" ||
|
||||
context.mode === "thinking" ||
|
||||
context.mode === "pro" ||
|
||||
context.mode === "ultra"
|
||||
? context.mode
|
||||
: "flash"
|
||||
}
|
||||
>
|
||||
? context.mode
|
||||
: "flash"
|
||||
}
|
||||
tooltipContent={
|
||||
selectedSingleRoundRag
|
||||
? "单轮 RAG 问答:基于知识库检索生成答案,并展示参考资料"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<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 px-2.5! py-1! text-[11px] font-medium",
|
||||
compact &&
|
||||
(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"
|
||||
compact &&
|
||||
(selectedSingleRoundRag
|
||||
? "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"
|
||||
: context.mode === "pro"
|
||||
? "border-violet-200 bg-violet-50/60 text-violet-700 hover:bg-violet-100 dark:border-violet-700 dark:bg-violet-950/40 dark:text-violet-200"
|
||||
: context.mode === "ultra"
|
||||
? "border-amber-200 bg-amber-50/60 text-amber-700 hover:bg-amber-100 dark:border-amber-700 dark:bg-amber-950/40 dark:text-amber-200"
|
||||
: "border-transparent bg-muted/40 hover:bg-muted hover:text-foreground"),
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
{context.mode === "flash" && <ZapIcon className="size-3" />}
|
||||
{context.mode === "thinking" && (
|
||||
<LightbulbIcon className="size-3" />
|
||||
)}
|
||||
{context.mode === "pro" && (
|
||||
<GraduationCapIcon className="size-3" />
|
||||
)}
|
||||
{context.mode === "ultra" && (
|
||||
<RocketIcon className="size-3 text-[#dabb5e]" />
|
||||
)}
|
||||
</div>
|
||||
>
|
||||
<div>
|
||||
{selectedSingleRoundRag ? (
|
||||
<BookMarkedIcon className="size-3" />
|
||||
) : context.mode === "flash" ? (
|
||||
<ZapIcon className="size-3" />
|
||||
) : context.mode === "thinking" ? (
|
||||
<LightbulbIcon className="size-3" />
|
||||
) : context.mode === "pro" ? (
|
||||
<GraduationCapIcon className="size-3" />
|
||||
) : context.mode === "ultra" ? (
|
||||
<RocketIcon className="size-3 text-[#dabb5e]" />
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"text-xs font-normal",
|
||||
context.mode === "ultra" ? "golden-text" : "",
|
||||
)}
|
||||
>
|
||||
{(context.mode === "flash" && t.inputBox.flashMode) ||
|
||||
(context.mode === "thinking" &&
|
||||
t.inputBox.reasoningMode) ||
|
||||
(context.mode === "pro" && t.inputBox.proMode) ||
|
||||
(context.mode === "ultra" && t.inputBox.ultraMode)}
|
||||
</div>
|
||||
{selectedSingleRoundRag
|
||||
? "单轮 RAG"
|
||||
: (context.mode === "flash" && t.inputBox.flashMode) ||
|
||||
(context.mode === "thinking" &&
|
||||
t.inputBox.reasoningMode) ||
|
||||
(context.mode === "pro" && t.inputBox.proMode) ||
|
||||
(context.mode === "ultra" && t.inputBox.ultraMode)}
|
||||
</div>
|
||||
</PromptInputActionMenuTrigger>
|
||||
</ModeHoverGuide>
|
||||
<PromptInputActionMenuContent className="w-80">
|
||||
@@ -838,11 +893,11 @@ export function InputBox({
|
||||
{t.inputBox.mode}
|
||||
</DropdownMenuLabel>
|
||||
<PromptInputActionMenu>
|
||||
<PromptInputActionMenuItem
|
||||
className={cn(
|
||||
context.mode === "flash"
|
||||
? "text-accent-foreground"
|
||||
: "text-muted-foreground/65",
|
||||
<PromptInputActionMenuItem
|
||||
className={cn(
|
||||
!selectedSingleRoundRag && context.mode === "flash"
|
||||
? "text-accent-foreground"
|
||||
: "text-muted-foreground/65",
|
||||
)}
|
||||
onSelect={() => handleModeSelect("flash")}
|
||||
>
|
||||
@@ -850,9 +905,9 @@ export function InputBox({
|
||||
<div className="flex items-center gap-1 font-bold">
|
||||
<ZapIcon
|
||||
className={cn(
|
||||
"mr-2 size-4",
|
||||
context.mode === "flash" &&
|
||||
"text-accent-foreground",
|
||||
"mr-2 size-4",
|
||||
!selectedSingleRoundRag && context.mode === "flash" &&
|
||||
"text-accent-foreground",
|
||||
)}
|
||||
/>
|
||||
{t.inputBox.flashMode}
|
||||
@@ -861,7 +916,7 @@ export function InputBox({
|
||||
{t.inputBox.flashModeDescription}
|
||||
</div>
|
||||
</div>
|
||||
{context.mode === "flash" ? (
|
||||
{!selectedSingleRoundRag && context.mode === "flash" ? (
|
||||
<CheckIcon className="ml-auto size-4" />
|
||||
) : (
|
||||
<div className="ml-auto size-4" />
|
||||
@@ -869,9 +924,9 @@ export function InputBox({
|
||||
</PromptInputActionMenuItem>
|
||||
{supportThinking && (
|
||||
<PromptInputActionMenuItem
|
||||
className={cn(
|
||||
context.mode === "thinking"
|
||||
? "text-accent-foreground"
|
||||
className={cn(
|
||||
!selectedSingleRoundRag && context.mode === "thinking"
|
||||
? "text-accent-foreground"
|
||||
: "text-muted-foreground/65",
|
||||
)}
|
||||
onSelect={() => handleModeSelect("thinking")}
|
||||
@@ -880,9 +935,9 @@ export function InputBox({
|
||||
<div className="flex items-center gap-1 font-bold">
|
||||
<LightbulbIcon
|
||||
className={cn(
|
||||
"mr-2 size-4",
|
||||
context.mode === "thinking" &&
|
||||
"text-accent-foreground",
|
||||
"mr-2 size-4",
|
||||
!selectedSingleRoundRag && context.mode === "thinking" &&
|
||||
"text-accent-foreground",
|
||||
)}
|
||||
/>
|
||||
{t.inputBox.reasoningMode}
|
||||
@@ -891,7 +946,7 @@ export function InputBox({
|
||||
{t.inputBox.reasoningModeDescription}
|
||||
</div>
|
||||
</div>
|
||||
{context.mode === "thinking" ? (
|
||||
{!selectedSingleRoundRag && context.mode === "thinking" ? (
|
||||
<CheckIcon className="ml-auto size-4" />
|
||||
) : (
|
||||
<div className="ml-auto size-4" />
|
||||
@@ -899,8 +954,8 @@ export function InputBox({
|
||||
</PromptInputActionMenuItem>
|
||||
)}
|
||||
<PromptInputActionMenuItem
|
||||
className={cn(
|
||||
context.mode === "pro"
|
||||
className={cn(
|
||||
!selectedSingleRoundRag && context.mode === "pro"
|
||||
? "text-accent-foreground"
|
||||
: "text-muted-foreground/65",
|
||||
)}
|
||||
@@ -910,8 +965,8 @@ export function InputBox({
|
||||
<div className="flex items-center gap-1 font-bold">
|
||||
<GraduationCapIcon
|
||||
className={cn(
|
||||
"mr-2 size-4",
|
||||
context.mode === "pro" &&
|
||||
"mr-2 size-4",
|
||||
!selectedSingleRoundRag && context.mode === "pro" &&
|
||||
"text-accent-foreground",
|
||||
)}
|
||||
/>
|
||||
@@ -921,15 +976,15 @@ export function InputBox({
|
||||
{t.inputBox.proModeDescription}
|
||||
</div>
|
||||
</div>
|
||||
{context.mode === "pro" ? (
|
||||
{!selectedSingleRoundRag && context.mode === "pro" ? (
|
||||
<CheckIcon className="ml-auto size-4" />
|
||||
) : (
|
||||
<div className="ml-auto size-4" />
|
||||
)}
|
||||
</PromptInputActionMenuItem>
|
||||
<PromptInputActionMenuItem
|
||||
className={cn(
|
||||
context.mode === "ultra"
|
||||
className={cn(
|
||||
!selectedSingleRoundRag && context.mode === "ultra"
|
||||
? "text-accent-foreground"
|
||||
: "text-muted-foreground/65",
|
||||
)}
|
||||
@@ -939,13 +994,17 @@ export function InputBox({
|
||||
<div className="flex items-center gap-1 font-bold">
|
||||
<RocketIcon
|
||||
className={cn(
|
||||
"mr-2 size-4",
|
||||
context.mode === "ultra" && "text-[#dabb5e]",
|
||||
"mr-2 size-4",
|
||||
!selectedSingleRoundRag &&
|
||||
context.mode === "ultra" &&
|
||||
"text-[#dabb5e]",
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
context.mode === "ultra" && "golden-text",
|
||||
!selectedSingleRoundRag &&
|
||||
context.mode === "ultra" &&
|
||||
"golden-text",
|
||||
)}
|
||||
>
|
||||
{t.inputBox.ultraMode}
|
||||
@@ -955,13 +1014,46 @@ export function InputBox({
|
||||
{t.inputBox.ultraModeDescription}
|
||||
</div>
|
||||
</div>
|
||||
{context.mode === "ultra" ? (
|
||||
{!selectedSingleRoundRag && context.mode === "ultra" ? (
|
||||
<CheckIcon className="ml-auto size-4" />
|
||||
) : (
|
||||
<div className="ml-auto size-4" />
|
||||
)}
|
||||
</PromptInputActionMenuItem>
|
||||
</PromptInputActionMenu>
|
||||
</PromptInputActionMenuItem>
|
||||
{extraModels.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuSeparator className="my-2" />
|
||||
<PromptInputActionMenuItem
|
||||
className={cn(
|
||||
selectedSingleRoundRag
|
||||
? "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={handleSingleRoundRagSelect}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-1 font-bold">
|
||||
<BookMarkedIcon
|
||||
className={cn(
|
||||
"mr-2 size-4",
|
||||
selectedSingleRoundRag && "text-emerald-700 dark:text-emerald-200",
|
||||
)}
|
||||
/>
|
||||
单轮 RAG 问答
|
||||
</div>
|
||||
<div className="pl-7 text-xs">
|
||||
基于知识库检索生成答案,并展示思考内容与参考资料
|
||||
</div>
|
||||
</div>
|
||||
{selectedSingleRoundRag ? (
|
||||
<CheckIcon className="ml-auto size-4" />
|
||||
) : (
|
||||
<div className="ml-auto size-4" />
|
||||
)}
|
||||
</PromptInputActionMenuItem>
|
||||
</>
|
||||
)}
|
||||
</PromptInputActionMenu>
|
||||
</DropdownMenuGroup>
|
||||
</PromptInputActionMenuContent>
|
||||
</PromptInputActionMenu>
|
||||
@@ -1146,7 +1238,7 @@ export function InputBox({
|
||||
<ModelSelectorContent>
|
||||
<ModelSelectorInput placeholder={t.inputBox.searchModels} />
|
||||
<ModelSelectorList>
|
||||
{models.map((m) => (
|
||||
{modelOptions.map((m) => (
|
||||
<ModelSelectorItem
|
||||
key={m.name}
|
||||
value={m.name}
|
||||
@@ -1154,9 +1246,11 @@ export function InputBox({
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<ModelSelectorName>{m.display_name}</ModelSelectorName>
|
||||
<span className="text-muted-foreground truncate text-[10px]">
|
||||
{m.model}
|
||||
</span>
|
||||
<span className="text-muted-foreground truncate text-[10px]">
|
||||
{selectedSingleRoundRag
|
||||
? `单轮知识库问答 · ${m.name}`
|
||||
: m.model}
|
||||
</span>
|
||||
</div>
|
||||
{m.name === context.model_name ? (
|
||||
<CheckIcon className="ml-auto size-4" />
|
||||
@@ -1164,8 +1258,8 @@ export function InputBox({
|
||||
<div className="ml-auto size-4" />
|
||||
)}
|
||||
</ModelSelectorItem>
|
||||
))}
|
||||
</ModelSelectorList>
|
||||
))}
|
||||
</ModelSelectorList>
|
||||
</ModelSelectorContent>
|
||||
</ModelSelector>
|
||||
<PromptInputSubmit
|
||||
|
||||
@@ -33,7 +33,8 @@ import {
|
||||
} from "@/core/skill-display/runtime";
|
||||
import type { Subtask } from "@/core/tasks";
|
||||
import { useUpdateSubtask } from "@/core/tasks/context";
|
||||
import type { AgentThreadState } from "@/core/threads";
|
||||
import type { AgentThreadState } from "@/core/threads";
|
||||
import { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
|
||||
|
||||
@@ -235,12 +236,17 @@ export function MessageList({
|
||||
}, [messages, askTimes]);
|
||||
|
||||
const [settings] = useLocalSettings();
|
||||
const ragModeEnabled =
|
||||
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
|
||||
const { skills } = useSkills();
|
||||
const skillDisplayMap = useMemo(
|
||||
() => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
||||
[ragModeEnabled, skills],
|
||||
const ragModeEnabled =
|
||||
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
|
||||
const hasSingleRoundRag = useMemo(
|
||||
() => messages.some(isSingleRoundRagMessage),
|
||||
[messages],
|
||||
);
|
||||
const referencesEnabled = ragModeEnabled || hasSingleRoundRag;
|
||||
const { skills } = useSkills();
|
||||
const skillDisplayMap = useMemo(
|
||||
() => (referencesEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
||||
[referencesEnabled, skills],
|
||||
);
|
||||
// Same per-turn reference batches the right-side panel renders (backend
|
||||
// batches preferred). Inline [n] tokens must resolve against these so badge
|
||||
@@ -373,7 +379,7 @@ export function MessageList({
|
||||
if (group.type === "human" || group.type === "assistant") {
|
||||
return group.messages.map((msg) => {
|
||||
const isAssistantMessage =
|
||||
ragModeEnabled &&
|
||||
referencesEnabled &&
|
||||
group.type === "assistant" &&
|
||||
msg.type === "ai";
|
||||
const batchSources = isAssistantMessage
|
||||
|
||||
@@ -46,20 +46,23 @@ function getModeDescriptionKey(
|
||||
}
|
||||
}
|
||||
|
||||
export function ModeHoverGuide({
|
||||
mode,
|
||||
children,
|
||||
showTitle = true,
|
||||
}: {
|
||||
mode: AgentMode;
|
||||
children: React.ReactNode;
|
||||
/** When true, tooltip shows "ModeName: Description". When false, only description. */
|
||||
showTitle?: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const label = t.inputBox[getModeLabelKey(mode)];
|
||||
const description = t.inputBox[getModeDescriptionKey(mode)];
|
||||
const content = showTitle ? `${label}: ${description}` : description;
|
||||
export function ModeHoverGuide({
|
||||
mode,
|
||||
children,
|
||||
showTitle = true,
|
||||
tooltipContent,
|
||||
}: {
|
||||
mode: AgentMode;
|
||||
children: React.ReactNode;
|
||||
/** When true, tooltip shows "ModeName: Description". When false, only description. */
|
||||
showTitle?: boolean;
|
||||
/** Overrides the standard mode tooltip for page-specific execution modes. */
|
||||
tooltipContent?: string;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const label = t.inputBox[getModeLabelKey(mode)];
|
||||
const description = t.inputBox[getModeDescriptionKey(mode)];
|
||||
const content = tooltipContent ?? (showTitle ? `${label}: ${description}` : description);
|
||||
|
||||
return <Tooltip content={content}>{children}</Tooltip>;
|
||||
}
|
||||
@@ -184,7 +184,7 @@ export function WorkspaceNavChatList({
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
{/* <SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={pathname.startsWith("/page/workspace/roundtable/position")}
|
||||
asChild
|
||||
@@ -195,7 +195,7 @@ export function WorkspaceNavChatList({
|
||||
<span>岗位会商</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenuItem> */}
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={pathname === "/page/workspace/scheduled/tasks"}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Model } from "./types";
|
||||
|
||||
export const SINGLE_ROUND_RAG_MODELS: Model[] = [
|
||||
{ id: "single-round-rag-openai3", name: "OpenAI3", model: "OpenAI3", display_name: "Qwen3-32B", supports_thinking: true, execution_profile: "single_round_rag" },
|
||||
{ id: "single-round-rag-openai4", name: "OpenAI4", model: "OpenAI4", display_name: "Qwen3-235B", supports_thinking: true, execution_profile: "single_round_rag" },
|
||||
{ id: "single-round-rag-openai5", name: "OpenAI5", model: "OpenAI5", display_name: "GLM-5", supports_thinking: true, execution_profile: "single_round_rag" },
|
||||
{ id: "single-round-rag-openai6", name: "OpenAI6", model: "OpenAI6", display_name: "Qwen3.5-27B", supports_thinking: true, execution_profile: "single_round_rag" },
|
||||
{ id: "single-round-rag-openai7", name: "OpenAI7", model: "OpenAI7", display_name: "Qwen3-8B", supports_thinking: true, execution_profile: "single_round_rag" },
|
||||
{ id: "single-round-rag-openai9", name: "OpenAI9", model: "OpenAI9", display_name: "四季7B", supports_thinking: true, execution_profile: "single_round_rag" },
|
||||
];
|
||||
|
||||
export function isSingleRoundRagProfile(
|
||||
context: unknown,
|
||||
): boolean {
|
||||
return (
|
||||
typeof context === "object" &&
|
||||
context !== null &&
|
||||
(context as { execution_profile?: unknown }).execution_profile ===
|
||||
"single_round_rag"
|
||||
);
|
||||
}
|
||||
@@ -4,9 +4,11 @@ export interface Model {
|
||||
model: string;
|
||||
display_name: string;
|
||||
description?: string | null;
|
||||
supports_thinking?: boolean;
|
||||
supports_reasoning_effort?: boolean;
|
||||
}
|
||||
supports_thinking?: boolean;
|
||||
supports_reasoning_effort?: boolean;
|
||||
/** Execution profiles handled by the frontend instead of DeerFlow. */
|
||||
execution_profile?: "single_round_rag";
|
||||
}
|
||||
|
||||
export interface TokenUsageSettings {
|
||||
enabled: boolean;
|
||||
|
||||
@@ -23,9 +23,11 @@ export interface AgentThreadContext extends Record<string, unknown> {
|
||||
rag_mode_enabled?: boolean;
|
||||
/** Per-request switch for context compression (上下文压缩). Default off. */
|
||||
summarization_enabled?: boolean;
|
||||
/** Per-conversation switch for knowledge-base RAG injection (phase 2). */
|
||||
knowledge_rag_enabled?: boolean;
|
||||
agent_id?: string;
|
||||
/** Per-conversation switch for knowledge-base RAG injection (phase 2). */
|
||||
knowledge_rag_enabled?: boolean;
|
||||
/** Direct knowledge-base RAG mode; this request never enters Lead Agent. */
|
||||
execution_profile?: "single_round_rag";
|
||||
agent_id?: string;
|
||||
/** @deprecated kept for reading legacy thread metadata; prefer agent_id */
|
||||
agent_name?: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { readRuntimeEnv } from "@/env";
|
||||
|
||||
const STORAGE_PREFIX = "workspace.single-round-rag.";
|
||||
|
||||
type KnowledgeBaseChunk = {
|
||||
answer?: unknown;
|
||||
docs?: unknown;
|
||||
};
|
||||
|
||||
type RagSource = {
|
||||
id?: string;
|
||||
title: string;
|
||||
type: string;
|
||||
snippet: string;
|
||||
content: string;
|
||||
source?: string;
|
||||
time?: string;
|
||||
url?: string;
|
||||
raw: Record<string, unknown>;
|
||||
};
|
||||
|
||||
/** Same JSON-object framing used by the established GeneralQA single-RAG flow. */
|
||||
function extractJsonObjects(raw: string): { objects: string[]; rest: string } {
|
||||
const objects: string[] = [];
|
||||
let depth = 0;
|
||||
let stringQuote: '"' | "'" | null = null;
|
||||
let escaped = false;
|
||||
let start = -1;
|
||||
for (let index = 0; index < raw.length; index += 1) {
|
||||
const char = raw[index]!;
|
||||
if (stringQuote) {
|
||||
if (escaped) escaped = false;
|
||||
else if (char === "\\") escaped = true;
|
||||
else if (char === stringQuote) stringQuote = null;
|
||||
continue;
|
||||
}
|
||||
if (char === '"' || char === "'") {
|
||||
stringQuote = char;
|
||||
continue;
|
||||
}
|
||||
if (char === "{") {
|
||||
if (depth === 0) start = index;
|
||||
depth += 1;
|
||||
} else if (char === "}") {
|
||||
depth -= 1;
|
||||
if (depth === 0 && start !== -1) {
|
||||
objects.push(raw.slice(start, index + 1));
|
||||
start = -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return depth > 0 && start !== -1
|
||||
? { objects, rest: raw.slice(start) }
|
||||
: { objects, rest: "" };
|
||||
}
|
||||
|
||||
/** Same `<think>` split semantics as GeneralQA, kept local to avoid loading its demo data. */
|
||||
function parseSingleRagStream(chunks: { answer: string }[]) {
|
||||
let thinkingMarkdown = "";
|
||||
let answerMarkdown = "";
|
||||
let inThink = false;
|
||||
let thinkingComplete = false;
|
||||
for (const { answer } of chunks) {
|
||||
let remaining = answer;
|
||||
while (remaining) {
|
||||
if (inThink) {
|
||||
const endIndex = remaining.indexOf("</think>");
|
||||
if (endIndex === -1) {
|
||||
thinkingMarkdown += remaining;
|
||||
remaining = "";
|
||||
} else {
|
||||
thinkingMarkdown += remaining.slice(0, endIndex);
|
||||
remaining = remaining.slice(endIndex + "</think>".length);
|
||||
inThink = false;
|
||||
thinkingComplete = true;
|
||||
}
|
||||
} else {
|
||||
const startIndex = remaining.indexOf("<think>");
|
||||
if (startIndex === -1) {
|
||||
answerMarkdown += remaining;
|
||||
remaining = "";
|
||||
} else {
|
||||
answerMarkdown += remaining.slice(0, startIndex);
|
||||
remaining = remaining.slice(startIndex + "<think>".length);
|
||||
inThink = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { thinkingMarkdown, answerMarkdown, thinkingComplete };
|
||||
}
|
||||
|
||||
function storageKey(threadId: string) {
|
||||
return `${STORAGE_PREFIX}${threadId}`;
|
||||
}
|
||||
|
||||
function readStoredMessages(threadId: string): Message[] {
|
||||
if (typeof window === "undefined" || !threadId) return [];
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(storageKey(threadId));
|
||||
const parsed: unknown = raw ? JSON.parse(raw) : [];
|
||||
return Array.isArray(parsed) ? (parsed as Message[]) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writeStoredMessages(threadId: string, messages: Message[]) {
|
||||
if (typeof window === "undefined" || !threadId) return;
|
||||
try {
|
||||
window.sessionStorage.setItem(storageKey(threadId), JSON.stringify(messages));
|
||||
} catch {
|
||||
// Quota errors must never interrupt an already-running answer.
|
||||
}
|
||||
}
|
||||
|
||||
function parseLooseStringValue(rawValue: string): string {
|
||||
try {
|
||||
return JSON.parse(rawValue) as string;
|
||||
} catch {
|
||||
const value = rawValue.trim();
|
||||
if (value.startsWith("'") && value.endsWith("'")) {
|
||||
return value
|
||||
.slice(1, -1)
|
||||
.replace(/\\n/g, "\n")
|
||||
.replace(/\\r/g, "\r")
|
||||
.replace(/\\t/g, "\t")
|
||||
.replace(/\\'/g, "'")
|
||||
.replace(/\\\\/g, "\\");
|
||||
}
|
||||
throw new Error(`无法解析知识库流片段:${rawValue}`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseKnowledgeBaseChunk(jsonText: string): KnowledgeBaseChunk {
|
||||
try {
|
||||
return JSON.parse(jsonText) as KnowledgeBaseChunk;
|
||||
} catch {
|
||||
const objectText = jsonText.trim();
|
||||
const answerMatch = objectText.match(/^\{\s*answer\s*:\s*([\s\S]*)\s*\}$/);
|
||||
if (answerMatch) {
|
||||
return { answer: parseLooseStringValue(answerMatch[1]!.trim()) };
|
||||
}
|
||||
const docsMatch = objectText.match(/^\{\s*docs\s*:\s*([\s\S]*)\s*\}$/);
|
||||
if (docsMatch) {
|
||||
const rawValue = docsMatch[1]!.trim();
|
||||
try {
|
||||
return { docs: JSON.parse(rawValue) };
|
||||
} catch {
|
||||
return { docs: parseLooseStringValue(rawValue) };
|
||||
}
|
||||
}
|
||||
const normalizedText = objectText.replace(
|
||||
/([{,]\s*)(answer|docs)(\s*:)/g,
|
||||
'$1"$2"$3',
|
||||
);
|
||||
return JSON.parse(normalizedText) as KnowledgeBaseChunk;
|
||||
}
|
||||
}
|
||||
|
||||
function flattenDocs(value: unknown): Record<string, unknown>[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value.flatMap((item) => flattenDocs(item));
|
||||
}
|
||||
if (!value || typeof value !== "object") return [];
|
||||
const record = value as Record<string, unknown>;
|
||||
if ("docs" in record) return flattenDocs(record.docs);
|
||||
return [record];
|
||||
}
|
||||
|
||||
function normalizeSources(value: unknown): RagSource[] {
|
||||
return flattenDocs(value).map((item, index) => {
|
||||
const title = String(item.m_title ?? item.title ?? item.source ?? `参考资料 ${index + 1}`);
|
||||
const content = String(item.m_content ?? item.content ?? item.snippet ?? "");
|
||||
const source = item.source1 ?? item.source;
|
||||
const rawUrl = item.url ?? item.link ?? item.href;
|
||||
return {
|
||||
id: String(item.recUuid ?? item.rec_uuid ?? item.uuid ?? item.id ?? "") || undefined,
|
||||
title,
|
||||
type: String(item.source1 ?? item.type ?? "知识库"),
|
||||
snippet: content,
|
||||
content,
|
||||
source: typeof source === "string" ? source : undefined,
|
||||
time: typeof item.m_publish === "string" ? item.m_publish : undefined,
|
||||
url: typeof rawUrl === "string" && /^https?:\/\//i.test(rawUrl) ? rawUrl : undefined,
|
||||
raw: item,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function createId(prefix: string) {
|
||||
return `${prefix}-${crypto.randomUUID()}`;
|
||||
}
|
||||
|
||||
function isAbortError(error: unknown) {
|
||||
return error instanceof DOMException && error.name === "AbortError";
|
||||
}
|
||||
|
||||
export function isSingleRoundRagMessage(message: Message): boolean {
|
||||
return message.additional_kwargs?.single_round_rag === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Frontend-only adapter for the existing general-QA single-round endpoint.
|
||||
* It deliberately never submits a run to DeerFlow or mutates the Lead Agent.
|
||||
*/
|
||||
export function useSingleRoundRagMessages(threadId: string) {
|
||||
const [messages, setMessages] = useState<Message[]>(() => readStoredMessages(threadId));
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const controllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setMessages(readStoredMessages(threadId));
|
||||
setError(null);
|
||||
}, [threadId]);
|
||||
|
||||
useEffect(() => () => controllerRef.current?.abort(), []);
|
||||
|
||||
const updateMessages = useCallback(
|
||||
(targetThreadId: string, updater: (current: Message[]) => Message[]) => {
|
||||
setMessages((current) => {
|
||||
const next = updater(current);
|
||||
writeStoredMessages(targetThreadId, next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const stop = useCallback(() => controllerRef.current?.abort(), []);
|
||||
|
||||
const submit = useCallback(
|
||||
async ({
|
||||
targetThreadId,
|
||||
query,
|
||||
modelName,
|
||||
}: {
|
||||
targetThreadId: string;
|
||||
query: string;
|
||||
modelName: string;
|
||||
}) => {
|
||||
if (isLoading) return;
|
||||
const question = query.trim();
|
||||
if (!question) return;
|
||||
|
||||
const humanId = createId("single-rag-human");
|
||||
const assistantId = createId("single-rag-ai");
|
||||
const sourceId = createId("single-rag-sources");
|
||||
const clientTs = new Date().toISOString();
|
||||
const userMessage: Message = {
|
||||
id: humanId,
|
||||
type: "human",
|
||||
content: [{ type: "text", text: question }],
|
||||
additional_kwargs: { client_ts: clientTs, single_round_rag: true },
|
||||
};
|
||||
const initialAssistant: Message = {
|
||||
id: assistantId,
|
||||
type: "ai",
|
||||
content: "",
|
||||
additional_kwargs: { single_round_rag: true },
|
||||
};
|
||||
updateMessages(targetThreadId, (current) => [...current, userMessage, initialAssistant]);
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
const controller = new AbortController();
|
||||
controllerRef.current = controller;
|
||||
const answerChunks: { answer: string }[] = [];
|
||||
let sources: RagSource[] = [];
|
||||
|
||||
const refreshAssistant = () => {
|
||||
const trace = parseSingleRagStream(answerChunks);
|
||||
updateMessages(targetThreadId, (current) => {
|
||||
const next = current.filter((message) => message.id !== sourceId);
|
||||
const assistantIndex = next.findIndex((message) => message.id === assistantId);
|
||||
if (assistantIndex < 0) return current;
|
||||
next[assistantIndex] = {
|
||||
...next[assistantIndex],
|
||||
content: trace.answerMarkdown,
|
||||
additional_kwargs: {
|
||||
...next[assistantIndex]!.additional_kwargs,
|
||||
single_round_rag: true,
|
||||
reasoning_content: trace.thinkingMarkdown || null,
|
||||
},
|
||||
} as Message;
|
||||
if (sources.length > 0) {
|
||||
next.splice(assistantIndex, 0, {
|
||||
id: sourceId,
|
||||
type: "tool",
|
||||
name: "knowledge_base_search",
|
||||
content: { results: sources },
|
||||
additional_kwargs: {
|
||||
hide_from_ui: true,
|
||||
single_round_rag: true,
|
||||
},
|
||||
} as unknown as Message);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
const chatUrl = readRuntimeEnv("VITE_AIPI_CHAT_URL") || "";
|
||||
if (!chatUrl) {
|
||||
throw new Error("未配置 VITE_AIPI_CHAT_URL,无法发起单轮 RAG 问答。");
|
||||
}
|
||||
const response = await fetch(
|
||||
`${chatUrl.replace(/\/$/, "")}/chat/knowledge_base_chat_tiaoshi`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
signal: controller.signal,
|
||||
body: JSON.stringify({
|
||||
query: question,
|
||||
knowledge_base_name: [],
|
||||
top_k: 5,
|
||||
score_threshold: 0.5,
|
||||
history: [],
|
||||
stream: true,
|
||||
model_name: modelName,
|
||||
temperature: 0.3,
|
||||
prompt_name: "default",
|
||||
local_doc_url: false,
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!response.ok) throw new Error(`知识库服务请求失败(${response.status})`);
|
||||
if (!response.body) throw new Error("知识库服务未返回流式响应。");
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const extracted = extractJsonObjects(buffer);
|
||||
buffer = extracted.rest;
|
||||
for (const jsonText of extracted.objects) {
|
||||
const chunk = parseKnowledgeBaseChunk(jsonText);
|
||||
if (typeof chunk.answer === "string") answerChunks.push({ answer: chunk.answer });
|
||||
if (chunk.docs !== undefined) sources = normalizeSources(chunk.docs);
|
||||
refreshAssistant();
|
||||
}
|
||||
}
|
||||
} catch (caught) {
|
||||
if (!isAbortError(caught)) {
|
||||
const message = caught instanceof Error ? caught.message : "单轮 RAG 问答失败。";
|
||||
setError(message);
|
||||
updateMessages(targetThreadId, (current) =>
|
||||
current.map((item) =>
|
||||
item.id === assistantId
|
||||
? ({
|
||||
...item,
|
||||
content: answerChunks.length > 0 ? item.content : message,
|
||||
additional_kwargs: {
|
||||
...item.additional_kwargs,
|
||||
single_round_rag: true,
|
||||
},
|
||||
} as Message)
|
||||
: item,
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (controllerRef.current === controller) controllerRef.current = null;
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[isLoading, updateMessages],
|
||||
);
|
||||
|
||||
return { messages, isLoading, error, submit, stop };
|
||||
}
|
||||
@@ -44,6 +44,10 @@ import { PanelLeftOpen } from "lucide-react";
|
||||
import { useAgentSelector, useAgentsPaginated } from "@/core/agents";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import {
|
||||
isSingleRoundRagProfile,
|
||||
SINGLE_ROUND_RAG_MODELS,
|
||||
} from "@/core/models/single-round-rag";
|
||||
import { useNotification } from "@/core/notification/hooks";
|
||||
import {
|
||||
getLastAgentId,
|
||||
@@ -52,6 +56,7 @@ import {
|
||||
useThreadSettings,
|
||||
} from "@/core/settings";
|
||||
import { useThreadStream } from "@/core/threads/hooks";
|
||||
import { useSingleRoundRagMessages } from "@/core/threads/use-single-round-rag";
|
||||
import { useLayoutMode } from "@/core/system-settings/hooks";
|
||||
import { useRouteBase } from "@/core/route-base";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
@@ -226,6 +231,20 @@ export default function ChatPage() {
|
||||
}
|
||||
},
|
||||
});
|
||||
const singleRoundRag = useSingleRoundRagMessages(threadId);
|
||||
const singleRoundRagSelected = isSingleRoundRagProfile(settings.context);
|
||||
const displayThread = useMemo(() => {
|
||||
if (singleRoundRag.messages.length === 0 && !singleRoundRag.isLoading) {
|
||||
return thread;
|
||||
}
|
||||
const messages = [...thread.messages, ...singleRoundRag.messages];
|
||||
return {
|
||||
...thread,
|
||||
values: { ...thread.values, messages },
|
||||
messages,
|
||||
isLoading: thread.isLoading || singleRoundRag.isLoading,
|
||||
} as typeof thread;
|
||||
}, [singleRoundRag.isLoading, singleRoundRag.messages, thread]);
|
||||
|
||||
const writingArtifactPath = useMemo(() => {
|
||||
const isMarkdown = (path: string | null | undefined) =>
|
||||
@@ -240,6 +259,46 @@ export default function ChatPage() {
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(message: PromptInputMessage) => {
|
||||
if (singleRoundRagSelected) {
|
||||
if (message.files && message.files.length > 0) {
|
||||
toast.error("单轮 RAG 问答暂不支持附件,请移除附件后重试。");
|
||||
return;
|
||||
}
|
||||
setHasStartedConversation(true);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("workspace:sandbox-resume-autoscroll"),
|
||||
);
|
||||
void (async () => {
|
||||
try {
|
||||
let targetThreadId = threadId;
|
||||
if (isNewThread) {
|
||||
const created = await getAPIClient(isMock).threads.create({});
|
||||
targetThreadId = created.thread_id;
|
||||
setThreadId(targetThreadId);
|
||||
setIsNewThread(false);
|
||||
history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`${window.location.pathname}${window.location.search}#${routeBase}/chats/${targetThreadId}`,
|
||||
);
|
||||
const title = message.text.trim().replace(/\s+/g, " ").slice(0, 60);
|
||||
if (title) {
|
||||
void getAPIClient(isMock).threads
|
||||
.updateState(targetThreadId, { values: { title } })
|
||||
.catch(() => undefined);
|
||||
}
|
||||
}
|
||||
await singleRoundRag.submit({
|
||||
targetThreadId,
|
||||
query: message.text,
|
||||
modelName: settings.context.model_name ?? "OpenAI3",
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "单轮 RAG 问答失败。");
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
// If the user picked an agent before sending the opening turn, hand off
|
||||
// to AgentChatPage so the conversation runs against that agent. We stash
|
||||
// the pending text in sessionStorage because the next/navigation shim
|
||||
@@ -332,7 +391,13 @@ export default function ChatPage() {
|
||||
isMock,
|
||||
router,
|
||||
settings.context.writing_mode,
|
||||
settings.context.model_name,
|
||||
writingArtifactPath,
|
||||
singleRoundRag,
|
||||
singleRoundRagSelected,
|
||||
routeBase,
|
||||
setIsNewThread,
|
||||
setThreadId,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -421,8 +486,12 @@ export default function ChatPage() {
|
||||
saveLastAgentId(agentId);
|
||||
}, []);
|
||||
const handleStop = useCallback(async () => {
|
||||
if (singleRoundRag.isLoading) {
|
||||
singleRoundRag.stop();
|
||||
return;
|
||||
}
|
||||
await thread.stop();
|
||||
}, [thread]);
|
||||
}, [singleRoundRag, thread]);
|
||||
|
||||
const promptPrefixProp = useMemo(() => {
|
||||
if (!promptPrefixSettings?.enabled) return undefined;
|
||||
@@ -468,7 +537,7 @@ export default function ChatPage() {
|
||||
const compactActionsHidden = isHideChromeActive();
|
||||
|
||||
return (
|
||||
<ThreadContext.Provider value={{ thread, isMock }}>
|
||||
<ThreadContext.Provider value={{ thread: displayThread, isMock }}>
|
||||
<ChatBox threadId={threadId}>
|
||||
<div
|
||||
className={cn(
|
||||
@@ -486,7 +555,7 @@ export default function ChatPage() {
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full items-center text-sm font-medium">
|
||||
<ThreadTitle threadId={threadId} thread={thread} />
|
||||
<ThreadTitle threadId={threadId} thread={displayThread} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 仅在收起态显示「展开历史」钮;展开后由侧栏内(新对话右侧)的收起钮负责收起。 */}
|
||||
@@ -505,7 +574,7 @@ export default function ChatPage() {
|
||||
)}
|
||||
<TokenUsageIndicator
|
||||
enabled={tokenUsageEnabled}
|
||||
messages={thread.messages}
|
||||
messages={displayThread.messages}
|
||||
/>
|
||||
<ExportTrigger threadId={threadId} />
|
||||
<KnowledgeSaveTrigger threadId={threadId} />
|
||||
@@ -543,7 +612,7 @@ export default function ChatPage() {
|
||||
<MessageList
|
||||
className={cn("size-full", "pt-10")}
|
||||
threadId={threadId}
|
||||
thread={thread}
|
||||
thread={displayThread}
|
||||
paddingBottom={messageListPaddingBottom}
|
||||
tokenUsageEnabled={tokenUsageEnabled}
|
||||
// 输入框为底部绝对浮层(约 160px 高),按钮上移到其上方居中。
|
||||
@@ -585,9 +654,9 @@ export default function ChatPage() {
|
||||
<div className="absolute right-0 bottom-0 left-0">
|
||||
<TodoList
|
||||
className="bg-background/5"
|
||||
todos={thread.values.todos ?? []}
|
||||
todos={displayThread.values.todos ?? []}
|
||||
hidden={
|
||||
!thread.values.todos || thread.values.todos.length === 0
|
||||
!displayThread.values.todos || displayThread.values.todos.length === 0
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -612,15 +681,17 @@ export default function ChatPage() {
|
||||
status={
|
||||
thread.error
|
||||
? "error"
|
||||
: thread.isLoading
|
||||
: displayThread.isLoading
|
||||
? "streaming"
|
||||
: "ready"
|
||||
}
|
||||
context={settings.context}
|
||||
extraModels={SINGLE_ROUND_RAG_MODELS}
|
||||
disabled={
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
|
||||
isUploading ||
|
||||
agentHandoffPending
|
||||
agentHandoffPending ||
|
||||
singleRoundRag.isLoading
|
||||
}
|
||||
onContextChange={(context) =>
|
||||
setSettings("context", context)
|
||||
@@ -664,7 +735,7 @@ export default function ChatPage() {
|
||||
*/}
|
||||
{!isLandingState && (
|
||||
<QuestionNavigator
|
||||
messages={thread.messages}
|
||||
messages={displayThread.messages}
|
||||
className="top-16 right-0 bottom-48 left-0"
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user