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 生效。
This commit is contained in:
1 parent
367abf2c8e
commit
c1fa1e9b9a
19 files changed
+1434
-11
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -79,6 +79,7 @@ export {
|
||||
adminPluginInstall,
|
||||
adminPluginUninstall,
|
||||
adminRevokeApiKey,
|
||||
adminResetLlmFromEnv,
|
||||
adminRunAutomation,
|
||||
adminSaveOwnerProfile,
|
||||
adminSearchRag,
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
AdminLlmCatalog,
|
||||
AdminLlmConfig,
|
||||
AdminLlmProvider,
|
||||
AdminLlmResetResult,
|
||||
AdminPluginRecord,
|
||||
AdminRagHit,
|
||||
AdminRagResponse,
|
||||
@@ -236,6 +237,15 @@ export function adminAddLlmProvider(req: LlmProviderRequest): Promise<{
|
||||
export function adminTestLlm(req: LlmTestRequest): Promise<LlmTestResult> {
|
||||
return request<LlmTestResult>("/admin/llm/test", { method: "POST", body: JSON.stringify(req) });
|
||||
}
|
||||
/** POST /api/v1/admin/llm/reset-from-env — 把面板里 `.env`(默认层)**声明过**的键
|
||||
* 覆盖回 env 值(v0.62):组集合不增减、env 未声明的键原样保留。破坏性,因此请求体
|
||||
* 必须带 `confirm:true`(客户端只在用户二次确认后才发)。响应只回键名,绝无凭据值。 */
|
||||
export function adminResetLlmFromEnv(): Promise<AdminLlmResetResult> {
|
||||
return request<AdminLlmResetResult>("/admin/llm/reset-from-env", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ confirm: true }),
|
||||
});
|
||||
}
|
||||
/** POST /api/v1/admin/plugins/install — installs (or refreshes) an MCP server
|
||||
* plugin (v0.28): connects, discovers its tools and hot-registers them. The
|
||||
* backend answers 502 and persists nothing when connect/discover fails. */
|
||||
|
||||
@@ -72,6 +72,26 @@ export interface LlmTestResult {
|
||||
tokens: number;
|
||||
}
|
||||
|
||||
/** 一次「重置为 env 默认」里**真的被改回**的分组:**只有键名,绝无凭据值**。 */
|
||||
export interface AdminLlmResetGroup {
|
||||
group: string;
|
||||
keys: string[];
|
||||
}
|
||||
|
||||
/** 一个未被改动的分组与原因(中文文案由后端给出,客户端原样渲染)。 */
|
||||
export interface AdminLlmResetSkip {
|
||||
group: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/** POST /api/v1/admin/llm/reset-from-env result(v0.62)。`reset` / `skipped` 恒为
|
||||
* 数组(没有对应记录时是 `[]`,不是 `null`)。 */
|
||||
export interface AdminLlmResetResult {
|
||||
reset: AdminLlmResetGroup[];
|
||||
skipped: AdminLlmResetSkip[];
|
||||
path: string;
|
||||
}
|
||||
|
||||
/** MCP server connection block for POST /api/v1/admin/plugins/install (v0.28).
|
||||
* `endpoint` is for http/sse, `command` (+ `args`) for stdio. apiKey is sent
|
||||
* once and persisted but never echoed back; env is the stdio child environment. */
|
||||
|
||||
Reference in new issue
Block a user