- 语义:只把 .env **确实声明过**的键覆盖回 env 值;env 未声明的键(fallback/multimodal/maxTokens/reasoning/personaModels)原样保留;**组集合不增减**(不复活被面板删的组、不删面板新增的组);无键可改时不写盘。成功即热重载 LLM 栈。返回 {reset:[{group,keys}], skipped:[{group,reason}], path},**只回键名、不回显凭据值**。
- 护栏:必须 confirm:true(缺失/false/非法 body ⇒ 400,且**先于任何读写**);**owner-only**(与同页 POST /admin/llm、/admin/llm/test 同门 —— 这是破坏性写,门不比同族更宽);审计动作 llm.resetFromEnv(camelCase,全仓多词动作的既有约定)。失败路径明确报错(env_defaults_unreadable / models_unreadable / llm_unavailable),不假成功。
- 组合根注入 Deps.EnvPath(网关不 fopen .env):请求时按既有解析入口重读,语义 =「退回 .env 现在声明的默认值」;.env 层里的 provider 是同义反复,不计入「env 为该组声明了什么」的判定(有专门用例)。
- 前端:LLM 页「重置为 env 默认」卡片(中文二次确认 → 提交 → 按 reset/skipped 渲染 → 成功后重拉配置),标签 owner 专属(与 System.tsx 同款)。
- 契约 v0.62:api-client(权限矩阵改回 12+10+38=60、端点表 9b、重置契约小节、写操作契约、changelog、banner)与 api-reference(端点表 #70、§5.13a 详情含 curl、§8.2/§8.3/§8.4)同步;字段名三方逐字对齐。
- 主会话独立验收:隔离 worktree(R1 的 19 文件)build/vet 绿、gateway+config 三包绿、R1 用例 10 PASS、reset_from_env 残留 0;**变异亲自复现**(去掉 confirm 校验 ⇒ RequiresConfirm 红:status = 200, want 400,且响应体显示面板配置真的被改)。
- 子代理自报并已处理:测试文件触 500 行上限 ⇒ 拆成语义侧 + 护栏侧两份;门改回 owner-only 后前端标签会误导 admin ⇒ 改「owner 专属」并重建 bundle。任务书本身仍是旧口径(owner|admin / snake_case)—— 已由主会话登记修正说明,避免压缩后有人按任务书回退。
- 未做(诚实边界):不支持按组重置;不纳入 channels.json/plugins.json;不自动重启(contextWindow 的预算推导仍只在启动时算一次)。新端点需下一次 build+restart 才在 :10942 生效。
275 lines
12 KiB
TypeScript
275 lines
12 KiB
TypeScript
import { useState } from "react";
|
||
import { useAsyncData } from "../../lib/useAsyncData";
|
||
import { adminGetLlm, adminListPersonas, adminTestLlm, isNotImplemented } from "../../lib/api";
|
||
import type { AdminLlmProvider } from "../../lib/types";
|
||
import { useAuditStore } from "../audit";
|
||
import { Notice, PageHeader } from "../components";
|
||
import { Button, EmptyState, Surface, Tag } from "../../components/primitives";
|
||
import { Icon } from "../../components/Icon";
|
||
import { CatalogPanel } from "./llm/LlmCatalogPanel";
|
||
import { LlmProviderForm } from "./llm/LlmProviderForm";
|
||
import { LlmResetFromEnv } from "./llm/LlmResetFromEnv";
|
||
import type { CatalogHint } from "./llm/LlmProviderRouting";
|
||
|
||
/** 一个供应商分组行:身份 + token 预算 + 上下文窗口 + 推理形状 + 路由 + 测试/编辑动作。 */
|
||
function ProviderRow({
|
||
p,
|
||
busy,
|
||
onTest,
|
||
onEdit,
|
||
}: {
|
||
p: AdminLlmProvider;
|
||
busy: boolean;
|
||
onTest: (p: AdminLlmProvider) => void;
|
||
onEdit: (p: AdminLlmProvider) => void;
|
||
}): React.ReactNode {
|
||
const routes = Object.keys(p.personaModels ?? {}).length;
|
||
return (
|
||
<div className="flex flex-wrap items-center gap-3 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2.5">
|
||
<span className="font-medium">{p.name}</span>
|
||
<Tag tone="neutral">{p.model}</Tag>
|
||
<span className="font-mono text-xs text-[var(--mu-text-lo)]">{p.baseUrl || "—"}</span>
|
||
<Tag tone={p.maxTokens ? "accent" : "neutral"}>
|
||
{p.maxTokens ? `maxTokens ${p.maxTokens}` : "maxTokens 默认"}
|
||
</Tag>
|
||
<Tag tone={p.contextWindow ? "accent" : "neutral"}>
|
||
{p.contextWindow ? `窗口 ${p.contextWindow}` : "窗口未声明"}
|
||
</Tag>
|
||
<Tag tone={p.reasoning?.kind ? "accent" : "neutral"}>
|
||
{`推理 ${p.reasoning?.kind || "未声明"}`}
|
||
</Tag>
|
||
{routes > 0 ? <Tag tone="accent">{`按角色路由 ${routes}`}</Tag> : null}
|
||
<span className="ml-auto flex items-center gap-4">
|
||
<Tag tone={p.enabled ? "ok" : "neutral"}>{p.enabled ? "启用" : "停用"}</Tag>
|
||
<Tag tone={p.multimodal ? "accent" : "neutral"}>{p.multimodal ? "主模型可收图" : "主模型不收图"}</Tag>
|
||
<Button size="sm" variant="ghost" disabled={busy} onClick={() => onEdit(p)}>
|
||
编辑
|
||
</Button>
|
||
<Button size="sm" variant="ghost" disabled={busy || !p.baseUrl} onClick={() => onTest(p)}>
|
||
测试
|
||
</Button>
|
||
</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
interface ProviderListProps {
|
||
providers: AdminLlmProvider[];
|
||
loading: boolean;
|
||
error: string | null;
|
||
/** 后端明确表示未提供该端点(404/501)—— 与「请求失败」「真为空」是三种状态。 */
|
||
unimplemented: boolean;
|
||
busy: boolean;
|
||
onTest: (p: AdminLlmProvider) => void;
|
||
onEdit: (p: AdminLlmProvider) => void;
|
||
}
|
||
|
||
/** 供应商列表:把「加载中 / 端点未实现 / 请求失败 / 真为空 / 有数据」分开渲染。
|
||
* 每种状态都只反映接口的真实结果,绝不回退到示例供应商。 */
|
||
function ProviderList({
|
||
providers,
|
||
loading,
|
||
error,
|
||
unimplemented,
|
||
busy,
|
||
onTest,
|
||
onEdit,
|
||
}: ProviderListProps): React.ReactNode {
|
||
if (loading) {
|
||
return <p className="py-8 text-center text-xs text-[var(--mu-text-lo)]">加载中…</p>;
|
||
}
|
||
if (providers.length === 0) {
|
||
if (unimplemented) {
|
||
return (
|
||
<Notice tone="warn">
|
||
后端未提供 GET /api/v1/admin/llm(404/501):配置读不到。本页只渲染接口返回的真实数据,不显示任何示例供应商。
|
||
</Notice>
|
||
);
|
||
}
|
||
if (error) {
|
||
return (
|
||
<Notice tone="err">
|
||
加载 LLM 配置失败(后端不可达或接口报错):{error} —— 这是错误态,不是空配置。
|
||
</Notice>
|
||
);
|
||
}
|
||
return (
|
||
<EmptyState
|
||
icon={<Icon name="robot" size={32} />}
|
||
title="还没有配置 LLM 供应商"
|
||
hint="用下方目录候选或表单新增第一个供应商 / 模型分组。"
|
||
/>
|
||
);
|
||
}
|
||
return (
|
||
<div className="space-y-3">
|
||
{error ? <Notice tone="err">刷新失败,下方仍是上次成功读取的配置:{error}</Notice> : null}
|
||
{providers.map((r) => (
|
||
<ProviderRow key={r.id} p={r} busy={busy} onTest={onTest} onEdit={onEdit} />
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** 一次真实模型探测(POST /admin/llm/test):结果与错误都写进页面顶部的提示区,
|
||
* 成功/失败各记一条前端动作日志。它是真实往返,绝不假装成功。 */
|
||
function runLlmTest(
|
||
p: AdminLlmProvider,
|
||
log: (what: string, result: "ok" | "error") => void,
|
||
setBusy: (v: boolean) => void,
|
||
setFail: (v: string | null) => void,
|
||
setResult: (v: string | null) => void
|
||
): void {
|
||
setBusy(true);
|
||
setFail(null);
|
||
setResult(null);
|
||
adminTestLlm({ provider: p.provider || "openai", baseUrl: p.baseUrl, model: p.model })
|
||
.then((r) => {
|
||
log("llm.test", "ok");
|
||
setResult(`【${p.name}】模型 ${r.model} 响应:${r.reply}(${r.tokens} tokens)`);
|
||
})
|
||
.catch((e: unknown) => {
|
||
log("llm.test", "error");
|
||
setFail(e instanceof Error ? e.message : "模型测试失败");
|
||
})
|
||
.finally(() => setBusy(false));
|
||
}
|
||
|
||
/** 页面标题 + 测试结果 / 失败提示。 */
|
||
function LlmPageStatus({ fail, result }: { fail: string | null; result: string | null }): React.ReactNode {
|
||
return (
|
||
<>
|
||
<PageHeader
|
||
title="LLM 模型"
|
||
subtitle="供应商 + 模型配置(base/key/主模型收图/token 预算/推理开关形状/按角色路由) + 测试(真实调用) + fallback"
|
||
/>
|
||
{fail ? <Notice tone="err">{fail}</Notice> : null}
|
||
{result ? <Notice tone="ok">{result}</Notice> : null}
|
||
</>
|
||
);
|
||
}
|
||
|
||
/** 一个分组已配置的 per-persona 路由(接口返回的配置真值)。两张表取并集,
|
||
* 使「只声明了协议没声明模型」的死配置也照实显示,而不是看不见。 */
|
||
function PersonaRouteList({ p }: { p: AdminLlmProvider }): React.ReactNode {
|
||
const providers = p.personaProviders ?? {};
|
||
const models = p.personaModels ?? {};
|
||
const personas = [...new Set([...Object.keys(models), ...Object.keys(providers)])].sort();
|
||
if (personas.length === 0) return null;
|
||
return (
|
||
<p className="pl-1 text-xs text-[var(--mu-text-lo)]">
|
||
按角色路由:
|
||
{personas
|
||
.map((id) => `${id} → ${models[id] || "(未配模型,不生效)"}${providers[id] ? `(协议 ${providers[id]})` : ""}`)
|
||
.join(",")}
|
||
</p>
|
||
);
|
||
}
|
||
|
||
/** 生效链:只画接口真的返回过的分组、它们的备选模型与已配置的 per-persona 路由;
|
||
* 本地第三级由后端开关决定、本端点不报告其运行态,因此不臆造。 */
|
||
function FallbackChain({ providers }: { providers: AdminLlmProvider[] }): React.ReactNode {
|
||
return (
|
||
<Surface className="space-y-3">
|
||
<h3 className="text-sm font-semibold">生效链与按角色路由(来自接口返回的真实分组)</h3>
|
||
{providers.length === 0 ? (
|
||
<p className="text-xs text-[var(--mu-text-lo)]">暂无分组:上面保存一个之后,这里会显示它的主/备链。</p>
|
||
) : (
|
||
providers.map((p) => (
|
||
<div key={p.id} className="space-y-1">
|
||
<div className="flex flex-wrap items-center gap-3 text-sm">
|
||
<Tag tone={p.enabled ? "ok" : "neutral"}>{p.enabled ? "生效组" : "备用组"}</Tag>
|
||
<span className="font-mono text-xs text-[var(--mu-text-lo)]">{p.model}</span>
|
||
<span className="text-[var(--mu-text-mid)]">→ {p.fallback || "无备选模型"}</span>
|
||
<span className="ml-auto text-xs text-[var(--mu-text-lo)]">接口报错自动降级</span>
|
||
</div>
|
||
<PersonaRouteList p={p} />
|
||
</div>
|
||
))
|
||
)}
|
||
<Notice>
|
||
本地模型第三级由后端开关与本地组配置决定,本端点不报告它的运行态,因此这里不显示,也不臆造。
|
||
</Notice>
|
||
</Surface>
|
||
);
|
||
}
|
||
|
||
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);
|
||
// 只渲染接口真的返回过的供应商;没有就是空列表,绝不拿示例配置顶替。
|
||
const providers = data?.providers ?? [];
|
||
|
||
const [editing, setEditing] = useState<AdminLlmProvider | null>(null);
|
||
// 同一分组 id 连续填入不同候选时也必须重挂表单(表单按 key 取初值),
|
||
// 否则第二次填入不会生效。
|
||
const [draftSeq, setDraftSeq] = useState(0);
|
||
const [busy, setBusy] = useState(false);
|
||
const [fail, setFail] = useState<string | null>(null);
|
||
const [result, setResult] = useState<string | null>(null);
|
||
// 目录候选带进来的能力建议(可推翻);没有建议时是 null。
|
||
const [hint, setHint] = useState<CatalogHint | null>(null);
|
||
|
||
const edit = (p: AdminLlmProvider | null, next: CatalogHint | null) => {
|
||
setEditing(p);
|
||
setHint(next);
|
||
setDraftSeq((n) => n + 1);
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-5">
|
||
<LlmPageStatus fail={fail} result={result} />
|
||
|
||
<Surface className="space-y-3">
|
||
<h3 className="text-sm font-semibold">供应商 / 模型</h3>
|
||
<ProviderList
|
||
providers={providers}
|
||
loading={loading}
|
||
error={error}
|
||
unimplemented={unimplemented}
|
||
busy={busy}
|
||
onTest={(p) => runLlmTest(p, log, setBusy, setFail, setResult)}
|
||
onEdit={(p) => edit(p, null)}
|
||
/>
|
||
</Surface>
|
||
|
||
{/* 「重置为 env 默认」(v0.62):面板把键改坏时的一键回基线入口。成功后重新
|
||
拉取配置,让上面的列表立刻反映退回后的真值。 */}
|
||
<LlmResetFromEnv onReset={reload} />
|
||
|
||
<CatalogPanel
|
||
onPick={(draft, picked) => {
|
||
log("llm.catalog.fill", "ok");
|
||
edit(draft, picked);
|
||
}}
|
||
/>
|
||
|
||
<LlmProviderForm
|
||
key={editing ? `${editing.id}#${draftSeq}` : `new#${draftSeq}`}
|
||
initial={editing}
|
||
busy={busy}
|
||
setBusy={setBusy}
|
||
onResult={setResult}
|
||
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}
|
||
/>
|
||
|
||
<FallbackChain providers={providers} />
|
||
|
||
<Notice>
|
||
每组的 reasoning.kind(推理开关形状)按端点选:形状属于端点而不属于模型,选错会 400,因此面板只给出「未声明 / auto」与后端下发的几种形状。按角色路由(persona → 模型 / 协议)与降级链由后端 loop 真接线;面板保存即写入 models.json 并热更新运行中的 LLM provider(无需重启,design/04 §3.1),.env 为兜底。maxTokens 留空 = 保持原值 / 未设时回落 LLM_FALLBACK_MAX_TOKENS。contextWindow(模型上下文窗口,token)留空 = 未声明:后端按 128 Ki token 保守估算并记一条 WARN,填该模型的真实窗口才能吃到按窗口推导的上下文预算(该推导只在启动时算一次 ⇒ 重启后生效)。multimodal 只表示主模型自己能否收图;图片/OCR 工具用的模型走 .env 的 LLM_VISION_MODEL / LLM_OCR_MODEL。目录候选来自 models.dev,由后端缓存成快照(离线仍可用),绝不进模型运行时热路径。
|
||
</Notice>
|
||
</div>
|
||
);
|
||
}
|