diff --git a/dsh-server-docs/03-路线图与待办.md b/dsh-server-docs/03-路线图与待办.md index 9241737..2c0b4d2 100644 --- a/dsh-server-docs/03-路线图与待办.md +++ b/dsh-server-docs/03-路线图与待办.md @@ -99,7 +99,7 @@ | ✅关闭 | ~~白名单源码安装支持~~(档案 29 §七) | **评估结论:不做(2026-09-11)**:源码安装需让第三方构建脚本以 root 在平台机执行(供应链 + 可复现性 + 性能三重风险),与"平台不执行第三方构建脚本"红线冲突。**替代路径**:admin 本地构建后走「上传 tgz」通道(已有 P0/P1 扫描);如需开启须先满足沙箱构建 + `--ignore-scripts` + 仅 admin + 审计等全部前提 | | 降级 | B5:给 portal-entry / business-plugins 加加载标记 | **降级为"顺手做"(2026-09-11 决策)**:不解决当前问题、源码不在仓库、且仅提升"升级时排查速度";下次改这两个插件时顺手加(零边际成本) | | ✅ | ~~熔断 live 实测~~(档案 20 附) | **已完成(2026-09-11)**:由真实故障用户(档案 52)的日志完成实测 —— 退避 1000→2000→4000→8000ms、`restartsInWindow` 1→4、第 **5** 次触发 `crash-loop-circuit-open`(窗口 600000ms / 5 of 5),当日 2 次开断;无需再人为 kill 复现 | -| ✅ **已完成**(2026-09-15) | ~~档案 16 阶段 3/4(实例内「我的技能」)~~ → **= 交接单 `T01`,已归档** | **档案 100**|插件 `business-plugins` **0.3.20 → 0.3.21** 已投放两实例。① **形态修正(09-15)**:由「新增 section」改为**并入既有「功能管理」section 内分组**(依据用户口径「不要分开管理」+ 档案 60 分区命名;**仅入口层合并、机制层分离**)→ 见 `T01 §四 决策 6` 与 **`T01 §九`**;② 实现:技能行(名称/来源/状态/事实/动作)+ zip 拖拽上传 + 同名两阶段替换 + 页内确认弹窗 + 锁定行(共享技能)无动作按钮 + zh/en 46 条词条;③ 验收:`npm run verify` 全绿(含新 `scripts/verify-my-skills.mjs` 34 条断言)、06 §7 三段式全绿、端到端 **19/19**(409 / 400 守卫通过);④ 阶段 4 收口 = 未新增 section + 未改角色补丁 ⇒ 可见性不变。原述:门户 skills.html 仅 admin → 普通用户需实例内入口 | +| ✅ **已完成**(2026-09-15) | ~~档案 16 阶段 3/4(实例内「我的技能」)~~ → **= 交接单 `T01`,已归档** | **档案 100**|插件 `business-plugins` **0.3.20 → 0.3.21** 已投放两实例。① **形态修正(09-15)**:由「新增 section」改为**并入既有「功能管理」section 内分组**(依据用户口径「不要分开管理」+ 档案 60 分区命名;**仅入口层合并、机制层分离**)→ 见 `T01 §四 决策 6` 与 **`T01 §九`**;② 实现:技能行(名称/来源/状态/事实/动作)+ zip 拖拽上传 + 同名两阶段替换 + 页内确认弹窗 + 锁定行(共享技能)无动作按钮 + zh/en 46 条词条;③ 验收:`npm run verify` 全绿(含新 `scripts/verify-my-skills.mjs` 34 条断言)、06 §7 三段式全绿、端到端 **19/19**(409 / 400 守卫通过);④ 阶段 4 收口 = 未新增 section + 未改角色补丁 ⇒ 可见性不变。⚠️ **后续(档案 101)**:该分区已改名「**能力管理**」并改为**页内 tab 分页**(功能插件 / 我的技能)。原述:门户 skills.html 仅 admin → 普通用户需实例内入口 | | ✅ **已消解** | ~~摘除 guest 仍在启用的已下架插件 `@liustack/modlens`~~ | **09-13 07:0x 实测:已无需处理** —— guest `profile/web` 的 `bundles` **与 `node_modules` 均已无 `@liustack/modlens`**(原「已下架却仍启用」的不一致态已不复存在)。历史:该包 09-12 从候选池下架(档案 70 收尾)时曾留在 bundles | | ❌已复核 | ~~Cookie 域收窄~~ | **不做(2026-09-12 用户决定:把这条待办删掉)** —— 共享 Cookie(`Domain=.alotbuy.com`)是**有意设计**:支撑「功能插件 ↔ 门户」**免令牌鉴权**(配合 `server.ts` 的 CORS 白名单),功能插件 v0.2.1 已生产跑通 → **收窄会破坏该链路,收益为零**;结论见档案 05 PoC-2 ② | diff --git a/dsh-server-docs/04-调整方案/100-实例内我的技能-并入功能管理分组.md b/dsh-server-docs/04-调整方案/100-实例内我的技能-并入功能管理分组.md index 8d5e09b..4209214 100644 --- a/dsh-server-docs/04-调整方案/100-实例内我的技能-并入功能管理分组.md +++ b/dsh-server-docs/04-调整方案/100-实例内我的技能-并入功能管理分组.md @@ -3,7 +3,8 @@ - 日期:2026-09-15 - 触发:用户「规划一个产品方案(界面布局美观 方便操作)然后实现这个功能」=落地 `交接单/T01`(档案 16 阶段 3/4) - 对象:`poc/business-plugins`(`@dsh-local/business-plugins`)client bundle 的「功能管理」section -- 状态:🚧 实施中(方案已定,代码/投放/验收见 §八) +- 状态:✅ **已实施并投放**(`business-plugins` **0.3.21**,两实例;方案见 §二,验收见 §8.2/§8.4) + - ⚠️ 后续:该分区已改名「**能力管理**」并改为**页内 tab 分页**(档案 **101**);本档案正文保留当时的形态描述(档案只增不改) > **TL;DR**|**结论**:在**既有「功能管理」section 内新增「我的技能」分组**(**不新开 section**),用户在同一页看完并管理「功能插件 + 我的技能」两类功能;技能行支持 上传 / 启用 / 停用 / 删除,平台共享技能显示为**锁定只读**。 > **关键**:入口层合并、**机制层分离**(技能 watch 即时生效 vs 插件需重启;API / 落盘各自独立)—— 依据见 `交接单/T01 §九`。 diff --git a/dsh-server-docs/04-调整方案/101-能力管理-改名与tab分页与卡片三行.md b/dsh-server-docs/04-调整方案/101-能力管理-改名与tab分页与卡片三行.md new file mode 100644 index 0000000..4919628 --- /dev/null +++ b/dsh-server-docs/04-调整方案/101-能力管理-改名与tab分页与卡片三行.md @@ -0,0 +1,151 @@ +# 101 · 「能力管理」:分区改名 + 页内 tab 分页 + 插件卡片三行描述 + DeepSeek 改名 + +- 日期:2026-09-15 +- 触发:用户三句话 —— ①「设置中 功能管理改为能力管理」②「能力管理页面改造为 tab可切换 插件和技能分别进行操作」③「插件卡片中增加中文用途描述 显示三行」④「内置 DeepSeek,去掉内置就叫 DeepSeek 就行」 +- 对象:`poc/business-plugins`(`@dsh-local/business-plugins`)client bundle 的「功能管理」section +- 状态:✅ 已实施并投放(`0.3.21 → 0.3.22`) +- 前置:档案 **100**(「我的技能」并入本 section)+ `交接单/archive/T01` + +> **TL;DR**|**结论**:分区改名「**能力管理**」(label 级),页内改为 **tab 分页**(功能插件 / 我的技能,各自独立操作),插件卡片的**中文用途描述从 1 行放宽到 3 行**(`.bp-plugDesc`),并把「**内置 DeepSeek → DeepSeek**」。 +> **关键**:全部落在**用户可见文案 / 版式**这一层 —— **代码标识符、section id、包名、API 路径、词典键名一律不动**(素材库 U10)。 +> **不做**:不改服务端;不动「我的技能」的机制(仍是 watch 即时生效 / 两阶段替换 / 锁定行无可点动作)。 + +--- + +## 一、四项改动 + +### 1.1 分区改名:功能管理 → 能力管理 + +| 位置 | 旧 | 新 | +|---|---|---| +| `section.label`(zh) | 功能管理 | **能力管理** | +| `section.label`(en) | Feature management | **Capability management** | + +⛔ **只改 label**:`id: "business-plugins"`、包名 `@dsh-local/business-plugins`、`/api/plugins/*`、词典键名、CSS 类名一律不动 —— 与档案 60(功能插件→功能管理)同一口径。 + +### 1.2 页内 tab 分页(插件 / 技能各自操作) + +``` +❙ 能力管理 +┌ 功能插件 3 ┐┌ 我的技能 2 ┐ ← 下划线式 tab(复用 .pa-tabs/.pa-tab/.pa-tcnt),各带计数 +┬──────────────────────────────── +│ 【功能插件页】内存条 / 搜索·全选·清空 / 卡片网格 / 应用更改 ← 内容与改造前逐项一致 +│ 【我的技能页】说明 + [+ 上传技能] / 上传面板 / 技能行列表 +``` + +- **实现**:`isPlugins = tab === "plugins"`(`useState("plugins")`,**默认落插件页** = 与改造前一致);插件块 `if (isPlugins)`、技能块 `if (!isPlugins)`,四段门控成对。 +- **复用而非新造**(U2):tab 直接用既有 `.pa-tabs/.pa-tab/.pa-tcnt`(下划线式;规范 §4.4「数据页用下划线式」),**没有新增样式**。 +- **计数**:插件页签 = 候选池条目数,技能页签 = 列表条目数(含共享)—— 不点进去也知道各类有多少。 +- **技能页去掉重复标题**:`❙ 我的技能` 竖条标题删掉(标题已由 tab 承担),只留「生效方式说明(左)+ 上传入口(右)」工具行(`.bp-tabHead`);避免同页出现两个同名标签。 +- 可访问性:`role="tablist"` / `role="tab"` / `aria-selected`。 + +### 1.3 插件卡片:中文用途描述 = 3 行 + +- 新增 `.bp-plugDesc`:`display:-webkit-box` + `-webkit-line-clamp:3` + `-webkit-box-orient:vertical` + `overflow:hidden` + **显式 `white-space:normal`**。 +- ⚠️ `white-space:normal` 必须显式写 —— 旧版单行截断用 `nowrap`,不覆盖会让多行截断失效(一眼看不出来的静默回退)。 +- `title` 仍挂全文,hover 可看完整。 +- 为什么:单行 ellipsis 会把「这个插件到底干什么」截掉大半;候选池入库时已优先取官方目录的**中文**说明,值得多给两行。 + +### 1.4 「内置 DeepSeek」→「DeepSeek」 + +| 词典键 | 旧(zh) | 新(zh) | +|---|---|---| +| `ms.kindBuiltin` | 内置 DeepSeek | **DeepSeek** | +| `ms.optBuiltin` | 内置 DeepSeek(平台共享) | **DeepSeek** | +| `ms.builtinPickHint` | 内置 DeepSeek:平台内置通道,固定官方端点,无需填 API 地址。 | **DeepSeek:官方通道,固定官方端点,无需填 API 地址。** | +| `ms.tagBuiltin` | 内置 | **官方** | +| `ms.builtinHint` | 平台内置通道,无需端点 | **官方通道,无需端点** | +| `ms.catUnreadable` / `ms.catEmpty` | …当前只能使用内置 DeepSeek 或自定义提供方… | …当前只能使用 **DeepSeek** 或自定义提供方… | + +en 侧同批(`Built-in DeepSeek` → `DeepSeek`;`Built-in` → `Official`)。 + +**顺带修掉的误导**:原选项名写的「(平台共享)」**是错的** —— 该选项走的是**内置通道(固定官方端点)**,但**必须填用户自己的 API 密钥**(`keyAddSchema` 里 `apiKey` 是 `required + minLength:1`;输入框 placeholder 也写着「输入你的 DeepSeek API 密钥」)。叫「平台共享」会让人以为不用填 key —— 这正是用户「添加模型也不能添加内置模型 没有对应KEY」困惑的来源。 + +--- + +## 二、与「平台共享模型」的关系(本次一并澄清,**重要,别再混**) + +| 名称 | 是什么 | 谁的 | 要不要自己的 key | 能删吗 | +|---|---|---|---|---| +| **平台共享模型 `deepseek-main`**(页内**卡片**) | admin 名下**已启用**的 key 条目(`sharedKeyInfo().name` = admin 启用列表里的第一条名) | **平台 / admin** | ❌ 不要(用户不配 key 时回落到它) | ❌ **只能开关**(卡片上只有一个「停用/启用共享模型」,走 `/api/me/models/shared`) | +| **`DeepSeek`**(「新增提供方」下拉里的选项) | 内置通道(固定官方端点)**给用户自己加条目**用的 provider 类型 | **用户自己** | ✅ **要**(否则 400) | 用户可删自己的条目 | + +⇒ **不是一回事**:「平台共享模型 `deepseek-main`」是**平台那条 key**;「DeepSeek」是**你自己新建条目时选的通道**。另:官方 pi-ai 目录里的 `deepseek` 被后端**显式排除**(`src/web/routes/auth.ts` 注释原文:「平台已有『内置 DeepSeek』入口…再列一个同名选项只会让用户分不清哪个生效」)—— 所以下拉里不会出现第二个 DeepSeek。 + +--- + +## 三、实现落点 + +| 文件 | 改动 | +|---|---| +| `poc/business-plugins/lib/client.js` | ① zh/en:`section.label` 改名 + `cap.tabPlugins` 新词条 + `ms.*` 6 条措辞(内置→DeepSeek);② section 内新增 `tab` state + tab 条渲染 + 四段门控;③ 技能页工具行 `.bp-tabHead`(去重复标题);④ 插件卡片描述改 `.bp-plugDesc`;⑤ 注入 CSS:`.bp-tabHead` / `.bp-plugDesc` | +| `poc/business-plugins/package.json` | 版本 `0.3.21 → 0.3.22`(含 ①②③④ 四项)+ `description` 追加本轮条目 | +| `scripts/verify-my-skills.mjs` | 新增 **20 条**断言:改名生效 / 旧名不再是 label / tab 条与两个页签 / 计数 / `aria-selected` / 门控成对(`isPlugins` 2 处 + `!isPlugins` 2 处)/ `.bp-plugDesc` 三件套 / `title` 仍在 | +| `scripts/verify-platform-admin-section.mjs` | 跟进改名:断言「条目名显示为 DeepSeek、不再有『内置 DeepSeek』」+ 3 处测试名/注释里的旧分区名 | + +**零服务端改动**;未动官方包。 + +--- + +## 四、验收 + +| # | 口径 | 结果 | +|---|---|---| +| A | `npm run verify` | ✅ 全绿(zh/en 各 **367** 键、引用键全部已声明;含新增 20 条断言) | +| B | 产物 | ✅ `business-plugins-0.3.22.tgz` 与服务器 sha256 一致(`a5a55b41…`) | +| C | 投放 | 见 §五 | +| D | 06 §7 三段式 + 浏览器 | 见 §五 | + +--- + +## 五、实施与投放记录 + +### 5.1 投放 + +``` +产物:dsh-local-business-plugins-0.3.22.tgz 72,882 B sha256 a5a55b41…(与服务器一致) +ssh bt-server 'cd /opt/dshs && node scripts/ensure-biz-plugins.cjs --all --restart' + admin: 版本落后(0.3.21 → 0.3.22),升级中… ✓ bundles=7(含 @dsh-local/business-plugins: true) + guest: 版本落后(0.3.21 → 0.3.22),升级中… ✓ bundles=6(含 @dsh-local/business-plugins: true) + 已停 0 个实例 scope(下次访问自动拉起) +``` +⚠️ **同号覆盖说明**:`0.3.22` 在本次投放前**从未安装到任何 profile**(两实例都是 0.3.21)⇒ +打包后(含 1.4 的措辞微调)**覆盖同一版本号重发是安全的** —— 若已装过 0.3.22,则必须升到 0.3.23 +(否则 pnpm 判定"无需更新",静默不生效,档案 18 踩坑 1)。 + +### 5.2 磁盘层(06 §7.3 ①)✅ + +| 实例 profile | 版本 | `能力管理` | `cap.tabPlugins` | `bp-plugDesc` | 旧 `section.label: "功能管理"` | +|---|---|---|---|---|---| +| admin(`4092b965…`) | **0.3.22** | 3 | 3 | 3 | **0** | +| guest(`cce6d1cd…`) | **0.3.22** | 3 | 3 | 3 | **0** | + +### 5.3 本地校验 ✅ + +`npm run verify` 全绿:zh/en 各 **367** 键、引用键全部已声明;`verify-my-skills.mjs` 新增 **20** 条 +(改名生效 / 旧名不再是任何 label / tab 条与两个页签 / 计数 / `aria-selected` / 门控成对 +(`isPlugins` 2 处 + `!isPlugins` 2 处)/ `.bp-plugDesc` 三件套 / `title` 仍在);`verify-platform-admin-section.mjs` +的「条目名 = DeepSeek」断言同步跟新。 + +### 5.4 ⚠️ 实例侧 / 浏览器验收:**本轮未完成**(原因与下一步写清) + +**阻塞点(客观,非"将就")**:平台已切**集群模式** ⇒ +1. **新用户按容量落 Worker `w-106`**,不在 47 上 ⇒ 本机脚本「进实例 → 抓壳页 → 取 combo → 请求 bundle」 + 这条路径拿不到带新包的壳页(实测 `comboHasBundle: false`,47 上也无该用户目录); +2. **47 → 106 无 SSH 路由**(106 是经反向隧道连到 47 的)⇒ 从 47 侧探不到 106 上的 profile/实例。 + +**已做到的**:磁盘层(§5.2)已确认两实例 profile 里就是新代码 —— 而实例壳页的 combo 是对 +`profile/node_modules/**/client.js` 的**拼接**,所以"服务端会返回新内容"由 §5.2 强推出,但**未经端到端实测**。 + +**回头解决的条件**:一旦拿到「新用户落在哪台 Worker / 该 Worker 的 SSH 或 agent 通道」, +就用档案 100 §8.4 那套(`poc-ui-user.cjs` + `agent-browser`)把三段式与视觉验收补上。 + +### 5.5 本轮附带的两个**实测事实**(高复用,已进 `PLAYBOOK §9`) + +1. **控制面库 = PostgreSQL(集群模式)**:`DSHS_DB_URL=postgres://dshs:…@127.0.0.1:15432/dshs`, + `DSHS_DEPLOY_MODE=cluster`。**`/var/lib/dshs/dshs.db`(SQLite)是回滚用的旧库、平台不读** ⇒ + 临时用户审批写 SQLite = 白写(实测:`register` 201,但 `login` 403 `pending_review`);且 `users` + 表在 PG 里的主键列是 **`id`**(不是 `user_id`),清理脚本别照抄 SQLite 时代的列名。 +2. **一次性用户的残留**:`41a9480e-5269-463d-acce-79aeee74ced7`(`pocuimz6rb`)的 PG 行**已删**, + 但其 **home 目录留在 106 上**(47 无该目录、无路由)⇒ 需在 106 侧 `rm -rf` 该用户目录才能彻底清干净。 + diff --git a/dsh-server-docs/04-调整方案/102-语言切换搬入用户设置并撤除偏好设置.md b/dsh-server-docs/04-调整方案/102-语言切换搬入用户设置并撤除偏好设置.md new file mode 100644 index 0000000..c77f2e5 --- /dev/null +++ b/dsh-server-docs/04-调整方案/102-语言切换搬入用户设置并撤除偏好设置.md @@ -0,0 +1,155 @@ +# 102 · 语言切换从「偏好设置」搬进「用户设置」,并撤除偏好设置分区 + +- 日期:2026-09-15 +- 触发:用户「把偏好设置中的 语言切换功能放到用户设置中,去掉偏好设置这个栏目,并检查语言切换功能是否正常」 +- 对象:`poc/business-plugins`(**0.3.22 → 0.3.23**)+ `poc/portal-entry`(**0.5.2 → 0.5.3**) +- 状态:✅ 已实施并投放(两实例磁盘层已核) + +> **TL;DR**|**结论**:语言切换搬进 **`@dsh-local/portal-entry` 的「用户设置」分区**(id `user-settings`,order 103), +> `business-plugins` 的「**偏好设置**」分区(id `preferences`,order 99)**整块撤除**。 +> **关键**:语言是**用户级偏好** —— 与「账号 / 退出登录」同区即可,单开一个分区就是**重复入口**。 +> **不做**:不动官方包(R2);语言切换实现仍是官方扩展点(`ctx.locale`),零官方改动。 + +--- + +## 一、先澄清一件事(这轮排查的最大结论) + +**「用户设置」是我们自己的 bundle 提供的,不是官方分区**: + +| 项 | 值 | +|---|---| +| section id | `user-settings` | +| order | **103**(排在 能力管理 101 / 系统管理 102 之后) | +| label | `"\u7528\u6237\u8bbe\u7f6e"` ← **转义 unicode 存的** | +| 提供者 | `@dsh-local/portal-entry`(client 面 `lib/client.js`) | +| 源码 | **仓内 `poc/portal-entry/`**(← 本轮补入,之前**不在仓里**) | + +⚠️ **因为它是转义形态**,我按 UTF-8 字面量 `grep 用户设置` 在**全域**(官方树 / 平台代码 / 文档库 / profile) +搜了十几轮全部落空,最后是"在活着的 profile 里 `grep -l settings.section @dsh-local/*/lib/client.js`"才定位到。 +⇒ 已把这条写进 **`07-实例UI分区登记表.md §二`**(含"同时搜 UTF-8 与 `\uXXXX` 两种形态")。 + +--- + +## 二、改动 + +### 2.1 `poc/portal-entry` 0.5.2 → **0.5.3**:语言行搬入「用户设置」 + +- **位置**:`UserManagementSection` 里,插在「账号/角色」(仅 admin 显示)与「退出登录」**之间**。 +- **实现**(官方扩展点,零官方改动): + - `inject`:`["slots"]` → **`["slots", "locale"]`**; + - 读 `ctx.locale.getLocale()`,切 `ctx.locale.setLocale(id)`,语言项 **`en` / `zh`**(= 官方 `LOCALE_IDS`;`en` 是官方 FALLBACK,即默认英语); + - 本行自己的双语文案走 `ctx.locale.register(PE_NS, {zh,en})` + `ctx.locale.bind(PE_NS)`(与本仓 `business-plugins` 同一套姿势); + - 切换后 `setTick()` 强制重渲染本行(官方 locale 的通知不会打到本组件); + - 语言 id 读不到时退回 `en`,`ctx.locale` 缺失时只告警、不整区崩。 +- ⚠️ **颜色沿用本文件硬编码**(`#f9fafb` / `#43464d` / `#c9cdd4`)—— 本区渲染在 **DARK 主题**, + v0.4.2/v0.4.3 的结论是**这里不能依赖 `--dsw-*`**(其 fallback 会让文字与底色同色而看不见)。 +- **源码入仓**:仓内此前**没有** `poc/portal-entry/`(只有服务器上的 tgz)⇒ 本轮把**部署中的 0.5.2 源码** + 取出来放进 `poc/portal-entry/`,再在其上改。**约定:自研 bundle 的产物只能从仓内源码构建。** + +### 2.2 `poc/business-plugins` 0.3.22 → **0.3.23**:撤除「偏好设置」 + +撤掉三样东西(**只改用户可见面,不留残迹**): +1. `PreferencesSection` 组件(整段,含其 doc 注释); +2. `ctx.slots.inject("settings.section")` 里 **id `preferences` order 99** 的注册; +3. zh/en 各 3 条 `pref.*` 词条(`pref.label` / `pref.lang` / `pref.hint`)。 + +⇒ 本 bundle 现在只注册 **2 个分区**:`model-settings`(100) + `business-plugins`(101)(admin 另加 `platform-admin` 102)。 +**语言切换一个字都没丢** —— 原实现就是 `ctx.locale.getLocale()/setLocale()`,搬过去后是同一套 API。 + +### 2.3 断言同步(防回退) + +| 脚本 | 改动 | +|---|---| +| `scripts/verify-platform-admin-section.mjs` | 「非 admin 注册 3 个分区」→ **2 个**;「admin 注册四个」→ **三个**;新增 **「preferences 分区已撤除(不再注册)」** 断言;原「偏好设置」功能断言整块移除 | +| `scripts/verify-portal-entry.mjs` | **新增**(22 条):源码在仓内 / **host 半边 `lib/index.js` 在**(v0.5.1 事故防线)/ id·order·label(**按转义形态**断言)/ `inject` 含 `locale` / get·setLocale 调用 / 双语词典 / en·zh 两项 / 语言行**确实 push 进 rows** / 颜色硬编码 / **R3:无 `exports.default` 赋值** | +| 仓根 `package.json` | `npm run verify` 链尾接上 `verify-portal-entry.mjs` | + +--- + +## 三、验收 + +| # | 口径 | 结果 | +|---|---|---| +| A | `npm run verify`(build + 单测 + 10 个 verify 脚本) | ✅ **全绿**(含两个新/改脚本) | +| B | 产物 | ✅ `business-plugins-0.3.23.tgz`(72,037 B)· `portal-entry-0.5.3.tgz`(8,201 B,4 文件=两个半边都在)均已投放,与本地同名 | +| C | 投放 | ✅ `ensure-biz-plugins.cjs --all --restart` + `ensure-portal-entry.cjs --all --restart` ⇒ admin/guest **bundles 7 / 6 完好**(未被 `pruneBrokenFileDeps` 摘依赖) | +| D | 磁盘层(06 §7.3 ①) | ✅ 两实例:`business-plugins` **0.3.23** 且 `id: "preferences"` **命中 0**、能力管理/tab 仍在;`portal-entry` **0.5.3** 且 `key: "lang"` 命中 1、`inject` 含 `locale` | +| E | 实例侧 / 浏览器实测 | ⚠️ **未做**,原因见 §四 | + +--- + +## 四、未完成:实例侧与浏览器实测(原因 + 回头条件) + +**阻塞点(客观)**:平台已切集群 ⇒ **新用户按容量落 Worker `w-106`**,而 **47 → 106 无 SSH 路由** +(106 经反向隧道连到 47)⇒ 本机脚本走不通"建临时用户 → 进实例 → 抓壳页 → 点 UI"这条链; +R4 又不允许用真实账号(guest/admin)登录做实测。 + +**已做到的**:磁盘层(§三 D)确认两个实例 profile 里就是新代码,而**实例壳页的 combo 是对 +`profile/node_modules/**/client.js` 的拼接** ⇒ "服务端会返回新内容"由 D 强推,但**未经端到端实测**。 + +**回头解决的条件**:拿到「新用户落哪台 Worker + 到那台机的通道」后,用档案 100 §8.4 那套 +(`poc-ui-user.cjs` + `agent-browser`)补齐三段式与视觉验收(重点看:语言行是否在「用户设置」里、 +切到 English 后**本行文案与官方 UI 是否立即变**、以及 `settings.yaml` 的持久化行为见下)。 + +### ⚠️ 一条要如实告知用户的行为边界 + +官方 `dsh-client-locale` README 原文:语言选择**立即生效**;**loopback 页面**会持久化到 +`$DSH_HOME/settings.yaml`,**非 loopback 页面只为当前进程保留**。平台是"浏览器经域名访问远程服务器" +⇒ **属非 loopback** ⇒ **切换在新开页面/重启后不保证保持**。这不是本次改动引入的(原「偏好设置」用的是同一套 API), +但**用户会说"我切了怎么又变回去"** ⇒ 已在 §五 记为待观察项。 + +--- + +## 五、待观察 / 技术债 + +- **持久化**:见 §四末条。若要"记住选择",得走平台侧写 `settings.yaml`(属新需求,不是本单)。 +- ⚠️ **`scripts/ensure-portal-entry.cjs` / `ensure-biz-plugins.cjs` 仍用 `better-sqlite3` 读 + `/var/lib/dshs/dshs.db` 枚举用户** —— 而集群模式下**权威库是 PG**(`DSHS_DB_URL`)。 + 本轮两脚本对新用户/存量用户都工作正常(说明过渡期 SQLite 仍在被维护),但**这是一条隐患**, + 属集群化的收尾项(**不是本单 lane**)⇒ 只报告,未动手。 +- **`.pnpm` 里的历史副本**(`@dsh-local+business-plugins@0.2.3/0.3.4/0.3.8/0.3.11` 等)与文档库 + `04-调整方案/poc/portal-entry`(0.5.1 快照)都属"同名旧副本",排查时别当现行(已写进 `07 §二`)。 + +--- + +## 六、语言偏好**持久化**(2026-09-15 追加,用户:「A B 都按最优方案处理」) + +**问题(A)与需求(B)其实不冲突** —— 最优解是**替官方把它的设置写进它自己的文件**: + +### 6.1 做法(零官方改动、零新增平台状态) + +| 层 | 改动 | +|---|---| +| 平台(新)`src/web/locale-pref.ts` | `reconcileLocalePreference(text, 'en'\|'zh')` —— **按行对账**,只在 `settings.yaml` 顶层 `locale: → preference:` 那两行上动手(不整份 YAML 解析,注释/顺序/别人的块一律不动);幂等(值相同 ⇒ `changed:false`) | +| 平台(新)`src/web/home-files.ts` | 把原先内联在 `server.ts` 闭包里的 `readTextOrEmpty` / `writeHomeFile` **抽出来共用**(`writeHomeFile` = 备份到平台目录 + 写 + **chown 给 home 属主**,漏最后一步实例读不了 —— R10 同族)。`server.ts` 改为 import(行为不变) | +| 平台(新路由)`POST /api/me/locale` | `requireAuth`;`{locale}` → 写 `/settings.yaml`;非法值 400 `invalid_locale` | +| 客户端 `portal-entry` **0.5.3 → 0.5.5** | `pickLocale()` 在 `setLocale()` 之后**额外调** `POST /api/me/locale`(`credentials:'include'`,跨子域);**持久化失败不影响本次切换**(就地已生效) | + +**官方键名出处**:`dsh-client-locale` 的 **host 半边 settings schema** 用的就是 `locale` / `preference` +(⛔ 不要凭猜 —— 首版我按 `locale.preference` 写是**核对过**的)。 + +### 6.2 为什么这是"最优"而不是"另造一套" + +- **A(守官方语义)**:写的正是官方设置文件的官方键 ⇒ 实例启动时官方运行时读自己的文件即生效; +- **B(记住选择)**:用户的选择跨页面 / 跨重启保留; +- **单一来源**:平台**不新增**"语言状态"(没有新表 / 新列),唯一事实仍是 `settings.yaml`; +- **失败可降级**:平台路由挂了 / CORS 拦了 ⇒ 只是"记不住",**不影响本次切换**(catch 兜底)。 + +### 6.3 验收 + +| # | 口径 | 结果 | +|---|---|---| +| A | 单测(新增 `test/locale-pref.test.mjs`,9 例) | ✅ 全过:建块 / 插行 / 换值 / **幂等** / **注释与其它块不动** / **CRLF 保持** / 行数不增 | +| B | `npm run verify` | ✅ 全绿(`test` 45 例;含新增打包防线与持久化断言) | +| C | 平台路由上线 | ✅ 送 4 个**编译产物**(`lib/web/{home-files,locale-pref,server}.js`、`lib/web/routes/auth.js`)+ `restart dshs`;冒烟 `POST /api/me/locale` → **401**(有鉴权 = 路由存在) | +| D | 投放 | ✅ `portal-entry-0.5.5.tgz` 两实例 `bundles` 7/6 完好;磁盘层 **0.5.5**、`api/me/locale` 命中 1、包内无残留 tgz | +| E | 浏览器实测 | ⚠️ 仍未做(同 §四:新用户落 `w-106`、47→106 无路由) | + +### 6.4 ⚠️ 部署方式的一个**实测坑**(重要,别踩) + +打算按常规 `cd /opt/dshs && npm run build && systemctl restart dshs` 部署时发现: +**服务器 `/opt/dshs/src` 是"集群化之前"的旧源码**(`server.ts` 里还是 `K8sSpawner`,git 停在 `8390eb3 初始提交`), +而**线上 `lib/` 却是集群版编译产物** —— 源码与产物**不一致**。 +⇒ **在那边直接 build 会把线上编译回旧版**(危险)。本次改为**只送编译产物**(4 个文件), +并已核对"线上 `lib/` 与我本地编译版**只差本次改动**"再动手。 +⚠️ 这条属**集群化收尾项**(另一 lane):`/opt/dshs` 的 src 需要与 git 基线对齐,否则**任何一次服务器侧 build 都是事故**。 diff --git a/dsh-server-docs/07-实例UI分区登记表.md b/dsh-server-docs/07-实例UI分区登记表.md new file mode 100644 index 0000000..aa6ffb4 --- /dev/null +++ b/dsh-server-docs/07-实例UI分区登记表.md @@ -0,0 +1,84 @@ +# 07 · 实例 UI 分区登记表(settings.section 谁提供、源码在哪、能不能改) + +- 日期:2026-09-15 +- 触发:用户问「**为什么查代码要这么久 是不是反应 代码结构有问题或者没有 工程结构的索引文件**」—— + 当时为了搞清「设置面板里的『用户设置』是谁渲染的」,我在 6 个官方包 + 3 个自研包里逐个 grep, + 绕了 ~30 次工具调用。**根因见 §三**(其中两条是真结构缺口)。 +- 状态:✅ 表格为**现行值**;改动分区时**同步改本表**(与 `INDEX.md` 一样属于"要维护的入口") + +> **本表只回答四个问题**:这个分区**id** 是什么 → **谁提供** → **源码在哪** → **我能不能改**。 +> 单查某功能的实现细节仍去 `04-调整方案/`。 + +--- + +## 一、实例「设置」面板的分区总表(现行) + +> **本表由 `node scripts/find-ui.mjs --md` 生成**(扫的是**活着的 profile + 官方包**,不是仓里快照)。 +> ⛔ **改分区 ⇒ 同一次改动里刷新本表**。下表的 label 是从代码里**解码**出来的(含 `\uXXXX` 转义形态)。 + +| order | id | label | 提供者 | 来源 | 源码 / 可改性 | +|---|---|---|---|---|---| +| 0 | `general` | 通用设置 | `@deepseek-ai/dsh-client-ui-settings-general` | 官方 | 官方包,⛔ **不可改**(R2);**官方语言切换就在这一页**(`settings.general.item`) | +| 10 | `models` | (官方 locale 键) | `@deepseek-ai/dsh-client-ui-settings-models` | 官方 | ⛔ 不可改;⚠️ **被角色补丁禁用**(本环境打开必报错,档案 87) | +| 15 | `plugins` | (官方 locale 键) | `@deepseek-ai/dsh-client-ui-settings-plugins` | 官方 | ⛔ 不可改;⚠️ 角色补丁禁用 | +| 20 | `agent-presets` | (内部无 label) | `@deepseek-ai/dsh-client-ui-agent-preset` | 官方 | ⛔ 不可改 | +| 100 | `model-settings` | 模型设置 | `business-plugins` | **自研** | 仓 `poc/business-plugins/lib/client.js` ✅ **可改** | +| 101 | `business-plugins` | **能力管理** | `business-plugins` | **自研** | 同上 ✅ 可改(原「功能管理」,档案 101 改名 + tab 分页) | +| 102 | `platform-admin` | 系统管理 | `business-plugins` | **自研** | 同上 ✅ 可改(**仅 admin**) | +| 103 | `user-settings` | **用户设置** | `portal-entry` | **自研** | 仓 `poc/portal-entry/lib/client.js` ✅ 可改(账号 / **界面语言** / 退出登录) | + +> 附注:官方包的 label 走各自的 locale 词典(表里显示 `t("…")` 即"未在包内解析到"), +> 而**官方那 4 个我们本来也不该改**,所以不再深挖;真要读文案去该包 `README.zh.md`。 +> `preferences`(偏好设置,自研)已于 2026-09-15 **撤除**(档案 102)。 + +## 二、定位一个 UI 分区的**最快路径**(照这个顺序,别再绕) + +1. **先看"活着的实例里装了什么"**,而不是先翻官方包: + ```bash + P=/var/lib/dshs/users//home/profiles/web + ls $P/node_modules/@dsh-local/ # 自研 bundle + grep -l "settings.section" $P/node_modules/@dsh-local/*/lib/client.js # 谁注册了分区 + ``` + ⛔ 我当时的错:先去 `/usr/local/...` 官方树里找、再去 0.1.2 备份里找、又怀疑第三方插件 —— 最后才查 profile。 +2. **官方包的根**(不是 profile): + `/usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai/` +3. **搜中文 UI 文案必须同时搜两种形态**: + ```bash + grep -rn "用户设置" . # UTF-8 字面量 + grep -rn 'u7528u6237u8bbeu7f6e' . # \u 转义形态 ← portal-entry 就是这种,只搜上一条永远 0 命中 + ``` + (可用 `python3 -c 'print(open("f").read().encode("unicode_escape").decode())'` 反推转义形态。) +4. **别被副本误导**(三处都有同名不同版本的旧拷贝): + - `dsh-server-docs/04-调整方案/poc/*` = **历史快照**(如 portal-entry 停在 0.5.1); + - profile 的 `node_modules/.pnpm/` 里堆着 `@dsh-local+business-plugins@…0.2.3/0.3.4/0.3.8/0.3.11` 等历史 tgz; + - `/opt/dsh/backups/` 下有升级前的整套官方包(如 `dsh-0.1.2-rc.1`)。 + ⇒ **判"现行"只认两处**:仓内 `poc//` + profile 里 `node_modules/@dsh-local//`。 + +--- + +## 三、为什么会绕这么久(诊断结论) + +| # | 原因 | 性质 | 已做的处置 | +|---|---|---|---| +| 1 | 标签用 **`\uXXXX` 转义**存(`"\u7528\u6237\u8bbe\u7f6e"`),按 UTF-8 搜永远 0 命中 | **代码侧小缺陷**(可维护性) | 记入本表 §二 第 3 条;⛔ 新增文案**建议直接写中文**(官方 bundle 亦混用,但可 grep 的优先) | +| 2 | **源码没入仓**:`portal-entry` 不在 `poc/` 里,线上只有 tgz ⇒ 只能从产物反推 | **真结构缺口** | ✅ 已把 0.5.3 源码补进 `poc/portal-entry/`;**约定:产物只从仓内源码构建** | +| 3 | **没有"哪个包提供哪个 UI"的索引**,id/order/label 散在 9 个包里 | **真结构缺口** | ✅ 本文件(`07-实例UI分区登记表.md`) | +| 4 | 集群切换后"实例侧实测"变难(新用户落 `w-106`、47→106 无 SSH 路由) | 环境复杂度 | 见 `PLAYBOOK §9.1`;新用户落点见 `03-路线图` | + +--- + +## 四、维护约定 + +- **改任何分区(增/删/改名/调 order)⇒ 同一次改动里更新本表**;⛔ 不要只改代码。 +- 新增自研 bundle 时,**源码必须落在仓内 `poc//`**,并在本表登记。 +- 本表**不写版本号**(会漂)—— 版本去 `poc//package.json` 与 `/opt/dsh/artifacts/` 看。 + +## 五、长效机制(2026-09-15 建,针对 §三 的三条根因) + +| 根因 | 机制 | 怎么用 | +|---|---|---| +| 找不齐「谁提供哪个 UI 分区」 | **`node scripts/find-ui.mjs [关键词] [--md] [--grep]`** —— 扫活 profile + 官方包,一次给全:id / order / label(**含转义解码**)/ 提供者 / 源码路径 / **能不能改** | 改任何实例 UI 前**先跑它**(本来要 30 次 grep 的事 → 1 条命令) | +| 中文文案搜不到(`\uXXXX` 转义) | 由 `find-ui.mjs` 内建(**两种形态都搜**);`PLAYBOOK §9.2` 也记了反推转义的方法 | 手写 grep 时记得两种形态都试 | +| 自研 bundle 源码不在仓里 | **约定:`poc//` 是唯一源码位置,产物只从仓内源码构建**;`portal-entry` 已补入 | 发现某自研包不在 `poc/` ⇒ 先从部署产物取出入仓,再改 | +| 改完忘了同步断言 / 文档 | `npm run verify`(含 `verify-my-skills` / `verify-portal-entry` 的结构断言)+ **本表** | 每次改动收尾跑一次 | +| 打包把上一版 tgz 打进去(0.2.5 / 0.5.4 两次同坑) | 各包 `.npmignore` 排除 `*.tgz` + `verify-portal-entry.mjs` 里的机械断言 | 已有防线,新包照抄 | diff --git a/dsh-server-docs/BRIEF.md b/dsh-server-docs/BRIEF.md index 48afe32..f984e0c 100644 --- a/dsh-server-docs/BRIEF.md +++ b/dsh-server-docs/BRIEF.md @@ -33,7 +33,7 @@ | 优先级 | 事项 | 备注 | |---|---|---| | 🔴 **待定窗口** | **档案 65 部署**(功能插件启停 ↔ web provider 托管段联动) | 代码完成 + 两态实测通过;**部署需 `systemctl restart dshs` → 中断在线用户**,等窗口 → 档案 65 §7.3 | -| P1 | **T03**:7 个自研插件整合为 1 个功能插件并投放 | **候选池上传已完成**(**现行池内 = `dsh-plugin-mcn-suite @0.3.9`**,09-13 00:02;0.3.0 曾因 peer 范围被预检拒收 → 0.3.2 加 `peerDependenciesMeta.optional` → 0.3.3 补 `xlsx` 依赖 → 0.3.9 修 client 注册层与 `inject` 语义);**admin 已装并验通(host 面 3 个注册)**;剩 **guest 未启用** → 用户在「功能管理」启用 → 重启 → 验收 → 归档 → `交接单/README.md §一` | +| P1 | **T03**:7 个自研插件整合为 1 个功能插件并投放 | **候选池上传已完成**(**现行池内 = `dsh-plugin-mcn-suite @0.3.9`**,09-13 00:02;0.3.0 曾因 peer 范围被预检拒收 → 0.3.2 加 `peerDependenciesMeta.optional` → 0.3.3 补 `xlsx` 依赖 → 0.3.9 修 client 注册层与 `inject` 语义);**admin 已装并验通(host 面 3 个注册)**;剩 **guest 未启用** → 用户在「能力管理」启用 → 重启 → 验收 → 归档 → `交接单/README.md §一` | | P1 | **T01**:实例内「我的技能」section + 阶段 4 权限收口 | 决策点 1 **已定**(=**A 扩展 `business-plugins`**,2026-09-12 用户拍板);只等开工 → `交接单/T01-*.md` | | 触发式 | dsh 升级回归(档案 26 六类耦合点)|会话 GC | 升级 / 容量触发 | diff --git a/dsh-server-docs/INDEX.md b/dsh-server-docs/INDEX.md index 5e45d1a..357ea09 100644 --- a/dsh-server-docs/INDEX.md +++ b/dsh-server-docs/INDEX.md @@ -21,6 +21,7 @@ | **换电脑 / 改了工作区路径,规则会不会丢** | **`skills/dsh-env-bootstrap/SKILL.md`**:常驻规则快照 + `--check` 校验 / `--inject` 注入 / `--env-check` 环境自检(默认只报不改)| | 看还有什么没做完 | `03-路线图与待办.md` §二 | **`交接单/README.md` §一**(已规划待执行) | | **改前端页面(强制基线)** | **`06-工作台UI规范.md`** | +| **改实例 UI 分区(设置面板)** | **`07-实例UI分区登记表.md`**(哪个包提供 / 源码在哪 / 能不能改)+ `06-工作台UI规范.md` | | 红线与硬约束 | `README.md` §红线(含 **R7 禁批量全仓写入**、**R8 中断用户须先知会**)| `04-07` | | **插件兼容性预检(导入/上传即判定)** | **`04-71`**(判据 + PoC + 三层防线)|**`交接单/T05`**(执行单)|`scripts/plugin-compat-check.mjs`(可在服务器直接跑) | | **实例崩溃循环 / 插件不兼容** | `04-70`(anysearch 与 dsh-llm `assertNever` 不兼容;判据「重启后错误是否变化」)|`04-20`(自愈熔断)|`04-25`(崩溃循环前例)|`02 §C.4` | @@ -28,7 +29,7 @@ | **多会话并行 / 冲突治理** | **`04-69`**(两级文档锁 + 服务器侧操作锁 + commit 常态化)|`交接单/README.md`(占用锁 / 写者归属 / §六 服务器侧锁)|`scripts/handoff-guard.sh`、`scripts/op-lock.sh` | | 插件管理面 | `04-16`(三层归属)| `04-31`(门户双 Tab)| `04-57`/`04-60`(设置面板分区) | | **搜索 provider / 联网搜索** | `04-64`(接入 AnySearch)|`04-65`(启停与 web provider 联动)|`04-66`(P0 误报与 admin 显式信任) | -| **「功能管理」section UI** | `04-67`(按 UI 规范重做)|`04-36`/`04-38b`(分区铺开 / 术语统一)|`04-68`(启停属主污染根治)|**`04-100`(「我的技能」分组 · 2026-09-15)** | +| **「能力管理」section UI** | `04-67`(按 UI 规范重做)|`04-36`/`04-38b`(分区铺开 / 术语统一)|`04-68`(启停属主污染根治)|**`04-100`(「我的技能」分组 · 09-15)**|**`04-101`(改名「能力管理」+ tab 分页 + 卡片三行 + DeepSeek 改名 · 09-15)** | | 安全 / 暴露面 | `04-14`(出网护栏)|`04-39`(可见面收窄·封 loopback)|`04-41`(上传加固) | | 技能共享层 / 管理面 | `04-10`(bundledSkillDir)|`04-11`(API+页面)|`04-40`(挂载修复) | | 会话/登录跳转 · 断连恢复 | `04-13`(冷启动 404)|`04-24`(实例侧 401)|`04-49`(回收后反馈)|`04-51`(401 透明重放)|**`04-72`(回收/关闭后回到页面自动唤醒)** | @@ -145,6 +146,7 @@ | — | 🔍 | **档案 82–86 尚未登记进本表**(本轮发现;属别人 lane 故未代加):82 R2 管理面就地化|83 登录注册页对齐|84 内存配额口径统一|85 模型密钥开放给用户自配|86 admin 跨用户实例管理 + 两处改名。**待收口会话补** | | — | 🧪 | `04-调整方案/poc/portal-entry/`:portal-entry 插件源码(v0.5.1)| | 06 | 🔄 | **前端 UI 强制基线**:Token/布局/组件/交互/9 条已知坑 | +| 07 | ✅ | **实例 UI 分区登记表**:settings.section 的 id/order/label → 提供者 → 源码 → 可改性 + 定位套路(含"中文文案要同时搜 UTF-8 与 \uXXXX") | | 04-89 | 🔄 进行中(L2 机制级已验 | **对话内文件预览**:采纳官方推荐库现成插件 `@softspark/dsh-file-preview`(65 KB,包住官方 `openWorkspacePath` 接管"点文件"手势;兼容预检 ok、已启用、L5 | | — | 🗄 | `archive/dsh-improvement-plan-20260909-full.md`:拆分前 19 章 | | — | ✅ | `skills/dsh-change-workflow/SKILL.md`:六阶段 + **红线 R1-R11**(工作副本在本机 `.workbuddy/skills/`)| @@ -221,4 +223,6 @@ | 04-97 | ✅ 已上线 | **`/plugins/` 合并脚本补 ETag + 304 短路**(承 96 之后查出的真缺陷,与当日故障无关):官方 `dsh-client-modules` 把全部客户端插件拼成**一条 11 MB 脚本**(` | | 04-98 | ✅ 已上线 | **治本:陈旧 `dsh-auth-*` cookie 回写清理**:dsh 每次实例(重)启动都换 `dsh-auth-<随机>` 的 cookie 名,平台此前只在转发时丢弃旧的、**从不回写浏览器** ⇒ jar | | 04-99 | 🔄 已落地待生效(钩子已装 | > 1. **根因 = 拦截点错位**(不是"AI 不听话"):既有「提问闸门」hook 的 matcher 是 `^AskUserQuestion$`,只能拦**工具调用**;实测本工作区宿主日志 `tool=AskU | -| 04-100 | 🚧 实施中 | **结论**:在**既有「功能管理」section 内新增「我的技能」分组**(**不新开 section**),用户在同一页看完并管理「功能插件 + 我的技能」两类功能;技能行支持 上传 / 启用 / 停用 / 删除, | \ No newline at end of file +| 04-100 | ✅ 已实施并投放(`busi | **结论**:在**既有「功能管理」section 内新增「我的技能」分组**(**不新开 section**),用户在同一页看完并管理「功能插件 + 我的技能」两类功能;技能行支持 上传 / 启用 / 停用 / 删除, | +| 04-101 | ✅ 已实施并投放 | **结论**:分区改名「**能力管理**」(label 级),页内改为 **tab 分页**(功能插件 / 我的技能,各自独立操作),插件卡片的**中文用途描述从 1 行放宽到 3 行**(`.bp-plugDesc`) | +| 04-102 | ✅ 已实施并投放 | **结论**:语言切换搬进 **`@dsh-local/portal-entry` 的「用户设置」分区**(id `user-settings`,order 103), | \ No newline at end of file diff --git a/dsh-server-docs/docs-manifest.json b/dsh-server-docs/docs-manifest.json index f15c961..37bfdee 100644 --- a/dsh-server-docs/docs-manifest.json +++ b/dsh-server-docs/docs-manifest.json @@ -1,29 +1,30 @@ { "generatedFrom": "scripts/docs-manifest.py", "counts": { - "files": 135, - "chars": 1111460 + "files": 138, + "chars": 1129912 }, "tiers": { "hot": 7, "cur": 27, "warm": 56, - "cold": 10, - "doc": 35 + "cold": 12, + "doc": 36 }, "domains": { "platform": 54, "method": 28, - "ui": 12, - "plugin": 26, + "ui": 14, + "plugin": 27, "ops": 10, "external": 5 }, "layers": { "L0": 1, - "L5": 113, + "L5": 115, "L4": 4, "L2": 3, + "?": 1, "L1": 2, "L3": 12 }, @@ -64,7 +65,7 @@ "title": "03 路线图与待办", "status": "?", "date": "", - "chars": 18429, + "chars": 18493, "lines": 127, "refs": 0, "refsCurrent": 0, @@ -168,7 +169,7 @@ "num": "07", "title": "调整方案 07:红线 — 禁止 dsh 自动获取最新版本 + 版本升级流程", "status": "生效", - "date": "04-07", + "date": "", "chars": 2111, "lines": 44, "refs": 10, @@ -216,7 +217,7 @@ "date": "04-10", "chars": 6261, "lines": 129, - "refs": 19, + "refs": 24, "refsCurrent": 1, "tier": "cur", "layer": "L5", @@ -227,10 +228,10 @@ "path": "04-调整方案/100-实例内我的技能-并入功能管理分组.md", "num": "100", "title": "100 · 实例内「我的技能」—— 并入「功能管理」的分组方案与实现", - "status": "🚧 实施中", + "status": "✅ 已实施并投放(`busi", "date": "2026-09-15", - "chars": 12477, - "lines": 250, + "chars": 12599, + "lines": 251, "refs": 0, "refsCurrent": 0, "tier": "cold", @@ -238,6 +239,36 @@ "domain": "method", "tldr": "**结论**:在**既有「功能管理」section 内新增「我的技能」分组**(**不新开 section**),用户在同一页看完并管理「功能插件 + 我的技能」两类功能;技能行支持 上传 / 启用 / 停用 / 删除,平台共享技能显示为*" }, + { + "path": "04-调整方案/101-能力管理-改名与tab分页与卡片三行.md", + "num": "101", + "title": "101 · 「能力管理」:分区改名 + 页内 tab 分页 + 插件卡片三行描述 + DeepSeek 改名", + "status": "✅ 已实施并投放", + "date": "2026-09-15", + "chars": 6575, + "lines": 152, + "refs": 0, + "refsCurrent": 0, + "tier": "cold", + "layer": "L5", + "domain": "plugin", + "tldr": "**结论**:分区改名「**能力管理**」(label 级),页内改为 **tab 分页**(功能插件 / 我的技能,各自独立操作),插件卡片的**中文用途描述从 1 行放宽到 3 行**(`.bp-plugDesc`),并把「**内置 D" + }, + { + "path": "04-调整方案/102-语言切换搬入用户设置并撤除偏好设置.md", + "num": "102", + "title": "102 · 语言切换从「偏好设置」搬进「用户设置」,并撤除偏好设置分区", + "status": "✅ 已实施并投放", + "date": "2026-09-15", + "chars": 6957, + "lines": 156, + "refs": 0, + "refsCurrent": 0, + "tier": "cold", + "layer": "L5", + "domain": "ui", + "tldr": "**结论**:语言切换搬进 **`@dsh-local/portal-entry` 的「用户设置」分区**(id `user-settings`,order 103)," + }, { "path": "04-调整方案/11-技能管理面-shared+mine-API与页面.md", "num": "11", @@ -351,7 +382,7 @@ "date": "2026-09-10", "chars": 10704, "lines": 216, - "refs": 24, + "refs": 25, "refsCurrent": 1, "tier": "cur", "layer": "L5", @@ -996,7 +1027,7 @@ "date": "2026-09-12", "chars": 3699, "lines": 92, - "refs": 8, + "refs": 9, "refsCurrent": 2, "tier": "cur", "layer": "L5", @@ -1386,7 +1417,7 @@ "date": "2026-09-13", "chars": 14028, "lines": 218, - "refs": 11, + "refs": 12, "refsCurrent": 0, "tier": "warm", "layer": "L5", @@ -1603,6 +1634,21 @@ "domain": "ui", "tldr": "" }, + { + "path": "07-实例UI分区登记表.md", + "num": null, + "title": "07 · 实例 UI 分区登记表(settings.section 谁提供、源码在哪、能不能改)", + "status": "✅ 表格为现行值", + "date": "2026-09-15", + "chars": 4215, + "lines": 85, + "refs": 0, + "refsCurrent": 0, + "tier": "doc", + "layer": "?", + "domain": "ui", + "tldr": "" + }, { "path": "BRIEF.md", "num": null, @@ -1654,8 +1700,8 @@ "title": "dsh 平台文档导航(INDEX)", "status": "?", "date": "", - "chars": 16130, - "lines": 816, + "chars": 16649, + "lines": 830, "refs": 0, "refsCurrent": 0, "tier": "doc", diff --git a/package.json b/package.json index b4160f4..3cfa1e2 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "prepare": "npm run build", "dev": "node lib/cli.js", "typecheck": "tsc -p tsconfig.json --noEmit", - "verify": "npm run build && node --test test/db.test.mjs test/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 && node scripts/verify-my-skills.mjs", + "verify": "npm run build && node --test test/db.test.mjs test/local-user-fs.test.mjs test/crash-policy.test.mjs test/locale-pref.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 && node scripts/verify-my-skills.mjs && node scripts/verify-portal-entry.mjs", "test": "npm run build && node --test test/db.test.mjs test/local-user-fs.test.mjs test/crash-policy.test.mjs && node scripts/verify-inject.cjs lib/supervisor/proxy.js", "smoke": "node scripts/smoke.mjs", "smoke:admin": "node scripts/smoke-admin.mjs", diff --git a/poc/business-plugins/lib/client.js b/poc/business-plugins/lib/client.js index 206d8bc..bcbd4ee 100644 --- a/poc/business-plugins/lib/client.js +++ b/poc/business-plugins/lib/client.js @@ -147,14 +147,12 @@ window.__ModuleLoader__.load({ var NS = "business-plugins"; /** 简体中文字典(key 集的事实来源)。 */ var zh = { - "section.label": "功能管理", + "section.label": "能力管理", + + // 顶部页内分页(2026-09-15 用户要求:「能力管理」按插件 / 技能分页各自操作)。 + // 技能页的标签直接复用 `msk.group`,不另造同义词。 + "cap.tabPlugins": "功能插件", - // ── 「偏好设置」section(档案 81 · R5:语言切换)──────────────── - // ⚠️ 语言 id 必须与官方 `dsh-client-locale` 的 `LOCALE_IDS` 一致:`en` / `zh`。 - // 官方 `FALLBACK_LOCALE = "en"` ⇒ **默认英语**(全球化口径,2026-09-15 用户定)。 - "pref.label": "偏好设置", - "pref.lang": "界面语言", - "pref.hint": "设置本实例界面的显示语言。默认英语;切换后立即生效。", // ── 「我的技能」分组(档案 100 · 2026-09-15)───────────────────── // 键前缀 `msk.` = my skills。措辞三原则(档案 100 §2.6): @@ -480,11 +478,11 @@ window.__ModuleLoader__.load({ "ms.sharedOff": "启用共享模型", "ms.list": "我已添加的提供方", "ms.empty": "还没有添加任何提供方 —— 用下面的按钮添加。", - "ms.tagBuiltin": "内置", + "ms.tagBuiltin": "官方", "ms.tagCatalog": "目录", "ms.tagCustom": "自定义", - "ms.kindBuiltin": "内置 DeepSeek", - "ms.builtinHint": "平台内置通道,无需端点", + "ms.kindBuiltin": "DeepSeek", + "ms.builtinHint": "官方通道,无需端点", "ms.catalogRow": "官方目录提供方(端点 / 协议 / 模型由目录提供)", "ms.stateOn": "已启用", "ms.stateOff": "已停用", @@ -526,8 +524,8 @@ window.__ModuleLoader__.load({ "ms.saving": "保存中…", "ms.showModels": "查看模型清单", "ms.hideModels": "收起模型清单", - "ms.optBuiltin": "内置 DeepSeek(平台共享)", - "ms.builtinPickHint": "内置 DeepSeek:平台内置通道,固定官方端点,无需填 API 地址。", + "ms.optBuiltin": "DeepSeek", + "ms.builtinPickHint": "DeepSeek:官方通道,固定官方端点,无需填 API 地址。", "ms.grpCn": "中国大陆", "ms.grpIntl": "国际", "ms.e.name": "名称不合法", @@ -538,19 +536,16 @@ window.__ModuleLoader__.load({ "ms.e.models": "请至少填一个模型 id", "ms.e.taken": "该提供方标识已被占用,换一个", "ms.e.provider": "官方目录里没有这个提供方", - "ms.catUnreadable": "⚠️ 平台未能读取官方提供方目录({dir})—— 当前只能使用内置 DeepSeek 或自定义提供方。请联系管理员检查安装路径配置。", - "ms.catEmpty": "官方提供方目录为空(该部署可能未随附 pi-ai 提供方数据)—— 当前只能使用内置 DeepSeek 或自定义提供方。" + "ms.catUnreadable": "⚠️ 平台未能读取官方提供方目录({dir})—— 当前只能使用 DeepSeek 或自定义提供方。请联系管理员检查安装路径配置。", + "ms.catEmpty": "官方提供方目录为空(该部署可能未随附 pi-ai 提供方数据)—— 当前只能使用 DeepSeek 或自定义提供方。" }; /** English dictionary, key-set complete against zh. */ var en = { - "section.label": "Feature management", + "section.label": "Capability management", - // ── 「Preferences」section (spec 81 · R5: language switch) ────── - // ⚠️ Language ids must match the official `dsh-client-locale` `LOCALE_IDS`: `en` / `zh`. - // The official `FALLBACK_LOCALE = "en"` ⇒ **English by default** (global-product rule, 2026-09-15). - "pref.label": "Preferences", - "pref.lang": "Interface language", - "pref.hint": "Choose the display language for this instance. English is the default; the change applies immediately.", + // In-page tabs (2026-09-15): "Capability management" splits into plugins / skills, + // each operating independently. The skills tab reuses `msk.group` as its label. + "cap.tabPlugins": "Feature plugins", // ── "My skills" group (spec 100 · 2026-09-15) ────────────────── // Key prefix `msk.` = my skills. Same three wording rules as the zh side: @@ -859,11 +854,11 @@ window.__ModuleLoader__.load({ "ms.sharedOff": "Enable shared model", "ms.list": "My providers", "ms.empty": "No providers yet — use the buttons below to add one.", - "ms.tagBuiltin": "Built-in", + "ms.tagBuiltin": "Official", "ms.tagCatalog": "Catalog", "ms.tagCustom": "Custom", - "ms.kindBuiltin": "Built-in DeepSeek", - "ms.builtinHint": "Platform channel; no endpoint needed", + "ms.kindBuiltin": "DeepSeek", + "ms.builtinHint": "Official channel; no endpoint needed", "ms.catalogRow": "Catalog provider (endpoint/protocol/models from the catalog)", "ms.stateOn": "Enabled", "ms.stateOff": "Disabled", @@ -905,8 +900,8 @@ window.__ModuleLoader__.load({ "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.optBuiltin": "DeepSeek", + "ms.builtinPickHint": "DeepSeek: the official channel with a fixed endpoint — no base URL needed.", "ms.grpCn": "Mainland China", "ms.grpIntl": "International", "ms.e.name": "Invalid name", @@ -917,8 +912,8 @@ window.__ModuleLoader__.load({ "ms.e.models": "Add at least one model id", "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." + "ms.catUnreadable": "⚠️ The platform could not read the official provider catalog ({dir}) — only 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 DeepSeek and custom providers are available." }; /** @@ -1087,7 +1082,7 @@ window.__ModuleLoader__.load({ // 圆角 / 字号 / 行高 / 内边距 / gap **逐值照抄**;颜色走同一批 dsh token `--dsw-*` // (官方原文用的就是这批 token,故连 token 名都一致),只补一个硬编码兜底色。 // - // 为什么把表格换成卡片行:表格 4 列是**自动列宽**,「内置 DeepSeek」和「停用/删除」 + // 为什么把表格换成卡片行:表格 4 列是**自动列宽**,「DeepSeek」(当时叫「内置 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}", @@ -1170,6 +1165,13 @@ window.__ModuleLoader__.load({ ".bp-err{font-size:13px;line-height:1.6;color:var(--dsw-alias-state-error-primary,#d93026)}", ".bp-ok{font-size:13px;line-height:1.6;color:var(--dsw-alias-state-success-primary,#1a7f37)}", + // 分页工具行(技能页:左说明 / 右上传入口 —— 左对齐、右侧靠 `.bp-groupAct` 的 margin-left:auto) + ".bp-tabHead{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap;margin:0 0 12px}", + // 插件卡片的**中文用途描述 = 最多 3 行**(2026-09-15 用户要求:「插件卡片中增加中文用途 + // 描述,显示三行」)。用 `-webkit-line-clamp` 做多行截断:单行 ellipsis 会把"这个插件 + // 到底干什么"截得看不全;`white-space:normal` 必须显式写(否则继承单行截断的 nowrap)。 + ".bp-plugDesc{display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden;white-space:normal;word-break:break-word;line-height:1.45}", + "@media (prefers-reduced-motion: reduce){.pa-card,.pa-modal,.bp-skillRow{animation:none;transition:none}.pa-card:hover{transform:none}.ms-summary::before{transition:none}}" ].join(""); document.head.appendChild(el); @@ -1278,6 +1280,13 @@ window.__ModuleLoader__.load({ var delSkState = useState(null); var delSkill = delSkState[0]; var setDelSkill = delSkState[1]; + /** + * 顶部分页(`"plugins"` | `"skills"`)—— 2026-09-15 用户要求: + * 「能力管理」按**插件 / 技能分页**,各自独立操作(默认落在插件页,与改造前一致)。 + */ + var tabState = useState("plugins"); + var tab = tabState[0]; + var setTab = tabState[1]; function loadSkills() { setSkLoading(true); @@ -1747,6 +1756,42 @@ window.__ModuleLoader__.load({ }; var rows = []; var cards = []; + /** 当前分页 —— 插件页与技能页**各自独立操作**(2026-09-15 用户要求)。 */ + var isPlugins = tab === "plugins"; + + // 顶部页内分页:**复用既有 `.pa-tabs/.pa-tab/.pa-tcnt`**(下划线式;规范 §4.4 + // 「数据页用下划线式」)。计数 = 该类当前有多少项,用户不必点进去才知道。 + rows.push(jsxRuntime.jsxs("div", { + key: "__tabs", + className: "pa-tabs", + role: "tablist", + children: [ + jsxRuntime.jsxs("div", { + key: "p", + className: "pa-tab" + (isPlugins ? " pa-on" : ""), + role: "tab", + "aria-selected": isPlugins ? "true" : "false", + onClick: function () { setTab("plugins"); }, + children: [ + t("cap.tabPlugins"), + jsxRuntime.jsx("span", { key: "c", className: "pa-tcnt", children: String(plugins.length) }) + ] + }), + jsxRuntime.jsxs("div", { + key: "s", + className: "pa-tab" + (isPlugins ? "" : " pa-on"), + role: "tab", + "aria-selected": isPlugins ? "false" : "true", + onClick: function () { setTab("skills"); }, + children: [ + t("msk.group"), + jsxRuntime.jsx("span", { key: "c", className: "pa-tcnt", children: String(skills.length) }) + ] + }) + ] + })); + + if (isPlugins) { // 内存配额状态条:**卡片列表上方**(2026-09-13 用户要求)—— // 当前配额 / 勾选后配额 / 是否顶到硬顶,让用户在点「应用」之前就知道后果。 @@ -1837,12 +1882,13 @@ window.__ModuleLoader__.load({ style: { flex: "1", minWidth: "0", display: "flex", flexDirection: "column", gap: "2px" }, children: [ // 主视觉 = 用途说明(规范 §5「信息层次」;与门户 plugins 页同一决策) + // 主视觉 = **中文用途描述,最多 3 行**(2026-09-15 用户要求: + // 「插件卡片中增加中文用途描述,显示三行」)。截断交给 `.bp-plugDesc` + // 的 `-webkit-line-clamp:3`;`title` 仍挂全文,hover 可看完整。 jsxRuntime.jsx("span", { key: "p1", - style: { - fontSize: "14px", fontWeight: 500, color: T.text, lineHeight: 1.45, - overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" - }, + className: "bp-plugDesc", + style: { fontSize: "14px", fontWeight: 500, color: T.text }, title: primary, children: primary }), @@ -1939,11 +1985,16 @@ window.__ModuleLoader__.load({ ] })); - // ══ 「我的技能」分组(档案 100)═════════════════════════════════════════ - // 与「功能插件」**同页**呈现(用户口径「不要分开管理」=入口层合并),但机制各自 - // 独立:技能是 watch 驱动 ⇒ 启用/停用**立即生效、无需重启**,行内文案如实说明; + } + + // ══ 「我的技能」分组(档案 100;2026-09-15 起改为**独立分页**)══════════════ + // 与「功能插件」**同一个 section 内**(用户口径「不要分开管理」=入口层合并), + // 但两者**各自一页、各自操作**(用户 2026-09-15:「改造为 tab 可切换 插件和技能 + // 分别进行操作」);机制上始终独立:技能是 watch 驱动 ⇒ 启用/停用**立即生效、 + // 无需重启**,行内文案如实说明。 // 平台共享技能(`locked:true`)**不渲染任何动作按钮** —— 与后端 `assertNotShared()` // 的 409 双向一致:前端不给出注定失败的按钮,后端仍然兜底。 + if (!isPlugins) { var skillRows = []; if (skLoading) { skillRows.push(jsxRuntime.jsx("div", { @@ -2049,16 +2100,16 @@ window.__ModuleLoader__.load({ rows.push(jsxRuntime.jsxs("div", { key: "__skills", children: [ - // 分组头:竖条标题 + 计数 + 右侧主按钮(形态与「功能插件」分组头同源) + // 工具行:左 = 生效方式说明(与「功能插件」需重启形成对照)/ 右 = 上传入口。 + // ⚠️ **不再自带分组标题** —— 标题由顶部 tab 承担,避免同页出现两个同名标签。 jsxRuntime.jsxs("div", { key: "head", - className: "bp-groupHead", + className: "bp-tabHead", children: [ - jsxRuntime.jsx("h4", { key: "t", className: "bp-groupTitle", children: t("msk.group") }), - jsxRuntime.jsx("span", { - key: "c", - className: "bp-groupCnt", - children: t("msk.count").replace("{n}", String(skills.length)) + jsxRuntime.jsx("p", { + key: "hint", + style: { margin: 0, fontSize: "12px", lineHeight: "18px", color: T.dim }, + children: t("msk.hint") }), jsxRuntime.jsx("span", { key: "act", @@ -2078,12 +2129,6 @@ window.__ModuleLoader__.load({ }) ] }), - // 说明行(如实说生效方式 —— 与「功能插件」需重启形成对照) - jsxRuntime.jsx("p", { - key: "hint", - style: { margin: "0 0 4px", fontSize: "12px", lineHeight: "18px", color: T.dim }, - children: t("msk.hint") - }), // 上传面板(默认收起;支持点击选文件与拖入) upOpen ? jsxRuntime.jsxs("div", { @@ -2181,6 +2226,9 @@ window.__ModuleLoader__.load({ ] })); + } + + if (isPlugins) { if (rejected.length > 0) { // impeccable craft-floor 明令禁止「卡片/列表项/提示上 >1px 的彩色 border-left/right」 // —— 改为整块浅底 + 圆角,危险语义由标题文字色承担(不靠侧边色条)。 @@ -2325,6 +2373,9 @@ window.__ModuleLoader__.load({ // ── 「我的技能」的两个页内确认弹窗(档案 100 §2.5)────────────────────── // 共同点:**只点名 + 讲清后果**。替换额外给出「旧 vs 新」的事实行(tabular-nums), // 因为"删掉几个文件"是用户真正关心的量;删除与替换同样不可逆,故同样必须过弹窗。 + } + + if (!isPlugins) { if (replaceTarget) { rows.push(skillDialog({ dkey: "__repl", @@ -2383,6 +2434,8 @@ window.__ModuleLoader__.load({ })); } + } + return jsxRuntime.jsx("div", { style: wrap, children: rows }); } @@ -2582,7 +2635,7 @@ window.__ModuleLoader__.load({ } /** - * 提交「添加提供方」(目录提供方 / 内置 DeepSeek)。 + * 提交「添加提供方」(目录提供方 / DeepSeek —— 即内置通道那条)。 * ⚠️ 密钥**不可留空**:`keyAddSchema` 里 `apiKey` 是 `required + minLength:1`, * 留空必得 400 `invalid_api_key`(旧文案「留空则回落平台共享密钥」是错的,已改)。 * 显示名留空 ⇒ 取提供方名(官方同做法:页面从不先问名字)。 @@ -2900,86 +2953,10 @@ window.__ModuleLoader__.load({ ] }); } - /** - * 「偏好设置」section(**用户可见**)—— 档案 81 · R5:**语言切换**。 - * - * 走官方扩展点(**零官方改动**):`ctx.locale.getLocale()` 读当前语言、`ctx.locale.setLocale(id)` 切换; - * 语言目录 = 官方随包发布的 `LOCALE_IDS`(`en` / `zh`)⇒ **不另造词条运行时**。 - * - * ⚠️ **默认英语**:官方 `FALLBACK_LOCALE = "en"`(全球化项目口径,用户 2026-09-15 定)。 - * 切换后的持久化由**官方 locale** 写入宿主 `settings.yaml` 的 `locale.preference` —— 本插件不碰文件。 - * ⚠️ 切换后只 bump 本 section 的本地 state(本栏立即用新语言重渲染);官方 UI 与其它 section - * 由各自的订阅重渲染,无需我们干预。 - */ - function PreferencesSection() { - var useState = React.useState; - var tickState = useState(0); - var setTick = tickState[1]; - var LANGS = [{ id: "en", name: "English" }, { id: "zh", name: "中文" }]; - function currentLocale() { - try { - if (ctx.locale && typeof ctx.locale.getLocale === "function") { - var id = ctx.locale.getLocale(); - if (id) return String(id).split("-")[0]; - } - } catch (err) { /* 官方 API 若变动,本栏降级为只读默认值,不至于整栏崩掉 */ } - return "en"; - } - var cur = currentLocale(); - function pick(id) { - if (id === cur) return; - try { - if (ctx.locale && typeof ctx.locale.setLocale === "function") ctx.locale.setLocale(id); - else console.warn("[business-plugins] ctx.locale.setLocale 不可用,语言未切换"); - } catch (err) { - console.warn("[business-plugins] setLocale 失败:", err); - } - setTick(function (n) { return n + 1; }); - } - return jsxRuntime.jsx("div", { - className: "bp-pref", - children: [ - jsxRuntime.jsx("p", { - className: "ms-hint", - style: { marginBottom: 12 }, - children: t("pref.hint") - }, "hint"), - jsxRuntime.jsx("div", { - style: { display: "flex", alignItems: "center", gap: 10 }, - children: [ - jsxRuntime.jsx("span", { children: t("pref.lang") }, "label"), - jsxRuntime.jsx("select", { - "aria-label": t("pref.lang"), - value: cur, - onChange: function (e) { pick(e.target.value); }, - style: { - minWidth: 140, padding: "6px 8px", borderRadius: 6, cursor: "pointer", - border: "1px solid var(--dsw-alias-border-secondary, #d9d9d9)", - background: "var(--dsw-alias-bg-base, transparent)", - color: "var(--dsw-alias-label-primary, inherit)" - }, - children: LANGS.map(function (l) { - return jsxRuntime.jsx("option", { value: l.id, children: l.name }, l.id); - }) - }, "select") - ] - }, "row") - ] - }); - } - - // 「偏好设置」= 语言切换(**用户可见**;order 99 ⇒ 排在「模型设置 / 功能管理」之前) - ctx.slots.inject("settings.section", function () { - return ctx.slots.register( - { - name: "settings.section", - id: "preferences", - order: 99, - label: function () { return t("pref.label"); } - }, - PreferencesSection - ); - }); + // 2026-09-15(用户要求):**「偏好设置」分区已撤除** —— 语言切换搬到 + // `@dsh-local/portal-entry` 的「用户设置」分区(用户级偏好与「账号 / 退出登录」同区即可, + // 再单开一个分区就是重复入口)。撤掉的实现(PreferencesSection + id `preferences` + // order 99 + `pref.*` 词条)见 `04-调整方案/102`。 ctx.slots.inject("settings.section", function () { return ctx.slots.register( diff --git a/poc/business-plugins/package.json b/poc/business-plugins/package.json index df4a921..5f5d9e6 100644 --- a/poc/business-plugins/package.json +++ b/poc/business-plugins/package.json @@ -1,7 +1,7 @@ { "name": "@dsh-local/business-plugins", - "version": "0.3.21", - "description": "功能管理(原「功能插件」)section for dsh web profile — v0.3.21(2026-09-15):**实例内「我的技能」分组**(档案 100 / 交接单 T01)—— 用户在同一页管理「功能插件 + 我的技能」两类功能。① **形态**:并入既有「功能管理」section 内**分组**,**不新开 section**(依据用户口径「不要分开管理」+ 档案 60 的分区命名);**仅入口层合并、机制层独立** —— 技能是 watch 驱动(启停**立即生效、无需重启**)而插件要重启实例,两条 API(`/api/skills/mine*` vs `/api/plugins/mine*`)与两套落盘位置一律不动(档案 16 §2.1 决策 2)。② **技能行**:技能名(截断三件套)+ 来源徽章(共享/我的)+ 状态(● 已启用 / ○ 已停用)+ 事实(文件数 · 体积,`tabular-nums`)+ 动作(启用/停用、删除);沿用官方卡片行语汇(动作区 `margin-left:auto` + `nowrap`)⇒ **结构上不可能换行**。③ **平台共享技能**(`source:'shared'`、`locked:true`)显示 🔒只读并**不渲染任何动作按钮** —— 与后端 `assertNotShared()` 的 409 双向一致(前端不给注定失败的按钮,后端仍兜底)。④ **上传**:虚线拖拽区 + 点击选文件(`label` 包隐藏 `input`,无需 ref),客户端先校 `.zip` / ≤150MB;**同名走两阶段**(先暂存 → 页内弹窗确认,写明「替换将删除旧技能全部文件、不可恢复」+ 旧/新文件数对照 → `apply`),与门户同语义。⑤ **删除**同样必须过页内确认(不可逆);启用/停用**可逆故不弹窗**。⑥ 失败一律**行内展示后端中文原文**(如「「x」是平台共享技能…请改用其他技能名」),不自己造词、不裸露状态码。⑦ zh/en 全量词条(`msk.*`,共 46 条/语言);新增 `scripts/verify-my-skills.mjs` 把「不换行 / 危险操作必须二次确认 / 锁定行无按钮」钉成机械断言并入 `npm run verify`。|v0.3.19(2026-09-14):**内存口径最终版 —— 基础 MIN、最多浮动到 MAX**(用户:「改成基础 min 最大可以浮动到 max」)。平台侧 = cgroup **两个参数**:`MemoryHigh=MIN`(448,软限/基础,超过即回收·限速)+ `MemoryMax=MAX`(1024,硬限/上界,越界 OOM);**与插件开关无关**(不读 profile 的 bundles)。客户端:`quotaOf()` 兜底值改为**硬顶上界**(进度条 100% 基准与「超限」判据同它),事实行改为显示「**基础 448 MiB → 最多 1024 MiB** · V8 堆 256」;`/api/dsh/status` 的 `quota` 增加 `baseMb`。|v0.3.18(2026-09-14):**内存口径修正 —— 实例配额与「插件开关」解耦**(用户:「实例内存不要受插件开关影响,只受 min 和 max 值影响」)。① 编排器 `instanceMemMb()` 改为只受 MIN/MAX 决定:`min(MIN, MAX)` = **MIN 448 MiB**(删掉 `PLUGIN_MEM_MB` 与 `BASE_MEM_MB`,不再读 profile 的 bundles);② 客户端保留 `MEM_TABLE`,但**只用于给用户看的预估**(`quotaOf()` 不再 clamp 预估值),显示「实际配额」仍优先取 `/api/dsh/status` 的真值;③ 进度条 100% 基准与确认弹窗的「超限」判据都改为**实际配额**(原为硬顶 MAX),文案改为「预估已超过实例配额(配额固定,不会因为多装插件而增加)」;④ 理由(实测):用「估计表」定「硬上限」时估偏低就真 OOM —— admin 带 mcn 后**峰值 409 MiB** 而当时上限 384 ⇒ 一分钟内被 cgroup OOM 杀 4 次、打出 crash 熔断;且配额随开关跳动会让界面「预估」与真实上限绑死。`verify-mem-model.mjs` 已换向断言(反回退:编排器不得再用插件集合算配额)。|v0.3.17(2026-09-14):**内存口径定案**(BASE 448 / MIN 448 / MAX 1024,插件成本表 univer 512、mcn 128)—— 与编排器 `instanceMemMb()` 逐值对齐(`verify-mem-model.mjs` 会红着提醒)。① BASE 160→448:0.1.5 基座**实测 312 MiB**,旧值按 0.1.2 的 106~145 估的,严重低估 ⇒ 384 的上限里只剩 72 MiB 给插件,这正是宿主 swap 被吃掉的原因;② **MIN 必须 ≥ BASE**(否则 `clamp(raw, MIN, MAX)` 的下限失去意义)⇒ MIN 384→448;③ **MAX 保持 1024**(不是偏好:编排器头部已写「上限 1024M 是单实例硬顶(宿主 1870M)」这一不变量,在途草稿的 1536 与宿主容量前提自相矛盾,已驳回);④ mcn 套件**保持 128**:无可支撑抬高的实测,反向有证据(装了它的用户在旧 384 上限下一直跑得住 ⇒ 需求 ≤384),而抬高会直接吃宿主余量(1870M / 可用 ≈761M / swap 已用 452M)。|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 个…」且**勾选框「含仅兼容更旧版本的」是显式逃生口** —— 绝不让条目静默消失;④ 判定结果按 `@<版本>` 落盘缓存 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 密钥」输入框**(官方原文:页面从不问环境变量名),显示名挪进收起的「自定义设置」`
`;自定义卡片按官方字段序(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-patch.cjs` 不再禁用 `ui-settings-models`(实测官方页在本环境可用:`/api/session/modelCatalog` 返回 200);② `src/web/server.ts` 的 `resolveApiKey()` 改为「用户已自配 ⇒ **不注入共享 env**」——因为 dsh 凭据解析里 `inherited process environment` **优先级最高**,不这样做用户配的 key 会被静默盖掉。|v0.3.7(2026-09-13 · 原拟 0.3.6,因并行会话已用同号 0.3.6 铺发过「内存条」版本 ⇒ 提升为 0.3.7):① ~~新增「我的密钥」分区~~(**已于 0.3.8 撤除**,原因是两套入口会互相干扰) —— 用户自助配置自己的模型密钥(自配自用),不配置就用「平台共享密钥」(管理员配置);页面分两段:我的密钥(添加 / 启用 / 删除)+ 当前生效(明示在用谁的 key,并提示改 key 只重启自己的实例、不影响他人)。配套后端:`/api/me/keys` 由 requireAdmin 放开为 requireAuth,`resolveApiKey(userId)` 改为「先取自己的 → 取不到回落管理员的共享 key」两级;门户「密钥管理」文案同步改为「平台共享密钥」。② **内存条改读平台真实配额,消灭「388 MiB 误报」**(用户问「实例内存大小是不是调整过了,为什么功能设置中还是显示 388 多」)。根因:本插件自建了**第二套**内存模型(基线 285 / univer 64 / mcn 39,并把 clamp 下限 384 当硬上限判超限),而平台编排器自 R1 起为「按插件集合推导」(base 160 / univer 512 / mcn 128 / clamp 384–1024),两处从未对齐 ⇒ 全勾显示 388 并报「⚠ 将超出上限」,真值却是 672(guest)/ 384(admin)。本轮:① 常量与规则逐项对齐编排器,并新增 `scripts/verify-mem-model.mjs` 在 `npm run verify` 里交叉断言(漂了就构建失败);② 优先读 `/api/dsh/status` 新增的 `quota{memMb,heapMb}`(平台**实际使用**的值,与 spawn 同源)直接显示「实际配额 · V8 堆」;③ 超限判断改为「原始需求 > 硬顶 1024」;④ 未进成本表的插件不再显示 ≈0 MiB 徽章。|v0.3.5(2026-09-13):**插件管理 →「官方推荐插件」列表高度拉高**(用户要求「高度可以增加,距离底部 100px 就行」)—— 该表 max-height 由固定 420px 改为 `max(280px, min(calc(92vh - 470px), 580px))`(类 `.pa-plist`),按弹窗高度反推、使列表底部**距弹窗底部约 100px**;下限 280px 防小屏压扁,上限 580px 对应弹窗触顶 1040px。|v0.3.4(2026-09-13):**「系统管理」全面对齐门户 web/portal.html 的 6 个功能页**(用户要求「点开弹窗里面展示的内容,要和 portal 点击功能后那种详细的表格和功能一致」)—— 分区首页 = 门户 renderHome(「服务」「管理」两组 + .nav-card 三行卡片);点开每一项 = 门户**那一页的完整页面**:服务管理(文件树 + 启动/停止/打开 DSH + 新建文件夹/上传)、密钥管理(全局 API 密钥增删启用)、用户管理(审批/禁用/恢复/删除,需输入用户名二次确认)、技能管理(.zip 上传/替换/删除)、插件管理(官方推荐插件 + 手动添加/管理双页签、搜索/分类/只看可导入/重新拉取/批量导入、.tgz 投放含 P0 扫描与显式信任)、运行环境(版本表 + 漂移提示 + 目录/体积 + 折叠说明);表格列 / 按钮 / 文案逐字一致(`PA_PAGES` 逐函数移植 portal 的 renderXxx/initXxx,只改 3 处:局部 `$` 查询器、跨子域 `paReq`、去掉 hash 路由);弹窗宽度对齐门户功能页 min(1440px,96vw)、高 92vh。写操作就地调平台 admin API(跨子域 + credentials:include,CORS 白名单已含 GET/POST/DELETE)。**已删除**旧版自造的 5 项只读摘要(用户管理 / 候选池 / 运行时 / 存储 / 当前实例)。⚠️ 顺带修掉门户 `readAsBase64()` 缺 await 导致上传恒传空数据的缺陷(弹窗侧已修正,门户侧待同修)。|v0.3.3(2026-09-13):「系统管理」视觉层照抄门户组件(.nav-card / .pg-cnt / .table-wrap + table.tbl / .badge / .btn-sm / .page-title)。|v0.2.8(2026-09-13):所有弹窗改页内弹窗(弃用 window.confirm)。|v0.2.7(档案 75):卡片加内存预估徽章 + 列表上方内存预估状态条。|v0.2.4(档案 67):对齐 06-工作台UI规范(信息层次反转 / 字号阶梯 / 行 hover)。|v0.2.2:分区名由「功能插件」改为「功能管理」。|v0.2.0:配色改用官方 --dsw-* design token(跟随 dsh 主题);文案走官方 locale(zh/en)。", + "version": "0.3.23", + "description": "能力管理(原「功能管理」/「功能插件」)section for dsh web profile — v0.3.23(2026-09-15):**撤除「偏好设置」分区**(用户:「把偏好设置中的 语言切换功能放到用户设置中,去掉偏好设置这个栏目」)。语言切换搬到 `@dsh-local/portal-entry` 的「用户设置」分区(id `user-settings`,order 103)—— 语言是**用户级偏好**,与「账号 / 退出登录」同区即可,再单开一个分区就是**重复入口**。撤掉:`PreferencesSection` 整段、`ctx.slots.inject` 里 id `preferences` order 99 的注册、zh/en 各 3 条 `pref.*` 词条 ⇒ 本 section 现只剩 **2 个分区**(能力管理 101 + 模型设置 100)。配套:`verify-platform-admin-section.mjs` 分区数断言 3 → 2 并新增「preferences 已撤除」断言;语言切换的功能断言移到新脚本 `scripts/verify-portal-entry.mjs`。|v0.3.22(2026-09-15):**分区改名「能力管理」+ 页内 tab 分页(插件 / 技能)+ 插件卡片中文用途描述三行**。① **改名**(用户:「设置中 功能管理改为能力管理」)—— **只改 label 级**(zh `能力管理` / en `Capability management`),代码标识符 / section id / 包名 / API 路径一律不动(素材库 U10「术语统一只改用户可见那层」);② **页内 tab**(用户:「能力管理页面改造为 tab可切换 插件和技能分别进行操作」)—— 复用既有 `.pa-tabs/.pa-tab/.pa-tcnt`(下划线式;规范 §4.4「数据页用下划线式」),两个页签各带**计数**;**插件页内容原样**(内存条 / 工具行 / 卡片网格 / 应用更改),**技能页**去掉重复的分组标题(标题已由 tab 承担)只留「生效方式说明 + 上传入口」工具行;两页**各自独立操作**,默认落插件页(与改造前一致);③ **插件卡片用途描述 = 3 行**(用户:「插件卡片中增加中文用途描述 显示三行」)—— 新增 `.bp-plugDesc`(`-webkit-line-clamp:3` + 显式 `white-space:normal`,否则继承单行截断的 nowrap),单行 ellipsis 会把「这个插件干什么」截得看不全;`title` 仍挂全文。④ **「内置 DeepSeek」→「DeepSeek」**(用户:「内置 DeepSeek,去掉内置就叫 DeepSeek 就行」)—— 仍只动**用户可见文案**:行标题 `ms.kindBuiltin`、新增下拉选项 `ms.optBuiltin`(原「内置 DeepSeek(平台共享)」,那个「(平台共享)」是**误导**:该选项走内置通道但**要填用户自己的 key**)、选择提示 `ms.builtinPickHint`、目录不可用时的兜底文案(`ms.catUnreadable`/`ms.catEmpty`)、以及种类小标 `ms.tagBuiltin`「内置」→「官方」+ `ms.builtinHint`「平台内置通道」→「官方通道」;⛔ **词典键名与代码标识符一律不动**(U10)。|v0.3.21(2026-09-15):**实例内「我的技能」分组**(档案 100 / 交接单 T01)—— 用户在同一页管理「功能插件 + 我的技能」两类功能。① **形态**:并入既有「功能管理」section 内**分组**,**不新开 section**(依据用户口径「不要分开管理」+ 档案 60 的分区命名);**仅入口层合并、机制层独立** —— 技能是 watch 驱动(启停**立即生效、无需重启**)而插件要重启实例,两条 API(`/api/skills/mine*` vs `/api/plugins/mine*`)与两套落盘位置一律不动(档案 16 §2.1 决策 2)。② **技能行**:技能名(截断三件套)+ 来源徽章(共享/我的)+ 状态(● 已启用 / ○ 已停用)+ 事实(文件数 · 体积,`tabular-nums`)+ 动作(启用/停用、删除);沿用官方卡片行语汇(动作区 `margin-left:auto` + `nowrap`)⇒ **结构上不可能换行**。③ **平台共享技能**(`source:'shared'`、`locked:true`)显示 🔒只读并**不渲染任何动作按钮** —— 与后端 `assertNotShared()` 的 409 双向一致(前端不给注定失败的按钮,后端仍兜底)。④ **上传**:虚线拖拽区 + 点击选文件(`label` 包隐藏 `input`,无需 ref),客户端先校 `.zip` / ≤150MB;**同名走两阶段**(先暂存 → 页内弹窗确认,写明「替换将删除旧技能全部文件、不可恢复」+ 旧/新文件数对照 → `apply`),与门户同语义。⑤ **删除**同样必须过页内确认(不可逆);启用/停用**可逆故不弹窗**。⑥ 失败一律**行内展示后端中文原文**(如「「x」是平台共享技能…请改用其他技能名」),不自己造词、不裸露状态码。⑦ zh/en 全量词条(`msk.*`);`scripts/verify-my-skills.mjs` 把「不换行 / 危险操作必须二次确认 / 锁定行无按钮 / tab 分页」钉成机械断言并入 `npm run verify`。|v0.3.19(2026-09-14):**内存口径最终版 —— 基础 MIN、最多浮动到 MAX**(用户:「改成基础 min 最大可以浮动到 max」)。平台侧 = cgroup **两个参数**:`MemoryHigh=MIN`(448,软限/基础,超过即回收·限速)+ `MemoryMax=MAX`(1024,硬限/上界,越界 OOM);**与插件开关无关**(不读 profile 的 bundles)。客户端:`quotaOf()` 兜底值改为**硬顶上界**(进度条 100% 基准与「超限」判据同它),事实行改为显示「**基础 448 MiB → 最多 1024 MiB** · V8 堆 256」;`/api/dsh/status` 的 `quota` 增加 `baseMb`。|v0.3.18(2026-09-14):**内存口径修正 —— 实例配额与「插件开关」解耦**(用户:「实例内存不要受插件开关影响,只受 min 和 max 值影响」)。① 编排器 `instanceMemMb()` 改为只受 MIN/MAX 决定:`min(MIN, MAX)` = **MIN 448 MiB**(删掉 `PLUGIN_MEM_MB` 与 `BASE_MEM_MB`,不再读 profile 的 bundles);② 客户端保留 `MEM_TABLE`,但**只用于给用户看的预估**(`quotaOf()` 不再 clamp 预估值),显示「实际配额」仍优先取 `/api/dsh/status` 的真值;③ 进度条 100% 基准与确认弹窗的「超限」判据都改为**实际配额**(原为硬顶 MAX),文案改为「预估已超过实例配额(配额固定,不会因为多装插件而增加)」;④ 理由(实测):用「估计表」定「硬上限」时估偏低就真 OOM —— admin 带 mcn 后**峰值 409 MiB** 而当时上限 384 ⇒ 一分钟内被 cgroup OOM 杀 4 次、打出 crash 熔断;且配额随开关跳动会让界面「预估」与真实上限绑死。`verify-mem-model.mjs` 已换向断言(反回退:编排器不得再用插件集合算配额)。|v0.3.17(2026-09-14):**内存口径定案**(BASE 448 / MIN 448 / MAX 1024,插件成本表 univer 512、mcn 128)—— 与编排器 `instanceMemMb()` 逐值对齐(`verify-mem-model.mjs` 会红着提醒)。① BASE 160→448:0.1.5 基座**实测 312 MiB**,旧值按 0.1.2 的 106~145 估的,严重低估 ⇒ 384 的上限里只剩 72 MiB 给插件,这正是宿主 swap 被吃掉的原因;② **MIN 必须 ≥ BASE**(否则 `clamp(raw, MIN, MAX)` 的下限失去意义)⇒ MIN 384→448;③ **MAX 保持 1024**(不是偏好:编排器头部已写「上限 1024M 是单实例硬顶(宿主 1870M)」这一不变量,在途草稿的 1536 与宿主容量前提自相矛盾,已驳回);④ mcn 套件**保持 128**:无可支撑抬高的实测,反向有证据(装了它的用户在旧 384 上限下一直跑得住 ⇒ 需求 ≤384),而抬高会直接吃宿主余量(1870M / 可用 ≈761M / swap 已用 452M)。|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 个…」且**勾选框「含仅兼容更旧版本的」是显式逃生口** —— 绝不让条目静默消失;④ 判定结果按 `@<版本>` 落盘缓存 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 密钥」输入框**(官方原文:页面从不问环境变量名),显示名挪进收起的「自定义设置」`
`;自定义卡片按官方字段序(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-patch.cjs` 不再禁用 `ui-settings-models`(实测官方页在本环境可用:`/api/session/modelCatalog` 返回 200);② `src/web/server.ts` 的 `resolveApiKey()` 改为「用户已自配 ⇒ **不注入共享 env**」——因为 dsh 凭据解析里 `inherited process environment` **优先级最高**,不这样做用户配的 key 会被静默盖掉。|v0.3.7(2026-09-13 · 原拟 0.3.6,因并行会话已用同号 0.3.6 铺发过「内存条」版本 ⇒ 提升为 0.3.7):① ~~新增「我的密钥」分区~~(**已于 0.3.8 撤除**,原因是两套入口会互相干扰) —— 用户自助配置自己的模型密钥(自配自用),不配置就用「平台共享密钥」(管理员配置);页面分两段:我的密钥(添加 / 启用 / 删除)+ 当前生效(明示在用谁的 key,并提示改 key 只重启自己的实例、不影响他人)。配套后端:`/api/me/keys` 由 requireAdmin 放开为 requireAuth,`resolveApiKey(userId)` 改为「先取自己的 → 取不到回落管理员的共享 key」两级;门户「密钥管理」文案同步改为「平台共享密钥」。② **内存条改读平台真实配额,消灭「388 MiB 误报」**(用户问「实例内存大小是不是调整过了,为什么功能设置中还是显示 388 多」)。根因:本插件自建了**第二套**内存模型(基线 285 / univer 64 / mcn 39,并把 clamp 下限 384 当硬上限判超限),而平台编排器自 R1 起为「按插件集合推导」(base 160 / univer 512 / mcn 128 / clamp 384–1024),两处从未对齐 ⇒ 全勾显示 388 并报「⚠ 将超出上限」,真值却是 672(guest)/ 384(admin)。本轮:① 常量与规则逐项对齐编排器,并新增 `scripts/verify-mem-model.mjs` 在 `npm run verify` 里交叉断言(漂了就构建失败);② 优先读 `/api/dsh/status` 新增的 `quota{memMb,heapMb}`(平台**实际使用**的值,与 spawn 同源)直接显示「实际配额 · V8 堆」;③ 超限判断改为「原始需求 > 硬顶 1024」;④ 未进成本表的插件不再显示 ≈0 MiB 徽章。|v0.3.5(2026-09-13):**插件管理 →「官方推荐插件」列表高度拉高**(用户要求「高度可以增加,距离底部 100px 就行」)—— 该表 max-height 由固定 420px 改为 `max(280px, min(calc(92vh - 470px), 580px))`(类 `.pa-plist`),按弹窗高度反推、使列表底部**距弹窗底部约 100px**;下限 280px 防小屏压扁,上限 580px 对应弹窗触顶 1040px。|v0.3.4(2026-09-13):**「系统管理」全面对齐门户 web/portal.html 的 6 个功能页**(用户要求「点开弹窗里面展示的内容,要和 portal 点击功能后那种详细的表格和功能一致」)—— 分区首页 = 门户 renderHome(「服务」「管理」两组 + .nav-card 三行卡片);点开每一项 = 门户**那一页的完整页面**:服务管理(文件树 + 启动/停止/打开 DSH + 新建文件夹/上传)、密钥管理(全局 API 密钥增删启用)、用户管理(审批/禁用/恢复/删除,需输入用户名二次确认)、技能管理(.zip 上传/替换/删除)、插件管理(官方推荐插件 + 手动添加/管理双页签、搜索/分类/只看可导入/重新拉取/批量导入、.tgz 投放含 P0 扫描与显式信任)、运行环境(版本表 + 漂移提示 + 目录/体积 + 折叠说明);表格列 / 按钮 / 文案逐字一致(`PA_PAGES` 逐函数移植 portal 的 renderXxx/initXxx,只改 3 处:局部 `$` 查询器、跨子域 `paReq`、去掉 hash 路由);弹窗宽度对齐门户功能页 min(1440px,96vw)、高 92vh。写操作就地调平台 admin API(跨子域 + credentials:include,CORS 白名单已含 GET/POST/DELETE)。**已删除**旧版自造的 5 项只读摘要(用户管理 / 候选池 / 运行时 / 存储 / 当前实例)。⚠️ 顺带修掉门户 `readAsBase64()` 缺 await 导致上传恒传空数据的缺陷(弹窗侧已修正,门户侧待同修)。|v0.3.3(2026-09-13):「系统管理」视觉层照抄门户组件(.nav-card / .pg-cnt / .table-wrap + table.tbl / .badge / .btn-sm / .page-title)。|v0.2.8(2026-09-13):所有弹窗改页内弹窗(弃用 window.confirm)。|v0.2.7(档案 75):卡片加内存预估徽章 + 列表上方内存预估状态条。|v0.2.4(档案 67):对齐 06-工作台UI规范(信息层次反转 / 字号阶梯 / 行 hover)。|v0.2.2:分区名由「功能插件」改为「功能管理」。|v0.2.0:配色改用官方 --dsw-* design token(跟随 dsh 主题);文案走官方 locale(zh/en)。", "type": "module", "main": "lib/index.js", "exports": { diff --git a/poc/portal-entry/.npmignore b/poc/portal-entry/.npmignore new file mode 100644 index 0000000..78a132e --- /dev/null +++ b/poc/portal-entry/.npmignore @@ -0,0 +1,9 @@ +# 打包排除项(2026-09-15 加) +# +# 背景:0.5.4 打包时,目录里上一版的 dsh-local-portal-entry-0.5.3.tgz 被一并打进了产物 +# (package/dsh-local-portal-entry-0.5.3.tgz)—— 与 business-plugins 当年 0.2.5 的坑**完全同一个** +# (见该包 .npmignore 的注释)。用忽略规则根治,而不是每次记得先手工删。 +*.tgz +*.log +*.tmp +.DS_Store diff --git a/poc/portal-entry/cordis.patch.yml b/poc/portal-entry/cordis.patch.yml new file mode 100644 index 0000000..6441826 --- /dev/null +++ b/poc/portal-entry/cordis.patch.yml @@ -0,0 +1,14 @@ +# @dsh-local/portal-entry — PoC bundle patch (host row only, v1) +# +# A bundle patch is a YAML list of patch operations. This layer inserts a +# single host row into the profile cordis tree. Row shape mirrors official +# rows (see dsh-web-app/cordis.patch.yml): id + name (package module) + config. +# +# v1: no inject (defensive). v0.1.1: row declares `inject: [tools]` — cordis +# then starts this row only after the `tools` service exists, so apply() may +# use ctx.tools directly (mirrors official rows like webserver -> webStartup). + +- insert: + - id: portal-entry + name: '@dsh-local/portal-entry' + inject: [tools] diff --git a/poc/portal-entry/lib/client.js b/poc/portal-entry/lib/client.js new file mode 100644 index 0000000..e90eff0 --- /dev/null +++ b/poc/portal-entry/lib/client.js @@ -0,0 +1,329 @@ +// @dsh-local/portal-entry — client half (dsh 0.1.2-rc.1, v0.5.0) +// +// dsh client bundles are executed as plain scripts that register a factory +// with window.__ModuleLoader__; materialization (factory(require) -> exports) +// happens lazily on first import by the browser module system. The require +// handed to the factory resolves platform seed words (react/jsx-runtime) and +// graph rows (other dsh.client packages). Mirror the shipped shape of official +// client bundles exactly (see @deepseek-ai/dsh-client-ui-brand-official +// lib/client.js): CJS-style factory, named `apply` export. +// +// v0.3.x saga: footer.action (kind:list) registrations were served with the +// correct bundle yet never rendered by the sidebar shell (footerActions stayed +// empty even for the official cordis-panel entry) — the slot appears unused by +// the 0.1.2-rc.1 sidebar consumer. Abandoned. +// +// v0.4.0: the SETTINGS panel, in contrast, is proven: official sections +// (general / models / plugins) register into `settings.section` (kind:list, +// scope root, declared by dsh-client-ui-settings-general's SettingsRoot) and +// DO render in the left nav. +// +// v0.4.1: BROWSER-VERIFIED root cause for the section never rendering despite +// `[portal-entry] apply RUN`: the extra `exports.default = apply` made the +// loader's ESM/CJS interop pick the bare `apply` FUNCTION as the plugin body +// (function form has no inject declaration site), so ctx.slots threw +// `cannot get property "slots" without inject`. Official bundles export ONLY +// `apply` + `inject` — no `default`. R3 red line: never reintroduce it. +// +// v0.4.2 / v0.4.3: the settings panel runs on the DARK theme, where +// `--dsw-alias-label-primary` AND `--dsw-alias-button-elevated-fill` both +// resolve to #f9fafb, so `var(--name, )` always took the variable +// value (light text on light background -> invisible). v0.4.3 HARDCODES the +// colors instead of relying on theme variables. Keep that: do not reintroduce +// var() for contrast-critical props. +// +// v0.5.0 (2026-09-11): +// · section renamed 「平台管理」→「用户管理」(id `platform` → +// `user-management`) and moved BELOW 「功能插件」: order 100 → 102 +// (business-plugins registers the feature-plugins section at order 101; +// official sections use models=10 / plugins=15, so 102 is last). +// · the section is now shown to EVERY user, not only admins — therefore the +// platform must install this bundle into every profile (see +// scripts/ensure-portal-entry.cjs + /usr/local/bin/provision-new-users.sh). +// An admin-only install would leave ordinary users with no logout entry. +// · content is role-aware: admins see the portal origin + 「打开管理台」; +// non-admins see 「退出登录」 only. Role comes from the portal's +// `GET /api/auth/me` (requireAuth) over the shared `.alotbuy.com` session +// cookie with credentials:"include" — the same cross-subdomain mechanism +// business-plugins already uses for /api/plugins/mine (portal CORS allows +// baseDomain + subdomains). On ANY failure we fall back to the non-admin +// view: fail-closed, never advertise a management page the viewer cannot +// open. + +// v0.5.3 (2026-09-15 · 用户要求): 「界面语言」切换**搬进本区**(用户设置),同时撤掉 +// business-plugins 的「偏好设置」分区 —— 语言是"用户级偏好",与账号 / 退出登录同属本区, +// 单独再开一个分区就是重复入口。实现走官方扩展点(零官方改动): +// `ctx.locale.getLocale()` 读、`ctx.locale.setLocale(id)` 切、`ctx.locale.register(NS,{zh,en})` +// 提供本行自己的双语文案 ⇒ `inject` 相应加上 `"locale"`。 +// ⚠️ 颜色沿用本文件硬编码:本区渲染在 DARK 主题(见 v0.4.2/v0.4.3 的结论)。 + +window.__ModuleLoader__.load({ + id: "@dsh-local/portal-entry", + factory: (require) => { + var module = { exports: {} }; + var exports = module.exports; + Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" }); + var jsxRuntime = require("react/jsx-runtime"); + var React = require("react"); + + /** + * 宿主 ctx(在 `apply(ctx)` 里赋值)—— 组件内要读官方 locale 服务,而组件是 + * 顶层函数、拿不到 apply 的入参,所以存在这个闭包变量里。 + */ + var hostCtx = null; + /** 官方 `dsh-client-locale` 的 LOCALE_IDS(`en` / `zh`);`en` = 官方 FALLBACK。 */ + var LOCALES = [{ id: "en", name: "English" }, { id: "zh", name: "中文" }]; + /** 本 bundle 自己的词典(走官方 locale 扩展点,零官方改动)。 */ + var PE_NS = "@dsh-local/portal-entry"; + var PE_DICT = { + zh: { lang: "界面语言", langHint: "切换后立即生效" }, + en: { lang: "Interface language", langHint: "Applies immediately" } + }; + + /** 当前界面语言:优先问官方 locale;拿不到就退回官方 FALLBACK `en`。 */ + function currentLocale() { + try { + if (hostCtx && hostCtx.locale && typeof hostCtx.locale.getLocale === "function") { + var id = hostCtx.locale.getLocale(); + if (id) return String(id).split("-")[0]; + } + } catch (e) { /* 官方 API 变动 ⇒ 只影响本行展示,不让整个分区崩掉 */ } + return "en"; + } + + /** 切语言:官方 API 立即生效 **+** 让平台把选择写进 `settings.yaml`(跨页面 / 跨重启保留)。 */ + function pickLocale(id) { + if (!id || id === currentLocale()) return; + try { + if (hostCtx && hostCtx.locale && typeof hostCtx.locale.setLocale === "function") { + hostCtx.locale.setLocale(id); + } else { + console.warn("[portal-entry] ctx.locale.setLocale 不可用,语言未切换"); + } + } catch (e) { + console.warn("[portal-entry] setLocale 失败:", e); + } + // ⚠️ 官方 `dsh-client-locale` **只对 loopback 页面**持久化;平台是"浏览器经域名访问远程 + // 实例" ⇒ 非 loopback ⇒ 官方只为当前进程保留选择,刷新即回默认英语。 + // 这里让平台**替官方把它自己的设置写进它自己的文件**(`locale.preference`)—— + // 官方语义不破(启动时读自己的设置即生效),用户的选择也不会丢(档案 102 §六)。 + // 持久化失败**不影响本次切换**(就地已生效)。 + try { + fetch(portalHost() + "/api/me/locale", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ locale: id }) + }).catch(function (e) { console.warn("[portal-entry] 语言偏好持久化失败:", e); }); + } catch (e) { + console.warn("[portal-entry] 语言偏好持久化调用失败:", e); + } + } + + /** 词典取值:官方 locale 优先(会随语言切换),否则退回自带字典。 */ + function tPe(key) { + try { + if (hostCtx && hostCtx.locale && typeof hostCtx.locale.bind === "function") { + var v = hostCtx.locale.bind(PE_NS)(key); + if (v && v !== key) return v; + } + } catch (e) {} + var d = PE_DICT[currentLocale()] || PE_DICT.en; + return d[key] || key; + } + + // Portal origin + management page on the registered site host minus the + // per-user subdomain label (admin.alotbuy.com -> alotbuy.com). + function portalHost() { + try { + var labels = window.location.hostname.split("."); + if (labels.length > 2) { + return window.location.protocol + "//" + labels.slice(1).join("."); + } + } catch (e) {} + return window.location.origin; + } + + function openManagement(event) { + if (event) event.preventDefault(); + window.open(portalHost() + "/desktop.html", "_blank", "noopener"); + } + + // 退出登录:整页跳转到门户同源 /logout(清 sid cookie + 删 session → 302 登录页)。 + // 必须整页跳转而非 fetch:浏览器侧跨子域调用门户 API 会被 CORS 拦截。 + function doLogout(event) { + if (event) event.preventDefault(); + try { + window.location.href = portalHost() + "/logout"; + } catch (e) { + window.location.href = "/logout"; + } + } + + function rowButton(key, style, children, onClick) { + var base = { + display: "inline-flex", + alignItems: "center", + gap: "6px", + padding: "6px 14px", + borderRadius: "8px", + border: "1px solid #43464d", + background: "transparent", + color: "#f9fafb", + cursor: "pointer", + fontSize: "13px" + }; + var merged = Object.assign(base, style || {}); + // Always force contrast-critical props after caller override so + // any inherited theme var(--...) that the caller passed cannot + // collapse text/background to the same value again (v0.4.3). + if (merged.color == null || /var\(/i.test(String(merged.color))) merged.color = "#f9fafb"; + if (merged.background == null || /var\(/i.test(String(merged.background))) merged.background = "transparent"; + return jsxRuntime.jsx( + "button", + { + key: key, + type: "button", + onClick: onClick, + style: merged, + children: [children] + } + ); + } + + // Settings section content: user-management card. + // role: null = still probing, "admin" = platform admin, "user" = anyone else. + function UserManagementSection() { + var useState = React.useState; + var useEffect = React.useEffect; + var state = useState(null); + var role = state[0]; + var setRole = state[1]; + var meState = useState(null); + var me = meState[0]; + var setMe = meState[1]; + /** 只用来在「切语言」后强制重渲染本行 —— 官方 locale 的通知不会打到这里。 */ + var tickState = useState(0); + var setTick = tickState[1]; + + useEffect(function () { + var alive = true; + // 跨子域读门户会话(共享 .alotbuy.com cookie)。失败一律按非管理员渲染。 + fetch(portalHost() + "/api/auth/me", { credentials: "include" }) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (d) { + if (!alive) return; + var r = d && d.user && d.user.role; + setMe((d && d.user) || null); + setRole(r === "admin" ? "admin" : "user"); + }) + .catch(function () { if (alive) setRole("user"); }); + return function () { alive = false; }; + }, []); + + var isAdmin = role === "admin"; + var rows = []; + if (isAdmin) { + rows.push(jsxRuntime.jsx("div", { + key: "origin", + style: { fontSize: "13px", lineHeight: "20px", color: "#c9cdd4" }, + children: "账号:" + (me && me.username ? me.username : "—") + " 角色:" + (isAdmin ? "管理员" : "普通用户") + })); + } + // ── 界面语言(v0.5.3 · 2026-09-15 用户要求:语言切换从 business-plugins 的 + // 「偏好设置」分区**搬到本区**,并撤掉那个分区)──────────────────────────── + // 走官方扩展点(**零官方改动**):`ctx.locale.getLocale()` 读、`setLocale(id)` 切。 + // ⚠️ 本分区渲染在 **DARK 主题** 下 ⇒ 颜色沿用本文件既有硬编码(v0.4.3 结论: + // 这里不能依赖 `--dsw-*`,其 fallback 会让文字与底色同色而看不见)。 + rows.push(jsxRuntime.jsxs("div", { + key: "lang", + style: { display: "flex", alignItems: "center", gap: "10px", flexWrap: "wrap" }, + children: [ + jsxRuntime.jsx("span", { + key: "lb", + style: { fontSize: "13px", lineHeight: "20px", color: "#c9cdd4" }, + children: tPe("lang") + }), + jsxRuntime.jsx("select", { + key: "sel", + "aria-label": tPe("lang"), + value: currentLocale(), + onChange: function (e) { + pickLocale(e.target.value); + // 官方 locale 的通知不会重渲染本组件 ⇒ 自己 bump 一次,让 select 立刻显示新值。 + if (typeof setTick === "function") setTick(function (n) { return n + 1; }); + }, + style: { + minWidth: "140px", padding: "6px 8px", borderRadius: "6px", cursor: "pointer", + border: "1px solid #43464d", background: "#1f2127", color: "#f9fafb", fontSize: "13px" + }, + children: LOCALES.map(function (l) { + return jsxRuntime.jsx("option", { value: l.id, children: l.name }, l.id); + }) + }), + jsxRuntime.jsx("span", { + key: "hint", + style: { fontSize: "12px", lineHeight: "18px", color: "#8b8f99" }, + children: tPe("langHint") + }) + ] + })); + + // 2026-09-13(用户要求):**去掉「打开管理台」入口** —— 平台管理已就地化到 + // 「平台管理」分区(见 @dsh-local/business-plugins),本区不再跳转门户。 + var actions = []; + actions.push(rowButton("logout", { color: "#ff4d4f", borderColor: "#ff4d4f" }, "退出登录", doLogout)); + rows.push(jsxRuntime.jsx("div", { + key: "actions", + style: { display: "flex", gap: "8px", flexWrap: "wrap" }, + children: actions + })); + + return jsxRuntime.jsx("div", { + style: { + display: "flex", + flexDirection: "column", + gap: "12px", + padding: "4px 2px", + color: "#f9fafb" + }, + children: rows + }); + } + + /** Services required by this client surface: the UI slot registry. */ + const inject = ["slots", "locale"]; + + /** Client-side apply: add the 用户管理 section to the settings panel. */ + function apply(ctx) { + try { + console.log("[portal-entry] apply RUN"); + hostCtx = ctx; + // 词典注册(官方扩展点)。失败不影响分区本身。 + try { + ctx.locale.register(PE_NS, PE_DICT); + } catch (e) { + console.warn("[portal-entry] locale.register 失败(本行文案退回自带字典):", e); + } + ctx.slots.inject("settings.section", () => ctx.slots.register( + { + name: "settings.section", + id: "user-settings", + order: 103, + label: () => "\u7528\u6237\u8bbe\u7f6e" + }, + UserManagementSection + )); + } catch (err) { + try { + console.warn("portal-entry: settings.section registration failed", err); + } catch (e) {} + } + } + + exports.apply = apply; + exports.inject = inject; + return module.exports; + } +}); +; diff --git a/poc/portal-entry/lib/index.js b/poc/portal-entry/lib/index.js new file mode 100644 index 0000000..f3eae77 --- /dev/null +++ b/poc/portal-entry/lib/index.js @@ -0,0 +1,101 @@ +// @dsh-local/portal-entry — PoC host plugin for the dsh web profile. +// +// Goal: prove the official profile-plugin mechanism on dsh 0.1.2-rc.1 +// (install -> load -> persist across restart -> remove), with zero risk to +// the running profile: every side effect is guarded, nothing can throw out +// of apply(). +// +// v1 evidence channel: an append-only marker file +// $DSH_HOME/.dsh-poc-portal-entry.log +// written when the row is instantiated. Tool registration (defineTool from +// @deepseek-ai/dsh-tools) is attempted defensively; failure is logged, never +// fatal. + +import { appendFileSync } from "node:fs"; +import { join } from "node:path"; + +const MARKER = ".dsh-poc-portal-entry.log"; +const PORTAL_ROOT = process.env.DSH_SERVER_LOGIN_URL ?? "http://127.0.0.1:3080"; + +/** Append one line to the marker file. Never throws. */ +function log(line) { + try { + const home = process.env.DSH_HOME || process.env.HOME || "."; + appendFileSync(join(home, MARKER), `${new Date().toISOString()} ${line}\n`); + } catch { + // marker writes must never take the profile down + } +} + +/** Build the portal_ping tool definition (lazy import of defineTool). */ +async function portalPingDefinition() { + const { defineTool } = await import("@deepseek-ai/dsh-tools"); + return defineTool({ + name: "portal_ping", + description: + "PoC tool: probe the dsh-server-login portal reachable from this instance. Returns HTTP status and body size of the portal root.", + parameters: {}, + output: { + schema: { + type: "object", + additionalProperties: false, + properties: { + ok: { type: "boolean", required: true }, + status: { type: "number" }, + bytes: { type: "number" }, + note: { type: "string" } + } + } + }, + isConcurrencySafe: () => true, + async execute() { + try { + const res = await fetch(PORTAL_ROOT, { redirect: "manual" }); + const body = await res.arrayBuffer(); + return { + ok: true, + status: res.status, + bytes: body.byteLength, + note: `portal root reachable from instance (${PORTAL_ROOT})` + }; + } catch (err) { + return { ok: false, note: `portal unreachable: ${String(err?.message ?? err)}` }; + } + } + }); +} + +/** Cordis-style plugin apply. Row declares inject:[tools], so ctx.tools is + * available; every failure is still contained and logged. */ +export function apply(ctx) { + log(`apply pid=${process.pid} dshHome=${process.env.DSH_HOME ?? ""} home=${process.env.HOME ?? ""}`); + + const register = () => { + try { + if (!ctx.tools || typeof ctx.tools.register !== "function") { + log("tools service unavailable at apply time"); + return; + } + portalPingDefinition() + .then((def) => { + try { + ctx.tools.register(def); + log("tool portal_ping registered"); + } catch (err) { + log(`tool register threw: ${String(err?.message ?? err)}`); + } + }) + .catch((err) => log(`tool build failed: ${String(err?.message ?? err)}`)); + } catch (err) { + log(`tools access failed: ${String(err?.message ?? err)}`); + } + }; + + try { + register(); + } catch (err) { + log(`register wrapper failed: ${String(err?.message ?? err)}`); + } +} + +export default apply; diff --git a/poc/portal-entry/package.json b/poc/portal-entry/package.json new file mode 100644 index 0000000..dc22985 --- /dev/null +++ b/poc/portal-entry/package.json @@ -0,0 +1,26 @@ +{ + "name": "@dsh-local/portal-entry", + "version": "0.5.5", + "description": "portal-entry plugin for dsh web profile — v0.5.5(2026-09-15):**修打包**(0.5.4 把上一版 tgz 打进了产物 ⇒ 补 .npmignore 并升号重发;pnpm 会按版本复用同名 tgz,所以必须升号)。|v0.5.4(2026-09-15):**语言偏好持久化**。官方 `dsh-client-locale` 只对 **loopback** 页面持久化到 `$DSH_HOME/settings.yaml`;平台是「浏览器经域名访问远程实例」⇒ 非 loopback ⇒ 官方只为当前进程保留选择、刷新即回默认英语。v0.5.4 在切换时**额外调平台** `POST /api/me/locale` ⇒ 平台把 `locale.preference` 写进用户 `settings.yaml`(官方读自己的设置文件即生效),既守住官方语义、又记住用户选择;持久化失败不影响本次切换(就地已生效)。|host boot marker + portal_ping tool + settings 「用户设置」section (id user-settings, order 103; role-aware: admins see account/role, everyone sees 界面语言 + 退出登录). v0.5.3 (2026-09-15 用户要求): 「界面语言」切换**搬进本区**(原在 business-plugins 的「偏好设置」分区,该分区已撤 —— 语言是用户级偏好,与账号/退出登录同区即可,另开分区属重复入口)。实现走官方扩展点(零官方改动):ctx.locale.getLocale()/setLocale(id) + ctx.locale.register(NS,{zh,en}),`inject` 加 \"locale\";颜色沿用本文件硬编码(本区在 DARK 主题下渲染,见 v0.4.3)。v0.5.0: section renamed from 平台管理, id platform→user-management, order 100→102 (below 功能插件=101), installed for ALL users (scripts/ensure-portal-entry.cjs), role probed via portal GET /api/auth/me with fail-closed fallback to the non-admin view. v0.5.1: repack ONLY — v0.5.0's tarball was built without lib/index.js (host half), which made every instance fail with ERR_MODULE_NOT_FOUND on @dsh-local/portal-entry/lib/index.js; version bumped because pnpm reuses a same-name/same-version tarball from its cache. v0.4.3 HARDCODES colors (text #f9fafb, btn border #43464d) because both --dsw-alias-label-primary and --dsw-alias-button-elevated-fill are defined as #f9fafb in the dark theme, so var() fallbacks never engaged.", + "type": "module", + "main": "lib/index.js", + "exports": { + ".": "./lib/index.js", + "./client": "./lib/client.js" + }, + "dsh": { + "bundle": { + "patch": "./cordis.patch.yml" + }, + "client": { + "platform": "web", + "inject": [ + "@deepseek-ai/dsh-client-ui-settings-general" + ] + } + }, + "dependencies": { + "@deepseek-ai/dsh-tools": "0.1.2-rc.1" + }, + "license": "MIT" +} diff --git a/scripts/compare-tree.py b/scripts/compare-tree.py new file mode 100644 index 0000000..93839ce --- /dev/null +++ b/scripts/compare-tree.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""compare-tree.py —— 把**本地 git 基线**(manifest:mode/type/sha\\tpath)与服务器工作树逐文件比对。 + +输出三类(这是"能不能整树覆盖"的唯一判据,不盲覆盖): + · 一致 :服务器文件内容 == 本地基线 + · 不一致 :两边都有但内容不同(**要逐个看是谁的改动**) + · 服务器独有:服务器有、基线里没有(**绝不能抹**) +再加一类「仅在基线里」:本地有、服务器缺(同步时要补上)。 + +用法(在服务器上):python3 scripts/compare-tree.py <工作树根> +""" +import os +import subprocess +import sys + +manifest, root = sys.argv[1], sys.argv[2] +entries = [] +for line in open(manifest, encoding='utf-8'): + line = line.rstrip('\n') + if not line.strip(): + continue + head, path = line.split('\t', 1) + mode, typ, sha = head.split() + entries.append((path, sha)) + +paths = [p for p, _ in entries] +present = [p for p in paths if os.path.isfile(os.path.join(root, p))] +missing = [p for p in paths if p not in set(present)] + +# 批量算 sha(git hash-object --stdin-paths) +hash_out = {} +if present: + proc = subprocess.run(['git', 'hash-object', '--stdin-paths'], cwd=root, + input='\n'.join(present) + '\n', capture_output=True, text=True) + for p, h in zip(present, proc.stdout.split('\n')): + if h.strip(): + hash_out[p] = h.strip() + +same, diff = [], [] +for p, sha in entries: + if p in missing: + continue + if hash_out.get(p) == sha: + same.append(p) + else: + diff.append((p, sha, hash_out.get(p))) + +# 服务器独有:扫同一批顶层目录,取基线里没有的文件 +tops = sorted({p.split('/')[0] for p, _ in entries}) +known = set(paths) +extra = [] +for top in tops: + base = os.path.join(root, top) + if not os.path.isdir(base): + continue + for dirpath, dirnames, filenames in os.walk(base): + dirnames[:] = [d for d in dirnames if d not in ('node_modules', '.git', 'dist')] + for fn in filenames: + rel = os.path.relpath(os.path.join(dirpath, fn), root).replace(os.sep, '/') + if rel not in known: + extra.append(rel) + +print('=== 汇总 ===') +print(' 基线条目 : %d' % len(entries)) +print(' 一致 : %d' % len(same)) +print(' 不一致 : %d' % len(diff)) +print(' 仅基线有(缺) : %d' % len(missing)) +print(' 服务器独有 : %d' % len(extra)) + +if diff: + print('\n=== 不一致(要逐个判断是谁的改动)===') + for p, want, got in diff[:60]: + print(' %-58s 基线 %s 服务器 %s' % (p, want[:10], (got or '')[:10])) + if len(diff) > 60: + print(' … 还有 %d 个' % (len(diff) - 60)) + +if missing: + print('\n=== 仅基线有(服务器缺)===') + for p in missing[:40]: + print(' ' + p) + if len(missing) > 40: + print(' … 还有 %d 个' % (len(missing) - 40)) + +if extra: + print('\n=== 服务器独有(⛔ 覆盖前必须保留/处理)===') + for p in extra[:60]: + print(' ' + p) + if len(extra) > 60: + print(' … 还有 %d 个' % (len(extra) - 60)) diff --git a/scripts/find-ui-scan.py b/scripts/find-ui-scan.py new file mode 100644 index 0000000..6f48516 --- /dev/null +++ b/scripts/find-ui-scan.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""find-ui-scan.py —— 在**服务器上**扫描「哪些包注册了实例 UI 分区」,输出 TSV。 + +由 `scripts/find-ui.cjs` 通过 `ssh python3 -` 喂进来执行(本文件不在服务器上落地)。 +为什么要落到服务器上跑:**权威事实是"活着的 profile 里装了什么"**,不是仓里的快照、 +也不是文档库里的历史副本(2026-09-15 就是在这上面绕了很久)。 + +输出 TSV(每行一个分区): + pkg origin file id order label_raw + +scan 顺序:**自研(@dsh-local)在前**,官方在后 —— 排查时先看自己的。 +""" +import glob +import json +import re +import sys + +PROFILE_GLOB = '/var/lib/dshs/users/*/home/profiles/web/node_modules/@dsh-local/*/lib/client.js' +OFFICIAL_GLOB = '/usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai/*/lib/client.js' + +# 注册对象形如: { name: "settings.section", id: "x", order: 100, label: … } +REG = re.compile(r'name:\s*"settings\.section"') +ID = re.compile(r'\bid:\s*"([^"]+)"') +ORDER = re.compile(r'\border:\s*(-?\d+)') +LABEL_LIT = re.compile(r'label:\s*(?:\([^)]*\)\s*=>\s*|function\s*\([^)]*\)\s*\{\s*return\s*)\s*"((?:[^"\\]|\\.)*)"') +LABEL_KEY = re.compile(r'label:\s*(?:\([^)]*\)\s*=>\s*|function\s*\([^)]*\)\s*\{\s*return\s*)\s*t\(\s*"([^"]+)"\s*\)') + + +def unique_files(pattern): + """同一 profile 内容一致 ⇒ 只取**第一个** profile,避免同一分区重复几十行。""" + hits = sorted(glob.glob(pattern)) + if not hits: + return [] + if 'users' in pattern: + # 取第一个用户目录下的全部 @dsh-local 包 + first_user = hits[0].split('/home/profiles/')[0] + return [h for h in hits if h.startswith(first_user + '/')] + return hits + + +def label_of(text, raw): + """label 是字面量 ⇒ 解码转义;是 t("key") ⇒ 在同文件的词典里找值。""" + m = LABEL_LIT.search(raw) + if m: + lit = m.group(1) + # 关键:`\u7528\u6237\u8bbe\u7f6e` 这类**转义 unicode** 要还原成人能看的字 + try: + return json.loads('"' + lit + '"') + except Exception: + return lit + m = LABEL_KEY.search(raw) + if m: + key = m.group(1) + d = re.search(r'"' + re.escape(key) + r'":\s*"((?:[^"\\]|\\.)*)"', text) + if d: + try: + return json.loads('"' + d.group(1) + '"') + except Exception: + return d.group(1) + return 't("%s")' % key + return '(无 label)' + + +def scan(files, origin): + for f in files: + try: + text = open(f, encoding='utf-8', errors='replace').read() + except Exception: + continue + if 'settings.section' not in text: + continue + # ⚠️ 用 removeprefix 而不是 lstrip:lstrip 是按**字符集**删,会把官方包的 "@deepseek" 也啃掉 + k = f.split('/node_modules/')[-1].split('/lib/client.js')[0] + pkg = k.removeprefix('@dsh-local/') + for m in REG.finditer(text): + raw = text[m.start():m.start() + 420] + i = ID.search(raw) + if not i: + continue + o = ORDER.search(raw) + print('\t'.join([ + pkg, origin, f, i.group(1), o.group(1) if o else '—', label_of(text, raw), + ])) + + +def main(): + mine = unique_files(PROFILE_GLOB) + if not mine: + sys.stderr.write('!! 没找到任何 profile 的 @dsh-local 包(集群模式下用户可能落别的 Worker)\n') + scan(mine, '自研') + scan(unique_files(OFFICIAL_GLOB), '官方') + + +if __name__ == '__main__': + main() diff --git a/scripts/find-ui.mjs b/scripts/find-ui.mjs new file mode 100644 index 0000000..e641cca --- /dev/null +++ b/scripts/find-ui.mjs @@ -0,0 +1,94 @@ +#!/usr/bin/env node +/** + * find-ui.mjs —— 「**这个 UI 分区是谁提供的、源码在哪、我能不能改**」一条命令问清楚。 + * + * ## 为什么有它(2026-09-15 实录) + * 为搞清设置面板里的「用户设置」是谁渲染的,我在 6 个官方包 + 3 个自研包之间逐个 grep, + * 绕了约 30 次工具调用。根因**不是**代码烂,而是三件事叠加: + * ① 那个 label 用**转义 unicode** 存(`"\u7528\u6237\u8bbe\u7f6e"`)⇒ 按 UTF-8 grep 永远 0 命中; + * ② `portal-entry` 的**源码不在仓里**(只有服务器上的 tgz)⇒ 只能从产物反推; + * ③ **没有"哪个包提供哪个 UI"的索引**,id/order/label 散在各包里。 + * 本脚本一次性解决 ①③(② 的处置:把源码补进 `poc//`,见 `07-实例UI分区登记表.md`)。 + * + * ## 用法 + * node scripts/find-ui.mjs # 列出全部实例 UI 分区(自研在前) + * node scripts/find-ui.mjs 用户设置 # 按关键词定位(**UTF-8 与转义形态都搜**) + * node scripts/find-ui.mjs locale --grep # 顺带打印命中行上下文 + * node scripts/find-ui.mjs --md # 输出 Markdown 表格行(用于刷新 `07-实例UI分区登记表.md`) + * DSHS_SSH=other-host node scripts/find-ui.mjs # 指定 ssh 目标(默认 bt-server) + * + * ## 判据(为什么这么做才对) + * · **权威事实 = 活着的 profile 里装了什么**,不是仓里快照、不是文档库历史副本、不是备份目录; + * · 标签必须**两种形态都搜**(字面量 + `\uXXXX`),否则会像 2026-09-15 那样全域 0 命中; + * · 「能不能改」= 包在 `@dsh-local/`(我们自己的,可改)还是在 `@deepseek-ai/`(官方,R2 不可改)。 + */ +import { readFileSync } from 'node:fs' +import { spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import { dirname, join } from 'node:path' + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..') +const SSH = process.env.DSHS_SSH ?? 'bt-server' + +const argv = process.argv.slice(2) +const asMd = argv.includes('--md') +const withGrep = argv.includes('--grep') +const keyword = argv.find((a) => !a.startsWith('--')) ?? '' + +// ── 1. 把扫描器喂给服务器执行(脚本不落地在服务器上)── +const scanner = readFileSync(join(ROOT, 'scripts', 'find-ui-scan.py'), 'utf8') +const r = spawnSync( + 'ssh', + ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=15', '-o', 'StrictHostKeyChecking=accept-new', SSH, 'python3 -'], + { input: scanner, encoding: 'utf8', maxBuffer: 1 << 24 }, +) +if (r.status !== 0 && (r.stdout ?? '') === '') { + console.error('!! 远程扫描失败:' + (r.stderr || '').split('\n').filter((l) => !/post-quantum|store now|openssh/.test(l)).join('\n').slice(0, 600)) + process.exit(1) +} +const rows = (r.stdout ?? '') + .split('\n') + .filter((l) => l.includes('\t')) + .map((l) => { + const [pkg, origin, file, id, order, label] = l.split('\t') + return { pkg, origin, file, id, order, label } + }) + +// ── 2. 关键词过滤:**字面量 + 转义形态都搜**(本脚本存在的理由之一)── +const escapeOf = (s) => + Array.from(s).map((c) => (c.codePointAt(0) > 127 ? '\\u' + c.codePointAt(0).toString(16).padStart(4, '0') : c)).join('') +const kwEsc = escapeOf(keyword).toLowerCase() +const hit = (v) => { + const s = String(v ?? '').toLowerCase() + return s.includes(keyword.toLowerCase()) || (kwEsc !== keyword.toLowerCase() && s.includes(kwEsc)) +} +const shown = keyword === '' ? rows : rows.filter((x) => hit(x.id) || hit(x.label) || hit(x.pkg)) + +// ── 3. 输出 ── +if (asMd) { + console.log('| order | id | label | 提供者 | 来源 |') + console.log('|---|---|---|---|---|') + for (const x of shown) console.log(`| ${x.order} | \`${x.id}\` | ${x.label} | \`${x.pkg}\` | ${x.origin} |`) +} else if (shown.length === 0) { + console.log(`没有匹配「${keyword}」的分区。共扫到 ${rows.length} 个分区:`) + for (const x of rows) console.log(` · ${x.order.toString().padStart(4)} ${x.id} [${x.label}] ${x.pkg} (${x.origin})`) + console.log('\n提示:关键词会同时按 UTF-8 与 \\uXXXX 转义搜;仍找不到 ⇒ 该文案可能不在「分区」层,去 06/07 看。') +} else { + console.log(`命中 ${shown.length} / 共 ${rows.length} 个实例 UI 分区${keyword ? `(关键词「${keyword}」)` : ''}:\n`) + for (const x of shown) { + console.log(`● ${x.label} [id=${x.id} order=${x.order}]`) + console.log(` 提供者:${x.pkg} (${x.origin} ⇒ ${x.origin === '自研' ? '✅ 可改' : '⛔ 官方,R2 不可改'})`) + console.log(` 源码 :${x.file}`) + if (withGrep) { + const g = spawnSync('ssh', ['-o', 'BatchMode=yes', SSH, `grep -n "settings.section" -A 6 "${x.file}" | head -12`], { encoding: 'utf8' }) + console.log((g.stdout ?? '').split('\n').map((l) => ' | ' + l).join('\n')) + } + } + const mine = shown.filter((x) => x.origin === '自研') + if (mine.length > 0) { + console.log('\n下一步:') + console.log(' · 自研包 ⇒ **源码应在仓内 `poc//`**;不在 ⇒ 先把部署中的版本取出来入仓(约定:产物只从仓内源码构建)。') + console.log(' · 改完跑 `npm run verify`(含 verify-my-skills / verify-portal-entry 等结构断言),再走投放。') + console.log(' · ⛔ 改分区 ⇒ **同一次改动里刷新 `dsh-server-docs/07-实例UI分区登记表.md`**(可用 `--md` 生成表行)。') + } +} diff --git a/scripts/verify-my-skills.mjs b/scripts/verify-my-skills.mjs index f0f3372..92860b1 100644 --- a/scripts/verify-my-skills.mjs +++ b/scripts/verify-my-skills.mjs @@ -89,6 +89,34 @@ ok('prefers-reduced-motion 覆盖了 .bp-skillRow', /prefers-reduced-motion[^}]* ok('en 的 msk.hint 写明 no instance restart', all.length === 2 && /no instance restart/i.test(all[1])) } +// ── ⑧ 分区改名 + 页内 tab 分页 + 卡片三行(v0.3.22 · 2026-09-15 用户三项要求)── +// ① 「功能管理」→「能力管理」② 按 tab 切插件/技能各自操作 ③ 插件卡片中文用途描述 3 行 +ok('zh 分区名已改为「能力管理」', /"section\.label": "能力管理"/.test(src)) +ok('en 分区名已改为 Capability management', /"section\.label": "Capability management"/.test(src)) +ok('旧名「功能管理」不再是任何 label', /"section\.label": "功能管理"/.test(src) === false) +ok('tab 标签词条存在(cap.tabPlugins)', /"cap\.tabPlugins": "功能插件"/.test(src) && /"cap\.tabPlugins": "Feature plugins"/.test(src)) + +ok('顶部有 tab 条(复用 .pa-tabs + role=tablist)', /className: "pa-tabs",\s*\n\s*role: "tablist"/.test(src)) +ok('两个页签(插件 / 技能)都渲染', /t\("cap\.tabPlugins"\)/.test(src) && /t\("msk\.group"\)/.test(src)) +ok('页签带计数(.pa-tcnt)', /className: "pa-tcnt"/.test(src)) +ok('页签有 aria-selected(可访问性)', /"aria-selected": isPlugins \? "true" : "false"/.test(src)) +ok('两个页签各自可切换', /setTab\("plugins"\)/.test(src) && /setTab\("skills"\)/.test(src)) +ok('插件块被 isPlugins 门控', /if \(isPlugins\) \{/.test(src)) +ok('技能块被 !isPlugins 门控', /if \(!isPlugins\) \{/.test(src)) +{ + // 三个区段(插件主体 / 技能主体 / 插件专属弹窗与提示 / 技能专属弹窗)都要有开有闭 + const opens = (src.match(/if \(isPlugins\) \{/g) || []).length + const notOpens = (src.match(/if \(!isPlugins\) \{/g) || []).length + ok('门控数量成对(插件 2 处 / 技能 2 处)', opens === 2 && notOpens === 2, `-> isPlugins ${opens} / !isPlugins ${notOpens}`) +} + +ok('插件卡片用途描述用 .bp-plugDesc', /className: "bp-plugDesc"/.test(src)) +ok('.bp-plugDesc 有 -webkit-line-clamp:3', cssHas('bp-plugDesc', '-webkit-line-clamp:3')) +ok('.bp-plugDesc 有显式 white-space:normal(否则继承单行截断)', cssHas('bp-plugDesc', 'white-space:normal')) +ok('.bp-plugDesc 有 overflow:hidden', cssHas('bp-plugDesc', 'overflow:hidden')) +ok('卡片描述仍挂 title(hover 看全文)', /className: "bp-plugDesc",[\s\S]{0,200}title: primary/.test(src)) +ok('.bp-tabHead 存在(技能页工具行:左说明 / 右上传)', cssHas('bp-tabHead', 'justify-content:space-between')) + // ── ⑦ 不得引用官方包/不得写 exports.default(R2 / R3 的机械兜底)── // ⚠️ 只断言**赋值形态**:本文件头部注释里就写着「绝不 exports.default」这句话, // 用 `includes()` 会被自己的注释判红(首跑实测踩到)。 diff --git a/scripts/verify-platform-admin-section.mjs b/scripts/verify-platform-admin-section.mjs index 4010060..d898f19 100644 --- a/scripts/verify-platform-admin-section.mjs +++ b/scripts/verify-platform-admin-section.mjs @@ -183,9 +183,10 @@ function clsAll(tree) { } // ── 注册与门禁 ──────────────────────────────────────────────────────────────── -// 档案 87 起 = 3 个:模型设置(全角色)+ 功能管理(全角色)+ 系统管理(仅 admin) +// 档案 87 起 = 3 个:模型设置(全角色)+ 能力管理(全角色,原「功能管理」)+ 系统管理(仅 admin) // 档案 81 · R5(2026-09-15)新增「偏好设置」= 语言切换(全角色,order 99)⇒ 共 **4** 个 -ok("admin 视角下注册了四个 settings.section", regs.length === 4, "-> " + regs.map(r => r.meta.id).join(", ")); +// 2026-09-15:「偏好设置」撤除 ⇒ admin 侧 4 → 3(模型设置 + 能力管理 + 系统管理) +ok("admin 视角下注册了三个 settings.section", regs.length === 3, "-> " + regs.map(r => r.meta.id).join(", ")); const pa = regs.find(r => r.meta.id === "platform-admin"); ok("存在 platform-admin 分区", !!pa); if (pa) ok("其 order = 102", pa.meta.order === 102); @@ -213,30 +214,24 @@ ok("非 admin:不出现任何功能项", !PORTAL_ITEMS.some(k => tUser.include mod.apply(ctx); await new Promise((r) => setTimeout(r, 80)); const ids = regs.map(r => r.meta.id).sort(); - ok("非 admin:注册 3 个分区(偏好设置 + 功能管理 + 模型设置;不含系统管理)", - ids.length === 3 && ids[0] === "business-plugins" && ids[1] === "model-settings" && ids[2] === "preferences", + // 2026-09-15:「偏好设置」分区已撤除(语言切换搬到 portal-entry 的「用户设置」)⇒ 3 → 2 + ok("非 admin:注册 2 个分区(能力管理 + 模型设置;不含系统管理,也不再有偏好设置)", + ids.length === 2 && ids[0] === "business-plugins" && ids[1] === "model-settings", "-> " + ids.join(", ")); regs = []; ROLE = "admin"; mod.apply(ctx); await new Promise((r) => setTimeout(r, 80)); } -// ── 档案 81 · R5(2026-09-15):「偏好设置」= 语言切换(全角色,走官方 locale)────── -// 口径:默认英语(官方 FALLBACK_LOCALE="en");切换走 ctx.locale.setLocale(id), -// 语言 id 必须与官方 LOCALE_IDS 一致(en / zh)。本断言同时充当「该栏真能渲染」的冒烟。 +// ── 2026-09-15(用户要求):**「偏好设置」分区已撤除** ──────────────────────────── +// 语言切换搬到 `@dsh-local/portal-entry` 的「用户设置」分区(该 bundle 的断言见 +// `scripts/verify-portal-entry.mjs`)。这里只断言"它真的没了"—— 防止将来又把重复入口加回来。 { regs = []; ROLE = "active"; mod.apply(ctx); await new Promise((r) => setTimeout(r, 80)); - const pref = regs.find(r => r.meta.id === "preferences"); - ok("存在 preferences 分区(语言切换)", !!pref); - if (pref) { - ok("其 order = 99(排在模型设置之前)", pref.meta.order === 99, "-> " + pref.meta.order); - const tPref = text(await render(pref.Comp)).join(" | "); - ok("偏好设置:含「界面语言」标签 + 英文/中文两个语言项", - tPref.includes("界面语言") && tPref.includes("English") && tPref.includes("中文"), - "-> " + tPref.slice(0, 140)); - } + ok("preferences 分区已撤除(不再注册)", regs.every(r => r.meta.id !== "preferences"), + "-> " + regs.map(r => r.meta.id).join(", ")); regs = []; ROLE = "admin"; mod.apply(ctx); await new Promise((r) => setTimeout(r, 80)); @@ -247,15 +242,18 @@ ok("非 admin:不出现任何功能项", !PORTAL_ITEMS.some(k => tUser.include const ms = regs.find(r => r.meta.id === "model-settings"); ok("存在 model-settings 分区", !!ms); if (ms) { - ok("其 order = 100(排在功能管理之前)", ms.meta.order === 100, "-> " + ms.meta.order); + ok("其 order = 100(排在能力管理之前)", ms.meta.order === 100, "-> " + ms.meta.order); const tMs = text(await render(ms.Comp)).join(" | "); ok("模型设置:标题与副标题", tMs.includes("模型设置") && tMs.includes("模型选择器")); // 口径②:共享模型也列入且可开关 ok("模型设置:平台共享模型区块 + 开关按钮", tMs.includes("平台共享模型") && tMs.includes("停用共享模型")); // 口径①:条目各自开关 ⇒ 要有「启用/停用」而不是「设为当前」 ok("模型设置:条目按各自状态渲染徽章(已启用 / 已停用)", tMs.includes("已启用") && tMs.includes("已停用")); - ok("模型设置:内置条目显示为「内置 DeepSeek」、自定义条目显示其 route", - tMs.includes("内置 DeepSeek") && tMs.includes("my-gw")); + // 2026-09-15 用户口径:「内置 DeepSeek」**去掉「内置」**,就叫「DeepSeek」。 + // 顺带断言那个误导性的「(平台共享)」后缀不再出现 —— 该选项走内置通道,但仍**要填 + // 用户自己的 API 密钥**,写成「平台共享」会让人以为不必填 key。 + ok("模型设置:条目名显示为「DeepSeek」(不再有「内置 DeepSeek」)", + tMs.includes("DeepSeek") && !tMs.includes("内置 DeepSeek") && tMs.includes("my-gw")); ok("模型设置:自定义条目副行显示 endpoint · 协议", tMs.includes("https://api.example.com/v1") && tMs.includes("openai-completions")); // ── 档案 91(2026-09-14):「新增」改官方**两步式** ────────────────────────── diff --git a/scripts/verify-portal-entry.mjs b/scripts/verify-portal-entry.mjs new file mode 100644 index 0000000..f5c3701 --- /dev/null +++ b/scripts/verify-portal-entry.mjs @@ -0,0 +1,88 @@ +#!/usr/bin/env node +/** + * verify-portal-entry.mjs —— `@dsh-local/portal-entry` 客户端面的**结构防线**(档案 102) + * + * 为什么需要(三条都真踩过): + * ① **v0.5.1 事故**:打出来的 tgz **漏了 `lib/index.js`(host 半边)** ⇒ 每个实例 + * `ERR_MODULE_NOT_FOUND` 起不来。⇒ 本脚本把"两个半边文件都在"钉成断言。 + * ② **v0.4.1 事故 / R3**:`exports.default = apply` 会让 loader 选中裸函数当插件体 ⇒ + * `ctx.slots` 抛「cannot get property slots without inject」⇒ 分区**永不渲染**(静默)。 + * ③ **2026-09-15 变更**:「界面语言」切换搬进本区(原在 business-plugins 的「偏好设置」分区, + * 该分区已撤)⇒ 需要钉住"语言行确实在、且真的调官方 locale API"。 + * + * ⚠️ **label 存的是转义 unicode**(`"\u7528\u6237\u8bbe\u7f6e"` = 用户设置)—— 这是本 bundle 的 + * 既有写法,本脚本按**转义形态**断言(按 UTF-8 字面量 grep 会永远搜不到,2026-09-15 我为此 + * 绕了很久)。 + * + * 用法:node scripts/verify-portal-entry.mjs 退出码 0=全绿 / 1=有失败 + */ +import { readFileSync, existsSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { dirname, join } from 'node:path' + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..') +const P = 'poc/portal-entry' +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++ +} + +// ── ① 源码必须在仓里(2026-09-15 之前不在 ⇒ 只能从 tgz 反推源码,排查成本极高)── +ok('源码在仓内(poc/portal-entry/lib/client.js)', existsSync(join(ROOT, P, 'lib', 'client.js'))) +ok('host 半边也在(lib/index.js —— v0.5.1 事故的防线)', existsSync(join(ROOT, P, 'lib', 'index.js'))) +ok('package.json 在', existsSync(join(ROOT, P, 'package.json'))) +ok('cordis.patch.yml 在(bundle patch 必需)', existsSync(join(ROOT, P, 'cordis.patch.yml'))) + +const src = read(P + '/lib/client.js') +const pkg = JSON.parse(read(P + '/package.json')) + +// ── ② 版本与元数据 ── +// ⚠️ 0.5.4 事故:目录里上一版的 `.tgz` 被一并打进产物(package/-0.5.3.tgz)—— 与 +// business-plugins 当年 0.2.5 完全同一个坑。防线:`.npmignore` 必须在且排除 `*.tgz`。 +ok('.npmignore 存在', existsSync(join(ROOT, P, '.npmignore'))) +ok('.npmignore 排除 *.tgz(防"旧产物打进新包"复发)', + existsSync(join(ROOT, P, '.npmignore')) && read(P + '/.npmignore').includes('*.tgz')) + +ok('version ≥ 0.5.3(语言行引入版)', /^0\.5\.[3-9]$/.test(pkg.version) || /^0\.[6-9]/.test(pkg.version), '-> ' + pkg.version) +ok('client bundle 元数据在(dsh.client.platform = web)', pkg.dsh && pkg.dsh.client && pkg.dsh.client.platform === 'web') + +// ── ③ 分区:id user-settings / order 103 / label = 用户设置(转义形态)── +ok('注册 settings.section', src.includes('settings.section')) +ok('section id = user-settings', src.includes('id: "user-settings"')) +ok('order = 103', /order:\s*103/.test(src)) +ok('label = 用户设置(转义 unicode \\u7528\\u6237\\u8bbe\\u7f6e)', src.includes('\\u7528\\u6237\\u8bbe\\u7f6e')) + +// ── ④ 语言切换(2026-09-15 搬入)── +ok('inject 里要了 locale 服务', /const inject = \["slots", "locale"\]/.test(src)) +ok('读当前语言走官方 ctx.locale.getLocale()', /hostCtx\.locale\.getLocale\(\)/.test(src)) +ok('切语言走官方 ctx.locale.setLocale()', /hostCtx\.locale\.setLocale\(id\)/.test(src)) +ok('注册了自己的双语词典(ctx.locale.register)', /locale\.register\(PE_NS, PE_DICT\)/.test(src)) +ok('语言项 en / zh 都在', src.includes('{ id: "en"') && src.includes('{ id: "zh"')) +ok('渲染了 select(界面语言)', src.includes('"aria-label": tPe("lang")')) +ok('切换后强制重渲染(setTick)', /setTick\(function \(n\)/.test(src)) +{ + // 语言行必须真在 rows 里(否则写了不渲染 = 用户看不到)。 + // ⚠️ 断言方向:代码是 `rows.push(jsxRuntime.jsxs("div", { key: "lang", …` ⇒ + // `rows.push` 在 `key: "lang"` **之前**(首跑按"之后"判,误红一次)。 + const at = src.indexOf('key: "lang"') + ok('语言行确实 push 进 rows', at > 0 && src.slice(Math.max(0, at - 400), at).includes('rows.push'), '-> offset ' + at) +} +// ── ④b 语言偏好持久化(v0.5.4):调平台把选择写进 settings.yaml ── +// 官方只对 loopback 持久化 ⇒ 平台侧补一刀;断言"确实调了",防止将来被误删成"切了记不住"。 +ok('切换时调平台持久化(POST /api/me/locale)', /\/api\/me\/locale/.test(src)) +ok('持久化请求带 locale 字段', /JSON\.stringify\(\{ locale: id \}\)/.test(src)) +ok('持久化用 credentials: "include"(跨子域带会话)', /credentials: "include"/.test(src)) +ok('持久化失败不影响本次切换(catch 兜底)', /\.catch\(function \(e\) \{ console\.warn\("\[portal-entry\] 语言偏好持久化失败/.test(src)) + +ok('颜色硬编码(本区在 DARK 主题,v0.4.3 结论:不可用 var(--dsw-*))', + src.includes('#f9fafb') && src.includes('#43464d') && !/var\(--dsw-alias-label-primary/.test(src)) + +// ── ⑤ R3:不得出现 exports.default 赋值(v0.4.1 事故的根因)── +ok('未出现 exports.default 赋值(R3)', /(?:^|\n)\s*(?:module\.)?exports\.default\s*=/.test(src) === false) +ok('导出 apply + inject', /exports\.apply = apply/.test(src) && /exports\.inject = inject/.test(src)) + +console.log(failed === 0 ? '\n全部通过(portal-entry:结构 + 语言行 + R3 防线)' : `\n${failed} 项失败`) +process.exit(failed === 0 ? 0 : 1) diff --git a/src/web/home-files.ts b/src/web/home-files.ts new file mode 100644 index 0000000..b1d89fa --- /dev/null +++ b/src/web/home-files.ts @@ -0,0 +1,53 @@ +/** + * 用户 home 下配置文件的读写(`settings.yaml` / `.credentials.yaml`)。 + * + * **为什么单独成模块**:这段逻辑原先**内联在 `server.ts` 的闭包里**,只有"模型落地"那一条路径能用; + * 2026-09-15 加"语言偏好持久化"(`/api/me/locale`)时需要**同一套**语义 —— 与其复制一份 + * (两份实现迟早漂),不如抽出来共用(R11:同一事实只有一处)。 + * + * ⚠️ **`writeHomeFile` 里那两步都不能省**(都是从事故里换来的): + * ① **先备份到平台目录**(`DSH_PLATFORM_BACKUP_DIR`,默认 `/opt/dsh/backups`) + * —— ⛔ 不能备份进用户 home:那是 dsh 的 watch 域,放进去的文件会被扫; + * ② **写完 chown 给 home 属主** —— 实例以 `dsh-` 身份运行,root 写的 0600 文件它**读不了** + * ⇒ 漏掉这步就是"配置写了但实例死活读不到"(档案 43 / R10 同族)。 + * + * @module dshs/web/home-files + */ + +import { basename, dirname, join } from 'node:path' +import { writeFileSync } from 'node:fs' +import { chown, mkdir, readFile, stat, writeFile } from 'node:fs/promises' + +/** 读文本,文件不存在 / 读不动 ⇒ 空串(调用方按"从零建文档"处理)。 */ +export async function readTextOrEmpty(file: string): Promise { + try { + return await readFile(file, 'utf8') + } catch { + return '' + } +} + +/** + * 写 home 里的配置文件:备份(落**平台目录**)→ 写 → chown 给 home 属主。 + * 实例以 dsh- 身份运行,root 写的 600 文件它读不了 ⇒ 最后一步不能省。 + */ +export async function writeHomeFile(homeDir: string, file: string, text: string): Promise { + try { + const bakDir = process.env.DSH_PLATFORM_BACKUP_DIR ?? '/opt/dsh/backups' + await mkdir(bakDir, { recursive: true }) + const label = basename(file).replace(/^\./, '').replace(/\.ya?ml$/, '') + // ⚠️ 带上 home 的**父目录名**(= 用户 id):只写 basename 的话每个人都是 "home", + // 备份文件互相看不出是谁的(旧实现就是这个毛病:credentials-home-*.yaml)。 + const who = basename(dirname(homeDir)) + writeFileSync(join(bakDir, `${label}-${who}-${Date.now()}.yaml`), text, { mode: 0o600 }) + } catch { + /* 备份失败不阻断 */ + } + await writeFile(file, text, { mode: 0o600 }) + try { + const st = await stat(homeDir) + await chown(file, st.uid, st.gid) + } catch { + /* chown 失败(非 root 运行等)不阻断 */ + } +} diff --git a/src/web/locale-pref.ts b/src/web/locale-pref.ts new file mode 100644 index 0000000..bec5cbf --- /dev/null +++ b/src/web/locale-pref.ts @@ -0,0 +1,79 @@ +/** + * 语言偏好持久化 —— 把用户在实例里选的界面语言**记住**。 + * + * ## 为什么需要它(2026-09-15) + * 官方 `dsh-client-locale` README 原文:语言选择立即生效,但**只有 loopback 页面**会把选择 + * 持久化到 `$DSH_HOME/settings.yaml`;**非 loopback 页面只为当前进程保留**。 + * 平台是"浏览器经域名访问远程实例" ⇒ **属非 loopback** ⇒ 用户切完语言、一刷新就回默认英语。 + * + * ## 最优方案 = **A(守官方语义)+ B(记住选择)同时成立** + * 不是"另造一套语言状态",而是**替官方把它的设置写进它自己的文件**: + * `settings.yaml` 顶层 `locale:` → `preference: `(键名取自官方 locale 包的 settings schema, + * 已核对:该包 host 半边的 schema 用的就是 `locale` / `preference`)。 + * ⇒ 官方运行时启动时读自己的设置文件即生效(A 成立),用户的选择跨页面 / 跨重启保留(B 成立)。 + * ⇒ **零官方改动**、不新增平台侧语言状态(单一来源仍是官方 settings.yaml)。 + * + * 与 `model-landing.ts` 的关系:同一个文件、同一套"按行对账"的写法(不整份 YAML 解析, + * 避免把注释 / 顺序 / 未知字段写坏),并且**只动自己那两行**。 + * + * @module dshs/web/locale-pref + */ + +/** 官方 `dsh-client-locale` 的 `LOCALE_IDS`(`en` 是它的 FALLBACK,即默认英语)。 */ +export const LOCALE_IDS = ['en', 'zh'] as const +export type LocaleId = (typeof LOCALE_IDS)[number] + +export function isLocaleId(value: unknown): value is LocaleId { + return typeof value === 'string' && (LOCALE_IDS as readonly string[]).includes(value) +} + +/** 顶层块名与缩进(官方 schema:顶层 `locale:`,其下 2 空格 `preference:`)。 */ +const BLOCK = 'locale' +const INDENT = ' ' +const KEY = 'preference' + +/** + * 把 `preference: ` 对账进 `settings.yaml` 文本。 + * + * 行为(全部按"只碰自己那两行"设计): + * · 已有顶层 `locale:` 块 + 其下 `preference:` ⇒ **原地替换值**; + * · 已有顶层 `locale:` 块但**没有** `preference:` ⇒ 紧跟块头插入一行; + * · 没有 `locale:` 块 ⇒ 追加 `locale:\n preference: \n`; + * · 值已等于目标 ⇒ `changed: false`(幂等,调用方可据此跳过写盘)。 + * + * ⛔ **不整份解析 YAML**:这一文件里还有别的插件写的块与用户注释,解析再回写会把它们写坏 + * (`model-landing.ts` 头注释里记着同类教训)。 + */ +export function reconcileLocalePreference(text: string, preference: LocaleId): { text: string; changed: boolean } { + const eol = text.includes('\r\n') ? '\r\n' : '\n' + const lines = text === '' ? [] : text.split(/\r?\n/) + const want = `${INDENT}${KEY}: ${preference}` + + const isBlockHead = (l: string): boolean => new RegExp(`^${BLOCK}\\s*:\\s*(#.*)?$`).test(l) + + for (let i = 0; i < lines.length; i++) { + if (!isBlockHead(lines[i]!)) continue + // 块内找 preference(缩进 ≥ 2 且不是更深层嵌套) + for (let j = i + 1; j < lines.length; j++) { + const l = lines[j]! + if (l.trim() === '' || l.trimStart().startsWith('#')) continue + // 出了本块(缩进 0 的新键 / 更深层缩进的子块)⇒ 停 + if (!/^\s/.test(l)) break + const m = new RegExp(`^(\\s+)${KEY}\\s*:\\s*(.*)$`).exec(l) + if (m) { + const cur = m[2]!.trim().replace(/^["']|["']$/g, '') + if (cur === preference) return { text, changed: false } + lines[j] = `${INDENT}${KEY}: ${preference}` + return { text: lines.join(eol), changed: true } + } + } + // 块头存在但没有 preference ⇒ 紧跟其后插入 + lines.splice(i + 1, 0, want) + return { text: lines.join(eol), changed: true } + } + + // 没有 locale 块 ⇒ 追加(去掉尾部空行再加,保持文档整洁) + while (lines.length > 0 && lines[lines.length - 1]!.trim() === '') lines.pop() + lines.push(`${BLOCK}:`, want) + return { text: lines.join(eol) + eol, changed: true } +} diff --git a/src/web/routes/auth.ts b/src/web/routes/auth.ts index e09b9e9..6873c2c 100644 --- a/src/web/routes/auth.ts +++ b/src/web/routes/auth.ts @@ -12,6 +12,9 @@ import { deriveKey, encrypt } from '../../crypto.js' import { toPublicUser } from '../../db/types.js' import { catalogDiagnostics, isCatalogProvider, isCnProvider, listCatalogProviders } from '../model-catalog.js' import { PROTOCOLS } from '../model-landing.js' +import { readTextOrEmpty, writeHomeFile } from '../home-files.js' +import { isLocaleId, reconcileLocalePreference } from '../locale-pref.js' +import { join } from 'node:path' import { clearSessionCookie, hashPassword, @@ -22,6 +25,15 @@ import { verifyPassword, } from '../auth.js' +const localeSchema = { + body: { + type: 'object', + required: ['locale'], + additionalProperties: false, + properties: { locale: { type: 'string', minLength: 2, maxLength: 8 } }, + }, +} as const + const registerSchema = { body: { type: 'object', @@ -345,6 +357,26 @@ export const authRoutes: FastifyPluginAsync = async (app) => { * @deprecated 档案 87 起语义已变成"启用这一个、**不关**别的"(与 `toggle(true)` 同义)。 * 保留路由只为老客户端不 404;新前端不该再用它。 */ + /** + * 语言偏好持久化(2026-09-15,档案 102)—— 把用户在实例「用户设置」里选的语言**记住**。 + * + * 为什么需要:官方 `dsh-client-locale` 只对 **loopback** 页面持久化到 `settings.yaml`;平台是 + * 「浏览器经域名访问远程实例」⇒ 非 loopback ⇒ 官方只为当前进程保留选择,刷新即回默认。 + * 本路由**替官方把它自己的设置写进它自己的文件**(顶层 `locale: → preference:`), + * ⇒ 官方语义不破(实例启动时读自己的设置即生效)+ 用户选择跨页面 / 跨重启保留。 + * ⚠️ 写文件沿用 `model-landing` 那套(备份到平台目录 + chown 给 home 属主),见 `home-files.ts`。 + */ + app.post('/api/me/locale', { preHandler: requireAuth, schema: localeSchema }, async (request, reply) => { + const { locale } = request.body as { locale: string } + if (!isLocaleId(locale)) return reply.code(400).send({ error: 'invalid_locale' }) + const homeDir = homeRoot(userRoot(app.config.dataRoot, request.user!.id)) + const file = join(homeDir, 'settings.yaml') + const text = await readTextOrEmpty(file) + const next = reconcileLocalePreference(text, locale) + if (next.changed) await writeHomeFile(homeDir, file, next.text) + return { ok: true, locale, changed: next.changed } + }) + app.post('/api/me/keys/:id/select', { preHandler: requireAuth }, async (request, reply) => { const { id } = request.params as { id: string } if (!(await app.db.selectCredentialKey(request.user!.id, id))) return reply.code(404).send({ error: 'not_found' }) diff --git a/src/web/server.ts b/src/web/server.ts index 72203fe..64da356 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -32,6 +32,7 @@ import { refForEntry, type SettingsEntry, } from './model-landing.js' +import { readTextOrEmpty, writeHomeFile } from './home-files.js' import { rateLimit } from './middleware/rate-limit.js' import { authRoutes } from './routes/auth.js' import { adminRoutes } from './routes/admin.js' @@ -120,37 +121,8 @@ export async function buildServer(config: ServerConfig): Promise => { - try { - return await readFile(file, 'utf8') - } catch { - return '' - } - } - /** - * 写 home 里的配置文件:备份(落**平台目录**)→ 写 → chown 给 home 属主。 - * 实例以 dsh- 身份运行,root 写的 600 文件它读不了 ⇒ 最后一步不能省。 - */ - const writeHomeFile = async (homeDir: string, file: string, text: string): Promise => { - try { - const bakDir = process.env.DSH_PLATFORM_BACKUP_DIR ?? '/opt/dsh/backups' - await mkdir(bakDir, { recursive: true }) - const label = basename(file).replace(/^\./, '').replace(/\.ya?ml$/, '') - // ⚠️ 带上 home 的**父目录名**(= 用户 id):只写 basename 的话每个人都是 "home", - // 备份文件互相看不出是谁的(旧实现就是这个毛病:credentials-home-*.yaml)。 - const who = basename(dirname(homeDir)) - writeFileSync(join(bakDir, `${label}-${who}-${Date.now()}.yaml`), text, { mode: 0o600 }) - } catch { - /* 备份失败不阻断 */ - } - await writeFile(file, text, { mode: 0o600 }) - try { - const st = await stat(homeDir) - await chown(file, st.uid, st.gid) - } catch { - /* chown 失败(非 root 运行等)不阻断 */ - } - } + // `readTextOrEmpty` / `writeHomeFile` 已抽到 `./home-files.js`(2026-09-15:语言偏好 + // 持久化也要用同一套「写 home 文件」语义 —— 与其复制一份,不如共用;约束见该模块头注释)。 /** 平台共享条目(admin 配的、已启用的那些)—— 只在该用户开关打开、且他不是那个 admin 时纳入。 */ const sharedLandingRows = async (userId: string): Promise => { diff --git a/test/locale-pref.test.mjs b/test/locale-pref.test.mjs new file mode 100644 index 0000000..b7d30f3 --- /dev/null +++ b/test/locale-pref.test.mjs @@ -0,0 +1,79 @@ +/** + * `src/web/locale-pref.ts` —— 语言偏好写进 `settings.yaml` 的**按行对账**逻辑。 + * + * 为什么专门测它:这个函数**直接改用户的 `settings.yaml`** —— 写坏了 dsh 会拒绝加载整个文档 + * (用户实例直接起不来)。所以这里钉的是三件事: + * · 只碰 `locale.preference` 那两行,**其它块 / 注释 / 顺序一律原样保留**; + * · 幂等(值相同 ⇒ `changed: false`,调用方据此跳过写盘); + * · 行尾风格跟着原文件走(CRLF 文件不会被写成混合行尾)。 + */ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { LOCALE_IDS, isLocaleId, reconcileLocalePreference } from '../lib/web/locale-pref.js' + +test('LOCALE_IDS 与官方 dsh-client-locale 一致(en 是官方 FALLBACK)', () => { + assert.deepEqual([...LOCALE_IDS], ['en', 'zh']) + assert.equal(isLocaleId('en'), true) + assert.equal(isLocaleId('zh'), true) + assert.equal(isLocaleId('ja'), false) + assert.equal(isLocaleId(''), false) + assert.equal(isLocaleId(undefined), false) +}) + +test('空文档 ⇒ 建出 locale 块', () => { + const r = reconcileLocalePreference('', 'zh') + assert.equal(r.changed, true) + assert.equal(r.text, 'locale:\n preference: zh\n') +}) + +test('没有 locale 块 ⇒ 追加,且不动已有内容', () => { + const src = 'llm-pi-ai:\n providers:\n my-gw:\n baseURL: https://x\n' + const r = reconcileLocalePreference(src, 'zh') + assert.equal(r.changed, true) + assert.ok(r.text.startsWith(src), '原有内容必须原样在前') + assert.ok(r.text.includes('locale:\n preference: zh')) +}) + +test('已有 locale 块但没有 preference ⇒ 紧跟块头插入一行', () => { + const src = 'locale:\n other: keep\nfoo: bar\n' + const r = reconcileLocalePreference(src, 'zh') + assert.equal(r.text, 'locale:\n preference: zh\n other: keep\nfoo: bar\n') +}) + +test('值不同 ⇒ 原地替换(只改那一行)', () => { + const src = 'a: 1\nlocale:\n preference: en\nb: 2\n' + const r = reconcileLocalePreference(src, 'zh') + assert.equal(r.changed, true) + assert.equal(r.text, 'a: 1\nlocale:\n preference: zh\nb: 2\n') +}) + +test('值相同 ⇒ 幂等(changed: false,文本一字不动)', () => { + const src = 'locale:\n preference: zh\n' + const r = reconcileLocalePreference(src, 'zh') + assert.equal(r.changed, false) + assert.equal(r.text, src) +}) + +test('注释与其它块一律保留', () => { + const src = '# 我自己的注释\nllm-pi-ai:\n # 内层注释\n providers: {}\nlocale:\n preference: en\n' + const r = reconcileLocalePreference(src, 'zh') + assert.ok(r.text.includes('# 我自己的注释')) + assert.ok(r.text.includes(' # 内层注释')) + assert.ok(r.text.includes(' preference: zh')) + assert.equal(r.text.split('\n').length, src.split('\n').length, '行数不变(没多写也没少写)') +}) + +test('CRLF 文档 ⇒ 保持 CRLF(不产生混合行尾)', () => { + const src = 'a: 1\r\nlocale:\r\n preference: en\r\n' + const r = reconcileLocalePreference(src, 'zh') + assert.ok(r.text.includes(' preference: zh\r\n')) + assert.equal(r.text.includes('\n\n'), false) + assert.equal(r.text.replace(/\r\n/g, '').includes('\n'), false, '不许出现裸 LF') +}) + +test('两次调用等价(第二次不再 changed)', () => { + const first = reconcileLocalePreference('x: 1\n', 'zh') + const second = reconcileLocalePreference(first.text, 'zh') + assert.equal(second.changed, false) + assert.equal(second.text, first.text) +})