Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@

## [Unreleased]

### 新增

- 模型路由:模型设置页可配置有序模型链(1..8),当前模型调用失败(限流/故障/不可用)时自动切换下一个;启用路由后优先于偏好模型生效。运行时切换由 harness 包的 ModelFailoverMiddleware 提供,本仓库负责链的存储、校验与逐轮透传

### 修复

- 共享专家的技能列表现在会在聊天输入框中加载,非所有者可查看并选择专家已配置的技能
Expand Down
2 changes: 2 additions & 0 deletions dashboard/src/api/modules/preferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@ export interface UserPreferences {
string,
{ mode: "auto" | "enabled" | "disabled"; effort?: string | null }
>;
model_routing: string[];
}

export type PatchPreferencesBody = {
locale?: string;
remote_browser_bookmarks?: RemoteBrowserBookmark[];
preferred_model?: string | null;
model_reasoning?: UserPreferences["model_reasoning"];
model_routing?: string[] | null;
};

export const preferencesApi = {
Expand Down
13 changes: 12 additions & 1 deletion dashboard/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
13 changes: 12 additions & 1 deletion dashboard/src/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "管理运行配置和环境变量等高级选项。",
Expand Down
Original file line number Diff line number Diff line change
@@ -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<void>;
}

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<string, string>();
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 (
<div className={styles.poolSection}>
<div className={styles.poolHeader}>
<div className={styles.poolHeaderLeft}>
<h3 className={styles.slotTitle}>{t("models.routingTitle")}</h3>
<Tooltip title={t("models.routingTooltip")}>
<Info size={14} className={styles.poolInfoIcon} />
</Tooltip>
</div>
{routing.length > 0 && (
<Tag color="blue" className={styles.poolCountTag}>
{t("models.routingCount", { count: routing.length })}
</Tag>
)}
</div>

<p className={styles.poolDesc}>{t("models.routingDesc")}</p>

{routing.length === 0 ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={t("models.routingEmpty")}
/>
) : (
<div className={styles.routingList}>
{routing.map((ref, i) => (
<div key={ref} className={styles.routingRow}>
<span className={styles.routingIndex}>{i + 1}</span>
<span className={styles.routingName} title={ref}>
{labelOf(ref)}
</span>
<div className={styles.routingActions}>
<Tooltip title={t("models.routingMoveUp")}>
<Button
type="text"
size="small"
disabled={i === 0 || saving}
icon={<ArrowUp size={14} />}
onClick={() => move(i, -1)}
/>
</Tooltip>
<Tooltip title={t("models.routingMoveDown")}>
<Button
type="text"
size="small"
disabled={i === routing.length - 1 || saving}
icon={<ArrowDown size={14} />}
onClick={() => move(i, 1)}
/>
</Tooltip>
<Tooltip title={t("models.routingRemove")}>
<Button
type="text"
size="small"
danger
disabled={saving}
icon={<Trash2 size={14} />}
onClick={() => remove(i)}
/>
</Tooltip>
</div>
</div>
))}
</div>
)}

<div className={styles.routingAddRow}>
{adding ? (
<Select
autoFocus
showSearch
placeholder={t("models.routingAddPlaceholder")}
style={{ minWidth: 280 }}
options={candidates}
onSelect={(value) => append(value as string)}
onBlur={() => setAdding(false)}
notFoundContent={t("models.noModelsAvailable")}
/>
) : (
<Button
size="small"
icon={<Plus size={14} />}
disabled={routing.length >= MAX_ROUTING || saving}
onClick={() => setAdding(true)}
>
{t("models.routingAdd")}
</Button>
)}
</div>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from "./LoadingState";
export * from "./ActiveModelPool";
export * from "./ModelRouting";
53 changes: 53 additions & 0 deletions dashboard/src/pages/Settings/Models/index.module.less
Original file line number Diff line number Diff line change
Expand Up @@ -1231,3 +1231,56 @@
border: 1px dashed var(--fn-border-input);
border-radius: var(--fn-radius-md);
}

/* ---- ModelRouting(模型路由 failover 链)---- */

.routingList {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 12px;
}

.routingRow {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border: 1px solid var(--fn-border-input);
border-radius: var(--fn-radius-md);
background: var(--fn-bg-container);
}

.routingIndex {
flex: none;
width: 22px;
height: 22px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--fn-primary, #1668dc);
color: #fff;
font-size: 12px;
font-weight: 600;
}

.routingName {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
}

.routingActions {
flex: none;
display: flex;
align-items: center;
gap: 2px;
}

.routingAddRow {
margin-top: 4px;
}
10 changes: 10 additions & 0 deletions dashboard/src/pages/Settings/Models/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
CustomProviderModal,
ActiveModelPool,
LoadingState,
ModelRouting,
PresetGroupCard,
PresetProviderCard,
ProviderCard,
Expand Down Expand Up @@ -80,6 +81,7 @@ export default function ModelsPage() {
resolvedModels,
activeModel,
modelReasoning,
modelRouting,
loading,
error,
fetchAll,
Expand Down Expand Up @@ -275,6 +277,14 @@ export default function ModelsPage() {

<Divider style={{ margin: "24px 0" }} />

<ModelRouting
resolvedModels={resolvedModels}
routing={modelRouting}
onSaved={fetchAll}
/>

<Divider style={{ margin: "24px 0" }} />

{showPresetSection && (
<>
<Title level={5} style={{ marginBottom: 12 }}>
Expand Down
8 changes: 8 additions & 0 deletions dashboard/src/pages/Settings/Models/useProviders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ export interface UseProvidersResult {
string,
{ mode: "auto" | "enabled" | "disabled"; effort?: string | null }
>;
modelRouting: string[];
loading: boolean;
error: string | null;
fetchAll: () => Promise<void>;
Expand All @@ -99,6 +100,7 @@ export function useProviders(): UseProvidersResult {
const [modelReasoning, setModelReasoning] = useState<
UseProvidersResult["modelReasoning"]
>({});
const [modelRouting, setModelRouting] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const hasLoadedRef = useRef(false);
Expand Down Expand Up @@ -166,6 +168,11 @@ export function useProviders(): UseProvidersResult {
: { provider_name: "", model: "" },
);
setModelReasoning(preferences?.model_reasoning || {});
setModelRouting(
Array.isArray(preferences?.model_routing)
? preferences.model_routing
: [],
);
} catch (err) {
const msg =
err instanceof Error ? err.message : t("models.loadProvidersFailed");
Expand All @@ -187,6 +194,7 @@ export function useProviders(): UseProvidersResult {
resolvedModels,
activeModel,
modelReasoning,
modelRouting,
loading,
error,
fetchAll,
Expand Down
Loading