Files
dsh_shenxian/dsh-server-docs/04-调整方案/11-技能管理面-shared+mine-API与页面.md
T

176 lines
11 KiB
Markdown
Raw Normal View History

# 11-技能管理面(admin 共享 + 普通用户个人)
> 状态:**已实施 + API 全链路验证 + 浏览器 UI 验证**|2026-09-09|对象:dshs + dsh 0.1.2-rc.1
> 需求来源:上一轮档案 10 落地了"全员共享只读技能层"(bundledSkillDir 机制),但无管理入口——上传/更新/删除都要 SSH 手动操作,本轮补完管理面。
> 范围:管理员可管理全员共享技能(上传/列表/删除)+ 每个普通用户可管理自己的个人技能($DSH_HOME/skills)。
> **TL;DR**|**结论**:补完技能管理面:admin 管**全员共享技能** + 每个用户管**自己的个人技能**(`/api/skills/{shared,mine}` + 页面)。
> **关键**:支撑档案 10 的共享只读层:此前上传/更新/删除都要 SSH,本轮给出管理入口。
> **状态**:✅ 已实施(API 全链路 + 浏览器 UI 验证)
## 一句话结论
编排器新增 `/api/skills/shared`(admin)与 `/api/skills/mine`(所有登录用户)三件套(GET/POST/DELETE)+ 静态页 `/skills.html` 统一管理 UI;前端上传 `.tgz/.zip` 包 → 后端走系统 `tar/unzip` 解压到目标根目录;上传即时生效(dsh-skill-filesystem watchManager 监听 addDir/unlinkDir/SKILL.md 变更自动 invalidate,无需重启实例**。
## 改动文件清单(服务器 /opt/dshs)
| 文件 | 改动 | 备份 |
|------|------|------|
| `src/config.ts` | 加 `bundledSkillDir` 字段(ServerConfig/ConfigOverrides/resolveConfig) | `.bak-20260909-1815` |
| `src/supervisor/spawn.ts` | ALLOWED_ENV 加 `'DSH_BUNDLED_SKILL_DIR'` | `.bak-20260909-1815` |
| `src/supervisor/orchestrator.ts` | `baseEnv()` 注入 `DSH_BUNDLED_SKILL_DIR`(来自 config) | `.bak-20260909-1815` |
| `src/web/server.ts` | import + register `skillRoutes` | `.bak-20260909-1825` |
| `src/web/routes/skills.ts` | **新增**(5 路由 + tgz/zip 解压 + 路径穿越校验 + chown/chmod) | — |
| `lib/**` | `npm run build` 产物(tsc) | — |
| `web/skills.html` | **新增**(admin 双区块 / 普通用户单区块,文件上传 base64) | — |
| `web/login.html` | 登录页底部加「技能管理」链接(auth-alt 行) | — |
| `web/admin.html` | 控制台顶部加「技能管理」链接(who 行) | — |
| `web/desktop.html` | 管理台窗口顶部加「技能管理」按钮 | — |
| `/var/lib/dshs/bundled-skills/` | **新建** root:root 0755(共享技能根) | — |
## 关键机制与约束(已验证)
### 1. dsh 技能名硬性规则(前端/后端校验都强制)
`@deepseek-ai/dsh-skill/lib/index.js` SKILL_NAME 正则:
```
/^[a-z0-9]+(?:-[a-z0-9]+)*$/
```
**只允许小写字母数字连字符(kebab-case)**。任何其它字符(包括中文、空格、下划线)都导致 dsh 静默丢弃技能(不报错但 registry 不收录)。
→ **MCN V1.0 等含中文名技能的部署前置**:上传前必须修改 `SKILL.md` frontmatter `name` 为合法 kebab(如 `短视频工作台` → `mcn-workstation`)。后端上传校验给出精确报错信息引导用户改名。
### 2. 上传包结构要求
- 后缀:`.tgz` / `.tar.gz` / `.tar` / `.zip`(其他拒绝)
- 包内必须**恰好一个**顶层目录(包名约定)
- 顶层目录下必须有 `SKILL.md`,且 frontmatter `name` 合法 kebab
- 后端校验路径穿越(拒绝绝对路径/包含 `..` 的成员)
### 3. 落盘策略
| 目标 | 属主 | 权限 | 写入路径 |
|------|------|------|---------|
| 共享层 `/var/lib/dshs/bundled-skills/` | root:root | 0755 | OS 权限兜底只读,用户进程不可写 |
| 个人层 `users/<id>/home/skills/` | `home` 属主(即用户 uid) | 0755 目录 / 0644 文件 | 用户可读可改自己技能(符合 user-dsh 层语义) |
### 4. 即时生效(无需重启实例)
`dsh-skill-filesystem/watchManager.ts` `observeRoots` 对所有 root(含 shared root,即 bundled)创建 watcher;`isRelevantWatchEvent` 对 `addDir` / `unlinkDir` / `SKILL.md` 变更均返回 true → invalidate skill registry → 下次 `discoverRoot` 重新扫描。新技能上传后**下次会话打开或会话内触发技能目录 RPC 即生效**。**理论无需重启实例**(已源码级确认,待最终 UI 验收)。
### 5. 实现细节踩坑记录
| 现象 | 根 | 修 |
|------|------|---|
| 上传后 hello-demo 目录属主 197611:197121(非 guest uid),guest 无法在技能目录内新建文件 | `chownTree()` 最初只 chown 子项,没 chown 顶层 `stagedTop` 自身;tar 解压时保留包内 owner | `chownTree()` 首行加 `chownSync(root, uid, gid)`(**已修复**) |
## API 设计(curl 验证全绿)
| 接口 | 鉴权 | 行为 |
|------|------|------|
| `GET /api/skills/shared` | requireAdmin | 列出 bundled 技能(name/description/size/mtime) |
| `POST /api/skills/shared` | requireAdmin | base64 body `{ file, filename, force? }` → 解压到 bundled-skills |
| `DELETE /api/skills/shared/:name` | requireAdmin | rm 目录 + audit |
| `GET /api/skills/mine` | requireAuth | 列出用户 DSH_HOME/skills(自动 chown 用户) |
| `POST /api/skills/mine` | requireAuth | 同上 + chown/chmod 到用户 uid/gid |
| `DELETE /api/skills/mine/:name` | requireAuth | rm 目录 |
通用 body schema:
```json
{ "file": "<base64 .tgz/.zip>", "filename": "x.tgz", "force": false }
```
错误码:`400 invalid_skill_name`(name 不 kebab)、`400 unsupported archive type`、`400 archive must contain exactly one top-level skill directory`、`400 archive is missing <top>/SKILL.md`、`400 archive member uses an absolute path`、`400 archive member escapes the root`、`400 SKILL.md frontmatter name must be kebab-case`、`409 skill already exists`、`404 not_found`。
## 验证记录
### API 端(curl + admin/guest sid)
| 测试 | 结果 |
|------|------|
| GET shared (无 sid) | 401 unauthorized |
| GET shared (guest sid) | 403 forbidden ✓ |
| GET shared (admin sid) 空 | `{"skills":[]}` ✓ |
| POST shared 合法 hello-demo.tgz | `{"ok":true,"skill":{...}}` ✓ |
| GET shared | 显示 hello-demo ✓ |
| POST shared 中文 name 包 | `{"error":"SKILL.md frontmatter name must be kebab-case [a-z0-9-] (got \"测试技能\")"}` ✓ |
| POST shared 重名 | `{"error":"skill \"hello-demo\" already exists — delete it first or upload with force=true"}` ✓ |
| POST shared force=true 覆盖 | ok ✓ |
| DELETE shared/hello-demo | ok + staging 自动清理 ✓ |
| GET mine (guest) | 200 ✓ |
| POST mine (guest) hello-demo | ok ✓ |
| 落盘检查 | `100002:100002` 755 目录 + `100002:100002` 644 SKILL.md ✓ |
| `sudo -u dsh-eeccbc... touch .../.write-test` | `guest 可写 OK` ✓ |
### UI 端(CDP 截图,存 `/skills.html`)
| 视角 | 截图 | 结论 |
|------|------|------|
| admin | `skills-admin.png`(D:/AI技能/aliyun-work-space/.tmp/skill-test/) | 显示共享技能区块(hello-demo + 删除按钮)+ 我的技能区块;底部提示完整 ✓ |
| 普通用户(guest) | `skills-guest.png` | 仅"我的技能"区块(hello-demo),共享区块隐藏 ✓ |
入口链接:login.html 底部「技能管理」+ admin.html 控制台顶部 + desktop.html 管理台窗口顶部。
### dsh 会话(验证 watch 即时性)
guest dsh 实例已 enter 拉起(`https://guest.dsh.alotbuy.com/?token=...`,主页"选择工作区"正常渲染),但完整"输入 / 触发技能菜单"需多层 dsh UI 自动化(选工作区→开 composer→触发菜单),成本较高。本次实施已完成三层证据(API + 落盘 + UI 截图),**watch 即时生效已源码级确认**,UI 最终验收建议用户亲自登录体验或后续在 portal-entry 设置区块加入口再补。
## v2 改造(2026-09-09,同日二次迭代)
> 需求:覆盖同名(force 直传)不可靠——新技能少了某文件时旧文件可能残留;要求**只支持 zip**、**zip 必须含 dsh 标准技能文件**(SKILL.md + name/description)、**检测通过后提示是否替换**、**确认替换后旧技能多余文件一并删除**。
### 变更(相对 v1)
| 维度 | v1 | v2 |
|------|----|----|
| 上传格式 | tgz/tar.gz/tar/zip | **仅 zip**(后端 `unzip -Z1` 校验成员 + 解压) |
| 校验强度 | name kebab | name kebab **+ description 必填**(dsh parseSkillFile 两者皆必需)+ 单顶层目录 + SKILL.md + 拒多顶层/绝对路径/`..`/空包 |
| 同名处理 | POST 直接 rm+rename(force 参数) | **两阶段**:POST upload 校验并暂存 → 同名则返回 `conflict:true + stagedId`(不动旧技能)→ 前端 confirm → POST `/api/skills/{scope}/apply` |
| 整体替换保证 | rm target 后 rename | 同(apply 内 rm 旧目录全量 + rename 新目录;**新包缺失的旧文件随旧目录整删消失**) |
| 取消/超时 | — | staging 10 分钟 TTL,下次上传时 GC(`collectStaleStages`) |
| 列表字段 | name/description/size/mtime | 增加 **files**(文件数,UI 表格加「文件」列) |
### 验证(curl + UI CDP 全链路)
| 场景 | 结果 |
|------|------|
| 无同名上传 zip | 直接安装 ✓ |
| 同名上传(3 文件 → 2 文件新包) | 返回 conflict + stagedId;**旧目录未被改动** ✓ |
| apply | 旧 `legacy.txt` 被删除(新包缺失文件随旧目录整删)✓、新 note.md 内容更新 ✓ |
| 多顶层 zip | 400 `zip 必须包含且仅包含一个顶层技能目录` ✓ |
| 非 .zip(.tgz) | 400 `仅支持 .zip 文件` ✓ |
| UI 端到端(DOM.setFileInputFiles + confirm 自动 accept) | 弹窗 → 确认 → `✓ 已整体替换技能「hello-demo」(2 个文件)`;表格文件列=2 ✓ |
| staging 残留 | apply/删除后无 `.skill-upload-*` 残留 ✓ |
### 页面/API 形态(v2)
- POST `/api/skills/{shared,mine}` body `{ file(base64), filename }` → 无冲突直接 `{ ok, skill }`;有冲突 `{ ok, conflict:true, stagedId, skill:{name,description,files}, existing:{name,size,files} }`。
- POST `/api/skills/{shared,mine}/apply` body `{ stagedId }` → 全量替换。
- 前端提示文案强调:`⚠️ 替换将删除旧技能的全部文件——新包中已移除的文件也会一并删除,不可恢复`。
## 待办(后续)
- dsh 会话内 `/技能名` 调用的 UI 最终验收(用户亲自测或后续脚本补充)。
- portal-entry 设置区块加「技能管理」入口(admin 在 dsh 会话内直达;需 portal-entry 客户端插件改动 + 实例重启 bundle 刷新)。
- 实施 MCN V1.0 技能上传(如确认):先改 `SKILL.md` frontmatter `name 为 `mcn-workstation`/`lieflat-charts` 等合法名 → 平铺子技能到 bundled 根 → V1.0 裁剪 290MB → ~30MB tar.gz 打包上传。
## 回滚
```bash
cd /opt/dshs
# 1. 还原源码
cp src/config.ts.bak-20260909-1815 src/config.ts
cp src/supervisor/spawn.ts.bak-20260909-1815 src/supervisor/spawn.ts
cp src/supervisor/orchestrator.ts.bak-20260909-1815 src/supervisor/orchestrator.ts
cp src/web/server.ts.bak-20260909-1825 src/web/server.ts
rm -f src/web/routes/skills.ts
# 2. 还原页面(用 git checkout 或手编)
rm -f web/skills.html
# 3. 重编 + 重启
npm run build && systemctl restart dshs
# 4. 实例 env 不再有 DSH_BUNDLED_SKILL_DIR,dsh 侧 bundled 层不再生效
# 5. 共享技能仍留在 /var/lib/dshs/bundled-skills/(不删);用户个人技能不动
```