Files
dsh_shenxian/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

21 KiB
Raw Blame History

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
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 即成功。服务端零改动、无需回滚。