feat(models): 平台自建「模型设置」—— 用户自配厂家 / 条目各自开关 / 共享模型开关(档案 87)

- 官方「设置 → 模型」页在平台环境**必然报错**(判据在**浏览器页面**的 loopback 判定;官方 README 原文 Non-loopback pages get no durable settings)⇒ 该分区对全角色(含 admin)隐藏,用户自配改走平台自建页(插件 0.3.11)
- DB 迁移 V6:credential_vault.route/base_url/api/models + users.shared_model_enabled;并**重定义 getEnabledCredentialKeyRef**(互斥删除后原实现无 ORDER BY ⇒ 「任取一条」)
- 新落地层 src/web/model-landing.ts:spawn 时把「已启用条目」写进实例 .credentials.yaml 与 settings.yaml 的 llm-pi-ai.providers.<route>;字段名与官方包实测对齐(apiKeyEnv / baseURL / api,**不是** protocol);只碰自己写过的 + 一次性交接
- 接口 /api/me/keys、/api/me/keys/:id/toggle、/api/me/models/shared;前端新增「设置 → 模型设置」分区(settings.section id=model-settings / order 100 / 全角色)
- 顺手修两处:ensure-role-profile-patch.cjs 的 --force 整文件覆盖会抹掉 admin 的 disable-hmr 与 workspace-scoped-picker 两个平台块(改为 stripManagedBlock 只替换自己那段);verify-mem-model.mjs 因档案 86 重构而长期失败的陈旧断言

⚠️ 本提交同时包含**档案 86(admin 跨用户实例管理 + 两处改名)**的代码改动 —— 该部分已上线并端到端验证;其 import/register 与本次改动同处 src/web/server.ts、poc/business-plugins/lib/client.js 等文件,按**文件粒度无法拆分**,且不带它会让仓库 tsc 直接失败(缺 src/web/routes/admin-user-ops.ts)。
This commit is contained in:
admin committed 2026-09-14 00:00:15 +08:00
1 parent eb50ca2d5b
commit 918f1d3a51
20 files changed
+2073 -276

No files matched your search

+57 -12
View File
@@ -54,8 +54,17 @@ const DATA = {
"/api/admin/storage": { generatedAt: 1, users: [] },
"/api/plugins/business": { plugins: [{ id: "p1", name: "x", version: "1.0.0" }] },
"/api/admin/runtime": { items: [{ name: "node", group: "g", kind: "k", version: "22", source: "s", script: "sc", removable: false }], note: "n", drift: [], runtimeDir: { path: "/p", bytes: 1, installedAt: 1 }, manifest: "m", baselineScript: "b" },
// 档案 85 ·「我的密钥」:两层密钥(我自己的 + 平台共享)
"/api/me/keys": { keys: [{ id: "k1", name: "my-key", enabled: true, updatedAt: 1 }], effective: "own", shared: { available: true, name: "shared-key", owner: "admin" } },
// 档案 87 ·「模型设置」:内置 + 自定义厂家条目、共享开关、协议枚举
"/api/me/keys": {
keys: [
{ id: "k1", name: "内置 DeepSeek", enabled: true, updatedAt: 1, route: null, baseUrl: null, api: null, models: null },
{ id: "k2", name: "我的中转网关", enabled: false, updatedAt: 2, route: "my-gw", baseUrl: "https://api.example.com/v1", api: "openai-completions", models: '["gpt-4o","gpt-4o-mini"]' },
],
effective: "own",
shared: { available: true, name: "shared-key", owner: "admin", ownerIsMe: false, enabled: true, count: 1 },
sharedModelEnabled: true,
protocols: ["openai-completions", "openai-responses", "anthropic-messages"],
},
};
const sandbox = {
console, setTimeout, clearTimeout,
@@ -100,6 +109,9 @@ function text(n, out = [], depth = 0) {
// 第 3 轮:功能页正文走 `dangerouslySetInnerHTML`(门户原文),必须单独收集
const dsi = n.props.dangerouslySetInnerHTML;
if (dsi && dsi.__html) out.push(dsi.__html);
// 表单控件的占位符也是**用户可见文案**(输入框没有 children)⇒ 一并收集,
// 否则「新增表单有哪些栏位」这类断言只能查到可见标签,漏掉真正的引导文案。
if (typeof n.props.placeholder === "string") out.push(n.props.placeholder);
text(n.props.children, out, depth + 1);
}
}
@@ -161,12 +173,13 @@ function clsAll(tree) {
}
// ── 注册与门禁 ────────────────────────────────────────────────────────────────
ok("admin 视角下注册了两个 settings.section", regs.length === 2, "-> " + regs.map(r => r.meta.id).join(", "));
// 档案 87 起 = 3 个:模型设置(全角色)+ 功能管理(全角色)+ 系统管理(仅 admin)
ok("admin 视角下注册了三个 settings.section", regs.length === 3, "-> " + regs.map(r => r.meta.id).join(", "));
const pa = regs.find(r => r.meta.id === "platform-admin");
ok("存在 platform-admin 分区", !!pa);
if (pa) ok("其 order = 102", pa.meta.order === 102);
const PORTAL_ITEMS = ["服务管理", "密钥管理", "用户管理", "技能管理", "插件管理", "运行环境"];
const PORTAL_ITEMS = ["实例管理", "模型管理", "用户管理", "技能管理", "插件管理", "运行环境"];
ROLE = "admin";
const tAdmin = text(await render(pa.Comp)).join(" | ");
ok("admin:含分区标题「系统管理」", tAdmin.includes("系统管理"));
@@ -181,16 +194,45 @@ const tUser = text(await render(pa.Comp)).join(" | ");
ok("非 admin:被门禁挡住", tUser.includes("仅对管理员显示"));
ok("非 admin:不出现任何功能项", !PORTAL_ITEMS.some(k => tUser.includes(k)));
// ── 非 admin:不注册「系统管理」(模型配置走官方「设置 → 模型」页,平台不再自带入口)──
// ── 档案 87:角色与分区注册 ────────────────────────────────────────────────────
// 「模型设置」**全角色**都要有(官方「设置 → 模型」页在平台环境必然报错 ⇒ 平台自建入口
// 是用户自配模型的**唯一**入口,不能只给 admin);「系统管理」仍仅 admin。
{
regs = []; ROLE = "active";
mod.apply(ctx);
await new Promise((r) => setTimeout(r, 80));
const ids = regs.map(r => r.meta.id).sort();
ok("非 admin 视角下只注册 1 个分区(功能管理;不含系统管理)",
ids.length === 1 && ids[0] === "business-plugins",
ok("非 admin:注册 2 个分区(功能管理 + 模型设置;不含系统管理)",
ids.length === 2 && ids[0] === "business-plugins" && ids[1] === "model-settings",
"-> " + ids.join(", "));
regs = []; ROLE = "admin";
mod.apply(ctx);
await new Promise((r) => setTimeout(r, 80));
}
// ── 档案 87「模型设置」分区:三条口径必须体现在界面上 ─────────────────────────
{
const ms = regs.find(r => r.meta.id === "model-settings");
ok("存在 model-settings 分区", !!ms);
if (ms) {
ok("其 order = 100(排在功能管理之前)", ms.meta.order === 100, "-> " + ms.meta.order);
const tMs = text(await render(ms.Comp)).join(" | ");
ok("模型设置:标题与副标题", tMs.includes("模型设置") && tMs.includes("模型选择器"));
// 口径②:共享模型也列入且可开关
ok("模型设置:平台共享模型区块 + 开关按钮", tMs.includes("平台共享模型") && tMs.includes("停用共享模型"));
// 口径①:条目各自开关 ⇒ 要有「启用/停用」而不是「设为当前」
ok("模型设置:条目按各自状态渲染徽章(已启用 / 已停用)", tMs.includes("已启用") && tMs.includes("已停用"));
ok("模型设置:内置与自定义两类条目都在列表里", tMs.includes("内置 DeepSeek") && tMs.includes("我的中转网关"));
ok("模型设置:自定义条目显示厂家标识 / endpoint / 协议",
tMs.includes("my-gw") && tMs.includes("https://api.example.com/v1") && tMs.includes("openai-completions"));
ok("模型设置:新增表单(名称 / API Key / Endpoint / 模型清单栏位齐备)",
tMs.includes("新增模型条目") && tMs.includes("模型 id,一行一个") && tMs.includes("留空 = 内置 DeepSeek"));
// 落地在 spawn 时 ⇒ 文案必须说清"重启才生效",否则用户会以为保存即生效
ok("模型设置:文案说明「重启实例生效」", tMs.includes("重启实例生效"));
const cMs = classes(await render(ms.Comp));
ok("模型设置:复用门户组件类(.pa-box/.pa-tbl/.pa-sm/.pa-badge/.pa-input)",
["pa-box", "pa-tbl", "pa-sm", "pa-badge", "pa-input"].every(c => cMs.includes(c)));
}
}
// ── 首页视觉:门户 `.nav-grid` / `.nav-card` 家族 ─────────────────────────────
@@ -204,8 +246,8 @@ ok("非 admin:不出现任何功能项", !PORTAL_ITEMS.some(k => tUser.include
// ── 6 个功能页逐个点开:**弹窗外壳 + 门户该页的表格/操作** ──────────────────
const EXPECT = {
files: ["服务管理", "启动 DSH", "新建文件夹", "上传文件", "修改时间", "根"],
keys: ["密钥管理", "全局 API 密钥", "sk-..."],
files: ["实例管理", "启动 DSH", "新建文件夹", "上传文件", "修改时间", "根"],
keys: ["模型管理", "平台共享密钥", "sk-..."],
users: ["用户管理", "注册时间", "操作"],
skills: ["技能管理", "共享技能", "更新时间", "上传 / 替换"],
plugins: ["插件管理", "官方推荐插件", "手动添加 / 管理", "导入到平台", "下载量", "投放时间"],
@@ -242,10 +284,13 @@ ok("非 admin:不出现任何功能项", !PORTAL_ITEMS.some(k => tUser.include
ok("用户页:角色徽章四色文案齐备(词典)",
["pa.role.admin", "pa.role.active", "pa.role.pending", "pa.role.disabled"].every(k => ZH[k]));
// 服务页:门户 .dsh-bar / .pathbar / .table-wrap
// 实例管理页:门户 .dsh-bar / .pathbar / .table-wrap + **目标用户切换器**(档案 87)
const clsF = clsAll(await renderWith(pa.Comp, () => { slots[1] = "files"; }));
ok("服务页:门户 .dsh-bar / .pathbar 取值", clsF.includes("pa-dshbar") && clsF.includes("pa-pathbar"));
ok("服务页:门户 .table-wrap/table.tbl 取值", clsF.includes("pa-wrap") && clsF.includes("pa-tbl"));
ok("实例管理页:门户 .dsh-bar / .pathbar 取值", clsF.includes("pa-dshbar") && clsF.includes("pa-pathbar"));
ok("实例管理页:门户 .table-wrap/table.tbl 取值", clsF.includes("pa-wrap") && clsF.includes("pa-tbl"));
const tF = text(await renderWith(pa.Comp, () => { slots[1] = "files"; })).join(" | ");
ok("实例管理页:含「用户」切换器(admin 可管任意用户)",
tF.includes('id="svcUser"') && tF.includes("用户:"), "-> 命中 svcUser=" + tF.includes('id="svcUser"'));
slots[1] = null; cursor = 0; dirty = false;
}