feat(admin): 面板「重置为 env 默认」动作(materialize 的反方向)+ 契约 v0.62
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 语义:只把 .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 生效。
This commit is contained in:
AskaEth committed 2026-10-04 21:33:11 +08:00
1 parent 367abf2c8e
commit c1fa1e9b9a
19 files changed
+1434 -11

No files matched your search

+5
View File
@@ -8,6 +8,7 @@ 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 预算 + 上下文窗口 + 推理形状 + 路由 + 测试/编辑动作。 */
@@ -236,6 +237,10 @@ export function LlmModule(): React.ReactNode {
/>
</Surface>
{/* 「重置为 env 默认」(v0.62):面板把键改坏时的一键回基线入口。成功后重新
拉取配置,让上面的列表立刻反映退回后的真值。 */}
<LlmResetFromEnv onReset={reload} />
<CatalogPanel
onPick={(draft, picked) => {
log("llm.catalog.fill", "ok");
@@ -0,0 +1,103 @@
import { useState } from "react";
import { adminResetLlmFromEnv } from "../../../lib/api";
import type { AdminLlmResetResult } from "../../../lib/types";
import { useAuditStore } from "../../audit";
import { Notice } from "../../components";
import { Button, Surface, Tag } from "../../../components/primitives";
/** 「重置为 env 默认」把面板值退回 `.env` 声明的默认层(v0.62)。
*
* 它是**破坏性**动作(覆盖面板对这些键的修改),因此按钮本身不直接发请求:先展开
* 一段中文二次确认文案,用户点「确认重置」才带上 `confirm:true` 提交。响应只回
* **键名**(绝不回显凭据值),因此这里只渲染组名 + 键名 + 后端给的跳过原因。 */
/** 把一次重置的响应渲染成可复核的中文摘要(只有键名与原因,不含任何值)。 */
function describeResult(res: AdminLlmResetResult): string {
if (res.reset.length === 0) {
const why = res.skipped.map((s) => `${s.group}(${s.reason})`).join(";");
return why
? `没有任何键被改回:${why}。面板值保持原样。`
: "没有任何键被改回:面板配置里没有 `.env` 声明过的键,面板值保持原样。";
}
const head = `已把 ${res.reset.length} 个分组的键退回 .env 默认值:`;
const body = res.reset.map((g) => `${g.group}(${g.keys.join("、")})`).join(";");
const skipped = res.skipped.length
? `;另 ${res.skipped.length} 个分组未改动:${res.skipped.map((s) => `${s.group}(${s.reason})`).join(";")}`
: "";
return `${head}${body}${skipped}。凭据值不会回显。`;
}
/** 一次重置往返:二次确认、busy、结果与失败四态都由本组件持有;成功时收起确认并让
* 父组件重新拉取配置(失败保持展开,便于用户修正后一键重试)。 */
function useResetAction(onReset: () => void) {
const log = useAuditStore((s) => s.log);
const [confirming, setConfirming] = useState(false);
const [busy, setBusy] = useState(false);
const [result, setResult] = useState<string | null>(null);
const [fail, setFail] = useState<string | null>(null);
const run = () => {
setBusy(true);
setFail(null);
setResult(null);
adminResetLlmFromEnv()
.then((res) => {
log("llm.resetFromEnv", "ok");
setResult(`${describeResult(res)}(${res.path})`);
setConfirming(false);
onReset();
})
.catch((e: unknown) => {
log("llm.resetFromEnv", "error");
setFail(e instanceof Error ? e.message : "重置为 env 默认失败");
})
.finally(() => setBusy(false));
};
return { confirming, setConfirming, busy, result, fail, run };
}
export function LlmResetFromEnv({ onReset }: { onReset: () => void }): React.ReactNode {
const { confirming, setConfirming, busy, result, fail, run } = useResetAction(onReset);
return (
<Surface className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold">重置为 env 默认</h3>
<Tag tone="err">危险</Tag>
<Tag tone="warn">owner 专属</Tag>
<Tag tone="neutral">写操作 · 已审计</Tag>
</div>
<p className="text-xs text-[var(--mu-text-lo)]">
把面板里 <b>.env(默认层)声明过</b>的键覆盖回 env 值 —— 例如面板把 baseUrl / apiKey
改坏了,一键回到部署基线。只动 env 声明过的键:<b>env 未声明的键原样保留</b>,
组集合<b>不增减</b>(不复活被面板删掉的组、不删面板新增的组)。真的改了配置就热更新运行中的
LLM 供应商栈(无需重启),并且只回<b>键名</b>、绝不回显凭据值。
</p>
{!confirming ? (
<Button size="sm" variant="danger" disabled={busy} onClick={() => setConfirming(true)}>
重置为 env 默认
</Button>
) : (
<div className="space-y-2 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] p-3">
<p className="text-xs text-[var(--mu-text-mid)]">
这会用 <b>.env</b> 声明的值<b>覆盖面板里这些键的修改</b>(面板值一旦被覆盖就找不回来,
需要重新填写或保存)。组集合与 env 未声明的键都不受影响。确定继续?
</p>
<div className="flex items-center gap-2">
<Button size="sm" variant="danger" disabled={busy} onClick={run}>
{busy ? "重置中…" : "确认重置"}
</Button>
<Button size="sm" variant="ghost" disabled={busy} onClick={() => setConfirming(false)}>
取消
</Button>
</div>
</div>
)}
{fail ? <Notice tone="err">{fail}</Notice> : null}
{result ? <Notice tone="ok">{result}</Notice> : null}
</Surface>
);
}