feat(web): 模型设置页复刻官方交互 + 官方推荐插件按 dsh 版本过滤(档案 91/92/93)

档案 91 ——「模型设置」页折行 + 复刻官方交互(插件 0.3.15)
· 折行根因不是"窄":原「我已添加的厂家」是 4 列表格(列宽 auto)⇒ 名称/状态/两按钮三处折行同源;
  改官方 dsh-client-ui-settings-models 的**卡片行**(rowHead + rowActions margin-left:auto,两侧 nowrap)
· 「新增」改官方**两步式**:默认两个虚线按钮 → 点开才出卡片,主字段只剩「API 密钥」
  (官方原文:页面从不询问环境变量名);次要字段收进 <details>「自定义设置」
· CSS 数值逐值照抄官方 CSS 模块;顺修「由 admin 配置」「密钥可留空」两处错文案,补删除二次确认

档案 92 —— 官方推荐插件列表按 dsh 版本过滤(插件 0.3.16)
· 官方目录不带 dsh 版本字段 ⇒ 逐包查 npm 声明与平台真实版本比,分类 match/none/newer/older/unknown
· 默认隐藏「仅兼容更旧版本」,「需更高版本」橙色标注;勾选框为逃生口;说明行给隐藏计数(不静默消失)
· ⚠️ 判 satisfies 必须带 includePrerelease(平台是 prerelease,默认语义会把在跑的 dshmarket 之类判成更旧)

档案 93 —— 兼容判定收口(服务器 CI 由红转绿)
· 「单测长期红」真相 = 服务器源码落后于 git(本机 48/0 全绿)⇒ 同步即修,非代码问题
· 伞包 @deepseek-ai/dsh 收口到 dsh-install.platformPackageVersion()(它不在自己的 node_modules 里,
  原先恒判 null ⇒ 声明在伞包上的"要求更高版本"全被放行);闸门与列表共用同一份版本表
· 闸门判据 A 加 prerelease 兜底:实测抽样 300 里 prerelease-artifact 117 条(≈39% 假阳性,
  含在跑的 dshmarket)⇒ 容忍下能满足则只计数(prereleaseOnly)不阻断

校验
· 新增 scripts/verify-dsh-compat.mjs(21 条回归锚点)与 verify-models-dict.mjs / verify-models-render.cjs
· verify-platform-admin-section.mjs:断言从旧 UI(表格/下拉「自定义厂家…」)改成断言新 UI
  (含模拟点击两个新增入口 + 选中目录厂家)
· 两个仓库外的动作:服务器 npm run build + ci.sh(CI OK)+ systemctl restart dshs;插件 0.3.16 已铺发

⚠️ 本提交未包含 src/supervisor/orchestrator.ts —— 那是别人在途的内存配额改动
(BASE 160→448 / MIN 384→512 / MAX 1024→1536 / mcn 128→256),其对应的客户端常量尚未同步,
本机 verify-mem-model 因此有 4 项红;提交后仓库树两边均为旧值,该断言在 HEAD 上一致。
This commit is contained in:
admin committed 2026-09-14 21:13:32 +08:00
1 parent 1d72e8f9c8
commit 29c57a8207
11 files changed
+1468 -203

No files matched your search

+1 -1
View File
@@ -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 && node scripts/verify-model-landing.mjs && node scripts/verify-dsh-install.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 && node scripts/verify-dsh-install.mjs && node scripts/verify-models-dict.mjs && node scripts/verify-models-render.cjs && node scripts/verify-dsh-compat.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",
+599 -165
View File
@@ -321,7 +321,7 @@ window.__ModuleLoader__.load({
"pa.pl.searchPh": "搜索插件名 / 描述 / npm 包名",
"pa.pl.allCat": "全部分类",
"pa.pl.onlyImportable": "只看可导入",
"pa.pl.hintOfficial": "来源:awesome-dsh-plugin 官方插件目录(dsh 官方社区精选,按下载量排序,取前 300 条)。导入只接受预构建包:官方 npm 包(registry tarball)或作者发布的 release 资产 —— 平台不执行任何第三方构建脚本。标记「需源码构建」的插件暂不支持一键导入,可自行构建后到「手动添加 / 管理」上传。",
"pa.pl.hintOfficial": "来源:awesome-dsh-plugin 官方插件目录(dsh 官方社区精选,按下载量排序,取前 300 条)。导入只接受预构建包:官方 npm 包(registry tarball)或作者发布的 release 资产 —— 平台不执行任何第三方构建脚本。标记「需源码构建」的插件暂不支持一键导入,可自行构建后到「手动添加 / 管理」上传。列表**已按当前 dsh 版本过滤**:只显示「匹配」与「需更高版本」(后者橙色标注,升级 dsh 前用不了);仅声明了更旧版本的会被隐藏(勾「含仅兼容更旧版本的」可查)。",
"pa.pl.importNpm": "npm 预构建",
"pa.pl.importTarball": "release 资产",
"pa.pl.importSource": "需源码构建",
@@ -335,6 +335,25 @@ window.__ModuleLoader__.load({
"pa.pl.wlEmpty": "无匹配",
"pa.pl.wlInfo": "清单 {total} 条 | 可导入 {importable} 条 | 匹配 {count} 条 | 显示 {shown} 条 | 目录缓存更新于 {when}(6 小时内直接复用,不联网)",
"pa.pl.wlStale": " | ⚠ 官方站暂不可达,正在使用本地缓存",
// ── 档案 92:按 dsh 版本过滤官方推荐列表(用户 2026-09-14 要求「只能显示匹配当前
// dsh 版本 和 超过当前版本 的插件,超过的要明确标注」)──
// 判定在平台侧做(官方目录**不带** dsh 版本字段 ⇒ 逐包查 npm 声明),
// 口径与实测依据见 `src/web/plugin-dsh-compat.ts` 头部注释。
"pa.pl.compatCol": "dsh 版本",
"pa.pl.showAllCompat": "含仅兼容更旧版本的",
"pa.pl.cmpMatch": "匹配",
"pa.pl.cmpNewer": "需更高版本",
"pa.pl.cmpNone": "未声明",
"pa.pl.cmpOlder": "仅兼容更旧",
"pa.pl.cmpUnknown": "未验证",
"pa.pl.cmpMatchTip": "声明范围满足当前 dsh {cur}。",
"pa.pl.cmpNewerTip": "该插件声明需要 dsh ≥ {v}(当前 {cur})—— 升级 dsh 之前装不上。",
"pa.pl.cmpNoneTip": "该插件没有声明任何 @deepseek-ai/* 依赖,据此判不了(≠ 不兼容)。",
"pa.pl.cmpOlderTip": "该插件只声明了更旧的 dsh 版本;实测未必不能跑,但不属于「兼容当前版本」。",
"pa.pl.cmpUnknownTip": "没取到该包的元数据(网络或 registry 问题),未做判断 —— 不是「不兼容」。",
"pa.pl.cmpHidden": " | 已按 dsh {cur} 过滤:**本次候选中**隐藏 {n} 个仅兼容更旧版本的插件",
"pa.pl.cmpAll": " | 未按 dsh 版本过滤",
"pa.pl.cmpWarming": " | 部分条目「未验证」:兼容判定仍在后台预热,稍后重新搜索即可",
"pa.pl.empty": "暂无已投放插件 —— 从「官方推荐插件」导入,或在上方上传 .tgz",
"pa.pl.repullOk": "✓ 目录已重新拉取(缓存已更新)",
"pa.pl.repullFail": "重新拉取失败(网络或官方站不可达)",
@@ -385,62 +404,84 @@ window.__ModuleLoader__.load({
// 三条口径(用户 2026-09-13 定,勿再当选择题):① 条目各自开关、可同时启用
// ② admin 配的共享模型**也列在这里**、用户可开关 ③ 用哪个模型在 dsh 对话框选。
"ms.title": "模型设置",
"ms.sub": "管理你自用的模型厂家:每条可单独开关、可同时启用;具体用哪个模型,在对话界面的模型选择器里选。保存后需重启实例生效。",
"ms.sub": "填入各提供方的 API 密钥即可使用其模型:每条可单独开关、可同时启用;具体用哪个模型,在对话界面的模型选择器里选。保存后需重启实例生效。",
"ms.loadFailed": "加载失败,请刷新重试",
"ms.saveFailed": "保存失败,请重试",
"ms.saved": "已保存,重启实例后生效",
"ms.needNameKey": "请填写名称与 API Key",
"ms.needModels": "自定义厂家至少要填一个模型",
"ms.saved": "已保存 —— 重启实例后生效",
"ms.needName": "请填写显示名称",
"ms.needKey": "请填写 API 密钥",
"ms.needUrl": "自定义提供方需要填写 API 地址。",
"ms.needModels": "自定义提供方至少需要一个模型。",
"ms.shared": "平台共享模型",
"ms.sharedOwner": "由 {who} 配置",
"ms.sharedOwnerMe": "由你配置",
"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.pickProvider": "厂家:",
"ms.optBuiltin": "内置 DeepSeek(平台共享)",
"ms.optCustom": "自定义厂家(OpenAI 兼容网关)…",
"ms.grpCn": "中国大陆",
"ms.grpIntl": "国际",
"ms.catalogHint": "端点与 {n} 个模型由官方目录提供,你只需填 API 密钥。",
"ms.showModels": "看看自带哪些模型",
"ms.hideModels": "收起模型清单",
"ms.builtinPickHint": "内置 DeepSeek:平台内置通道;密钥留空则回落到平台共享密钥。",
"ms.needUrl": "请填 Endpoint",
"ms.save": "保存",
"ms.list": "我已添加的厂家",
"ms.col.provider": "厂家",
"ms.col.desc": "说明",
"ms.col.state": "状态",
"ms.col.act": "操作",
"ms.empty": "还没有厂家 —— 在下面选一个添加即可",
"ms.list": "我已添加的提供方",
"ms.empty": "还没有添加任何提供方 —— 用下面的按钮添加。",
"ms.tagBuiltin": "内置",
"ms.tagCatalog": "目录",
"ms.tagCustom": "自定义",
"ms.kindBuiltin": "内置 DeepSeek",
"ms.builtinHint": "平台内置通道,无需 endpoint",
"ms.catalogRow": "官方目录厂家(端点 / 协议 / 模型由目录提供)",
"ms.on": "已启用",
"ms.off": "已停用",
"ms.builtinHint": "平台内置通道,无需端点",
"ms.catalogRow": "官方目录提供方(端点 / 协议 / 模型由目录提供)",
"ms.stateOn": "已启用",
"ms.stateOff": "已停用",
"ms.enable": "启用",
"ms.disable": "停用",
"ms.del": "删除",
"ms.delTitle": "删除 {who}?",
"ms.delDesc": "删除 {who} 会移除该条目的配置与已存储的 API 密钥,且无法撤销。",
"ms.delConfirm": "删除 {who}",
"ms.addProvider": "添加提供方",
"ms.addCustom": "添加自定义提供方",
"ms.provider": "提供方",
"ms.keyInput": "API 密钥",
"ms.keyPlaceholder": "输入 API 密钥",
"ms.keyPlaceholderBuiltin": "输入你的 DeepSeek API 密钥",
"ms.keyHintCatalog": "端点与 {n} 个模型由官方目录提供,你只需填 API 密钥。",
"ms.customized": "自定义设置",
"ms.displayName": "显示名称",
"ms.displayNamePlaceholder": "留空时使用提供方名称",
"ms.baseUrl": "API 地址",
"ms.baseUrlPlaceholder": "https://gateway.example/v1",
"ms.customApi": "API 协议",
"ms.customApiUnset": "未选择",
"ms.customTitle": "自定义提供方",
"ms.customRoute": "Provider ID",
"ms.customRouteHint": "以小写字母开头的标识,在请求中唯一标识该提供方,并用于派生凭据名。",
"ms.customRouteInvalid": "需以小写字母开头,之后可用小写字母、数字和短横线。",
"ms.catalogModels": "模型目录",
"ms.catalogModelsMeta": "由官方目录提供,共 {n} 个模型:",
"ms.addModel": "添加模型",
"ms.removeModel": "删除模型",
"ms.modelIdPlaceholder": "模型 ID",
"ms.modelEmpty": "尚未添加模型 —— 至少需要一个,否则模型选择器里不会出现该提供方。",
"ms.advancedHint": "其余字段在 settings.yaml 中,请直接编辑对应段。",
"ms.cancel": "取消",
"ms.create": "创建提供方",
"ms.creating": "创建中…",
"ms.save": "保存",
"ms.saving": "保存中…",
"ms.showModels": "查看模型清单",
"ms.hideModels": "收起模型清单",
"ms.optBuiltin": "内置 DeepSeek(平台共享)",
"ms.builtinPickHint": "内置 DeepSeek:平台内置通道,固定官方端点,无需填 API 地址。",
"ms.grpCn": "中国大陆",
"ms.grpIntl": "国际",
"ms.e.name": "名称不合法",
"ms.e.key": "API Key 不合法(只允许字母数字与 - _ .)",
"ms.e.url": "Endpoint 必须以 http(s):// 开头",
"ms.e.route": "厂家标识不合法(小写字母/数字/短横线,字母开头)",
"ms.e.key": "API 密钥不合法(只允许字母数字与 - _ .)",
"ms.e.url": "API 地址必须以 http(s):// 开头",
"ms.e.route": "Provider ID 不合法(小写字母/数字/短横线,字母开头)",
"ms.e.api": "不支持该协议",
"ms.e.models": "请至少填一个模型 id",
"ms.e.taken": "该厂家标识已被占用,换一个",
"ms.e.provider": "官方目录里没有这个厂家",
// 目录可读性(2026-09-14):读不到时必须**如实说明**,别让用户以为是"功能没做"
"ms.catUnreadable": "⚠️ 平台未能读取官方厂家目录({dir})—— 当前只能使用内置 DeepSeek 或自定义厂家。请联系管理员检查安装路径配置。",
"ms.catEmpty": "官方厂家目录为空(该部署可能未随附 pi-ai 厂家数据)—— 当前只能使用内置 DeepSeek 或自定义厂家。"
"ms.e.taken": "该提供方标识已被占用,换一个",
"ms.e.provider": "官方目录里没有这个提供方",
"ms.catUnreadable": "⚠️ 平台未能读取官方提供方目录({dir})—— 当前只能使用内置 DeepSeek 或自定义提供方。请联系管理员检查安装路径配置。",
"ms.catEmpty": "官方提供方目录为空(该部署可能未随附 pi-ai 提供方数据)—— 当前只能使用内置 DeepSeek 或自定义提供方。"
};
/** English dictionary, key-set complete against zh. */
var en = {
@@ -618,7 +659,7 @@ window.__ModuleLoader__.load({
"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.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\". This list is **filtered by the current dsh version**: only \"Match\" and \"Needs newer\" entries are shown (the latter in orange — unusable until dsh is upgraded); entries that only declare older versions are hidden (tick \"Include older-only\" to see them).",
"pa.pl.importNpm": "npm prebuilt",
"pa.pl.importTarball": "release asset",
"pa.pl.importSource": "needs source build",
@@ -632,6 +673,21 @@ window.__ModuleLoader__.load({
"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.compatCol": "dsh version",
"pa.pl.showAllCompat": "Include older-only",
"pa.pl.cmpMatch": "Match",
"pa.pl.cmpNewer": "Needs newer",
"pa.pl.cmpNone": "Undeclared",
"pa.pl.cmpOlder": "Older only",
"pa.pl.cmpUnknown": "Unverified",
"pa.pl.cmpMatchTip": "The declared range covers the current dsh {cur}.",
"pa.pl.cmpNewerTip": "This plugin requires dsh ≥ {v} (current {cur}) — it cannot be installed before upgrading.",
"pa.pl.cmpNoneTip": "This plugin declares no @deepseek-ai/* dependency, so nothing can be judged from it (≠ incompatible).",
"pa.pl.cmpOlderTip": "This plugin only declares older dsh versions; it may still run, but it is not declared-compatible with the current one.",
"pa.pl.cmpUnknownTip": "Package metadata could not be fetched (network or registry issue) — no judgement was made, which is not the same as incompatible.",
"pa.pl.cmpHidden": " | filtered by dsh {cur}: {n} older-only plugin(s) hidden **in this candidate set**",
"pa.pl.cmpAll": " | not filtered by dsh version",
"pa.pl.cmpWarming": " | some entries are “Unverified”: the compatibility check is still warming up in the background — search again shortly",
"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)",
@@ -675,57 +731,81 @@ window.__ModuleLoader__.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.sub": "Enter each provider's API key to use its models: every 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.needName": "Please fill in a display name",
"ms.needKey": "Please fill in an API key",
"ms.needUrl": "A custom provider needs a base URL.",
"ms.needModels": "A custom provider needs at least one model.",
"ms.shared": "Platform-shared model",
"ms.sharedOwner": "Configured by {who}",
"ms.sharedOwnerMe": "Configured by you",
"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.pickProvider": "Provider:",
"ms.optBuiltin": "Built-in DeepSeek (platform-shared)",
"ms.optCustom": "Custom provider (OpenAI-compatible gateway)…",
"ms.grpCn": "Mainland China",
"ms.grpIntl": "International",
"ms.catalogHint": "Endpoint and {n} models come from the official catalog — you only need the API key.",
"ms.showModels": "Show bundled models",
"ms.hideModels": "Hide models",
"ms.builtinPickHint": "Built-in DeepSeek: the platform channel; leave the key empty to fall back to the platform-shared one.",
"ms.needUrl": "Endpoint is required",
"ms.save": "Save",
"ms.list": "My providers",
"ms.col.provider": "Provider",
"ms.col.desc": "Details",
"ms.col.state": "State",
"ms.col.act": "Actions",
"ms.empty": "No providers yet — pick one below to add",
"ms.empty": "No providers yet — use the buttons below to add one.",
"ms.tagBuiltin": "Built-in",
"ms.tagCatalog": "Catalog",
"ms.tagCustom": "Custom",
"ms.kindBuiltin": "Built-in DeepSeek",
"ms.builtinHint": "Platform channel; no endpoint needed",
"ms.catalogRow": "Catalog provider (endpoint/protocol/models from the catalog)",
"ms.on": "Enabled",
"ms.off": "Disabled",
"ms.stateOn": "Enabled",
"ms.stateOff": "Disabled",
"ms.enable": "Enable",
"ms.disable": "Disable",
"ms.del": "Delete",
"ms.delTitle": "Delete {who}?",
"ms.delDesc": "Deleting {who} removes its configuration and stored API key. This cannot be undone.",
"ms.delConfirm": "Delete {who}",
"ms.addProvider": "Add provider",
"ms.addCustom": "Add a custom provider",
"ms.provider": "Provider",
"ms.keyInput": "API key",
"ms.keyPlaceholder": "Enter your API key",
"ms.keyPlaceholderBuiltin": "Enter your DeepSeek API key",
"ms.keyHintCatalog": "Endpoint and {n} models come from the official catalog — you only need the API key.",
"ms.customized": "Customized settings",
"ms.displayName": "Display name",
"ms.displayNamePlaceholder": "Uses the provider name when empty",
"ms.baseUrl": "Base URL",
"ms.baseUrlPlaceholder": "https://gateway.example/v1",
"ms.customApi": "API protocol",
"ms.customApiUnset": "Not selected",
"ms.customTitle": "Custom provider",
"ms.customRoute": "Provider ID",
"ms.customRouteHint": "Lowercase identifier, starting with a letter, that uniquely names this provider in requests and as its credential name.",
"ms.customRouteInvalid": "Start with a lowercase letter; then lowercase letters, digits, and dashes.",
"ms.catalogModels": "Model catalog",
"ms.catalogModelsMeta": "Supplied by the official catalog — {n} models in total:",
"ms.addModel": "Add model",
"ms.removeModel": "Delete model",
"ms.modelIdPlaceholder": "Model ID",
"ms.modelEmpty": "No models yet — at least one is required, otherwise this provider will not show up in the model selector.",
"ms.advancedHint": "Other fields live in settings.yaml; edit that section directly.",
"ms.cancel": "Cancel",
"ms.create": "Create provider",
"ms.creating": "Creating…",
"ms.save": "Save",
"ms.saving": "Saving…",
"ms.showModels": "Show models",
"ms.hideModels": "Hide models",
"ms.optBuiltin": "Built-in DeepSeek (platform-shared)",
"ms.builtinPickHint": "Built-in DeepSeek: the platform channel with a fixed official endpoint — no base URL needed.",
"ms.grpCn": "Mainland China",
"ms.grpIntl": "International",
"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.url": "The base URL 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",
"ms.e.taken": "That provider ID is taken — pick another",
"ms.e.provider": "That provider is not in the official catalog",
"ms.catUnreadable": "⚠️ The platform could not read the official provider catalog ({dir}) — only the built-in DeepSeek and custom providers are available. Ask your administrator to check the install-path configuration.",
"ms.catEmpty": "The official provider catalog is empty (this deployment may not ship the pi-ai provider data) — only the built-in DeepSeek and custom providers are available."
@@ -891,7 +971,66 @@ window.__ModuleLoader__.load({
"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}}"
// ── 档案 91(2026-09-14):「模型设置」页面视觉层 —— **照抄官方 `dsh-client-ui-settings-models`
// 的 CSS 模块**(官方类名带 CSS Module 哈希前缀 `zGbnIq_`,此处去哈希、前缀改 `ms-`)。
// 圆角 / 字号 / 行高 / 内边距 / gap **逐值照抄**;颜色走同一批 dsh token `--dsw-*`
// (官方原文用的就是这批 token,故连 token 名都一致),只补一个硬编码兜底色。
//
// 为什么把表格换成卡片行:表格 4 列是**自动列宽**,「内置 DeepSeek」和「停用/删除」
// 两个按钮会被挤到换行(2026-09-14 用户截图);官方这套 `rowHead + margin-left:auto`
// +`white-space:nowrap` 的卡片行**结构上不可能换行**,顺带把整页拉齐到官方观感。
".ms-rows{display:flex;flex-direction:column;gap:8px;margin:12px 0 16px;padding:0;list-style:none}",
".ms-rowCard{border:.5px solid var(--dsw-alias-border-l4,#e3e6ea);border-radius:16px;display:flex;flex-direction:column;gap:12px;padding:12px 14px}",
".ms-rowHead{display:flex;align-items:center;gap:10px}",
".ms-rowIdentity{display:inline-flex;align-items:center;gap:6px;min-width:0}",
".ms-rowName{color:var(--dsw-alias-label-primary,#24292f);font-size:14px;font-weight:500;line-height:22px;white-space:nowrap}",
".ms-rowTag{border:.5px solid var(--dsw-alias-border-l3,#d0d5dd);color:var(--dsw-alias-label-secondary,#6b7280);border-radius:4px;flex:none;padding:1px 6px;font-size:11px;line-height:16px;white-space:nowrap}",
".ms-dot{box-sizing:border-box;border-radius:50%;flex:none;width:8px;height:8px;display:inline-block}",
".ms-dot.on{background:var(--dsw-alias-state-success-primary,#1a7f37)}",
".ms-dot.idle{background:var(--dsw-alias-label-dimmed,#c4c9d0)}",
".ms-rowActions{display:inline-flex;align-items:center;gap:4px;margin-left:auto;white-space:nowrap}",
".ms-rowMeta{margin:0;font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary,#6b7280);word-break:break-all}",
".ms-btn{box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;gap:4px;height:36px;padding:0 14px;border:none;border-radius:18px;background:transparent;color:var(--dsw-alias-label-primary,#24292f);font-family:inherit;font-size:14px;line-height:22px;cursor:pointer;transition:background .15s,border-color .15s,color .15s}",
".ms-btn.outline{border:.5px solid var(--dsw-alias-border-l3,#d0d5dd)}",
".ms-btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,rgba(9,24,58,.05))}",
".ms-btn.primary{background:var(--dsw-alias-button-primary-fill,#2f6fed);color:#fff}",
".ms-btn.danger{color:var(--dsw-alias-state-error-primary,#d93026)}",
".ms-btn:disabled{opacity:.4;cursor:default}",
".ms-rowActions .ms-btn{height:28px;border-radius:14px;padding:0 10px;font-size:12px;line-height:18px}",
".ms-addBlock{display:flex;flex-direction:column;gap:12px}",
".ms-addActions{display:flex;flex-wrap:wrap;gap:10px}",
".ms-addBtn{box-sizing:border-box;flex:1 1 0;min-width:180px;height:44px;display:inline-flex;align-items:center;justify-content:center;gap:6px;border:1px dashed var(--dsw-alias-border-l3,#d0d5dd);border-radius:16px;background:transparent;color:var(--dsw-alias-label-primary,#24292f);font-family:inherit;font-size:14px;cursor:pointer;transition:background .15s,border-color .15s,color .15s}",
".ms-addBtn:hover:not(:disabled){border-color:var(--dsw-alias-brand-primary,#2f6fed);color:var(--dsw-alias-brand-primary,#2f6fed);background:var(--dsw-alias-interactive-bg-hover,rgba(47,111,237,.05))}",
".ms-addBtn:disabled{opacity:.4;cursor:default}",
".ms-card{background:var(--dsw-alias-bg-layer-2,#f6f8fa);border-radius:12px;display:flex;flex-direction:column;gap:14px;padding:14px 16px}",
".ms-field{display:flex;flex-direction:column;gap:6px}",
".ms-fieldLabel{display:inline-flex;align-items:center;gap:10px;color:var(--dsw-alias-label-secondary,#6b7280);font-size:12px;font-weight:500;line-height:18px}",
".ms-input{box-sizing:border-box;width:100%;height:32px;padding:0 10px;border:.5px solid var(--dsw-alias-border-l4,#e3e6ea);border-radius:8px;background:var(--dsw-alias-bg-layer-1,#fff);color:var(--dsw-alias-label-primary,#24292f);font-family:inherit;font-size:14px;line-height:22px}",
".ms-input:focus{border-color:var(--dsw-alias-brand-primary,#2f6fed);outline:none}",
".ms-input::placeholder{color:var(--dsw-alias-label-dimmed,#9aa1ab)}",
"select.ms-input{cursor:pointer;max-width:320px}",
".ms-editorActions{display:flex;justify-content:flex-end;gap:8px}",
".ms-hint{margin:0;font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary,#6b7280)}",
".ms-err{margin:0;font-size:12px;line-height:18px;color:var(--dsw-alias-state-error-primary,#d93026)}",
".ms-ok{margin:0;font-size:12px;line-height:18px;color:var(--dsw-alias-state-success-primary,#1a7f37)}",
".ms-details{border-top:.5px solid var(--dsw-alias-border-l2,#e3e6ea);padding-top:10px}",
".ms-summary{display:flex;align-items:center;gap:6px;width:fit-content;margin-left:-4px;padding:2px 4px;border-radius:6px;color:var(--dsw-alias-label-secondary,#6b7280);font-size:12px;font-weight:500;line-height:18px;cursor:pointer;list-style:none}",
".ms-summary::-webkit-details-marker{display:none}",
".ms-summary::before{content:'';width:5px;height:5px;border-bottom:1.5px solid;border-right:1.5px solid;transform:rotate(-45deg) translate(-1px,-1px);transition:transform .12s}",
".ms-details[open]>.ms-summary::before{transform:rotate(45deg) translate(-1px,-1px)}",
".ms-summary:hover{color:var(--dsw-alias-label-primary,#24292f)}",
".ms-detailsBody{display:flex;flex-direction:column;gap:12px;padding-top:12px}",
".ms-modelList{display:flex;flex-direction:column;gap:8px}",
".ms-modelEntry{border:.5px solid var(--dsw-alias-border-l4,#e3e6ea);border-radius:10px;padding:6px}",
".ms-modelRow{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:6px}",
".ms-iconBtn{box-sizing:border-box;width:28px;height:28px;display:inline-flex;align-items:center;justify-content:center;border:none;border-radius:6px;background:transparent;color:var(--dsw-alias-label-tertiary,#6b7280);font-family:inherit;font-size:14px;line-height:1;cursor:pointer}",
".ms-iconBtn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,rgba(9,24,58,.05));color:var(--dsw-alias-label-primary,#24292f)}",
".ms-iconBtn.danger:hover:not(:disabled){background:rgba(217,48,38,.08);color:var(--dsw-alias-state-error-primary,#d93026)}",
".ms-chips{display:flex;flex-wrap:wrap;gap:6px;margin:0;padding:0;list-style:none}",
".ms-chip{border:.5px solid var(--dsw-alias-border-l2,#e3e6ea);border-radius:6px;padding:2px 7px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary,#6b7280)}",
"@media (prefers-reduced-motion: reduce){.pa-card,.pa-modal{animation:none;transition:none}.pa-card:hover{transform:none}.ms-summary::before{transition:none}}"
].join("");
document.head.appendChild(el);
return function () { el.remove(); };
@@ -1614,15 +1753,31 @@ window.__ModuleLoader__.load({
var msgState = useState("");
var msg = msgState[0];
var setMsg = msgState[1];
// 「新增」流程的状态 —— **官方同构:虚线按钮 → 卡片**,同一时刻只开一张。
// `mode`:"" = 未开始(露两个虚线按钮)/"add" = 添加提供方/"custom" = 自定义提供方。
// 官方原文:「每一类卡片各自持有自己的展开状态,因此关掉其中一张绝不会丢弃另一张里的草稿」
// —— 我们只有一张卡,关掉即整份草稿作废(`closeAdd()`)。
var modeState = useState("");
var mode = modeState[0];
var setMode = modeState[1];
var fPick = useState("");
var fName = useState("");
var fKey = useState("");
var fName = useState("");
var fRoute = useState("");
var fUrl = useState("");
var fApi = useState("");
var fModels = useState("");
// 模型目录 = **行数组**(官方 `ModelListEditor` 的形态)。平台后端 `models` 就是
// `string[]`(`keyAddSchema`),所以只收模型 ID —— 不假装支持官方的
// 「显示名称 / 上下文窗口 / 最大输出」三列(那些字段无处落库)。
var fModels = useState([""]);
var fShow = useState(false);
// 后端给的**厂家目录可读性**诊断:读不到时要说清"为什么只剩两项",
// 删除确认(官方 `deleteDialog`:**点名该提供方**再删)。
// 为什么必须有:删条目 = 连已存的 API 密钥一起删;而「删除」就挨着「停用」
// (2026-09-14 用户截图里这两个按钮还被挤到换行)⇒ 误点的代价不可逆。
var delState = useState(null);
var delTarget = delState[0];
var setDelTarget = delState[1];
// 后端给的**提供方目录可读性**诊断:读不到时要说清"为什么只剩两项",
// 而不是静默给个空列表(2026-09-14:这个静默曾让一个 P1 布局缺陷长期不可见)。
var catState = useState(null);
var catalog = catState[0];
@@ -1666,14 +1821,20 @@ window.__ModuleLoader__.load({
.catch(function () { setBusy(false); setMsg(t("ms.saveFailed")); });
}
/** 收起新增卡片并清空草稿(官方:取消 = 丢弃这张卡的草稿)。 */
function closeAdd() {
setMode("");
fPick[1](""); fKey[1](""); fName[1](""); fRoute[1](""); fUrl[1](""); fApi[1](""); fModels[1]([""]);
setShow(false);
}
var pick = fPick[0];
var isCatalog = pick !== "" && pick !== "custom";
var picked = null;
for (var pi = 0; pi < providers.length; pi++) {
if (providers[pi].id === pick) picked = providers[pi];
}
/** 行标题:内置 → 固定文案;目录厂家 → 官方目录里的中文名;自定义 → route。 */
/** 行标题:内置 → 固定文案;目录提供方 → 官方目录里的中文名;自定义 → route。 */
function labelOf(k) {
if (!k.route) return t("ms.kindBuiltin");
for (var i = 0; i < providers.length; i++) {
@@ -1681,34 +1842,79 @@ window.__ModuleLoader__.load({
}
return k.route;
}
/** 行副标题:内置 → 说明;自定义 → endpoint · 协议;目录厂家 → 「由目录提供」。 */
/** 行标签(官方 `rowTag` 的位置):内置 / 目录 / 自定义。 */
function tagOf(k) {
if (!k.route) return t("ms.tagBuiltin");
for (var i = 0; i < providers.length; i++) {
if (providers[i].id === k.route) return t("ms.tagCatalog");
}
return t("ms.tagCustom");
}
/** 行副标题:内置 → 说明;自定义 → 端点 · 协议;目录提供方 → 「由目录提供」。 */
function descOf(k) {
if (!k.route) return t("ms.builtinHint");
if (k.baseUrl) return k.baseUrl + " · " + (k.api || "openai-completions");
return t("ms.catalogRow");
}
function submit() {
var name = fName[0].trim();
/** 模型目录的**行式编辑器**(官方 `ModelListEditor` 的形态:一行一个 + 行尾删除)。 */
function modelRows() {
return fModels[0].map(function (m, i) {
return jsxRuntime.jsx("div", { className: "ms-modelEntry", children: jsxRuntime.jsxs("div", { className: "ms-modelRow", children: [
jsxRuntime.jsx("input", {
className: "ms-input",
placeholder: t("ms.modelIdPlaceholder"),
value: m,
onChange: function (e) {
var next = fModels[0].slice();
next[i] = e.target.value;
fModels[1](next);
}
}),
jsxRuntime.jsx("button", {
type: "button",
className: "ms-iconBtn danger",
title: t("ms.removeModel"),
"aria-label": t("ms.removeModel"),
onClick: function () {
var next = fModels[0].slice();
next.splice(i, 1);
fModels[1](next.length > 0 ? next : [""]);
},
children: "✕"
})
] }) }, "m" + i);
});
}
/**
* 提交「添加提供方」(目录提供方 / 内置 DeepSeek)。
* ⚠️ 密钥**不可留空**:`keyAddSchema` 里 `apiKey` 是 `required + minLength:1`,
* 留空必得 400 `invalid_api_key`(旧文案「留空则回落平台共享密钥」是错的,已改)。
* 显示名留空 ⇒ 取提供方名(官方同做法:页面从不先问名字)。
*/
function submitAdd() {
var key = fKey[0].trim();
if (isCatalog && name === "" && picked !== null) name = picked.label;
if (name === "" || key === "") { setMsg(t("ms.needNameKey")); return; }
if (key === "") { setMsg(t("ms.needKey")); return; }
var name = fName[0].trim() || (picked !== null ? picked.label : "");
if (name === "") { setMsg(t("ms.needName")); return; }
var body = { name: name, apiKey: key };
if (isCatalog) {
// 目录厂家:只给 apiKey —— 端点 / 协议 / 模型清单全部由官方目录兜底。
body.provider = pick;
} else if (pick === "custom") {
if (pick !== "") body.provider = pick; // 目录提供方:端点 / 协议 / 模型全由官方目录兜底
act(papostJson("/api/me/keys", body), closeAdd);
}
/** 提交「自定义提供方」:端点 / 协议 / ≥1 模型 / 可选 Provider ID —— 官方同字段集。 */
function submitCustom() {
var key = fKey[0].trim();
if (fUrl[0].trim() === "") { setMsg(t("ms.needUrl")); return; }
var ids = fModels[0].split("\n").map(function (s) { return s.trim(); }).filter(function (s) { return s !== ""; });
var ids = fModels[0].map(function (s) { return s.trim(); }).filter(function (s) { return s !== ""; });
if (ids.length === 0) { setMsg(t("ms.needModels")); return; }
body.baseUrl = fUrl[0].trim();
body.models = ids;
var name = fName[0].trim() || fRoute[0].trim();
if (name === "") { setMsg(t("ms.needName")); return; }
var body = { name: name, apiKey: key, baseUrl: fUrl[0].trim(), models: ids };
if (fRoute[0].trim() !== "") body.route = fRoute[0].trim();
if (fApi[0]) body.api = fApi[0];
}
act(papostJson("/api/me/keys", body), function () {
fName[1](""); fKey[1](""); fUrl[1](""); fRoute[1](""); fModels[1](""); setShow(false);
});
act(papostJson("/api/me/keys", body), closeAdd);
}
if (loading) {
@@ -1727,18 +1933,241 @@ window.__ModuleLoader__.load({
});
}
// ── 已添加的提供方:**卡片行**(官方 `rowCard` 形态;原表格已删)──
// 官方 `rowHead` = 「身份(名称+标签+状态点)」左对齐 + 「操作」`margin-left:auto`
// 右对齐,两侧都 `white-space:nowrap` ⇒ **结构上不会换行**(这是换掉表格的全部理由)。
var rows = keys.map(function (k) {
return jsxRuntime.jsxs("tr", { children: [
jsxRuntime.jsx("td", { children: labelOf(k) }),
jsxRuntime.jsx("td", { className: "pa-dim", children: descOf(k) }),
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") })
return jsxRuntime.jsxs("li", { className: "ms-rowCard", children: [
jsxRuntime.jsxs("div", { className: "ms-rowHead", children: [
jsxRuntime.jsxs("span", { className: "ms-rowIdentity", children: [
jsxRuntime.jsx("span", { className: "ms-rowName", children: labelOf(k) }),
jsxRuntime.jsx("span", { className: "ms-rowTag", children: tagOf(k) }),
// 状态点:官方 `credentialDot` 的位置与尺寸(8px 圆点),语义换成「启用 / 停用」。
jsxRuntime.jsx("span", {
className: "ms-dot " + (k.enabled ? "on" : "idle"),
role: "img",
title: k.enabled ? t("ms.stateOn") : t("ms.stateOff"),
"aria-label": k.enabled ? t("ms.stateOn") : t("ms.stateOff")
})
] }),
jsxRuntime.jsxs("span", { className: "ms-rowActions", children: [
jsxRuntime.jsx("button", {
type: "button",
className: "ms-btn outline",
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", {
type: "button",
className: "ms-btn danger",
disabled: busy,
onClick: function () { setDelTarget(k); },
children: t("ms.del")
})
] })
] }),
// 状态文字放**独占一行的副标题**(整行可用宽度 ⇒ 不可能换行),信息量也不丢。
jsxRuntime.jsx("p", { className: "ms-rowMeta", children: descOf(k) + " · " + (k.enabled ? t("ms.stateOn") : t("ms.stateOff")) })
] }, k.id);
});
/** 编辑卡片右下角的动作行(官方的「取消 / 保存」两键,右对齐)。 */
function editorActions(submit, label, busyLabel, disabled) {
return jsxRuntime.jsxs("div", { className: "ms-editorActions", children: [
jsxRuntime.jsx("button", { type: "button", className: "ms-btn outline", disabled: busy, onClick: closeAdd, children: t("ms.cancel") }),
jsxRuntime.jsx("button", { type: "button", className: "ms-btn primary", disabled: disabled, onClick: submit, children: busy ? t(busyLabel) : t(label) })
] });
}
var addBlock = jsxRuntime.jsxs("div", { className: "ms-addBlock", children: [
// ── 卡片 A:添加提供方(官方 `addCard` + `ProviderEditor` 同构)──
// 官方原话:「编辑卡片上的主字段是单独一个 **API 密钥**输入框 —— 页面从不问
// 环境变量名」「收起的『自定义设置』才承载 baseURL / 协议 / 模型目录」。
mode === "add" ? jsxRuntime.jsxs("div", { className: "ms-card", children: [
jsxRuntime.jsxs("div", { className: "ms-field", children: [
jsxRuntime.jsx("span", { className: "ms-fieldLabel", children: t("ms.provider") }),
jsxRuntime.jsx("select", {
className: "ms-input",
value: pick,
"aria-label": t("ms.provider"),
onChange: function (e) { fPick[1](e.target.value); },
children: [
jsxRuntime.jsx("option", { value: "", children: t("ms.optBuiltin") }, "_b"),
cnList.length > 0 ? jsxRuntime.jsx("optgroup", { label: t("ms.grpCn"), children: provOpts(cnList) }, "_cn") : null,
intlList.length > 0 ? jsxRuntime.jsx("optgroup", { label: t("ms.grpIntl"), children: provOpts(intlList) }, "_intl") : null
]
})
] }),
jsxRuntime.jsxs("div", { className: "ms-field", children: [
jsxRuntime.jsxs("span", { className: "ms-fieldLabel", children: [
t("ms.keyInput"),
picked !== null ? jsxRuntime.jsx("span", { className: "ms-hint", children: t("ms.keyHintCatalog").replace("{n}", String(picked.modelCount)) }) : null
] }),
jsxRuntime.jsx("input", {
className: "ms-input",
type: "password",
autoComplete: "off",
placeholder: pick === "" ? t("ms.keyPlaceholderBuiltin") : t("ms.keyPlaceholder"),
value: fKey[0],
onChange: function (e) { fKey[1](e.target.value); }
})
] }),
jsxRuntime.jsxs("details", { className: "ms-details", children: [
jsxRuntime.jsx("summary", { className: "ms-summary", children: t("ms.customized") }),
jsxRuntime.jsxs("div", { className: "ms-detailsBody", children: [
jsxRuntime.jsxs("div", { className: "ms-field", children: [
jsxRuntime.jsx("span", { className: "ms-fieldLabel", children: t("ms.displayName") }),
jsxRuntime.jsx("input", {
className: "ms-input",
placeholder: picked !== null ? picked.label : t("ms.displayNamePlaceholder"),
value: fName[0],
onChange: function (e) { fName[1](e.target.value); }
})
] }),
pick === "" ? jsxRuntime.jsx("p", { className: "ms-hint", children: t("ms.builtinPickHint") }) : null,
picked !== null ? jsxRuntime.jsxs("div", { className: "ms-field", children: [
jsxRuntime.jsxs("span", { className: "ms-fieldLabel", children: [
t("ms.catalogModels"),
jsxRuntime.jsx("button", { type: "button", className: "ms-btn outline", style: { height: 28, borderRadius: 14, padding: "0 10px", fontSize: 12 }, onClick: function () { setShow(!fShow[0]); }, children: fShow[0] ? t("ms.hideModels") : t("ms.showModels") })
] }),
fShow[0]
? jsxRuntime.jsx("ul", { className: "ms-chips", children: (picked.models || []).map(function (m) { return jsxRuntime.jsx("li", { className: "ms-chip", children: m }, m); }) })
: jsxRuntime.jsx("p", { className: "ms-hint", children: t("ms.catalogModelsMeta").replace("{n}", String(picked.modelCount)) })
] }) : null,
jsxRuntime.jsx("p", { className: "ms-hint", children: t("ms.advancedHint") })
] })
] }),
msg ? jsxRuntime.jsx("p", { className: "ms-err", children: msg }) : null,
editorActions(submitAdd, "ms.save", "ms.saving", busy || fKey[0].trim() === "")
] }) : mode === "custom" ? (
// ── 卡片 B:自定义提供方(官方 `CustomProviderCard` 同字段集)──
// 官方:「创建卡片会索要唯一的 **Provider ID**、端点、协议与至少一个可唯一识别
// 的模型,因为没有东西能为它们兜底」;Provider ID 必填 ⇒ 我们把它排在首位,
// 留空时后端会用显示名 slug 化兜底(`slugify`),不额外造字段。
jsxRuntime.jsxs("div", { className: "ms-card", children: [
jsxRuntime.jsx("div", { className: "ms-field", children: jsxRuntime.jsx("span", { className: "ms-fieldLabel", children: t("ms.customTitle") }) }),
jsxRuntime.jsxs("div", { className: "ms-field", children: [
jsxRuntime.jsx("span", { className: "ms-fieldLabel", children: t("ms.customRoute") }),
jsxRuntime.jsx("input", {
className: "ms-input",
placeholder: "acme-gateway",
value: fRoute[0],
onChange: function (e) { fRoute[1](e.target.value); }
}),
// 与小写字母/数字/短横线的约定不符时**就地报错**(官方同:语法错误在字段处阻止创建)。
fRoute[0] !== "" && !/^[a-z][a-z0-9-]*$/.test(fRoute[0])
? jsxRuntime.jsx("p", { className: "ms-err", children: t("ms.customRouteInvalid") })
: jsxRuntime.jsx("p", { className: "ms-hint", children: t("ms.customRouteHint") })
] }),
jsxRuntime.jsxs("div", { className: "ms-field", children: [
jsxRuntime.jsx("span", { className: "ms-fieldLabel", children: t("ms.displayName") }),
jsxRuntime.jsx("input", {
className: "ms-input",
placeholder: fRoute[0] === "" ? t("ms.displayNamePlaceholder") : fRoute[0],
value: fName[0],
onChange: function (e) { fName[1](e.target.value); }
})
] }),
jsxRuntime.jsxs("div", { className: "ms-field", children: [
jsxRuntime.jsx("span", { className: "ms-fieldLabel", children: t("ms.baseUrl") }),
jsxRuntime.jsx("input", {
className: "ms-input",
placeholder: t("ms.baseUrlPlaceholder"),
value: fUrl[0],
onChange: function (e) { fUrl[1](e.target.value); }
})
] }),
jsxRuntime.jsxs("div", { className: "ms-field", children: [
jsxRuntime.jsx("span", { className: "ms-fieldLabel", children: t("ms.customApi") }),
jsxRuntime.jsx("select", {
className: "ms-input",
value: fApi[0],
onChange: function (e) { fApi[1](e.target.value); },
children: [
jsxRuntime.jsx("option", { value: "", children: t("ms.customApiUnset") }, "_u"),
protocols.map(function (p) { return jsxRuntime.jsx("option", { value: p, children: p }, p); })
]
})
] }),
jsxRuntime.jsxs("div", { className: "ms-field", children: [
jsxRuntime.jsx("span", { className: "ms-fieldLabel", children: t("ms.keyInput") }),
jsxRuntime.jsx("input", {
className: "ms-input",
type: "password",
autoComplete: "off",
placeholder: t("ms.keyPlaceholder"),
value: fKey[0],
onChange: function (e) { fKey[1](e.target.value); }
})
] }),
jsxRuntime.jsxs("div", { className: "ms-field", children: [
jsxRuntime.jsx("span", { className: "ms-fieldLabel", children: t("ms.catalogModels") }),
fModels[0].some(function (s) { return s.trim() !== ""; }) ? null : jsxRuntime.jsx("p", { className: "ms-hint", children: t("ms.modelEmpty") }),
jsxRuntime.jsx("div", { className: "ms-modelList", children: modelRows() }),
jsxRuntime.jsx("button", {
type: "button",
className: "ms-btn outline",
style: { alignSelf: "flex-start", height: 28, borderRadius: 14, padding: "0 10px", fontSize: 12 },
onClick: function () { fModels[1](fModels[0].concat([""])); },
children: "+ " + t("ms.addModel")
})
] }),
msg ? jsxRuntime.jsx("p", { className: "ms-err", children: msg }) : null,
jsxRuntime.jsx("p", { className: "ms-hint", children: t("ms.advancedHint") }),
editorActions(submitCustom, "ms.create", "ms.creating", busy || fUrl[0].trim() === "" || fModels[0].every(function (s) { return s.trim() === ""; }))
] }) ) : (
// ── 默认态:**两个虚线按钮**(官方 `addActions`;按钮本身不展开任何字段)──
jsxRuntime.jsxs("div", { className: "ms-addActions", children: [
jsxRuntime.jsxs("button", {
type: "button",
className: "ms-addBtn",
onClick: function () { setMsg(""); setMode("add"); },
children: ["+ " + t("ms.addProvider")]
}),
jsxRuntime.jsxs("button", {
type: "button",
className: "ms-addBtn",
onClick: function () { setMsg(""); setMode("custom"); },
children: ["+ " + t("ms.addCustom")]
})
] })
),
// 目录读不到时如实说明(不得静默只剩两个按钮)
providers.length === 0
? jsxRuntime.jsx("p", { className: "ms-hint", children: catalog && catalog.readable === false ? t("ms.catUnreadable").replace("{dir}", catalog.dir || "?") : t("ms.catEmpty") })
: null
] });
/** 删除确认弹窗(官方 `deleteDialog` 的等价物):遮罩点击 / 取消 = 关闭。 */
var delModal = delTarget === null ? null : jsxRuntime.jsxs("div", {
className: "pa-overlay",
onClick: function () { setDelTarget(null); },
children: jsxRuntime.jsxs("div", {
className: "pa-modal",
style: { width: "min(480px,100%)", padding: "18px 22px", display: "flex", flexDirection: "column", gap: 12 },
onClick: function (e) { e.stopPropagation(); },
children: [
jsxRuntime.jsx("div", { className: "pa-card-h", style: { margin: 0 }, children: t("ms.delTitle").replace("{who}", labelOf(delTarget)) }),
jsxRuntime.jsx("p", { className: "ms-hint", children: t("ms.delDesc").replace("{who}", labelOf(delTarget)) }),
msg ? jsxRuntime.jsx("p", { className: "ms-err", children: msg }) : null,
jsxRuntime.jsxs("div", { className: "ms-editorActions", children: [
jsxRuntime.jsx("button", { type: "button", className: "ms-btn outline", disabled: busy, onClick: function () { setDelTarget(null); }, children: t("ms.cancel") }),
jsxRuntime.jsx("button", {
type: "button",
className: "ms-btn danger",
disabled: busy,
onClick: function () { act(paReq("/api/me/keys/" + delTarget.id, { method: "DELETE" }), function () { setDelTarget(null); }); },
children: busy ? t("ms.saving") : t("ms.delConfirm").replace("{who}", labelOf(delTarget))
})
] })
]
})
});
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") }),
@@ -1747,74 +2176,33 @@ window.__ModuleLoader__.load({
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
// ⚠️ 看的人是 admin 本人时不能说「由 admin 配置」(2026-09-14 截图里就是这么写的)
// —— 后端给了 `ownerIsMe`,据此换成「由你配置」。
shared.owner
? jsxRuntime.jsx("span", { className: "sub", children: shared.ownerIsMe ? t("ms.sharedOwnerMe") : 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")) + (shared.count > 1 ? " ×" + shared.count : "") : 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") })
jsxRuntime.jsx("button", { className: "ms-btn " + (sharedOn ? "outline" : "primary"), disabled: busy || !shared.available, onClick: function () { act(papostJson("/api/me/models/shared", { enabled: !sharedOn })); }, children: sharedOn ? t("ms.sharedOn") : t("ms.sharedOff") })
] })
] }),
// ── 我已添加的厂家(官方页的「提供方行」)──
// ── 我已添加的提供方(官方页的「提供方行」)──
jsxRuntime.jsx("div", { className: "pa-sec", children: t("ms.list") }),
jsxRuntime.jsx("div", { className: "pa-wrap", style: { marginBottom: 16 }, children: jsxRuntime.jsxs("table", { className: "pa-tbl", children: [
jsxRuntime.jsx("thead", { children: jsxRuntime.jsxs("tr", { children: [
jsxRuntime.jsx("th", { children: t("ms.col.provider") }),
jsxRuntime.jsx("th", { children: t("ms.col.desc") }),
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: 4, children: t("ms.empty") }) })
})
] }) }),
rows.length > 0
? jsxRuntime.jsx("ul", { className: "ms-rows", children: rows })
: jsxRuntime.jsx("p", { className: "ms-hint", style: { marginBottom: 16 }, children: t("ms.empty") }),
// ── 新增厂家:**先选厂家**,目录厂家只需填 API 密钥(官方「新增」卡片同构)──
jsxRuntime.jsxs("div", { className: "pa-box", children: [
jsxRuntime.jsx("div", { className: "pa-card-h", children: jsxRuntime.jsx("span", { children: t("ms.add") }) }),
providers.length === 0
? jsxRuntime.jsx("p", {
className: "pa-hint",
children: catalog && catalog.readable === false
? t("ms.catUnreadable").replace("{dir}", catalog.dir || "?")
: t("ms.catEmpty")
})
: null,
jsxRuntime.jsxs("div", { className: "pa-row", children: [
jsxRuntime.jsx("span", { className: "pa-hint", style: { margin: 0 }, children: t("ms.pickProvider") }),
jsxRuntime.jsx("select", { className: "pa-input", value: pick, onChange: function (e) { fPick[1](e.target.value); setShow(false); }, children: [
jsxRuntime.jsx("option", { value: "", children: t("ms.optBuiltin") }, "_b"),
cnList.length > 0 ? jsxRuntime.jsx("optgroup", { label: t("ms.grpCn"), children: provOpts(cnList) }, "_cn") : null,
intlList.length > 0 ? jsxRuntime.jsx("optgroup", { label: t("ms.grpIntl"), children: provOpts(intlList) }, "_intl") : null,
jsxRuntime.jsx("option", { value: "custom", children: t("ms.optCustom") }, "_c")
] })
] }),
jsxRuntime.jsxs("div", { className: "pa-row", children: [
jsxRuntime.jsx("input", { className: "pa-input", placeholder: isCatalog && picked !== null ? picked.label : 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); } })
] }),
isCatalog && picked !== null ? jsxRuntime.jsxs("div", { className: "pa-row", children: [
jsxRuntime.jsx("span", { className: "pa-hint", style: { margin: 0 }, children: t("ms.catalogHint").replace("{n}", String(picked.modelCount)) }),
jsxRuntime.jsx("button", { className: "pa-sm", onClick: function () { setShow(!fShow[0]); }, children: fShow[0] ? t("ms.hideModels") : t("ms.showModels") })
] }) : null,
isCatalog && picked !== null && fShow[0] ? jsxRuntime.jsx("p", { className: "pa-hint", children: (picked.models || []).join("、") }) : null,
pick === "custom" ? jsxRuntime.jsxs("div", { children: [
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.jsx("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); } })
] }) : null,
pick === "" ? jsxRuntime.jsx("p", { className: "pa-hint", children: t("ms.builtinPickHint") }) : null,
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
] })
] })
// ── 新增区(官方 `addBlock`)──
addBlock,
// 写操作的结果提示(保存成功 / 后端错误码译文)
msg ? jsxRuntime.jsx("p", { className: mode === "" ? "ms-ok" : "ms-err", style: { marginTop: 12 }, role: "status", "aria-live": "polite", children: msg }) : null,
// 删除确认弹窗(点名该提供方)
delModal
] });
}
@@ -2303,6 +2691,8 @@ window.__ModuleLoader__.load({
'<input id="wlQ" class="pa-input" placeholder="' + paesc(t("pa.pl.searchPh")) + '" />' +
'<select id="wlCat" class="pa-input" style="flex:none;min-width:130px"><option value="">' + paesc(t("pa.pl.allCat")) + "</option></select>" +
'<label class="pa-check"><input type="checkbox" id="wlOnlyImportable" checked />' + paesc(t("pa.pl.onlyImportable")) + "</label>" +
// 档案 92:默认按 dsh 版本过滤(隐藏「仅兼容更旧版本」),此勾为显式逃生口。
'<label class="pa-check"><input type="checkbox" id="wlAllCompat" />' + paesc(t("pa.pl.showAllCompat")) + "</label>" +
'<button class="pa-btn" id="wlSearchBtn">' + paesc(t("pa.op.search")) + "</button>" +
'<button class="pa-btn" id="wlRefreshBtn">' + paesc(t("pa.op.refreshDir")) + "</button>" +
"</div>" +
@@ -2316,6 +2706,8 @@ window.__ModuleLoader__.load({
"<th>" + paesc(t("pa.k.pluginDesc")) + "</th>" +
"<th>" + paesc(t("pa.k.cat")) + "</th>" +
"<th>" + paesc(t("pa.k.importKind")) + "</th>" +
// 档案 92:dsh 版本兼容列(「需更高版本」是用户要的显式标注)
"<th>" + paesc(t("pa.pl.compatCol")) + "</th>" +
'<th class="pa-num" style="text-align:right">' + paesc(t("pa.k.downloads")) + "</th>" +
'<th class="pa-act">' + paesc(t("pa.k.detail")) + "</th>" +
'</tr></thead><tbody id="wlTbody"></tbody></table></div>' +
@@ -2356,6 +2748,37 @@ window.__ModuleLoader__.load({
if (kind === "tarball") return { label: t("pa.pl.importTarball"), cls: "admin" };
return { label: t("pa.pl.importSource"), cls: "disabled" };
};
/**
* 档案 92:dsh 版本兼容徽章。判定在平台侧做(官方目录不带 dsh 版本字段 ⇒
* 逐包查 npm 声明),口径与实测依据见 `src/web/plugin-dsh-compat.ts` 头部注释。
* `cur` = 当前平台 dsh 版本,只用于 tooltip 给对比。
*/
function wlCompat(p, cur) {
var v = p.dshCompat;
var label, tip, cls;
if (v === "newer") {
label = t("pa.pl.cmpNewer") + (p.dshRequires ? " ≥ " + p.dshRequires : "");
tip = pafmt(t("pa.pl.cmpNewerTip"), { v: p.dshRequires || "?", cur: cur || "?" });
cls = "pending"; // 橙色:用户点名要「明确标注」的就是这一类
} else if (v === "match") {
label = t("pa.pl.cmpMatch");
tip = pafmt(t("pa.pl.cmpMatchTip"), { cur: cur || "?" });
cls = "active";
} else if (v === "none") {
label = t("pa.pl.cmpNone");
tip = t("pa.pl.cmpNoneTip");
cls = "disabled";
} else if (v === "older") {
label = t("pa.pl.cmpOlder");
tip = t("pa.pl.cmpOlderTip");
cls = "disabled";
} else {
label = t("pa.pl.cmpUnknown");
tip = t("pa.pl.cmpUnknownTip");
cls = "disabled";
}
return '<span class="pa-badge ' + cls + '" title="' + paesc(tip) + '">' + paesc(label) + "</span>";
}
/** 页签切换(门户另写 hash 深链,弹窗内不需要)。 */
function showTab(key) {
$.__querySelectorAll("#pluginTabs .pa-tab").forEach(function (x) {
@@ -2382,13 +2805,15 @@ window.__ModuleLoader__.load({
}
function loadWhitelist() {
var tbody = $("wlTbody");
tbody.innerHTML = paemptyRow(6, t("pa.pl.wlLoading"));
tbody.innerHTML = paemptyRow(7, t("pa.pl.wlLoading"));
var q = encodeURIComponent($("wlQ").value.trim());
var cat = encodeURIComponent($("wlCat").value);
var only = $("wlOnlyImportable").checked ? "1" : "";
return paReq("/api/plugins/whitelist?q=" + q + "&category=" + cat + "&onlyImportable=" + only)
// 档案 92:默认按 dsh 版本过滤;勾了「含仅兼容更旧版本的」才不过滤(排障用)。
var cmp = $("wlAllCompat").checked ? "&dshCompat=all" : "";
return paReq("/api/plugins/whitelist?q=" + q + "&category=" + cat + "&onlyImportable=" + only + cmp)
.then(function (r) { return r.json().catch(function () { return {}; }).then(function (d) {
if (!r.ok) { tbody.innerHTML = paemptyRow(6, t("pa.pl.wlFail") + ":" + (d.error == null ? r.status : d.error)); return; }
if (!r.ok) { tbody.innerHTML = paemptyRow(7, t("pa.pl.wlFail") + ":" + (d.error == null ? r.status : d.error)); return; }
var sel = $("wlCat");
if (sel.dataset.filled !== "1") {
(d.categories || []).forEach(function (c) {
@@ -2397,10 +2822,17 @@ window.__ModuleLoader__.load({
sel.dataset.filled = "1";
}
var shown = (d.plugins || []).length;
// 档案 92:把「按 dsh 版本过滤了什么」说清楚,绝不让条目静默消失。
var unknownN = (d.plugins || []).filter(function (x) { return x.dshCompat === "unknown"; }).length;
var cmpNote = d.dshVersion
? (d.compatFiltered === false
? t("pa.pl.cmpAll")
: pafmt(t("pa.pl.cmpHidden"), { cur: d.dshVersion, n: d.hiddenByDsh || 0 }))
: "";
$("wlInfo").textContent = pafmt(t("pa.pl.wlInfo"), {
total: d.total, importable: d.importable, count: d.count, shown: shown, when: parelTime(d.fetchedAt)
}) + (d.stale ? t("pa.pl.wlStale") : "");
if (shown === 0) { tbody.innerHTML = paemptyRow(6, t("pa.pl.wlEmpty")); return; }
}) + cmpNote + (unknownN > 0 ? t("pa.pl.cmpWarming") : "") + (d.stale ? t("pa.pl.wlStale") : "");
if (shown === 0) { tbody.innerHTML = paemptyRow(7, t("pa.pl.wlEmpty")); return; }
tbody.innerHTML = d.plugins.map(function (p) {
var k = WL_KIND(p.importKind);
var dis = p.importKind === "source";
@@ -2419,6 +2851,7 @@ window.__ModuleLoader__.load({
paesc(primary) + '</div><div class="pa-wlmeta" title="' + paesc(meta) + '">' + paesc(meta) + "</div></td>" +
'<td><span class="pa-wlcat">' + (paesc(p.categoryLabel || p.category) || "—") + "</span></td>" +
'<td><span class="pa-badge ' + k.cls + '">' + paesc(k.label) + "</span></td>" +
'<td style="white-space:nowrap">' + wlCompat(p, d.dshVersion) + "</td>" +
'<td class="pa-num">' + (p.downloads || 0) + "</td>" +
'<td class="pa-act">' + (p.page
? '<a class="pa-sm pa-wllink" href="' + paesc(p.page) + '" target="_blank" rel="noreferrer noopener">' + paesc(t("pa.op.detail")) + "</a>"
@@ -2438,6 +2871,7 @@ window.__ModuleLoader__.load({
$("wlQ").onkeydown = function (e) { if (e.key === "Enter") loadWhitelist(); };
$("wlCat").onchange = function () { return loadWhitelist(); };
$("wlOnlyImportable").onchange = function () { return loadWhitelist(); };
$("wlAllCompat").onchange = function () { return loadWhitelist(); };
$("wlRefreshBtn").onclick = function () {
var b = $("wlRefreshBtn");
b.disabled = true;
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@dsh-local/business-plugins",
"version": "0.3.13",
"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.Line truncated
"version": "0.3.16",
"description": "功能管理(原「功能插件」)section for dsh web profile — v0.3.16(2026-09-14):**官方推荐插件列表按 dsh 版本过滤**(用户:「插件管理中的官方推荐插件列表 只能显示匹配当前dsh版本 和 超过当前版本的插件(超过的要明确标注)」)。① 官方目录(`awesome-dsh-plugin.com/plugins.json`)**不带 dsh 版本字段** ⇒ 平台侧逐包查 npm 精简 packument 的 `peerDependencies / engines` 与**平台真实版本**比对,分类 `match / none / newer / older / unknown`(判定在 `src/web/plugin-dsh-compat.ts`);② **默认隐藏 `older`**(只声明了更旧版本),`newer`(要求更高的 dsh)**橙色显著标注**并在 tooltip 给出「需 ≥ X,当前 Y」;③ 说明行给出「已按 dsh X 过滤,隐藏 N 个…」且**勾选框「含仅兼容更旧版本的」是显式逃生口** —— 绝不让条目静默消失;④ 判定结果按 `<npm>@<版本>` 落盘缓存 7 天,首次进页后台预热全量,请求内只给 6s 预算、超预算判 `unknown` 并**照常显示**(网络抖动不该等同「不兼容」);⑤ ⚠️ 语义:判 `satisfies` **必须带 `includePrerelease: true`** —— 平台版本是 prerelease,默认语义下 `^0.1.2` 甚至 `*` 都不满足,实测 TOP300 会把 `dsh-univer-office`/`dshmarket` 这类**在跑的**插件误判为 older(`match 97/older 139` → 容忍后 `match 214/older 22`);且**伞包 `@deepseek-ai/dsh` 必须单独解析**(它不在自己的 node_modules 里,而那 3 条 `newer` 全部只写在伞包上)。|v0.3.15(2026-09-14):**删除条目补二次确认**(官方 `deleteDialog` 的等价物)。原「删除」紧挨着「停用」且**无任何确认** ⇒ 一次误点就连已存储的 API 密钥一起删掉(`DELETE /api/me/keys/:id` 不可撤销,用户只能重新粘贴密钥);现改为弹窗**点名该提供方**(`ms.delTitle` / `ms.delDesc` / `ms.delConfirm`)后才执行,遮罩点击或「取消」可关。|v0.3.14(2026-09-14):**「模型设置」页按官方 `dsh-client-ui-settings-models` 复刻交互**(用户:「admin模型设置和状态 一个名称换行了,另一个两个按钮换行了,交互体验需要优化…新增模型的操作交互和官方原始新增模型的交互差距很大,建议仔细参考 复刻官方的交互」)。① **换行根因**:原「我已添加的厂家」是 4 列表格(`pa-tbl` 自动列宽)⇒「内置 DeepSeek」与「停用/删除」被挤到换行;改为**官方卡片行**(`ms-rowCard`/`ms-rowHead`/`ms-rowIdentity`/`ms-rowActions`,名称与按钮两侧均 `nowrap`)⇒ **结构上不可能换行**,状态文字下沉到独占一行的副标题。② **新增流程改为官方的两步式**:默认只露**两个虚线按钮**(「+ 添加提供方」/「+ 添加自定义提供方」,`flex:1 1 0;min-width:180px;height:44px`),点击才展开卡片 —— 旧版是「常开表单 + 厂家下拉里混一项『自定义厂家…』」。③ 新增卡片**主字段是单独一个「API 密钥」输入框**(官方原文:页面从不问环境变量名),显示名挪进收起的「自定义设置」`<details>`;自定义卡片按官方字段序(Provider ID → 显示名称 → API 地址 → API 协议 → API 密钥 → 模型目录),模型目录由**一行一个的文本框**改成官方的**行式编辑器**(`+ 添加模型` / 行尾 ✕ 删除)。④ CSS 数值**逐值照抄**官方 CSS 模块(圆角 16/18/14/12/10/8/6/4、字号 14/12/11、行高 22/18/16、按钮高 36/28/44px),颜色走同一批 `--dsw-*` token。⑤ 顺带修两处文案缺陷:admin 自己看共享模型卡片时原写「由 admin 配置」(后端早已给 `ownerIsMe`)⇒ 改「由你配置」;「内置 DeepSeek 密钥留空则回落平台共享密钥」是**错的**(`keyAddSchema` 里 `apiKey` 是 `required + minLength:1`,留空必得 400)⇒ 改为「输入你的 DeepSeek API 密钥」。⑥ 新增 `scripts/verify-models-dict.mjs` 断言 zh/en 键集一致 + 代码引用的键都已声明(本轮就是靠它抓到 `ms.needUrl`/`ms.needModels` 被引用未声明)。|v0.3.10(2026-09-13):① **「服务管理」改名「实例管理」**、**「密钥管理」改名「模型管理」**(用户要求;门户导航/卡片同步改名);② **「实例管理」页可管任意用户**(用户要求「admin 要能管所有用户的服务」)—— 工具条新增「用户」下拉,切换后文件树/启停/打开全部针对所选用户,走新开的 `/api/admin/users/:id/{fs,dsh}`(requireAdmin)|v0.3.8(2026-09-13):**撤掉「我的密钥」分区** —— 模型配置统一走**官方「设置 → 模型」页**(用户要求「界面交互和官方一模一样」⇒ 不仿制、直接放开官方页,见档案 86)。平台侧同步两处:① `ensure-role-profile-patchLine truncated
"type": "module",
"main": "lib/index.js",
"exports": {
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env node
/**
* verify-dsh-compat.mjs —— 「官方推荐插件列表按 dsh 版本过滤」(档案 92)的**回归锚点**。
*
* 为什么需要:这条链路的关键判据**全都不是"跑得通/跑不通"能暴露的** ——
* · 少了 `includePrerelease: true`:功能照跑,但会把**在平台上跑得好好的**插件
* (`dsh-univer-office`、`dshmarket` …)判成「只兼容更旧版本」而被隐藏。
* 2026-09-14 实测:TOP300 默认语义 `match 97 / older 139`;prerelease 容忍 `match 214 / older 22`。
* · 少了「伞包 `@deepseek-ai/dsh` 单独解析」:所有"要求 dsh ≥ 0.1.5-rc.2"的插件都判不出来
* (伞包**不在自己的 node_modules 里**)—— 而那恰恰是用户要标注的那一类
* (实测 TOP300 的 3 条 `newer` **全部**只写在伞包上)。
* · 前端少了「隐藏计数/说明」:条目会**静默消失**,正是本库反复强调要避免的。
* 以上都会"静默降级",所以钉成断言。
*
* 用法:node scripts/verify-dsh-compat.mjs 退出码 0=全绿 / 1=有失败
*/
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
const read = (p) => readFileSync(join(ROOT, p), 'utf8')
let failed = 0
const ok = (name, cond, extra = '') => {
console.log((cond ? ' ✓ ' : ' ✗ ') + name + (extra ? ' ' + extra : ''))
if (!cond) failed++
}
const compat = read('src/web/plugin-dsh-compat.ts')
const route = read('src/web/routes/whitelist.ts')
const client = read('poc/business-plugins/lib/client.js')
const install = read('src/web/dsh-install.ts')
const legacy = read('src/web/plugin-compat.ts')
// ── ① 后端判据:prerelease 语义 + 伞包解析(两条都是"静默降级"型)──
ok('判定用 prerelease 容忍语义(includePrerelease: true)', /includePrerelease:\s*true/.test(compat))
ok('伞包解析收口在 dsh-install.platformPackageVersion(单独读 <dsh 包根>/package.json)',
/export function platformPackageVersion\(/.test(install) && /full === PKG_NAME \? dshPackageRoot\(\)/.test(install))
ok('两处消费方共用同一份版本表(不再各写一遍)',
/platformPackageVersion/.test(compat) && /platformPackageVersion/.test(legacy) &&
!/versionTable/.test(compat) && !/versionCache/.test(legacy))
ok('分类枚举含 newer / older / match / none / unknown',
['newer', 'older', 'match', 'none', 'unknown'].every((k) => new RegExp(`'${k}'`).test(compat)))
ok('"要求更高"优先于"只兼容更旧"(newer 先返回)', /if \(newer !== null\)[\s\S]{0,200}kind: 'newer'[\s\S]{0,200}if \(older\.length > 0\)/.test(compat))
ok('semver 不可用时降级为 unknown(而不是判兼容/不兼容)', /semver === null\)\s*return \{ kind: 'unknown'/.test(compat))
// 闸门(判据 A)也必须先按默认语义、再容忍 prerelease —— 否则「声明 `*`」会被判不兼容。
// 实测:抽样 300 里这类"prerelease 产物"有 117 条(dshmarket 等能跑的都在其中)。
ok('闸门判据 A 有 prerelease 兜底(默认语义不满足时先看 includePrerelease)',
/semver\.satisfies\(v, range\)[\s\S]{0,2000}includePrerelease: true/.test(legacy))
ok('prerelease 产物只计数、不进 findings(不阻断)',
/prereleaseOnly/.test(legacy) && /out\.findings\.push[\s\S]{0,200}prereleaseOnly/.test(legacy))
// ── ② 路由:过滤 + 标注 + 缓存 + 预热 + 不静默 ──
ok('GET 尊重 dshCompat 参数(=all 时只标注不过滤)', /dshCompat !== 'all'/.test(route))
ok('只隐藏 older,其余(含 unknown)照常显示', /kind !== 'older'/.test(route))
ok('响应带 hiddenByDsh(供前端说明隐藏了多少)', /hiddenByDsh/.test(route))
ok('响应带 dshVersion(当前版本)', /dshVersion: platformDshVersion\(\)/.test(route))
ok('判定结果落盘缓存(避免每次搜索打 npm)', /npm-dsh-compat\.json/.test(route))
ok('有后台预热,避免冷启动全判 unknown', /warmCompat/.test(route))
ok('请求内只给预算:取不到判 unknown 并仍显示', /COMPAT_BUDGET_MS/.test(route))
// ── ③ 前端:徽章 + 说明 + 逃生口 ──
ok('前端有兼容徽章函数 wlCompat', /function wlCompat\(/.test(client))
ok('「需更高版本」用醒目徽章(pending/橙色)', /cls = "pending"/.test(client))
ok('查询串带 dshCompat=all 逃生口', /&dshCompat=all/.test(client))
ok('说明行给出隐藏计数(不静默消失)', /pa\.pl\.cmpHidden/.test(client) && /hiddenByDsh/.test(client))
ok('表格新增 dsh 版本列', /pa\.pl\.compatCol/.test(client))
console.log(failed === 0 ? '\n全部通过(dsh 版本兼容过滤:判据 + 过滤 + 标注)' : `\n${failed} 项失败`)
process.exit(failed === 0 ? 0 : 1)
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env node
/**
* verify-models-dict.mjs —— 「模型设置」页(`poc/business-plugins/lib/client.js` 的 ms.* 分区)
* 的**词典完整性 + 版式防线**交叉校验(档案 91)。
*
* 为什么需要(两个都真踩过):
* ① **漏键是静默的**:2026-09-14 重写该页时把 `ms.needUrl` / `ms.needModels` 从词典里删掉、
* 却仍被 `submitCustom()` 引用 —— 页面只会在校验失败时把**键名原样**显示给用户,
* 构建与语法检查**都不会报错**。zh/en 也容易只改一边。
* ② **换行是版式问题、不是逻辑问题**:用户截图报「内置 DeepSeek 换行 + 两个按钮换行」,
* 根因是那 4 列表格用了**自动列宽**。改用官方卡片行后,只要 `rowName` / `rowActions`
* 的 `white-space:nowrap` 与 `rowActions` 的 `margin-left:auto` 任一被删,缺陷就会复发
* —— 而肉眼很难在构建期发现。这里把它钉成断言。
*
* 用法:node scripts/verify-models-dict.mjs 退出码 0=全绿 / 1=有失败
*/
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
const read = (p) => readFileSync(join(ROOT, p), 'utf8')
let failed = 0
const ok = (name, cond, extra = '') => {
console.log((cond ? ' ✓ ' : ' ✗ ') + name + (extra ? ' ' + extra : ''))
if (!cond) failed++
}
const src = read('poc/business-plugins/lib/client.js')
// ── ① 取出 zh / en 两个词典对象字面量(按花括号配平截取,不用 eval 全文件)──
/** 从 `var <name> = {` 起按花括号配平截出对象字面量文本。 */
function grabDict(name) {
const at = src.indexOf('var ' + name + ' = {')
if (at < 0) return null
const start = src.indexOf('{', at)
let depth = 0
for (let i = start; i < src.length; i++) {
const c = src[i]
if (c === '{') depth++
else if (c === '}') {
depth--
if (depth === 0) return src.slice(start, i + 1)
}
}
return null
}
const zhText = grabDict('zh')
const enText = grabDict('en')
ok('取到 zh 词典对象', zhText !== null)
ok('取到 en 词典对象', enText !== null)
if (zhText === null || enText === null) process.exit(1)
/** 只取**顶层**键:按缩进深度 1 的行首 `"k":` 判定,避免把嵌套对象里的键也算进来。 */
function keysOf(text) {
const keys = []
const dups = []
const seen = new Set()
for (const m of text.matchAll(/^\t{3}"([^"]+)":/gm)) {
if (seen.has(m[1])) dups.push(m[1])
seen.add(m[1])
keys.push(m[1])
}
return { keys, dups }
}
const zh = keysOf(zhText)
const en = keysOf(enText)
ok('zh 无重复键(重复会让后者静默胜出)', zh.dups.length === 0, zh.dups.length ? '-> ' + zh.dups.join(', ') : '')
ok('en 无重复键', en.dups.length === 0, en.dups.length ? '-> ' + en.dups.join(', ') : '')
const zhSet = new Set(zh.keys)
const enSet = new Set(en.keys)
const onlyZh = zh.keys.filter((k) => !enSet.has(k))
const onlyEn = en.keys.filter((k) => !zhSet.has(k))
ok('zh / en 键数一致', zh.keys.length === en.keys.length, `-> zh ${zh.keys.length} / en ${en.keys.length}`)
ok('zh ⊆ en', onlyZh.length === 0, onlyZh.length ? '-> 仅 zh 有: ' + onlyZh.join(', ') : '')
ok('en ⊆ zh', onlyEn.length === 0, onlyEn.length ? '-> 仅 en 有: ' + onlyEn.join(', ') : '')
// ── ② 代码里引用的键必须都声明了(本轮真漏过)──
// ⚠️ 2026-09-14 扩到**全词典**(原来只查 `ms.*`):`pa.*` 那批一样会漏,
// 而漏键在页面上只表现为"把键名原样显示给用户",构建与语法检查都不报错。
const used = new Set()
for (const m of src.matchAll(/\bt\("([A-Za-z0-9_.]+)"\)/g)) used.add(m[1])
// MS_ERR 的值是把后端错误码映射成的词典键
{
const block = /var MS_ERR = \{([\s\S]*?)\n\t{3}\};/.exec(src)
if (block) for (const m of block[1].matchAll(/"(ms\.[A-Za-z0-9_.]+)"/g)) used.add(m[1])
}
ok('词典引用点非空', used.size > 100, `-> 引用键 ${used.size} 个`)
const missing = [...used].filter((k) => !zhSet.has(k))
ok('被引用的词典键全部已声明', missing.length === 0, missing.length ? '-> 缺失: ' + missing.join(', ') : '')
// ── ③ 版式防线:卡片行的「不换行」三件套(少任何一个,用户报的换行就会复发)──
const cssHas = (selector, decl) => {
const re = new RegExp('\\.' + selector + '\\{[^}]*' + decl + '[^}]*\\}')
return re.test(src)
}
ok('.ms-rowName 有 white-space:nowrap', cssHas('ms-rowName', 'white-space:nowrap'))
ok('.ms-rowActions 有 white-space:nowrap', cssHas('ms-rowActions', 'white-space:nowrap'))
ok('.ms-rowActions 有 margin-left:auto(动作右对齐,官方 rowActions 同)', cssHas('ms-rowActions', 'margin-left:auto'))
ok('.ms-rowHead 是 flex 且 align-items:center', cssHas('ms-rowHead', 'align-items:center'))
ok('.ms-addActions 可换行(两个虚线按钮窄屏时上下堆叠)', cssHas('ms-addActions', 'flex-wrap:wrap'))
ok('.ms-addBtn 有 flex:1 1 0 + min-width:180px(官方 addButton 同)', cssHas('ms-addBtn', 'min-width:180px'))
// ── ④ 新的新增流程确实在(两个虚线入口 + 已无 4 列表格)──
ok('默认态有「添加提供方」入口', src.includes('ms.addProvider'))
ok('默认态有「添加自定义提供方」入口', src.includes('ms.addCustom'))
// 描述字符串很长,最容易混进未转义的 `"` 把 JSON 写坏(2026-09-14 实际踩过一次)
{
let pkgsOk = true
for (const p of ['package.json', 'poc/business-plugins/package.json']) {
try {
JSON.parse(read(p))
} catch {
pkgsOk = false
}
}
ok('package.json 均为合法 JSON', pkgsOk)
}
ok('「我已添加的提供方」已从表格改为 ul.ms-rows', /className: "ms-rows"/.test(src))
{
// 「模型设置」分区内不应再出现表格(其它页面仍可用 pa-tbl)
const from = src.indexOf('function ModelSettingsSection()')
const to = src.indexOf('ctx.slots.inject("settings.section"')
const body = from >= 0 && to > from ? src.slice(from, to) : ''
ok('「模型设置」分区内无 pa-tbl 残留', body.includes('pa-tbl') === false)
}
console.log(failed === 0 ? '\n全部通过(模型设置:词典一致性 + 版式防线)' : `\n${failed} 项失败`)
process.exit(failed === 0 ? 0 : 1)
+186
View File
@@ -0,0 +1,186 @@
#!/usr/bin/env node
/**
* verify-models-render.cjs —— 「模型设置」分区(`poc/business-plugins/lib/client.js`)
* 的**真渲染冒烟**:把 `ModelSettingsSection` 抠出来,用桩 React / jsxRuntime 跑一遍,
* 覆盖「卡片行 / 添加提供方卡片 / 自定义提供方卡片 / 默认双按钮 / 删除确认弹窗」五个分支。
*
* 为什么需要(语法检查抓不到的东西):
* `node --check` 只保证能解析;`verify-models-dict.mjs` 只保证词典齐全与版式锚点。
* 但组件里还有一类**只在渲染时才炸**的错:未定义变量、jsxRuntime 用法错、
* 访问了 `undefined.trim()` 之类。这类错在浏览器里表现为「整个设置页白屏」,
* 而本仓库没有前端测试运行器。本脚本用最小桩把渲染跑通,把这类错拦在构建期。
*
* ⚠️ **维护约束**:桩 `useState` 按**调用序号**喂值(见 `STATE_MAP`)。若给组件新增/调换
* `useState`,请同步 `STATE_MAP`;脚本会先断言调用数量,数量不符时**明确报错**而不是静默测错。
*
* 用法:node scripts/verify-models-render.cjs 退出码 0=全绿 / 1=有失败
*/
const { readFileSync } = require('node:fs')
const { join, dirname } = require('node:path')
const ROOT = join(__dirname, '..')
const FILE = join(ROOT, 'poc', 'business-plugins', 'lib', 'client.js')
const src = readFileSync(FILE, 'utf8')
let failed = 0
const ok = (name, cond, extra = '') => {
console.log((cond ? ' ✓ ' : ' ✗ ') + name + (extra ? ' ' + extra : ''))
if (!cond) failed++
}
/** 从 `function <name>() {` 起按大括号配平截取函数源码(跳过字符串与注释)。 */
function extractFn(text, name) {
const at = text.indexOf('function ' + name + '()')
if (at < 0) return null
const start = text.indexOf('{', at)
let depth = 0
let i = start
while (i < text.length) {
const c = text[i]
if (c === '/' && text[i + 1] === '/') { while (i < text.length && text[i] !== '\n') i++; continue }
if (c === '/' && text[i + 1] === '*') { i = text.indexOf('*/', i) + 2; continue }
if (c === '"' || c === "'" || c === '`') {
const q = c
i++
while (i < text.length && text[i] !== q) { if (text[i] === '\\') i++; i++ }
i++
continue
}
if (c === '{') depth++
else if (c === '}') { depth--; if (depth === 0) return text.slice(at, i + 1) }
i++
}
return null
}
const fnSrc = extractFn(src, 'ModelSettingsSection')
ok('取到 ModelSettingsSection 源码', fnSrc !== null)
if (fnSrc === null) process.exit(1)
/** 真词典(顺便验证键真的存在)。 */
function grabObj(text, name) {
const at = text.indexOf('var ' + name + ' = {')
const start = text.indexOf('{', at)
let depth = 0
for (let i = start; i < text.length; i++) {
if (text[i] === '{') depth++
else if (text[i] === '}') { depth--; if (depth === 0) return text.slice(start, i + 1) }
}
return null
}
const zh = eval('(' + grabObj(src, 'zh') + ')')
/**
* 桩 `useState` 的**位置→语义**映射。顺序 = 组件里 `var x = useState(...)` 的先后。
* 键是调用序号,值是「该序号在真实组件里是什么」——仅用于可读性,实际取值看 `over`。
*/
const STATE_MAP = [
'data', 'providers', 'loading', 'busy', 'msg', 'mode',
'fPick', 'fKey', 'fName', 'fRoute', 'fUrl', 'fApi', 'fModels', 'fShow', 'delTarget', 'catalog',
]
const mockedKeys = []
const FAKE_DATA = {
keys: [
{ id: 1, name: '内置 DeepSeek', route: null, enabled: true, baseUrl: null, api: null },
{ id: 2, name: 'Moonshot', route: 'moonshot', enabled: false, baseUrl: null, api: null },
{ id: 3, name: '我的中转', route: 'my-gw', enabled: true, baseUrl: 'https://gw.example/v1', api: 'openai-completions' },
],
shared: { available: true, name: '内置 DeepSeek', owner: 'admin', ownerIsMe: true, count: 1 },
sharedModelEnabled: true,
protocols: ['openai-completions', 'anthropic-messages'],
}
const FAKE_PROVIDERS = [
{ id: 'moonshot', label: '月之暗面', cn: true, modelCount: 3, models: ['kimi-k2', 'kimi-lite', 'kimi-pro'] },
{ id: 'openai', label: 'OpenAI', cn: false, modelCount: 2, models: ['gpt-5', 'gpt-5-mini'] },
]
function makeUseState(pass, counter) {
return function useState(init) {
const k = counter.i++
const over = pass.over || {}
// 未提供的字段回落到 init(而不是 undefined)—— 免得桩的疏漏被当成组件缺陷
const v = Object.prototype.hasOwnProperty.call(over, k) && over[k] !== undefined ? over[k] : init
return [v, function () {}]
}
}
const React = { useState: null, useEffect: function () {} }
const jsxRuntime = {
jsx: (type, props) => ({ type, props: props || {} }),
jsxs: (type, props) => ({ type, props: props || {} }),
Fragment: 'Fragment',
}
const t = (k) => {
if (!(k in zh)) { mockedKeys.push(k); return '<' + k + '>' }
return zh[k]
}
const noopReq = () => Promise.resolve({ ok: true, json: () => Promise.resolve({}) })
const MS_ERR = {}
const portalHost = () => ''
function render(pass) {
const counter = { i: 0 }
React.useState = makeUseState(pass, counter)
const factory = new Function(
'React', 'jsxRuntime', 't', 'paReq', 'papostJson', 'portalHost', 'MS_ERR',
fnSrc + '\nreturn ModelSettingsSection;',
)
const tree = factory(React, jsxRuntime, t, noopReq, noopReq, portalHost, MS_ERR)()
return { tree, calls: counter.i }
}
function walk(node, out) {
if (node == null || typeof node !== 'object') return
if (Array.isArray(node)) { for (const n of node) walk(n, out); return }
const p = node.props || {}
if (p.className) out.classes.push(String(p.className))
if (typeof p.children === 'string') out.text.push(p.children)
if (Array.isArray(p.children)) {
for (const c of p.children) { if (typeof c === 'string') out.text.push(c); else walk(c, out) }
} else if (p.children && typeof p.children === 'object') walk(p.children, out)
}
const BASE = { key: 'sk-abc', msg: '', over: {} }
const CASES = [
['默认态(两个虚线按钮)', { over: { 0: FAKE_DATA, 1: FAKE_PROVIDERS, 2: false, 3: false, 4: '', 5: '', 6: '', 7: BASE.key, 12: [''], 13: false, 14: null, 15: null } },
['ms-rows', 'ms-rowCard', 'ms-rowHead', 'ms-rowIdentity', 'ms-rowActions', 'ms-addActions', 'ms-addBtn']],
['添加提供方卡片', { over: { 0: FAKE_DATA, 1: FAKE_PROVIDERS, 2: false, 3: false, 4: '', 5: 'add', 6: 'moonshot', 7: BASE.key, 12: [''], 13: false, 14: null, 15: null } },
['ms-card', 'ms-field', 'ms-fieldLabel', 'ms-details', 'ms-summary', 'ms-editorActions']],
['自定义提供方卡片', { over: { 0: FAKE_DATA, 1: FAKE_PROVIDERS, 2: false, 3: false, 4: '', 5: 'custom', 6: '', 7: BASE.key, 8: '我的网关', 9: 'my-gw', 10: 'https://gw.example/v1', 11: 'openai-completions', 12: ['m1', ''], 13: false, 14: null, 15: null } },
['ms-modelList', 'ms-modelEntry', 'ms-modelRow', 'ms-iconBtn']],
['删除确认弹窗', { over: { 0: FAKE_DATA, 1: FAKE_PROVIDERS, 2: false, 3: false, 4: '', 5: '', 6: '', 7: BASE.key, 12: [''], 13: false, 14: FAKE_DATA.keys[1], 15: null } },
['pa-overlay', 'pa-modal', 'ms-editorActions']],
]
let sawCalls = 0
for (const [title, pass, expect] of CASES) {
let tree, calls
try {
const r = render(pass)
tree = r.tree
calls = r.calls
} catch (e) {
console.log(' ✗ ' + title + ' → 抛异常: ' + e.message)
failed++
continue
}
sawCalls = calls
const out = { classes: [], text: [] }
walk(tree, out)
const joined = out.classes.join(' | ')
const miss = expect.filter((c) => joined.indexOf(c) < 0)
const problems = []
if (miss.length) problems.push('缺 class: ' + miss.join(', '))
const unrendered = out.text.filter((s) => /^<ms\./.test(s))
if (unrendered.length) problems.push('未渲染词典键: ' + unrendered.join(', '))
if (problems.length) { console.log(' ✗ ' + title + ' → ' + problems.join(' ; ')); failed++ }
else console.log(' ✓ ' + title + ' (class ' + out.classes.length + ' 个, 文本 ' + out.text.length + ' 段)')
}
ok('useState 调用数量与 STATE_MAP 对齐', sawCalls === STATE_MAP.length,
sawCalls === STATE_MAP.length ? '-> ' + sawCalls : '-> 实测 ' + sawCalls + ' / 映射 ' + STATE_MAP.length + '(组件加了 state?请同步本脚本 STATE_MAP)')
ok('渲染期未出现缺词典键', mockedKeys.length === 0, mockedKeys.length ? '-> ' + [...new Set(mockedKeys)].join(', ') : '')
console.log(failed === 0 ? '\n渲染冒烟全部通过' : '\n' + failed + ' 项失败')
process.exit(failed === 0 ? 0 : 1)
+49 -13
View File
@@ -236,13 +236,41 @@ ok("非 admin:不出现任何功能项", !PORTAL_ITEMS.some(k => tUser.include
tMs.includes("内置 DeepSeek") && tMs.includes("my-gw"));
ok("模型设置:自定义条目副行显示 endpoint · 协议",
tMs.includes("https://api.example.com/v1") && tMs.includes("openai-completions"));
// 档案 87 补做:**按官方页的结构** —— 新增先「选厂家」,目录厂家只需填 API 密钥
ok("模型设置:新增有厂家选择框(含内置 / 自定义两个端点选项)",
tMs.includes("厂家:") && tMs.includes("内置 DeepSeek(平台共享)") && tMs.includes("自定义厂家(OpenAI 兼容网关)…"));
ok("模型设置:选择框按「中国大陆 / 国际」分组,并列出目录厂家(含国内厂家)",
tMs.includes("中国大陆") && tMs.includes("国际") && tMs.includes("月之暗面 Moonshot(中国)") && tMs.includes("OpenAI"));
ok("模型设置:默认选中「内置 DeepSeek」时给出回落说明",
tMs.includes("密钥留空则回落到平台共享密钥"));
// ── 档案 91(2026-09-14):「新增」改官方**两步式** ──────────────────────────
// 官方口径(`dsh-client-ui-settings-models` README.zh 原文):
// · 「「新增」流程是一张承载休眠目录提供方选择框的卡片」
// · 「编辑卡片上的主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名」
// 所以默认态**只露两个虚线入口**,点开才出卡片(旧版是常开表单 + 下拉里混一项
// 「自定义厂家(OpenAI 兼容网关)…」,两者形态不同 ⇒ 断言必须跟着换)。
ok("模型设置:新增默认态只有两个虚线入口(添加提供方 / 添加自定义提供方)",
tMs.includes("添加提供方") && tMs.includes("添加自定义提供方"));
ok("模型设置:默认态**不再**出现厂家下拉与旧「自定义厂家…」选项",
!tMs.includes("厂家:") && !tMs.includes("自定义厂家(OpenAI 兼容网关)"));
// 点开「添加提供方」:出现「提供方」下拉(中国大陆 / 国际分组 + 目录厂家)+ 单一「API 密钥」主字段
const tAdd = text(await renderWith(ms.Comp, () => { slots[5] = "add"; })).join(" | ");
ok("模型设置:点开后有「提供方」下拉,按中国大陆 / 国际分组并列出目录厂家",
tAdd.includes("提供方") && tAdd.includes("中国大陆") && tAdd.includes("国际") &&
tAdd.includes("月之暗面 Moonshot(中国)") && tAdd.includes("OpenAI"));
ok("模型设置:新增卡片主字段是「API 密钥」(内置回落占位符)",
tAdd.includes("API 密钥") && tAdd.includes("输入你的 DeepSeek API 密钥"));
ok("模型设置:次要字段收进「自定义设置」,并说明其余字段在 settings.yaml",
tAdd.includes("自定义设置") && tAdd.includes("显示名称") && tAdd.includes("其余字段在 settings.yaml 中"));
// 选中一个目录厂家 ⇒ 给出「端点/模型由目录兜底」的说明(档案 87 的核心口径)
const tPick = text(await renderWith(ms.Comp, () => { slots[5] = "add"; slots[6] = "moonshotai-cn"; })).join(" | ");
ok("模型设置:选目录厂家后提示「端点与 N 个模型由官方目录提供」",
tPick.includes("由官方目录提供") && tPick.includes("12 个模型"));
// 点开「添加自定义提供方」:官方字段序 + 模型改**行式编辑器**(旧版是 textarea 一行一个)
const treeCustom = await renderWith(ms.Comp, () => { slots[5] = "custom"; });
const tCustom = text(treeCustom).join(" | ");
const cCustom = classes(treeCustom);
ok("模型设置:自定义卡片含 Provider ID / API 地址 / API 协议 / 模型目录",
tCustom.includes("Provider ID") && tCustom.includes("API 地址") &&
tCustom.includes("API 协议") && tCustom.includes("模型目录"));
// 行式编辑器:逐行条目(ms-modelEntry/ms-modelRow)+「添加模型」按钮;
// 行尾删除按钮的文案只在 `aria-label`(文本收集器不收属性)⇒ 用词典键 + 图标按钮类来断言
ok("模型设置:自定义模型目录是**行式编辑器**(逐行条目 + 添加模型 + 行尾删除)",
cCustom.includes("ms-modelEntry") && cCustom.includes("ms-modelRow") && cCustom.includes("ms-iconBtn") &&
tCustom.includes("添加模型") && !!ZH["ms.removeModel"]);
// 2026-09-14 留痕:目录读不到时要**如实说明**,不能静默给个空列表
{
const saved = DATA["/api/me/model-providers"];
@@ -252,20 +280,28 @@ ok("非 admin:不出现任何功能项", !PORTAL_ITEMS.some(k => tUser.include
};
const tBad = text(await render(ms.Comp)).join(" | ");
ok("模型设置:目录不可读时如实提示(含目录路径,不静默空列表)",
tBad.includes("未能读取官方厂家目录") && tBad.includes("/opt/nope/pi-ai"), tBad.slice(0, 0) + "命中");
tBad.includes("未能读取官方提供方目录") && tBad.includes("/opt/nope/pi-ai"), tBad.slice(0, 0) + "命中");
DATA["/api/me/model-providers"] = { providers: [], catalog: { dir: "/x", readable: true, count: 0 } };
const tEmpty = text(await render(ms.Comp)).join(" | ");
ok("模型设置:目录为空的文案与「读不到」不同",
tEmpty.includes("官方厂家目录为空") && !tEmpty.includes("未能读取官方厂家目录"));
tEmpty.includes("官方提供方目录为空") && !tEmpty.includes("未能读取官方提供方目录"));
DATA["/api/me/model-providers"] = saved;
}
ok("模型设置:词典备齐「只填密钥」相关文案(目录提示 / 展开模型)",
!!ZH["ms.catalogHint"] && !!ZH["ms.showModels"] && !!ZH["ms.hideModels"] && !!ZH["ms.pickProvider"]);
ok("模型设置:词典备齐「只填密钥」相关文案(目录提示 / 展开模型 / 两个入口)",
!!ZH["ms.keyHintCatalog"] && !!ZH["ms.showModels"] && !!ZH["ms.hideModels"] &&
!!ZH["ms.addProvider"] && !!ZH["ms.addCustom"] && !!ZH["ms.customized"]);
// 落地在 spawn 时 ⇒ 文案必须说清"重启才生效",否则用户会以为保存即生效
ok("模型设置:文案说明「重启实例生效」", tMs.includes("重启实例生效"));
// 档案 91:视觉层从「门户表格家族」换成**官方 CSS 模块家族**(`.ms-*`)——
// 换掉表格的原因见档案 91:4 列 auto 列宽会把名称/按钮挤到折行。
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)));
ok("模型设置:行用官方卡片行类(ms-rows/ms-rowCard/ms-rowHead/ms-rowIdentity/ms-rowActions)",
["ms-rows", "ms-rowCard", "ms-rowHead", "ms-rowIdentity", "ms-rowActions"].every(c => cMs.includes(c)));
ok("模型设置:新增入口用官方虚线按钮类(ms-addActions/ms-addBtn/ms-btn)",
["ms-addActions", "ms-addBtn", "ms-btn"].every(c => cMs.includes(c)));
ok("模型设置:**不再**用 4 列表格(pa-tbl / pa-sm / pa-input 已从本分区移除)",
!cMs.includes("pa-tbl") && !cMs.includes("pa-sm") && !cMs.includes("pa-input"));
ok("模型设置:保留门户 .pa-box 外壳与 .pa-badge 徽章", cMs.includes("pa-box") && cMs.includes("pa-badge"));
}
}
+40
View File
@@ -137,6 +137,45 @@ export function dshScopeDir(): string {
return join(dshPackageRoot(), 'node_modules', '@deepseek-ai')
}
/* ------------------------------------------------------------------ *
* 平台包版本查询(**含伞包**)
* ------------------------------------------------------------------ */
const SCOPE = '@deepseek-ai/'
/** 包名 → 平台版本;平台没有该包 → `null`。进程内缓存。 */
const versionCache = new Map<string, string | null>()
/**
* 平台某 `@deepseek-ai/*` 包的真实版本;平台没有该包 → `null`。
*
* ⚠️ **伞包 `@deepseek-ai/dsh` 必须单独解析**:`dshScopeDir()` = `<dsh 包根>/node_modules/@deepseek-ai`,
* 而**伞包不在自己的 `node_modules` 里** ⇒ 只走「scope 目录 + 短名」,对伞包恒为 `null`。
* 2026-09-14 实测代价:声明 `@deepseek-ai/dsh: ^0.1.5-rc.2` 的插件
* (`dsh-zotero` / `dsh-any-background` / `dsh-md-notes`)在兼容性判定里**一条都认不出来**
* —— 会被当成「平台没这个包,不算不兼容」而放行。见 `04-调整方案/92`。
*
* @param pkg 包名;`@deepseek-ai/dsh` / `@deepseek-ai/dsh-*` / 不带 scope 的短名都认
*/
export function platformPackageVersion(pkg: string): string | null {
const full = pkg.startsWith('@') ? pkg : SCOPE + pkg
const hit = versionCache.get(full)
if (hit !== undefined) return hit
let v: string | null = null
try {
// 伞包读 `<dsh 包根>/package.json`;子包读 scope 目录下的同名目录
const dir = full === PKG_NAME ? dshPackageRoot() : join(dshScopeDir(), full.slice(SCOPE.length))
const pj = join(dir, 'package.json')
if (existsSync(pj)) {
const meta = JSON.parse(readFileSync(pj, 'utf8')) as { version?: unknown }
v = typeof meta.version === 'string' ? meta.version : null
}
} catch {
v = null
}
versionCache.set(full, v)
return v
}
/**
* `@earendil-works/pi-ai` 的**厂家目录**数据位置。
* @returns 绝对路径;**不保证存在**(不存在时返回首选候选,由调用方降级为空目录)。
@@ -168,4 +207,5 @@ export function piAiDataDir(): string {
/** 清解析缓存(改了 env 之后重新解析;测试用)。 */
export function resetInstallPathsCache(): void {
cachedRoot = null
versionCache.clear()
}
+36 -19
View File
@@ -33,7 +33,7 @@ import { pathToFileURL } from 'node:url'
// 平台内置包的落点**不能写死**:`npm root -g` 随发行版变(`/usr/local/lib` vs `/usr/lib`),
// 写死只会在另一种布局上静默退化成「平台包目录不可读」而不报错(测试服实测 = 0)。
// 统一走 `dsh-install.ts` 的按序探测。
import { dshScopeDir } from './dsh-install.js'
import { dshScopeDir, platformPackageVersion } from './dsh-install.js'
const DS_SCOPE = '@deepseek-ai/'
@@ -53,6 +53,11 @@ export interface CompatResult {
platformPkgs: number
/** 是否真的看到了 @deepseek-ai 的依赖声明或 import(区分 ok / unknown 用) */
sawPlatformRef: boolean
/**
* 有几条声明「按 semver 默认语义不满足、按 prerelease 容忍满足」——**平台是 prerelease**
* 的语义产物,**不计入 `findings`、不阻断**,只留个可观测计数(2026-09-14,档案 92)。
*/
prereleaseOnly?: number
/** 判定过程出错时的说明(此时 level=unknown) */
note?: string
}
@@ -61,7 +66,6 @@ export interface CompatResult {
* 平台侧:版本表(读 package.json)与导出符号(按需 import,带缓存)
* ------------------------------------------------------------------ */
const versionCache = new Map<string, string | null>()
const exportCache = new Map<string, Set<string> | null>()
function shortName(pkg: string): string {
@@ -94,22 +98,15 @@ export function platformPkgCount(): number {
}
}
/** 平台内置某包的版本;平台没有该包 → null。 */
/** 平台内置某包的版本;平台没有该包 → `null`。
*
* 2026-09-14(档案 92):改为走 `dsh-install.platformPackageVersion()` —— 原实现只查
* `<scope>/<短名>/package.json`,而**伞包 `@deepseek-ai/dsh` 不在自己的 `node_modules` 里**
* ⇒ 对它恒判 `null`,被当作「平台没这个包,不算不兼容」而放行(实测那 3 条"要求更高版本"的
* 插件全部只写在伞包上)。收口后该判据**含伞包**,与 `plugin-dsh-compat` 共用同一份版本表。
*/
function platformVersion(pkg: string): string | null {
const hit = versionCache.get(pkg)
if (hit !== undefined) return hit
let v: string | null = null
try {
const pj = join(platformPkgDir(pkg), 'package.json')
if (existsSync(pj)) {
const meta = JSON.parse(readFileSync(pj, 'utf8')) as { version?: unknown }
v = typeof meta.version === 'string' ? meta.version : null
}
} catch {
v = null
}
versionCache.set(pkg, v)
return v
return platformPackageVersion(pkg)
}
function resolveEntryFile(pkg: string): string | null {
@@ -159,7 +156,7 @@ async function platformExports(pkg: string): Promise<Set<string> | null> {
return keys
}
type SemverLike = { satisfies(v: string, r: string): boolean }
type SemverLike = { satisfies(v: string, r: string, o?: { includePrerelease?: boolean }): boolean }
let semverPromise: Promise<SemverLike | null> | null = null
function loadSemver(): Promise<SemverLike | null> {
@@ -303,8 +300,25 @@ export async function checkPluginCompat(root: string): Promise<CompatResult> {
continue
}
let ok: boolean
let prereleaseOnly = false
try {
ok = semver.satisfies(v, range) // **默认语义**:与 pnpm 的实际安装判定一致
// ① 先按**默认语义**判(与 pnpm 的实际安装判定一致)
ok = semver.satisfies(v, range)
if (!ok) {
// ② 不满足时再看是不是**prerelease 语义产物**。
// 平台版本是 prerelease(`0.1.5-rc.1`、子包 `0.1.5-rc.2`),而 semver 默认语义下
// prerelease **不满足** `*`、`^0.1.2` 这类不含 prerelease 的范围 —— 于是
// 「声明 `*`(= 任意版本)」会被判成不兼容。
// 实测 2026-09-14(抽样 300,见 `04-调整方案/92`):
// real-newer 3 | narrow-pin 22 | **prerelease-artifact 117** | match 98
// ⇒ 若把这 117 也当"不兼容",闸门会有 ~39% 假阳性(`dshmarket`、`dsh-context`
// 这些**在平台上跑得好好的**都会被拦)。所以:**只有 prerelease 容忍下也不满足,
// 才算真不兼容**;容忍下能满足的只记一条 note(可观测,不阻断)。
if (semver.satisfies(v, range, { includePrerelease: true })) {
ok = true
prereleaseOnly = true
}
}
} catch {
continue // 范围写法无法解析:不据此断言
}
@@ -314,6 +328,9 @@ export async function checkPluginCompat(root: string): Promise<CompatResult> {
pkg,
detail: `平台为 ${v},插件要求 ${range}(不满足)`,
})
} else if (prereleaseOnly) {
// 保持可观测:这一条是"按 pnpm 语义不满足、按 prerelease 容忍满足"的平台效应
out.prereleaseOnly = (out.prereleaseOnly ?? 0) + 1
}
}
+169
View File
@@ -0,0 +1,169 @@
/**
* plugin-dsh-compat.ts —— 判「**尚未安装**的 npm 插件按其声明,是否兼容**当前** dsh 版本」。
*
* 为什么单独一个文件(不复用 `plugin-compat.ts` 的**判定逻辑**):
* · 那个模块判的是「**已解包到磁盘**的插件目录」,要扫 bundle 里的 import、要 import 平台包
* 取真实导出 —— 对「官方推荐列表里还没下载的一条」根本用不上;
* · 两处的**语义口径必须不同**(见下),混在一起会把两边都带偏。
* ⚠️ **版本表是共用的**:伞包 `@deepseek-ai/dsh` **不在自己的 `node_modules` 里**,
* 只按「scope 目录 + 短名」查会对它恒判 `null` —— 而实测那 3 条「要求更高版本」的声明
* **全部只写在伞包上**(不补这一步就一条都认不出来)。该解析已收口到
* `dsh-install.platformPackageVersion()`,`plugin-compat` 与本模块**共用同一份**(档案 92)。
*
* ⚠️ **语义口径(踩过才知道)**:判 `satisfies` 必须带 **`includePrerelease: true`**。
* 平台版本是 prerelease(`0.1.5-rc.1` / 子包 `0.1.5-rc.2`),而 semver **默认语义**下
* prerelease **不满足** `^0.1.2`、甚至不满足 `*`(除非范围里带同元组 prerelease)。
* 实测 2026-09-14:TOP300 用默认语义得 `match 97 / older 139`(`dsh-univer-office`、
* `dshmarket` 这些**在平台上跑得好好的**都被判成 "只兼容更旧版本");换成 prerelease
* 容忍后是 `match 214 / older 22`。⇒ **此处的"匹配"= prerelease 容忍语义**。
* (`plugin-compat.ts` 用默认语义是**有意的**:那里要跟 pnpm 的实际安装判定一致 —— 两处
* 语义不同是**正确的**,别"统一"掉。)
*
* @module dshs/web/plugin-dsh-compat
*/
import { platformPackageVersion } from './dsh-install.js'
const DS_SCOPE = '@deepseek-ai/'
/** 伞包包名:它带版本声明时最值得看(子包版本通常与它同位)。 */
const UMBRELLA = '@deepseek-ai/dsh'
/** 兼容性分类。 */
export type DshCompat = 'match' | 'none' | 'newer' | 'older' | 'unknown'
export interface DshCompatVerdict {
/** `match` 声明满足 / `none` 未声明任何平台依赖 / `newer` 要求更高的 dsh / `older` 只允许更旧的 / `unknown` 判不了。 */
kind: DshCompat
/** `kind === 'newer'` 时给出**它要求的最低 dsh 版本**(能算出来才有)。 */
requires?: string
/** 判据摘要(排障用,落日志/缓存,不下发给浏览器)。 */
why?: string
}
/* ------------------------------------------------------------------ *
* 平台版本表(含伞包)—— 唯一实现在 `dsh-install.ts`
* ------------------------------------------------------------------ */
/**
* 平台某 `@deepseek-ai/*` 包的真实版本;平台没有 → `null`。
* 与本文件的伞包解析需求同源:`dsh-install.platformPackageVersion()` **含伞包**
* (伞包不在自己的 `node_modules` 里,那条坑在 `dsh-install.ts` 里有完整记录)。
*/
export function platformVersionOf(pkg: string): string | null {
return platformPackageVersion(pkg)
}
/** 当前平台的 dsh 伞包版本(`0.1.5-rc.1` 这种);读不到 → `null`。 */
export function platformDshVersion(): string | null {
return platformPackageVersion(UMBRELLA)
}
/* ------------------------------------------------------------------ *
* semver(与 plugin-compat 同款"软依赖"载入:拿不到就全部 unknown)
* ------------------------------------------------------------------ */
type SemverLike = {
satisfies(v: string, r: string, o?: { includePrerelease?: boolean }): boolean
minVersion(r: string): { version: string } | null
lt(a: string, b: string): boolean
}
let semverPromise: Promise<SemverLike | null> | null = null
function loadSemver(): Promise<SemverLike | null> {
semverPromise ??= (async () => {
try {
const m = (await import('semver')) as unknown as { default?: SemverLike } & SemverLike
return (m.default ?? m) as SemverLike
} catch {
return null
}
})()
return semverPromise
}
/* ------------------------------------------------------------------ *
* 声明抽取 + 判定
* ------------------------------------------------------------------ */
/**
* 从 npm 包的 manifest 里抽出**与平台相关**的声明:`@deepseek-ai/*` 的
* peer / optional / 运行依赖,外加 `engines.dsh`(有插件用它写版本要求)。
* @param manifest npm registry 返回的 `versions[<ver>]` 对象(字段可能缺失)
*/
export function platformDeclsOf(manifest: unknown): Map<string, string> {
const out = new Map<string, string>()
if (typeof manifest !== 'object' || manifest === null) return out
const m = manifest as Record<string, Record<string, unknown> | undefined>
for (const bucket of ['peerDependencies', 'optionalDependencies', 'dependencies']) {
const bag = m[bucket]
if (typeof bag !== 'object' || bag === null) continue
for (const [k, v] of Object.entries(bag)) {
if (k.startsWith(DS_SCOPE) && typeof v === 'string') out.set(k, v)
}
}
const eng = m['engines']
if (typeof eng === 'object' && eng !== null && typeof (eng as Record<string, unknown>)['dsh'] === 'string') {
out.set(UMBRELLA, (eng as Record<string, string>)['dsh'])
}
return out
}
/**
* 判定「按该插件声明的平台依赖范围,能否装在**当前** dsh 上」。
* @param manifest npm registry 的版本 manifest(要用 `platformDeclsOf` 能认的那一层)
*/
export async function judgeDshCompat(manifest: unknown): Promise<DshCompatVerdict> {
const decls = platformDeclsOf(manifest)
if (decls.size === 0) return { kind: 'none', why: '未声明任何 @deepseek-ai/* 依赖' }
const semver = await loadSemver()
if (semver === null) return { kind: 'unknown', why: 'semver 不可用' }
let satisfied = 0
let unjudged = 0
/** 不满足且「要求比平台更高」→ 记下它要求的最低版本。 */
let newer: { pkg: string; range: string; min: string } | null = null
const older: string[] = []
for (const [pkg, range] of decls) {
const v = platformVersionOf(pkg)
// 平台没有这个包 ⇒ 不据此断言(与 plugin-compat 判据 A 同一处理:可能是插件自带)
if (v === null) {
unjudged++
continue
}
let ok: boolean
try {
ok = semver.satisfies(v, range, { includePrerelease: true })
} catch {
unjudged++
continue
}
if (ok) {
satisfied++
continue
}
let min: string | null = null
try {
min = semver.minVersion(range)?.version ?? null
} catch {
min = null
}
if (min !== null && semver.lt(v, min)) {
// 平台版本低于该范围允许的最低版本 ⇒ 插件要求**更新的** dsh
if (newer === null || semver.lt(newer.min, min)) newer = { pkg, range, min }
} else {
older.push(`${pkg}(${v} ∉ ${range})`)
}
}
// 只要有一条"要求更高",这条插件就属于「需要升级 dsh 才能用」—— 比"只兼容更旧"更重要。
if (newer !== null) {
return { kind: 'newer', requires: newer.min, why: `${newer.pkg} 要求 ${newer.range}` }
}
if (older.length > 0) return { kind: 'older', why: older.slice(0, 3).join(';') }
if (satisfied > 0) return { kind: 'match', why: `满足 ${satisfied} 条声明` }
if (unjudged > 0) return { kind: 'unknown', why: `平台无对应包或范围不可解析(${unjudged} 条)` }
return { kind: 'none', why: '无可判定的声明' }
}
+183 -3
View File
@@ -28,6 +28,7 @@ import { mkdir, rename, rm } from 'node:fs/promises'
import { join } from 'node:path'
import { requireAdmin } from '../middleware/authn.js'
import { httpError } from '../security-scan.js'
import { type DshCompat, type DshCompatVerdict, judgeDshCompat, platformDshVersion } from '../plugin-dsh-compat.js'
import { stageTgzArchive } from './business-plugins.js'
/** 官方目录规范地址(同一份内容亦镜像为 npm 包 `dsh-plugin-catalog`)。 */
@@ -208,9 +209,164 @@ export const whitelistRoutes: FastifyPluginAsync = async (app) => {
throw httpError(400, `该插件需从源码构建安装${entry.install === '' ? '' : `(${entry.install})`},暂不支持一键导入`)
}
/* ------------------------------------------------------------------ *
* dsh 版本兼容判定(档案 92)
*
* 由来(用户 2026-09-14):「官方推荐插件列表**只能显示匹配当前 dsh 版本 和 超过当前版本**的
* 插件(超过的要明确标注)」。官方目录**不带 dsh 版本字段**(只有插件自身 `version`)⇒ 只能
* 逐包去 npm 取 `peerDependencies / engines`,与**平台真实版本**比(判定细节见
* `src/web/plugin-dsh-compat.ts` 头部注释,含「必须 includePrerelease」的实测缘由)。
*
* 成本与缓存:一条 = 1 次 `registry.npmjs.org` 的**精简 packument**(十几 KB,不下载 tarball)。
* 结果按 `<npm>@<version>` 落盘缓存,TTL 7 天(peer 范围极少变)。首次冷启动靠 `warmCompat`
* 在后台预热;请求内只给有限预算,超预算的条目判 `unknown`(**显示并标注"未验证"**,
* 绝不因为一次网络抖动就把插件**静默藏掉**)。
* ------------------------------------------------------------------ */
const compatFile = join(cacheDir, 'npm-dsh-compat.json')
/** 缓存结构版本(判定口径变了就 +1,免得旧口径结论被复用)。 */
const COMPAT_VERSION = 1
const COMPAT_TTL = 7 * 24 * 60 * 60 * 1000
/** 请求内判定的预算:超时即判 `unknown`(页面仍可用,后台预热会补上)。 */
const COMPAT_BUDGET_MS = 6_000
/** 请求内并发(别再高:registry 有软限流)。 */
const COMPAT_CONCURRENCY = 12
/** 预热并发(更保守:这是后台任务,不赶时间)。 */
const WARM_CONCURRENCY = 6
/** 每次请求最多判定多少条(列表页上限 300,多留一点余量给"过滤后补位")。 */
const COMPAT_WINDOW = 360
interface CompatRecord {
kind: DshCompat
requires?: string
why?: string
at: number
}
let compatMem: Map<string, CompatRecord> | null = null
let warmStarted = false
const compatKey = (entry: WhitelistEntry): string | null =>
entry.npm === null || entry.importKind === 'source' ? null : `${entry.npm}@${entry.version ?? ''}`
const loadCompat = (): Map<string, CompatRecord> => {
if (compatMem !== null) return compatMem
compatMem = new Map()
try {
if (existsSync(compatFile)) {
const raw = JSON.parse(readFileSync(compatFile, 'utf8')) as { version?: number; items?: Record<string, CompatRecord> }
if (raw.version === COMPAT_VERSION && typeof raw.items === 'object' && raw.items !== null) {
for (const [k, v] of Object.entries(raw.items)) if (typeof v?.kind === 'string') compatMem.set(k, v)
}
}
} catch {
/* 缓存坏了就当空表(不影响功能,只是要重判) */
}
return compatMem
}
let compatDirty = false
const saveCompat = (): void => {
if (!compatDirty || compatMem === null) return
try {
mkdirSync(cacheDir, { recursive: true, mode: 0o755 })
const items: Record<string, CompatRecord> = {}
for (const [k, v] of compatMem) items[k] = v
writeFileSync(compatFile, JSON.stringify({ version: COMPAT_VERSION, items }))
compatDirty = false
} catch (err) {
app.log.warn({ err }, 'whitelist: dsh 兼容缓存写入失败(下次请求重判)')
}
}
/** 取单条判定;命中缓存(且未过期)直接返回,否则查 npm registry。 */
const compatOf = async (entry: WhitelistEntry): Promise<DshCompatVerdict> => {
const key = compatKey(entry)
if (key === null) return { kind: 'none', why: '未发 npm 包' }
const cache = loadCompat()
const hit = cache.get(key)
if (hit !== undefined && Date.now() - hit.at < COMPAT_TTL) return { kind: hit.kind, requires: hit.requires, why: hit.why }
let verdict: DshCompatVerdict
try {
const name = entry.npm as string
// 精简 packument:带 `peerDependencies`,体积远小于完整 packument
const res = await fetch(`${REGISTRY}/${encodeURIComponent(name).replace('%40', '@')}`, {
headers: { accept: 'application/vnd.npm.install-v1+json' },
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const meta = (await res.json()) as { 'dist-tags'?: { latest?: string }; versions?: Record<string, unknown> }
const latest = meta['dist-tags']?.latest
// 优先用「目录声明的版本」;缺了才用 latest(目录版本 = 用户会装到的那个)
const manifest = (entry.version !== null ? meta.versions?.[entry.version] : undefined) ?? (latest === undefined ? undefined : meta.versions?.[latest])
if (manifest === undefined) throw new Error('包内找不到该版本')
verdict = await judgeDshCompat(manifest)
} catch (err) {
// 网络/元数据问题 ≠ 不兼容:判 `unknown` 并**照常显示**(fixture: 见本文件头部注释)
verdict = { kind: 'unknown', why: err instanceof Error ? err.message : String(err) }
}
cache.set(key, { ...verdict, at: Date.now() })
compatDirty = true
return verdict
}
/** 批量判定(限并发 + 限总预算);返回 id → 判定。 */
const compatBatch = async (list: WhitelistEntry[], budgetMs: number): Promise<Map<string, DshCompatVerdict>> => {
const out = new Map<string, DshCompatVerdict>()
const deadline = Date.now() + budgetMs
const queue = [...list]
await Promise.all(
Array.from({ length: Math.min(COMPAT_CONCURRENCY, queue.length) }, async () => {
while (queue.length > 0) {
if (Date.now() > deadline) return
const e = queue.shift() as WhitelistEntry
out.set(e.id, await compatOf(e))
}
}),
)
saveCompat()
return out
}
/**
* 后台预热:把**全部可导入条目**的判定补进缓存(进程内只跑一次)。
* 不 await —— 首次冷启动的列表请求不该等这 1800 次查询;预热完成后列表即为缓存命中。
*/
const warmCompat = (entries: WhitelistEntry[]): void => {
if (warmStarted) return
warmStarted = true
const queue = entries.filter((e) => compatKey(e) !== null)
void (async () => {
let done = 0
const t0 = Date.now()
await Promise.all(
Array.from({ length: WARM_CONCURRENCY }, async () => {
while (queue.length > 0) {
const e = queue.shift() as WhitelistEntry
try {
await compatOf(e)
} catch {
/* compatOf 自身不会抛;这里只是双保险 */
}
done++
}
}),
)
saveCompat()
app.log.info(`whitelist: dsh 兼容预热完成 ${done} 条,用时 ${Math.round((Date.now() - t0) / 1000)}s`)
})()
}
app.get('/api/plugins/whitelist', { preHandler: requireAdmin }, async (request) => {
const { q, category, onlyImportable } = request.query as { q?: string; category?: string; onlyImportable?: string }
const { q, category, onlyImportable, dshCompat } = request.query as {
q?: string
category?: string
onlyImportable?: string
dshCompat?: string
}
const { fetchedAt, entries, categoryLabels, stale } = await getIndex()
// 首次进列表页就把全量判定在后台热起来(不阻塞本次响应)
warmCompat(entries)
let list = entries
if (category !== undefined && category !== '') list = list.filter((e) => e.category === category)
if (onlyImportable === '1') list = list.filter((e) => e.importKind !== 'source')
@@ -230,19 +386,43 @@ export const whitelistRoutes: FastifyPluginAsync = async (app) => {
const categories = [...counts.entries()]
.map(([id, count]) => ({ id, label: categoryLabels[id] ?? id, count }))
.sort((a, b) => b.count - a.count)
/** 兼容判定结果(`dshCompat=all` 时只标注、不过滤,便于排障)。 */
const onlyCompat = dshCompat !== 'all'
// 判定窗口取「比页面上限略大」:过滤掉"只兼容更旧版本"的后仍能把页面填满
const window = list.slice(0, COMPAT_WINDOW)
const verdicts = await compatBatch(
onlyCompat ? window : window.filter((e) => compatKey(e) !== null),
COMPAT_BUDGET_MS,
)
const annotate = (e: WhitelistEntry): WhitelistEntry & { dshCompat: DshCompat; dshRequires?: string } => {
const v = verdicts.get(e.id)
return v === undefined ? { ...e, dshCompat: 'unknown' as DshCompat } : { ...e, dshCompat: v.kind, ...(v.requires === undefined ? {} : { dshRequires: v.requires }) }
}
const shownWindow = onlyCompat ? window.filter((e) => verdicts.get(e.id)?.kind !== 'older') : window
const hiddenByDsh = onlyCompat ? window.length - shownWindow.length : 0
const plugins = shownWindow.slice(0, 300).map(annotate)
return {
fetchedAt,
stale,
total: entries.length,
count: list.length,
count: plugins.length,
/** 过滤前的可展示条数(本次筛选条件命中数),供前端说明「被隐藏了多少」。 */
countAll: window.length,
hiddenByDsh,
/** 当前平台 dsh 版本 —— 前端据此显示「已按 dsh X 过滤」与「需 ≥ Y」的对比。 */
dshVersion: platformDshVersion(),
compatFiltered: onlyCompat,
importable: entries.filter((e) => e.importKind !== 'source').length,
categories,
plugins: list.slice(0, 300),
plugins,
}
})
app.post('/api/plugins/whitelist/refresh', { preHandler: requireAdmin }, async () => {
const { entries, fetchedAt } = await getIndex(true)
warmCompat(entries)
return { ok: true, total: entries.length, importable: entries.filter((e) => e.importKind !== 'source').length, fetchedAt }
})