Files
Cyrene_v2/frontend/web/src/admin/modules/Llm.tsx
T
AskaEth c1fa1e9b9a
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(admin): 面板「重置为 env 默认」动作(materialize 的反方向)+ 契约 v0.62
- 语义:只把 .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 生效。
2026-10-04 21:33:11 +08:00

275 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}