From 819cc73fcdb7ed5588adc2190de1a26108774062 Mon Sep 17 00:00:00 2001 From: angri450 Date: Tue, 8 Sep 2026 01:58:57 +0800 Subject: [PATCH 1/2] feat(dashboard): model routing panel - ordered failover chain on models page - New ModelRouting section: ordered model list (1..8), add/move/remove, saved via PATCH /api/preferences { model_routing: [...] } - preferences api types + useProviders expose modelRouting - i18n zh/en --- dashboard/src/api/modules/preferences.ts | 2 + dashboard/src/locales/en.json | 13 +- dashboard/src/locales/zh.json | 13 +- .../components/sections/ModelRouting.tsx | 180 ++++++++++++++++++ .../Models/components/sections/index.ts | 1 + .../pages/Settings/Models/index.module.less | 53 ++++++ dashboard/src/pages/Settings/Models/index.tsx | 10 + .../src/pages/Settings/Models/useProviders.ts | 8 + 8 files changed, 278 insertions(+), 2 deletions(-) create mode 100644 dashboard/src/pages/Settings/Models/components/sections/ModelRouting.tsx diff --git a/dashboard/src/api/modules/preferences.ts b/dashboard/src/api/modules/preferences.ts index 5df775fda..cbd3bc06c 100644 --- a/dashboard/src/api/modules/preferences.ts +++ b/dashboard/src/api/modules/preferences.ts @@ -13,6 +13,7 @@ export interface UserPreferences { string, { mode: "auto" | "enabled" | "disabled"; effort?: string | null } >; + model_routing: string[]; } export type PatchPreferencesBody = { @@ -20,6 +21,7 @@ export type PatchPreferencesBody = { remote_browser_bookmarks?: RemoteBrowserBookmark[]; preferred_model?: string | null; model_reasoning?: UserPreferences["model_reasoning"]; + model_routing?: string[] | null; }; export const preferencesApi = { diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index 3aafe5487..5071c46ad 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -2921,7 +2921,18 @@ "onnxNotReady": "not ready", "onnxCacheDir": "Cache: {{dir}}", "onnxLocalCached": "Cached models", - "onnxQuickDownload": "Quick download from catalog…" + "onnxQuickDownload": "Quick download from catalog…", + "routingTitle": "Model Routing", + "routingTooltip": "Models are tried in order: when one fails (rate limit / outage / unavailable), the next is called automatically. When routing is set it takes precedence; the star preference above only applies when routing is empty.", + "routingDesc": "Models are called in order; on failure (rate limit / outage) the next one takes over automatically. 2-4 models recommended: primary first, fallbacks after.", + "routingCount": "{{count}} set", + "routingEmpty": "No routing configured. Add models in order below — if the first fails, the next takes over automatically.", + "routingAdd": "Add Model", + "routingAddPlaceholder": "Pick a model to add", + "routingMoveUp": "Move up", + "routingMoveDown": "Move down", + "routingRemove": "Remove", + "routingSaved": "Model routing updated" }, "advancedSettings": { "description": "Manage runtime configuration and environment variables.", diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json index 6f0cebf91..e143cfcf1 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -2918,7 +2918,18 @@ "onnxNotReady": "未就绪", "onnxCacheDir": "缓存目录:{{dir}}", "onnxLocalCached": "已缓存模型", - "onnxQuickDownload": "从目录快速下载…" + "onnxQuickDownload": "从目录快速下载…", + "routingTitle": "模型路由", + "routingTooltip": "按顺序依次调用:第 1 个模型失败(限流/故障/不可用)时自动切换下一个,全部失败才报错。启用路由后优先按此顺序调用,上方星星偏好仅在未配置路由时生效。", + "routingDesc": "按顺序依次调用:第 1 个模型失败(限流/故障)时自动切换下一个。建议放 2-4 个:主用模型在前,备用在后。", + "routingCount": "{{count}} 个", + "routingEmpty": "未配置路由。点击下方按钮按顺序添加模型,第 1 个失败时自动切换下一个。", + "routingAdd": "添加模型", + "routingAddPlaceholder": "选择要加入路由的模型", + "routingMoveUp": "上移", + "routingMoveDown": "下移", + "routingRemove": "移除", + "routingSaved": "模型路由已更新" }, "advancedSettings": { "description": "管理运行配置和环境变量等高级选项。", diff --git a/dashboard/src/pages/Settings/Models/components/sections/ModelRouting.tsx b/dashboard/src/pages/Settings/Models/components/sections/ModelRouting.tsx new file mode 100644 index 000000000..89c302d75 --- /dev/null +++ b/dashboard/src/pages/Settings/Models/components/sections/ModelRouting.tsx @@ -0,0 +1,180 @@ +/** + * ModelRouting — 模型路由(有序 failover 链)。 + * + * 按用户手动设置的顺序依次调用模型:第 1 个失败(限流/故障/不可用)时 + * 自动切换下一个,全部失败才报错。链为空时不启用路由,按偏好模型(星星)走。 + * 保存走 PATCH /api/preferences { model_routing: string[] }。 + */ +import { useMemo, useState } from "react"; +import { ArrowDown, ArrowUp, Info, Plus, Trash2 } from "lucide-react"; +import { Button, Empty, Select, Tag, Tooltip } from "antd"; +import { message } from "@/utils/antdMessage"; + +import { useTranslation } from "react-i18next"; +import { preferencesApi } from "../../../../../api/modules/preferences"; +import type { ResolvedModel } from "../../useProviders"; +import { modelOptionLabel } from "../../../../../utils/modelOptions"; +import styles from "../../index.module.less"; + +const MAX_ROUTING = 8; + +interface ModelRoutingProps { + resolvedModels: ResolvedModel[]; + routing: string[]; + onSaved: () => void | Promise; +} + +export function ModelRouting({ + resolvedModels, + routing, + onSaved, +}: ModelRoutingProps) { + const { t } = useTranslation(); + const [saving, setSaving] = useState(false); + const [adding, setAdding] = useState(false); + + // ref -> 展示名(找不到就显示原始 ref,容忍 provider 被删后链里残留的项) + const labelOf = useMemo(() => { + const map = new Map(); + for (const m of resolvedModels) { + map.set(`${m.provider_name}/${m.model}`, modelOptionLabel(m)); + } + return (ref: string) => map.get(ref) ?? ref; + }, [resolvedModels]); + + const candidates = useMemo( + () => + resolvedModels + .map((m) => ({ + value: `${m.provider_name}/${m.model}`, + label: modelOptionLabel(m), + })) + .filter((o) => !routing.includes(o.value)), + [resolvedModels, routing], + ); + + const save = async (next: string[]) => { + setSaving(true); + try { + await preferencesApi.patch({ model_routing: next }); + message.success(t("models.routingSaved")); + await onSaved(); + } catch (err) { + message.error( + err instanceof Error ? err.message : t("common.saveFailed"), + ); + } finally { + setSaving(false); + } + }; + + const move = (index: number, delta: number) => { + const next = [...routing]; + const target = index + delta; + if (target < 0 || target >= next.length) return; + [next[index], next[target]] = [next[target], next[index]]; + void save(next); + }; + + const remove = (index: number) => { + void save(routing.filter((_, i) => i !== index)); + }; + + const append = (ref: string) => { + if (!ref || routing.length >= MAX_ROUTING) return; + void save([...routing, ref]); + setAdding(false); + }; + + return ( +
+
+
+

{t("models.routingTitle")}

+ + + +
+ {routing.length > 0 && ( + + {t("models.routingCount", { count: routing.length })} + + )} +
+ +

{t("models.routingDesc")}

+ + {routing.length === 0 ? ( + + ) : ( +
+ {routing.map((ref, i) => ( +
+ {i + 1} + + {labelOf(ref)} + +
+ +
+
+ ))} +
+ )} + +
+ {adding ? ( +