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:
1 parent
eb50ca2d5b
commit
918f1d3a51
20 files changed
+2073
-276
No files matched your search
@@ -91,7 +91,10 @@ ok('编排器 heap 推导算法未变', /Math\.max\(128, Math\.min\(256, memMb -
|
||||
// ── ⑤ 断言:status 真的把真值透出来了(否则前端的「读真值」是空接线)──
|
||||
ok('Spawner 接口声明 quotaInfo?', /quotaInfo\?\(userId: string\): \{ memMb: number; heapMb: number \} \| null/.test(spawner))
|
||||
ok('编排器实现 quotaInfo', /quotaInfo\(userId: string\): \{ memMb: number; heapMb: number \} \| null \{/.test(orch))
|
||||
ok('/api/dsh/status 返回 quota', /quota: app\.supervisor\.quotaInfo\?\.\(request\.user!\.id\) \?\? null/.test(dshRoute))
|
||||
// 档案 87 顺手修:档案 86 把 status 主体抽成 `statusForUser(app, user)` 之后,这里从
|
||||
// `request.user!.id` 变成了 `user.id` ⇒ 老断言正则失配、`npm run verify` 一直红着。
|
||||
// 断言意图不变("status 真的把真值透出来了"),改成不绑定形参名。
|
||||
ok('/api/dsh/status 返回 quota', /quota:\s*app\.supervisor\.quotaInfo\?\.\([^)]*\)\s*\?\?\s*null/.test(dshRoute))
|
||||
ok('客户端会去读 /api/dsh/status', /\/api\/dsh\/status/.test(client) && /setQuotaInfo\(/.test(client))
|
||||
|
||||
console.log(failed === 0 ? '\n结论:全绿 ✅' : '\n结论:有 ' + failed + ' 项失败 ❌')
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* verify-model-landing.mjs —— 模型落地层回归(档案 87)
|
||||
*
|
||||
* 为什么需要:`src/web/model-landing.ts` 干的是**改写实例自己的配置文件**
|
||||
* (`$DSH_HOME/.credentials.yaml` 与 `settings.yaml`)——这类逻辑错了**不会报错**,
|
||||
* 只会让实例静默少一个厂家、或者把用户自己配的 key 覆盖掉。官方「设置 → 模型」页
|
||||
* 在平台环境必然报错(见 `ensure-role-profile-patch.cjs` 注释),所以平台自己写的这两处
|
||||
* 文件就是**唯一**的配置来源,没有第二双眼睛。
|
||||
*
|
||||
* 本脚本用固定样例把 12 条不变式钉死:**幂等**、**不碰用户自己的**、**删得掉自己写的**、
|
||||
* **字段名必须与官方一致**(`api` 不是 `protocol`;`apiKeyEnv` 不是 `apiKey`)。
|
||||
* 用法:node scripts/verify-model-landing.mjs 退出码 0=全绿 / 1=有失败
|
||||
*/
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
// ⚠️ 动态 import 必须走 file:// URL —— 本机是 Windows,裸 `D:\...` 会被 ESM loader 拒
|
||||
// (ERR_UNSUPPORTED_ESM_URL_SCHEME);pathToFileURL 在 Linux 上同样正确。
|
||||
const {
|
||||
BUILTIN_REF,
|
||||
PI_AI_NS,
|
||||
PROTOCOLS,
|
||||
normalizeProtocol,
|
||||
parseModels,
|
||||
readRefValue,
|
||||
reconcileCredentials,
|
||||
reconcileSettings,
|
||||
refForEntry,
|
||||
routeRef,
|
||||
} = await import(pathToFileURL(join(ROOT, 'lib/web/model-landing.js')).href)
|
||||
|
||||
let failed = 0
|
||||
const ok = (name, cond, extra = '') => {
|
||||
console.log((cond ? ' ✓ ' : ' ✗ ') + name + (extra ? ' ' + extra : ''))
|
||||
if (!cond) failed++
|
||||
}
|
||||
const REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/
|
||||
|
||||
console.log('verify-model-landing(档案 87 落地层)\n')
|
||||
|
||||
// ── 1. ref 命名 ───────────────────────────────────────────────────────────────
|
||||
console.log('[1] ref 命名(须匹配官方 REF_PATTERN,否则 dsh 静默不认)')
|
||||
ok('my-gateway → MY_GATEWAY_API_KEY', routeRef('my-gateway') === 'MY_GATEWAY_API_KEY', routeRef('my-gateway'))
|
||||
ok('数字开头补 X', routeRef('2fast') === 'X2FAST_API_KEY', routeRef('2fast'))
|
||||
ok('空串也合法', REF_PATTERN.test(routeRef('')))
|
||||
ok('中文名也合法(折成 X_API_KEY)', routeRef('硅基流动') === 'X_API_KEY', routeRef('硅基流动'))
|
||||
ok('内置条目 → DEEPSEEK_API_KEY', refForEntry({ route: 'deepseek', baseUrl: null }) === BUILTIN_REF)
|
||||
ok('自定义 → routeRef(route)', refForEntry({ route: 'my-gw', baseUrl: 'https://x/v1' }) === 'MY_GW_API_KEY')
|
||||
ok('官方分区名是 llm-pi-ai', PI_AI_NS === 'llm-pi-ai')
|
||||
ok('协议只有官方那三个', PROTOCOLS.length === 3 && PROTOCOLS[0] === 'openai-completions')
|
||||
ok('未知协议回落默认', normalizeProtocol('nope') === 'openai-completions')
|
||||
|
||||
// ── 2. 凭据文件:从零创建 ─────────────────────────────────────────────────────
|
||||
console.log('\n[2] .credentials.yaml 从零创建')
|
||||
let cred = reconcileCredentials('', [{ ref: BUILTIN_REF, value: 'sk-shared' }], [])
|
||||
ok('写出 version/refs/行', cred.text === "version: 1\nrefs:\n DEEPSEEK_API_KEY: 'sk-shared'\n", JSON.stringify(cred.text))
|
||||
ok('managed 记录该 ref', cred.managed.length === 1 && cred.managed[0] === BUILTIN_REF)
|
||||
|
||||
// ── 3. 凭据文件:追加第二条 + 保留用户自己的内容 ──────────────────────────────
|
||||
console.log('\n[3] 追加第二条(用户自己的 ref 必须原样保留)')
|
||||
const withUserOwn = "version: 1\nrefs:\n DEEPSEEK_API_KEY: 'sk-shared'\nrecords:\n saved-by-dsh: 'zzz'\n"
|
||||
cred = reconcileCredentials(
|
||||
withUserOwn,
|
||||
[
|
||||
{ ref: BUILTIN_REF, value: 'sk-shared' },
|
||||
{ ref: 'MY_GW_API_KEY', value: 'sk-gw' },
|
||||
],
|
||||
[BUILTIN_REF],
|
||||
)
|
||||
ok('新增了自定义 ref', cred.text.includes("MY_GW_API_KEY: 'sk-gw'"))
|
||||
ok('records: 段完整保留', cred.text.includes('records:') && cred.text.includes("saved-by-dsh: 'zzz'"))
|
||||
ok('已管线的 ref 值被刷新', cred.text.includes("DEEPSEEK_API_KEY: 'sk-shared'"))
|
||||
ok('缩进正确(两条都在 refs 下)', /refs:\n( {2}\S+.*\n)+/.test(cred.text), JSON.stringify(cred.text))
|
||||
|
||||
// ── 4. 凭据文件:用户自己的 ref 绝不覆盖 / 删除 ───────────────────────────────
|
||||
console.log('\n[4] 用户自己写的 ref:不覆盖、不删除')
|
||||
const userOwn = "version: 1\nrefs:\n USER_OWN_KEY: 'mine'\n"
|
||||
const untouched = reconcileCredentials(userOwn, [{ ref: 'USER_OWN_KEY', value: 'platform-would-write' }], [])
|
||||
ok('值未被改写', untouched.text.includes("USER_OWN_KEY: 'mine'"), JSON.stringify(untouched.text))
|
||||
ok('未把它记为平台托管', untouched.managed.length === 0)
|
||||
const notManagedDrop = reconcileCredentials(userOwn, [], ['SOME_OTHER_KEY'])
|
||||
ok('不在 managed 里的 ref 不会被删', notManagedDrop.text.includes('USER_OWN_KEY'), JSON.stringify(notManagedDrop.text))
|
||||
|
||||
// ── 5. 凭据文件:关掉条目 ⇒ 平台自己写的那行被删 ──────────────────────────────
|
||||
console.log('\n[5] 关掉条目 ⇒ 撤掉平台自己写的 ref(验收③依赖这条)')
|
||||
const twoRefs = "version: 1\nrefs:\n DEEPSEEK_API_KEY: 'sk-shared'\n MY_GW_API_KEY: 'sk-gw'\n"
|
||||
const disabled = reconcileCredentials(twoRefs, [], [BUILTIN_REF, 'MY_GW_API_KEY'])
|
||||
ok('DEEPSEEK_API_KEY 已移除', !disabled.text.includes('DEEPSEEK_API_KEY'), JSON.stringify(disabled.text))
|
||||
ok('MY_GW_API_KEY 已移除', !disabled.text.includes('MY_GW_API_KEY'))
|
||||
ok('managed 清空', disabled.managed.length === 0)
|
||||
|
||||
// ── 6. 幂等(最容易被忽略的不变式)────────────────────────────────────────────
|
||||
console.log('\n[6] 幂等:同一输入跑两次结果必须相同')
|
||||
const once = reconcileCredentials(twoRefs, [{ ref: BUILTIN_REF, value: 'sk-shared' }], [BUILTIN_REF])
|
||||
const twice = reconcileCredentials(once.text, [{ ref: BUILTIN_REF, value: 'sk-shared' }], once.managed)
|
||||
ok('凭据:第二次无变化', twice.text === once.text, JSON.stringify(twice.text))
|
||||
|
||||
// ── 7. settings.yaml:从零创建厂商段 ─────────────────────────────────────────
|
||||
console.log('\n[7] settings.yaml 从零创建(字段名必须与官方一致)')
|
||||
const gw = { route: 'my-gw', apiKeyEnv: 'MY_GW_API_KEY', baseURL: 'https://api.example.com/v1', api: 'openai-completions', models: ['gpt-4o', 'gpt-4o-mini'] }
|
||||
let set = reconcileSettings('', [gw], [])
|
||||
ok('含 llm-pi-ai → providers 链', set.text.includes('llm-pi-ai:\n providers:'))
|
||||
ok('字段是 apiKeyEnv(不是 apiKey)', set.text.includes('apiKeyEnv: MY_GW_API_KEY') && !set.text.includes('apiKey:'))
|
||||
ok('字段是 api(不是 protocol)', set.text.includes('api: openai-completions') && !set.text.includes('protocol:'))
|
||||
ok('baseURL 存在', set.text.includes('baseURL: https://api.example.com/v1'))
|
||||
ok('models 是 id 列表', set.text.includes('- id: gpt-4o') && set.text.includes('- id: gpt-4o-mini'))
|
||||
ok('有 begin/end 标记(删的依据)', set.text.includes('# dshs:model-route my-gw begin') && set.text.includes('# dshs:model-route my-gw end'))
|
||||
ok('managed 记录 route', set.managed.includes('my-gw'))
|
||||
|
||||
// ── 8. settings.yaml:往既有文档追加,且不破坏别人的键 ────────────────────────
|
||||
console.log('\n[8] settings.yaml 追加:既有顶层键与注释必须保留')
|
||||
const existingSettings = "# 用户自己的备注\n" + "llm-pi-ai:\n providers:\n deepseek:\n apiKeyEnv: DEEPSEEK_API_KEY\nagent-default-model: deepseek-chat\n"
|
||||
set = reconcileSettings(existingSettings, [gw], [])
|
||||
ok('既有 deepseek 段保留', set.text.includes('deepseek:\n apiKeyEnv: DEEPSEEK_API_KEY'))
|
||||
ok('既有顶层键保留', set.text.includes('agent-default-model: deepseek-chat'))
|
||||
ok('既有注释保留', set.text.includes('# 用户自己的备注'))
|
||||
ok('新厂家已插入 providers 之下', set.text.indexOf('my-gw:') > set.text.indexOf('providers:'))
|
||||
ok('未重复写入 deepseek', (set.text.match(/^ {4}deepseek:$/gm) ?? []).length === 1)
|
||||
// 回归项:第一版实现把 `providers:` 当成顶层键去找 ⇒ 找不到 ⇒ 又补一行 ⇒ 文档里两个同键。
|
||||
ok('providers 只有一处(防重复键)', (set.text.match(/^[ \t]+providers:$/gm) ?? []).length === 1)
|
||||
|
||||
// ── 9. settings.yaml:关掉厂家 ⇒ 整块删除 ────────────────────────────────────
|
||||
console.log('\n[9] settings.yaml 关掉厂家 ⇒ 只删自己那块')
|
||||
const removed = reconcileSettings(set.text, [], ['my-gw'])
|
||||
ok('my-gw 块已删干净', !removed.text.includes('my-gw'), JSON.stringify(removed.text.slice(-160)))
|
||||
ok('deepseek 段还在', removed.text.includes('deepseek:'))
|
||||
ok('顶层键还在', removed.text.includes('agent-default-model'))
|
||||
const removedTwice = reconcileSettings(removed.text, [], removed.managed)
|
||||
ok('删除也幂等', removedTwice.text === removed.text)
|
||||
|
||||
// ── 10. settings.yaml:文件里已有同名 route(非平台写的)⇒ 不重复写 ──────────
|
||||
console.log('\n[10] 已存在同名 route ⇒ 不重复写(防 YAML 重复键)')
|
||||
const collide = reconcileSettings("llm-pi-ai:\n providers:\n my-gw:\n apiKeyEnv: USER_SET\n", [gw], [])
|
||||
ok('未插入第二个 my-gw', (collide.text.match(/^ {4}my-gw:$/gm) ?? []).length === 1, JSON.stringify(collide.text))
|
||||
|
||||
// ── 11. parseModels 宽容性 ───────────────────────────────────────────────────
|
||||
console.log('\n[11] parseModels:坏值只能被丢弃,不能把整份文件写坏')
|
||||
ok('坏 JSON → []', parseModels('{oops').length === 0)
|
||||
ok('非数组 → []', parseModels('"x"').length === 0)
|
||||
ok('过滤非字符串项', JSON.stringify(parseModels('["a", 1, null, "b"]')) === '["a","b"]')
|
||||
ok('去重', parseModels('["a","a"]').length === 1)
|
||||
ok('限长 50', parseModels(JSON.stringify(Array.from({ length: 80 }, (_, i) => 'm' + i))).length === 50)
|
||||
ok('null → []', parseModels(null).length === 0)
|
||||
|
||||
// ── 12. 认不出的布局:宁可不动 ───────────────────────────────────────────────
|
||||
console.log('\n[12] 认不出的凭据布局 ⇒ 原样返回(不冒写坏凭据的风险)')
|
||||
const weird = 'this: is\n not: a credentials doc\n'
|
||||
const keptWeird = reconcileCredentials(weird, [{ ref: 'A_KEY', value: 'v' }], [])
|
||||
ok('原样返回', keptWeird.text === weird)
|
||||
|
||||
// ── 13. readRefValue(一次性交接:认领老实现写下的 ref)──────────────────────
|
||||
console.log('\n[13] readRefValue:读出文件里某 ref 的当前值')
|
||||
ok('带单引号', readRefValue("version: 1\nrefs:\n DEEPSEEK_API_KEY: 'sk-1'\n", 'DEEPSEEK_API_KEY') === 'sk-1')
|
||||
ok('不带引号', readRefValue('version: 1\nrefs:\n ANYSEARCH_API_KEY: as_sk_x\n', 'ANYSEARCH_API_KEY') === 'as_sk_x')
|
||||
ok('没有该 ref → null', readRefValue("version: 1\nrefs:\n OTHER: 'x'\n", 'DEEPSEEK_API_KEY') === null)
|
||||
ok('不在 refs 段下的同名行不算', readRefValue("version: 1\nrecords:\n DEEPSEEK_API_KEY: 'z'\n", 'DEEPSEEK_API_KEY') === null)
|
||||
ok(
|
||||
'真实样例(服务器现状两把 key + records 段)',
|
||||
readRefValue(
|
||||
"version: 1\nrefs:\n DEEPSEEK_API_KEY: 'sk-81c7'\n ANYSEARCH_API_KEY: as_sk_4\nrecords:\n client-connection/browser-session:\n kind: grant\n",
|
||||
'DEEPSEEK_API_KEY',
|
||||
) === 'sk-81c7',
|
||||
)
|
||||
|
||||
console.log('')
|
||||
if (failed > 0) {
|
||||
console.log(`✗ verify-model-landing 失败 ${failed} 项`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('✓ verify-model-landing 全绿')
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user