Merge branch 'master-nj' into 'master'
feat: 添加管理员模型配置页面与 config.yaml 模型管理 API See merge request knowledge-base-bj/deerflow-code!19
This commit is contained in:
commit
2a869f4ff6
20 files changed
+1720
-179
No files matched your search
+5
-2
@@ -80,8 +80,11 @@ offline-backend-20260512/backend/skill_evolution.json
|
|||||||
skills/
|
skills/
|
||||||
hermes-agent-main/
|
hermes-agent-main/
|
||||||
.gitignore
|
.gitignore
|
||||||
offline-backend-20260512/backend/tests/*
|
offline-backend-20260512/backend/tests/*
|
||||||
deploy
|
# 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
|
.history
|
||||||
|
|
||||||
# Cloned third-party repos (local only)
|
# Cloned third-party repos (local only)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { useNavigate, useLocation } from "react-router-dom";
|
import { useNavigate, useLocation } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
Bell,
|
Bell,
|
||||||
|
Cpu,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
ChevronsUpDown,
|
ChevronsUpDown,
|
||||||
@@ -252,6 +253,14 @@ function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) {
|
|||||||
白名单管理
|
白名单管理
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
{isAdmin && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => navigate("/page/workspace/admin/models")}
|
||||||
|
>
|
||||||
|
<Cpu className="size-4" />
|
||||||
|
模型配置
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => navigate("/page/strategy/settings/model-concurrency")}
|
onClick={() => navigate("/page/strategy/settings/model-concurrency")}
|
||||||
@@ -555,6 +564,12 @@ function LegacyAccordionSidebar({
|
|||||||
岗位角色配置
|
岗位角色配置
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
{isAdmin && (
|
||||||
|
<DropdownMenuItem onClick={() => navigate("/page/workspace/admin/models")}>
|
||||||
|
<Cpu className="size-4" />
|
||||||
|
模型配置
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/login-whitelist")}>
|
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/login-whitelist")}>
|
||||||
<ShieldCheck className="size-4" />
|
<ShieldCheck className="size-4" />
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|||||||
import { apiFetch } from "@/core/api";
|
import { apiFetch } from "@/core/api";
|
||||||
import { getBackendBaseURL } from "@/core/config";
|
import { getBackendBaseURL } from "@/core/config";
|
||||||
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
|
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
|
||||||
|
import { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag";
|
||||||
import { useSkills } from "@/core/skills/hooks";
|
import { useSkills } from "@/core/skills/hooks";
|
||||||
import type { SkillDisplayConfig } from "@/core/skills/type";
|
import type { SkillDisplayConfig } from "@/core/skills/type";
|
||||||
import {
|
import {
|
||||||
@@ -92,6 +93,13 @@ const ChatBox: React.FC<{
|
|||||||
const [settings] = useLocalSettings();
|
const [settings] = useLocalSettings();
|
||||||
const ragModeEnabled =
|
const ragModeEnabled =
|
||||||
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
|
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 { skills } = useSkills();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -106,11 +114,11 @@ const ChatBox: React.FC<{
|
|||||||
|
|
||||||
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
|
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
|
||||||
const skillDisplayMap = useMemo(
|
const skillDisplayMap = useMemo(
|
||||||
() => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
() => (referencesEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
||||||
[ragModeEnabled, skills],
|
[referencesEnabled, skills],
|
||||||
);
|
);
|
||||||
const fallbackReferenceBatches = useMemo(() => {
|
const fallbackReferenceBatches = useMemo(() => {
|
||||||
if (!ragModeEnabled) {
|
if (!referencesEnabled) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return buildTurnReferenceBatches(
|
return buildTurnReferenceBatches(
|
||||||
@@ -118,9 +126,9 @@ const ChatBox: React.FC<{
|
|||||||
skillDisplayMap,
|
skillDisplayMap,
|
||||||
thread.isLoading,
|
thread.isLoading,
|
||||||
);
|
);
|
||||||
}, [ragModeEnabled, skillDisplayMap, thread.isLoading, thread.values.messages]);
|
}, [referencesEnabled, skillDisplayMap, thread.isLoading, thread.values.messages]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ragModeEnabled) {
|
if (!referencesEnabled) {
|
||||||
setBackendReferenceBatches([]);
|
setBackendReferenceBatches([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -156,7 +164,7 @@ const ChatBox: React.FC<{
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [ragModeEnabled, thread.isLoading, threadId, thread.values.messages]);
|
}, [referencesEnabled, thread.isLoading, threadId, thread.values.messages]);
|
||||||
const adaptedBackendReferenceBatches = useMemo(
|
const adaptedBackendReferenceBatches = useMemo(
|
||||||
() =>
|
() =>
|
||||||
adaptBackendReferenceBatches(
|
adaptBackendReferenceBatches(
|
||||||
@@ -175,7 +183,7 @@ const ChatBox: React.FC<{
|
|||||||
[adaptedBackendReferenceBatches, fallbackReferenceBatches],
|
[adaptedBackendReferenceBatches, fallbackReferenceBatches],
|
||||||
);
|
);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ragModeEnabled) {
|
if (!referencesEnabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const signature = JSON.stringify({
|
const signature = JSON.stringify({
|
||||||
@@ -202,7 +210,7 @@ const ChatBox: React.FC<{
|
|||||||
fallbackBatches: debugReferenceBatches(fallbackReferenceBatches),
|
fallbackBatches: debugReferenceBatches(fallbackReferenceBatches),
|
||||||
finalBatches: debugReferenceBatches(referenceBatches),
|
finalBatches: debugReferenceBatches(referenceBatches),
|
||||||
});
|
});
|
||||||
}, [adaptedBackendReferenceBatches, backendReferenceBatches, fallbackReferenceBatches, ragModeEnabled, referenceBatches, threadId]);
|
}, [adaptedBackendReferenceBatches, backendReferenceBatches, fallbackReferenceBatches, referencesEnabled, referenceBatches, threadId]);
|
||||||
const latestReferenceBatch =
|
const latestReferenceBatch =
|
||||||
referenceBatches.length > 0
|
referenceBatches.length > 0
|
||||||
? referenceBatches[referenceBatches.length - 1]!
|
? referenceBatches[referenceBatches.length - 1]!
|
||||||
@@ -213,7 +221,7 @@ const ChatBox: React.FC<{
|
|||||||
const referenceSources = activeReferenceBatch?.sources ?? [];
|
const referenceSources = activeReferenceBatch?.sources ?? [];
|
||||||
const hasCitationReferences = referenceSources.length > 0;
|
const hasCitationReferences = referenceSources.length > 0;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ragModeEnabled || !activeReferenceBatch) {
|
if (!referencesEnabled || !activeReferenceBatch) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
logReferenceDebug("frontend-active-reference-batch-payload", threadId, {
|
logReferenceDebug("frontend-active-reference-batch-payload", threadId, {
|
||||||
@@ -222,7 +230,7 @@ const ChatBox: React.FC<{
|
|||||||
sourceCount: activeReferenceBatch.sources.length,
|
sourceCount: activeReferenceBatch.sources.length,
|
||||||
sources: activeReferenceBatch.sources.map(debugReferenceSource),
|
sources: activeReferenceBatch.sources.map(debugReferenceSource),
|
||||||
});
|
});
|
||||||
}, [activeReferenceBatch, ragModeEnabled, threadId]);
|
}, [activeReferenceBatch, referencesEnabled, threadId]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasCitationReferences) {
|
if (hasCitationReferences) {
|
||||||
setReferencesOpen(true);
|
setReferencesOpen(true);
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ import { getBackendBaseURL } from "@/core/config";
|
|||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import { isHiddenFromUIMessage } from "@/core/messages/utils";
|
import { isHiddenFromUIMessage } from "@/core/messages/utils";
|
||||||
import { useModels } from "@/core/models/hooks";
|
import { useModels } from "@/core/models/hooks";
|
||||||
|
import type { Model } from "@/core/models/types";
|
||||||
import type { AgentThreadContext } from "@/core/threads";
|
import type { AgentThreadContext } from "@/core/threads";
|
||||||
import { textOfMessage } from "@/core/threads/utils";
|
import { textOfMessage } from "@/core/threads/utils";
|
||||||
import { desensitize } from "@/lib/desensitize";
|
import { desensitize } from "@/lib/desensitize";
|
||||||
@@ -133,8 +134,9 @@ export function InputBox({
|
|||||||
onSubmit,
|
onSubmit,
|
||||||
onStop,
|
onStop,
|
||||||
promptPrefix,
|
promptPrefix,
|
||||||
agentSelector,
|
agentSelector,
|
||||||
assistantId,
|
extraModels = [],
|
||||||
|
assistantId,
|
||||||
hideAdminRecommendedQuestions,
|
hideAdminRecommendedQuestions,
|
||||||
compact,
|
compact,
|
||||||
...props
|
...props
|
||||||
@@ -190,16 +192,23 @@ export function InputBox({
|
|||||||
* homepage (``ChatPage``) passes this; ``AgentChatPage`` omits it because the
|
* homepage (``ChatPage``) passes this; ``AgentChatPage`` omits it because the
|
||||||
* thread is already bound to one agent.
|
* thread is already bound to one agent.
|
||||||
*/
|
*/
|
||||||
agentSelector?: {
|
agentSelector?: {
|
||||||
selectedAgentId: string | null;
|
selectedAgentId: string | null;
|
||||||
onAgentChange: (agentId: string | null) => void;
|
onAgentChange: (agentId: string | null) => void;
|
||||||
};
|
};
|
||||||
}) {
|
/** Page-scoped model choices implemented outside the DeerFlow model API. */
|
||||||
|
extraModels?: Model[];
|
||||||
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [localSettings, setLocalSettings] = useLocalSettings();
|
const [localSettings, setLocalSettings] = useLocalSettings();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const [modelDialogOpen, setModelDialogOpen] = useState(false);
|
const [modelDialogOpen, setModelDialogOpen] = useState(false);
|
||||||
const { models } = useModels();
|
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 { thread, isMock } = useThread();
|
||||||
const { textInput } = usePromptInputController();
|
const { textInput } = usePromptInputController();
|
||||||
const promptRootRef = useRef<HTMLDivElement | null>(null);
|
const promptRootRef = useRef<HTMLDivElement | null>(null);
|
||||||
@@ -223,34 +232,40 @@ export function InputBox({
|
|||||||
recommendedQuestions.length > 0;
|
recommendedQuestions.length > 0;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (models.length === 0) {
|
if (modelOptions.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const currentModel = models.find((m) => m.name === context.model_name);
|
const currentModel = modelOptions.find((m) => m.name === context.model_name);
|
||||||
const fallbackModel = currentModel ?? models[0]!;
|
const fallbackModel = currentModel ?? modelOptions[0]!;
|
||||||
const supportsThinking = fallbackModel.supports_thinking ?? false;
|
const supportsThinking = fallbackModel.supports_thinking ?? false;
|
||||||
const nextModelName = fallbackModel.name;
|
const nextModelName = fallbackModel.name;
|
||||||
const nextMode = getResolvedMode(context.mode, supportsThinking);
|
const nextMode = getResolvedMode(context.mode, supportsThinking);
|
||||||
|
|
||||||
if (context.model_name === nextModelName && context.mode === nextMode) {
|
if (
|
||||||
return;
|
context.model_name === nextModelName &&
|
||||||
}
|
context.mode === nextMode &&
|
||||||
|
context.execution_profile === fallbackModel.execution_profile
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
onContextChange?.({
|
onContextChange?.({
|
||||||
...context,
|
...context,
|
||||||
model_name: nextModelName,
|
model_name: nextModelName,
|
||||||
mode: nextMode,
|
mode: nextMode,
|
||||||
});
|
execution_profile: fallbackModel.execution_profile,
|
||||||
}, [context, models, onContextChange]);
|
});
|
||||||
|
}, [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(() => {
|
const resolvedModelName = selectedModel?.name;
|
||||||
if (models.length === 0) {
|
const selectedSingleRoundRag = singleRoundRagActive;
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return models.find((m) => m.name === context.model_name) ?? models[0];
|
|
||||||
}, [context.model_name, models]);
|
|
||||||
|
|
||||||
const resolvedModelName = selectedModel?.name;
|
|
||||||
|
|
||||||
const supportThinking = useMemo(
|
const supportThinking = useMemo(
|
||||||
() => selectedModel?.supports_thinking ?? false,
|
() => selectedModel?.supports_thinking ?? false,
|
||||||
@@ -264,38 +279,65 @@ export function InputBox({
|
|||||||
|
|
||||||
const handleModelSelect = useCallback(
|
const handleModelSelect = useCallback(
|
||||||
(model_name: string) => {
|
(model_name: string) => {
|
||||||
const model = models.find((m) => m.name === model_name);
|
const model = modelOptions.find((m) => m.name === model_name);
|
||||||
if (!model) {
|
if (!model) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onContextChange?.({
|
onContextChange?.({
|
||||||
...context,
|
...context,
|
||||||
model_name,
|
model_name,
|
||||||
mode: getResolvedMode(context.mode, model.supports_thinking ?? false),
|
mode: getResolvedMode(context.mode, model.supports_thinking ?? false),
|
||||||
reasoning_effort: context.reasoning_effort,
|
reasoning_effort: context.reasoning_effort,
|
||||||
});
|
execution_profile: model.execution_profile,
|
||||||
setModelDialogOpen(false);
|
});
|
||||||
},
|
setModelDialogOpen(false);
|
||||||
[onContextChange, context, models],
|
},
|
||||||
|
[onContextChange, context, modelOptions],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleModeSelect = useCallback(
|
const handleModeSelect = useCallback(
|
||||||
(mode: InputMode) => {
|
(mode: InputMode) => {
|
||||||
onContextChange?.({
|
const ordinaryModel =
|
||||||
...context,
|
models.find((model) => model.name === context.model_name) ?? models[0];
|
||||||
mode: getResolvedMode(mode, supportThinking),
|
onContextChange?.({
|
||||||
reasoning_effort:
|
...context,
|
||||||
mode === "ultra"
|
model_name: ordinaryModel?.name ?? context.model_name,
|
||||||
? "high"
|
mode: getResolvedMode(
|
||||||
|
mode,
|
||||||
|
ordinaryModel?.supports_thinking ?? supportThinking,
|
||||||
|
),
|
||||||
|
reasoning_effort:
|
||||||
|
mode === "ultra"
|
||||||
|
? "high"
|
||||||
: mode === "pro"
|
: mode === "pro"
|
||||||
? "medium"
|
? "medium"
|
||||||
: mode === "thinking"
|
: mode === "thinking"
|
||||||
? "low"
|
? "low"
|
||||||
: "minimal",
|
: "minimal",
|
||||||
});
|
execution_profile: undefined,
|
||||||
},
|
});
|
||||||
[onContextChange, context, supportThinking],
|
},
|
||||||
);
|
[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(
|
const handleReasoningEffortSelect = useCallback(
|
||||||
(effort: "minimal" | "low" | "medium" | "high") => {
|
(effort: "minimal" | "low" | "medium" | "high") => {
|
||||||
@@ -361,10 +403,11 @@ export function InputBox({
|
|||||||
onContextChange?.({
|
onContextChange?.({
|
||||||
...context,
|
...context,
|
||||||
model_name: resolvedModelName,
|
model_name: resolvedModelName,
|
||||||
mode: getResolvedMode(
|
mode: getResolvedMode(
|
||||||
context.mode,
|
context.mode,
|
||||||
selectedModel?.supports_thinking ?? false,
|
selectedModel?.supports_thinking ?? false,
|
||||||
),
|
),
|
||||||
|
execution_profile: selectedModel?.execution_profile,
|
||||||
});
|
});
|
||||||
setTimeout(() => onSubmit?.(message), 0);
|
setTimeout(() => onSubmit?.(message), 0);
|
||||||
return;
|
return;
|
||||||
@@ -379,7 +422,8 @@ export function InputBox({
|
|||||||
onStop,
|
onStop,
|
||||||
resolvedModelName,
|
resolvedModelName,
|
||||||
ragModeEnabled,
|
ragModeEnabled,
|
||||||
selectedModel?.supports_thinking,
|
selectedModel?.supports_thinking,
|
||||||
|
selectedModel?.execution_profile,
|
||||||
status,
|
status,
|
||||||
threadId,
|
threadId,
|
||||||
],
|
],
|
||||||
@@ -437,8 +481,9 @@ export function InputBox({
|
|||||||
}, [pendingSuggestion, requestFormSubmit, textInput]);
|
}, [pendingSuggestion, requestFormSubmit, textInput]);
|
||||||
|
|
||||||
const showFollowups =
|
const showFollowups =
|
||||||
!disabled &&
|
!disabled &&
|
||||||
!isNewThread &&
|
!isNewThread &&
|
||||||
|
!selectedSingleRoundRag &&
|
||||||
!followupsHidden &&
|
!followupsHidden &&
|
||||||
(followupsLoading || followups.length > 0);
|
(followupsLoading || followups.length > 0);
|
||||||
|
|
||||||
@@ -464,7 +509,7 @@ export function InputBox({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (disabled || isMock) {
|
if (disabled || isMock || selectedSingleRoundRag) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -526,7 +571,7 @@ export function InputBox({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [context.model_name, disabled, isMock, status, thread.messages, threadId]);
|
}, [context.model_name, disabled, isMock, selectedSingleRoundRag, status, thread.messages, threadId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={promptRootRef} className="relative flex flex-col gap-6">
|
<div ref={promptRootRef} className="relative flex flex-col gap-6">
|
||||||
@@ -782,54 +827,64 @@ export function InputBox({
|
|||||||
</PromptInputButton>
|
</PromptInputButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<PromptInputActionMenu>
|
<PromptInputActionMenu>
|
||||||
<ModeHoverGuide
|
<ModeHoverGuide
|
||||||
mode={
|
mode={
|
||||||
context.mode === "flash" ||
|
context.mode === "flash" ||
|
||||||
context.mode === "thinking" ||
|
context.mode === "thinking" ||
|
||||||
context.mode === "pro" ||
|
context.mode === "pro" ||
|
||||||
context.mode === "ultra"
|
context.mode === "ultra"
|
||||||
? context.mode
|
? context.mode
|
||||||
: "flash"
|
: "flash"
|
||||||
}
|
}
|
||||||
>
|
tooltipContent={
|
||||||
|
selectedSingleRoundRag
|
||||||
|
? "单轮 RAG 问答:基于知识库检索生成答案,并展示参考资料"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
<PromptInputActionMenuTrigger
|
<PromptInputActionMenuTrigger
|
||||||
className={cn(
|
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",
|
"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 && "rounded-lg border px-2.5! py-1! text-[11px] font-medium",
|
||||||
compact &&
|
compact &&
|
||||||
(context.mode === "thinking"
|
(selectedSingleRoundRag
|
||||||
? "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"
|
? "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"
|
: 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"
|
? "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"
|
: 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-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"),
|
: "border-transparent bg-muted/40 hover:bg-muted hover:text-foreground"),
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
{context.mode === "flash" && <ZapIcon className="size-3" />}
|
{selectedSingleRoundRag ? (
|
||||||
{context.mode === "thinking" && (
|
<BookMarkedIcon className="size-3" />
|
||||||
<LightbulbIcon className="size-3" />
|
) : context.mode === "flash" ? (
|
||||||
)}
|
<ZapIcon className="size-3" />
|
||||||
{context.mode === "pro" && (
|
) : context.mode === "thinking" ? (
|
||||||
<GraduationCapIcon className="size-3" />
|
<LightbulbIcon className="size-3" />
|
||||||
)}
|
) : context.mode === "pro" ? (
|
||||||
{context.mode === "ultra" && (
|
<GraduationCapIcon className="size-3" />
|
||||||
<RocketIcon className="size-3 text-[#dabb5e]" />
|
) : context.mode === "ultra" ? (
|
||||||
)}
|
<RocketIcon className="size-3 text-[#dabb5e]" />
|
||||||
</div>
|
) : null}
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-xs font-normal",
|
"text-xs font-normal",
|
||||||
context.mode === "ultra" ? "golden-text" : "",
|
context.mode === "ultra" ? "golden-text" : "",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{(context.mode === "flash" && t.inputBox.flashMode) ||
|
{selectedSingleRoundRag
|
||||||
(context.mode === "thinking" &&
|
? "单轮 RAG"
|
||||||
t.inputBox.reasoningMode) ||
|
: (context.mode === "flash" && t.inputBox.flashMode) ||
|
||||||
(context.mode === "pro" && t.inputBox.proMode) ||
|
(context.mode === "thinking" &&
|
||||||
(context.mode === "ultra" && t.inputBox.ultraMode)}
|
t.inputBox.reasoningMode) ||
|
||||||
</div>
|
(context.mode === "pro" && t.inputBox.proMode) ||
|
||||||
|
(context.mode === "ultra" && t.inputBox.ultraMode)}
|
||||||
|
</div>
|
||||||
</PromptInputActionMenuTrigger>
|
</PromptInputActionMenuTrigger>
|
||||||
</ModeHoverGuide>
|
</ModeHoverGuide>
|
||||||
<PromptInputActionMenuContent className="w-80">
|
<PromptInputActionMenuContent className="w-80">
|
||||||
@@ -838,11 +893,11 @@ export function InputBox({
|
|||||||
{t.inputBox.mode}
|
{t.inputBox.mode}
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
<PromptInputActionMenu>
|
<PromptInputActionMenu>
|
||||||
<PromptInputActionMenuItem
|
<PromptInputActionMenuItem
|
||||||
className={cn(
|
className={cn(
|
||||||
context.mode === "flash"
|
!selectedSingleRoundRag && context.mode === "flash"
|
||||||
? "text-accent-foreground"
|
? "text-accent-foreground"
|
||||||
: "text-muted-foreground/65",
|
: "text-muted-foreground/65",
|
||||||
)}
|
)}
|
||||||
onSelect={() => handleModeSelect("flash")}
|
onSelect={() => handleModeSelect("flash")}
|
||||||
>
|
>
|
||||||
@@ -850,9 +905,9 @@ export function InputBox({
|
|||||||
<div className="flex items-center gap-1 font-bold">
|
<div className="flex items-center gap-1 font-bold">
|
||||||
<ZapIcon
|
<ZapIcon
|
||||||
className={cn(
|
className={cn(
|
||||||
"mr-2 size-4",
|
"mr-2 size-4",
|
||||||
context.mode === "flash" &&
|
!selectedSingleRoundRag && context.mode === "flash" &&
|
||||||
"text-accent-foreground",
|
"text-accent-foreground",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{t.inputBox.flashMode}
|
{t.inputBox.flashMode}
|
||||||
@@ -861,7 +916,7 @@ export function InputBox({
|
|||||||
{t.inputBox.flashModeDescription}
|
{t.inputBox.flashModeDescription}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{context.mode === "flash" ? (
|
{!selectedSingleRoundRag && context.mode === "flash" ? (
|
||||||
<CheckIcon className="ml-auto size-4" />
|
<CheckIcon className="ml-auto size-4" />
|
||||||
) : (
|
) : (
|
||||||
<div className="ml-auto size-4" />
|
<div className="ml-auto size-4" />
|
||||||
@@ -869,9 +924,9 @@ export function InputBox({
|
|||||||
</PromptInputActionMenuItem>
|
</PromptInputActionMenuItem>
|
||||||
{supportThinking && (
|
{supportThinking && (
|
||||||
<PromptInputActionMenuItem
|
<PromptInputActionMenuItem
|
||||||
className={cn(
|
className={cn(
|
||||||
context.mode === "thinking"
|
!selectedSingleRoundRag && context.mode === "thinking"
|
||||||
? "text-accent-foreground"
|
? "text-accent-foreground"
|
||||||
: "text-muted-foreground/65",
|
: "text-muted-foreground/65",
|
||||||
)}
|
)}
|
||||||
onSelect={() => handleModeSelect("thinking")}
|
onSelect={() => handleModeSelect("thinking")}
|
||||||
@@ -880,9 +935,9 @@ export function InputBox({
|
|||||||
<div className="flex items-center gap-1 font-bold">
|
<div className="flex items-center gap-1 font-bold">
|
||||||
<LightbulbIcon
|
<LightbulbIcon
|
||||||
className={cn(
|
className={cn(
|
||||||
"mr-2 size-4",
|
"mr-2 size-4",
|
||||||
context.mode === "thinking" &&
|
!selectedSingleRoundRag && context.mode === "thinking" &&
|
||||||
"text-accent-foreground",
|
"text-accent-foreground",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{t.inputBox.reasoningMode}
|
{t.inputBox.reasoningMode}
|
||||||
@@ -891,7 +946,7 @@ export function InputBox({
|
|||||||
{t.inputBox.reasoningModeDescription}
|
{t.inputBox.reasoningModeDescription}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{context.mode === "thinking" ? (
|
{!selectedSingleRoundRag && context.mode === "thinking" ? (
|
||||||
<CheckIcon className="ml-auto size-4" />
|
<CheckIcon className="ml-auto size-4" />
|
||||||
) : (
|
) : (
|
||||||
<div className="ml-auto size-4" />
|
<div className="ml-auto size-4" />
|
||||||
@@ -899,8 +954,8 @@ export function InputBox({
|
|||||||
</PromptInputActionMenuItem>
|
</PromptInputActionMenuItem>
|
||||||
)}
|
)}
|
||||||
<PromptInputActionMenuItem
|
<PromptInputActionMenuItem
|
||||||
className={cn(
|
className={cn(
|
||||||
context.mode === "pro"
|
!selectedSingleRoundRag && context.mode === "pro"
|
||||||
? "text-accent-foreground"
|
? "text-accent-foreground"
|
||||||
: "text-muted-foreground/65",
|
: "text-muted-foreground/65",
|
||||||
)}
|
)}
|
||||||
@@ -910,8 +965,8 @@ export function InputBox({
|
|||||||
<div className="flex items-center gap-1 font-bold">
|
<div className="flex items-center gap-1 font-bold">
|
||||||
<GraduationCapIcon
|
<GraduationCapIcon
|
||||||
className={cn(
|
className={cn(
|
||||||
"mr-2 size-4",
|
"mr-2 size-4",
|
||||||
context.mode === "pro" &&
|
!selectedSingleRoundRag && context.mode === "pro" &&
|
||||||
"text-accent-foreground",
|
"text-accent-foreground",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -921,15 +976,15 @@ export function InputBox({
|
|||||||
{t.inputBox.proModeDescription}
|
{t.inputBox.proModeDescription}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{context.mode === "pro" ? (
|
{!selectedSingleRoundRag && context.mode === "pro" ? (
|
||||||
<CheckIcon className="ml-auto size-4" />
|
<CheckIcon className="ml-auto size-4" />
|
||||||
) : (
|
) : (
|
||||||
<div className="ml-auto size-4" />
|
<div className="ml-auto size-4" />
|
||||||
)}
|
)}
|
||||||
</PromptInputActionMenuItem>
|
</PromptInputActionMenuItem>
|
||||||
<PromptInputActionMenuItem
|
<PromptInputActionMenuItem
|
||||||
className={cn(
|
className={cn(
|
||||||
context.mode === "ultra"
|
!selectedSingleRoundRag && context.mode === "ultra"
|
||||||
? "text-accent-foreground"
|
? "text-accent-foreground"
|
||||||
: "text-muted-foreground/65",
|
: "text-muted-foreground/65",
|
||||||
)}
|
)}
|
||||||
@@ -939,13 +994,17 @@ export function InputBox({
|
|||||||
<div className="flex items-center gap-1 font-bold">
|
<div className="flex items-center gap-1 font-bold">
|
||||||
<RocketIcon
|
<RocketIcon
|
||||||
className={cn(
|
className={cn(
|
||||||
"mr-2 size-4",
|
"mr-2 size-4",
|
||||||
context.mode === "ultra" && "text-[#dabb5e]",
|
!selectedSingleRoundRag &&
|
||||||
|
context.mode === "ultra" &&
|
||||||
|
"text-[#dabb5e]",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
context.mode === "ultra" && "golden-text",
|
!selectedSingleRoundRag &&
|
||||||
|
context.mode === "ultra" &&
|
||||||
|
"golden-text",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{t.inputBox.ultraMode}
|
{t.inputBox.ultraMode}
|
||||||
@@ -955,13 +1014,46 @@ export function InputBox({
|
|||||||
{t.inputBox.ultraModeDescription}
|
{t.inputBox.ultraModeDescription}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{context.mode === "ultra" ? (
|
{!selectedSingleRoundRag && context.mode === "ultra" ? (
|
||||||
<CheckIcon className="ml-auto size-4" />
|
<CheckIcon className="ml-auto size-4" />
|
||||||
) : (
|
) : (
|
||||||
<div className="ml-auto size-4" />
|
<div className="ml-auto size-4" />
|
||||||
)}
|
)}
|
||||||
</PromptInputActionMenuItem>
|
</PromptInputActionMenuItem>
|
||||||
</PromptInputActionMenu>
|
{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>
|
</DropdownMenuGroup>
|
||||||
</PromptInputActionMenuContent>
|
</PromptInputActionMenuContent>
|
||||||
</PromptInputActionMenu>
|
</PromptInputActionMenu>
|
||||||
@@ -1146,7 +1238,7 @@ export function InputBox({
|
|||||||
<ModelSelectorContent>
|
<ModelSelectorContent>
|
||||||
<ModelSelectorInput placeholder={t.inputBox.searchModels} />
|
<ModelSelectorInput placeholder={t.inputBox.searchModels} />
|
||||||
<ModelSelectorList>
|
<ModelSelectorList>
|
||||||
{models.map((m) => (
|
{modelOptions.map((m) => (
|
||||||
<ModelSelectorItem
|
<ModelSelectorItem
|
||||||
key={m.name}
|
key={m.name}
|
||||||
value={m.name}
|
value={m.name}
|
||||||
@@ -1154,9 +1246,11 @@ export function InputBox({
|
|||||||
>
|
>
|
||||||
<div className="flex min-w-0 flex-1 flex-col">
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
<ModelSelectorName>{m.display_name}</ModelSelectorName>
|
<ModelSelectorName>{m.display_name}</ModelSelectorName>
|
||||||
<span className="text-muted-foreground truncate text-[10px]">
|
<span className="text-muted-foreground truncate text-[10px]">
|
||||||
{m.model}
|
{selectedSingleRoundRag
|
||||||
</span>
|
? `单轮知识库问答 · ${m.name}`
|
||||||
|
: m.model}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{m.name === context.model_name ? (
|
{m.name === context.model_name ? (
|
||||||
<CheckIcon className="ml-auto size-4" />
|
<CheckIcon className="ml-auto size-4" />
|
||||||
@@ -1164,8 +1258,8 @@ export function InputBox({
|
|||||||
<div className="ml-auto size-4" />
|
<div className="ml-auto size-4" />
|
||||||
)}
|
)}
|
||||||
</ModelSelectorItem>
|
</ModelSelectorItem>
|
||||||
))}
|
))}
|
||||||
</ModelSelectorList>
|
</ModelSelectorList>
|
||||||
</ModelSelectorContent>
|
</ModelSelectorContent>
|
||||||
</ModelSelector>
|
</ModelSelector>
|
||||||
<PromptInputSubmit
|
<PromptInputSubmit
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ import {
|
|||||||
} from "@/core/skill-display/runtime";
|
} from "@/core/skill-display/runtime";
|
||||||
import type { Subtask } from "@/core/tasks";
|
import type { Subtask } from "@/core/tasks";
|
||||||
import { useUpdateSubtask } from "@/core/tasks/context";
|
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 { cn } from "@/lib/utils";
|
||||||
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
|
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
|
||||||
|
|
||||||
@@ -235,12 +236,17 @@ export function MessageList({
|
|||||||
}, [messages, askTimes]);
|
}, [messages, askTimes]);
|
||||||
|
|
||||||
const [settings] = useLocalSettings();
|
const [settings] = useLocalSettings();
|
||||||
const ragModeEnabled =
|
const ragModeEnabled =
|
||||||
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
|
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
|
||||||
const { skills } = useSkills();
|
const hasSingleRoundRag = useMemo(
|
||||||
const skillDisplayMap = useMemo(
|
() => messages.some(isSingleRoundRagMessage),
|
||||||
() => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
[messages],
|
||||||
[ragModeEnabled, skills],
|
);
|
||||||
|
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
|
// Same per-turn reference batches the right-side panel renders (backend
|
||||||
// batches preferred). Inline [n] tokens must resolve against these so badge
|
// 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") {
|
if (group.type === "human" || group.type === "assistant") {
|
||||||
return group.messages.map((msg) => {
|
return group.messages.map((msg) => {
|
||||||
const isAssistantMessage =
|
const isAssistantMessage =
|
||||||
ragModeEnabled &&
|
referencesEnabled &&
|
||||||
group.type === "assistant" &&
|
group.type === "assistant" &&
|
||||||
msg.type === "ai";
|
msg.type === "ai";
|
||||||
const batchSources = isAssistantMessage
|
const batchSources = isAssistantMessage
|
||||||
|
|||||||
@@ -46,20 +46,23 @@ function getModeDescriptionKey(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ModeHoverGuide({
|
export function ModeHoverGuide({
|
||||||
mode,
|
mode,
|
||||||
children,
|
children,
|
||||||
showTitle = true,
|
showTitle = true,
|
||||||
}: {
|
tooltipContent,
|
||||||
mode: AgentMode;
|
}: {
|
||||||
children: React.ReactNode;
|
mode: AgentMode;
|
||||||
/** When true, tooltip shows "ModeName: Description". When false, only description. */
|
children: React.ReactNode;
|
||||||
showTitle?: boolean;
|
/** When true, tooltip shows "ModeName: Description". When false, only description. */
|
||||||
}) {
|
showTitle?: boolean;
|
||||||
const { t } = useI18n();
|
/** Overrides the standard mode tooltip for page-specific execution modes. */
|
||||||
const label = t.inputBox[getModeLabelKey(mode)];
|
tooltipContent?: string;
|
||||||
const description = t.inputBox[getModeDescriptionKey(mode)];
|
}) {
|
||||||
const content = showTitle ? `${label}: ${description}` : description;
|
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>;
|
return <Tooltip content={content}>{children}</Tooltip>;
|
||||||
}
|
}
|
||||||
@@ -184,7 +184,7 @@ export function WorkspaceNavChatList({
|
|||||||
</Link>
|
</Link>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
<SidebarMenuItem>
|
{/* <SidebarMenuItem>
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
isActive={pathname.startsWith("/page/workspace/roundtable/position")}
|
isActive={pathname.startsWith("/page/workspace/roundtable/position")}
|
||||||
asChild
|
asChild
|
||||||
@@ -195,7 +195,7 @@ export function WorkspaceNavChatList({
|
|||||||
<span>岗位会商</span>
|
<span>岗位会商</span>
|
||||||
</Link>
|
</Link>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem> */}
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
isActive={pathname === "/page/workspace/scheduled/tasks"}
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,9 +4,11 @@ export interface Model {
|
|||||||
model: string;
|
model: string;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
supports_thinking?: boolean;
|
supports_thinking?: boolean;
|
||||||
supports_reasoning_effort?: boolean;
|
supports_reasoning_effort?: boolean;
|
||||||
}
|
/** Execution profiles handled by the frontend instead of DeerFlow. */
|
||||||
|
execution_profile?: "single_round_rag";
|
||||||
|
}
|
||||||
|
|
||||||
export interface TokenUsageSettings {
|
export interface TokenUsageSettings {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
|||||||
@@ -23,9 +23,11 @@ export interface AgentThreadContext extends Record<string, unknown> {
|
|||||||
rag_mode_enabled?: boolean;
|
rag_mode_enabled?: boolean;
|
||||||
/** Per-request switch for context compression (上下文压缩). Default off. */
|
/** Per-request switch for context compression (上下文压缩). Default off. */
|
||||||
summarization_enabled?: boolean;
|
summarization_enabled?: boolean;
|
||||||
/** Per-conversation switch for knowledge-base RAG injection (phase 2). */
|
/** Per-conversation switch for knowledge-base RAG injection (phase 2). */
|
||||||
knowledge_rag_enabled?: boolean;
|
knowledge_rag_enabled?: boolean;
|
||||||
agent_id?: string;
|
/** 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 */
|
/** @deprecated kept for reading legacy thread metadata; prefer agent_id */
|
||||||
agent_name?: string;
|
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 { useAgentSelector, useAgentsPaginated } from "@/core/agents";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import { useModels } from "@/core/models/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 { useNotification } from "@/core/notification/hooks";
|
||||||
import {
|
import {
|
||||||
getLastAgentId,
|
getLastAgentId,
|
||||||
@@ -52,6 +56,7 @@ import {
|
|||||||
useThreadSettings,
|
useThreadSettings,
|
||||||
} from "@/core/settings";
|
} from "@/core/settings";
|
||||||
import { useThreadStream } from "@/core/threads/hooks";
|
import { useThreadStream } from "@/core/threads/hooks";
|
||||||
|
import { useSingleRoundRagMessages } from "@/core/threads/use-single-round-rag";
|
||||||
import { useLayoutMode } from "@/core/system-settings/hooks";
|
import { useLayoutMode } from "@/core/system-settings/hooks";
|
||||||
import { useRouteBase } from "@/core/route-base";
|
import { useRouteBase } from "@/core/route-base";
|
||||||
import { textOfMessage } from "@/core/threads/utils";
|
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 writingArtifactPath = useMemo(() => {
|
||||||
const isMarkdown = (path: string | null | undefined) =>
|
const isMarkdown = (path: string | null | undefined) =>
|
||||||
@@ -240,6 +259,46 @@ export default function ChatPage() {
|
|||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
(message: PromptInputMessage) => {
|
(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
|
// If the user picked an agent before sending the opening turn, hand off
|
||||||
// to AgentChatPage so the conversation runs against that agent. We stash
|
// to AgentChatPage so the conversation runs against that agent. We stash
|
||||||
// the pending text in sessionStorage because the next/navigation shim
|
// the pending text in sessionStorage because the next/navigation shim
|
||||||
@@ -332,7 +391,13 @@ export default function ChatPage() {
|
|||||||
isMock,
|
isMock,
|
||||||
router,
|
router,
|
||||||
settings.context.writing_mode,
|
settings.context.writing_mode,
|
||||||
|
settings.context.model_name,
|
||||||
writingArtifactPath,
|
writingArtifactPath,
|
||||||
|
singleRoundRag,
|
||||||
|
singleRoundRagSelected,
|
||||||
|
routeBase,
|
||||||
|
setIsNewThread,
|
||||||
|
setThreadId,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -421,8 +486,12 @@ export default function ChatPage() {
|
|||||||
saveLastAgentId(agentId);
|
saveLastAgentId(agentId);
|
||||||
}, []);
|
}, []);
|
||||||
const handleStop = useCallback(async () => {
|
const handleStop = useCallback(async () => {
|
||||||
|
if (singleRoundRag.isLoading) {
|
||||||
|
singleRoundRag.stop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
await thread.stop();
|
await thread.stop();
|
||||||
}, [thread]);
|
}, [singleRoundRag, thread]);
|
||||||
|
|
||||||
const promptPrefixProp = useMemo(() => {
|
const promptPrefixProp = useMemo(() => {
|
||||||
if (!promptPrefixSettings?.enabled) return undefined;
|
if (!promptPrefixSettings?.enabled) return undefined;
|
||||||
@@ -468,7 +537,7 @@ export default function ChatPage() {
|
|||||||
const compactActionsHidden = isHideChromeActive();
|
const compactActionsHidden = isHideChromeActive();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThreadContext.Provider value={{ thread, isMock }}>
|
<ThreadContext.Provider value={{ thread: displayThread, isMock }}>
|
||||||
<ChatBox threadId={threadId}>
|
<ChatBox threadId={threadId}>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -486,7 +555,7 @@ export default function ChatPage() {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex w-full items-center text-sm font-medium">
|
<div className="flex w-full items-center text-sm font-medium">
|
||||||
<ThreadTitle threadId={threadId} thread={thread} />
|
<ThreadTitle threadId={threadId} thread={displayThread} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* 仅在收起态显示「展开历史」钮;展开后由侧栏内(新对话右侧)的收起钮负责收起。 */}
|
{/* 仅在收起态显示「展开历史」钮;展开后由侧栏内(新对话右侧)的收起钮负责收起。 */}
|
||||||
@@ -505,7 +574,7 @@ export default function ChatPage() {
|
|||||||
)}
|
)}
|
||||||
<TokenUsageIndicator
|
<TokenUsageIndicator
|
||||||
enabled={tokenUsageEnabled}
|
enabled={tokenUsageEnabled}
|
||||||
messages={thread.messages}
|
messages={displayThread.messages}
|
||||||
/>
|
/>
|
||||||
<ExportTrigger threadId={threadId} />
|
<ExportTrigger threadId={threadId} />
|
||||||
<KnowledgeSaveTrigger threadId={threadId} />
|
<KnowledgeSaveTrigger threadId={threadId} />
|
||||||
@@ -543,7 +612,7 @@ export default function ChatPage() {
|
|||||||
<MessageList
|
<MessageList
|
||||||
className={cn("size-full", "pt-10")}
|
className={cn("size-full", "pt-10")}
|
||||||
threadId={threadId}
|
threadId={threadId}
|
||||||
thread={thread}
|
thread={displayThread}
|
||||||
paddingBottom={messageListPaddingBottom}
|
paddingBottom={messageListPaddingBottom}
|
||||||
tokenUsageEnabled={tokenUsageEnabled}
|
tokenUsageEnabled={tokenUsageEnabled}
|
||||||
// 输入框为底部绝对浮层(约 160px 高),按钮上移到其上方居中。
|
// 输入框为底部绝对浮层(约 160px 高),按钮上移到其上方居中。
|
||||||
@@ -585,9 +654,9 @@ export default function ChatPage() {
|
|||||||
<div className="absolute right-0 bottom-0 left-0">
|
<div className="absolute right-0 bottom-0 left-0">
|
||||||
<TodoList
|
<TodoList
|
||||||
className="bg-background/5"
|
className="bg-background/5"
|
||||||
todos={thread.values.todos ?? []}
|
todos={displayThread.values.todos ?? []}
|
||||||
hidden={
|
hidden={
|
||||||
!thread.values.todos || thread.values.todos.length === 0
|
!displayThread.values.todos || displayThread.values.todos.length === 0
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -612,15 +681,17 @@ export default function ChatPage() {
|
|||||||
status={
|
status={
|
||||||
thread.error
|
thread.error
|
||||||
? "error"
|
? "error"
|
||||||
: thread.isLoading
|
: displayThread.isLoading
|
||||||
? "streaming"
|
? "streaming"
|
||||||
: "ready"
|
: "ready"
|
||||||
}
|
}
|
||||||
context={settings.context}
|
context={settings.context}
|
||||||
|
extraModels={SINGLE_ROUND_RAG_MODELS}
|
||||||
disabled={
|
disabled={
|
||||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
|
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
|
||||||
isUploading ||
|
isUploading ||
|
||||||
agentHandoffPending
|
agentHandoffPending ||
|
||||||
|
singleRoundRag.isLoading
|
||||||
}
|
}
|
||||||
onContextChange={(context) =>
|
onContextChange={(context) =>
|
||||||
setSettings("context", context)
|
setSettings("context", context)
|
||||||
@@ -664,7 +735,7 @@ export default function ChatPage() {
|
|||||||
*/}
|
*/}
|
||||||
{!isLandingState && (
|
{!isLandingState && (
|
||||||
<QuestionNavigator
|
<QuestionNavigator
|
||||||
messages={thread.messages}
|
messages={displayThread.messages}
|
||||||
className="top-16 right-0 bottom-48 left-0"
|
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 PromptPrefixSettingsPage from './PromptPrefixSettingsPage';
|
||||||
import PrivateSquareSettingsPage from './PrivateSquareSettingsPage';
|
import PrivateSquareSettingsPage from './PrivateSquareSettingsPage';
|
||||||
import ModelConcurrencySettingsPage from './ModelConcurrencySettingsPage';
|
import ModelConcurrencySettingsPage from './ModelConcurrencySettingsPage';
|
||||||
|
import ModelManagementPage from './ModelManagementPage';
|
||||||
import PublishSquaresSettingsPage from './PublishSquaresSettingsPage';
|
import PublishSquaresSettingsPage from './PublishSquaresSettingsPage';
|
||||||
import SkillCompressionSettingsPage from './SkillCompressionSettingsPage';
|
import SkillCompressionSettingsPage from './SkillCompressionSettingsPage';
|
||||||
import SkillEvolutionPage from './SkillEvolutionPage';
|
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
|
<Route
|
||||||
path="settings/model-concurrency"
|
path="settings/model-concurrency"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -47,8 +47,9 @@ from app.gateway.routers import (
|
|||||||
llm_metrics,
|
llm_metrics,
|
||||||
mcp,
|
mcp,
|
||||||
memory,
|
memory,
|
||||||
menu_overrides,
|
menu_overrides,
|
||||||
models,
|
model_management,
|
||||||
|
models,
|
||||||
multi_agent,
|
multi_agent,
|
||||||
notifications,
|
notifications,
|
||||||
open_3qfx,
|
open_3qfx,
|
||||||
@@ -1425,8 +1426,10 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Include routers
|
# Include routers
|
||||||
# Models API is mounted at /api/models
|
# Models API is mounted at /api/models
|
||||||
app.include_router(models.router)
|
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
|
# MCP API is mounted at /api/mcp
|
||||||
app.include_router(mcp.router)
|
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
|
||||||
Reference in new issue
Block a user