-
{p.enabled ? "生效组" : "备用组"}
-
{p.model}
-
→ {p.fallback || "无备选模型"}
-
接口报错自动降级
+
+
+ {p.enabled ? "生效组" : "备用组"}
+ {p.model}
+ → {p.fallback || "无备选模型"}
+ 接口报错自动降级
+
+
))
)}
- 本地模型第三级与 per-persona 路由由后端开关/配置决定,本端点不报告它们的运行态,因此这里不显示,也不臆造。
+ 本地模型第三级由后端开关与本地组配置决定,本端点不报告它的运行态,因此这里不显示,也不臆造。
);
@@ -166,6 +192,8 @@ function FallbackChain({ providers }: { providers: AdminLlmProvider[] }): React.
export function LlmModule(): React.ReactNode {
const { data, error, loading, reload } = useAsyncData(adminGetLlm, []);
+ // 角色清单只作候选提示:取不到也不阻塞编辑(persona id 可手填)。
+ const personas = useAsyncData(adminListPersonas, []);
const log = useAuditStore((s) => s.log);
// 端点未实现 / 请求失败必须分开:前者是 404/501,后者是断连或 5xx。
const unimplemented = isNotImplemented(error);
@@ -179,9 +207,12 @@ export function LlmModule(): React.ReactNode {
const [busy, setBusy] = useState(false);
const [fail, setFail] = useState
(null);
const [result, setResult] = useState(null);
+ // 目录候选带进来的能力建议(可推翻);没有建议时是 null。
+ const [hint, setHint] = useState(null);
- const edit = (p: AdminLlmProvider | null) => {
+ const edit = (p: AdminLlmProvider | null, next: CatalogHint | null) => {
setEditing(p);
+ setHint(next);
setDraftSeq((n) => n + 1);
};
@@ -198,14 +229,14 @@ export function LlmModule(): React.ReactNode {
unimplemented={unimplemented}
busy={busy}
onTest={(p) => runLlmTest(p, log, setBusy, setFail, setResult)}
- onEdit={(p) => edit(p)}
+ onEdit={(p) => edit(p, null)}
/>
{
+ onPick={(draft, picked) => {
log("llm.catalog.fill", "ok");
- edit(draft);
+ edit(draft, picked);
}}
/>
@@ -218,12 +249,17 @@ export function LlmModule(): React.ReactNode {
onError={setFail}
onSaved={reload}
onCancel={() => setEditing(null)}
+ hint={hint}
+ reasoningKinds={data?.reasoningKinds ?? []}
+ personaProviderOptions={data?.personaProviderOptions ?? []}
+ personaIds={(personas.data ?? []).map((p) => p.id)}
+ personaListError={personas.error}
/>
- per-role 路由 + 降级逻辑由后端 loop 实现;面板保存即写入 models.json 并热更新运行中的 LLM provider(无需重启,design/04 §3.1)。.env 为兜底。每组 maxTokens 留空 = 保持原值 / 未设时回落 LLM_FALLBACK_MAX_TOKENS。目录候选来自 models.dev,由后端缓存成快照(离线仍可用),绝不进模型运行时热路径。
+ 每组的 reasoning.kind(推理开关形状)按端点选:形状属于端点而不属于模型,选错会 400,因此面板只给出「未声明 / auto」与后端下发的几种形状。按角色路由(persona → 模型 / 协议)与降级链由后端 loop 真接线;面板保存即写入 models.json 并热更新运行中的 LLM provider(无需重启,design/04 §3.1),.env 为兜底。maxTokens 留空 = 保持原值 / 未设时回落 LLM_FALLBACK_MAX_TOKENS。multimodal 只表示主模型自己能否收图;图片/OCR 工具用的模型走 .env 的 LLM_VISION_MODEL / LLM_OCR_MODEL。目录候选来自 models.dev,由后端缓存成快照(离线仍可用),绝不进模型运行时热路径。
);
diff --git a/frontend/web/src/admin/modules/llm/LlmCatalogPanel.tsx b/frontend/web/src/admin/modules/llm/LlmCatalogPanel.tsx
index 63970b1..d7f975d 100644
--- a/frontend/web/src/admin/modules/llm/LlmCatalogPanel.tsx
+++ b/frontend/web/src/admin/modules/llm/LlmCatalogPanel.tsx
@@ -4,6 +4,7 @@ import { adminGetLlmCatalog, isNotImplemented } from "../../../lib/api";
import type { AdminLlmProvider } from "../../../lib/types";
import { Notice } from "../../components";
import { Button, Surface, Tag } from "../../../components/primitives";
+import type { CatalogHint } from "./LlmProviderRouting";
/** 目录候选一次最多渲染多少行(其余用一行文字如实说明还有多少条)。 */
const CATALOG_MAX_ROWS = 40;
@@ -12,9 +13,12 @@ const CATALOG_MAX_ROWS = 40;
interface CatalogModel {
id: string;
name: string;
- /** 接受图片/文件输入 → 表单的 multimodal。 */
- attachment: boolean;
- reasoning: boolean;
+ /** 接受图片/文件输入 → 表单的 multimodal。undefined = **目录未标注**该能力
+ * (不是「不支持」),面板据此既不预填也不说反话。 */
+ attachment?: boolean;
+ /** 目录标注该模型会推理;undefined = 未标注。它**只描述模型**,不描述端点
+ * 的开关形状,因此面板只作提示,绝不据此猜 reasoning.kind。 */
+ reasoning?: boolean;
toolCall: boolean;
contextLimit: number;
outputLimit: number;
@@ -60,8 +64,8 @@ function parseCatalogModel(raw: unknown, modelId: string): CatalogModel | null {
return {
id,
name: str(obj.name) || id,
- attachment: obj.attachment === true,
- reasoning: obj.reasoning === true,
+ attachment: typeof obj.attachment === "boolean" ? obj.attachment : undefined,
+ reasoning: typeof obj.reasoning === "boolean" ? obj.reasoning : undefined,
toolCall: obj.toolCall === true || obj.tool_call === true,
contextLimit: num(obj.contextLimit) || (limit ? num(limit.context) : 0),
outputLimit: num(obj.outputLimit) || (limit ? num(limit.output) : 0),
@@ -153,18 +157,27 @@ function protocolFromCatalog(p: CatalogProvider): string {
return /anthropic/i.test(p.npm) || /anthropic/i.test(p.id) ? "anthropic" : "openai";
}
-/** 目录候选 → 表单草稿。分组 id 取目录 id,重复填入即幂等更新同名分组;
- * maxTokens 建议值取模型的输出上限(目录真值,没有任何猜测成分)。 */
-function draftFromCatalog(p: CatalogProvider, m: CatalogModel): AdminLlmProvider {
+/** 目录候选 → 表单草稿 + 能力建议。分组 id 取目录 id,重复填入即幂等更新同名
+ * 分组;multimodal 只在目录**明确标注**接受图片输入时预填,maxTokens 建议值取
+ * 模型的输出上限(目录真值,没有任何猜测成分)——两者都只是预填,用户可推翻。 */
+function draftFromCatalog(p: CatalogProvider, m: CatalogModel): { draft: AdminLlmProvider; hint: CatalogHint } {
return {
- id: p.id,
- name: p.name,
- provider: protocolFromCatalog(p),
- baseUrl: p.api,
- model: m.id,
- multimodal: m.attachment,
- enabled: false,
- maxTokens: m.outputLimit || undefined,
+ draft: {
+ id: p.id,
+ name: p.name,
+ provider: protocolFromCatalog(p),
+ baseUrl: p.api,
+ model: m.id,
+ multimodal: m.attachment === true,
+ enabled: false,
+ maxTokens: m.outputLimit || undefined,
+ },
+ hint: {
+ provider: p.id,
+ model: m.id,
+ attachment: m.attachment,
+ supportsReasoning: m.reasoning === true,
+ },
};
}
@@ -182,8 +195,8 @@ function CatalogModelRow({
{model.name}
{model.id}
- {model.attachment ?
多模态 : null}
- {model.reasoning ?
推理 : null}
+ {model.attachment === true ?
可收图 : null}
+ {model.reasoning === true ?
推理 : null}
{model.toolCall ?
工具 : null}
{model.outputLimit ?
{`输出上限 ${model.outputLimit}`} : null}
{provider.api ? null :
目录未标 baseURL}
@@ -207,7 +220,7 @@ function CatalogList({
providers: CatalogProvider[];
query: string;
providerId: string;
- onPick: (draft: AdminLlmProvider) => void;
+ onPick: (draft: AdminLlmProvider, hint: CatalogHint) => void;
}): React.ReactNode {
const q = query.trim().toLowerCase();
const rows: { provider: CatalogProvider; model: CatalogModel }[] = [];
@@ -229,7 +242,10 @@ function CatalogList({
key={`${provider.id}/${model.id}`}
provider={provider}
model={model}
- onPick={() => onPick(draftFromCatalog(provider, model))}
+ onPick={() => {
+ const pick = draftFromCatalog(provider, model);
+ onPick(pick.draft, pick.hint);
+ }}
/>
))}
{rows.length > shown.length ? (
@@ -373,7 +389,11 @@ function CatalogFilters({
/** 目录面板:把「在线目录(后端快照 + 离线兜底)」与「粘贴导入」汇成同一份候选。
* 任何失败都只影响本栏,绝不编造候选、也绝不改动表单内容。 */
-export function CatalogPanel({ onPick }: { onPick: (draft: AdminLlmProvider) => void }): React.ReactNode {
+export function CatalogPanel({
+ onPick,
+}: {
+ onPick: (draft: AdminLlmProvider, hint: CatalogHint) => void;
+}): React.ReactNode {
// 第一次点「强制在线刷新」起,请求带上 ?refresh=1(再次点击即再刷一次)。
const [refreshSeq, setRefreshSeq] = useState(0);
const force = refreshSeq > 0;
diff --git a/frontend/web/src/admin/modules/llm/LlmProviderForm.tsx b/frontend/web/src/admin/modules/llm/LlmProviderForm.tsx
index 5576448..667fee6 100644
--- a/frontend/web/src/admin/modules/llm/LlmProviderForm.tsx
+++ b/frontend/web/src/admin/modules/llm/LlmProviderForm.tsx
@@ -2,7 +2,16 @@ import { useState } from "react";
import { adminAddLlmProvider } from "../../../lib/api";
import type { AdminLlmProvider, LlmProviderRequest } from "../../../lib/types";
import { useAuditStore } from "../../audit";
-import { Button, Surface, Switch } from "../../../components/primitives";
+import { Button, Surface } from "../../../components/primitives";
+import {
+ MultimodalField,
+ PersonaRoutesEditor,
+ ReasoningField,
+ initPersonaRoutes,
+ personaRouteProblem,
+ routesToMaps,
+} from "./LlmProviderRouting";
+import type { CatalogHint, PersonaRouteDraft } from "./LlmProviderRouting";
/** Add/edit form for one LLM provider group. The group list hands in an
* `initial` group to edit (remounted via key) or null to add a new one. The
@@ -17,6 +26,10 @@ interface FormState {
apiKey: string;
multimodal: boolean;
maxTokens: string;
+ /** 推理开关形状;空串 = 未声明(等同 auto)。 */
+ reasoning: string;
+ /** per-persona 路由行(persona → 模型 / 协议覆盖)。 */
+ personaRoutes: PersonaRouteDraft[];
}
type FieldSetter =
(key: K, value: FormState[K]) => void;
@@ -30,11 +43,31 @@ function initState(initial: AdminLlmProvider | null): FormState {
apiKey: "",
multimodal: initial?.multimodal ?? false,
maxTokens: initial?.maxTokens ? String(initial.maxTokens) : "",
+ reasoning: initial?.reasoning?.kind ?? "",
+ personaRoutes: initPersonaRoutes(initial),
};
}
/** The form fields; state lives in the form component below. */
-function ProviderFields({ form, set, editing }: { form: FormState; set: FieldSetter; editing: boolean }): React.ReactNode {
+function ProviderFields({
+ form,
+ set,
+ editing,
+ hint,
+ reasoningKinds,
+ personaProviderOptions,
+ personaIds,
+ personaListError,
+}: {
+ form: FormState;
+ set: FieldSetter;
+ editing: boolean;
+ hint: CatalogHint | null;
+ reasoningKinds: string[];
+ personaProviderOptions: string[];
+ personaIds: string[];
+ personaListError: string | null;
+}): React.ReactNode {
return (
<>
@@ -84,9 +117,21 @@ function ProviderFields({ form, set, editing }: { form: FormState; set: FieldSet
/>
-
- set("multimodal", v)} label="多模态" />
-
+ set("multimodal", v)} />
+ set("reasoning", v)}
+ />
+ set("personaRoutes", next)}
+ />
>
);
}
@@ -99,6 +144,16 @@ interface LlmProviderFormProps {
onError: (msg: string | null) => void;
onSaved: () => void;
onCancel: () => void;
+ /** 目录候选带进来的能力建议(可推翻);null = 没有建议。 */
+ hint: CatalogHint | null;
+ /** reasoning.kind 的可选取值,由后端下发(缺省 = 后端未报告)。 */
+ reasoningKinds: string[];
+ /** personaProviders 的可选取值,由后端下发(缺省 = 后端未报告)。 */
+ personaProviderOptions: string[];
+ /** 已知 persona id,仅作为候选提示(可手填)。 */
+ personaIds: string[];
+ /** 角色清单取不到时的原因;null = 清单可用。 */
+ personaListError: string | null;
}
/** Owns the field state and the save round-trip; the module supplies callbacks. */
@@ -110,25 +165,53 @@ export function LlmProviderForm({
onError,
onSaved,
onCancel,
+ hint,
+ reasoningKinds,
+ personaProviderOptions,
+ personaIds,
+ personaListError,
}: LlmProviderFormProps): React.ReactNode {
const editing = initial !== null;
const [form, setForm] = useState(() => initState(initial));
const log = useAuditStore((s) => s.log);
const set: FieldSetter = (key, value) => setForm((prev) => ({ ...prev, [key]: value }));
- const save = () => {
+ const buildRequest = (): LlmProviderRequest | null => {
const budget = form.maxTokens.trim();
if (budget !== "" && !/^\d+$/.test(budget)) {
onError("maxTokens 必须为非负整数(留空 = 保持/默认)");
- return;
+ return null;
}
- setBusy(true);
- onError(null);
- const req: LlmProviderRequest = { provider: form.provider, baseUrl: form.baseUrl, model: form.model, multimodal: form.multimodal };
+ const problem = personaRouteProblem(form.personaRoutes);
+ if (problem) {
+ onError(problem);
+ return null;
+ }
+ const { personaModels, personaProviders } = routesToMaps(form.personaRoutes);
+ const req: LlmProviderRequest = {
+ provider: form.provider,
+ baseUrl: form.baseUrl,
+ model: form.model,
+ multimodal: form.multimodal,
+ // reasoning 整块给出:kind 空串 = 显式回到「未声明」,否则面板无法把
+ // 已存形状改回去(缺省字段意味着「保留原值」)。
+ reasoning: { kind: form.reasoning },
+ // 两张路由表整表提交:面板删掉一行后要真的能从存储里删掉。
+ personaModels,
+ personaProviders,
+ };
if (editing && initial) req.id = initial.id;
else if (form.name.trim()) req.id = form.name.trim();
if (form.apiKey.trim()) req.apiKey = form.apiKey.trim();
if (budget !== "") req.maxTokens = Number(budget);
+ return req;
+ };
+
+ const save = () => {
+ const req = buildRequest();
+ if (!req) return;
+ setBusy(true);
+ onError(null);
adminAddLlmProvider(req)
.then((r) => {
log("llm.addProvider", "ok");
@@ -155,13 +238,24 @@ export function LlmProviderForm({
) : null}
-