From ba912acf72bbb20a83f651f204265c03d0af6a90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Tue, 11 Aug 2026 21:45:56 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=91=98=E6=A8=A1=E5=9E=8B=E9=85=8D=E7=BD=AE=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E4=B8=8E=20config.yaml=20=E6=A8=A1=E5=9E=8B=E7=AE=A1=E7=90=86?= =?UTF-8?q?=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 /api/admin/models 路由(读写 config.yaml models 段、连接测试、写前备份),以及工作区「模型配置」管理页与侧栏入口。 Co-authored-by: Cursor --- .gitignore | 7 +- frontend-web/src/components/page-sidebar.tsx | 15 + frontend-web/src/core/model-management/api.ts | 107 +++++ .../src/core/model-management/hooks.ts | 38 ++ .../src/core/model-management/index.ts | 2 + .../src/pages/ModelManagementPage.tsx | 246 ++++++++++ frontend-web/src/pages/WorkspaceRoutes.tsx | 13 + .../backend/app/gateway/app.py | 11 +- .../app/gateway/routers/model_management.py | 437 ++++++++++++++++++ .../backend/tests/test_model_management.py | 92 ++++ 10 files changed, 962 insertions(+), 6 deletions(-) create mode 100644 frontend-web/src/core/model-management/api.ts create mode 100644 frontend-web/src/core/model-management/hooks.ts create mode 100644 frontend-web/src/core/model-management/index.ts create mode 100644 frontend-web/src/pages/ModelManagementPage.tsx create mode 100644 offline-backend-20260512/backend/app/gateway/routers/model_management.py create mode 100644 offline-backend-20260512/backend/tests/test_model_management.py diff --git a/.gitignore b/.gitignore index 7f1164a1..64e2ad7d 100644 --- a/.gitignore +++ b/.gitignore @@ -80,8 +80,11 @@ offline-backend-20260512/backend/skill_evolution.json skills/ hermes-agent-main/ .gitignore -offline-backend-20260512/backend/tests/* -deploy +offline-backend-20260512/backend/tests/* +# Keep the model configuration regression coverage versioned even though +# this deployment repository otherwise ignores newly added backend tests. +!offline-backend-20260512/backend/tests/test_model_management.py +deploy .history # Cloned third-party repos (local only) diff --git a/frontend-web/src/components/page-sidebar.tsx b/frontend-web/src/components/page-sidebar.tsx index eb1f0bd1..5119a2ba 100644 --- a/frontend-web/src/components/page-sidebar.tsx +++ b/frontend-web/src/components/page-sidebar.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { useNavigate, useLocation } from "react-router-dom"; import { Bell, + Cpu, ChevronLeft, ChevronRight, ChevronsUpDown, @@ -252,6 +253,14 @@ function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) { 白名单管理 )} + {isAdmin && ( + navigate("/page/workspace/admin/models")} + > + + 模型配置 + + )} {isAdmin && ( navigate("/page/strategy/settings/model-concurrency")} @@ -555,6 +564,12 @@ function LegacyAccordionSidebar({ 岗位角色配置 )} + {isAdmin && ( + navigate("/page/workspace/admin/models")}> + + 模型配置 + + )} {isAdmin && ( navigate("/page/strategy/admin/login-whitelist")}> diff --git a/frontend-web/src/core/model-management/api.ts b/frontend-web/src/core/model-management/api.ts new file mode 100644 index 00000000..aaa74bef --- /dev/null +++ b/frontend-web/src/core/model-management/api.ts @@ -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 | null; + when_thinking_disabled: Record | null; + thinking: Record | null; + use_responses_api: boolean | null; + output_version: string | null; + extra_settings: Record; + 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 | null; + when_thinking_disabled?: Record | null; + thinking?: Record | null; + use_responses_api?: boolean | null; + output_version?: string | null; + extra_settings: Record; +} + +export interface ModelConnectionTestResult { + success: boolean; + latency_ms: number; + message: string; + preview?: string | null; +} + +async function readError(response: Response, fallback: string): Promise { + const payload = await response.json().catch(() => null) as { detail?: string } | null; + throw new Error(payload?.detail || fallback); +} + +export async function loadManagedModels(): Promise { + const response = await apiFetch(`${getBackendBaseURL()}/api/admin/models`); + if (!response.ok) return readError(response, "加载模型配置失败"); + return response.json() as Promise; +} + +export async function createManagedModel(payload: ModelSavePayload): Promise { + 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; +} + +export async function updateManagedModel(name: string, payload: ModelSavePayload): Promise { + 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; +} + +export async function testManagedModel(model: ModelSavePayload): Promise { + 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; +} diff --git a/frontend-web/src/core/model-management/hooks.ts b/frontend-web/src/core/model-management/hooks.ts new file mode 100644 index 00000000..65a9e4b7 --- /dev/null +++ b/frontend-web/src/core/model-management/hooks.ts @@ -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 }); +} diff --git a/frontend-web/src/core/model-management/index.ts b/frontend-web/src/core/model-management/index.ts new file mode 100644 index 00000000..35312304 --- /dev/null +++ b/frontend-web/src/core/model-management/index.ts @@ -0,0 +1,2 @@ +export * from "./api"; +export * from "./hooks"; diff --git a/frontend-web/src/pages/ModelManagementPage.tsx b/frontend-web/src/pages/ModelManagementPage.tsx new file mode 100644 index 00000000..567a867b --- /dev/null +++ b/frontend-web/src/pages/ModelManagementPage.tsx @@ -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 = {}; + 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; + } 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 ( +
+ +
{value}
{label}
+
+ ); +} + +function Field({ label, required = false, children }: { label: string; required?: boolean; children: ReactNode }) { + return ; +} + +function ModelEditor({ model, onClose }: { model: ManagedModel | null; onClose: () => void }) { + const isNew = model === null; + const [draft, setDraft] = useState(() => 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) => 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 ( + void handleSave()} + onCancel={onClose} + > +
+
+ 保存会直接更新当前部署的 config.yaml 模型段,自动备份并热刷新;不会中断已开始的会话。 +
+
+ patch({ name: String(value) })} /> + patch({ display_name: String(value) })} /> + patch({ model: String(value) })} /> + patch({ base_url: String(value) })} /> + patch({ api_key: String(value), clear_api_key: false })} /> + {draft.keep_existing_key && !draft.api_key &&
已配置密钥{model?.api_key_source ? `(环境变量 ${model.api_key_source})` : ""},不会回显。 patch({ clear_api_key: !draft.clear_api_key })}>{draft.clear_api_key ? "撤销清除" : "清除密钥"}
} +