chore(docs): 文档库并入代码仓(R4 选 a)+ 索引/台账跟进

1) dsh-server-docs/ 从工作区(原 E:\...\aliyun-dsh-server\dsh-server-docs)**整体并入本仓**,
   保留目录名 ⇒ 仓库内 dsh-server-docs/... 的相对引用天然继续有效;旧目录(含其 .git)已归档到
   工作区 _中间产物_待清理/,未随本提交带入。
2) .gitattributes:新增 `dsh-server-docs/** -text` —— 原文档库是 `* -text` + autocrlf=false,
   必须保持纯 LF,否则会被本仓的 CRLF 规则翻掉。
3) 活引用里的绝对路径已全部改到新位置(docs 的 INDEX / README / scripts / skills + 用户级 skills
   + ~/.workbuddy/settings.json 的 hooks);历史档案(04-调整方案/、archive/)按「只增不改」未动。
   ⚠️ hooks 路径改动需「完全重启会话」才生效(配置是会话启动快照)。
4) 交接单/T08:新增 §16「生产整体切换执行记录」(形态 / 落地动作 / **4 个只有真上线才暴露的真 bug** /
   验收证据 / 回滚命令 / 残留项);台账 T08 行 → 已完成并归档;03-路线图 §二 登记 T08 收尾项。
5) 统一称谓:**「本机」只指跑 WorkBuddy 的开发机**,47 / 106 一律写「远程服务器」。
This commit is contained in:
admin committed 2026-09-15 18:47:13 +08:00
1 parent c70d5d860e
commit 5ad755116e
173 files changed
+27632

No files matched your search

@@ -0,0 +1,249 @@
# 100 · 实例内「我的技能」—— 并入「功能管理」的分组方案与实现
- 日期:2026-09-15
- 触发:用户「规划一个产品方案(界面布局美观 方便操作)然后实现这个功能」=落地 `交接单/T01`(档案 16 阶段 3/4)
- 对象:`poc/business-plugins`(`@dsh-local/business-plugins`)client bundle 的「功能管理」section
- 状态:🚧 实施中(方案已定,代码/投放/验收见 §八)
> **TL;DR**|**结论**:在**既有「功能管理」section 内新增「我的技能」分组**(**不新开 section**),用户在同一页看完并管理「功能插件 + 我的技能」两类功能;技能行支持 上传 / 启用 / 停用 / 删除,平台共享技能显示为**锁定只读**。
> **关键**:入口层合并、**机制层分离**(技能 watch 即时生效 vs 插件需重启;API / 落盘各自独立)—— 依据见 `交接单/T01 §九`。
> **不做**:不改服务端(6 个 `/api/skills/mine*` 路由早已就绪,档案 41);不新造包;不碰官方包。
---
## 一、目标与完成判定
**目标**:普通用户在实例内**自助管理个人技能**,且平台投放/用户自传两类"功能"在**同一页、同一套交互语汇**下呈现。
**完成判定(可被第三方复现)**:
1. guest 实例 → 设置 →「功能管理」页内出现「**我的技能**」分组,且与「功能插件」同页可见;
2. 上传一个合法 `.zip` 技能 → 行内新增且状态「已启用」;`$DSH_HOME/skills/<name>` 存在;
3. 停用 → 文件移到 `home/skills-library/<name>`;启用 → 回到 `skills/`;删除 → 两处均无;
4. 与共享技能同名上传 / 对锁定行操作 → 后端 409,界面以**行内错误**如实呈现;
5. 全程**无需重启实例**(技能是 watch 驱动)。
---
## 二、产品方案(界面布局)
### 2.1 页内结构(自上而下,同页滚动)
```
┌─ 功能管理 ───────────────────────────────────────────────────────┐
│ │
│ ▍功能插件 ← 既有分组(不动) │
│ ├─ 内存配额状态条(既有) │
│ ├─ 工具行:搜索 / 全选 / 清空 / 已选 N 项(既有) │
│ └─ 插件卡片网格(既有,勾选式) │
│ │
│ ─────────────── 1px 分隔线 ─────────────── │
│ │
│ ▍我的技能 [3] [+ 上传技能] ← 新增分组(本单) │
│ ├─ 说明行:上传 .zip 技能包;启用/停用**立即生效,无需重启** │
│ ├─ 上传面板(默认收起,点「上传技能」展开 / 支持拖入) │
│ └─ 技能行列表 │
│ ┌──────────────────────────────────────────────────┐ │
│ │ 账号诊断 [共享]🔒只读 12 文件 · 136 KB │ │
│ │ 我的图表工具 [我的]●已启用 8 文件 · 24 KB [停用][删除]│
│ │ 旧版脚本 [我的]○已停用 5 文件 · 12 KB [启用][删除]│
│ └──────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘
```
**为什么是这个结构**:用户在「功能管理」里的心智是"**我有哪些功能、开没开**"。两类功能放同一页、同一行卡片语汇,一次滚动看完;差异(要不要重启)由**行内事实文案**承担,而不是靠分成两页去解释。
### 2.2 分组头(`.bp-group-h`)
| 元素 | 规格 |
|---|---|
| 标题 | 15px / 600 / `T.text`,前缀 3px 竖条(`T.primary`,圆角 2px)—— 与「功能插件」分组头**同一形态** |
| 计数徽章 | 13px,`T.field` 底 + `T.sub` 字,`2px 8px` r10(规范 §4.6 `.badge` 量级) |
| 右侧主按钮 | 「+ 上传技能」,白底 + `T.border` 边 + `T.primary` 字(规范 §4.1 `.btn-view` 量级),点击展开/收起上传面板(`aria-expanded`) |
| 与上方分组的分隔 | `1px solid T.border` + `margin-top: 22px; padding-top: 18px`(规范 §2.4 间距序列) |
### 2.3 技能行(`.bp-skill-row`)—— 单行不换行
| 区 | 内容 | 规格 |
|---|---|---|
| 左 | **技能名**(`name`) | 14px / 500 / `nowrap` + ellipsis + `title`(规范 §5 长文本截断三件套);`min-width:0` 允许收缩 |
| 中 | **来源徽章** | `共享` = `T.primary` 12% 底 + `T.primary` 字;`我的` = `T.field` 底 + `T.sub` 字。13px / r10 |
| 中 | **状态** | ● 点(6px 圆点)+ 文字:已启用 = `T.success`;已停用 = `T.dim`。13px |
| 中 | **事实**(右对齐) | `12 文件 · 136 KB`,13px + `font-variant-numeric: tabular-nums`(规范 §5「数值一律右对齐 + 等宽数字」) |
| 右 | **动作区** | `margin-left:auto` + `nowrap`;启用/停用 = 白底 border 按钮(`.btn-sm` 量级);删除 = `T.danger` 文字按钮。**busy 时该行按钮统一 disabled + opacity .5** |
| 整行 | 容器 | `padding: 12px 14px`,`border: 1px solid T.border`,r12,白底(`--dsw-alias-bg-layer-1`),hover 变 `T.field` 底 |
**锁定行(`locked: true`,平台共享技能)**:来源徽章后追加 🔒 + 「只读」,**不渲染任何动作按钮**;`title` 说明「平台共享技能,全员只读,不可停用/删除/覆盖」。→ 与后端 `assertNotShared()` 的 409 双向一致(前端不给出会失败的按钮,后端仍然兜底)。
### 2.4 上传面板(`.bp-upload`)
| 元素 | 交互 |
|---|---|
| 虚线投放区 | `border: 1px dashed T.border`,r10,`padding: 18px`,居中;文案「把 .zip 拖到这里」+ 次行「技能包需含 `SKILL.md`(name / description)」 |
| 「选择文件」 | `input[type=file][accept=".zip"]` 隐藏 + `<label>` 触发(点击即开系统文件框);`aria-label` 齐备 |
| 选中态 | 显示文件名 + 体积;主按钮「上传」+ 次按钮「取消」;**客户端先校**:后缀非 `.zip` → 行内报错;`> 150MB` → 行内报错(与门户一致) |
| 拖拽 | `onDragOver` 阻止默认 + 高亮边框;`onDrop` 取 `dataTransfer.files[0]`;**非文件拖入**静默忽略 |
| 上传中 | 区禁用 + 按钮文案「上传中…」;完成后 `load()` 重拉列表 |
| 错误 | **行内**(`.bp-upload-err`,`T.danger`,13px)显示后端返回的中文错误原文(如「「x」是平台共享技能…请改用其他技能名」),不弹窗 |
### 2.5 两个页内确认弹窗(沿用 v0.2.8/v0.3.15 的既有弹窗语汇)
| 弹窗 | 触发 | 内容 | 按钮 |
|---|---|---|---|
| **同名替换** | 上传返回 `conflict: true` | 标题「检测到同名技能「X」」;正文:⚠️**替换将删除旧技能的全部文件 —— 新包中已移除的文件也会一并删除,不可恢复**;事实行:`旧 12 文件 · 136 KB → 新 8 文件`(`tabular-nums`) | 取消(白底)/ **确认替换**(`T.danger` 实心) |
| **删除技能** | 行内「删除」 | 标题「删除技能「X」?」;正文:该技能将从你的实例移除,**不可恢复**(若是停用状态,库中的文件也一并删除) | 取消 / **删除**(`T.danger` 实心) |
两者均:遮罩 `rgba(0,0,0,.35)` + 面板 r12 + 标题行 ✕ + 点遮罩关闭 + `stopPropagation`(与既有 `__confirm` 同款,规范 §4.7 提到 `.modal-mask[hidden]` 的坑 → 这里用条件渲染,不存在该问题)。
### 2.6 文案(zh / en 双语,键前缀 `msk.`)
新增词典键全部落在客户端既有 `zh` / `en` 两个对象里,**同批加、键数一致**(由 `scripts/verify-models-dict.mjs` 全词典断言守住:键数一致 + 被引用必已声明)。
**措辞三原则**:
1. **如实说生效方式**:技能「启用 / 停用**立即生效,无需重启**」;插件侧文案仍保留「需重启」——两者并存,用户看得到区别。
2. **如实说后果**:替换/删除弹窗写明"不可恢复""旧文件一并删除"。
3. **不暴露内部标识**:只说"技能 / 技能包 / .zip",不出现路径、`skills-library`、rank、"共享层"等内部词。
### 2.7 视觉同源(为什么"看起来是一家")
复用 v0.3.14 起确立的**官方卡片行语汇**:`min-width:0` + `overflow/ellipsis/nowrap` 三件套、动作区 `margin-left:auto` + `nowrap`、颜色全走 `--dsw-*` token(跟随 dsh 主题,亮/暗自动适配)。**结构上不可能换行**,也就不会再犯 v0.3.14 那次"按钮被挤换行"的缺陷。
---
## 三、为什么是「分组」而不是「新 section」
见 `交接单/T01 §九`(本次追加)。一句话:用户口径「不要分开管理」+ 该 section 已由档案 60 命名为「**功能管理**」⇒ 另立「我的技能」等于在"功能"之外再造平行概念。
⛔ **仅入口层合并**:两条后端 API、两套落盘位置、两种生效方式**都不合并**(档案 16 §2.1 决策 2「技能与插件启用语义不同」)。
---
## 四、接口契约(后端**已就绪**,本单不改服务端)
`src/web/routes/skills.ts`(档案 11 v2 + 档案 41,均 `requireAuth`):
| 方法 | 路径 | 请求 | 响应 |
|---|---|---|---|
| GET | `/api/skills/mine` | — | `{ skills: [{ dir,name,description,size,files,mtimeMs, source:'shared'\|'user', enabled, locked }] }`(**三类合并**:共享 → user 已启用 → user 已停用) |
| POST | `/api/skills/mine` | `{ file: base64, filename }` | 无同名 → `{ ok, skill }`;有同名 → `{ ok, conflict:true, stagedId, skill:{name,description,files}, existing:{name,size,files,mtimeMs} }`;错误 → 4xx `{ error }` |
| POST | `/api/skills/mine/apply` | `{ stagedId }` | `{ ok, skill }`(**全量替换**:旧目录整删,新包缺失的文件随之消失) |
| POST | `/api/skills/mine/:name/enable` | — | `{ ok, skill }`;404 `not_found`;409 `already_enabled` |
| POST | `/api/skills/mine/:name/disable` | — | `{ ok }`;404 `not_found` |
| DELETE | `/api/skills/mine/:name` | — | `{ ok }`;404 `not_found`;409(与共享技能同名) |
- 与共享技能同名 → 上传/启用/停用/删除**一律 409**,错误文案为中文原文(前端直接展示)。
- 跨子域调用:沿用 `fetch(portalHost() + …)` + `credentials: 'include'`(门户 CORS 白名单含 GET/POST/DELETE,与 `/api/plugins/mine` 同机制)。
---
## 五、实现落点
| 文件 | 改动 |
|---|---|
| `poc/business-plugins/lib/client.js` | ① zh/en 各加 `msk.*` 词条;② `BusinessPluginsSection` 内新增 `skills` 状态 + `loadSkills/upload/applyReplace/toggle/remove` 动作;③ 渲染「我的技能」分组(分组头 + 上传面板 + 技能行 + 空态);④ 两个页内弹窗;⑤ 注入 CSS(`.bp-skill-row` 等) |
| `poc/business-plugins/package.json` | 版本 `0.3.20 → 0.3.21`;`description` 头部追加本轮条目 |
| `scripts/verify-my-skills.mjs` | 新增:**词典键 / 版式不换行 / 危险操作二次确认 / 锁定行无动作按钮** 的机械断言(防回退) |
| `package.json`(仓根) | `npm run verify` 串联新脚本 |
**明确不动**:服务端 `src/**`(无改动)· 官方包(R2)· 现有「功能插件」分组的行为与文案 · `web/**` 静态页。
---
## 六、验收
| # | 口径 | 命令 / 动作 | 期望 |
|---|---|---|---|
| A | 本地 | `npm run verify` | 退出码 0(build + 单测 + 全部 verify 脚本) |
| B | 磁盘(06 §7.3 ①) | 在实例 profile 的插件里 grep 新特征串 | ≥ 1 |
| C | 实例已加载(06 §7.3 ②) | 抓实例页 HTML,比 URL 里的 `rev` | **与改前不同** |
| D | 服务端返回(06 §7.3 ③) | 用 HTML 里**完整** bundle URL 请求 | 200 + 含新特征串 |
| E | 端到端(R4 模板:临时用户,用完即删) | 上传 → 停用 → 启用 → 删除 | 四步皆 200;列表状态随之变化 |
| F | 守卫 | 与共享技能同名上传;对锁定行调 enable/disable/delete | 全部 **409** |
| G | 界面 | 浏览器(`agent-browser`,唯一许可)打开 guest 实例「功能管理」 | 「我的技能」分组与「功能插件」同页;锁定行无可点动作 |
**生效链路提醒**(06 §7.1):改 client bundle **必须**走 `npm pack` → 铺 profile → **重启实例** → 浏览器侧由 `rev` 自动更新(平台已加 `no-cache`)。
---
## 七、回滚
- **插件回退**:把 `/opt/dsh/artifacts/business-plugins-0.3.20.tgz` 放回并按 `ensure-biz-plugins.cjs --all --restart` 重铺(版本号不同即可,pnpm 不会命中缓存)。
- **服务端**:本单**零改动**,无需回滚。
- **残留检查**:实例 profile 的 `@dsh-local/business-plugins` 版本号回落到 0.3.20 即成功。
---
## 八、实现与验证记录
### 8.1 改动清单
| 文件 | 改动 |
|---|---|
| `poc/business-plugins/lib/client.js` | ① zh/en 各 +46 条 `msk.*` 词条;② `BusinessPluginsSection` 内新增「我的技能」状态机 + `loadSkills/submitUpload/applyReplace/toggleSkill/removeSkill` + 参数化弹窗 `skillDialog()`;③ 渲染分组(分组头 + 说明行 + 拖拽上传面板 + 行内结果 + 技能行列表);④ 两个页内确认弹窗;⑤ 注入 `.bp-*` CSS(含 hover / 拖拽高亮 / `prefers-reduced-motion`) |
| `poc/business-plugins/package.json` | 版本 **0.3.20 → 0.3.21**;`description` 头部追加本轮条目 |
| `scripts/verify-my-skills.mjs` | **新增**:34 条机械断言(结构 / 危险操作 / 锁定行 / 版式不换行 / 措辞 / R3) |
| `package.json`(仓根) | `npm run verify` 串联新脚本(末位) |
**零服务端改动**(后端 6 路由早已就绪);未动官方包(R2);未动现有「功能插件」分组行为。
### 8.2 验收结果(全绿)
| # | 口径 | 结果 |
|---|---|---|
| A | 本地 `npm run verify`(build + 3 组单测 + 全部 verify 脚本) | ✅ **通过**;词典 zh/en 各 **366** 键、引用键 343 个全部已声明 |
| B | 磁盘(06 §7.3 ①) | ✅ 两实例 profile 均 **0.3.21**,`msk.group` 命中 3 / `bp-skillRow` 命中 4 |
| C | 实例已加载(06 §7.3 ②) | ✅ 壳页 combo 出现 `@dsh-local/business-plugins`;壳页 58,126 B → **58,724 B**;`rev=533537bbc02b` |
| D | 服务端返回(06 §7.3 ③) | ✅ bundle **HTTP 200**,**11,363,655 B**,含 `msk.group` / `msk.replaceWarn` / `bp-skillRow` / 「我的技能」 |
| E | 端到端(R4 一次性用户) | ✅ **19/19**:上传 → 列表 enabled → 同名 conflict+stagedId → apply 全量替换 → 停用(文件落 `skills-library` 且不在 `skills`)→ 启用(回到 `skills`)→ 删除(两处皆无、列表消失) |
| F | 守卫 | ✅ 对共享技能 `platform-capabilities` 的 disable / delete **均 409**;非 `.zip` **400**「仅支持 .zip 文件」 |
| G | 浏览器视觉 | 见 §8.4 |
### 8.3 部署记录
```
# 打包(先删 lib/*.bak;npm pack 前核断言锚点)
dsh-local-business-plugins-0.3.21.tgz 70,758 B 4 files sha256 77429d2c…
# 投放
scp → /opt/dsh/artifacts/business-plugins-0.3.21.tgz (哈希与本地一致)
ssh bt-server 'cd /opt/dshs && node scripts/ensure-biz-plugins.cjs --all --restart'
admin: 版本落后(0.3.20 → 0.3.21),升级中… ✓ bundles=7(含 @dsh-local/business-plugins: true)
guest: 版本落后(0.3.20 → 0.3.21),升级中… ✓ bundles=6(含 @dsh-local/business-plugins: true)
已停 0 个实例 scope(下次访问自动拉起,新 bundle 才生效)
```
✅ **`bundles` 未被 `pruneBrokenFileDeps()` 摘掉依赖**(这是换产物时最需要核对的一项)。
### 8.4 浏览器视觉验收(✅ 全绿,`agent-browser` + 一次性用户)
R4 一次性用户 `pocui*` → 门户登录 → 进实例 → 设置 →「功能管理」。**全程零缺陷**:
| 步骤 | 观察到的结果 |
|---|---|
| 实例页壳 | 浏览器**实际请求**的 combo URL 含 `@dsh-local/business-plugins/client.js`、`rev=533537bbc02b`(= 06 §7.3 ② 的浏览器侧复核) |
| 设置 → 功能管理 | 分区列表 = 通用设置 / 模型 / 插件 / Agent 预设 / 偏好设置 / 模型设置 / **功能管理** / 用户设置;「功能管理」页 = 内存条 + 工具行 + 插件卡片 + **「我的技能」分组**(同页,分隔线之上是「功能插件」) |
| 分组头 | `❙ 我的技能` + 计数徽章「1 个技能」+ 右侧「+ 上传技能」;下方说明行「上传你自己的技能包(.zip)。启用 / 停用立即生效,无需重启实例。」 |
| 共享技能行 | `platform-capabilities|共享|🔒 只读|已启用|1 个文件 · 2.9 KB` —— **没有任何动作按钮** ✓ |
| 上传面板 | 点「+ 上传技能」展开 → 按钮变「收起」;面板 = 虚线框 +「把 .zip 拖到这里,或 [选择文件]」+ SKILL.md 要求说明 |
| 选文件 | 选 `poc-skill-demo.zip` → 行内出现 `poc-skill-demo.zip|843 B|[上传][取消]`(客户端校验通过) |
| **点「上传」** | `POST /api/skills/mine` → **200**;页面出现「✓ 已上传技能「poc-skill-demo」」;列表新增 `poc-skill-demo|我的|已启用|3 个文件 · 575 B|[停用][删除]` |
| **点「删除」** | 弹窗 = 标题「删除技能「poc-skill-demo」?」+ ✕ + 「该技能将从你的实例移除,不可恢复;若它当前处于停用状态,保留的文件也会一并删除。」+ [取消][删除] |
| **弹窗确认「删除」** | `DELETE /api/skills/mine/poc-skill-demo` → **200**;页面出现「✓ 已删除「poc-skill-demo」」;列表回到只剩共享技能行 |
截图:`_tmp_t01/ui-myskills2.png`(分组 + 展开的上传面板)、`ui-uploaded.png`、`ui-del-dialog.png`。
⚠️ **一处值得记的机制**:用 `agent-browser` 的 `click @ref` 时,ref 会随每次 `snapshot` 重排;连续操作间**必须重新 snapshot 取 ref**,或用 `eval` 按**按钮文本**定位后再 click(本轮最终用后者才稳定跑通,前者出现过"点了没反应 / 弹窗消失"的假象 —— 不是产品缺陷)。
### 8.5 踩坑记录(**全部是可复用判据,后续同类验收直接照用**)
| # | 现象 | 根因 / 正确做法 |
|---|---|---|
| 1 | 抓实例页 HTML 只拿到 10.7 KB 的**登录页** | 实例子域代理按**会话**判归属 ⇒ curl 必须**带上门户 `sid` cookie**(只有 launch token 不够)。正确姿势:303 → `set-cookie: dsh-auth-*` → 200,壳页 ≈58 KB |
| 2 | `/plugins/??…` 请求 **404(2702 B)** | 两点都要对:① 站点必须是**实例子域**(`/plugins/` 是 dsh 自己的路由,打门户域只会得到门户 404);② 同样要带 `sid`(否则被当成"无租户"请求 → **假 404**,与"fetch 不能设 Host"同族) |
| 3 | 新用户壳页 combo 里**没有** `@dsh-local/business-plugins` | **新用户 profile 是首次进实例时才创建的** ⇒ `ensure-biz-plugins` 对它直接"无 profile → 跳过"(实测输出)。正确顺序:**先 enter 起一次实例**(生成 profile)→ 再铺 bundle → **`POST /api/dsh/restart`**(client bundle 只在实例启动时加载)→ 再 enter |
| 4 | 直改 DB 审批后登录失败 / CHECK 报错 | 角色取值是 **`active`**(约束 `admin|pending|active|disabled`),**不是 `approved`**;且直改 DB 会**绕过"审批时自动铺 bundle"的钩子**(档案 36)—— 这正是坑 3 的另一个成因 |
| 5 | `POST /api/dsh/restart` 返回 **400** | `restartSchema` 要求 `command` 是**必填 string**(handoff 停写后恒传空串),必须发 `{"command":""}` + JSON 头 |
| 6 | `verify-my-skills.mjs` 首跑"R3 断言"假红 | 断言 `exports.default` 别用 `includes()` —— 本文件**头部注释里就有这句话**("绝不 exports.default")⇒ 只断言**赋值形态** `exports.default =` |
### 8.6 回滚演练要点
装回 `/opt/dsh/artifacts/business-plugins-0.3.20.tgz`(版本号不同即可,pnpm 不会命中缓存)→
`ensure-biz-plugins.cjs --all --restart` → 磁盘层版本回落 0.3.20 即成功。**服务端零改动、无需回滚。**