diff --git a/ensure-role-profile-patch.cjs b/ensure-role-profile-patch.cjs index 6c0dc0c..2d91ddb 100644 --- a/ensure-role-profile-patch.cjs +++ b/ensure-role-profile-patch.cjs @@ -5,12 +5,20 @@ * 背景: * · 2026-09-09:普通用户在设置面板不应看到「模型」分区(模型 KEY 由管理员经门户统一 * 管控,档案 03;且避免普通用户误配自用 key 绕过统一 key)。 - * · **2026-09-13(档案 86):「模型」分区对普通用户放开** —— 用户要求把配置模型密钥 + * · 2026-09-13(**档案 85 §九**):「模型」分区对普通用户放开 —— 用户要求把配置模型密钥 * 开放给用户自己配,且「界面交互和官方一模一样」(⇒ 直接用官方页,不仿制)。 * 实测:官方页在本环境**可用**(`/api/session/modelCatalog` 返回 200,"provider 目录 * 不可用"的旧判断已不成立)。配套改平台注入:用户自配 ⇒ 不注入共享 env - * (`src/web/server.ts` `userHasOwnKey()`,否则 env 优先级会静默盖掉用户配的 key)。 - * 仍禁用:plugins / plugin-inventory / cordis(骨架插件禁任一都可能搞坏实例)。 + * (`src/web/server.ts` 的 `resolveApiKey()` 恒返回 null,否则 env 优先级会静默盖掉 + * 用户配的 key)。仍禁用:plugins / plugin-inventory / cordis。 + * · **2026-09-13(档案 87):上面那条被推翻 —— 该分区必须重新禁掉,且这次连 admin 一起禁**。 + * 实测:官方页要求 Host settings 镜像,而平台是「浏览器经域名访问远程服务器」⇒ + * `isLoopback` 为 false ⇒ persistence 降级为 `memory` ⇒ 页面**必报**「加载提供方目录失败」 + * (完整判据见下方 `DISABLE_MODELS_BLOCK` 的注释)。⇒ 放开它只是给用户一个报错页; + * 用户自配模型改走**平台自建的「模型设置」分区**(`poc/business-plugins` **0.3.11**: + * 界面走 `/api/me/keys` 等接口,落地由 `src/web/server.ts` 在 spawn 时写 + * `$DSH_HOME/.credentials.yaml` 与 `$DSH_HOME/settings.yaml` —— 字段名与官方包对齐, + * 见 `src/web/model-landing.ts` 头注释)。 * cordis patch 支持对 client 插件行 * `disabled: true`(dsh-app-boot applyEntryPatches:非 insert patch 按 id 合入 * overrides)——生效位置 = profile 层 `cordis.patch.yml`(实例启动时打包 client @@ -34,14 +42,21 @@ const MARK = '# dshs role patch' // --force:已由本脚本管理但内容落后(缺新版禁用项)时,整体升级为当前块。 const FORCE = process.argv.includes('--force') const DISABLE_MODELS_BLOCK = [ - '# dshs role patch: 收归核心插件开关(档案 15)', + '# dshs role patch: 普通用户隐藏模型分区 + 收归核心插件开关(档案 15 / 87)', '# admin 保留;由 ensure-role-profile-patch.cjs 管理,勿手改', '# 148 个 @deepseek-ai 官方插件均为运行骨架,用户禁用任一都可能搞坏实例,', '# 故插件栏 / 插件清单 / cordis 面板只对 admin 开放;ui-skill、ui-permission 保留给用户。', '#', - '# ⚠️ ui-settings-models **自 2026-09-13 起不再禁用**(档案 86):用户要自助配置模型厂家与', - '# key(「界面交互和官方一模一样」)。配套:平台注入策略改为「用户自配则不注入共享 env」,', - '# 使模型页配的 key 真能生效 —— 见 src/web/server.ts 的 userHasOwnKey()。', + '# ⛔ ui-settings-models **必须保持禁用**(2026-09-13 实地查证,档案 87):', + '# 官方模型页要求 Host settings 镜像,而 `dsh-client-ui-settings` 的持久化判定是', + '# `isLoopback = transport.ownsHost || pageLocation === undefined || isLoopbackHostname(page)`,', + '# 平台是「浏览器经域名访问远程服务器」⇒ 三条都不成立 ⇒ persistence 降级为 `memory`', + '# ⇒ `ensure()` 直接返回不读 ⇒ 页面必报「加载提供方目录失败: settings are unavailable', + '# in this browser」(官方 README 原文:a non-loopback browser cannot use that Host-only namespace)。', + '# ⇒ **放开它只会给用户一个报错页**;用户自配模型改走平台自建的「模型设置」分区(档案 87)。', + '- id: ui-settings-models', + ' name: "@deepseek-ai/dsh-client-ui-settings-models"', + ' disabled: true', '- id: ui-settings-plugins', ' name: "@deepseek-ai/dsh-client-ui-settings-plugins"', ' disabled: true', @@ -54,6 +69,26 @@ const DISABLE_MODELS_BLOCK = [ '', ].join('\n') +/** + * admin 的块:**只隐藏「模型」分区**(档案 87)。 + * + * 为什么连 admin 也要隐藏:该页要求 Host settings 镜像,而平台是「浏览器经域名访问远程服务器」 + * ⇒ `isLoopback` 为 false ⇒ persistence 降级为 `memory` ⇒ 页面必报 + * 「加载提供方目录失败: settings are unavailable in this browser」。 + * **对 admin 同样如此** —— 之前这个补丁只写非 admin,所以 admin 一直能看到并点进报错页。 + * admin 的其余能力(插件栏 / cordis 面板)保持官方默认,**不**跟着禁。 + */ +const ADMIN_MODELS_BLOCK = [ + '# dshs role patch: admin 仅隐藏「模型」分区(档案 87)', + '# 由 ensure-role-profile-patch.cjs 管理,勿手改', + '# 该页在平台环境必然报错(non-loopback 页面拿不到 Host settings),故对 admin 一并隐藏。', + '# 用户自配模型改走平台自建页(实例内「设置 → 模型设置」)。', + '- id: ui-settings-models', + ' name: "@deepseek-ai/dsh-client-ui-settings-models"', + ' disabled: true', + '', +].join('\n') + function isEmptyPatch(content) { const body = content .split('\n') @@ -62,26 +97,72 @@ function isEmptyPatch(content) { return body.length === 0 || body.join('') === '[]' } +/** + * 从文本里摘掉本脚本**上一次写的那个块**(从 `MARK` 行起,到下一个平台块 `# >>>` 或文件尾), + * 其余内容原样保留。 + * + * ⚠️ 为什么不能直接 `writeFileSync(patchPath, block)`(老实现就是那么写的,是个**雷**): + * admin 的 `cordis.patch.yml` 里同时住着三个平台块 —— `disable-hmr`、 + * `workspace-scoped-picker`、本脚本的 role patch。整文件覆盖 ⇒ **另两个块被静默抹掉**: + * disable-hmr 丢掉 = 生产实例重新连 HMR;picker 块丢掉 = 目录选择器回到「可改任意路径」的 + * 无限制版(档案 18 v3 的收敛形同作废)。所以升级只准替换**自己那一段**。 + * @param text - 现有 patch 文本。 + * @returns 去掉本脚本那个块之后的文本(找不到 MARK 时原样返回)。 + */ +function stripManagedBlock(text) { + const lines = text.split('\n') + const start = lines.findIndex((l) => l.startsWith(MARK)) + if (start < 0) return text + let end = lines.length + for (let i = start + 1; i < lines.length; i++) { + if (lines[i].startsWith('# >>>')) { + end = i + break + } + } + while (end > start && lines[end - 1].trim() === '') end-- // 吃掉块尾空行,避免越删越空 + return [...lines.slice(0, start), ...lines.slice(end)].join('\n').replace(/\n{3,}/g, '\n\n') +} + function ensureUserPatch(user) { const patchPath = join(user.home_dir, 'profiles', PROFILE, 'cordis.patch.yml') if (!existsSync(patchPath)) { return { user: user.username, action: 'NO_PROFILE', detail: 'profile 尚未创建(用户未首登 spawn);请先登录一次再跑' } } const current = readFileSync(patchPath, 'utf8') - if (current.includes(MARK)) { - // 需要升级的两种旧态:① 缺「插件开关收归」;② **还禁着 ui-settings-models** - // (2026-09-13 之前写入的块 —— 那一版把「模型」分区也对用户藏了,现已放开,见档案 86)。 - const legacy = - !current.includes('ui-settings-plugins') || /^-\s*id:\s*ui-settings-models\s*$/m.test(current) - if (FORCE && legacy) { - writeFileSync(patchPath, DISABLE_MODELS_BLOCK, 'utf8') - return { user: user.username, action: 'upgraded', detail: '已升级(放开「模型」分区 + 保留核心插件开关收归)' } + const isAdmin = user.role === 'admin' + const block = isAdmin ? ADMIN_MODELS_BLOCK : DISABLE_MODELS_BLOCK + // 需要升级的三种旧态:① 还没写上本轮的「档案 87」标记(注释是判据的**唯一落盘处**, + // 陈旧就等于判据丢失);② 缺核心插件开关收归;③ 还禁着 ui-settings-models 的旧版(已放开)。 + const stale = !current.includes('档案 87') + const legacy = isAdmin + ? stale || !/^-\s*id:\s*ui-settings-models\s*$/m.test(current) + : stale || !current.includes('ui-settings-plugins') || !/^-\s*id:\s*ui-settings-models\s*$/m.test(current) + if (current.includes(MARK)) { + if (FORCE && legacy) { + const rest = stripManagedBlock(current).replace(/^\s*\n+/, '') + writeFileSync(patchPath, (rest.trim() === '' ? '' : rest.replace(/\s*$/, '') + '\n\n') + block, 'utf8') + return { + user: user.username, + action: 'upgraded', + detail: isAdmin + ? '已替换 admin 块(只隐藏「模型」分区;disable-hmr / picker 两个平台块原样保留)' + : '已升级(收回「模型」分区 + 核心插件开关收归)', } - return { user: user.username, action: 'skip', detail: '已由本脚本管理' } } - if (!isEmptyPatch(current)) return { user: user.username, action: 'skip', detail: '用户已定制 cordis.patch.yml,不覆盖' } - writeFileSync(patchPath, DISABLE_MODELS_BLOCK, 'utf8') - return { user: user.username, action: 'wrote', detail: '已写入 disable models patch' } + return { user: user.username, action: 'skip', detail: '已由本脚本管理' } + } + if (!isEmptyPatch(current)) { + // 档案 87:admin 的 patch 里已经有**别的平台块**(disable-hmr / workspace-scoped-picker), + // 整文件覆盖会丢掉它们 ⇒ **追加**(幂等:无 MARK 才追加;有 MARK 走上面的 upgrade 分支)。 + if (isAdmin) { + writeFileSync(patchPath, current.replace(/\s*$/, '') + '\n\n' + block, 'utf8') + return { user: user.username, action: 'wrote', detail: '已**追加** admin 块(只隐藏「模型」分区,保留既有平台块)' } + } + return { user: user.username, action: 'skip', detail: '用户已定制 cordis.patch.yml,不覆盖' } + } + writeFileSync(patchPath, block, 'utf8') + return { user: user.username, action: 'wrote', detail: isAdmin ? '已写 admin 块(只隐藏「模型」分区)' : '已写入 role patch' } } function restartUserInstance(user) { @@ -118,7 +199,9 @@ function main() { if (usernames.length > 0) { rows = usernames.map((u) => db.prepare('SELECT id, username, role, home_dir, uid FROM users WHERE username=?').get(u)).filter(Boolean) } else { - rows = db.prepare("SELECT id, username, role, home_dir, uid FROM users WHERE role != 'admin'").all() + // 档案 87:**含 admin** —— admin 也要隐藏「模型」分区(该页在平台环境必然报错)。 + // admin 的块只禁 models 一项(插件栏 / cordis 面板保持官方默认,admin 需要它们)。 + rows = db.prepare("SELECT id, username, role, home_dir, uid FROM users WHERE role IN ('admin','active')").all() } db.close() if (rows.length === 0) { diff --git a/package.json b/package.json index ec1eb14..cd61ff3 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "prepare": "npm run build", "dev": "node lib/cli.js", "typecheck": "tsc -p tsconfig.json --noEmit", - "verify": "npm run build && node --test test/db.test.mjs test/k8s-spawner.test.mjs test/leader.test.mjs test/local-user-fs.test.mjs test/crash-policy.test.mjs && node scripts/verify-inject.cjs lib/supervisor/proxy.js && node scripts/verify-static.mjs && node scripts/verify-platform-admin-section.mjs && node scripts/verify-mem-model.mjs", + "verify": "npm run build && node --test test/db.test.mjs test/k8s-spawner.test.mjs test/leader.test.mjs test/local-user-fs.test.mjs test/crash-policy.test.mjs && node scripts/verify-inject.cjs lib/supervisor/proxy.js && node scripts/verify-static.mjs && node scripts/verify-platform-admin-section.mjs && node scripts/verify-mem-model.mjs && node scripts/verify-model-landing.mjs", "test": "npm run build && node --test test/db.test.mjs test/k8s-spawner.test.mjs test/leader.test.mjs test/local-user-fs.test.mjs test/crash-policy.test.mjs && node scripts/verify-inject.cjs lib/supervisor/proxy.js", "smoke": "node scripts/smoke.mjs", "smoke:admin": "node scripts/smoke-admin.mjs", diff --git a/poc/business-plugins/lib/client.js b/poc/business-plugins/lib/client.js index 1e652a5..3273af5 100644 --- a/poc/business-plugins/lib/client.js +++ b/poc/business-plugins/lib/client.js @@ -209,9 +209,9 @@ window.__ModuleLoader__.load({ "pa.failed": "操作失败", "pa.working": "处理中…", "pa.sk.delFail": "删除失败", - "pa.p.files": "服务管理", - "pa.d.files": "文件树 + DSH 启动", - "pa.p.keys": "密钥管理", + "pa.p.files": "实例管理", + "pa.d.files": "用户实例与工作区", + "pa.p.keys": "模型管理", "pa.d.keys": "全局 API 密钥", "pa.p.users": "用户管理", "pa.d.users": "审批 / 禁用 / 删除", @@ -221,8 +221,8 @@ window.__ModuleLoader__.load({ "pa.d.plugins": "功能插件投放", "pa.p.runtime": "运行环境", "pa.d.runtime": "共享运行时与工具", - "pa.s.files": "浏览文件、在此文件夹启动 DSH", - "pa.s.keys": "全局 API 密钥(所有用户共用,仅管理员可改)", + "pa.s.files": "按用户查看工作区、启动 / 停止其 DSH 实例", + "pa.s.keys": "平台共享密钥(未自配密钥的用户默认使用;仅管理员可改)", "pa.s.users": "审批 / 禁用 / 删除用户", "pa.s.skills": "共享技能(所有用户可用 · 只读)", "pa.s.plugins": "功能插件(系统外插件)的投放与管理", @@ -282,6 +282,8 @@ window.__ModuleLoader__.load({ "pa.op.detail": "详情 ↗", "pa.op.repull": "正在重新拉取…", "pa.f.launchDir": "启动目录:", + "pa.f.targetUser": "用户:", + "pa.f.selfOnly": "仅管理员可切换用户", "pa.f.root": "根目录", "pa.f.rootShort": "根", "pa.f.newFolderPrompt": "文件夹名称:", @@ -373,7 +375,60 @@ window.__ModuleLoader__.load({ "pa.rt.q1": "升级 / 卸载 / 迁移怎么做?", "pa.rt.a1": "升级:改脚本里的固定版本号 → 重跑对应安装脚本(幂等 + 官方 sha256 校验)→ 再跑 {script} 刷新版本基线,否则页面会一直提示漂移。\n卸载:删 /usr/local/bin/<名字> 软链即可(「平台必备」项请勿删);\n迁移:整个 {dir} 就是一个打包单元 —— tar czf dsh-runtime.tar.gz -C /usr/local dsh-runtime,目标机解压回原位后重跑两个安装脚本(只重建软链)。\n实例内 /usr 只读 → 用户无法自行安装或修改,这里的变更对全部用户立即生效。", "pa.rt.q2": "安装清单原文(SHARED-TOOLS.md)", - "pa.rt.loadFail": "加载失败:" + "pa.rt.loadFail": "加载失败:", + + // ── 档案 87「模型设置」分区 ──────────────────────────────────────────── + // 为什么平台自己做这一页:官方「设置 → 模型」页在平台环境**必然报错**(它要 + // Host settings 镜像,而平台是浏览器经域名访问远程服务器 ⇒ `isLoopback=false` + // ⇒ persistence 降级 memory ⇒ 页面报「加载提供方目录失败」)。所以官方那个分区 + // 被 `ensure-role-profile-patch.cjs` 对**所有角色(含 admin)**隐藏。 + // 三条口径(用户 2026-09-13 定,勿再当选择题):① 条目各自开关、可同时启用 + // ② admin 配的共享模型**也列在这里**、用户可开关 ③ 用哪个模型在 dsh 对话框选。 + "ms.title": "模型设置", + "ms.sub": "管理你自用的模型厂家:每条可单独开关、可同时启用;具体用哪个模型,在对话界面的模型选择器里选。保存后需重启实例生效。", + "ms.loadFailed": "加载失败,请刷新重试", + "ms.saveFailed": "保存失败,请重试", + "ms.saved": "已保存,重启实例后生效", + "ms.needNameKey": "请填写名称与 API Key", + "ms.needModels": "自定义厂家至少要填一个模型", + "ms.shared": "平台共享模型", + "ms.sharedOwner": "由 {who} 配置", + "ms.sharedHint": "关掉后,你的实例不再接收平台共享的模型条目(你自己配的仍照常生效)。", + "ms.available": "可用", + "ms.unavailable": "平台未配置", + "ms.sharedOn": "停用共享模型", + "ms.sharedOff": "启用共享模型", + "ms.add": "新增模型条目", + "ms.ph.name": "名称(如 我的中转网关)", + "ms.ph.key": "API Key", + "ms.ph.url": "Endpoint(留空 = 内置 DeepSeek)", + "ms.ph.route": "厂家标识(可选,英文小写)", + "ms.ph.models": "模型 id,一行一个", + "ms.formHint": "Endpoint 留空即使用平台内置 DeepSeek;一旦填写,就必须给至少一个模型 id。厂家标识会写进实例配置,同一个标识不能重复。", + "ms.save": "保存", + "ms.list": "我的模型条目", + "ms.col.name": "名称", + "ms.col.route": "厂家标识", + "ms.col.url": "Endpoint", + "ms.col.api": "协议", + "ms.col.models": "模型数", + "ms.col.state": "状态", + "ms.col.act": "操作", + "ms.empty": "还没有条目 —— 在上面添加一个即可", + "ms.builtin": "内置 DeepSeek", + "ms.builtinHint": "官方内置,无需 endpoint", + "ms.on": "已启用", + "ms.off": "已停用", + "ms.enable": "启用", + "ms.disable": "停用", + "ms.del": "删除", + "ms.e.name": "名称不合法", + "ms.e.key": "API Key 不合法(只允许字母数字与 - _ .)", + "ms.e.url": "Endpoint 必须以 http(s):// 开头", + "ms.e.route": "厂家标识不合法(小写字母/数字/短横线,字母开头)", + "ms.e.api": "不支持该协议", + "ms.e.models": "请至少填一个模型 id", + "ms.e.taken": "该厂家标识已被占用,换一个" }; /** English dictionary, key-set complete against zh. */ var en = { @@ -439,9 +494,9 @@ window.__ModuleLoader__.load({ "pa.failed": "Action failed", "pa.working": "Working…", "pa.sk.delFail": "Delete failed", - "pa.p.files": "Service management", - "pa.d.files": "File tree + launch DSH", - "pa.p.keys": "API keys", + "pa.p.files": "Instance management", + "pa.d.files": "User instances and workspaces", + "pa.p.keys": "Model management", "pa.d.keys": "Global API keys", "pa.p.users": "User management", "pa.d.users": "Approve / disable / delete", @@ -451,8 +506,8 @@ window.__ModuleLoader__.load({ "pa.d.plugins": "Feature plugin publishing", "pa.p.runtime": "Runtime", "pa.d.runtime": "Shared runtimes and tools", - "pa.s.files": "Browse files and launch DSH from this folder", - "pa.s.keys": "Global API keys (shared by all users; admin-editable)", + "pa.s.files": "Browse a user's workspace and start/stop their DSH instance", + "pa.s.keys": "Platform-shared key (used by users who have not set their own; admin-only)", "pa.s.users": "Approve / disable / delete users", "pa.s.skills": "Shared skills (available to all users · read-only)", "pa.s.plugins": "Publishing and managing feature (out-of-tree) plugins", @@ -512,6 +567,8 @@ window.__ModuleLoader__.load({ "pa.op.detail": "Details ↗", "pa.op.repull": "Re-fetching…", "pa.f.launchDir": "Launch folder:", + "pa.f.targetUser": "User:", + "pa.f.selfOnly": "Only an admin can switch users", "pa.f.root": "root", "pa.f.rootShort": "root", "pa.f.newFolderPrompt": "Folder name:", @@ -603,7 +660,53 @@ window.__ModuleLoader__.load({ "pa.rt.q1": "How do I upgrade / uninstall / migrate?", "pa.rt.a1": "Upgrade: bump the pinned version in the script → re-run that install script (idempotent + official sha256 check) → run {script} to refresh the baseline, otherwise this page keeps reporting drift.\nUninstall: just remove the /usr/local/bin/ symlink (do not remove platform-required entries).\nMigrate: {dir} is a single packaging unit — tar czf dsh-runtime.tar.gz -C /usr/local dsh-runtime, unpack it back in place on the target and re-run both install scripts (they only rebuild symlinks).\n/usr is read-only inside instances → users cannot install or modify anything; changes here apply to all users immediately.", "pa.rt.q2": "Manifest source (SHARED-TOOLS.md)", - "pa.rt.loadFail": "Failed to load: " + "pa.rt.loadFail": "Failed to load: ", + + "ms.title": "Model settings", + "ms.sub": "Manage your own model providers: each entry has its own switch and several can be on at once. Pick which model to use in the chat model selector. Restart the instance for changes to take effect.", + "ms.loadFailed": "Failed to load — refresh and try again", + "ms.saveFailed": "Save failed — try again", + "ms.saved": "Saved — restart the instance to apply", + "ms.needNameKey": "Please fill in a name and an API key", + "ms.needModels": "A custom provider needs at least one model", + "ms.shared": "Platform-shared model", + "ms.sharedOwner": "Configured by {who}", + "ms.sharedHint": "When off, your instance stops receiving the platform-shared model entries (your own entries still apply).", + "ms.available": "Available", + "ms.unavailable": "Not configured", + "ms.sharedOn": "Disable shared model", + "ms.sharedOff": "Enable shared model", + "ms.add": "Add a model entry", + "ms.ph.name": "Name (e.g. My gateway)", + "ms.ph.key": "API key", + "ms.ph.url": "Endpoint (empty = built-in DeepSeek)", + "ms.ph.route": "Provider id (optional, lowercase)", + "ms.ph.models": "One model id per line", + "ms.formHint": "Leave Endpoint empty to use the platform's built-in DeepSeek; if you fill it in you must give at least one model id. The provider id is written into the instance config and must be unique.", + "ms.save": "Save", + "ms.list": "My model entries", + "ms.col.name": "Name", + "ms.col.route": "Provider id", + "ms.col.url": "Endpoint", + "ms.col.api": "Protocol", + "ms.col.models": "Models", + "ms.col.state": "State", + "ms.col.act": "Actions", + "ms.empty": "No entries yet — add one above", + "ms.builtin": "Built-in DeepSeek", + "ms.builtinHint": "Built in; no endpoint needed", + "ms.on": "Enabled", + "ms.off": "Disabled", + "ms.enable": "Enable", + "ms.disable": "Disable", + "ms.del": "Delete", + "ms.e.name": "Invalid name", + "ms.e.key": "Invalid API key (letters, digits, - _ . only)", + "ms.e.url": "Endpoint must start with http(s)://", + "ms.e.route": "Invalid provider id (lowercase letters, digits, dashes; must start with a letter)", + "ms.e.api": "Unsupported protocol", + "ms.e.models": "Add at least one model id", + "ms.e.taken": "That provider id is taken — pick another" }; /** @@ -1428,6 +1531,211 @@ window.__ModuleLoader__.load({ return jsxRuntime.jsx("div", { style: wrap, children: rows }); } + // ═══════════════════════════════════════════════════════════════════════════ + // 「模型设置」分区(档案 87 · 2026-09-13 第三轮口径) + // + // 为什么平台自己做这一页:官方「设置 → 模型」页在平台环境**必然报错** —— + // 它要求 Host settings 镜像,而平台是"浏览器经域名访问远程服务器"⇒ + // `isLoopback = transport.ownsHost || pageLocation === undefined || + // isLoopbackHostname(page)` 三条皆不成立 ⇒ persistence 降级为 `memory` + // ⇒ 页面必报「加载提供方目录失败: settings are unavailable in this browser」。 + // 所以官方那个分区被 `ensure-role-profile-patch.cjs` 对**所有角色(含 admin)** + // 隐藏(见其 `DISABLE_MODELS_BLOCK` / `ADMIN_MODELS_BLOCK`),用户自配改走本页。 + // + // 用户定下的三条口径(**已定,不要再拿去当选择题**): + // ① 条目**各自开关、可同时启用**(互斥已删); + // ② admin 配的**平台共享模型也列在这里**,用户可开关; + // ③ 具体用哪个模型**在 dsh 对话框的模型选择器里选** —— 本页只负责把 + // 「已启用」的都配好(平台在 spawn 时写 `$DSH_HOME/.credentials.yaml` + // 与 `settings.yaml` 的 `llm-pi-ai.providers.`)。 + // ⚠️ 改动**重启实例后生效**(落地发生在 spawn 时),页面文案已如实说明。 + // + // 接口:平台 `src/web/routes/auth.ts` 的 `/api/me/keys`(GET/POST)、 + // `/api/me/keys/:id/toggle`、`/api/me/models/shared`、`DELETE /api/me/keys/:id` + // —— 与门户既有 API 一样走 `paReq`(跨子域 + credentials)。 + // ═══════════════════════════════════════════════════════════════════════════ + /** 后端错误码 → 词典键(认不出的码退回通用失败文案,不把码裸露给用户)。 */ + var MS_ERR = { + invalid_name: "ms.e.name", invalid_api_key: "ms.e.key", invalid_base_url: "ms.e.url", + invalid_route: "ms.e.route", invalid_api: "ms.e.api", models_required: "ms.e.models", + route_taken: "ms.e.taken" + }; + + function ModelSettingsSection() { + var useState = React.useState; + var useEffect = React.useEffect; + var dataState = useState(null); + var data = dataState[0]; + var setData = dataState[1]; + var loadingState = useState(true); + var loading = loadingState[0]; + var setLoading = loadingState[1]; + var busyState = useState(false); + var busy = busyState[0]; + var setBusy = busyState[1]; + var msgState = useState(""); + var msg = msgState[0]; + var setMsg = msgState[1]; + var fName = useState(""); + var fKey = useState(""); + var fUrl = useState(""); + var fRoute = useState(""); + var fApi = useState(""); + var fModels = useState(""); + + function load() { + setLoading(true); + paReq("/api/me/keys") + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (d) { + if (d) { + setData(d); + // 协议默认取官方表的第一项(`dsh-llm-pi-ai` 的 PROTOCOLS 顺序即默认优先级)。 + if (!fApi[0]) fApi[1]((d.protocols || [])[0] || ""); + } + setLoading(false); + }) + .catch(function () { setLoading(false); setMsg(t("ms.loadFailed")); }); + } + useEffect(function () { load(); }, []); + + /** 统一收尾:跑一个写操作 → 刷新列表 / 或把后端错误码翻成文案。 */ + function act(promise) { + if (busy) return; + setBusy(true); + setMsg(""); + promise + .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); }) + .then(function (res) { + setBusy(false); + if (!res.ok) { + var code = res.d && res.d.error; + setMsg(t(MS_ERR[code] || "ms.saveFailed")); + return; + } + if (res.d && res.d.saved) setMsg(t("ms.saved")); + load(); + }) + .catch(function () { setBusy(false); setMsg(t("ms.saveFailed")); }); + } + + function submit() { + var name = fName[0].trim(); + var key = fKey[0].trim(); + if (name === "" || key === "") { setMsg(t("ms.needNameKey")); return; } + var url = fUrl[0].trim(); + var body = { name: name, apiKey: key }; + if (url !== "") { + var ids = fModels[0].split("\n").map(function (s) { return s.trim(); }).filter(function (s) { return s !== ""; }); + if (ids.length === 0) { setMsg(t("ms.needModels")); return; } + body.baseUrl = url; + body.models = ids; + if (fRoute[0].trim() !== "") body.route = fRoute[0].trim(); + if (fApi[0]) body.api = fApi[0]; + } + act(papostJson("/api/me/keys", body)); + fName[1](""); fKey[1](""); fUrl[1](""); fRoute[1](""); fModels[1](""); + } + + if (loading) { + return jsxRuntime.jsx("div", { className: "pa-page", children: jsxRuntime.jsx("div", { className: "pa-empty", children: t("loading") }) }); + } + + var keys = (data && data.keys) || []; + var shared = (data && data.shared) || {}; + var sharedOn = !!(data && data.sharedModelEnabled); + var protocols = (data && data.protocols) || (fApi[0] ? [fApi[0]] : []); + + var rows = keys.map(function (k) { + var custom = !!(k.baseUrl && k.baseUrl !== ""); + var n = 0; + try { var arr = JSON.parse(k.models || "[]"); n = Array.isArray(arr) ? arr.length : 0; } catch (e) { n = 0; } + return jsxRuntime.jsxs("tr", { children: [ + jsxRuntime.jsx("td", { children: k.name }), + jsxRuntime.jsx("td", { className: "pa-dim", children: custom ? (k.route || "—") : t("ms.builtin") }), + jsxRuntime.jsx("td", { className: "pa-dim", children: custom ? k.baseUrl : t("ms.builtinHint") }), + jsxRuntime.jsx("td", { className: "pa-dim", children: custom ? (k.api || "openai-completions") : "—" }), + jsxRuntime.jsx("td", { className: "pa-num", children: custom ? String(n) : "—" }), + jsxRuntime.jsx("td", { children: jsxRuntime.jsx("span", { className: "pa-badge " + (k.enabled ? "active" : "disabled"), children: k.enabled ? t("ms.on") : t("ms.off") }) }), + jsxRuntime.jsxs("td", { children: [ + jsxRuntime.jsx("button", { key: "t", className: "pa-sm", disabled: busy, onClick: function () { act(papostJson("/api/me/keys/" + k.id + "/toggle", { enabled: !k.enabled })); }, children: k.enabled ? t("ms.disable") : t("ms.enable") }), + jsxRuntime.jsx("button", { key: "d", className: "pa-sm danger", style: { marginLeft: 6 }, disabled: busy, onClick: function () { act(paReq("/api/me/keys/" + k.id, { method: "DELETE" })); }, children: t("ms.del") }) + ] }) + ] }, k.id); + }); + + return jsxRuntime.jsxs("div", { className: "pa-page", children: [ + jsxRuntime.jsx("h1", { className: "pa-hd", children: t("ms.title") }), + jsxRuntime.jsx("p", { className: "pa-sub", children: t("ms.sub") }), + + // ── 平台共享模型(口径②:列入 + 可开关;开关只影响自己,不动 admin 的配置)── + jsxRuntime.jsxs("div", { className: "pa-box", style: { marginBottom: 16 }, children: [ + jsxRuntime.jsxs("div", { className: "pa-card-h", children: [ + jsxRuntime.jsx("span", { children: t("ms.shared") }), + shared.owner ? jsxRuntime.jsx("span", { className: "sub", children: t("ms.sharedOwner").replace("{who}", shared.owner) }) : null + ] }), + jsxRuntime.jsx("p", { className: "pa-hint", children: t("ms.sharedHint") }), + jsxRuntime.jsxs("div", { className: "pa-row", style: { margin: 0 }, children: [ + jsxRuntime.jsx("span", { className: "pa-badge " + (shared.available ? "active" : "disabled"), children: shared.available ? (shared.name || t("ms.available")) : t("ms.unavailable") }), + jsxRuntime.jsx("button", { className: "pa-btn" + (sharedOn ? "" : " pa-primary"), disabled: busy || !shared.available, onClick: function () { act(papostJson("/api/me/models/shared", { enabled: !sharedOn })); }, children: sharedOn ? t("ms.sharedOn") : t("ms.sharedOff") }) + ] }) + ] }), + + // ── 新增条目(Endpoint 留空 = 内置 DeepSeek;填了就必须给模型清单)── + jsxRuntime.jsxs("div", { className: "pa-box", style: { marginBottom: 16 }, children: [ + jsxRuntime.jsx("div", { className: "pa-card-h", children: jsxRuntime.jsx("span", { children: t("ms.add") }) }), + jsxRuntime.jsxs("div", { className: "pa-row", children: [ + jsxRuntime.jsx("input", { className: "pa-input", placeholder: t("ms.ph.name"), value: fName[0], onChange: function (e) { fName[1](e.target.value); } }), + jsxRuntime.jsx("input", { className: "pa-input", type: "password", autoComplete: "off", placeholder: t("ms.ph.key"), value: fKey[0], onChange: function (e) { fKey[1](e.target.value); } }) + ] }), + jsxRuntime.jsxs("div", { className: "pa-row", children: [ + jsxRuntime.jsx("input", { className: "pa-input", placeholder: t("ms.ph.url"), value: fUrl[0], onChange: function (e) { fUrl[1](e.target.value); } }), + jsxRuntime.jsx("input", { className: "pa-input", placeholder: t("ms.ph.route"), value: fRoute[0], onChange: function (e) { fRoute[1](e.target.value); } }) + ] }), + jsxRuntime.jsxs("div", { className: "pa-row", children: [ + jsxRuntime.jsx("select", { className: "pa-input", value: fApi[0], onChange: function (e) { fApi[1](e.target.value); }, children: protocols.map(function (p) { return jsxRuntime.jsx("option", { value: p, children: p }, p); }) }) + ] }), + jsxRuntime.jsx("textarea", { className: "pa-input", rows: 3, style: { width: "100%", boxSizing: "border-box" }, placeholder: t("ms.ph.models"), value: fModels[0], onChange: function (e) { fModels[1](e.target.value); } }), + jsxRuntime.jsx("p", { className: "pa-hint", children: t("ms.formHint") }), + jsxRuntime.jsxs("div", { className: "pa-row", style: { marginBottom: 0 }, children: [ + jsxRuntime.jsx("button", { className: "pa-btn pa-primary", disabled: busy, onClick: submit, children: t("ms.save") }), + msg ? jsxRuntime.jsx("span", { className: "pa-hint", style: { margin: 0 }, children: msg }) : null + ] }) + ] }), + + // ── 我的条目(口径①:每条独立开关,可同时启用)── + jsxRuntime.jsx("div", { className: "pa-sec", children: t("ms.list") }), + jsxRuntime.jsx("div", { className: "pa-wrap", children: jsxRuntime.jsxs("table", { className: "pa-tbl", children: [ + jsxRuntime.jsx("thead", { children: jsxRuntime.jsxs("tr", { children: [ + jsxRuntime.jsx("th", { children: t("ms.col.name") }), + jsxRuntime.jsx("th", { children: t("ms.col.route") }), + jsxRuntime.jsx("th", { children: t("ms.col.url") }), + jsxRuntime.jsx("th", { children: t("ms.col.api") }), + jsxRuntime.jsx("th", { children: t("ms.col.models") }), + jsxRuntime.jsx("th", { children: t("ms.col.state") }), + jsxRuntime.jsx("th", { children: t("ms.col.act") }) + ] }) }), + jsxRuntime.jsx("tbody", { + children: rows.length > 0 + ? rows + : jsxRuntime.jsx("tr", { children: jsxRuntime.jsx("td", { className: "pa-empty-cell", colSpan: 7, children: t("ms.empty") }) }) + }) + ] }) }) + ] }); + } + + ctx.slots.inject("settings.section", function () { + return ctx.slots.register( + { + name: "settings.section", + id: "model-settings", + order: 100, + label: function () { return t("ms.title"); } + }, + ModelSettingsSection + ); + }); + ctx.slots.inject("settings.section", function () { return ctx.slots.register( { @@ -1526,6 +1834,9 @@ window.__ModuleLoader__.load({ sub: function () { return t("pa.s.files"); }, html: function () { return '
' + + // 档案 86:admin 可切换目标用户 —— 下方文件树 / 启停 / 打开 全部针对所选用户 + '' + paesc(t("pa.f.targetUser")) + "" + + '' + '' + paesc(t("pa.st.stopped")) + "" + '" + '" + @@ -1548,11 +1859,14 @@ window.__ModuleLoader__.load({ ''; }, init: function ($, root) { - var cur = []; + var cur = []; // 目录栈 + var uid = ""; // 目标用户(档案 86:admin 可切到任意用户;不再是"自己") + /** admin 视角的基路径 —— 文件与实例操作全部打到这里(requireAdmin)。 */ + function base() { return "/api/admin/users/" + encodeURIComponent(uid); } function pathString() { return cur.join("/"); } function load() { var p = pathString(); - return paReq("/api/desktop/tree" + (p ? "?path=" + encodeURIComponent(p) : "")) + return paReq(base() + "/fs/tree" + (p ? "?path=" + encodeURIComponent(p) : "")) .then(function (r) { return r.json(); }) .then(function (body) { var rows = $("rows"); @@ -1595,7 +1909,7 @@ window.__ModuleLoader__.load({ }); } function refreshDsh() { - return paReq("/api/dsh/status").then(function (r) { return r.ok ? r.json() : null; }).then(function (body) { + return paReq(base() + "/dsh/status").then(function (r) { return r.ok ? r.json() : null; }).then(function (body) { if (!body) return; var running = body.running; $("dshState").innerHTML = '' + @@ -1606,7 +1920,7 @@ window.__ModuleLoader__.load({ }); } function doCreate(name, type) { - return papostJson("/api/fs/create", { path: pathString(), name: name, type: type }).then(function (res) { + return papostJson(base() + "/fs/create", { path: pathString(), name: name, type: type }).then(function (res) { if (!res.ok) { return res.json().catch(function () { return {}; }).then(function (e) { root.toast(t("pa.f.createFail") + (e.error || res.status)); @@ -1616,7 +1930,7 @@ window.__ModuleLoader__.load({ }); } $("launchBtn").onclick = function () { - return papostJson("/api/dsh/launch", { folder: pathString() }).then(function (res) { + return papostJson(base() + "/dsh/launch", { folder: pathString() }).then(function (res) { if (!res.ok) { return res.json().catch(function () { return {}; }).then(function (b) { root.toast(b.error === "already_running" ? t("pa.f.alreadyRunning") : t("pa.f.launchFail")); @@ -1627,7 +1941,7 @@ window.__ModuleLoader__.load({ }); }; $("stopBtn").onclick = function () { - return paReq("/api/dsh/stop", { method: "POST" }).then(function () { return refreshDsh(); }); + return paReq(base() + "/dsh/stop", { method: "POST" }).then(function () { return refreshDsh(); }); }; $("newFolderBtn").onclick = function () { var name = window.prompt(t("pa.f.newFolderPrompt")); @@ -1639,7 +1953,7 @@ window.__ModuleLoader__.load({ if (!file) return; var name = file.name; return pareadAsBase64(file).then(function (data) { - return papostJson("/api/fs/upload", { path: pathString(), name: name, data: data }).then(function (res) { + return papostJson(base() + "/fs/upload", { path: pathString(), name: name, data: data }).then(function (res) { if (!res.ok) { return res.json().catch(function () { return {}; }).then(function (e) { root.toast(t("pa.f.uploadFail") + (e.error || res.status)); @@ -1650,11 +1964,29 @@ window.__ModuleLoader__.load({ }); }); }; - return Promise.all([load(), refreshDsh()]); + /** 填「用户」下拉(`/api/admin/users`,requireAdmin);默认选自己。 */ + function loadUsers() { + return paReq("/api/admin/users") + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (d) { + var list = (d && d.users) || []; + var sel = $("svcUser"); + if (!sel) return; + sel.innerHTML = list.map(function (u) { + return '"; + }).join(""); + var mine = list.filter(function (u) { return u.role === "admin"; })[0] || list[0]; + uid = mine ? mine.id : ""; + sel.value = uid; + sel.onchange = function () { uid = sel.value; cur = []; load(); refreshDsh(); }; + }); + } + return loadUsers().then(function () { return Promise.all([load(), refreshDsh()]); }); } }; - /** 密钥管理 ← 门户 `#/keys`(`renderKeys` + `initKeys`)。 */ + /** 模型管理 ← 门户 `#/keys`(`renderKeys` + `initKeys`)。 */ PA_PAGES.keys = { icon: "🔑", title: function () { return t("pa.p.keys"); }, diff --git a/poc/business-plugins/package.json b/poc/business-plugins/package.json index 36643a2..52cd0d6 100644 --- a/poc/business-plugins/package.json +++ b/poc/business-plugins/package.json @@ -1,7 +1,7 @@ { "name": "@dsh-local/business-plugins", - "version": "0.3.8", - "description": "功能管理(原「功能插件」)section for dsh web profile — v0.3.8(2026-09-13):**撤掉「我的密钥」分区** —— 模型配置统一走**官方「设置 → 模型」页**(用户要求「界面交互和官方一模一样」⇒ 不仿制、直接放开官方页,见档案 86)。平台侧同步两处:① `ensure-role-profile-patch.cjs` 不再禁用 `ui-settings-models`(实测官方页在本环境可用:`/api/session/modelCatalog` 返回 200);② `src/web/server.ts` 的 `resolveApiKey()` 改为「用户已自配 ⇒ **不注入共享 env**」——因为 dsh 凭据解析里 `inherited process environment` **优先级最高**,不这样做用户配的 key 会被静默盖掉。|v0.3.7(2026-09-13 · 原拟 0.3.6,因并行会话已用同号 0.3.6 铺发过「内存条」版本 ⇒ 提升为 0.3.7):① ~~新增「我的密钥」分区~~(**已于 0.3.8 撤除**,原因是两套入口会互相干扰) —— 用户自助配置自己的模型密钥(自配自用),不配置就用「平台共享密钥」(管理员配置);页面分两段:我的密钥(添加 / 启用 / 删除)+ 当前生效(明示在用谁的 key,并提示改 key 只重启自己的实例、不影响他人)。配套后端:`/api/me/keys` 由 requireAdmin 放开为 requireAuth,`resolveApiKey(userId)` 改为「先取自己的 → 取不到回落管理员的共享 key」两级;门户「密钥管理」文案同步改为「平台共享密钥」。② **内存条改读平台真实配额,消灭「388 MiB 误报」**(用户问「实例内存大小是不是调整过了,为什么功能设置中还是显示 388 多」)。根因:本插件自建了**第二套**内存模型(基线 285 / univer 64 / mcn 39,并把 clamp 下限 384 当硬上限判超限),而平台编排器自 R1 起为「按插件集合推导」(base 160 / univer 512 / mcn 128 / clamp 384–1024),两处从未对齐 ⇒ 全勾显示 388 并报「⚠ 将超出上限」,真值却是 672(guest)/ 384(admin)。本轮:① 常量与规则逐项对齐编排器,并新增 `scripts/verify-mem-model.mjs` 在 `npm run verify` 里交叉断言(漂了就构建失败);② 优先读 `/api/dsh/status` 新增的 `quota{memMb,heapMb}`(平台**实际使用**的值,与 spawn 同源)直接显示「实际配额 · V8 堆」;③ 超限判断改为「原始需求 > 硬顶 1024」;④ 未进成本表的插件不再显示 ≈0 MiB 徽章。|v0.3.5(2026-09-13):**插件管理 →「官方推荐插件」列表高度拉高**(用户要求「高度可以增加,距离底部 100px 就行」)—— 该表 max-height 由固定 420px 改为 `max(280px, min(calc(92vh - 470px), 580px))`(类 `.pa-plist`),按弹窗高度反推、使列表底部**距弹窗底部约 100px**;下限 280px 防小屏压扁,上限 580px 对应弹窗触顶 1040px。|v0.3.4(2026-09-13):**「系统管理」全面对齐门户 web/portal.html 的 6 个功能页**(用户要求「点开弹窗里面展示的内容,要和 portal 点击功能后那种详细的表格和功能一致」)—— 分区首页 = 门户 renderHome(「服务」「管理」两组 + .nav-card 三行卡片);点开每一项 = 门户**那一页的完整页面**:服务管理(文件树 + 启动/停止/打开 DSH + 新建文件夹/上传)、密钥管理(全局 API 密钥增删启用)、用户管理(审批/禁用/恢复/删除,需输入用户名二次确认)、技能管理(.zip 上传/替换/删除)、插件管理(官方推荐插件 + 手动添加/管理双页签、搜索/分类/只看可导入/重新拉取/批量导入、.tgz 投放含 P0 扫描与显式信任)、运行环境(版本表 + 漂移提示 + 目录/体积 + 折叠说明);表格列 / 按钮 / 文案逐字一致(`PA_PAGES` 逐函数移植 portal 的 renderXxx/initXxx,只改 3 处:局部 `$` 查询器、跨子域 `paReq`、去掉 hash 路由);弹窗宽度对齐门户功能页 min(1440px,96vw)、高 92vh。写操作就地调平台 admin API(跨子域 + credentials:include,CORS 白名单已含 GET/POST/DELETE)。**已删除**旧版自造的 5 项只读摘要(用户管理 / 候选池 / 运行时 / 存储 / 当前实例)。⚠️ 顺带修掉门户 `readAsBase64()` 缺 await 导致上传恒传空数据的缺陷(弹窗侧已修正,门户侧待同修)。|v0.3.3(2026-09-13):「系统管理」视觉层照抄门户组件(.nav-card / .pg-cnt / .table-wrap + table.tbl / .badge / .btn-sm / .page-title)。|v0.2.8(2026-09-13):所有弹窗改页内弹窗(弃用 window.confirm)。|v0.2.7(档案 75):卡片加内存预估徽章 + 列表上方内存预估状态条。|v0.2.4(档案 67):对齐 06-工作台UI规范(信息层次反转 / 字号阶梯 / 行 hover)。|v0.2.2:分区名由「功能插件」改为「功能管理」。|v0.2.0:配色改用官方 --dsw-* design token(跟随 dsh 主题);文案走官方 locale(zh/en)。", + "version": "0.3.11", + "description": "功能管理(原「功能插件」)section for dsh web profile — v0.3.10(2026-09-13):① **「服务管理」改名「实例管理」**、**「密钥管理」改名「模型管理」**(用户要求;门户导航/卡片同步改名);② **「实例管理」页可管任意用户**(用户要求「admin 要能管所有用户的服务」)—— 工具条新增「用户」下拉,切换后文件树/启停/打开全部针对所选用户,走新开的 `/api/admin/users/:id/{fs,dsh}`(requireAdmin)|v0.3.8(2026-09-13):**撤掉「我的密钥」分区** —— 模型配置统一走**官方「设置 → 模型」页**(用户要求「界面交互和官方一模一样」⇒ 不仿制、直接放开官方页,见档案 86)。平台侧同步两处:① `ensure-role-profile-patch.cjs` 不再禁用 `ui-settings-models`(实测官方页在本环境可用:`/api/session/modelCatalog` 返回 200);② `src/web/server.ts` 的 `resolveApiKey()` 改为「用户已自配 ⇒ **不注入共享 env**」——因为 dsh 凭据解析里 `inherited process environment` **优先级最高**,不这样做用户配的 key 会被静默盖掉。|v0.3.7(2026-09-13 · 原拟 0.3.6,因并行会话已用同号 0.3.6 铺发过「内存条」版本 ⇒ 提升为 0.3.7):① ~~新增「我的密钥」分区~~(**已于 0.3.8 撤除**,原因是两套入口会互相干扰) —— 用户自助配置自己的模型密钥(自配自用),不配置就用「平台共享密钥」(管理员配置);页面分两段:我的密钥(添加 / 启用 / 删除)+ 当前生效(明示在用谁的 key,并提示改 key 只重启自己的实例、不影响他人)。配套后端:`/api/me/keys` 由 requireAdmin 放开为 requireAuth,`resolveApiKey(userId)` 改为「先取自己的 → 取不到回落管理员的共享 key」两级;门户「密钥管理」文案同步改为「平台共享密钥」。② **内存条改读平台真实配额,消灭「388 MiB 误报」**(用户问「实例内存大小是不是调整过了,为什么功能设置中还是显示 388 多」)。根因:本插件自建了**第二套**内存模型(基线 285 / univer 64 / mcn 39,并把 clamp 下限 384 当硬上限判超限),而平台编排器自 R1 起为「按插件集合推导」(base 160 / univer 512 / mcn 128 / clamp 384–1024),两处从未对齐 ⇒ 全勾显示 388 并报「⚠ 将超出上限」,真值却是 672(guest)/ 384(admin)。本轮:① 常量与规则逐项对齐编排器,并新增 `scripts/verify-mem-model.mjs` 在 `npm run verify` 里交叉断言(漂了就构建失败);② 优先读 `/api/dsh/status` 新增的 `quota{memMb,heapMb}`(平台**实际使用**的值,与 spawn 同源)直接显示「实际配额 · V8 堆」;③ 超限判断改为「原始需求 > 硬顶 1024」;④ 未进成本表的插件不再显示 ≈0 MiB 徽章。|v0.3.5(2026-09-13):**插件管理 →「官方推荐插件」列表高度拉高**(用户要求「高度可以增加,距离底部 100px 就行」)—— 该表 max-height 由固定 420px 改为 `max(280px, min(calc(92vh - 470px), 580px))`(类 `.pa-plist`),按弹窗高度反推、使列表底部**距弹窗底部约 100px**;下限 280px 防小屏压扁,上限 580px 对应弹窗触顶 1040px。|v0.3.4(2026-09-13):**「系统管理」全面对齐门户 web/portal.html 的 6 个功能页**(用户要求「点开弹窗里面展示的内容,要和 portal 点击功能后那种详细的表格和功能一致」)—— 分区首页 = 门户 renderHome(「服务」「管理」两组 + .nav-card 三行卡片);点开每一项 = 门户**那一页的完整页面**:服务管理(文件树 + 启动/停止/打开 DSH + 新建文件夹/上传)、密钥管理(全局 API 密钥增删启用)、用户管理(审批/禁用/恢复/删除,需输入用户名二次确认)、技能管理(.zip 上传/替换/删除)、插件管理(官方推荐插件 + 手动添加/管理双页签、搜索/分类/只看可导入/重新拉取/批量导入、.tgz 投放含 P0 扫描与显式信任)、运行环境(版本表 + 漂移提示 + 目录/体积 + 折叠说明);表格列 / 按钮 / 文案逐字一致(`PA_PAGES` 逐函数移植 portal 的 renderXxx/initXxx,只改 3 处:局部 `$` 查询器、跨子域 `paReq`、去掉 hash 路由);弹窗宽度对齐门户功能页 min(1440px,96vw)、高 92vh。写操作就地调平台 admin API(跨子域 + credentials:include,CORS 白名单已含 GET/POST/DELETE)。**已删除**旧版自造的 5 项只读摘要(用户管理 / 候选池 / 运行时 / 存储 / 当前实例)。⚠️ 顺带修掉门户 `readAsBase64()` 缺 await 导致上传恒传空数据的缺陷(弹窗侧已修正,门户侧待同修)。|v0.3.3(2026-09-13):「系统管理」视觉层照抄门户组件(.nav-card / .pg-cnt / .table-wrap + table.tbl / .badge / .btn-sm / .page-title)。|v0.2.8(2026-09-13):所有弹窗改页内弹窗(弃用 window.confirm)。|v0.2.7(档案 75):卡片加内存预估徽章 + 列表上方内存预估状态条。|v0.2.4(档案 67):对齐 06-工作台UI规范(信息层次反转 / 字号阶梯 / 行 hover)。|v0.2.2:分区名由「功能插件」改为「功能管理」。|v0.2.0:配色改用官方 --dsw-* design token(跟随 dsh 主题);文案走官方 locale(zh/en)。", "type": "module", "main": "lib/index.js", "exports": { diff --git a/scripts/verify-mem-model.mjs b/scripts/verify-mem-model.mjs index a805303..5c9f825 100644 --- a/scripts/verify-mem-model.mjs +++ b/scripts/verify-mem-model.mjs @@ -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 + ' 项失败 ❌') diff --git a/scripts/verify-model-landing.mjs b/scripts/verify-model-landing.mjs new file mode 100644 index 0000000..6ad4b95 --- /dev/null +++ b/scripts/verify-model-landing.mjs @@ -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 全绿') diff --git a/scripts/verify-platform-admin-section.mjs b/scripts/verify-platform-admin-section.mjs index 4e4c690..597ab66 100644 --- a/scripts/verify-platform-admin-section.mjs +++ b/scripts/verify-platform-admin-section.mjs @@ -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; } diff --git a/src/db/adapter.ts b/src/db/adapter.ts index f6f6098..93f581a 100644 --- a/src/db/adapter.ts +++ b/src/db/adapter.ts @@ -8,6 +8,8 @@ import type { BusinessPlugin, CredentialKey, + CredentialKeyMeta, + CredentialLandingRow, CreateSessionInput, CreateUserInput, Domain, @@ -71,9 +73,23 @@ export interface DbAdapter { setDomainVerified(id: string, verified: boolean): Promise // credential vault listCredentialKeys(userId: string): Promise + /** 档案 86:该用户**全部已启用**的条目(spawn 时按它们写实例配置)。 */ + listEnabledCredentialKeys(userId: string): Promise + /** 档案 87:同上 + **encrypted ref** —— **仅供 `server.ts` 的落地层**,绝不经 API 返回。 */ + listCredentialLandingRows(userId: string): Promise getEnabledCredentialKeyRef(userId: string): Promise - setCredentialKey(userId: string, name: string, encryptedRef: string): Promise + setCredentialKey( + userId: string, + name: string, + encryptedRef: string, + meta?: CredentialKeyMeta, + ): Promise selectCredentialKey(userId: string, id: string): Promise + /** 档案 86:开/关**单个**条目(不动其它条目 —— 用户口径:可同时启用多个)。 */ + toggleCredentialKey(userId: string, id: string, enabled: boolean): Promise + /** 档案 86:用户是否使用「平台共享模型」(admin 配的那把)—— 用户侧偏好。 */ + getSharedModelEnabled(userId: string): Promise + setSharedModelEnabled(userId: string, enabled: boolean): Promise deleteCredentialKey(userId: string, id: string): Promise // instances (desired state the k8s controller reconciles against — docs/k8s.md §5.7) upsertInstance(input: UpsertDshInstanceInput): Promise diff --git a/src/db/pg.ts b/src/db/pg.ts index 78767ee..3e8b5a1 100644 --- a/src/db/pg.ts +++ b/src/db/pg.ts @@ -20,6 +20,8 @@ import { toWorkspace, type BusinessPlugin, type CredentialKey, + type CredentialKeyMeta, + type CredentialLandingRow, type CreateSessionInput, type CreateUserInput, type Domain, @@ -64,6 +66,38 @@ export async function withTx(pool: Pool, fn: (client: PoolClient) => Promise< } } +/** + * 凭据行的列清单与映射(档案 87)—— 与 `repo.ts` 里同名的一组**逐字对应**: + * 两个后端必须选出同一批列,否则就是"SQLite 上好好的、k8s 上少字段"这种 + * 只在生产才出现的偏差。(**不**从 `repo.ts` 导入:那会把 better-sqlite3 原生依赖 + * 拖进 pg 模式。) + */ +const CREDENTIAL_COLS = 'id, key_name, enabled, updated_at, route, base_url, api, models' + +interface CredentialRow { + id: string + key_name: string + enabled: number + updated_at: number + route: string | null + base_url: string | null + api: string | null + models: string | null +} + +function toCredentialKey(r: CredentialRow): CredentialKey { + return { + id: r.id, + name: r.key_name, + enabled: r.enabled === 1, + updatedAt: r.updated_at, + route: r.route, + baseUrl: r.base_url, + api: r.api, + models: r.models, + } +} + export class PgAdapter implements DbAdapter { constructor(private readonly pool: Pool, private readonly baseUid: number) {} @@ -336,65 +370,123 @@ export class PgAdapter implements DbAdapter { async listCredentialKeys(userId: string): Promise { const { rows } = await this.pool.query( - 'SELECT id, key_name, enabled, updated_at FROM credential_vault WHERE user_id = $1 ORDER BY updated_at DESC', + `SELECT ${CREDENTIAL_COLS} FROM credential_vault WHERE user_id = $1 ORDER BY updated_at DESC`, [userId], ) - return (rows as Array<{ id: string; key_name: string; enabled: number; updated_at: number }>).map((r) => ({ - id: r.id, + return (rows as CredentialRow[]).map(toCredentialKey) + } + + async listEnabledCredentialKeys(userId: string): Promise { + const { rows } = await this.pool.query( + `SELECT ${CREDENTIAL_COLS} FROM credential_vault WHERE user_id = $1 AND enabled = 1 ORDER BY updated_at DESC`, + [userId], + ) + return (rows as CredentialRow[]).map(toCredentialKey) + } + + /** 落地层专用:多返回 `secret_ref`(密文)—— 与 `listEnabledCredentialKeys` 的唯一差别。 */ + async listCredentialLandingRows(userId: string): Promise { + const { rows } = await this.pool.query( + 'SELECT key_name, route, base_url, api, models, secret_ref FROM credential_vault WHERE user_id = $1 AND enabled = 1 ORDER BY updated_at DESC', + [userId], + ) + return ( + rows as Array<{ + key_name: string + route: string | null + base_url: string | null + api: string | null + models: string | null + secret_ref: string + }> + ).map((r) => ({ name: r.key_name, - enabled: r.enabled === 1, - updatedAt: r.updated_at, + route: r.route, + baseUrl: r.base_url, + api: r.api, + models: r.models, + encryptedRef: r.secret_ref, })) } + /** + * **内置 DeepSeek 条目**的 encrypted ref(档案 87 的语义重定义)。 + * 互斥被删之后 `enabled = 1` 可能命中多行 ⇒ 必须钉死"内置 + 最新一条", + * 否则调用方会随机拿到某一个厂家的 key(详见 `repo.ts` 同名函数的注释)。 + */ async getEnabledCredentialKeyRef(userId: string): Promise { const { rows } = await this.pool.query( - 'SELECT secret_ref FROM credential_vault WHERE user_id = $1 AND enabled = 1', + "SELECT secret_ref FROM credential_vault WHERE user_id = $1 AND enabled = 1 AND (base_url IS NULL OR base_url = '') ORDER BY updated_at DESC, id DESC LIMIT 1", [userId], ) const row = rows[0] as { secret_ref: string } | undefined return row?.secret_ref ?? null } - async setCredentialKey(userId: string, name: string, encryptedRef: string): Promise { + /** Upsert by `name` 并启用它 —— **不动**其它条目(档案 87,互斥已删)。 */ + async setCredentialKey( + userId: string, + name: string, + encryptedRef: string, + meta?: CredentialKeyMeta, + ): Promise { + const route = meta?.route ?? null + const baseUrl = meta?.baseUrl ?? null + const api = meta?.api ?? null + const models = meta?.models ?? null try { return await withTx(this.pool, async (client) => { - await client.query('UPDATE credential_vault SET enabled = 0 WHERE user_id = $1', [userId]) - const existing = await client.query( - 'SELECT id FROM credential_vault WHERE user_id = $1 AND key_name = $2', - [userId, name], - ) + const existing = await client.query('SELECT id FROM credential_vault WHERE user_id = $1 AND key_name = $2', [ + userId, + name, + ]) let id: string if (existing.rows.length > 0) { id = (existing.rows[0] as { id: string }).id - await client.query('UPDATE credential_vault SET secret_ref = $1, enabled = 1, updated_at = $2 WHERE id = $3', [ - encryptedRef, - Date.now(), - id, - ]) + await client.query( + 'UPDATE credential_vault SET secret_ref = $1, route = $2, base_url = $3, api = $4, models = $5, enabled = 1, updated_at = $6 WHERE id = $7', + [encryptedRef, route, baseUrl, api, models, Date.now(), id], + ) } else { id = randomUUID() await client.query( - 'INSERT INTO credential_vault (id, user_id, key_name, secret_ref, enabled, updated_at) VALUES ($1, $2, $3, $4, 1, $5)', - [id, userId, name, encryptedRef, Date.now()], + 'INSERT INTO credential_vault (id, user_id, key_name, secret_ref, route, base_url, api, models, enabled, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 1, $9)', + [id, userId, name, encryptedRef, route, baseUrl, api, models, Date.now()], ) } - return { id, name, enabled: true, updatedAt: Date.now() } + return { id, name, enabled: true, updatedAt: Date.now(), route, baseUrl, api, models } }) } catch (e) { mapPgError(e) } } + /** 启用一个条目(档案 87:**非互斥**,不再先全关)。 */ async selectCredentialKey(userId: string, id: string): Promise { - return await withTx(this.pool, async (client) => { - await client.query('UPDATE credential_vault SET enabled = 0 WHERE user_id = $1', [userId]) - const result = await client.query('UPDATE credential_vault SET enabled = 1 WHERE id = $1 AND user_id = $2', [ - id, - userId, - ]) - return (result.rowCount ?? 0) > 0 - }) + return await this.toggleCredentialKey(userId, id, true) + } + + async toggleCredentialKey(userId: string, id: string, enabled: boolean): Promise { + const result = await this.pool.query( + 'UPDATE credential_vault SET enabled = $1, updated_at = $2 WHERE id = $3 AND user_id = $4', + [enabled ? 1 : 0, Date.now(), id, userId], + ) + return (result.rowCount ?? 0) > 0 + } + + /** 该用户是否启用「平台共享模型」(档案 87)—— 缺失行按 `true`(默认值)。 */ + async getSharedModelEnabled(userId: string): Promise { + const { rows } = await this.pool.query('SELECT shared_model_enabled FROM users WHERE id = $1', [userId]) + const row = rows[0] as { shared_model_enabled: number } | undefined + return row === undefined ? true : Number(row.shared_model_enabled) !== 0 + } + + async setSharedModelEnabled(userId: string, enabled: boolean): Promise { + const result = await this.pool.query('UPDATE users SET shared_model_enabled = $1 WHERE id = $2', [ + enabled ? 1 : 0, + userId, + ]) + return (result.rowCount ?? 0) > 0 } async deleteCredentialKey(userId: string, id: string): Promise { diff --git a/src/db/repo.ts b/src/db/repo.ts index de862c3..bca8d6c 100644 --- a/src/db/repo.ts +++ b/src/db/repo.ts @@ -21,6 +21,8 @@ import { toWorkspace, type BusinessPlugin, type CredentialKey, + type CredentialKeyMeta, + type CredentialLandingRow, type CreateSessionInput, type CreateUserInput, type Domain, @@ -211,57 +213,188 @@ export function upsertDomain(db: Database, userId: string, domain: string, nginx return findDomainByUser(db, userId)! } +/** + * 两个凭据列表共用的列清单与行映射(档案 87)—— 抽出来是为了**两处不可能漂**: + * 之前 `listCredentialKeys` 与 `listEnabledCredentialKeys` 各写一份 SELECT, + * 加一次列就要改两处,漏一处就是"一个列表有 route、另一个没有"。 + */ +const CREDENTIAL_COLS = 'id, key_name, enabled, updated_at, route, base_url, api, models' + +interface CredentialRow { + id: string + key_name: string + enabled: number + updated_at: number + route: string | null + base_url: string | null + api: string | null + models: string | null +} + +function toCredentialKey(r: CredentialRow): CredentialKey { + return { + id: r.id, + name: r.key_name, + enabled: r.enabled === 1, + updatedAt: r.updated_at, + route: r.route, + baseUrl: r.base_url, + api: r.api, + models: r.models, + } +} + /** List a user's named credential keys (metadata only). */ export function listCredentialKeys(db: Database, userId: string): CredentialKey[] { const rows = prepare( db, - 'SELECT id, key_name, enabled, updated_at FROM credential_vault WHERE user_id = ? ORDER BY updated_at DESC', - ).all(userId) as Array<{ id: string; key_name: string; enabled: number; updated_at: number }> - return rows.map((r) => ({ id: r.id, name: r.key_name, enabled: r.enabled === 1, updatedAt: r.updated_at })) + `SELECT ${CREDENTIAL_COLS} FROM credential_vault WHERE user_id = ? ORDER BY updated_at DESC`, + ).all(userId) as CredentialRow[] + return rows.map(toCredentialKey) } -/** The enabled key's encrypted ref for a user (decrypt with the deployment secret). */ +/** + * **内置 DeepSeek 条目**的 encrypted ref(档案 87 的**语义重定义**,必须读这一条)。 + * + * 老语义是「那一把启用的 key」—— 当时 `setCredentialKey` 会先 `SET enabled = 0` 全关, + * 所以 `enabled = 1` **最多一行**,不带 ORDER BY 也唯一。 + * 用户口径改成「条目各自开关、都能同时启用」后,互斥被删(见 `setCredentialKey`)⇒ + * `WHERE enabled = 1` 可能命中**多行**,而**没有 ORDER BY 就是"任取一条"** —— + * `auth.ts` 的 `keySourceOf()` 与 `server.ts` 的 `resolveApiKey()` 会因此**随机飘**。 + * + * ⇒ 这里把语义钉死为:**已启用、且是内置 DeepSeek(`base_url` 为空)的最新一条**。 + * 自定义厂家**不算**"自己的 DeepSeek key"(它们各自有自己的 ref 与 settings 段)。 + * @returns 该 ref,或 `null`(用户没有任何启用的内置条目)。 + */ export function getEnabledCredentialKeyRef(db: Database, userId: string): string | null { - const row = prepare(db, 'SELECT secret_ref FROM credential_vault WHERE user_id = ? AND enabled = 1').get(userId) as - | { secret_ref: string } - | undefined + const row = prepare( + db, + "SELECT secret_ref FROM credential_vault WHERE user_id = ? AND enabled = 1 AND (base_url IS NULL OR base_url = '') ORDER BY updated_at DESC, id DESC LIMIT 1", + ).get(userId) as { secret_ref: string } | undefined return row?.secret_ref ?? null } -/** Upsert a named key, disable the others, and enable this one. */ -export function setCredentialKey(db: Database, userId: string, name: string, encryptedRef: string): CredentialKey { +/** + * Upsert a named key by `name` and enable it —— **不动**其它条目(档案 87)。 + * + * ⚠️ 老行为是「先 `SET enabled = 0` 全关,再开这一个」(单选)。用户口径已改为 + * 「条目各自开关、都能同时启用」,所以那一行**已删**:否则用户每加一把 key, + * 别的厂家就被静默关掉。要"只开这一个"请显式先关别的(`toggleCredentialKey`)。 + * @param meta - 模型厂家元数据(route / endpoint / 协议 / 模型清单),见 {@link CredentialKeyMeta}。 + */ +export function setCredentialKey( + db: Database, + userId: string, + name: string, + encryptedRef: string, + meta: CredentialKeyMeta = {}, +): CredentialKey { + const route = meta.route ?? null + const baseUrl = meta.baseUrl ?? null + const api = meta.api ?? null + const models = meta.models ?? null const id = db.transaction(() => { - prepare(db, 'UPDATE credential_vault SET enabled = 0 WHERE user_id = ?').run(userId) const existing = prepare(db, 'SELECT id FROM credential_vault WHERE user_id = ? AND key_name = ?').get( userId, name, ) as { id: string } | undefined if (existing !== undefined) { - prepare(db, 'UPDATE credential_vault SET secret_ref = ?, enabled = 1, updated_at = ? WHERE id = ?').run( - encryptedRef, - Date.now(), - existing.id, - ) + prepare( + db, + 'UPDATE credential_vault SET secret_ref = ?, route = ?, base_url = ?, api = ?, models = ?, enabled = 1, updated_at = ? WHERE id = ?', + ).run(encryptedRef, route, baseUrl, api, models, Date.now(), existing.id) return existing.id } - const id = randomUUID() + const newId = randomUUID() prepare( db, - 'INSERT INTO credential_vault (id, user_id, key_name, secret_ref, enabled, updated_at) VALUES (?, ?, ?, ?, 1, ?)', - ).run(id, userId, name, encryptedRef, Date.now()) - return id + 'INSERT INTO credential_vault (id, user_id, key_name, secret_ref, route, base_url, api, models, enabled, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?)', + ).run(newId, userId, name, encryptedRef, route, baseUrl, api, models, Date.now()) + return newId })() - return { id, name, enabled: true, updatedAt: Date.now() } + return { id, name, enabled: true, updatedAt: Date.now(), route, baseUrl, api, models } } -/** Enable one of a user's named keys (disabling the others). */ +/** + * 启用某一个条目(档案 87:**改为非互斥**)。 + * + * 老语义是「单选」:先 `SET enabled = 0` 把该用户所有条目关掉,再开这一个。 + * 用户口径改成「各自开关、可同时启用」之后,那一步会**悄悄关掉别的已启用条目** + * (用户看不出为什么换了个厂家另一个就不生效了),所以这里退化成 + * `toggleCredentialKey(id, true)` 的同义实现 —— **保留函数名只为接口兼容**。 + */ export function selectCredentialKey(db: Database, userId: string, id: string): boolean { - const ok = db.transaction(() => { - prepare(db, 'UPDATE credential_vault SET enabled = 0 WHERE user_id = ?').run(userId) - const info = prepare(db, 'UPDATE credential_vault SET enabled = 1 WHERE id = ? AND user_id = ?').run(id, userId) - return info.changes > 0 - })() - return ok as boolean + return toggleCredentialKey(db, userId, id, true) +} + +/** + * 打开/关闭**单个**条目(档案 87;用户口径:条目各自开关、可同时启用)。 + * 与 `selectCredentialKey`(名字带"单选"但已改为非互斥)的差别:这里**只碰这一行**。 + * @returns 是否命中了该用户下的这一行(false = 不存在或不属于他)。 + */ +export function toggleCredentialKey(db: Database, userId: string, id: string, enabled: boolean): boolean { + const info = prepare(db, 'UPDATE credential_vault SET enabled = ?, updated_at = ? WHERE id = ? AND user_id = ?').run( + enabled ? 1 : 0, + Date.now(), + id, + userId, + ) + return info.changes > 0 +} + +/** 该用户**所有已启用**的条目(含 route / base_url / api / models)—— spawn 时按它们写实例配置。 */ +export function listEnabledCredentialKeys(db: Database, userId: string): CredentialKey[] { + const rows = prepare( + db, + `SELECT ${CREDENTIAL_COLS} FROM credential_vault WHERE user_id = ? AND enabled = 1 ORDER BY updated_at DESC`, + ).all(userId) as CredentialRow[] + return rows.map(toCredentialKey) +} + +/** + * 该用户**所有已启用**的条目 + 各自 encrypted ref —— **仅供落地层**(档案 87)。 + * 与 `listEnabledCredentialKeys` 的差别只有一个:多返回 `secret_ref`。 + * 单独一个函数是为了让"密文"这件事**不可能**顺着 `/api/me/keys` 漏出去。 + */ +export function listCredentialLandingRows(db: Database, userId: string): CredentialLandingRow[] { + const rows = prepare( + db, + 'SELECT key_name, route, base_url, api, models, secret_ref FROM credential_vault WHERE user_id = ? AND enabled = 1 ORDER BY updated_at DESC', + ).all(userId) as Array<{ + key_name: string + route: string | null + base_url: string | null + api: string | null + models: string | null + secret_ref: string + }> + return rows.map((r) => ({ + name: r.key_name, + route: r.route, + baseUrl: r.base_url, + api: r.api, + models: r.models, + encryptedRef: r.secret_ref, + })) +} + +/** + * 该用户是否启用「平台共享模型」(档案 87)—— **用户侧偏好**,开关它**不动** admin 的配置。 + * + * 缺失行一律按 `true` 处理:V6 迁移给所有老用户填了默认 1,而"查不到这个人"时 + * 也不该因为一个开关把共享 key 断掉(宁可多给,不可少给)。 + */ +export function getSharedModelEnabled(db: Database, userId: string): boolean { + const row = prepare(db, 'SELECT shared_model_enabled FROM users WHERE id = ?').get(userId) as + | { shared_model_enabled: number } + | undefined + return row === undefined ? true : row.shared_model_enabled !== 0 +} + +/** 打开/关闭「平台共享模型」(档案 87)。@returns 是否命中该用户。 */ +export function setSharedModelEnabled(db: Database, userId: string, enabled: boolean): boolean { + const info = prepare(db, 'UPDATE users SET shared_model_enabled = ? WHERE id = ?').run(enabled ? 1 : 0, userId) + return info.changes > 0 } /** Delete a named key (by id, scoped to the user). */ diff --git a/src/db/schema.ts b/src/db/schema.ts index c85f2f8..711a568 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -258,6 +258,40 @@ CREATE TABLE IF NOT EXISTS business_plugins ( ); ` +// v6: 用户自配「模型厂家」支持(档案 87)。 +// 原先 `credential_vault` 只存一把 key(`key_name` 兼作展示名);用户要按**厂家**配模型, +// 平台还需要知道:**route**(= settings.yaml 里 `llm-pi-ai.providers` 的 dict 键)、 +// **endpoint**(`baseURL`)、**协议**(`api`)、**模型清单**(`models`)—— spawn 时按这四样写 +// `$DSH_HOME/settings.yaml` 的 `llm-pi-ai.providers.` 与 +// `$DSH_HOME/.credentials.yaml` 的 `refs.`。 +// · `base_url` 为空 = **内置 DeepSeek**(只写 refs,不写 settings.yaml)。 +// · REF 命名:内置 = `DEEPSEEK_API_KEY`;自定义 = `_API_KEY` +// (须匹配 `@deepseek-ai/dsh-credentials` 的 `REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/`)。 +// · `api` 的合法值只有三个(`dsh-llm-pi-ai` 的 `PROTOCOLS` 键,顺序即默认优先级): +// `openai-completions` / `openai-responses` / `anthropic-messages`。 +// · ⚠️ 字段名是 **`api` 不是 `protocol`**;`apiKeyEnv`(不是 `apiKey`);且该 profile +// **不接受** `provider` / `maxRetries` / `maxRetryDelayMs`(会直接抛错)。 +// 以上全部为 2026-09-13 读官方包 `dsh-llm-pi-ai@0.1.2-rc.1` 的 `lib/index.js` +// (`const NS = "llm-pi-ai"` / `profile` schema / `PROTOCOLS`)实测结论。 +// 另:`users.shared_model_enabled` = 用户**要不要用平台共享模型**(admin 配的那把)—— +// 属于用户侧偏好,开关它**不动** admin 的配置(用户口径:条目各自开关,都能同时启用; +// 具体用哪个模型是在 dsh 对话框的模型选择器里挑)。 +const SQLITE_V6 = ` +ALTER TABLE credential_vault ADD COLUMN route TEXT; +ALTER TABLE credential_vault ADD COLUMN base_url TEXT; +ALTER TABLE credential_vault ADD COLUMN api TEXT; +ALTER TABLE credential_vault ADD COLUMN models TEXT; +ALTER TABLE users ADD COLUMN shared_model_enabled INTEGER NOT NULL DEFAULT 1; +` + +const PG_V6 = ` +ALTER TABLE credential_vault ADD COLUMN route TEXT; +ALTER TABLE credential_vault ADD COLUMN base_url TEXT; +ALTER TABLE credential_vault ADD COLUMN api TEXT; +ALTER TABLE credential_vault ADD COLUMN models TEXT; +ALTER TABLE users ADD COLUMN shared_model_enabled INTEGER NOT NULL DEFAULT 1; +` + interface Migration { version: number name: string @@ -271,6 +305,7 @@ const MIGRATIONS: readonly Migration[] = [ { version: 3, name: 'per-user uid', sqlite: SQLITE_V3, pg: PG_V3 }, { version: 4, name: 'instance desired state', sqlite: SQLITE_V4, pg: PG_V4 }, { version: 5, name: 'business plugin candidate pool', sqlite: SQLITE_V5, pg: PG_V5 }, + { version: 6, name: 'user model providers', sqlite: SQLITE_V6, pg: PG_V6 }, ] /** Apply unapplied SQLite migrations inside a single transaction. */ diff --git a/src/db/sqlite.ts b/src/db/sqlite.ts index 152b894..9449a74 100644 --- a/src/db/sqlite.ts +++ b/src/db/sqlite.ts @@ -39,9 +39,12 @@ import { getEnabledCredentialKeyRef as getEnabledCredentialKeyRefSync, getEnabledPluginIds as getEnabledPluginIdsSync, getOrCreateWorkspace as getOrCreateWorkspaceSync, + getSharedModelEnabled as getSharedModelEnabledSync, listBusinessPlugins as listBusinessPluginsSync, listCredentialKeys as listCredentialKeysSync, + listCredentialLandingRows as listCredentialLandingRowsSync, listDomains as listDomainsSync, + listEnabledCredentialKeys as listEnabledCredentialKeysSync, listInstancesByRole as listInstancesByRoleSync, listPublicUsers as listPublicUsersSync, listUsersWithoutUid as listUsersWithoutUidSync, @@ -50,8 +53,10 @@ import { setDomainVerified as setDomainVerifiedSync, setFolderPlugins as setFolderPluginsSync, setInstanceStatus as setInstanceStatusSync, + setSharedModelEnabled as setSharedModelEnabledSync, setUserRole as setUserRoleSync, setUserUid as setUserUidSync, + toggleCredentialKey as toggleCredentialKeySync, upsertBusinessPlugin as upsertBusinessPluginSync, upsertDomain as upsertDomainSync, upsertInstance as upsertInstanceSync, @@ -59,6 +64,8 @@ import { import type { BusinessPlugin, CredentialKey, + CredentialKeyMeta, + CredentialLandingRow, CreateSessionInput, CreateUserInput, Domain, @@ -229,13 +236,26 @@ export class SqliteAdapter implements DbAdapter { return listCredentialKeysSync(this.db, userId) } + async listEnabledCredentialKeys(userId: string): Promise { + return listEnabledCredentialKeysSync(this.db, userId) + } + + async listCredentialLandingRows(userId: string): Promise { + return listCredentialLandingRowsSync(this.db, userId) + } + async getEnabledCredentialKeyRef(userId: string): Promise { return getEnabledCredentialKeyRefSync(this.db, userId) } - async setCredentialKey(userId: string, name: string, encryptedRef: string): Promise { + async setCredentialKey( + userId: string, + name: string, + encryptedRef: string, + meta?: CredentialKeyMeta, + ): Promise { try { - return setCredentialKeySync(this.db, userId, name, encryptedRef) + return setCredentialKeySync(this.db, userId, name, encryptedRef, meta) } catch (e) { mapSqliteError(e) } @@ -245,6 +265,18 @@ export class SqliteAdapter implements DbAdapter { return selectCredentialKeySync(this.db, userId, id) } + async toggleCredentialKey(userId: string, id: string, enabled: boolean): Promise { + return toggleCredentialKeySync(this.db, userId, id, enabled) + } + + async getSharedModelEnabled(userId: string): Promise { + return getSharedModelEnabledSync(this.db, userId) + } + + async setSharedModelEnabled(userId: string, enabled: boolean): Promise { + return setSharedModelEnabledSync(this.db, userId, enabled) + } + async deleteCredentialKey(userId: string, id: string): Promise { return deleteCredentialKeySync(this.db, userId, id) } diff --git a/src/db/types.ts b/src/db/types.ts index 151d8c9..bc76a8f 100644 --- a/src/db/types.ts +++ b/src/db/types.ts @@ -98,6 +98,58 @@ export interface CredentialKey { name: string enabled: boolean updatedAt: number + /** + * settings.yaml 里 `llm-pi-ai.providers` 的 **dict 键**(档案 87)。内置 DeepSeek 条目 + * 可用 `deepseek`;自定义厂家由用户给(平台按名字生成 slug 作为默认值)。 + * ⚠️ 不是「厂家名」,而是**寻址键** —— 改名不影响它,所以平台把它显式存下来。 + */ + route?: string | null + /** + * 自定义厂家的 endpoint(档案 87)。`null` = **内置 DeepSeek**(此时不写 settings.yaml, + * 只把 key 落到 `refs.DEEPSEEK_API_KEY`);非空 = 用户声明的 OpenAI 兼容网关, + * 平台会写 `settings.yaml` 的 `llm-pi-ai.providers.`(`baseURL` + `api` + `models`)。 + */ + baseUrl?: string | null + /** + * 该 route 的**线协议**(档案 87)—— settings.yaml 里字段名是 **`api`**。 + * 合法值只有 `openai-completions` / `openai-responses` / `anthropic-messages` + * (官方 `dsh-llm-pi-ai` 的 `PROTOCOLS` 键;空 = 取默认 `openai-completions`)。 + */ + api?: string | null + /** 该厂家下的模型 id 清单(JSON 数组字符串;自定义厂家必填,内置厂家可为空)。 */ + models?: string | null +} + +/** + * 写入一条凭据时可带的**模型厂家元数据**(档案 87)。 + * 全部可空:只给 `name` + `encryptedRef` 时就是老语义的「内置 DeepSeek 那一把 key」。 + */ +export interface CredentialKeyMeta { + /** settings.yaml 里 `llm-pi-ai.providers` 的 dict 键;空 = 内置 DeepSeek。 */ + route?: string | null + /** 自定义厂家的 endpoint;空 = 内置(只写 `.credentials.yaml`,不写 settings.yaml)。 */ + baseUrl?: string | null + /** 线协议(settings.yaml 的 `api`);空 = 官方默认 `openai-completions`。 */ + api?: string | null + /** 模型 id 的 JSON 数组字符串。 */ + models?: string | null +} + +/** + * **落地层专用**:一条已启用条目 + 它的 encrypted ref(档案 87)。 + * + * 为什么单独一个类型:{@link CredentialKey} 会被 `/api/me/keys` **原样返回给浏览器**, + * 所以它刻意不含密文;而 `server.ts` 要把 key 写进实例的 `.credentials.yaml`, + * 必须要密文(用部署密钥解出来)。两件事的受众不同 ⇒ 两个类型,别合并。 + */ +export interface CredentialLandingRow { + name: string + route: string | null + baseUrl: string | null + api: string | null + models: string | null + /** 部署密钥加密后的 ref(`decrypt()` 之后才是明文 key)。 */ + encryptedRef: string } /** A business-plugin (系统外插件) candidate-pool row. `id` = bundle package name. */ diff --git a/src/web/model-landing.ts b/src/web/model-landing.ts new file mode 100644 index 0000000..323106f --- /dev/null +++ b/src/web/model-landing.ts @@ -0,0 +1,365 @@ +/** + * 模型条目的**落地层**(档案 87):把平台凭据库里「已启用」的条目写进实例的两个配置文件。 + * + * 为什么必须有这一层:官方「设置 → 模型」页在平台环境**必然报错** —— 该页要求 Host settings + * 镜像,而 `dsh-client-ui-settings` 的持久化判定是 + * `isLoopback = transport.ownsHost || pageLocation === undefined || isLoopbackHostname(page)`, + * 平台是「浏览器经域名访问远程服务器」⇒ 三条皆不成立 ⇒ persistence 降级为 `memory` + * ⇒ `ensure()` 直接返回不读 ⇒ 页面必报「加载提供方目录失败」。详见 + * `ensure-role-profile-patch.cjs` 的 `DISABLE_MODELS_BLOCK` / `ADMIN_MODELS_BLOCK` 注释。 + * ⇒ 用户自配模型只能由**平台自己写文件**。 + * + * 落点两处(字段名均为 2026-09-13 读官方包 `dsh-llm-pi-ai@0.1.2-rc.1` 实测,勿凭记忆改): + * + * 1. `$DSH_HOME/.credentials.yaml` → `refs.: ''` + * (REF 须匹配 `@deepseek-ai/dsh-credentials` 的 `REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/`) + * 2. `$DSH_HOME/settings.yaml` → 顶层 `llm-pi-ai:` → `providers.` → + * `apiKeyEnv` / `baseURL` / `api` / `models` + * ⚠️ 是 **`api`**(不是 `protocol`)、是 **`apiKeyEnv`**(不是 `apiKey`); + * 该 profile **不接受** `provider` / `maxRetries` / `maxRetryDelayMs`(会直接抛错); + * `api` 的合法值只有 `openai-completions` / `openai-responses` / `anthropic-messages`。 + * + * 本模块**只做纯文本变换**(无 IO、无 db、无 crypto)⇒ 可以被 + * `scripts/verify-model-landing.mjs` 用固定样例逐条断言。这类"改写别人家配置文件"的逻辑 + * 最怕没有回归网:它错了不会报错,只会让实例静默少一个厂家。 + * + * 安全约束(与既有 `ensureRefInCredentials` 同族,每条都是踩出来的): + * · 只认 `version: 1` 的凭据文档;认不出的布局**宁可不动**; + * · 平台**只管自己写过的**(`managed` 清单):用户自己放的 ref / 厂家段**绝不覆盖、绝不删**; + * · 要"删掉"时只删平台自己的:凭据=我们自己写的那一行,settings=我们自己那对标记之间; + * · 内联样式(`baseURL: ...`)而不是 JSON 块,避免把用户的其它字段卷进重排。 + * + * @module dshs/web/model-landing + */ + +/** 官方凭据 ref 名的语法(`dsh-credentials` 的 `REF_PATTERN`)。 */ +const REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/ + +/** 内置 DeepSeek 条目的 ref(官方与平台既有实现共同约定的名字)。 */ +export const BUILTIN_REF = 'DEEPSEEK_API_KEY' + +/** `settings.yaml` 里那个用户设置分区的名字(官方 `const NS = "llm-pi-ai"`)。 */ +export const PI_AI_NS = 'llm-pi-ai' + +/** 官方支持的线协议(`dsh-llm-pi-ai` 的 `PROTOCOLS` 键,顺序即默认优先级)。 */ +export const PROTOCOLS = ['openai-completions', 'openai-responses', 'anthropic-messages'] as const +export type Protocol = (typeof PROTOCOLS)[number] + +/** 一条「已启用」条目落地所需的全部信息(`value` 已是解密后的明文 key)。 */ +export interface LandingEntry { + /** 展示名(只用于注释与日志)。 */ + name: string + /** settings.yaml 的 `providers` dict 键;内置条目可为空。 */ + route: string | null + /** 自定义厂家 endpoint;空 = 内置 DeepSeek。 */ + baseUrl: string | null + /** 线协议;空 = `openai-completions`。 */ + api: string | null + /** 模型 id 清单。 */ + models: string[] + /** 解密后的 key 明文。 */ + value: string +} + +/** `reconcile*` 的返回:新文本 + 这一轮之后仍归平台管的键。 */ +export interface ReconcileResult { + text: string + managed: string[] +} + +/** + * 自定义厂家的 ref 名:`_API_KEY`。 + * 结果**一定**匹配 `REF_PATTERN`(首字符强制成字母)—— 否则 dsh 解析凭据时会直接不认, + * 而且是静默的"这条 ref 不存在",排查成本极高。 + */ +export function routeRef(route: string): string { + let up = (route ?? '').toUpperCase().replace(/[^A-Z0-9]/g, '_').replace(/^_+|_+$/g, '') + if (up === '' || !/^[A-Z]/.test(up)) up = 'X' + up + return `${up}_API_KEY` +} + +/** 该条目用哪个 ref:内置 ⇒ `DEEPSEEK_API_KEY`;自定义 ⇒ `routeRef(route)`。 */ +export function refForEntry(entry: Pick): string { + return entry.baseUrl === null || entry.baseUrl === '' ? BUILTIN_REF : routeRef(entry.route ?? 'custom') +} + +/** 协议取值规范化:认不出的一律回落官方默认(第一个),不抛错、不写坏配置。 */ +export function normalizeProtocol(api: string | null | undefined): Protocol { + return (PROTOCOLS as readonly string[]).includes(api ?? '') ? (api as Protocol) : PROTOCOLS[0] +} + +/** YAML 单引号标量转义(`'` → `''`)—— 避免 key 里的引号把文档弄坏。 */ +function yamlSingle(value: string): string { + return `'${value.replace(/'/g, "''")}'` +} + +function escapeRe(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +/** + * 把「已启用条目」对账进 `.credentials.yaml`。 + * + * @param text - 现有文件内容(空串 = 文件不存在)。 + * @param desired - 期望存在的 `{ ref, value }`。 + * @param managed - **平台自己写过**的 ref 清单(上一次的返回值)。只有这里的 ref 允许被改写/删除。 + * @returns 新文本 + 新的 managed 清单。认不出的布局会原样返回(宁可不动)。 + */ +export function reconcileCredentials( + text: string, + desired: readonly { ref: string; value: string }[], + managed: readonly string[] = [], +): ReconcileResult { + const owned = new Set(managed) + const wanted = new Map(desired.map((d) => [d.ref, d.value])) + + // 文件不存在 / 空 ⇒ 从零建一个最小合法文档(与既有 ensureRefInCredentials 同款)。 + if (text.trim() === '') { + if (desired.length === 0) return { text, managed: [] } + const body = desired.map((d) => ` ${d.ref}: ${yamlSingle(d.value)}`).join('\n') + return { text: `version: 1\nrefs:\n${body}\n`, managed: desired.map((d) => d.ref) } + } + + const lines = text.split('\n') + const insideRefs: boolean[] = [] + let refsAt = -1 + let versionAt = -1 + let inside = false + for (let i = 0; i < lines.length; i++) { + const raw = lines[i] + const indented = /^[ \t]/.test(raw) + inside = indented ? inside : raw.trim() === 'refs:' + if (!indented && raw.trim() === 'refs:' && refsAt < 0) refsAt = i + if (!indented && versionAt < 0 && /^version:[ \t]*1[ \t]*$/.test(raw.trim())) versionAt = i + insideRefs.push(inside && indented) + } + + const refAt = new Map() + for (let i = 0; i < lines.length; i++) { + if (!insideRefs[i]) continue + const m = /^[ \t]+([A-Za-z_][A-Za-z0-9_]*)[ \t]*:/.exec(lines[i]) + if (m !== null) refAt.set(m[1], i) + } + + const drop = new Set() + const kept: string[] = [] + const setLine: Array<{ at: number; ref: string; value: string }> = [] + const add: Array<{ ref: string; value: string }> = [] + + for (const ref of owned) { + if (wanted.has(ref)) { + kept.push(ref) + continue + } + const at = refAt.get(ref) + if (at !== undefined) drop.add(at) // 用户关掉了 ⇒ 把平台自己写的那行撤掉 + } + for (const [ref, value] of wanted) { + const at = refAt.get(ref) + if (at === undefined) { + add.push({ ref, value }) + kept.push(ref) + continue + } + if (!owned.has(ref)) continue // 文件里有、但不是平台写的 ⇒ 用户自己的,绝不碰 + setLine.push({ at, ref, value }) + kept.push(ref) + } + + const inserted = new Map() + if (add.length > 0) { + const anchor = refsAt >= 0 ? refsAt : versionAt + if (anchor < 0) return { text, managed: [...owned] } // 认不出布局 ⇒ 宁可不动 + const body = add.map((d) => ` ${d.ref}: ${yamlSingle(d.value)}`) + inserted.set(anchor, refsAt >= 0 ? body : ['refs:', ...body]) + } + + const out: string[] = [] + for (let i = 0; i < lines.length; i++) { + if (!drop.has(i)) { + const s = setLine.find((d) => d.at === i) + out.push(s === undefined ? lines[i] : ` ${s.ref}: ${yamlSingle(s.value)}`) + } + const extra = inserted.get(i) + if (extra !== undefined) out.push(...extra) + } + return { text: out.join('\n'), managed: [...new Set(kept)] } +} + +/** 一条要写进 `settings.yaml` 的厂家声明。 */ +export interface SettingsEntry { + route: string + apiKeyEnv: string + baseURL: string + api: Protocol + models: string[] +} + +const markBegin = (route: string): string => ` # dshs:model-route ${route} begin` +const markEnd = (route: string): string => ` # dshs:model-route ${route} end` + +/** + * 把厂家声明对账进 `settings.yaml`。 + * + * 用**成对标记**夹住平台自己写的那个 route 段:① 不解析别人的 YAML(不重排、不丢注释); + * ② "关掉某个厂家"就是删掉自己那对标记之间的内容 ⇒ 精确可控。 + * 代价:若 dsh 或用户某次把文件整体重写、标记丢了,就再也删不掉那个 route + * (但"已存在"检查仍会拦住重复写入,所以最坏是留一条模型清单里的僵尸厂家,不会写坏配置)。 + * + * @param text - 现有文件内容(空串 = 文件不存在)。 + * @param desired - 期望存在的厂家声明。 + * @param managed - 平台自己写过的 route 清单。 + */ +export function reconcileSettings( + text: string, + desired: readonly SettingsEntry[], + managed: readonly string[] = [], +): ReconcileResult { + const owned = new Set(managed) + const wanted = new Map(desired.map((d) => [d.route, d])) + + // ① 先删:不再需要的、且是我们自己写的块。 + const lines = text === '' ? [] : text.split('\n') + const kept: string[] = [] + const present = new Set() + for (let i = 0; i < lines.length; i++) { + const b = /^[ \t]*# dshs:model-route ([^\s]+) begin[ \t]*$/.exec(lines[i]) + if (b === null) { + // 非标记行里如果已经有 ` :`(可能是用户/官方自己写的)⇒ 记为"已存在",不重复写。 + const k = /^[ \t]{4}([^\s:#][^\s:]*)[ \t]*:[ \t]*$/.exec(lines[i]) + if (k !== null) present.add(k[1]) + kept.push(lines[i]) + continue + } + const route = b[1] + const endRe = new RegExp(`^[ \\t]*# dshs:model-route ${escapeRe(route)} end[ \\t]*$`) + let end = -1 + for (let j = i + 1; j < lines.length; j++) { + if (endRe.test(lines[j])) { + end = j + break + } + } + if (wanted.has(route) || !owned.has(route)) { + // 还要留着(或不是我们的,不该动)⇒ 原样搬过去。 + present.add(route) + if (end < 0) kept.push(lines[i]) + else { + kept.push(...lines.slice(i, end + 1)) + i = end + } + continue + } + // 用户已关掉这个厂家 ⇒ 整块丢掉(这就是"删")。 + i = end < 0 ? i : end + } + + const add = desired.filter((d) => !present.has(d.route)) + const newOwned = [...new Set([...owned].filter((r) => wanted.has(r) || present.has(r)))] + if (add.length === 0) return { text: kept.join('\n'), managed: newOwned } + + // ② 找 `llm-pi-ai:` → `providers:` 链,缺什么补什么,然后在 `providers:` 之后插入。 + // ⚠️ 这里**不能**用"只看顶层行"的循环:`providers:` 本身就是缩进 2 的(它是 + // `llm-pi-ai:` 的子键)。第一版就是这么写的 ⇒ 找不到既有 providers ⇒ 又补一行 + // ` providers:` ⇒ 文档里出现**两个**同键(回归 [8]/[9] 当场抓到)。 + let nsAt = -1 + for (let i = 0; i < kept.length; i++) { + if (/^[ \t]/.test(kept[i])) continue + if (kept[i].trim() === `${PI_AI_NS}:`) { + nsAt = i + break + } + } + let provAt = -1 + if (nsAt >= 0) { + for (let i = nsAt + 1; i < kept.length; i++) { + const raw = kept[i] + if (!/^[ \t]/.test(raw)) { + if (raw.trim() !== '') break // 撞到下一个顶层键 ⇒ 该分区到此为止 + continue // 分区里的空行不算结束 + } + if (raw.trim() === 'providers:') { + provAt = i + break + } + } + } + + const blocks = add.flatMap((d) => [ + markBegin(d.route), + ` ${d.route}:`, + ` apiKeyEnv: ${d.apiKeyEnv}`, + ` baseURL: ${d.baseURL}`, + ` api: ${d.api}`, + ' models:', + ...d.models.map((m) => ` - id: ${m}`), + markEnd(d.route), + ]) + + const out = [...kept] + if (provAt >= 0) { + out.splice(provAt + 1, 0, ...blocks) + } else if (nsAt >= 0) { + out.splice(nsAt + 1, 0, ' providers:', ...blocks) + } else { + // 整个 `llm-pi-ai:` 分区都不在 ⇒ 追加到文件末尾(顶层键,顺序无关)。 + if (out.length > 0 && out[out.length - 1] === '') out.splice(out.length - 1, 0, `${PI_AI_NS}:`, ' providers:', ...blocks, '') + else out.push(`${PI_AI_NS}:`, ' providers:', ...blocks) + } + return { text: out.join('\n'), managed: [...new Set([...newOwned, ...add.map((d) => d.route)])] } +} + +/** + * 读出 `.credentials.yaml` 里某个 ref 的**当前值**(没有该 ref 时 `null`)。 + * + * 用途只有一个:**一次性交接**(档案 87)。老实现把平台共享 key 直接写进 + * `refs.DEEPSEEK_API_KEY`,但那时没有托管清单 ⇒ 新逻辑会把它当成"用户自己写的"而永不清理 + * ⇒ 用户关掉共享开关后那个 key 仍然留在文件里("关掉即生效"就不成立)。 + * 所以首次运行时要**认领**该 ref,但只在那行确实等于平台共享 key 的明文时才认领 + * —— 否则就是用户自己配的,绝不碰。 + * @param text - `.credentials.yaml` 内容。 + * @param ref - 要读的 ref 名。 + * @returns 去掉引号后的值,或 `null`。 + */ +export function readRefValue(text: string, ref: string): string | null { + if (text === '') return null + const lines = text.split('\n') + let inside = false + for (const raw of lines) { + const indented = /^[ \t]/.test(raw) + inside = indented ? inside : raw.trim() === 'refs:' + if (!inside || !indented) continue + const m = new RegExp(`^[ \\t]+${ref}[ \\t]*:[ \\t]*(.*)$`).exec(raw) + if (m === null) continue + const v = m[1].trim() + if (v.length >= 2 && ((v.startsWith("'") && v.endsWith("'")) || (v.startsWith('"') && v.endsWith('"')))) { + return v.slice(1, -1).replace(/''/g, "'") + } + return v + } + return null +} + +/** + * 把 `models` 列(JSON 数组字符串)解析成 id 清单。 + * 宽容:非数组、非字符串项、超量一律丢弃 —— 这条路上宁可少写一个模型, + * 也不能让一个坏值把整个 `settings.yaml` 变成 dsh 拒绝加载的文档。 + */ +export function parseModels(json: string | null | undefined, limit = 50): string[] { + if (json === null || json === undefined) return [] + try { + const raw: unknown = JSON.parse(json) + if (!Array.isArray(raw)) return [] + const out: string[] = [] + for (const item of raw) { + if (typeof item !== 'string') continue + const v = item.trim() + if (v === '' || v.length > 128) continue + if (!out.includes(v)) out.push(v) + if (out.length >= limit) break + } + return out + } catch { + return [] + } +} diff --git a/src/web/routes/admin-user-ops.ts b/src/web/routes/admin-user-ops.ts new file mode 100644 index 0000000..c2a3257 --- /dev/null +++ b/src/web/routes/admin-user-ops.ts @@ -0,0 +1,165 @@ +/** + * Admin 视角的「用户服务 / 工作区文件」路由(档案 86)。 + * + * 背景:平台所有服务与文件 API 都是 `request.user.id` 语义(`desktop.ts` 头注释更明确写着 + * "one user can never address another user's files")⇒ admin 在「设置 → 系统管理 → 服务管理」 + * 里**只能管自己**。用户要求「admin 要能管所有用户的服务」。 + * + * 做法:**不动既有路由的语义**(避免把越界风险塞进普通用户路径),另开一组 + * `/api/admin/users/:id/...`,全部 `requireAdmin`,把 `request.user.id` 换成路径参数。 + * 底层 `UserFs` / `Spawner` 本来就都接受 `userId` 首参 ⇒ 零新增能力面; + * 启动/状态复用 `dsh.ts` 导出的 `launchForUser` / `statusForUser`(一份实现,两处入口)。 + * + * ⚠️ R5 权限影响评估:新增的是 **admin 对任意用户**的 + * ① 浏览 / 新建 / 上传其工作区文件 —— 仍限定在该用户 ws 根内(`UserFs` 自带逃逸防护, + * 越界即 `bad_path`) + * ② 启停其 DSH 实例 —— 与用户自己点「启动 / 停止」同一条 `supervisor` 路径 + * 这与 `requireAdmin` 既有职能(审批 / 禁用 / 删除用户)同级;服务器层面 admin 本就能读 + * `/var/lib/dshs/users/**`。**不扩大普通用户的能力面** —— 这些前缀下没有任何 `requireAuth` 版本。 + * @module dshs/web/routes/admin-user-ops + */ + +import type { FastifyPluginAsync, FastifyReply } from 'fastify' +import { requireAdmin } from '../middleware/authn.js' +import { sendFsError } from './desktop.js' +import { AlreadyRunningError, CrashBreakerOpenError } from '../../supervisor/orchestrator.js' +import { dshUrl, launchForUser, sendBreakerOpen, statusForUser } from './dsh.js' + +// 与 desktop.ts / dsh.ts 的同名 schema 同形(那两处未导出,这里按同一形状内联)。 +const createSchema = { + body: { + type: 'object', + required: ['path', 'name', 'type'], + additionalProperties: false, + properties: { + path: { type: 'string', maxLength: 512 }, + name: { type: 'string', maxLength: 255 }, + type: { type: 'string', enum: ['file', 'dir'] }, + }, + }, +} as const + +const uploadSchema = { + body: { + type: 'object', + required: ['path', 'name', 'data'], + additionalProperties: false, + properties: { + path: { type: 'string', maxLength: 512 }, + name: { type: 'string', maxLength: 255 }, + data: { type: 'string' }, + }, + }, +} as const + +const launchSchema = { + body: { + type: 'object', + required: ['folder'], + additionalProperties: false, + properties: { folder: { type: 'string', maxLength: 512 } }, + }, +} as const + +export const adminUserOpsRoutes: FastifyPluginAsync = async (app) => { + /** + * 解析 `:id` 指向的用户;不存在则回 404 并返回 `undefined`。 + * 每个路由都过这一关 —— 防 `:id` 乱填导致 `UserFs` 在错误根上操作。 + */ + async function targetOr404(id: string, reply: FastifyReply) { + const user = await app.db.findUserById(id) + if (user === undefined) { + reply.code(404).send({ error: 'not_found' }) + return undefined + } + return user + } + + // ── 工作区文件 ────────────────────────────────────────────────────────────── + app.get('/api/admin/users/:id/fs/tree', { preHandler: requireAdmin }, async (request, reply) => { + const { id } = request.params as { id: string } + const { path = '' } = request.query as { path?: string } + if ((await targetOr404(id, reply)) === undefined) return + try { + return { path, entries: await app.userFs.listDir(id, path) } + } catch (err) { + return sendFsError(reply, err) + } + }) + + app.post( + '/api/admin/users/:id/fs/create', + { preHandler: requireAdmin, schema: createSchema }, + async (request, reply) => { + const { id } = request.params as { id: string } + const { path, name, type } = request.body as { path: string; name: string; type: 'file' | 'dir' } + if ((await targetOr404(id, reply)) === undefined) return + try { + return { ok: true, name: await app.userFs.createEntry(id, path, name, type), type } + } catch (err) { + return sendFsError(reply, err) + } + }, + ) + + app.post( + '/api/admin/users/:id/fs/upload', + { preHandler: requireAdmin, schema: uploadSchema }, + async (request, reply) => { + const { id } = request.params as { id: string } + const { path, name, data } = request.body as { path: string; name: string; data: string } + if ((await targetOr404(id, reply)) === undefined) return + let buf: Buffer + try { + buf = Buffer.from(data, 'base64') + } catch { + return reply.code(400).send({ error: 'bad_data' }) + } + try { + return { ok: true, name: await app.userFs.upload(id, path, name, buf) } + } catch (err) { + return sendFsError(reply, err) + } + }, + ) + + // ── 实例启停与状态 ───────────────────────────────────────────────────────── + app.get('/api/admin/users/:id/dsh/status', { preHandler: requireAdmin }, async (request, reply) => { + const { id } = request.params as { id: string } + const user = await targetOr404(id, reply) + if (user === undefined) return + return statusForUser(app, user) + }) + + app.post( + '/api/admin/users/:id/dsh/launch', + { preHandler: requireAdmin, schema: launchSchema }, + async (request, reply) => { + const { id } = request.params as { id: string } + const { folder } = request.body as { folder: string } + const user = await targetOr404(id, reply) + if (user === undefined) return + let instance + try { + instance = await launchForUser(app, id, folder) + } catch (err) { + if (err instanceof AlreadyRunningError) return reply.code(409).send({ error: 'already_running' }) + if (err instanceof CrashBreakerOpenError) return sendBreakerOpen(reply, err) + return sendFsError(reply, err) + } + if (instance === null) return reply.code(400).send({ error: 'not_a_folder' }) + return { + instance: { id: instance.id, port: instance.port, status: instance.status, launchToken: instance.launchToken }, + // ⚠️ 打开的是**该用户**实例的带 token URL —— admin 用它即可直接进去看(同 `dshUrl` 语义) + url: dshUrl(app.config.baseDomain, user, instance.launchToken), + } + }, + ) + + app.post('/api/admin/users/:id/dsh/stop', { preHandler: requireAdmin }, async (request, reply) => { + const { id } = request.params as { id: string } + if ((await targetOr404(id, reply)) === undefined) return + await app.supervisor.stop(id) + return { ok: true } + }) +} diff --git a/src/web/routes/auth.ts b/src/web/routes/auth.ts index 880c0bf..f4db18a 100644 --- a/src/web/routes/auth.ts +++ b/src/web/routes/auth.ts @@ -10,6 +10,7 @@ import { requireAuth } from '../middleware/authn.js' import { homeRoot, userRoot } from '../../fs/workspace.js' import { deriveKey, encrypt } from '../../crypto.js' import { toPublicUser } from '../../db/types.js' +import { PROTOCOLS } from '../model-landing.js' import { clearSessionCookie, hashPassword, @@ -128,41 +129,79 @@ export const authRoutes: FastifyPluginAsync = async (app) => { properties: { name: { type: 'string', minLength: 1, maxLength: 32 }, apiKey: { type: 'string', minLength: 1, maxLength: 256 }, + // 档案 87:自定义厂家三件套 —— **都不给**就是老语义的「内置 DeepSeek 那一把 key」。 + route: { type: 'string', minLength: 1, maxLength: 40 }, + baseUrl: { type: 'string', maxLength: 300 }, + api: { type: 'string', minLength: 1, maxLength: 40 }, + models: { type: 'array', maxItems: 50, items: { type: 'string', minLength: 1, maxLength: 128 } }, }, }, } as const - // ---- 模型密钥:**两层并存**(档案 85 · 2026-09-13,用户要求「配置模型密钥开放给用户自己配」)-- - // ① **用户自己的 key** —— 任何登录用户都能管理**自己那一格**(自配自用); - // ② **平台共享 key**(管理员设置的)—— 用户侧**只读可见**:没自配的人默认就用它。 - // 两层互相独立、互不覆盖:`server.ts` 的 `resolveApiKey(userId)` 先取 ①,取不到才回落 ②。 - // ⚠️ `DEEPSEEK_API_KEY` 是 **spawn 时注入 env 的快照** ⇒ 换 key 后必须重启实例才生效: - // admin 改的 key 就是共享 key ⇒ `restartAllMains()`(所有仍在回落的用户都得刷新); - // 其他人改自己的 ⇒ 只 `restartMain(自己)`,不动任何人。 + const toggleSchema = { + body: { + type: 'object', + required: ['enabled'], + additionalProperties: false, + properties: { enabled: { type: 'boolean' } }, + }, + } as const + + // ---- 模型厂家与密钥(档案 87 · 2026-09-13 第三轮口径)-------------------------- + // 用户口径(**已定,不得再拿去当选择题**): + // ① 条目**各自开关、可同时启用**(不再互斥); + // ② admin 配的**平台共享模型也列入**列表,用户可开关(`users.shared_model_enabled`); + // ③ 具体用哪个模型**在 dsh 对话框的模型选择器里选** —— 平台只负责把「已启用」的都配好。 + // ⇒ 因此**不再有**"当前生效的那一把"这种概念:`keySourceOf` 只回答"有没有自己的内置 + // DeepSeek key",供界面文案用;真正生效的是 spawn 时的落地结果(`server.ts`)。 + // ⚠️ 落地发生在 **spawn** 时 ⇒ 改完必须**重启实例**才生效,这也是这几条路由最后都要 + // `refreshAfterKeyChange` 的原因。 + + /** 展示名 → route 的默认值:英文/数字折成小写短横线;纯中文名折不出东西 ⇒ `provider`。 */ + function slugify(name: string): string { + const s = name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 40) + return s === '' || !/^[a-z0-9]/.test(s) ? 'provider' : s + } + /** 该用户当前**实际生效**的密钥来源。 */ async function keySourceOf(userId: string): Promise<'own' | 'shared' | 'none'> { if ((await app.db.getEnabledCredentialKeyRef(userId)) !== null) return 'own' + // 关掉了共享开关的人**就是** none —— 这正是验收③要的语义。 + if (!(await app.db.getSharedModelEnabled(userId))) return 'none' const admins = (await app.db.listPublicUsers()).filter((u) => u.role === 'admin') if (admins.length === 0) return 'none' return (await app.db.getEnabledCredentialKeyRef(admins[0].id)) !== null ? 'shared' : 'none' } - /** 平台共享密钥的**非敏感**信息(名字 / 归属;绝不返回密钥本身)。 */ - async function sharedKeyInfo( - userId: string, - ): Promise<{ available: boolean; name: string | null; owner: string | null; ownerIsMe: boolean }> { + + /** 平台共享模型的**非敏感**信息(名字 / 归属 / 条数;绝不返回密钥本身)。 */ + async function sharedKeyInfo(userId: string): Promise<{ + available: boolean + name: string | null + owner: string | null + ownerIsMe: boolean + enabled: boolean + count: number + }> { const admins = (await app.db.listPublicUsers()).filter((u) => u.role === 'admin') - if (admins.length === 0) return { available: false, name: null, owner: null, ownerIsMe: false } - const keys = await app.db.listCredentialKeys(admins[0].id) - const on = keys.find((k) => k.enabled) - // `ownerIsMe`:admin 看的是**自己**配的那把 ⇒ 前端文案要区分「我配的共享 key」与「别人配的」。 + const enabled = await app.db.getSharedModelEnabled(userId) + if (admins.length === 0) return { available: false, name: null, owner: null, ownerIsMe: false, enabled, count: 0 } + // 档案 87:共享**不再假设只有一把** —— admin 也能配多条(与用户侧同一套口径)。 + const keys = await app.db.listEnabledCredentialKeys(admins[0].id) + // `ownerIsMe`:admin 看的是**自己**配的那些 ⇒ 前端文案要区分「我配的」与「别人配的」。 return { - available: on !== undefined, - name: on?.name ?? null, + available: keys.length > 0, + name: keys[0]?.name ?? null, owner: admins[0].username, ownerIsMe: admins[0].id === userId, + enabled, + count: keys.length, } } - /** 换 key 后的刷新:admin 动的是共享 key ⇒ 广播重启;其他人只重启自己。 */ + /** 改动后的刷新:admin 动的是共享内容 ⇒ 广播重启;其他人只重启自己。 */ async function refreshAfterKeyChange(userId: string, role: string): Promise { if (role === 'admin') await app.supervisor.restartAllMains() else await app.supervisor.restartMain(userId) @@ -172,28 +211,88 @@ export const authRoutes: FastifyPluginAsync = async (app) => { keys: await app.db.listCredentialKeys(request.user!.id), effective: await keySourceOf(request.user!.id), shared: await sharedKeyInfo(request.user!.id), + // 档 87:把「共享开关」与「协议枚举」一并给出,免得前端各写一份常量然后漂掉。 + sharedModelEnabled: await app.db.getSharedModelEnabled(request.user!.id), + protocols: [...PROTOCOLS], })) app.post('/api/me/keys', { preHandler: requireAuth, schema: keyAddSchema }, async (request, reply) => { - const { name, apiKey } = request.body as { name: string; apiKey: string } - const cleanName = name.trim() - if (!/^[A-Za-z0-9\-_ .]{1,32}$/.test(cleanName)) { + const body = request.body as { + name: string + apiKey: string + route?: string + baseUrl?: string + api?: string + models?: string[] + } + const cleanName = body.name.trim() + // 展示名**允许中文**(寻址用的是 route),但仍拒掉控制字符,免得污染日志与界面。 + // eslint-disable-next-line no-control-regex + if (cleanName === '' || /[\u0000-\u001f\u007f]/.test(cleanName)) { return reply.code(400).send({ error: 'invalid_name' }) } // Header-safe charset only: reject spaces, quotes, non-ASCII, etc. - if (!/^[A-Za-z0-9\-_.]{1,256}$/.test(apiKey)) { + if (!/^[A-Za-z0-9\-_.]{1,256}$/.test(body.apiKey)) { return reply.code(400).send({ error: 'invalid_api_key' }) } + const baseUrl = (body.baseUrl ?? '').trim() + const isCustom = baseUrl !== '' + let route: string | null = null + let api: string | null = null + let models: string | null = null + if (isCustom) { + if (!/^https?:\/\/\S{1,280}$/.test(baseUrl)) return reply.code(400).send({ error: 'invalid_base_url' }) + route = (body.route ?? '').trim().toLowerCase() || slugify(cleanName) + if (!/^[a-z0-9][a-z0-9-]{0,39}$/.test(route)) return reply.code(400).send({ error: 'invalid_route' }) + if (body.api !== undefined && !(PROTOCOLS as readonly string[]).includes(body.api)) { + return reply.code(400).send({ error: 'invalid_api' }) + } + api = body.api ?? null + const ids = (body.models ?? []).map((m) => m.trim()).filter((m) => m !== '') + if (ids.length === 0) return reply.code(400).send({ error: 'models_required' }) + models = JSON.stringify(ids.slice(0, 50)) + // route 是 settings.yaml 里的 dict 键 ⇒ 同一个用户下撞键 = 后写入的会覆盖前者,静默失效。 + const existing = await app.db.listCredentialKeys(request.user!.id) + if (existing.some((k) => k.route === route && k.name !== cleanName)) { + return reply.code(409).send({ error: 'route_taken' }) + } + } const key = await app.db.setCredentialKey( request.user!.id, cleanName, - encrypt(apiKey, deriveKey(app.config.encryptionSecret)), + encrypt(body.apiKey, deriveKey(app.config.encryptionSecret)), + { route, baseUrl: isCustom ? baseUrl : null, api, models }, ) - await app.db.audit(request.user!.id, 'set_api_key', JSON.stringify({ name: cleanName })) + await app.db.audit(request.user!.id, 'set_api_key', JSON.stringify({ name: cleanName, route, custom: isCustom })) await refreshAfterKeyChange(request.user!.id, request.user!.role) return { key } }) + /** 开/关**单个**条目(档案 87 口径①:不互斥、可同时启用)。 */ + app.post('/api/me/keys/:id/toggle', { preHandler: requireAuth, schema: toggleSchema }, async (request, reply) => { + const { id } = request.params as { id: string } + const { enabled } = request.body as { enabled: boolean } + if (!(await app.db.toggleCredentialKey(request.user!.id, id, enabled))) { + return reply.code(404).send({ error: 'not_found' }) + } + await refreshAfterKeyChange(request.user!.id, request.user!.role) + return { ok: true } + }) + + /** 平台共享模型的开关(档案 87 口径②)—— 只动**自己**的偏好,不碰 admin 的配置。 */ + app.post('/api/me/models/shared', { preHandler: requireAuth, schema: toggleSchema }, async (request, reply) => { + const { enabled } = request.body as { enabled: boolean } + if (!(await app.db.setSharedModelEnabled(request.user!.id, enabled))) { + return reply.code(404).send({ error: 'not_found' }) + } + await refreshAfterKeyChange(request.user!.id, request.user!.role) + return { ok: true } + }) + + /** + * @deprecated 档案 87 起语义已变成"启用这一个、**不关**别的"(与 `toggle(true)` 同义)。 + * 保留路由只为老客户端不 404;新前端不该再用它。 + */ app.post('/api/me/keys/:id/select', { preHandler: requireAuth }, async (request, reply) => { const { id } = request.params as { id: string } if (!(await app.db.selectCredentialKey(request.user!.id, id))) return reply.code(404).send({ error: 'not_found' }) @@ -204,7 +303,7 @@ export const authRoutes: FastifyPluginAsync = async (app) => { app.delete('/api/me/keys/:id', { preHandler: requireAuth }, async (request, reply) => { const { id } = request.params as { id: string } if (!(await app.db.deleteCredentialKey(request.user!.id, id))) return reply.code(404).send({ error: 'not_found' }) - // 删掉自己最后一把 ⇒ 自动回落到平台共享密钥(这是"两层"应有的语义,不需要额外开关) + // 删掉的是自己配的 ⇒ 落地时自然回落到「平台共享模型」(前提是共享开关开着)。 await refreshAfterKeyChange(request.user!.id, request.user!.role) return { ok: true } }) diff --git a/src/web/routes/dsh.ts b/src/web/routes/dsh.ts index 61ddb76..71c41ca 100644 --- a/src/web/routes/dsh.ts +++ b/src/web/routes/dsh.ts @@ -6,7 +6,8 @@ * @module dshs/web/routes/dsh */ -import type { FastifyPluginAsync, FastifyReply } from 'fastify' +import type { FastifyInstance, FastifyPluginAsync, FastifyReply } from 'fastify' +import type { Instance } from '../../supervisor/spawner.js' import { requireAuth } from '../middleware/authn.js' import { sendFsError } from './desktop.js' import { AlreadyRunningError, CrashBreakerOpenError } from '../../supervisor/orchestrator.js' @@ -33,7 +34,7 @@ const restartSchema = { }, } as const -function alive(status: string | undefined): boolean { +export function alive(status: string | undefined): boolean { // 'failed' = 崩溃熔断后停止自动重启(档案 20),同样不可复用。 return status !== undefined && status !== 'crashed' && status !== 'stopped' && status !== 'failed' } @@ -45,7 +46,7 @@ function alive(status: string | undefined): boolean { * 客户端应展示「稍后重试」。`retryAfterMs` 供前端提示具体等待时长; * `opens` 是累计熔断次数(同一实例反复崩 → 该值递增,可据此判断"该找人了")。 */ -function sendBreakerOpen(reply: FastifyReply, err: CrashBreakerOpenError): FastifyReply { +export function sendBreakerOpen(reply: FastifyReply, err: CrashBreakerOpenError): FastifyReply { return reply.code(503).send({ error: 'instance_circuit_open', opens: err.opens, @@ -54,50 +55,92 @@ function sendBreakerOpen(reply: FastifyReply, err: CrashBreakerOpenError): Fasti }) } -function dshUrl(baseDomain: string, user: { id: string; username: string }, token?: string): string { +export function dshUrl(baseDomain: string, user: { id: string; username: string }, token?: string): string { const sub = subdomainForUser(baseDomain, user.username) const base = sub !== null ? `https://${sub}/` : `/u/${user.id}/dsh/` return token !== undefined && token !== '' ? `${base}?token=${encodeURIComponent(token)}` : base } +/** + * 启动**任意用户**实例的共用主体(档案 86):`POST /api/dsh/launch`(自己)与 + * `POST /api/admin/users/:id/dsh/launch`(admin 替别人)都走它 —— 避免"两处副本必然漂"。 + * + * 返回 `null` 表示目标路径不是文件夹(调用方回 400 `not_a_folder`); + * `fs.resolvePath` 的越界错误原样抛出(调用方走 `sendFsError`); + * `AlreadyRunningError` / `CrashBreakerOpenError` 也原样抛出(调用方映射 409 / 503)。 + * + * ⚠️ `userId` 是**被操作的那个用户**,不是调用者 —— 调用方负责鉴权(自己的 id 或 admin)。 + */ +export async function launchForUser( + app: FastifyInstance, + userId: string, + folder: string, +): Promise { + const fs = app.userFs + const folderAbs = fs.resolvePath(userId, folder) + if (!(await fs.isDirectory(userId, folder))) return null + // Per-folder plugin selection → cordis patch. Rendered here but *not* written: + // the spawner decides where it lands (a file under local, a ConfigMap under k8s). + let patch: string | undefined + if (app.config.enablePatch) { + const workspace = await app.db.findWorkspaceByPath(userId, folder) + // Only inject plugins the user still has installed; a stale selection for a + // since-removed bundle would otherwise fail to resolve in the child DSH. + const installed = new Set((await fs.listInstalledPlugins(userId)).map((plugin) => plugin.id)) + const enabled = (workspace === undefined ? [] : await app.db.getEnabledPluginIds(workspace.id)).filter((id) => + installed.has(id), + ) + patch = renderPatch(enabled) + } + return app.supervisor.launch(userId, folderAbs, patch) +} + +/** + * 某用户实例的**观测面**(`GET /api/dsh/status` 与 admin 视角共用;档案 86)。 + * 归档口径见档案 20 / 78 / 84 —— 只此一份,别再复制出第二份。 + */ +export async function statusForUser(app: FastifyInstance, user: { id: string; username: string }) { + const { main, watchdog } = await app.supervisor.status(user.id) + return { + running: alive(main?.status), + instance: main + ? { + id: main.id, + port: main.port, + status: main.status, + exitCode: main.exitCode, + lastError: main.lastError, + // 观测面(档案 20 · A2):自动重启次数 + 最近崩溃时间 + restarts: main.restarts ?? 0, + lastCrashedAt: main.lastCrashedAt ?? null, + } + : null, + watchdog: watchdog ? { id: watchdog.id, status: watchdog.status, exitCode: watchdog.exitCode } : null, + // 观测面(档案 78):熔断状态 —— 非 null 即"该用户正被冷却",供门户/排查直接看到 + breaker: app.supervisor.breakerInfo?.(user.id) ?? null, + // 观测面(档案 84):本实例**真实**内存配额(= instanceMemMb() 的结果,与 spawn 同源) + quota: app.supervisor.quotaInfo?.(user.id) ?? null, + url: dshUrl(app.config.baseDomain, user, main?.launchToken), + } +} + export const dshRoutes: FastifyPluginAsync = async (app) => { app.post('/api/dsh/launch', { preHandler: requireAuth, schema: launchSchema }, async (request, reply) => { const { folder } = request.body as { folder: string } const user = request.user! - const fs = app.userFs - let folderAbs: string + let instance: Instance | null try { - folderAbs = fs.resolvePath(user.id, folder) - if (!(await fs.isDirectory(user.id, folder))) return reply.code(400).send({ error: 'not_a_folder' }) - } catch (err) { - return sendFsError(reply, err) - } - - // Per-folder plugin selection → cordis patch. Rendered here but *not* - // written: the spawner decides where it lands (a file under local, a - // ConfigMap under k8s, where the control plane has no user volume). - let patch: string | undefined - if (app.config.enablePatch) { - const workspace = await app.db.findWorkspaceByPath(user.id, folder) - // Only inject plugins the user still has installed; a stale selection for - // a since-removed bundle would otherwise fail to resolve in the child DSH. - const installed = new Set((await fs.listInstalledPlugins(user.id)).map((plugin) => plugin.id)) - const enabled = (workspace === undefined ? [] : await app.db.getEnabledPluginIds(workspace.id)) - .filter((id) => installed.has(id)) - patch = renderPatch(enabled) - } - - try { - const instance = await app.supervisor.launch(user.id, folderAbs, patch) - return { - instance: { id: instance.id, port: instance.port, status: instance.status, launchToken: instance.launchToken }, - url: dshUrl(app.config.baseDomain, user, instance.launchToken), - } + instance = await launchForUser(app, user.id, folder) } catch (err) { if (err instanceof AlreadyRunningError) return reply.code(409).send({ error: 'already_running' }) // 档案 78:熔断冷却期内的启动被拒(用户选文件夹也会走到这里) if (err instanceof CrashBreakerOpenError) return sendBreakerOpen(reply, err) - throw err + return sendFsError(reply, err) // 路径越界等 → 400(非 UserFsError 会被原样抛出) + } + if (instance === null) return reply.code(400).send({ error: 'not_a_folder' }) + return { + instance: { id: instance.id, port: instance.port, status: instance.status, launchToken: instance.launchToken }, + url: dshUrl(app.config.baseDomain, user, instance.launchToken), } }) @@ -157,31 +200,7 @@ export const dshRoutes: FastifyPluginAsync = async (app) => { } }) - app.get('/api/dsh/status', { preHandler: requireAuth }, async (request) => { - const { main, watchdog } = await app.supervisor.status(request.user!.id) - return { - running: alive(main?.status), - instance: main - ? { - id: main.id, - port: main.port, - status: main.status, - exitCode: main.exitCode, - lastError: main.lastError, - // 观测面(档案 20 · A2):自动重启次数 + 最近崩溃时间 - restarts: main.restarts ?? 0, - lastCrashedAt: main.lastCrashedAt ?? null, - } - : null, - watchdog: watchdog ? { id: watchdog.id, status: watchdog.status, exitCode: watchdog.exitCode } : null, - // 观测面(档案 78):熔断状态 —— 非 null 即"该用户正被冷却",供门户/排查直接看到 - breaker: app.supervisor.breakerInfo?.(request.user!.id) ?? null, - // 观测面(档案 84):本实例**真实**内存配额(= instanceMemMb() 的结果,与 spawn 同源)。 - // 实例内「功能管理」读它显示真值;读不到(老平台/ k8s 模式)时前端才退回保守估算。 - quota: app.supervisor.quotaInfo?.(request.user!.id) ?? null, - url: dshUrl(app.config.baseDomain, request.user!, main?.launchToken), - } - }) + app.get('/api/dsh/status', { preHandler: requireAuth }, async (request) => statusForUser(app, request.user!)) // 登录直达(方案 05,2026-09-09;admin 亦直达 —— 2026-09-09 决策②补充): // 所有已放行角色(admin / active)统一:已运行实例复用其 launchToken URL, diff --git a/src/web/server.ts b/src/web/server.ts index 61c568a..7a3661a 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -11,7 +11,7 @@ import { basename, dirname, join } from 'node:path' import { writeFileSync } from 'node:fs' import { chown, mkdir, readFile, stat, writeFile } from 'node:fs/promises' import type { ServerConfig } from '../config.js' -import { createDbAdapter, type DbAdapter, type PublicUser } from '../db/index.js' +import { createDbAdapter, type CredentialLandingRow, type DbAdapter, type PublicUser } from '../db/index.js' import { createUserFs } from '../fs/provider.js' import type { UserFs } from '../fs/user-fs.js' import { decrypt, deriveKey } from '../crypto.js' @@ -20,9 +20,20 @@ import { LocalSpawner } from '../supervisor/orchestrator.js' import { K8sSpawner } from '../supervisor/k8s-spawner.js' import { registerDshProxy } from '../supervisor/proxy.js' import type { Spawner } from '../supervisor/spawner.js' +import { + BUILTIN_REF, + normalizeProtocol, + parseModels, + readRefValue, + reconcileCredentials, + reconcileSettings, + refForEntry, + type SettingsEntry, +} from './model-landing.js' import { rateLimit } from './middleware/rate-limit.js' import { authRoutes } from './routes/auth.js' import { adminRoutes } from './routes/admin.js' +import { adminUserOpsRoutes } from './routes/admin-user-ops.js' import { businessPluginRoutes } from './routes/business-plugins.js' import { desktopRoutes } from './routes/desktop.js' import { dshRoutes } from './routes/dsh.js' @@ -65,99 +76,174 @@ function isAllowedOrigin(origin: string, baseDomain: string): boolean { export async function buildServer(config: ServerConfig): Promise { const db = await createDbAdapter(config) const encryptionKey = deriveKey(config.encryptionSecret) - /** - * 把「平台共享密钥」预置进用户的 dsh 凭据文件 `$DSH_HOME/.credentials.yaml` 的 `refs:` 段。 - * - * 为什么是写文件而不是注入 env(2026-09-13 读官方源码定的): - * · dsh 凭据解析顺序 `inherited process environment (read-only, wins) > $DSH_HOME/.credentials.yaml > …` - * ⇒ **env 永远赢**; - * · 更要命的是 `dsh-credentials-local` 的 `write()` 里有 `assertUnshadowed()`: - * 只要 env 里存在同名 ref,用户在官方「设置 → 模型」页**保存该 key 会直接报错** - * ("supplied read-only by the launching environment … unset it in the shell you start dsh from")。 - * ⇒ 注入 env 等于**把用户锁死在"不能自配 DeepSeek key"**的状态。 - * · 所以平台改为**预置到凭据文件**:用户没配 ⇒ 用平台共享 key;用户去模型页改 ⇒ 直接覆盖同一个 ref。 - * - * 安全约束(保守到极限): - * ① 只在 `refs:` 段**没有**该 ref 时写 —— 用户配过就绝不碰; - * ② 写前备份,但**备份必须放到平台自己的目录**(`/opt/dsh/backups`), - * ⛔ **绝不能落在用户 home 里**:dsh 用 chokidar watch 该目录,一个**实例读不了**的文件 - * (root 属主 600)会让它抛 `EACCES` ⇒ **实例崩溃循环**(2026-09-13 实测踩过, - * 当时 .credentials.yaml.bak-platform 直接把 guest 打进 attempt=5); - * ③ 只在 `version: 1` 的文档上插入,**不重排、不重写其它行**; - * ④ 写完 chown 给实例 uid(否则 600 权限下实例读不了自己的凭据文件)。 - */ - async function ensureRefInCredentials(homeDir: string, ref: string, value: string): Promise { - const file = join(homeDir, '.credentials.yaml') - let text = '' + // ── 模型条目落地(档案 87)────────────────────────────────────────────────── + // 用户口径(2026-09-13 定):条目**各自开关、可同时启用**;admin 配的**平台共享模型 + // **也列入**、用户可开关(`users.shared_model_enabled`);平台只负责把「**已启用**」 + // 的都配好 —— 具体用哪个模型在 dsh 对话框的模型选择器里挑。 + // + // 为什么必须由平台写文件:官方「设置 → 模型」页在平台环境**必然报错**(该页要 Host + // settings 镜像,而平台是浏览器经域名访问远程服务器 ⇒ `isLoopback=false` ⇒ persistence + // 降级 `memory` ⇒ 页面报「加载提供方目录失败」)。详见 `ensure-role-profile-patch.cjs`。 + // + // 为什么**仍然不注入 env**(2026-09-13 读官方源码定的):dsh 凭据解析顺序是 + // `inherited process environment (read-only, wins) > $DSH_HOME/.credentials.yaml > …`, + // 且 `dsh-credentials-local.write()` 里有 `assertUnshadowed()` —— 只要 env 存在同名 ref, + // 保存就报错("supplied read-only by the launching environment …")⇒ 注入 env 等于 + // **把用户锁死在"不能自配 key"**。所以共享 key 改成**预置进凭据文件**,本函数恒返回 null。 + // + // 落地两处(字段名 2026-09-13 读官方包实测,勿凭记忆改 —— 见 model-landing.ts 头注释): + // · `$DSH_HOME/.credentials.yaml` 的 `refs.` + // · `$DSH_HOME/settings.yaml` 的 `llm-pi-ai.providers.` + // + // ⛔ 备份**绝不能落在用户 home 里**:dsh 用 chokidar watch 整个 home,一个**实例读不了** + // 的文件(root 属主 600)会让它抛 `EACCES` ⇒ **实例崩溃循环**(2026-09-13 实测踩过, + // 当时 .credentials.yaml.bak-platform 直接把 guest 打进 attempt=5)。 + interface Managed { + refs: string[] + routes: string[] + } + /** 托管清单落点:**平台状态目录**(不在 home、也不在文档库)。 */ + const managedDir = join(process.env.DSH_PLATFORM_STATE_DIR ?? '/opt/dsh/state', 'model-landing') + /** 只有清单里的 ref / route 才允许被平台改写或删除 —— 用户自己配的一律不碰。 */ + const readManaged = async (userId: string): Promise => { + const strs = (v: unknown): string[] => (Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : []) try { - text = await readFile(file, 'utf8') + const raw = JSON.parse(await readFile(join(managedDir, userId + '.json'), 'utf8')) as Record + return { refs: strs(raw.refs), routes: strs(raw.routes) } } catch { - text = '' // 文件不存在 ⇒ 从零创建一个最小合法文档 + return { refs: [], routes: [] } // 不存在 / 读坏 ⇒ 视为"平台还没管过任何东西"(保守) } - const has = new RegExp('^[ \\t]*' + ref + '[ \\t]*:', 'm') - if (has.test(text)) return false // 用户已自配(或有该 ref)⇒ 绝不覆盖 - const line = ' ' + ref + ": '" + value + "'" - let next: string - if (text.trim() === '') { - next = 'version: 1\nrefs:\n' + line + '\n' - } else if (/^refs:[ \t]*$/m.test(text)) { - next = text.replace(/^refs:[ \t]*$/m, (m) => m + '\n' + line) - } else if (/^version:[ \t]*1[ \t]*$/m.test(text)) { - // 有 version 但还没 refs 段 ⇒ 紧跟 version 建一个 - next = text.replace(/^version:[ \t]*1[ \t]*$/m, (m) => m + '\nrefs:\n' + line) - } else { - return false // 认不出的布局 ⇒ 宁可不动(让实例照旧报"没有 key",也不冒写坏凭据的风险) + } + const writeManaged = async (userId: string, m: Managed): Promise => { + await mkdir(managedDir, { recursive: true }) + await writeFile(join(managedDir, userId + '.json'), JSON.stringify(m), { mode: 0o600 }) + } + const readTextOrEmpty = async (file: string): Promise => { + try { + return await readFile(file, 'utf8') + } catch { + return '' } - // 备份落在**平台目录**(不进 home —— 见上面 ②) - if (text !== '') { - try { - const bakDir = process.env.DSH_PLATFORM_BACKUP_DIR ?? '/opt/dsh/backups' - await mkdir(bakDir, { recursive: true }) - writeFileSync(join(bakDir, 'credentials-' + basename(homeDir) + '-' + Date.now() + '.yaml'), text, { mode: 0o600 }) - } catch { - /* 备份失败不阻断 */ - } + } + /** + * 写 home 里的配置文件:备份(落**平台目录**)→ 写 → chown 给 home 属主。 + * 实例以 dsh- 身份运行,root 写的 600 文件它读不了 ⇒ 最后一步不能省。 + */ + const writeHomeFile = async (homeDir: string, file: string, text: string): Promise => { + try { + const bakDir = process.env.DSH_PLATFORM_BACKUP_DIR ?? '/opt/dsh/backups' + await mkdir(bakDir, { recursive: true }) + const label = basename(file).replace(/^\./, '').replace(/\.ya?ml$/, '') + // ⚠️ 带上 home 的**父目录名**(= 用户 id):只写 basename 的话每个人都是 "home", + // 备份文件互相看不出是谁的(旧实现就是这个毛病:credentials-home-*.yaml)。 + const who = basename(dirname(homeDir)) + writeFileSync(join(bakDir, `${label}-${who}-${Date.now()}.yaml`), text, { mode: 0o600 }) + } catch { + /* 备份失败不阻断 */ } - await writeFile(file, next, { mode: 0o600 }) - // 实例以 dsh- 身份运行;root 写的 600 文件它读不了 ⇒ 交给该 home 的属主 + await writeFile(file, text, { mode: 0o600 }) try { const st = await stat(homeDir) await chown(file, st.uid, st.gid) } catch { /* chown 失败(非 root 运行等)不阻断 */ } - return true } - // ── 模型密钥供给(档案 86;2026-09-13 用户要求用户可自配模型厂家)──────────────── - // 口径:**平台不再向实例注入 `DEEPSEEK_API_KEY` env**,改为在 spawn 时把「平台共享密钥」 - // 预置进该用户的凭据文件(仅当他还没配过)。这样: - // · 没配的用户 —— 照旧能用(共享 key); - // · 想用自己的 —— 直接去官方「设置 → 模型」页改,覆盖同一个 ref,**不会再被 env 挡住**; - // · 配了自定义厂家(OpenAI 兼容网关)的 —— 那走各自的 `_API_KEY`,平台完全不介入。 - // ⚠️ 返回 null(不注入 env)是**刻意的**,不是"没有 key"。见上面 ensureRefInCredentials 的注释。 - const resolveApiKey = async (userId: string): Promise => { + /** 平台共享条目(admin 配的、已启用的那些)—— 只在该用户开关打开、且他不是那个 admin 时纳入。 */ + const sharedLandingRows = async (userId: string): Promise => { + if (!(await db.getSharedModelEnabled(userId))) return [] + const admins = (await db.listPublicUsers()).filter((u) => u.role === 'admin') + if (admins.length === 0 || admins[0].id === userId) return [] + return db.listCredentialLandingRows(admins[0].id) + } + + /** + * 把「已启用条目」对账进实例的两个配置文件。**幂等**,且只在真有变化时写盘。 + * 合并顺序 = **自己的在前** ⇒ 同一个 ref 上,用户自己配的 key 永远赢过平台共享的那把。 + */ + const landModels = async (userId: string): Promise => { const owner = await db.findUserById(userId) - if (owner === undefined) return null + if (owner === undefined) return + const previous = await readManaged(userId) + const rows = [...(await db.listCredentialLandingRows(userId)), ...(await sharedLandingRows(userId))] + const seen = new Set() + const creds: Array<{ ref: string; value: string }> = [] + const providers: SettingsEntry[] = [] + for (const row of rows) { + const ref = refForEntry({ route: row.route, baseUrl: row.baseUrl }) + if (seen.has(ref)) continue + let value: string + try { + value = decrypt(row.encryptedRef, encryptionKey) + } catch { + // 解不开的条目跳过:宁可少配一个厂家,也不能让整次 spawn 失败。 + console.error('model landing: 解不开的条目已跳过', { userId, name: row.name }) + continue + } + seen.add(ref) + creds.push({ ref, value }) + // 只有"自定义厂家"才写 settings.yaml:内置 DeepSeek 由官方 `dsh-llm-deepseek` 自己管 + // (它的 route 是 `deepseek-official`,不是 `llm-pi-ai` 下的键)。 + if (row.baseUrl !== null && row.baseUrl !== '' && row.route !== null && row.route !== '') { + providers.push({ + route: row.route, + apiKeyEnv: ref, + baseURL: row.baseUrl, + api: normalizeProtocol(row.api), + models: parseModels(row.models), + }) + } + } + const credFile = join(owner.home_dir, '.credentials.yaml') + const setFile = join(owner.home_dir, 'settings.yaml') + const credText = await readTextOrEmpty(credFile) + const setText = await readTextOrEmpty(setFile) + // 一次性交接(档案 87):老实现把平台共享 key 写进 `refs.DEEPSEEK_API_KEY` 时没有托管清单, + // 新逻辑会把它当成"用户自己写的" ⇒ 关掉共享开关后那行仍留着("关掉即生效"不成立)。 + // 首次运行(没有任何清单)且**文件里那行确实等于平台共享 key 明文**时,认领它; + // 不相等 = 用户自己配的 ⇒ 绝不碰。 + let prevRefs = previous.refs + if (previous.refs.length === 0 && previous.routes.length === 0) { + if (readRefValue(credText, BUILTIN_REF) !== null) { + const shared = await sharedDeepseekKey() + if (shared !== null && shared === readRefValue(credText, BUILTIN_REF)) prevRefs = [BUILTIN_REF] + } + } + const nextCred = reconcileCredentials(credText, creds, prevRefs) + const nextSet = reconcileSettings(setText, providers, previous.routes) + if (nextCred.text !== credText) await writeHomeFile(owner.home_dir, credFile, nextCred.text) + if (nextSet.text !== setText) await writeHomeFile(owner.home_dir, setFile, nextSet.text) + await writeManaged(userId, { refs: nextCred.managed, routes: nextSet.managed }) + } + + /** 保底:平台共享的那把内置 DeepSeek key 明文 —— 只在写配置失败退回 env 注入时才用。 */ + const sharedDeepseekKey = async (): Promise => { const admins = (await db.listPublicUsers()).filter((u) => u.role === 'admin') if (admins.length === 0) return null const ref = await db.getEnabledCredentialKeyRef(admins[0].id) if (ref === null) return null - let shared: string | null = null try { - shared = decrypt(ref, encryptionKey) + return decrypt(ref, encryptionKey) } catch { - return null // corrupt ref — treat as unset, let the admin re-enter it + return null // 密文坏了 ⇒ 当作没配,等 admin 重填 } - if (shared === null) return null + } + + /** + * 「该给实例注入什么 env」的答案:**什么也不注入**(恒 `null`)。 + * 保留函数名与签名是因为 `Spawner` 的接口就是这么定义的(见上面那段大注释:注入 env 会把 + * 用户在模型页的保存打回错误)。写配置失败时**退回 env 注入保底** —— 宁可让用户暂时用 + * 平台共享 key,也不能因为写文件出错就让实例起不来。 + */ + const resolveApiKey = async (userId: string): Promise => { try { - await ensureRefInCredentials(owner.home_dir, 'DEEPSEEK_API_KEY', shared) + await landModels(userId) + return null } catch (err) { - // 写失败不能让实例起不来:退回老办法(注入 env)保底 - console.error('ensureRefInCredentials failed, falling back to env injection', err) - return shared + console.error('model landing failed, falling back to env injection', err) + return await sharedDeepseekKey() } - return null } const resolveUid = async (userId: string): Promise => { const user = await db.findUserById(userId) @@ -216,6 +302,8 @@ export async function buildServer(config: ServerConfig): Promise { + // 档案 87:条目口径从「互斥单选」改为「**各自开关、可同时启用**」⇒ 老断言整体改写。 + test(`${backend}: concurrent setCredentialKey keeps every entry enabled (不再互斥)`, async () => { const db = await makeAdapter() try { await db.createUser(user('a', 'alice')) @@ -48,7 +49,10 @@ function register(backend, makeAdapter) { Array.from({ length: 5 }, (_, i) => db.setCredentialKey('a', `k${i}`, `ref${i}`)), ) const keys = await db.listCredentialKeys('a') - assert.equal(keys.filter((k) => k.enabled).length, 1, 'exactly one key enabled') + assert.equal(keys.length, 5, 'five rows') + // 写新条目**不再**把其它条目全关(老实现是 `SET enabled = 0 WHERE user_id = ?`)。 + assert.equal(keys.filter((k) => k.enabled).length, 5, 'every entry stays enabled') + assert.equal((await db.listEnabledCredentialKeys('a')).length, 5, 'listEnabled agrees with list') const ref = await db.getEnabledCredentialKeyRef('a') assert.ok(ref !== null && ref.startsWith('ref'), 'enabled key has a ref') } finally { @@ -56,15 +60,77 @@ function register(backend, makeAdapter) { } }) - test(`${backend}: selectCredentialKey flips the enabled key`, async () => { + test(`${backend}: toggleCredentialKey 只动一行(不影响其它条目)`, async () => { const db = await makeAdapter() try { await db.createUser(user('a', 'alice')) const k1 = await db.setCredentialKey('a', 'k1', 'r1') await db.setCredentialKey('a', 'k2', 'r2') - assert.equal(await db.getEnabledCredentialKeyRef('a'), 'r2') + assert.equal(await db.toggleCredentialKey('a', k1.id, false), true) + const keys = await db.listCredentialKeys('a') + assert.equal(keys.find((k) => k.id === k1.id).enabled, false, 'target row off') + assert.equal(keys.find((k) => k.name === 'k2').enabled, true, 'the other row untouched') + assert.equal((await db.listEnabledCredentialKeys('a')).length, 1, 'only one enabled now') + // 不存在的 id ⇒ false(路由据此回 404) + assert.equal(await db.toggleCredentialKey('a', 'nope', true), false) + } finally { + await db.close() + } + }) + + test(`${backend}: getEnabledCredentialKeyRef 只认内置条目(档案 87 语义重定义)`, async () => { + const db = await makeAdapter() + try { + await db.createUser(user('a', 'alice')) + // 自定义厂家(给了 baseUrl)**不是**"用户自己的 DeepSeek key",否则 keySourceOf / + // resolveApiKey 会把一个网关的 key 当成平台内置 key 用。 + await db.setCredentialKey('a', 'gw', 'gwref', { + route: 'my-gw', + baseUrl: 'https://api.example.com/v1', + api: 'openai-completions', + models: '["gpt-4o"]', + }) + assert.equal(await db.getEnabledCredentialKeyRef('a'), null, 'custom provider is not the builtin ref') + await db.setCredentialKey('a', 'ds', 'dsref') + assert.equal(await db.getEnabledCredentialKeyRef('a'), 'dsref', 'builtin entry wins') + // 元数据必须原样存回来(route / baseUrl / api / models) + const gw = (await db.listCredentialKeys('a')).find((k) => k.name === 'gw') + assert.equal(gw.route, 'my-gw') + assert.equal(gw.baseUrl, 'https://api.example.com/v1') + assert.equal(gw.api, 'openai-completions') + assert.equal(gw.models, '["gpt-4o"]') + } finally { + await db.close() + } + }) + + test(`${backend}: sharedModelEnabled 默认开、可关(档案 87 口径②)`, async () => { + const db = await makeAdapter() + try { + await db.createUser(user('a', 'alice')) + assert.equal(await db.getSharedModelEnabled('a'), true, 'V6 默认 true') + assert.equal(await db.setSharedModelEnabled('a', false), true) + assert.equal(await db.getSharedModelEnabled('a'), false) + assert.equal(await db.setSharedModelEnabled('a', true), true) + assert.equal(await db.getSharedModelEnabled('a'), true) + } finally { + await db.close() + } + }) + + test(`${backend}: selectCredentialKey 不再关掉别的条目(档案 87)`, async () => { + const db = await makeAdapter() + try { + await db.createUser(user('a', 'alice')) + const k1 = await db.setCredentialKey('a', 'k1', 'r1') + const k2 = await db.setCredentialKey('a', 'k2', 'r2') + // 两条内置条目都启用 ⇒ 取"最新一条"(两条写在同一毫秒时由 id 决定,故这里只断非空) + assert.ok((await db.getEnabledCredentialKeyRef('a')) !== null) assert.equal(await db.selectCredentialKey('a', k1.id), true) - assert.equal(await db.getEnabledCredentialKeyRef('a'), 'r1') + // 老语义会先把所有条目关掉再开这一个;新语义只保证"这一个开"。 + const keys = await db.listCredentialKeys('a') + assert.equal(keys.find((k) => k.id === k2.id).enabled, true, 'another entry is not switched off') + assert.equal(keys.find((k) => k.id === k1.id).enabled, true, 'target stays enabled') } finally { await db.close() } diff --git a/web/portal.html b/web/portal.html index a412d46..8bcd6b8 100644 --- a/web/portal.html +++ b/web/portal.html @@ -151,7 +151,7 @@ function readAsBase64(file) { return String(new Promise((res, rej) => { const r function emptyRow(cols, text) { return `${esc(text)}` } /* ================= 路由 ================= */ -const CRUMB_TITLES = { files: '服务管理', keys: '密钥管理', users: '用户管理', skills: '技能管理', plugins: '插件管理' } +const CRUMB_TITLES = { files: '实例管理', keys: '模型管理', users: '用户管理', skills: '技能管理', plugins: '插件管理' } function parseHash() { const h = (location.hash || '#/').replace(/^#/, ''); const parts = h.split('/').filter(Boolean); return parts[0] || '' } function updateCrumbs(name) { const c = $('crumbs') @@ -164,8 +164,8 @@ function pageHead(title, sub) { function renderHome() { const sec1 = [ - { ic: '🖥️', t: '服务管理', d: '文件树 + DSH 启动', hash: '#/files' }, - { ic: '🔑', t: '密钥管理', d: '平台共享密钥', hash: '#/keys' }, + { ic: '🖥️', t: '实例管理', d: '文件树 + DSH 启动', hash: '#/files' }, + { ic: '🔑', t: '模型管理', d: '平台共享密钥', hash: '#/keys' }, ] const sec2 = [ { ic: '👥', t: '用户管理', d: '审批 / 禁用 / 删除', hash: '#/users', admin: true },