Merge branch 'master-nj' into 'master'

feat: 添加管理员模型配置页面与 config.yaml 模型管理 API

See merge request knowledge-base-bj/deerflow-code!19
This commit is contained in:
董宏凯 committed 2026-08-12 06:44:18 +00:00
commit 2a869f4ff6
20 files changed
+1720 -179

No files matched your search

+5 -2
View File
@@ -80,8 +80,11 @@ offline-backend-20260512/backend/skill_evolution.json
skills/
hermes-agent-main/
.gitignore
offline-backend-20260512/backend/tests/*
deploy
offline-backend-20260512/backend/tests/*
# Keep the model configuration regression coverage versioned even though
# this deployment repository otherwise ignores newly added backend tests.
!offline-backend-20260512/backend/tests/test_model_management.py
deploy
.history
# Cloned third-party repos (local only)
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import {
Bell,
Cpu,
ChevronLeft,
ChevronRight,
ChevronsUpDown,
@@ -252,6 +253,14 @@ function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) {
白名单管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/models")}
>
<Cpu className="size-4" />
模型配置
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/strategy/settings/model-concurrency")}
@@ -555,6 +564,12 @@ function LegacyAccordionSidebar({
岗位角色配置
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem onClick={() => navigate("/page/workspace/admin/models")}>
<Cpu className="size-4" />
模型配置
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/login-whitelist")}>
<ShieldCheck className="size-4" />
@@ -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,107 @@
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
export interface ManagedModel {
name: string;
display_name: string | null;
description: string | null;
use: string;
model: string;
base_url: string | null;
request_timeout: number | null;
max_retries: number | null;
max_tokens: number | null;
temperature: number | null;
stream_usage: boolean | null;
supports_thinking: boolean;
supports_reasoning_effort: boolean;
supports_vision: boolean;
when_thinking_enabled: Record<string, unknown> | null;
when_thinking_disabled: Record<string, unknown> | null;
thinking: Record<string, unknown> | null;
use_responses_api: boolean | null;
output_version: string | null;
extra_settings: Record<string, unknown>;
api_key_configured: boolean;
api_key_source: string | null;
source: "config.yaml";
}
export interface ManagedModelsResponse {
models: ManagedModel[];
config_path: string;
updated_at: string | null;
}
export interface ModelSavePayload {
name: string;
display_name?: string | null;
description?: string | null;
use: string;
model: string;
api_key?: string;
clear_api_key?: boolean;
base_url?: string | null;
request_timeout?: number | null;
max_retries?: number | null;
max_tokens?: number | null;
temperature?: number | null;
stream_usage?: boolean | null;
supports_thinking: boolean;
supports_reasoning_effort: boolean;
supports_vision: boolean;
when_thinking_enabled?: Record<string, unknown> | null;
when_thinking_disabled?: Record<string, unknown> | null;
thinking?: Record<string, unknown> | null;
use_responses_api?: boolean | null;
output_version?: string | null;
extra_settings: Record<string, unknown>;
}
export interface ModelConnectionTestResult {
success: boolean;
latency_ms: number;
message: string;
preview?: string | null;
}
async function readError(response: Response, fallback: string): Promise<never> {
const payload = await response.json().catch(() => null) as { detail?: string } | null;
throw new Error(payload?.detail || fallback);
}
export async function loadManagedModels(): Promise<ManagedModelsResponse> {
const response = await apiFetch(`${getBackendBaseURL()}/api/admin/models`);
if (!response.ok) return readError(response, "加载模型配置失败");
return response.json() as Promise<ManagedModelsResponse>;
}
export async function createManagedModel(payload: ModelSavePayload): Promise<ManagedModel> {
const response = await apiFetch(`${getBackendBaseURL()}/api/admin/models`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) return readError(response, "创建模型失败");
return response.json() as Promise<ManagedModel>;
}
export async function updateManagedModel(name: string, payload: ModelSavePayload): Promise<ManagedModel> {
const response = await apiFetch(`${getBackendBaseURL()}/api/admin/models/${encodeURIComponent(name)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) return readError(response, "保存模型配置失败");
return response.json() as Promise<ManagedModel>;
}
export async function testManagedModel(model: ModelSavePayload): Promise<ModelConnectionTestResult> {
const response = await apiFetch(`${getBackendBaseURL()}/api/admin/models/test`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model }),
});
if (!response.ok) return readError(response, "模型连接测试失败");
return response.json() as Promise<ModelConnectionTestResult>;
}
@@ -0,0 +1,38 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
createManagedModel,
loadManagedModels,
testManagedModel,
updateManagedModel,
type ModelSavePayload,
} from "./api";
export const MANAGED_MODELS_QUERY_KEY = ["admin", "model-config"] as const;
export function useManagedModels() {
return useQuery({
queryKey: MANAGED_MODELS_QUERY_KEY,
queryFn: loadManagedModels,
staleTime: 10_000,
refetchOnWindowFocus: false,
});
}
export function useSaveManagedModel() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ name, payload, isNew }: { name: string; payload: ModelSavePayload; isNew: boolean }) =>
isNew ? createManagedModel(payload) : updateManagedModel(name, payload),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: MANAGED_MODELS_QUERY_KEY }),
queryClient.invalidateQueries({ queryKey: ["models"] }),
]);
},
});
}
export function useTestManagedModel() {
return useMutation({ mutationFn: testManagedModel });
}
@@ -0,0 +1,2 @@
export * from "./api";
export * from "./hooks";
@@ -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"
);
}
+5 -3
View File
@@ -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;
+5 -3
View File
@@ -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 };
}
+81 -10
View File
@@ -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"
/>
)}
@@ -0,0 +1,246 @@
import {
BotIcon,
ChevronDownIcon,
CircleAlertIcon,
CpuIcon,
EyeIcon,
KeyRoundIcon,
LoaderCircleIcon,
PlusIcon,
RefreshCwIcon,
Settings2Icon,
SparklesIcon,
WrenchIcon,
} from "lucide-react";
import { useMemo, useState, type ReactNode } from "react";
import {
Button as TButton,
Dialog,
Input,
InputNumber,
MessagePlugin,
Select,
Switch,
Tag,
Textarea,
} from "tdesign-react";
import {
type ManagedModel,
type ModelSavePayload,
useManagedModels,
useSaveManagedModel,
useTestManagedModel,
} from "@/core/model-management";
import {
WorkspaceBody,
WorkspaceContainer,
WorkspaceHeader,
} from "@/components/workspace/workspace-container";
type ModelDraft = ModelSavePayload & {
api_key: string;
keep_existing_key: boolean;
extra_settings_text: string;
};
const PROVIDER_OPTIONS = [
{ label: "OpenAI 兼容接口", value: "langchain_openai:ChatOpenAI" },
{ label: "Anthropic", value: "langchain_anthropic:ChatAnthropic" },
{ label: "Google Gemini", value: "langchain_google_genai:ChatGoogleGenerativeAI" },
{ label: "Groq", value: "langchain_groq:ChatGroq" },
{ label: "Ollama", value: "langchain_ollama:ChatOllama" },
];
const emptyDraft = (): ModelDraft => ({
name: "",
display_name: "",
description: "",
use: "langchain_openai:ChatOpenAI",
model: "",
api_key: "",
keep_existing_key: false,
clear_api_key: false,
base_url: "",
request_timeout: 30,
max_retries: 2,
max_tokens: undefined,
temperature: undefined,
stream_usage: true,
supports_thinking: false,
supports_reasoning_effort: false,
supports_vision: false,
when_thinking_enabled: null,
when_thinking_disabled: null,
thinking: null,
use_responses_api: null,
output_version: null,
extra_settings: {},
extra_settings_text: "{}",
});
function draftFromModel(model: ManagedModel): ModelDraft {
return {
...model,
api_key: "",
keep_existing_key: model.api_key_configured,
clear_api_key: false,
extra_settings_text: JSON.stringify(model.extra_settings ?? {}, null, 2),
};
}
function optionalString(value: string | null | undefined): string | null {
const normalized = (value ?? "").trim();
return normalized || null;
}
function toNumber(value: number | string | null | undefined): number | undefined {
if (value === "" || value === null || value === undefined) return undefined;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
function buildPayload(draft: ModelDraft): ModelSavePayload {
let extraSettings: Record<string, unknown> = {};
try {
const parsed = JSON.parse(draft.extra_settings_text || "{}") as unknown;
if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") {
throw new Error("额外参数必须是 JSON 对象");
}
extraSettings = parsed as Record<string, unknown>;
} catch (error) {
throw error instanceof Error ? error : new Error("额外参数不是有效 JSON");
}
const payload: ModelSavePayload = {
name: draft.name.trim(),
display_name: optionalString(draft.display_name),
description: optionalString(draft.description),
use: draft.use.trim(),
model: draft.model.trim(),
clear_api_key: Boolean(draft.clear_api_key),
base_url: optionalString(draft.base_url),
request_timeout: toNumber(draft.request_timeout),
max_retries: toNumber(draft.max_retries),
max_tokens: toNumber(draft.max_tokens),
temperature: toNumber(draft.temperature),
stream_usage: draft.stream_usage,
supports_thinking: draft.supports_thinking,
supports_reasoning_effort: draft.supports_reasoning_effort,
supports_vision: draft.supports_vision,
when_thinking_enabled: draft.when_thinking_enabled,
when_thinking_disabled: draft.when_thinking_disabled,
thinking: draft.thinking,
use_responses_api: draft.use_responses_api,
output_version: optionalString(draft.output_version),
extra_settings: extraSettings,
};
if (draft.api_key.trim()) payload.api_key = draft.api_key.trim();
return payload;
}
function StatCard({ icon: Icon, label, value }: { icon: typeof CpuIcon; label: string; value: number }) {
return (
<div className="flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3 shadow-sm">
<span className="flex size-10 items-center justify-center rounded-lg bg-primary/10 text-primary"><Icon className="size-5" /></span>
<div><div className="text-xl font-semibold tabular-nums">{value}</div><div className="text-xs text-muted-foreground">{label}</div></div>
</div>
);
}
function Field({ label, required = false, children }: { label: string; required?: boolean; children: ReactNode }) {
return <label className="space-y-1.5 text-sm font-medium">{label}{required && <span className="ml-1 text-destructive">*</span>}{children}</label>;
}
function ModelEditor({ model, onClose }: { model: ManagedModel | null; onClose: () => void }) {
const isNew = model === null;
const [draft, setDraft] = useState<ModelDraft>(() => model ? draftFromModel(model) : emptyDraft());
const [advanced, setAdvanced] = useState(false);
const [testResult, setTestResult] = useState<{ success: boolean; latency: number; message: string; preview?: string | null } | null>(null);
const save = useSaveManagedModel();
const test = useTestManagedModel();
const patch = (next: Partial<ModelDraft>) => setDraft((current) => ({ ...current, ...next }));
const handleTest = async () => {
try {
const payload = buildPayload(draft);
const result = await test.mutateAsync(payload);
setTestResult({ success: result.success, latency: result.latency_ms, message: result.message, preview: result.preview });
if (!result.success) return void MessagePlugin.error(`连接失败:${result.message}`);
MessagePlugin.success(`连接成功${result.latency_ms ? `,${result.latency_ms} ms` : ""}`);
} catch (error) {
MessagePlugin.error(error instanceof Error ? error.message : "连接测试失败");
}
};
const handleSave = async () => {
try {
const payload = buildPayload(draft);
if (!payload.name || !payload.use || !payload.model) {
return void MessagePlugin.warning("请填写模型标识、驱动与实际模型名");
}
await save.mutateAsync({ name: model?.name ?? payload.name, payload, isNew });
MessagePlugin.success(isNew ? "模型已添加,所有页面的模型选择器已刷新" : "模型配置已保存并立即生效");
onClose();
} catch (error) {
MessagePlugin.error(error instanceof Error ? error.message : "保存模型配置失败");
}
};
return (
<Dialog
visible
header={isNew ? "添加模型" : `编辑模型:${model?.display_name || model?.name}`}
placement="center"
width={860}
zIndex={3200}
confirmBtn={{ content: isNew ? "添加并启用" : "保存配置", theme: "primary" }}
cancelBtn="取消"
confirmLoading={save.isPending}
onClose={onClose}
onConfirm={() => void handleSave()}
onCancel={onClose}
>
<div className="max-h-[70vh] space-y-5 overflow-y-auto px-1 py-1 pr-3">
<div className="rounded-lg border border-primary/20 bg-primary/5 px-3 py-2 text-sm text-muted-foreground">
保存会直接更新当前部署的 <code className="rounded bg-background px-1 py-0.5">config.yaml</code> 模型段,自动备份并热刷新;不会中断已开始的会话。
</div>
<section className="grid gap-4 md:grid-cols-2">
<Field label="稳定标识" required><Input value={draft.name} disabled={!isNew} placeholder="例如 deepseek-chat" tips={isNew ? "保存后不可修改,用于智能体和运行配置引用。" : "稳定标识不可修改,避免已有配置失效。"} onChange={(value) => patch({ name: String(value) })} /></Field>
<Field label="显示名称"><Input value={draft.display_name ?? ""} placeholder="例如 DeepSeek Chat" onChange={(value) => patch({ display_name: String(value) })} /></Field>
<Field label="模型驱动" required><Select value={draft.use} options={PROVIDER_OPTIONS} filterable creatable inputProps={{ placeholder: "选择或输入 use 类路径" }} onChange={(value) => patch({ use: String(value) })} /></Field>
<Field label="实际模型名" required><Input value={draft.model} placeholder="例如 deepseek-chat" onChange={(value) => patch({ model: String(value) })} /></Field>
<Field label="Base URL"><Input value={draft.base_url ?? ""} type="url" placeholder="https://api.example.com/v1" onChange={(value) => patch({ base_url: String(value) })} /></Field>
<Field label="API Key"><Input value={draft.api_key} type="password" placeholder={draft.keep_existing_key && !draft.clear_api_key ? "已配置,留空则保持不变" : "可填写密钥或 $ENVIRONMENT_VARIABLE"} onChange={(value) => patch({ api_key: String(value), clear_api_key: false })} /></Field>
{draft.keep_existing_key && !draft.api_key && <div className="md:col-span-2 flex items-center gap-2 text-xs text-muted-foreground"><KeyRoundIcon className="size-3.5" />已配置密钥{model?.api_key_source ? `(环境变量 ${model.api_key_source})` : ""},不会回显。<TButton theme="default" variant="text" size="small" onClick={() => patch({ clear_api_key: !draft.clear_api_key })}>{draft.clear_api_key ? "撤销清除" : "清除密钥"}</TButton></div>}
<div className="md:col-span-2"><Field label="描述"><Textarea value={draft.description ?? ""} autosize={{ minRows: 2, maxRows: 4 }} placeholder="说明模型适用的任务与限制" onChange={(value) => patch({ description: String(value) })} /></Field></div>
</section>
<section className="rounded-lg border border-border p-4">
<div className="mb-2"><h3 className="font-medium">能力声明</h3><p className="mt-1 text-xs text-muted-foreground">这些标识决定哪些页面会向模型提供思考、视觉或工具调用入口。</p></div>
<div className="flex flex-wrap gap-x-5 gap-y-3 text-sm"><span className="flex items-center gap-2"><Switch value={draft.supports_thinking} onChange={(value) => patch({ supports_thinking: Boolean(value) })} />支持思考</span><span className="flex items-center gap-2"><Switch value={draft.supports_vision} onChange={(value) => patch({ supports_vision: Boolean(value) })} />支持视觉</span><span className="flex items-center gap-2"><Switch value={draft.supports_reasoning_effort} onChange={(value) => patch({ supports_reasoning_effort: Boolean(value) })} />推理档位</span></div>
</section>
<div className="flex items-center justify-between border-t pt-4"><TButton theme="default" variant="outline" loading={test.isPending} onClick={() => void handleTest()}><WrenchIcon className="mr-1.5 size-4" />测试连接</TButton><TButton theme="default" variant="text" onClick={() => setAdvanced((value) => !value)}><Settings2Icon className="mr-1.5 size-4" />高级配置 <ChevronDownIcon className={`ml-1 size-4 transition-transform ${advanced ? "rotate-180" : ""}`} /></TButton></div>
{testResult && <div className={`rounded-lg border px-3 py-2 text-sm ${testResult.success ? "border-primary/20 bg-primary/5 text-foreground" : "border-destructive/25 bg-destructive/5 text-destructive"}`}><div className="font-medium">{testResult.success ? "连接成功" : "连接失败"}{testResult.success && testResult.latency ? ` · ${testResult.latency} ms` : ""}</div><p className="mt-1 break-words text-xs opacity-80">{testResult.preview || testResult.message}</p></div>}
{advanced && <section className="space-y-4 rounded-lg border border-dashed border-border bg-muted/10 p-4"><div className="grid gap-4 md:grid-cols-3"><Field label="请求超时(秒)"><InputNumber value={draft.request_timeout ?? ""} min={1} max={600} onChange={(value) => patch({ request_timeout: toNumber(value) })} /></Field><Field label="最大重试次数"><InputNumber value={draft.max_retries ?? ""} min={0} max={20} onChange={(value) => patch({ max_retries: toNumber(value) })} /></Field><Field label="最大输出 Token"><InputNumber value={draft.max_tokens ?? ""} min={1} max={1_000_000} onChange={(value) => patch({ max_tokens: toNumber(value) })} /></Field><Field label="温度"><InputNumber value={draft.temperature ?? ""} min={0} max={2} step={0.1} decimalPlaces={1} onChange={(value) => patch({ temperature: toNumber(value) })} /></Field><span className="flex items-end gap-2 pb-1 text-sm font-medium"><Switch value={draft.stream_usage ?? true} onChange={(value) => patch({ stream_usage: Boolean(value) })} />流式 Token 统计</span></div><Field label="额外参数 JSON"><Textarea value={draft.extra_settings_text} autosize={{ minRows: 4, maxRows: 10 }} placeholder={'{\n "extra_body": {}\n}'} onChange={(value) => patch({ extra_settings_text: String(value) })} /><p className="text-xs font-normal text-muted-foreground">保留并编辑配置文件中未在基础表单展示的兼容参数。必须为 JSON 对象。</p></Field></section>}
</div>
</Dialog>
);
}
export default function ModelManagementPage() {
const { data, isLoading, error, refetch, isFetching } = useManagedModels();
const [search, setSearch] = useState("");
const [filter, setFilter] = useState<"all" | "thinking" | "vision">("all");
const [editor, setEditor] = useState<ManagedModel | null | "new">(null);
const models = useMemo(() => {
const keyword = search.trim().toLowerCase();
return (data?.models ?? []).filter((model) => {
const matchesFilter = filter === "all" || (filter === "thinking" ? model.supports_thinking : model.supports_vision);
const matchesSearch = !keyword || [model.name, model.display_name, model.model, model.use].filter(Boolean).join(" ").toLowerCase().includes(keyword);
return matchesFilter && matchesSearch;
});
}, [data?.models, filter, search]);
const stats = useMemo(() => ({ total: data?.models.length ?? 0, thinking: data?.models.filter((item) => item.supports_thinking).length ?? 0, vision: data?.models.filter((item) => item.supports_vision).length ?? 0 }), [data?.models]);
return (
<WorkspaceContainer><WorkspaceHeader /><WorkspaceBody><main className="flex size-full min-h-0 flex-col overflow-y-auto bg-background px-6 py-5 md:px-8"><div className="mx-auto flex w-full max-w-7xl flex-col gap-5"><header className="flex flex-wrap items-start justify-between gap-4"><div><div className="flex items-center gap-2"><span className="flex size-9 items-center justify-center rounded-lg bg-primary/10 text-primary"><CpuIcon className="size-5" /></span><h1 className="text-2xl font-semibold tracking-tight">模型配置</h1></div><p className="mt-2 max-w-3xl text-sm leading-6 text-muted-foreground">管理当前部署 <code className="rounded bg-muted px-1 py-0.5">config.yaml</code> 中的对话模型。保存后所有页面立即可选;密钥仅可写入,永不回显。</p></div><div className="flex flex-wrap items-center gap-2"><TButton theme="default" variant="outline" loading={isFetching} onClick={() => void refetch()}><RefreshCwIcon className="mr-1.5 size-4" />刷新</TButton><TButton theme="primary" onClick={() => setEditor("new")}><PlusIcon className="mr-1.5 size-4" />添加模型</TButton></div></header><div className="grid gap-3 sm:grid-cols-3"><StatCard icon={BotIcon} label="已配置模型" value={stats.total} /><StatCard icon={SparklesIcon} label="支持思考" value={stats.thinking} /><StatCard icon={EyeIcon} label="支持视觉" value={stats.vision} /></div><section className="rounded-xl border border-border bg-card shadow-sm"><div className="flex flex-wrap items-center justify-between gap-3 border-b px-4 py-3"><div className="flex flex-wrap items-center gap-2">{([ ["all", "全部"], ["thinking", "支持思考"], ["vision", "支持视觉"] ] as const).map(([value, label]) => <TButton key={value} theme={filter === value ? "primary" : "default"} variant={filter === value ? "base" : "text"} size="small" onClick={() => setFilter(value)}>{label}</TButton>)}</div><Input value={search} placeholder="搜索名称、模型或驱动" clearable className="w-full sm:w-72" onChange={(value) => setSearch(String(value))} /></div>{isLoading && <div className="flex h-52 items-center justify-center gap-2 text-sm text-muted-foreground"><LoaderCircleIcon className="size-4 animate-spin" />加载模型配置…</div>}{error && <div className="m-4 flex items-start gap-2 rounded-lg border border-destructive/25 bg-destructive/5 p-3 text-sm text-destructive"><CircleAlertIcon className="mt-0.5 size-4" />{error.message}</div>}{!isLoading && !error && models.length === 0 && <div className="flex h-52 flex-col items-center justify-center gap-2 text-sm text-muted-foreground"><CpuIcon className="size-7" />没有匹配的模型配置</div>}{!isLoading && !error && models.length > 0 && <div className="grid gap-3 p-4 lg:grid-cols-2">{models.map((model) => <article key={model.name} className="rounded-lg border border-border bg-background p-4 transition-colors hover:border-primary/35"><div className="flex items-start justify-between gap-3"><div className="min-w-0"><div className="flex flex-wrap items-center gap-2"><h2 className="truncate font-semibold">{model.display_name || model.name}</h2><Tag theme="success" variant="light" size="small">config.yaml</Tag></div><p className="mt-1 truncate font-mono text-xs text-muted-foreground">{model.name}</p></div><TButton theme="default" variant="outline" size="small" onClick={() => setEditor(model)}>配置</TButton></div>{model.description ? <p className="mt-3 line-clamp-2 min-h-10 text-sm leading-5 text-muted-foreground">{model.description}</p> : <p className="mt-3 min-h-10 text-sm leading-5 text-muted-foreground">未填写描述</p>}<div className="mt-3 grid gap-2 border-t pt-3 text-xs text-muted-foreground sm:grid-cols-2"><span className="truncate" title={model.use}>驱动:{model.use}</span><span className="truncate" title={model.model}>模型:{model.model}</span></div><div className="mt-3 flex flex-wrap gap-1.5">{model.api_key_configured && <Tag size="small" variant="light"><KeyRoundIcon className="mr-1 size-3" />已配置密钥</Tag>}{model.supports_thinking && <Tag theme="primary" size="small" variant="light"><SparklesIcon className="mr-1 size-3" />思考</Tag>}{model.supports_vision && <Tag theme="warning" size="small" variant="light"><EyeIcon className="mr-1 size-3" />视觉</Tag>}{model.supports_reasoning_effort && <Tag size="small" variant="light">推理档位</Tag>}</div></article>)}</div>}</section>{data?.config_path && <p className="text-xs text-muted-foreground">当前配置文件:<code>{data.config_path}</code>{data.updated_at ? ` · 最近更新 ${new Date(data.updated_at).toLocaleString()}` : ""}</p>}</div></main></WorkspaceBody>{editor !== null && <ModelEditor key={editor === "new" ? "new" : editor.name} model={editor === "new" ? null : editor} onClose={() => setEditor(null)} />}</WorkspaceContainer>
);
}
@@ -43,6 +43,7 @@ import MemoryPage from './MemoryPage';
import PromptPrefixSettingsPage from './PromptPrefixSettingsPage';
import PrivateSquareSettingsPage from './PrivateSquareSettingsPage';
import ModelConcurrencySettingsPage from './ModelConcurrencySettingsPage';
import ModelManagementPage from './ModelManagementPage';
import PublishSquaresSettingsPage from './PublishSquaresSettingsPage';
import SkillCompressionSettingsPage from './SkillCompressionSettingsPage';
import SkillEvolutionPage from './SkillEvolutionPage';
@@ -786,6 +787,18 @@ export default function WorkspaceRoutes({
)
}
/>
<Route
path="admin/models"
element={
getStoredAuth()?.system_role === 'admin' ? (
<WorkspaceLayout {...layoutProps}>
<ModelManagementPage />
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="settings/model-concurrency"
element={
@@ -47,8 +47,9 @@ from app.gateway.routers import (
llm_metrics,
mcp,
memory,
menu_overrides,
models,
menu_overrides,
model_management,
models,
multi_agent,
notifications,
open_3qfx,
@@ -1425,8 +1426,10 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
)
# Include routers
# Models API is mounted at /api/models
app.include_router(models.router)
# Models API is mounted at /api/models
app.include_router(models.router)
# Administrator-managed config.yaml model settings and connection tests.
app.include_router(model_management.router)
# MCP API is mounted at /api/mcp
app.include_router(mcp.router)
@@ -0,0 +1,437 @@
"""Administrator model configuration backed by DeerFlow's ``config.yaml``.
The public ``/api/models`` endpoint intentionally stays small and never
returns secrets. This router is the complementary administrator surface: it
edits only the top-level ``models:`` section in the active DeerFlow config,
keeps a disk backup before every write, and swaps the gateway's runtime config
after validation so new requests can use the change without a restart.
"""
from __future__ import annotations
import asyncio
import copy
import logging
import os
import re
import shutil
import tempfile
from datetime import UTC, datetime
from pathlib import Path
from time import perf_counter
from typing import Any
import yaml
from fastapi import APIRouter, HTTPException, Request
from langchain_core.messages import HumanMessage
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.gateway.deps import get_optional_user_from_request
from deerflow.config.app_config import AppConfig, reload_app_config
from deerflow.config.model_config import ModelConfig
from deerflow.models.factory import create_chat_model
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/admin/models", tags=["admin-models"])
_MODEL_BLOCK_RE = re.compile(r"(?m)^models:[^\r\n]*(?:\r?\n|$)")
_TOP_LEVEL_KEY_RE = re.compile(r"(?m)^[A-Za-z_][A-Za-z0-9_-]*:[^\r\n]*(?:\r?\n|$)")
_BACKUP_DIR_NAME = ".model-config-backups"
_MUTATION_LOCK = asyncio.Lock()
_EDITOR_FIELDS = frozenset(
{
"name",
"display_name",
"description",
"use",
"model",
"api_key",
"base_url",
"request_timeout",
"max_retries",
"max_tokens",
"temperature",
"stream_usage",
"supports_thinking",
"supports_reasoning_effort",
"supports_vision",
"when_thinking_enabled",
"when_thinking_disabled",
"thinking",
"use_responses_api",
"output_version",
}
)
_PRESERVED_FIELDS = frozenset(ModelConfig.model_fields) | _EDITOR_FIELDS
class ModelAdminItem(BaseModel):
"""A model configuration safe to return to an administrator browser."""
name: str
display_name: str | None = None
description: str | None = None
use: str
model: str
base_url: str | None = None
request_timeout: float | None = None
max_retries: int | None = None
max_tokens: int | None = None
temperature: float | None = None
stream_usage: bool | None = None
supports_thinking: bool = False
supports_reasoning_effort: bool = False
supports_vision: bool = False
when_thinking_enabled: dict[str, Any] | None = None
when_thinking_disabled: dict[str, Any] | None = None
thinking: dict[str, Any] | None = None
use_responses_api: bool | None = None
output_version: str | None = None
extra_settings: dict[str, Any] = Field(default_factory=dict)
api_key_configured: bool = False
api_key_source: str | None = None
source: str = "config.yaml"
class ModelAdminListResponse(BaseModel):
models: list[ModelAdminItem]
config_path: str
updated_at: str | None = None
class ModelUpsertRequest(BaseModel):
"""Editable subset of the ``ModelConfig`` schema plus safe extra fields."""
model_config = ConfigDict(extra="forbid")
name: str = Field(min_length=1, max_length=160)
display_name: str | None = Field(default=None, max_length=160)
description: str | None = Field(default=None, max_length=1000)
use: str = Field(min_length=1, max_length=255)
model: str = Field(min_length=1, max_length=255)
# ``None`` means "keep the currently configured secret" on update.
# The value is write-only in practice: it is never part of a response.
api_key: str | None = Field(default=None, max_length=4096)
clear_api_key: bool = False
base_url: str | None = Field(default=None, max_length=2048)
request_timeout: float | None = Field(default=None, ge=1, le=600)
max_retries: int | None = Field(default=None, ge=0, le=20)
max_tokens: int | None = Field(default=None, ge=1, le=1_000_000)
temperature: float | None = Field(default=None, ge=0, le=2)
stream_usage: bool | None = None
supports_thinking: bool = False
supports_reasoning_effort: bool = False
supports_vision: bool = False
when_thinking_enabled: dict[str, Any] | None = None
when_thinking_disabled: dict[str, Any] | None = None
thinking: dict[str, Any] | None = None
use_responses_api: bool | None = None
output_version: str | None = Field(default=None, max_length=128)
extra_settings: dict[str, Any] = Field(default_factory=dict)
@field_validator("name", "use", "model")
@classmethod
def _trim_required(cls, value: str) -> str:
normalized = value.strip()
if not normalized:
raise ValueError("value cannot be empty")
return normalized
@field_validator("display_name", "description", "base_url", "api_key", "output_version")
@classmethod
def _trim_optional(cls, value: str | None) -> str | None:
return value.strip() if isinstance(value, str) else value
class ModelTestRequest(BaseModel):
model: ModelUpsertRequest
class ModelTestResponse(BaseModel):
success: bool
latency_ms: int
message: str
preview: str | None = None
def _mask_env_source(value: Any) -> str | None:
if isinstance(value, str) and value.startswith("$") and len(value) > 1:
return value[1:]
return None
def _to_admin_item(raw: dict[str, Any]) -> ModelAdminItem:
api_key = raw.get("api_key")
extra = {
key: copy.deepcopy(value)
for key, value in raw.items()
if key not in _PRESERVED_FIELDS
}
return ModelAdminItem(
name=str(raw.get("name") or ""),
display_name=raw.get("display_name"),
description=raw.get("description"),
use=str(raw.get("use") or ""),
model=str(raw.get("model") or ""),
base_url=raw.get("base_url"),
request_timeout=raw.get("request_timeout"),
max_retries=raw.get("max_retries"),
max_tokens=raw.get("max_tokens"),
temperature=raw.get("temperature"),
stream_usage=raw.get("stream_usage"),
supports_thinking=bool(raw.get("supports_thinking", False)),
supports_reasoning_effort=bool(raw.get("supports_reasoning_effort", False)),
supports_vision=bool(raw.get("supports_vision", False)),
when_thinking_enabled=copy.deepcopy(raw.get("when_thinking_enabled")),
when_thinking_disabled=copy.deepcopy(raw.get("when_thinking_disabled")),
thinking=copy.deepcopy(raw.get("thinking")),
use_responses_api=raw.get("use_responses_api"),
output_version=raw.get("output_version"),
extra_settings=extra,
api_key_configured=bool(api_key),
api_key_source=_mask_env_source(api_key),
)
def _resolve_config_path() -> Path:
return AppConfig.resolve_config_path()
def _read_config_data(path: Path) -> tuple[str, dict[str, Any], list[dict[str, Any]]]:
raw_text = path.read_text(encoding="utf-8")
try:
data = yaml.safe_load(raw_text) or {}
except yaml.YAMLError as exc:
raise HTTPException(status_code=500, detail=f"Unable to parse active config.yaml: {exc}") from exc
if not isinstance(data, dict):
raise HTTPException(status_code=500, detail="The active config.yaml root must be a mapping")
raw_models = data.get("models") or []
if not isinstance(raw_models, list) or not all(isinstance(item, dict) for item in raw_models):
raise HTTPException(status_code=500, detail="config.yaml models must be a list of mappings")
return raw_text, data, [copy.deepcopy(item) for item in raw_models]
def _render_models_block(models: list[dict[str, Any]], newline: str) -> str:
rendered = yaml.safe_dump(
{"models": models},
allow_unicode=True,
sort_keys=False,
default_flow_style=False,
width=120,
)
if newline != "\n":
rendered = rendered.replace("\n", newline)
return rendered if rendered.endswith(newline) else f"{rendered}{newline}"
def _replace_models_block(raw_text: str, models: list[dict[str, Any]]) -> str:
"""Replace the active model entries without touching other config sections.
PyYAML does not retain comments. Keep every comment found in the old
model section as documentation after the newly rendered model list rather
than silently deleting an operator's notes or disabled examples.
"""
block_match = _MODEL_BLOCK_RE.search(raw_text)
if block_match is None:
raise HTTPException(status_code=500, detail="The active config.yaml has no top-level models section")
newline = "\r\n" if "\r\n" in raw_text else "\n"
next_key = _TOP_LEVEL_KEY_RE.search(raw_text, block_match.end())
end = next_key.start() if next_key else len(raw_text)
previous_section = raw_text[block_match.end() : end]
preserved_comments = "".join(
line
for line in previous_section.splitlines(keepends=True)
if line.lstrip().startswith("#")
)
rendered = _render_models_block(models, newline)
if preserved_comments:
if not preserved_comments.endswith(("\n", "\r")):
preserved_comments = f"{preserved_comments}{newline}"
rendered = f"{rendered}{preserved_comments}"
return f"{raw_text[:block_match.start()]}{rendered}{raw_text[end:]}"
def _backup_and_write_config(path: Path, raw_before: str, raw_after: str) -> Path:
backup_dir = path.parent / _BACKUP_DIR_NAME
backup_dir.mkdir(parents=True, exist_ok=True)
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%S%fZ")
backup_path = backup_dir / f"{path.stem}.{stamp}.yaml"
# Persist the exact text that was read for this mutation. This makes the
# rollback deterministic even if another operator changes the file while
# the gateway is processing a request.
with backup_path.open("w", encoding="utf-8", newline="") as handle:
handle.write(raw_before)
fd, temp_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
try:
with os.fdopen(fd, "w", encoding="utf-8", newline="") as handle:
handle.write(raw_after)
handle.flush()
os.fsync(handle.fileno())
os.replace(temp_name, path)
except Exception:
try:
os.unlink(temp_name)
except OSError:
pass
raise
return backup_path
def _restore_backup(path: Path, backup_path: Path) -> None:
try:
shutil.copy2(backup_path, path)
except Exception:
logger.exception("Failed to restore model config backup at %s", backup_path)
def _request_to_raw_model(payload: ModelUpsertRequest, existing: dict[str, Any] | None = None) -> dict[str, Any]:
merged = copy.deepcopy(existing or {})
supplied = payload.model_dump(exclude={"extra_settings", "clear_api_key"}, exclude_none=True)
merged.update(supplied)
for key, value in payload.extra_settings.items():
normalized = str(key).strip()
if not normalized:
raise HTTPException(status_code=422, detail="Extra setting names cannot be blank")
if normalized in _EDITOR_FIELDS or normalized in {"api_key", "clear_api_key", "extra_settings"}:
raise HTTPException(status_code=422, detail=f"Extra setting '{normalized}' duplicates a managed field")
merged[normalized] = copy.deepcopy(value)
if payload.clear_api_key:
merged.pop("api_key", None)
elif payload.api_key is None and existing is not None and "api_key" in existing:
merged["api_key"] = existing["api_key"]
# Validate the raw schema without resolving `$ENV_VAR` values. Environment
# resolution happens during the post-write runtime reload and is rolled back
# if unavailable.
try:
ModelConfig.model_validate(merged)
except Exception as exc:
raise HTTPException(status_code=422, detail=f"Invalid model configuration: {exc}") from exc
return merged
def _redact_text(value: str, secrets: list[str]) -> str:
result = value
for secret in secrets:
if secret:
result = result.replace(secret, "***")
return result[:800]
async def _require_admin(request: Request) -> None:
user = getattr(request.state, "user", None)
if user is None:
user = await get_optional_user_from_request(request)
if user is None:
raise HTTPException(status_code=401, detail="Authentication is required")
if getattr(user, "system_role", None) != "admin":
raise HTTPException(status_code=403, detail="Model configuration requires an administrator")
async def _save_models(request: Request, raw_before: str, models: list[dict[str, Any]]) -> list[dict[str, Any]]:
path = _resolve_config_path()
raw_after = _replace_models_block(raw_before, models)
backup_path = await asyncio.to_thread(_backup_and_write_config, path, raw_before, raw_after)
try:
config = reload_app_config(str(path))
request.app.state.config = config
except Exception as exc:
logger.warning("Updated model configuration could not be activated: %s", type(exc).__name__)
await asyncio.to_thread(_restore_backup, path, backup_path)
try:
request.app.state.config = reload_app_config(str(path))
except Exception:
logger.exception("Failed to reload restored model config")
raise HTTPException(
status_code=422,
detail="The model configuration could not be activated; the previous config was restored.",
) from exc
return models
@router.get("", response_model=ModelAdminListResponse)
async def list_model_configurations(request: Request) -> ModelAdminListResponse:
await _require_admin(request)
path = _resolve_config_path()
_, _, models = await asyncio.to_thread(_read_config_data, path)
updated_at = datetime.fromtimestamp(path.stat().st_mtime, tz=UTC).isoformat()
return ModelAdminListResponse(
models=[_to_admin_item(item) for item in models],
config_path=str(path),
updated_at=updated_at,
)
@router.post("", response_model=ModelAdminItem, status_code=201)
async def create_model_configuration(body: ModelUpsertRequest, request: Request) -> ModelAdminItem:
await _require_admin(request)
async with _MUTATION_LOCK:
path = _resolve_config_path()
raw_before, _, models = await asyncio.to_thread(_read_config_data, path)
if any(str(item.get("name")) == body.name for item in models):
raise HTTPException(status_code=409, detail=f"A model named '{body.name}' already exists")
created = _request_to_raw_model(body)
await _save_models(request, raw_before, [*models, created])
return _to_admin_item(created)
@router.put("/{model_name}", response_model=ModelAdminItem)
async def update_model_configuration(model_name: str, body: ModelUpsertRequest, request: Request) -> ModelAdminItem:
await _require_admin(request)
if body.name != model_name:
raise HTTPException(status_code=422, detail="A model's stable name cannot be changed; edit its display name instead")
async with _MUTATION_LOCK:
path = _resolve_config_path()
raw_before, _, models = await asyncio.to_thread(_read_config_data, path)
index = next((i for i, item in enumerate(models) if str(item.get("name")) == model_name), None)
if index is None:
raise HTTPException(status_code=404, detail=f"Model '{model_name}' was not found")
updated = _request_to_raw_model(body, models[index])
next_models = [*models]
next_models[index] = updated
await _save_models(request, raw_before, next_models)
return _to_admin_item(updated)
@router.post("/test", response_model=ModelTestResponse)
async def test_model_connection(body: ModelTestRequest, request: Request) -> ModelTestResponse:
"""Perform one bounded minimal completion without persisting the draft."""
await _require_admin(request)
path = _resolve_config_path()
_, _, models = await asyncio.to_thread(_read_config_data, path)
existing = next((item for item in models if str(item.get("name")) == body.model.name), None)
candidate = _request_to_raw_model(body.model, existing)
candidate_secret = str(candidate.get("api_key") or "")
secrets = [candidate_secret]
try:
runtime_candidate = AppConfig.resolve_env_variables(copy.deepcopy(candidate))
# An environment-variable API key is resolved only in memory for this
# request. Redact both forms because provider exceptions can include
# either the original `$NAME` reference or its resolved value.
resolved_secret = str(runtime_candidate.get("api_key") or "")
secrets = [candidate_secret, resolved_secret]
model_config = ModelConfig.model_validate(runtime_candidate)
base_config = request.app.state.config
test_config = base_config.model_copy(update={"models": [model_config]})
chat_model = create_chat_model(model_config.name, app_config=test_config, force_disable_thinking=True)
started = perf_counter()
response = await asyncio.wait_for(
chat_model.ainvoke([HumanMessage(content="Reply with exactly: OK")]),
timeout=min(float(candidate.get("request_timeout") or 30), 45),
)
latency_ms = round((perf_counter() - started) * 1000)
preview = _redact_text(str(getattr(response, "content", "OK")), secrets)
return ModelTestResponse(success=True, latency_ms=latency_ms, message="Connection successful", preview=preview)
except Exception as exc:
# Provider errors sometimes echo a credential. Never log the full
# exception or return it without redacting the submitted key.
logger.warning("Model connection test failed for '%s': %s", candidate.get("name"), type(exc).__name__)
return ModelTestResponse(
success=False,
latency_ms=0,
message=_redact_text(str(exc) or type(exc).__name__, secrets),
)
@@ -0,0 +1,92 @@
"""Focused regression tests for administrator model configuration helpers."""
from __future__ import annotations
from types import SimpleNamespace
import pytest
import yaml
from app.gateway.routers import model_management as router
def test_replace_models_block_keeps_other_sections_and_comments() -> None:
raw = """config_version: 1
models:
# Existing provider note
- name: old-model
use: langchain_openai:ChatOpenAI
model: old-model
# Disabled provider example remains useful to operators.
# - name: disabled-model
# model: disabled-model
agents:
default: lead_agent
"""
replaced = router._replace_models_block(
raw,
[
{
"name": "new-model",
"use": "langchain_openai:ChatOpenAI",
"model": "new-model",
}
],
)
parsed = yaml.safe_load(replaced)
assert parsed["models"] == [
{
"name": "new-model",
"use": "langchain_openai:ChatOpenAI",
"model": "new-model",
}
]
assert parsed["agents"] == {"default": "lead_agent"}
assert "# Existing provider note" in replaced
assert "# Disabled provider example remains useful to operators." in replaced
def test_connection_error_redaction_hides_literal_and_resolved_keys() -> None:
message = "provider rejected raw-secret and resolved-secret"
assert router._redact_text(message, ["raw-secret", "resolved-secret"]) == "provider rejected *** and ***"
@pytest.mark.asyncio
async def test_connection_test_uses_one_minimal_prompt_without_writing_config(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.yaml"
original = "models: []\nagent: lead_agent\n"
config_path.write_text(original, encoding="utf-8")
messages_seen = []
class FakeChatModel:
async def ainvoke(self, messages):
messages_seen.extend(messages)
return SimpleNamespace(content="OK")
monkeypatch.setattr(router, "_resolve_config_path", lambda: config_path)
monkeypatch.setattr(router, "create_chat_model", lambda *_args, **_kwargs: FakeChatModel())
request = SimpleNamespace(
state=SimpleNamespace(user=SimpleNamespace(system_role="admin")),
app=SimpleNamespace(state=SimpleNamespace(config=SimpleNamespace(model_copy=lambda **_kwargs: SimpleNamespace()))),
)
body = router.ModelTestRequest(
model=router.ModelUpsertRequest(
name="test-model",
use="langchain_openai:ChatOpenAI",
model="test-model",
api_key="raw-secret",
)
)
result = await router.test_model_connection(body, request)
assert result.success is True
assert result.preview == "OK"
assert len(messages_seen) == 1
assert messages_seen[0].content == "Reply with exactly: OK"
assert config_path.read_text(encoding="utf-8") == original