From eb50ca2d5b8fcfa6f4a81267c388fbd71788cf1b Mon Sep 17 00:00:00 2001 From: maogeigei Date: Sun, 13 Sep 2026 22:27:49 +0800 Subject: [PATCH] =?UTF-8?q?feat(keys):=20=E6=A8=A1=E5=9E=8B=E5=AF=86?= =?UTF-8?q?=E9=92=A5=E5=BC=80=E6=94=BE=E7=BB=99=E7=94=A8=E6=88=B7=E8=87=AA?= =?UTF-8?q?=E9=85=8D=20=E2=80=94=E2=80=94=20=E6=94=BE=E5=BC=80=E5=AE=98?= =?UTF-8?q?=E6=96=B9=E3=80=8C=E6=A8=A1=E5=9E=8B=E3=80=8D=E9=A1=B5=20+=20?= =?UTF-8?q?=E5=B9=B3=E5=8F=B0=E6=94=B9=E9=A2=84=E7=BD=AE=E5=87=AD=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户要求「把配置模型密钥开放给用户自己配」且「界面交互和官方一模一样」⇒ 不仿制,直接放开官方 ui-settings-models 页(可选厂家:DeepSeek 内置 + 自定义 OpenAI 兼容网关含 baseURL/模型)。 - ensure-role-profile-patch.cjs:不再对普通用户禁用 ui-settings-models(保留 plugins/inventory/cordis 禁用);--force 时能把「还禁着 models」的旧块升级。 - src/web/server.ts:resolveApiKey 不再注入 DEEPSEEK_API_KEY env,改为把「平台共享密钥」预置进 $DSH_HOME/.credentials.yaml 的 refs 段(新增 ensureRefInCredentials:只在无该 ref 时写 / 只在 version:1 上插入 / 备份落平台目录 / 写完 chown 给实例 uid / 失败退回 env)。真因:dsh 凭据解析里 env 优先级最高,且 dsh-credentials-local.write() 的 assertUnshadowed() 会让用户在模型页保存直接报错 ⇒ 注入 env 等于锁死用户自配。 - src/web/routes/auth.ts:/api/me/keys 由 requireAdmin 放开为 requireAuth(平台侧密钥 API 保留,UI 不再暴露)。 - poc/business-plugins 0.3.4→0.3.8:「系统管理」对齐门户 6 个功能页(同名同构,PA_PAGES 逐函数移植 portal)/ 官方插件列表高度改为「离弹窗底部约 100px」/ 撤掉自造的「我的密钥」分区(改由官方模型页承担)。 - web/portal.html:keys 页语义改名「平台共享密钥(未自配密钥的用户默认使用;仅管理员可改)」。scripts/verify-platform-admin-section.mjs:断言同步升级(含 zh/en 词典键集一致性、内联 HTML class 扫描)。 --- ensure-role-profile-patch.cjs | 38 +- poc/business-plugins/lib/client.js | 1820 ++++++++++++++++----- poc/business-plugins/package.json | 4 +- scripts/verify-platform-admin-section.mjs | 190 ++- src/web/routes/auth.ts | 59 +- src/web/server.ts | 93 +- web/portal.html | 6 +- 7 files changed, 1739 insertions(+), 471 deletions(-) diff --git a/ensure-role-profile-patch.cjs b/ensure-role-profile-patch.cjs index 8522e51..6c0dc0c 100644 --- a/ensure-role-profile-patch.cjs +++ b/ensure-role-profile-patch.cjs @@ -2,9 +2,16 @@ /** * ensure-role-profile-patch.cjs — 按角色给 dsh profile 注入 cordis patch。 * - * 背景(2026-09-09):普通用户在设置面板不应看到「模型」分区(模型 KEY 由管理员 - * 经门户统一管控,档案 03;dsh 官方 web profile 的模型 provider 目录在此环境不可用, - * 且避免普通用户误配自用 key 绕过统一 key)。cordis patch 支持对 client 插件行 + * 背景: + * · 2026-09-09:普通用户在设置面板不应看到「模型」分区(模型 KEY 由管理员经门户统一 + * 管控,档案 03;且避免普通用户误配自用 key 绕过统一 key)。 + * · **2026-09-13(档案 86):「模型」分区对普通用户放开** —— 用户要求把配置模型密钥 + * 开放给用户自己配,且「界面交互和官方一模一样」(⇒ 直接用官方页,不仿制)。 + * 实测:官方页在本环境**可用**(`/api/session/modelCatalog` 返回 200,"provider 目录 + * 不可用"的旧判断已不成立)。配套改平台注入:用户自配 ⇒ 不注入共享 env + * (`src/web/server.ts` `userHasOwnKey()`,否则 env 优先级会静默盖掉用户配的 key)。 + * 仍禁用:plugins / plugin-inventory / cordis(骨架插件禁任一都可能搞坏实例)。 + * cordis patch 支持对 client 插件行 * `disabled: true`(dsh-app-boot applyEntryPatches:非 insert patch 按 id 合入 * overrides)——生效位置 = profile 层 `cordis.patch.yml`(实例启动时打包 client * bundle,改后必须重启实例才生效;patchReload:live 对 client 增减不生效,已实测)。 @@ -27,13 +34,14 @@ const MARK = '# dshs role patch' // --force:已由本脚本管理但内容落后(缺新版禁用项)时,整体升级为当前块。 const FORCE = process.argv.includes('--force') const DISABLE_MODELS_BLOCK = [ - '# dshs role patch: 普通用户隐藏模型分区 + 收归核心插件开关(档案 15)', + '# dshs role patch: 收归核心插件开关(档案 15)', '# admin 保留;由 ensure-role-profile-patch.cjs 管理,勿手改', '# 148 个 @deepseek-ai 官方插件均为运行骨架,用户禁用任一都可能搞坏实例,', '# 故插件栏 / 插件清单 / cordis 面板只对 admin 开放;ui-skill、ui-permission 保留给用户。', - '- id: ui-settings-models', - ' name: "@deepseek-ai/dsh-client-ui-settings-models"', - ' disabled: true', + '#', + '# ⚠️ ui-settings-models **自 2026-09-13 起不再禁用**(档案 86):用户要自助配置模型厂家与', + '# key(「界面交互和官方一模一样」)。配套:平台注入策略改为「用户自配则不注入共享 env」,', + '# 使模型页配的 key 真能生效 —— 见 src/web/server.ts 的 userHasOwnKey()。', '- id: ui-settings-plugins', ' name: "@deepseek-ai/dsh-client-ui-settings-plugins"', ' disabled: true', @@ -60,13 +68,17 @@ function ensureUserPatch(user) { return { user: user.username, action: 'NO_PROFILE', detail: 'profile 尚未创建(用户未首登 spawn);请先登录一次再跑' } } const current = readFileSync(patchPath, 'utf8') - if (current.includes(MARK)) { - if (FORCE && !current.includes('ui-settings-plugins')) { - writeFileSync(patchPath, DISABLE_MODELS_BLOCK, 'utf8') - return { user: user.username, action: 'upgraded', detail: '已升级为新版禁用块(含核心插件开关收归)' } + 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: '已升级(放开「模型」分区 + 保留核心插件开关收归)' } + } + return { user: user.username, action: 'skip', detail: '已由本脚本管理' } } - 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' } diff --git a/poc/business-plugins/lib/client.js b/poc/business-plugins/lib/client.js index 965ff85..1e652a5 100644 --- a/poc/business-plugins/lib/client.js +++ b/poc/business-plugins/lib/client.js @@ -82,40 +82,61 @@ window.__ModuleLoader__.load({ success: "var(--dsw-alias-state-success-primary, #1a7f37)" }; - // ── 内存预估模型(档案 75「维度 B 资源成本」的前端落地)──────────────────── - // 口径:**预估值** = 空载基线 + Σ(已启用插件的加载成本)。客户端拿不到 cgroup 实测值, - // 因此这里是估算而非实时读数;数值来源 = 2026-09-13 隔离 cgroup 实测 - //(`node --expose-gc` 逐项 import 的 rss 增量,见 `.workbuddy/memory/2026-09-13.md`)。 - /** 单实例 cgroup 上限(MiB)—— 档案 58 定档;改它必须同时改编排器的 `MemoryMax`。 */ - var MEM_LIMIT_MIB = 384; - /** 空载基线(MiB):dsh 本体 + 148 个官方插件 + 平台自研 ×3(smaps 实测 ≈ 285)。 */ - var MEM_BASE_MIB = 285; - /** 逐插件加载成本(MiB,实测 rss 增量)。不在此表的按 MEM_FALLBACK_MIB 计。 */ + // ── 内存模型(档案 84;口径**与平台编排器同源**)────────────────────────── + // + // 权威定义在 `src/supervisor/orchestrator.ts`: + // 配额 = clamp(BASE_MEM_MB + Σ(PLUGIN_MEM_MB[bundles]), MIN_MEM_MB, MAX_MEM_MB) + // 下面的常量**逐键与它对齐**,并由 `scripts/verify-mem-model.mjs` 在 `npm run verify` + // 里交叉断言 —— 两处一旦不同步,构建直接失败。 + // + // ★ 历史教训(2026-09-13 实测):这里曾自建一套**独立**模型(基线 285 / univer 64 / + // mcn-suite 39,且把 384 当硬上限)⇒ 界面显示「勾选后 388 MiB」并报「⚠ 将超出上限」, + // 而平台真实配额是 672(guest)/ 384(admin),**384 早已只是下限** ⇒ 纯属误报。 + // 修法:① 常量与规则对齐平台;② 优先读 `/api/dsh/status` 的 `quota.memMb/heapMb` + // (平台**实际使用**的值)直接显示;③「超限」改为判断是否顶到硬顶 MAX。 + /** 与编排器 `BASE_MEM_MB` 一致。 */ + var MEM_BASE_MIB = 160; + /** 与编排器 `MIN_MEM_MB` 一致 —— ⚠️ 这是**下限**,不是上限。 */ + var MEM_MIN_MIB = 384; + /** 与编排器 `MAX_MEM_MB` 一致 —— 单实例硬顶。 */ + var MEM_MAX_MIB = 1024; + /** 与编排器 `PLUGIN_MEM_MB` 一致;未识别的插件平台按 0 计。 */ var MEM_TABLE = { - "dsh-univer-office": 64, - "dsh-plugin-mcn-suite": 39 + "dsh-univer-office": 512, + "dsh-plugin-mcn-suite": 128 }; - /** 未实测插件的兜底值 —— 平台自研轻量插件实测合计仅 1.7 MiB。 */ - var MEM_FALLBACK_MIB = 2; - /** 越过该比例即提前警示,给页缓存与用户自己的任务留余量。 */ + /** 与编排器 `HEAP_HEADROOM_MB` 一致。 */ + var MEM_HEAP_HEADROOM_MIB = 96; + /** 越过该比例即提前警示(相对**硬顶**),给页缓存与用户自己的任务留余量。 */ var MEM_TIGHT_RATIO = 0.85; + /** 单个插件的加载成本(MiB);未识别 = 0(与平台同口径:宁可少给也不虚高)。 */ function memMiB(p) { if (p && p.name && Object.prototype.hasOwnProperty.call(MEM_TABLE, p.name)) { return MEM_TABLE[p.name]; } - return MEM_FALLBACK_MIB; + return 0; } - /** 把一组插件里 `key` 为真的那些的预估内存求和。 */ - function sumMiB(list, key) { - var total = 0; + /** 未 clamp 的原始需求(MiB)= 基线 + Σ命中 `key` 为真的插件。 */ + function rawMiB(list, key) { + var total = MEM_BASE_MIB; for (var i = 0; i < list.length; i++) { if (list[i][key]) total += memMiB(list[i]); } return total; } + /** 按平台同款规则把原始需求折算成实际配额(clamp 到 [MIN, MAX])。 */ + function quotaOf(raw) { + return Math.min(Math.max(raw, MEM_MIN_MIB), MEM_MAX_MIB); + } + + /** V8 老生代上限(与编排器 `heapMbFor` 一致):配额 − 96,夹在 [128, 256]。 */ + function heapMiBFor(memMb) { + return Math.max(128, Math.min(256, memMb - MEM_HEAP_HEADROOM_MIB)); + } + /** 本插件的 locale 命名空间(官方 i18n)。 */ var NS = "business-plugins"; /** 简体中文字典(key 集的事实来源)。 */ @@ -138,7 +159,7 @@ window.__ModuleLoader__.load({ "confirm": "将重启当前 dsh 实例以生效。重启会中断当前会话(正在进行的对话会断开)。", "confirm.cancel": "取消", "confirm.ok": "确认应用", - "confirm.overTitle": "⚠ 本次勾选将超出单实例内存上限", + "confirm.overTitle": "⚠ 内存需求已顶到单实例硬顶", "nochange": "没有需要更改的插件", "queued": "排队中…", "installing": "正在安装", @@ -152,71 +173,207 @@ window.__ModuleLoader__.load({ "retry": "重试", "requestFailed": "请求失败", "loadFailed": "加载失败", - "mem.title": "内存预估", - "mem.est": "预估(实测基准,非实时读数)", + "mem.title": "内存配额", + "mem.est": "估算值(未读到平台配额,按平台同款算式推导)", + "mem.real": "实际配额", + "mem.heap": "V8 堆", "mem.now": "当前", "mem.planned": "勾选后", - "mem.limit": "上限", + "mem.limit": "硬顶", "mem.left": "剩余", - "mem.over": "将超出上限", - "mem.tight": "接近上限", + "mem.over": "顶到硬顶", + "mem.tight": "接近硬顶", "mem.safe": "余量充足", - "mem.overWarn": "勾选后将超出单实例内存上限,实例可能起不来。建议先停用部分插件再应用。", + "mem.overWarn": "勾选后的内存需求已顶到单实例硬顶(1024 MiB),配额不会再增加,实例可能起不来。建议先停用部分插件再应用。", "mem.perCard": "预估内存", + // ── 「系统管理」分区(第 3 轮 · 2026-09-13)────────────────────────────── + // 用户要求:弹窗里的**功能名称 / 表格 / 功能**必须与门户 `web/portal.html` 一致。 + // ⇒ 分区内不再是自造的 5 项摘要,而是门户的 **6 个功能页**,成对命名: + // `pa.p.*` = 卡片名 / `pa.d.*` = 卡片说明 / `pa.s.*` = 页头副标题 + // (三者的中文逐字取自门户 `renderHome()` 与 `pageHead()`) + // 命名规则:`pa.<域>.<项>`;`{x}` 为占位符,由 `pafmt()` 填充。 "pa.label": "系统管理", - "pa.readonly": "只读", + "pa.homeSub": "与门户同一批功能;点开任一功能,在弹窗内直接管理。", + "pa.group.svc": "服务", + "pa.group.mgmt": "管理", + "pa.back": "← 返回", + "pa.close": "关闭", + "pa.retry": "重试", "pa.loading": "加载中…", "pa.loadingDesc": "正在读取平台状态…", + "pa.loadFailed": "平台状态读取失败", "pa.notAdminTitle": "系统管理仅对管理员显示", "pa.notAdminDesc": "当前账号不是管理员,此处不展示平台信息。", - "pa.loadFailed": "平台状态读取失败", - "pa.retry": "重试", - "pa.openPortal": "打开完整管理台", - "pa.readonlyHint": "点开每一项,在弹窗内直接管理(无需跳转门户)。", - "pa.inst": "当前实例", - "pa.running": "运行中", - "pa.stopped": "未运行", - "pa.port": "端口", - "pa.restarts": "自动重启", - "pa.breaker": "熔断", - "pa.breakerOpen": "已熔断(冷却中)", - "pa.breakerOk": "正常", - "pa.users": "用户", - "pa.total": "合计", - "pa.pending": "待审核", - "pa.disabled": "已禁用", - "pa.storage": "存储", - "pa.storageUsers": "有用量的用户", - "pa.storageNote": "报告每小时刷新", - "pa.pool": "候选池", - "pa.poolCount": "已投放插件", - "pa.runtime": "运行时", - "pa.items": "条目", - "pa.usersTitle": "用户管理", - "pa.colUser": "用户名", - "pa.colRole": "角色", - "pa.colOp": "操作", - "pa.roleAdmin": "管理员", - "pa.roleActive": "已启用", - "pa.rolePending": "待审核", - "pa.roleDisabled": "已禁用", - "pa.opApprove": "通过", - "pa.opDisable": "禁用", - "pa.opEnable": "启用", - "pa.opRemove": "删除", - "pa.confirmRemove": "确认删除该用户?此操作不可撤销。", + "pa.empty": "暂无数据", "pa.done": "已更新", "pa.failed": "操作失败", - "pa.noUsers": "暂无用户", "pa.working": "处理中…", - "pa.colName": "插件", - "pa.colVer": "版本", - "pa.colDesc": "说明", - "pa.colItem": "项目", - "pa.colValue": "值", - "pa.colUsed": "已用", - "pa.detail": "详情", - "pa.close": "关闭" + "pa.sk.delFail": "删除失败", + "pa.p.files": "服务管理", + "pa.d.files": "文件树 + DSH 启动", + "pa.p.keys": "密钥管理", + "pa.d.keys": "全局 API 密钥", + "pa.p.users": "用户管理", + "pa.d.users": "审批 / 禁用 / 删除", + "pa.p.skills": "技能管理", + "pa.d.skills": "共享技能上传", + "pa.p.plugins": "插件管理", + "pa.d.plugins": "功能插件投放", + "pa.p.runtime": "运行环境", + "pa.d.runtime": "共享运行时与工具", + "pa.s.files": "浏览文件、在此文件夹启动 DSH", + "pa.s.keys": "全局 API 密钥(所有用户共用,仅管理员可改)", + "pa.s.users": "审批 / 禁用 / 删除用户", + "pa.s.skills": "共享技能(所有用户可用 · 只读)", + "pa.s.plugins": "功能插件(系统外插件)的投放与管理", + "pa.s.runtime": "实例共享的运行时与工具(服务器装一次,全部用户共享)", + "pa.k.name": "名称", + "pa.k.type": "类型", + "pa.k.size": "大小", + "pa.k.mtime": "修改时间", + "pa.k.op": "操作", + "pa.k.user": "用户名", + "pa.k.role": "角色", + "pa.k.reg": "注册时间", + "pa.k.skill": "技能名", + "pa.k.desc": "说明", + "pa.k.files": "文件", + "pa.k.updated": "更新时间", + "pa.k.plugin": "插件名", + "pa.k.ver": "版本", + "pa.k.deployed": "投放时间", + "pa.k.src": "来源", + "pa.k.script": "安装/升级脚本", + "pa.k.removable": "可卸载", + "pa.k.item": "项目", + "pa.k.value": "值", + "pa.k.used": "已用", + "pa.k.pluginDesc": "插件说明", + "pa.k.cat": "分类", + "pa.k.importKind": "导入方式", + "pa.k.downloads": "下载量", + "pa.k.detail": "详情", + "pa.st.running": "运行中", + "pa.st.stopped": "未运行", + "pa.st.dir": "文件夹", + "pa.st.file": "文件", + "pa.st.enabled": "启用中", + "pa.st.removable": "可卸载", + "pa.st.required": "平台必备", + "pa.role.admin": "管理员", + "pa.role.active": "正常", + "pa.role.pending": "待审核", + "pa.role.disabled": "已禁用", + "pa.op.approve": "通过", + "pa.op.disable": "禁用", + "pa.op.enable": "恢复", + "pa.op.del": "删除", + "pa.op.add": "添加", + "pa.op.select": "启用", + "pa.op.upload": "上传 / 替换", + "pa.op.newFolder": "新建文件夹", + "pa.op.uploadFile": "上传文件", + "pa.op.launch": "启动 DSH", + "pa.op.stop": "停止", + "pa.op.openDsh": "打开 DSH ↗", + "pa.op.search": "搜索", + "pa.op.refreshDir": "重新拉取目录", + "pa.op.import": "导入到平台", + "pa.op.detail": "详情 ↗", + "pa.op.repull": "正在重新拉取…", + "pa.f.launchDir": "启动目录:", + "pa.f.root": "根目录", + "pa.f.rootShort": "根", + "pa.f.newFolderPrompt": "文件夹名称:", + "pa.f.alreadyRunning": "已有运行中的 DSH", + "pa.f.launchFail": "启动失败", + "pa.f.launched": "DSH 已启动", + "pa.f.uploadFail": "上传失败:", + "pa.f.createFail": "创建失败:", + "pa.key.empty": "暂无全局密钥。添加后所有用户的 DSH 才能调用模型。", + "pa.key.namePh": "名称(如 家用 / 服务器)", + "pa.key.invalid": "名称或密钥格式无效", + "pa.key.needBoth": "请填名称和密钥", + "pa.key.confirmDel": "删除这个密钥?", + "pa.u.confirmDel": "删除后不可恢复。输入用户名「{name}」以确认:", + "pa.u.mismatch": "用户名不匹配,已取消", + "pa.u.delFail": "删除失败(admin 不可删除)", + "pa.u.noUsers": "暂无用户", + "pa.sk.hint": "仅支持 .zip:包内单个顶层目录且含 SKILL.md(name 小写连字符 + description)。上传先做安全检测;同名确认后整体替换。", + "pa.sk.empty": "暂无共享技能 —— 上传 .zip 投放第一个", + "pa.sk.loadFail": "加载失败", + "pa.sk.pickZip": "请先选择 .zip 文件", + "pa.sk.zipOnly": "仅支持 .zip 文件", + "pa.sk.tooBig": "文件过大(>150MB)", + "pa.sk.detecting": "检测中…", + "pa.sk.uploadFail": "上传失败:", + "pa.sk.installed": "✓ 已安装技能「{name}」", + "pa.sk.replaceConfirm": "检测到同名技能「{name}」已存在。确认整体替换?\n\n⚠️ 替换将删除旧技能全部文件,不可恢复。", + "pa.sk.canceled": "已取消", + "pa.sk.replaceFail": "替换失败:", + "pa.sk.replaced": "✓ 已整体替换技能「{name}」", + "pa.sk.delConfirm": "删除共享技能「{name}」?所有用户将立即不可用。", + "pa.sk.deleted": "✓ 已删除", + "pa.pl.tabOfficial": "官方推荐插件", + "pa.pl.tabManual": "手动添加 / 管理", + "pa.pl.searchPh": "搜索插件名 / 描述 / npm 包名", + "pa.pl.allCat": "全部分类", + "pa.pl.onlyImportable": "只看可导入", + "pa.pl.hintOfficial": "来源:awesome-dsh-plugin 官方插件目录(dsh 官方社区精选,按下载量排序,取前 300 条)。导入只接受预构建包:官方 npm 包(registry tarball)或作者发布的 release 资产 —— 平台不执行任何第三方构建脚本。标记「需源码构建」的插件暂不支持一键导入,可自行构建后到「手动添加 / 管理」上传。", + "pa.pl.importNpm": "npm 预构建", + "pa.pl.importTarball": "release 资产", + "pa.pl.importSource": "需源码构建", + "pa.pl.publishing": "投放中…", + "pa.pl.published": "✓ 已投放功能插件「{name}」", + "pa.pl.replacedOk": "✓ 已替换功能插件「{name}」", + "pa.pl.trusted": "(已记录信任声明)", + "pa.pl.uploadFail": "上传失败:", + "pa.pl.wlLoading": "加载目录中…(首次会从官方站下载一次,之后读本地缓存)", + "pa.pl.wlFail": "拉取失败", + "pa.pl.wlEmpty": "无匹配", + "pa.pl.wlInfo": "清单 {total} 条 | 可导入 {importable} 条 | 匹配 {count} 条 | 显示 {shown} 条 | 目录缓存更新于 {when}(6 小时内直接复用,不联网)", + "pa.pl.wlStale": " | ⚠ 官方站暂不可达,正在使用本地缓存", + "pa.pl.empty": "暂无已投放插件 —— 从「官方推荐插件」导入,或在上方上传 .tgz", + "pa.pl.repullOk": "✓ 目录已重新拉取(缓存已更新)", + "pa.pl.repullFail": "重新拉取失败(网络或官方站不可达)", + "pa.pl.selInfo": "已选 {n}", + "pa.pl.importPick": "请先勾选插件", + "pa.pl.importMax": "单次最多导入 20 个", + "pa.pl.importConfirm": "将 {n} 个插件投放(收录)到平台?\n\n投放后默认禁用,对用户不生效;用户需在实例「设置 → 功能插件」里自行启用。", + "pa.pl.importing": "导入中…", + "pa.pl.importResult": "投放完成:成功 {ok} / {total}", + "pa.pl.importFailTail": ",失败 {fail}", + "pa.pl.importOkBox": "✓ 已投放 {n} 个插件到平台(默认禁用,用户未启用前不生效)。切到「手动添加 / 管理」可查看或删除。", + "pa.pl.failTitle": "失败 {n} 个", + "pa.pl.importErr": "投放失败:网络错误", + "pa.pl.cardPublish": "投放插件", + "pa.pl.cardPublishSub": "上传 .tgz 收录到平台", + "pa.pl.cardPublished": "已投放插件", + "pa.pl.poolHint": "共 {n} 个 · 全部默认禁用", + "pa.pl.hintManual": "投放 ≠ 生效。这里只是把插件收录到平台,对任何用户都不生效;用户要真正用上,需在自己实例的「设置 → 功能插件」里勾选启用 —— 启用时平台才会把插件装进他的环境,重启实例后生效。", + "pa.pl.hintTgz": "仅支持 .tgz:dsh 插件 bundle(含 package.json,name 为 npm 包名)。上传先做安全检测;同名按替换策略(旧包先删再落新包)。命中 P0 规则时默认拒绝,但会逐条列出命中内容 —— 确认可信可显式声明信任后投放(会记入审计)。", + "pa.pl.pickTgz": "请先选择 .tgz 文件", + "pa.pl.tgzOnly": "仅支持 .tgz 文件", + "pa.pl.delConfirm": "删除已投放插件「{name}」?\n\n将从平台移除;已启用它的用户实例会失去该插件。", + "pa.pl.deleted": "✓ 已删除", + "pa.pl.delFail": "删除失败", + "pa.pl.scanP0": "安全检测命中 P0 规则,已拒绝投放(共 {n} 处)", + "pa.pl.scanCompat": "与当前平台 dsh 版本不兼容,已拒绝投放(共 {n} 条依据)", + "pa.pl.warnP1": "另有 {n} 条 P1 告警(不阻断)", + "pa.pl.trustPh": "信任理由(可选,会记入审计)", + "pa.pl.trustBtn": "我已逐条确认,信任并投放", + "pa.pl.trustNote": "⚠ 只在确认命中确属误报或风险可控时继续。平台会记录「谁 / 何时 / 命中什么 / 理由」。", + "pa.rt.drift": "⚠️ 检测到版本漂移(与基线不一致):{list}", + "pa.rt.driftHint": "若是有意升级,请跑 {script} 刷新基线。", + "pa.rt.ok": "✅ 版本与基线一致", + "pa.rt.okAt": "(基线时间 {t})", + "pa.rt.dir": "目录", + "pa.rt.size": "体积", + "pa.rt.changed": "最近变更", + "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": "加载失败:" }; /** English dictionary, key-set complete against zh. */ var en = { @@ -252,71 +409,201 @@ window.__ModuleLoader__.load({ "retry": "Retry", "requestFailed": "Request failed", "loadFailed": "Failed to load", - "mem.title": "Memory estimate", - "mem.est": "estimate from measured baselines, not a live reading", + "mem.title": "Memory quota", + "mem.est": "estimate (platform quota unavailable; same formula as the orchestrator)", + "mem.real": "actual quota", + "mem.heap": "V8 heap", "mem.now": "now", "mem.planned": "after selection", - "mem.limit": "limit", + "mem.limit": "hard cap", "mem.left": "left", - "mem.over": "over the limit", - "mem.tight": "close to the limit", + "mem.over": "hit the hard cap", + "mem.tight": "close to the hard cap", "mem.safe": "headroom is fine", - "mem.overWarn": "The selection exceeds the per-instance memory limit and the instance may fail to start. Disable some plugins first.", - "mem.perCard": "est. memory", + "mem.overWarn": "The selected plugins need more than the per-instance hard cap (1024 MiB), so the quota will not grow further and the instance may fail to start. Disable some plugins first.", + "mem.perCard": "load cost", "pa.label": "System management", - "pa.readonly": "read-only", + "pa.homeSub": "The same feature set as the portal — open any item to manage it in a popup.", + "pa.group.svc": "Service", + "pa.group.mgmt": "Administration", + "pa.back": "← Back", + "pa.close": "Close", + "pa.retry": "Retry", "pa.loading": "Loading…", "pa.loadingDesc": "Reading platform status…", + "pa.loadFailed": "Failed to read platform status", "pa.notAdminTitle": "System management is admin-only", "pa.notAdminDesc": "This account is not an admin, so platform info is hidden here.", - "pa.loadFailed": "Failed to read platform status", - "pa.retry": "Retry", - "pa.openPortal": "Open full admin console", - "pa.readonlyHint": "Open any item to manage it in a popup — no portal jump.", - "pa.inst": "Current instance", - "pa.running": "Running", - "pa.stopped": "Stopped", - "pa.port": "Port", - "pa.restarts": "Auto-restarts", - "pa.breaker": "Circuit breaker", - "pa.breakerOpen": "Open (cooling down)", - "pa.breakerOk": "OK", - "pa.users": "Users", - "pa.total": "Total", - "pa.pending": "Pending", - "pa.disabled": "Disabled", - "pa.storage": "Storage", - "pa.storageUsers": "Users with usage", - "pa.storageNote": "report refreshes hourly", - "pa.pool": "Candidate pool", - "pa.poolCount": "Published plugins", - "pa.runtime": "Runtime", - "pa.items": "items", - "pa.usersTitle": "User management", - "pa.colUser": "Username", - "pa.colRole": "Role", - "pa.colOp": "Actions", - "pa.roleAdmin": "Admin", - "pa.roleActive": "Active", - "pa.rolePending": "Pending", - "pa.roleDisabled": "Disabled", - "pa.opApprove": "Approve", - "pa.opDisable": "Disable", - "pa.opEnable": "Enable", - "pa.opRemove": "Delete", - "pa.confirmRemove": "Delete this user? This cannot be undone.", + "pa.empty": "No data", "pa.done": "Updated", "pa.failed": "Action failed", - "pa.noUsers": "No users", "pa.working": "Working…", - "pa.colName": "Plugin", - "pa.colVer": "Version", - "pa.colDesc": "Description", - "pa.colItem": "Item", - "pa.colValue": "Value", - "pa.colUsed": "Used", - "pa.detail": "Details", - "pa.close": "Close" + "pa.sk.delFail": "Delete failed", + "pa.p.files": "Service management", + "pa.d.files": "File tree + launch DSH", + "pa.p.keys": "API keys", + "pa.d.keys": "Global API keys", + "pa.p.users": "User management", + "pa.d.users": "Approve / disable / delete", + "pa.p.skills": "Skill management", + "pa.d.skills": "Shared skill upload", + "pa.p.plugins": "Plugin management", + "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.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", + "pa.s.runtime": "Runtimes and tools shared by every instance (installed once, shared by all users)", + "pa.k.name": "Name", + "pa.k.type": "Type", + "pa.k.size": "Size", + "pa.k.mtime": "Modified", + "pa.k.op": "Actions", + "pa.k.user": "Username", + "pa.k.role": "Role", + "pa.k.reg": "Registered", + "pa.k.skill": "Skill", + "pa.k.desc": "Description", + "pa.k.files": "Files", + "pa.k.updated": "Updated", + "pa.k.plugin": "Plugin", + "pa.k.ver": "Version", + "pa.k.deployed": "Published", + "pa.k.src": "Source", + "pa.k.script": "Install/upgrade script", + "pa.k.removable": "Removable", + "pa.k.item": "Item", + "pa.k.value": "Value", + "pa.k.used": "Used", + "pa.k.pluginDesc": "Plugin", + "pa.k.cat": "Category", + "pa.k.importKind": "Import", + "pa.k.downloads": "Downloads", + "pa.k.detail": "Details", + "pa.st.running": "Running", + "pa.st.stopped": "Stopped", + "pa.st.dir": "Folder", + "pa.st.file": "File", + "pa.st.enabled": "Enabled", + "pa.st.removable": "Removable", + "pa.st.required": "Platform-required", + "pa.role.admin": "Admin", + "pa.role.active": "Active", + "pa.role.pending": "Pending", + "pa.role.disabled": "Disabled", + "pa.op.approve": "Approve", + "pa.op.disable": "Disable", + "pa.op.enable": "Restore", + "pa.op.del": "Delete", + "pa.op.add": "Add", + "pa.op.select": "Enable", + "pa.op.upload": "Upload / replace", + "pa.op.newFolder": "New folder", + "pa.op.uploadFile": "Upload file", + "pa.op.launch": "Launch DSH", + "pa.op.stop": "Stop", + "pa.op.openDsh": "Open DSH ↗", + "pa.op.search": "Search", + "pa.op.refreshDir": "Re-fetch catalog", + "pa.op.import": "Import to platform", + "pa.op.detail": "Details ↗", + "pa.op.repull": "Re-fetching…", + "pa.f.launchDir": "Launch folder:", + "pa.f.root": "root", + "pa.f.rootShort": "root", + "pa.f.newFolderPrompt": "Folder name:", + "pa.f.alreadyRunning": "A DSH instance is already running", + "pa.f.launchFail": "Launch failed", + "pa.f.launched": "DSH launched", + "pa.f.uploadFail": "Upload failed: ", + "pa.f.createFail": "Create failed: ", + "pa.key.empty": "No global key yet. Add one so every user's DSH can call the model.", + "pa.key.namePh": "Name (e.g. home / server)", + "pa.key.invalid": "Invalid name or key format", + "pa.key.needBoth": "Enter both name and key", + "pa.key.confirmDel": "Delete this key?", + "pa.u.confirmDel": "Deletion cannot be undone. Type the username \"{name}\" to confirm:", + "pa.u.mismatch": "Username mismatch — cancelled", + "pa.u.delFail": "Delete failed (admin cannot be deleted)", + "pa.u.noUsers": "No users", + "pa.sk.hint": ".zip only: a single top-level directory containing SKILL.md (lowercase-hyphenated name + description). Uploads are security-scanned first; a same-name skill is replaced as a whole after confirmation.", + "pa.sk.empty": "No shared skill yet — upload a .zip to publish the first one", + "pa.sk.loadFail": "Failed to load", + "pa.sk.pickZip": "Choose a .zip file first", + "pa.sk.zipOnly": ".zip files only", + "pa.sk.tooBig": "File too large (>150MB)", + "pa.sk.detecting": "Scanning…", + "pa.sk.uploadFail": "Upload failed: ", + "pa.sk.installed": "✓ Installed skill \"{name}\"", + "pa.sk.replaceConfirm": "A skill named \"{name}\" already exists. Replace it entirely?\n\n⚠️ Replacement deletes all of the old skill's files. This cannot be undone.", + "pa.sk.canceled": "Cancelled", + "pa.sk.replaceFail": "Replace failed: ", + "pa.sk.replaced": "✓ Replaced skill \"{name}\"", + "pa.sk.delConfirm": "Delete shared skill \"{name}\"? It becomes unavailable to all users immediately.", + "pa.sk.deleted": "✓ Deleted", + "pa.pl.tabOfficial": "Recommended plugins", + "pa.pl.tabManual": "Manual upload / manage", + "pa.pl.searchPh": "Search name / description / npm package", + "pa.pl.allCat": "All categories", + "pa.pl.onlyImportable": "Importable only", + "pa.pl.hintOfficial": "Source: the awesome-dsh-plugin official catalog (community picks, sorted by downloads, top 300). Import accepts prebuilt packages only — official npm tarballs or author release assets; the platform runs no third-party build scripts. Entries marked \"needs source build\" cannot be imported directly; build them yourself and upload under \"Manual upload / manage\".", + "pa.pl.importNpm": "npm prebuilt", + "pa.pl.importTarball": "release asset", + "pa.pl.importSource": "needs source build", + "pa.pl.publishing": "Publishing…", + "pa.pl.published": "✓ Published feature plugin \"{name}\"", + "pa.pl.replacedOk": "✓ Replaced feature plugin \"{name}\"", + "pa.pl.trusted": " (trust declaration recorded)", + "pa.pl.uploadFail": "Upload failed: ", + "pa.pl.wlLoading": "Loading catalog… (downloaded from the official site on first open, cached afterwards)", + "pa.pl.wlFail": "Fetch failed", + "pa.pl.wlEmpty": "No match", + "pa.pl.wlInfo": "{total} listed | {importable} importable | {count} matched | {shown} shown | catalog cached {when} (reused within 6 hours, no network)", + "pa.pl.wlStale": " | ⚠ official site unreachable, using the local cache", + "pa.pl.empty": "No published plugin yet — import from \"Recommended plugins\" or upload a .tgz above", + "pa.pl.repullOk": "✓ Catalog re-fetched (cache updated)", + "pa.pl.repullFail": "Re-fetch failed (network or official site unreachable)", + "pa.pl.selInfo": "{n} selected", + "pa.pl.importPick": "Select at least one plugin", + "pa.pl.importMax": "Up to 20 plugins per import", + "pa.pl.importConfirm": "Publish (list) {n} plugins on the platform?\n\nThey stay disabled and have no effect until each user enables them under Settings → Feature plugins.", + "pa.pl.importing": "Importing…", + "pa.pl.importResult": "Publish finished: {ok} / {total} succeeded", + "pa.pl.importFailTail": ", {fail} failed", + "pa.pl.importOkBox": "✓ Published {n} plugins to the platform (disabled by default; no effect until a user enables them). Switch to \"Manual upload / manage\" to inspect or delete them.", + "pa.pl.failTitle": "{n} failed", + "pa.pl.importErr": "Publish failed: network error", + "pa.pl.cardPublish": "Publish a plugin", + "pa.pl.cardPublishSub": "Upload a .tgz to list it on the platform", + "pa.pl.cardPublished": "Published plugins", + "pa.pl.poolHint": "{n} total · all disabled by default", + "pa.pl.hintManual": "Publishing ≠ enabling. This only lists the plugin on the platform — it stays inert for every user. To actually use it, a user must tick it under Settings → Feature plugins in their own instance; the platform then installs it into their environment and it takes effect after an instance restart.", + "pa.pl.hintTgz": ".tgz only: a dsh plugin bundle (with package.json whose name is the npm package name). Uploads are security-scanned first; same-name uploads follow the replace policy (old package removed before the new one lands). A P0 hit is rejected by default, but every hit is listed — if you trust it, declare that explicitly and it will be published (and audited).", + "pa.pl.pickTgz": "Choose a .tgz file first", + "pa.pl.tgzOnly": ".tgz files only", + "pa.pl.delConfirm": "Delete published plugin \"{name}\"?\n\nIt will be removed from the platform; instances that enabled it will lose it.", + "pa.pl.deleted": "✓ Deleted", + "pa.pl.delFail": "Delete failed", + "pa.pl.scanP0": "Security scan hit P0 rules — publishing rejected ({n} findings)", + "pa.pl.scanCompat": "Incompatible with the platform's current dsh version — publishing rejected ({n} findings)", + "pa.pl.warnP1": "{n} further P1 warnings (non-blocking)", + "pa.pl.trustPh": "Reason for trusting (optional, audited)", + "pa.pl.trustBtn": "I reviewed every finding — trust and publish", + "pa.pl.trustNote": "⚠ Continue only if the hits are genuine false positives or the risk is acceptable. The platform records who / when / what was hit / why.", + "pa.rt.drift": "⚠️ Version drift detected (differs from the baseline): {list}", + "pa.rt.driftHint": "If this upgrade was intentional, run {script} to refresh the baseline.", + "pa.rt.ok": "✅ Versions match the baseline", + "pa.rt.okAt": " (baseline {t})", + "pa.rt.dir": "Directory", + "pa.rt.size": "Size", + "pa.rt.changed": "Last change", + "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: " }; /** @@ -385,6 +672,11 @@ window.__ModuleLoader__.load({ ".pa-card .pa-d{font-size:12px;color:var(--dsw-alias-label-tertiary,#6b7280)}", ".pa-cnt{display:inline-block;min-width:18px;padding:1px 6px;margin-left:6px;border-radius:9px;background:rgba(47,111,237,.10);color:var(--dsw-alias-brand-primary,#2f6fed);font-size:12px;font-weight:600;line-height:16px;text-align:center}", ".pa-wrap{overflow:auto;border:1px solid var(--dsw-alias-border-l2,#e3e6ea);border-radius:8px;background:var(--dsw-alias-bg-layer-1,#fff)}", + // 官方推荐插件列表:**拉高到「离弹窗底部约 100px」**(2026-09-13 用户要求)。 + // 弹窗高 = min(92vh,1040px);表上方固定消耗 ≈470px(弹窗头 + 页签 + 工具条 + 说明 + 信息行), + // 表下方还有按钮行 + 结果区 + 内边距 ⇒ 用 `92vh - 470px` 反推,再夹到 [280, 580]: + // 下限 280px 防小屏被压成一条线;上限 580px 对应弹窗触顶 1040px 时的可用高度。 + ".pa-plist{max-height:max(280px,min(calc(92vh - 470px),580px))}", ".pa-tbl{width:100%;border-collapse:collapse;font-size:15px}", ".pa-tbl th{background:var(--dsw-alias-bg-layer-2,#f6f8fa);padding:8px 10px;text-align:left;font-weight:600;white-space:nowrap;border-bottom:1px solid var(--dsw-alias-border-l2,#e3e6ea)}", ".pa-tbl td{padding:7px 10px;border-bottom:1px solid var(--dsw-alias-border-l2,#e3e6ea);color:var(--dsw-alias-label-primary,#24292f)}", @@ -408,6 +700,72 @@ window.__ModuleLoader__.load({ ".pa-overlay{position:fixed;inset:0;z-index:9999;background:rgba(15,28,51,.35);display:flex;align-items:center;justify-content:center;padding:24px}", ".pa-modal{background:var(--dsw-alias-bg-layer-1,#fff);border:1px solid var(--dsw-alias-border-l2,#e3e6ea);border-radius:10px;box-shadow:0 18px 50px -18px rgba(9,24,58,.35);animation:paIn .18s cubic-bezier(.16,1,.3,1)}", "@keyframes paIn{from{opacity:0;transform:translateY(6px) scale(.985)}to{opacity:1;transform:none}}", + + // ── 第 3 轮(2026-09-13):门户 `web/portal.html` **页面级组件**逐条译为 `.pa-*` ── + // 译法与第 2 轮一致:**字号 / 间距 / 圆角 / 动效逐值照抄**,颜色换 dsh 官方 token。 + // 对应关系(门户 → 本节): + // .card → .pa-box | .btn / .btn-primary / .btn:disabled → .pa-btn* + // .page-head → .pa-phead | #view 内边距 → .pa-page + // .pg-tabs/.pg-tab/.pg-cnt → .pa-tabs/.pa-tab/.pa-tcnt(下划线式页内 tab) + // .dsh-bar/.dot → .pa-dshbar/.pa-dot | .pathbar/.crumb → .pa-pathbar/.pa-crumb + // .upload-row/.hint → .pa-row/.pa-hint | .key-row → .pa-keyrow + // .wl-cell/.wl-desc/.wl-meta/.wl-cat/.wl-link → .pa-wl* + // .empty → .pa-empty | .toast → .pa-toast | td.desc/td.act → td.pa-desc/td.pa-act + ".pa-box{background:var(--dsw-alias-bg-layer-1,#fff);border:1px solid var(--dsw-alias-border-l2,#e3e6ea);border-radius:10px;padding:18px}", + ".pa-btn{display:inline-flex;align-items:center;gap:6px;padding:6px 14px;border-radius:8px;font-size:14px;cursor:pointer;border:1px solid var(--dsw-alias-border-l2,#e3e6ea);background:var(--dsw-alias-bg-layer-1,#fff);color:var(--dsw-alias-label-primary,#24292f);transition:all .15s}", + ".pa-btn:hover:not(:disabled){border-color:var(--dsw-alias-brand-primary,#2f6fed);color:var(--dsw-alias-brand-primary,#2f6fed)}", + ".pa-btn.pa-primary{background:var(--dsw-alias-brand-primary,#2f6fed);border-color:var(--dsw-alias-brand-primary,#2f6fed);color:#fff}", + ".pa-btn.pa-primary:hover:not(:disabled){background:#2a5fd0;color:#fff}", + ".pa-btn:disabled{opacity:.5;cursor:default}", + ".pa-phead{display:flex;align-items:center;gap:14px;flex:1;min-width:0}", + ".pa-phead .pa-hd{margin:0}", + ".pa-phead .pa-sub{margin:4px 0 0}", + ".pa-page{padding:18px 20px 28px}", + ".pa-mhead{display:flex;align-items:center;gap:14px;padding:18px 20px 14px;border-bottom:1px solid var(--dsw-alias-border-l2,#e3e6ea);flex:none}", + ".pa-mbody{flex:1;min-height:0;overflow:auto}", + // 「需要多大就多大」:宽度对齐门户功能页(`.page` max-width 1440)、高度吃满视口。 + // 门户首页是 960,但弹窗只在**打开具体功能页**时出现 ⇒ 统一取功能页宽度即可。 + ".pa-modal.pa-wide{width:min(1440px,96vw);height:min(92vh,1040px);display:flex;flex-direction:column;overflow:hidden;padding:0}", + ".pa-tabs{display:flex;gap:22px;border-bottom:1px solid var(--dsw-alias-border-l2,#e3e6ea);margin:0 0 16px}", + ".pa-tab{padding:8px 2px 10px;font-size:15px;color:var(--dsw-alias-label-tertiary,#6b7280);cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-1px;transition:color .15s,border-color .15s;user-select:none;white-space:nowrap}", + ".pa-tab:hover{color:var(--dsw-alias-brand-primary,#2f6fed)}", + ".pa-tab.pa-on{color:var(--dsw-alias-brand-primary,#2f6fed);font-weight:600;border-bottom-color:var(--dsw-alias-brand-primary,#2f6fed)}", + ".pa-tcnt{display:inline-block;min-width:18px;padding:1px 6px;margin-left:6px;border-radius:9px;background:rgba(47,111,237,.10);color:var(--dsw-alias-brand-primary,#2f6fed);font-size:12px;font-weight:600;line-height:16px;text-align:center}", + ".pa-tab:not(.pa-on) .pa-tcnt{background:rgba(107,114,128,.12);color:var(--dsw-alias-label-tertiary,#6b7280)}", + ".pa-dshbar{display:flex;align-items:center;gap:12px;background:var(--dsw-alias-bg-layer-1,#fff);border:1px solid var(--dsw-alias-border-l2,#e3e6ea);border-radius:10px;padding:14px 16px;margin-bottom:16px}", + ".pa-dshbar .pa-stat{font-size:14px}", + ".pa-dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:6px}", + ".pa-dot.pa-on{background:#1fb56a}", + ".pa-dot.pa-off{background:#c4c9d0}", + ".pa-pathbar{display:flex;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap}", + ".pa-crumbs{display:flex;align-items:center;gap:4px}", + ".pa-crumb{color:var(--dsw-alias-brand-primary,#2f6fed);cursor:pointer;font-size:14px}", + ".pa-crumb:hover{text-decoration:underline}", + ".pa-row{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:6px}", + ".pa-row input[type=file]{flex:1;min-width:220px;padding:8px;border:1px solid var(--dsw-alias-border-l2,#e3e6ea);border-radius:8px;font-size:14px;background:var(--dsw-alias-bg-layer-1,#fff);color:var(--dsw-alias-label-primary,#24292f)}", + ".pa-hint{font-size:12px;color:var(--dsw-alias-label-tertiary,#6b7280);margin:0 0 14px;line-height:1.6}", + ".pa-keyrow{display:flex;align-items:center;gap:10px;padding:10px 0;border-bottom:1px solid var(--dsw-alias-border-l2,#e3e6ea)}", + ".pa-keyrow:last-child{border-bottom:none}", + ".pa-wlcell{white-space:normal}", + ".pa-wldesc{font-size:14px;line-height:1.5;color:var(--dsw-alias-label-primary,#24292f);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;max-width:620px}", + ".pa-wldesc.pa-fb{font-weight:600}", + ".pa-wlmeta{font-size:12px;color:var(--dsw-alias-label-tertiary,#6b7280);margin-top:3px;max-width:520px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}", + ".pa-wlcat{font-size:12px;color:var(--dsw-alias-label-tertiary,#6b7280);white-space:nowrap}", + ".pa-wllink{text-decoration:none;white-space:nowrap}", + ".pa-tbl td.pa-desc{max-width:280px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-tertiary,#6b7280)}", + ".pa-tbl td.pa-act,.pa-tbl th.pa-act{text-align:center}", + ".pa-tbl td.pa-cell{white-space:normal}", + ".pa-csep{color:var(--dsw-alias-label-tertiary,#6b7280)}", + // 门户的文件夹行没有任何可点提示(无手型 / 无颜色)⇒ 弹窗内补上,否则文件树没法导航。 + ".pa-dir{cursor:pointer;color:var(--dsw-alias-brand-primary,#2f6fed)}", + ".pa-dir:hover{text-decoration:underline}", + ".pa-empty{padding:48px 20px;text-align:center;color:var(--dsw-alias-label-tertiary,#6b7280);font-size:14px}", + ".pa-toast{position:fixed;bottom:32px;left:50%;transform:translateX(-50%);background:#24292f;color:#fff;padding:10px 18px;border-radius:8px;font-size:14px;z-index:10001;display:none;white-space:pre-wrap;max-width:90vw}", + ".pa-toast.pa-show{display:block}", + ".pa-input{flex:1;min-width:120px;padding:8px 10px;border:1px solid var(--dsw-alias-border-l2,#e3e6ea);border-radius:8px;font-size:14px;background:var(--dsw-alias-bg-layer-1,#fff);color:var(--dsw-alias-label-primary,#24292f)}", + "select.pa-input{cursor:pointer}", + ".pa-check{display:inline-flex;align-items:center;gap:6px;font-size:14px;color:var(--dsw-alias-label-tertiary,#6b7280);cursor:pointer}", + ".pa-note{border:1px solid var(--dsw-alias-border-l2,#e3e6ea);border-radius:8px;padding:12px 14px;font-size:13px;line-height:1.6}", "@media (prefers-reduced-motion: reduce){.pa-card,.pa-modal{animation:none;transition:none}.pa-card:hover{transform:none}}" ].join(""); document.head.appendChild(el); @@ -445,6 +803,12 @@ window.__ModuleLoader__.load({ var savedState = useState([]); var savedIds = savedState[0]; var setSavedIds = savedState[1]; + // 平台**实际下发**的内存配额(档案 84)—— 来自 `/api/dsh/status` 的 `quota` + // (`{memMb, heapMb}`,与编排器 spawn 时用的是同一个值)。有它就以它为准显示; + // 拿不到(老平台 / k8s 模式)才退回本地算式,并在文案里如实标注为估算。 + var quotaState = useState(null); + var quotaInfo = quotaState[0]; + var setQuotaInfo = quotaState[1]; function load() { setLoading(true); @@ -458,6 +822,12 @@ window.__ModuleLoader__.load({ setLoading(false); }) .catch(function () { setLoading(false); setMsg(t("loadFailed")); }); + // 并行取「平台实际配了多少内存」(档案 84)。失败静默 —— 只影响内存条, + // 不该连带把插件列表也报成加载失败。 + fetch(portalHost() + "/api/dsh/status", { credentials: "include" }) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (d) { setQuotaInfo(d && d.quota ? d.quota : null); }) + .catch(function () { setQuotaInfo(null); }); } useEffect(function () { load(); }, []); @@ -577,22 +947,31 @@ window.__ModuleLoader__.load({ * 带来的增量;超过单实例上限时整条转红并给出文字警告。全程只读本地数据、不发请求。 */ function MemBar(props) { + // 进度条的 100% 基准 = **硬顶**(`MEM_MAX_MIB`)。 + // ⚠️ 不能拿「配额」当基准:配额本身是 clamp 出来的(≥384),当基准会让所有实例都满格。 var limit = props.limit; - var nowMiB = props.nowMiB; - var plannedMiB = props.plannedMiB; - var over = plannedMiB > limit; - var tight = !over && plannedMiB > limit * MEM_TIGHT_RATIO; + var quotaNow = props.quotaNow; // 当前实际配额(优先 status 真值) + var quotaPlanned = props.quotaPlanned; // 勾选后的配额 + var rawNow = props.rawNow; // 未 clamp 的原始需求 + var rawPlanned = props.rawPlanned; + var factText = props.factText; // 事实行文案(调用方算好,确认弹窗复用同一条) + // 「顶到硬顶」= 原始需求已超过 MAX ⇒ 再加也不会给更多 ⇒ 这才是真会起不来的情形。 + // (旧版把 clamp 下限 384 当上限判,导致 388 就报「将超出上限」的**误报**。) + var over = rawPlanned > limit; + var tight = !over && rawPlanned > limit * MEM_TIGHT_RATIO; var accent = over ? T.danger : (tight ? T.warn : T.success); - var nowPct = Math.max(0, Math.min(100, (nowMiB / limit) * 100)); - var planPct = Math.max(0, Math.min(100, (plannedMiB / limit) * 100)); - var delta = plannedMiB - nowMiB; + var nowPct = Math.max(0, Math.min(100, (rawNow / limit) * 100)); + var planPct = Math.max(0, Math.min(100, (rawPlanned / limit) * 100)); + var delta = quotaPlanned - quotaNow; var stateText = over ? t("mem.over") : (tight ? t("mem.tight") : t("mem.safe")); - var line = t("mem.now") + " " + nowMiB + " MiB"; + var line = t("mem.now") + " " + quotaNow + " MiB"; if (delta !== 0) { - line += " → " + t("mem.planned") + " " + plannedMiB + " MiB"; + line += " → " + t("mem.planned") + " " + quotaPlanned + " MiB"; } else { line += " / " + t("mem.limit") + " " + limit + " MiB"; } + // 事实行:平台**实际下发**的配额与 V8 堆(来自 /api/dsh/status;文案由调用方传入)。 + var fact = factText; return jsxRuntime.jsxs("div", { style: { display: "flex", flexDirection: "column", gap: "6px", padding: "10px 12px", @@ -622,7 +1001,7 @@ window.__ModuleLoader__.load({ jsxRuntime.jsx("span", { key: "e", style: { fontSize: "12px", color: T.dim, flexBasis: "100%" }, - children: t("mem.est") + children: fact }) ] }), @@ -680,14 +1059,20 @@ window.__ModuleLoader__.load({ return (p.name || "").toLowerCase().indexOf(kw) >= 0 || (p.description || "").toLowerCase().indexOf(kw) >= 0; }); var selected = plugins.filter(function (p) { return p.enabled; }).length; - // 内存预估:基线 + Σ 插件成本。 - // · nowMiB = 服务端快照里「已启用」的那批(savedIds)→ 代表**当前**占用; - // · plannedMiB = 当前勾选状态(用户点一下就变)→ 代表**应用后会是多少**。 - var nowMiB = MEM_BASE_MIB + plugins.reduce(function (sum, p) { - return sum + (savedIds.indexOf(p.id) >= 0 ? memMiB(p) : 0); - }, 0); - var plannedMiB = MEM_BASE_MIB + sumMiB(plugins, "enabled"); - var memOver = plannedMiB > MEM_LIMIT_MIB; + // 内存配额推演(档案 84,**与平台编排器同一套算式**): + // · raw* = 基线 + Σ 成本(未 clamp);quota* = clamp(raw, MIN, MAX) + // · Now = 服务端快照里「已启用」的那批(savedIds)→ 当前状态 + // · Planned = 当前勾选状态(用户点一下就变)→ 应用后会是多少 + // 若 `/api/dsh/status` 给了 `quota.memMb`(平台实际用的值),**以它为准**显示「当前」。 + var rawNow = rawMiB(plugins.filter(function (p) { return savedIds.indexOf(p.id) >= 0; }), "enabled"); + var rawPlanned = rawMiB(plugins, "enabled"); + var quotaNow = (quotaInfo && quotaInfo.memMb != null) ? quotaInfo.memMb : quotaOf(rawNow); + var quotaPlanned = quotaOf(rawPlanned); + var realHeapMb = (quotaInfo && quotaInfo.heapMb != null) ? quotaInfo.heapMb : heapMiBFor(quotaNow); + // 事实行文案:优先展示平台**实际下发**的配额与堆;读不到才如实标注为估算。 + var memFactText = (quotaInfo && quotaInfo.memMb != null) + ? (t("mem.real") + " " + quotaInfo.memMb + " MiB · " + t("mem.heap") + " " + realHeapMb + " MiB") + : t("mem.est"); var inputStyle = { flex: "1", @@ -708,13 +1093,16 @@ window.__ModuleLoader__.load({ var rows = []; var cards = []; - // 内存预估状态条:**卡片列表上方**(2026-09-13 用户要求)—— - // 当前占用 / 勾选后预估 / 是否超上限,让用户在点「应用」之前就知道后果。 + // 内存配额状态条:**卡片列表上方**(2026-09-13 用户要求)—— + // 当前配额 / 勾选后配额 / 是否顶到硬顶,让用户在点「应用」之前就知道后果。 rows.push(jsxRuntime.jsx(MemBar, { key: "__membar", - limit: MEM_LIMIT_MIB, - nowMiB: nowMiB, - plannedMiB: plannedMiB + limit: MEM_MAX_MIB, + quotaNow: quotaNow, + quotaPlanned: quotaPlanned, + rawNow: rawNow, + rawPlanned: rawPlanned, + factText: memFactText })); // 工具行:搜索 + 全选/清空 + 计数(规范 §2.4:工具条内部 gap 8~10px) @@ -815,20 +1203,23 @@ window.__ModuleLoader__.load({ children: meta }) : null, - // 预估内存(2026-09-13 用户要求):数值越大越"贵",用文字色分级 - // (≥60 MiB 红 / ≥30 MiB 黄 / 其余次要色),一眼能看出谁是大头。 - jsxRuntime.jsx("span", { - key: "mem", - style: { marginTop: "4px" }, - children: jsxRuntime.jsx("span", { - style: { - padding: "1px 8px", borderRadius: "10px", - border: "1px solid " + T.border, background: T.field, - color: memMiB(p) >= 60 ? T.danger : (memMiB(p) >= 30 ? T.warn : T.sub) - }, - children: t("mem.perCard") + " ≈ " + memMiB(p) + " MiB" + // 加载成本(2026-09-13 用户要求):数值越大越"贵",按硬顶量级分色 + // (≥256 红 / ≥64 黄 / 其余次要色)。⚠️ 未进 `MEM_TABLE` 的插件平台按 0 计 + // (「宁可少给也不虚高」)⇒ 此时**不显示徽章**,免得满屏「≈ 0 MiB」噪音。 + memMiB(p) > 0 + ? jsxRuntime.jsx("span", { + key: "mem", + style: { marginTop: "4px" }, + children: jsxRuntime.jsx("span", { + style: { + padding: "1px 8px", borderRadius: "10px", + border: "1px solid " + T.border, background: T.field, + color: memMiB(p) >= 256 ? T.danger : (memMiB(p) >= 64 ? T.warn : T.sub) + }, + children: t("mem.perCard") + " ≈ " + memMiB(p) + " MiB" + }) }) - }) + : null ] }), jsxRuntime.jsx("span", { @@ -921,7 +1312,7 @@ window.__ModuleLoader__.load({ // 内存块(**超限时红底 + 警告**,用户 2026-09-13 明确要求)→ 按钮行(取消 / 确认)。 // 点遮罩关闭、点面板 stopPropagation(与 MCN 同款交互)。 if (confirmOpen) { - var overNow = plannedMiB > MEM_LIMIT_MIB; + var overNow = rawPlanned > MEM_MAX_MIB; rows.push(jsxRuntime.jsxs("div", { key: "__confirm", style: { @@ -983,8 +1374,8 @@ window.__ModuleLoader__.load({ jsxRuntime.jsx("div", { key: "n", style: { fontSize: "13px", color: T.sub, fontVariantNumeric: "tabular-nums" }, - children: t("mem.now") + " " + nowMiB + " MiB → " + t("mem.planned") + " " + plannedMiB - + " MiB / " + t("mem.limit") + " " + MEM_LIMIT_MIB + " MiB" + children: t("mem.now") + " " + quotaNow + " MiB → " + t("mem.planned") + " " + quotaPlanned + + " MiB / " + t("mem.limit") + " " + MEM_MAX_MIB + " MiB" }), overNow ? jsxRuntime.jsx("div", { @@ -996,7 +1387,7 @@ window.__ModuleLoader__.load({ jsxRuntime.jsx("div", { key: "e", style: { fontSize: "12px", color: T.dim }, - children: t("mem.est") + children: memFactText }) ] }), @@ -1049,243 +1440,918 @@ window.__ModuleLoader__.load({ ); }); - // ── R2 · 平台管理(只读就地化;档案 81 §9.2b「原生弹窗」形态)──────────────── - // 形态:**原生渲染**(非 iframe)——复用本插件的 token 与 06-工作台UI规范 字号阶梯。 - // 数据:直接调平台只读 API(跨子域 fetch + credentials,门户已加 CORS 白名单)。 - // 范围:**只读子集**(实例 / 用户 / 存储 / 候选池 / 运行时); - // **写操作一律回跳平台管理台** —— 不复制门户的写 UI(否则双源维护 + 扩大执行面)。 - // 可见性:**仅 admin**(先 /api/auth/me 判角色;非 admin 只渲染一行说明)。 - function PaCard(props) { + // ═══════════════════════════════════════════════════════════════════════════ + // 「系统管理」页面族 —— **逐函数移植门户 `web/portal.html` 的