Files
dsh_ai1net_server/dsh-server-docs/04-调整方案/100-实例内我的技能-并入功能管理分组.md
T
admin 3efd68517f chore: 并入已删除会话的在途成果(防丢失;原会话已删,未做功能验收)
**背景**:这些改动原属本工作区另外几个会话(T01/T02 等),**那些会话已被用户删除** ⇒
工作树里的成果处于"无主"状态,一次错误 checkout / 覆盖即**永久丢失** ⇒ 代入库保全。
口径遵循本项目**先例**(`39a1f2e` / `b617cdb`:**别人的活,代入库并在提交信息里注明**)。

**内容**:档案 101「能力管理」改名 + 页内 tab 分页|档案 102 语言切换搬入「用户设置」|
`07-实例UI分区登记表.md`|`scripts/find-ui*.mjs`(UI 元素定位工具)|`poc/portal-entry/`(0.5.3)|
`src/web/locale-pref.ts` + `home-files.ts`(语言偏好持久化)|`test/locale-pref.test.mjs`|
`package.json`|`BRIEF.md` / `INDEX.md` / `docs-manifest.json` / `03-路线图与待办.md` / 档案 100 增量。

**已做最小健全性检查**(⚠️ **未跑完整构建 / 单测** —— 那是原会话的验收职责,本次只求"不丢"):
- JSON 合法:`package.json` / `poc/business-plugins/package.json` / `docs-manifest.json` ✓
- 4 个 TS 文件 `{}`/`()` 配平 ✓;新增文件均非空 ✓
- 规模:13 文件改动 +340/−210,新增 12 条

**未 push**(按 §4 提交边界:用户说"提交",未说"推送")。
2026-09-15 21:17:12 +08:00

251 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 100 · 实例内「我的技能」—— 并入「功能管理」的分组方案与实现
- 日期: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 §九`。
> **不做**:不改服务端(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 即成功。**服务端零改动、无需回滚。**