平台共享模型改为「管理员逐用户授权」+ 品牌中文名改「能力网络」+ 修掉跨机模型落地/语言偏好静默失效

三条线合并入库(同一次部署批次,源码与生产此前已一致):

一、档案 138 · 平台共享模型:管理员逐用户授权(默认关闭)
  用户口径原文:「admin 设置的共享模型,需要 admin 在用户列表中开启(新增选项,默认关闭),
  用户才能在会话中使用(以及在设置的模型设置页面展示)」。
  · DB 迁移 v11:新增 users.shared_model_granted(DEFAULT 0 = 默认关闭)。
    ⚠️ 刻意**不**复用 v6 的 shared_model_enabled —— 那是用户侧偏好(用户能自己关,默认 1),
    而本需求要的是**管理员门禁**;共用一列则用户点一下就给自己授权,门禁形同不存在。
    生效 = granted ∧ enabled(server.ts#sharedLandingRows)。
  · 新路由 POST /api/admin/users/:id/models/shared(requireAdmin)+ 审计 shared_model_grant
    + **尽力而为**重启该用户实例(租约被占/实例未运行都不算失败)。
  · admin 用户列表新增「共享模型」列;用户侧 /api/me/keys 增 shared.granted / sharedModelGranted;
    插件 0.3.24:未授权时「平台共享模型」整块不渲染。

二、顺带修掉一个既有真缺陷:平台侧写用户 home 必须走 UserFs(用户卷在 worker 上)
  landModels 原先 join(owner.home_dir, …) + 本机 fs ⇒ 对「实例不在控制面本机」的用户
  读到空串(**不报错**)⇒ 模型落地一直是**静默空操作**(托管清单还被清空)
  ——即档案 87 的模型设置页对 guest 这类用户**从未生效**。
  · UserFs 新增 readHomeFile/writeHomeFile + 文件名白名单(settings.yaml / .credentials.yaml)
  · worker agent 新增 /fs/home-read /fs/home-write(只认白名单裸文件名)
  · landModels 改走 userFs(与"文件面"同一份按归属路由 ⇒ 落地与实例必然同机)
  · 同轮把 /api/me/locale(档案 102 语言偏好)也改成同一套(原先同样失效)
  · home-files.ts 抽出 backupHomeFile(备份留平台侧,命名规则逐字不变)

三、档案 139 · 品牌中文名:能力枢纽 → 能力网络(其他语言仍 CapabilityNet)
  落点四处:i18n 中文词条 / admin.html 顶栏 / favicon.svg 的 title+aria-label / design.css 注释;
  test/i18n-brand.test.mjs 期望值同步。档案 137 顶部加"后续"指针,不改历史。

验证(全部真机实测):
  · 红腿:未授权 → 106 上 guest 的 .credentials.yaml refs 变空(共享 key 被撤)
  · 绿腿:授权 → key 回来 + 托管清单恢复 ["DEEPSEEK_API_KEY"]
  · admin 列表带出 sharedModelGranted;用户侧 granted 随授权翻面(true/shared ↔ false/none)
  · 开关两次均 200(不再假失败);插件实装 0.3.24 且含 gating 字符串
  · 语言偏好:106 上 settings.yaml 出现 locale.preference=en(属主=实例属主,既有段逐字保留)
  · 本机 npm test 226 tests / 225 pass / 0 fail / 1 skipped;四个 verify 脚本全绿

部署:47 推 51 个 lib 产物、106 推 12 个(lib/ 是 gitignore ⇒ 回滚点物化在
/opt/dsh/backups/seq138b-20260919-125345/,逐文件对账 0 不一致;先 106 后 47);
插件 business-plugins 0.3.24(两机 artifacts 与本机 pack md5 一致)。
This commit is contained in:
admin committed 2026-09-19 13:51:01 +08:00
1 parent 971ccc3703
commit c2b7c5ef71
30 files changed
+770 -70

No files matched your search

@@ -1,5 +1,8 @@
# 137-品牌标识改造(去 DeepSeek 图形 → 平台自有标识) # 137-品牌标识改造(去 DeepSeek 图形 → 平台自有标识)
> ⚠️ **后续(2026-09-19)**:中文品牌名已由「能力枢纽」改为 **「能力网络」**(其他语言仍 `CapabilityNet`)
> —— 见档案 **139**。本档正文保留当时的定名,**不改历史**。
> **一句话**:把我们自己的页面上所有 **DeepSeek 品牌标识**(鲸鱼图标 / 「DeepSeek」文字图形 / favicon) > **一句话**:把我们自己的页面上所有 **DeepSeek 品牌标识**(鲸鱼图标 / 「DeepSeek」文字图形 / favicon)
> 换成**平台自有标识** —— 中文「**能力枢纽**」,英语及其他语言「**CapabilityNet**」。 > 换成**平台自有标识** —— 中文「**能力枢纽**」,英语及其他语言「**CapabilityNet**」。
> **触发**:用户 2026-09-19 原话三句 —— > **触发**:用户 2026-09-19 原话三句 ——
@@ -0,0 +1,154 @@
# 138 · 平台共享模型 —— 管理员**逐用户**授权(默认关闭)
- 日期:2026-09-19
- 状态:✅ **已上线并端到端验证**(控制面 + 前端 + 插件 **0.3.24**;两腿实测:未授权撤销、授权恢复)
- 触发(用户口径,原话):「**调整设计 admin设置的共享模型,需要admin在用户列表中开启(新增选项,默认关闭),用户才能在会话中使用(以及在设置的模型设置页面展示)**」
- 落点:`src/db/{schema,types,repo,pg,sqlite,adapter}.ts`、`src/web/routes/{admin,auth}.ts`、`src/web/server.ts`、`web/admin.html`、`poc/business-plugins/lib/client.js`(**0.3.24**)+ **顺带修掉一个既有真缺陷**:`src/fs/{user-fs,local-user-fs,remote-user-fs}.ts`、`src/worker/agent.ts`、`src/web/home-files.ts`(见 §五)
> **TL;DR**|① 共享模型从「人人默认可用(用户可自关)」改成「**管理员逐用户授权**」——新增列
> `users.shared_model_granted`(**默认 0 = 关闭**),**生效 = granted ∧ enabled**(前者 admin 在**用户列表**里开,
> 后者是用户自己的偏好,保留不动);② 未授权时**设置页连"平台共享模型"那一块都不渲染**(用户原话"开启才展示");
> ③ 🔴 **顺带发现并修掉一个既有真缺陷**:落地层原先用**本机 fs** 写用户 home,而用户卷在 worker 上
> ⇒ 对"实例不在本机"的用户,**模型落地一直是静默空操作**(托管清单被清空、目标文件一个字节没动)
> —— 也就是说档案 87 那套「模型设置页」对 **guest 这类用户从来就没生效过**;本档把它改成走
> `UserFs`(与"文件面"同一份按归属路由),并给 worker agent 补了两个**白名单**端点。
---
## 一、为什么是"新开一列"而不是改旧列的默认值(本轮最关键的设计判断)
| 事实 | 依据 |
|---|---|
| 旧列 `users.shared_model_enabled`(v6)语义 = **用户侧偏好**(用户自己能在设置页关掉,默认 1) | `schema.ts` 的 `SQLITE_V6` 注释 + `dashboard`(档案 87 口径②) |
| 用户要的是**管理员门禁** —— "只有 admin 开了,用户才能用" | 本轮触发原话 |
| 若两者共用一列:**用户在自己设置页点一下就把自己"授权"了** ⇒ 门禁形同不存在 | 直接推论 |
⇒ 故**两列两人**:`shared_model_granted`(admin,默认 **0**)+ `shared_model_enabled`(用户,默认 1,**不变**),
**生效 = `granted ∧ enabled`**(`server.ts#sharedLandingRows`)。缺一不给。
> ⚠️ `DEFAULT 0` 让**存量行也一并变 0**(两个方言加列都用默认值回填)⇒ 迁移后所有既有用户都"未授权"。
> 这正是"默认关闭"的字面语义;代价是 **guest 需要 admin 开一次**才会重新拿到共享模型(见 §四-③)。
## 二、改动清单
| 层 | 文件 | 改动 |
|---|---|---|
| DB 迁移 | `src/db/schema.ts` | **v11**:`ALTER TABLE users ADD COLUMN shared_model_granted INTEGER NOT NULL DEFAULT 0;`(两方言同款;注释写清"为什么新开列") |
| 类型 | `src/db/types.ts` | `User.shared_model_granted` + `PublicUser.sharedModelGranted`;`toUser` 用 **`Number(row.x ?? 0) !== 0`** |
| 仓储 | `src/db/repo.ts` | `USER_COLS` 加列、`findSessionWithUser` 补列、新增 `get/setSharedModelGranted`(缺失行按 **false** = 失败关闭) |
| PG | `src/db/pg.ts` | 🔴 **本文件另有一份 `USER_COLS`** —— 加列未同步它 ⇒ `listPublicUsers` 读不到该列、admin 列表恒显示"未开启"(**实测踩到**,见 §六-2) |
| SQLite | `src/db/sqlite.ts` / `adapter.ts` | 转发 + 接口声明 |
| 生效判据 | `src/web/server.ts` | `sharedLandingRows` 三条件(**granted ∧ enabled ∧ 不是那个 admin 本人**);`resolveApiKey` 的回退注入**也判门禁**(失败关闭) |
| 管理面路由 | `src/web/routes/admin.ts` | 新增 **`POST /api/admin/users/:id/models/shared`**(`requireAdmin`,body `{enabled}`)→ 落库 + 审计 `shared_model_grant` + **尽力而为**重启该用户实例 |
| 用户面接口 | `src/web/routes/auth.ts` | `sharedKeyInfo` 加 `granted`;`keySourceOf` 加门禁(未授权 ⇒ `none`);`GET /api/me/keys` 增 `sharedModelGranted` |
| 管理页 | `web/admin.html` | 用户表**新增「共享模型」列**(已开启/已关闭按钮;admin 与 pending 行显示「—」)+ 一行说明 |
| 插件 | `poc/business-plugins/lib/client.js`(0.3.24) | 「平台共享模型」区块改为 **`shared.granted` 为真才渲染**(未授权整块不出现) |
| 测试 | `test/db.test.mjs`、`test/local-user-fs.test.mjs`、`scripts/verify-platform-admin-section.mjs` | 新增 4 条:granted 默认关 ∧ 可开关 ∧ 与偏好互不影响、列表带出该列、**未授权 ⇒ 区块不渲染(正反两腿)** |
## 三、🔴 三条判据(都是踩出来的)
1. **门禁类判据一律"失败关闭"**:`getSharedModelGranted` 对"查不到这个人"返回 **false**(而 `getSharedModelEnabled`
对同样情形返回 true —— 两条**故意相反**,已在单测里钉死)。
2. **门禁判在"使用点",不判在"归属判据"里**(本轮红腿实测):`sharedDeepseekKey()` 同时被
① 「一次性交接」用来**认领**老实现写下的那行 key(这是**归属**问题)② 回退 env 注入(这是**授权**问题)。
第一版把门禁加在函数内部 ⇒ 被撤销授权的用户**认不出自己写过的行** ⇒ 那行永远删不掉
⇒ "关掉即生效"不成立。**修法**:函数保持不判,门禁挪到 `resolveApiKey` 的调用点。
3. **授权路由里的"重启实例"必须尽力而为**:授权已落库才是真结果;实例没在跑、或归属租约被别的持有者占着,
都**不算开启失败**(第一版直接 `await restartMain` ⇒ 租约被占时回 500,admin 界面显示"操作失败",
而库里其实已经改对了)。
## 四、验证(全部真机实测)
| # | 项 | 证据 |
|---|---|---|
| ① | **迁移 v11 生效** | `SELECT version FROM schema_migrations` ⇒ `11`;`\d users` 出现 `shared_model_granted integer not null default 0`;四个用户该列全 0(默认关闭) |
| ② | **红腿:未授权 ⇒ 共享 key 被撤** | guest(实例在 **w-106**)设为 `false` → 重启实例 → 106 上 `.credentials.yaml` 的 `refs:` **变空**(`DEEPSEEK_API_KEY` 消失)、托管清单 `{"refs":[],"routes":[]}` |
| ③ | **绿腿:授权 ⇒ 恢复** | 设为 `true` → 重启 → `refs: DEEPSEEK_API_KEY: 'sk-…'` **回来**、托管清单 `{"refs":["DEEPSEEK_API_KEY"]}` |
| ④ | **admin 列表带出状态** | `GET /api/admin/users` ⇒ guest `sharedModelGranted=True`(其余 false) |
| ⑤ | **用户侧展示判据翻面** | guest 会话 `GET /api/me/keys`:授权时 `shared.granted=true effective=shared`;撤销后立刻 `granted=false effective=none` |
| ⑥ | **开关不再报假失败** | 撤销/开启两次调用均 **200** `{ok:true, sharedModelGranted:…, restarted:false}`(实例未运行) |
| ⑦ | **插件实装** | 106 上 guest 的 `profiles/web/node_modules/@dsh-local/business-plugins` = **0.3.24**,`lib/client.js` 含 `shared.granted` ×2;`.dsh-stage/` 有 0.3.24.tgz(本机 pack 与 `/opt/dsh/artifacts/` md5 一致 `75f342eb…`) |
| ⑧ | **零回归(本机)** | `npm test` **226 tests / 225 pass / 0 fail / 1 skipped**;`verify-static` / `verify-inject` / `verify-platform-admin-section`(含新 2 条)/ `verify-models-render` 全绿 |
| ⑨ | **语言偏好跨机修复生效** | guest(实例在 **w-106**)`POST /api/me/locale {locale:'en'}` ⇒ 200 `changed:true`;106 上 `settings.yaml` 出现 `locale: preference: en`(属主 = 实例属主,既有段逐字保留);平台侧备份 `settings-<id>-<ts>.yaml` 同步落地 |
⚠️ **未做**:无浏览器的 UI 目视(admin 表格多了一列、「共享模型」区块的隐藏效果)—— 留给你看一眼。
⛔ 未重启任何**正在服务**的用户实例去做验证(验证用的 guest 实例由我自己停/起,收口时回到 stopped)。
## 五、🔴 顺带修掉的既有真缺陷:**跨机用户的"模型落地"一直是空操作**
**怎么发现的**:按上面 §四-② 第一次跑红腿时,guest 的凭据文件**一个字节没动**、而托管清单却被清空了。
根因链(文件级):
| # | 位置 | 事实 |
|---|---|---|
| 1 | `server.ts#landModels`(改前) | `join(owner.home_dir, '.credentials.yaml')` + **本机 fs** 读 —— 而 `home_dir` 是**worker 上的路径** |
| 2 | 实测 | 47 上 `/var/lib/dshs/users/<guest>/` **只有 `ws/`**、没有 `home/` ⇒ 读到空串(**不报错**) |
| 3 | 后果 | `reconcileCredentials('', [], [...])` ⇒ 无变化 ⇒ 不写盘;但 `writeManaged()` 照写 ⇒ **托管清单被清成空**(平台从此"忘记"自己写过的那行) |
⇒ 影响面**不止本档**:**档案 87 的整套「模型设置页」对"实例不在控制面本机"的用户从来就没生效过**
(guest 的实例在 w-106 ⇒ 用户改模型设置不生效);档案 102 的**语言偏好**(`/api/me/locale`)走的是同一套
`home-files` 写入,**同样命中这个缺陷**(⚠️ 本档**未修**它,见 §七-3)。
**修法**(与既有"文件面"设计对齐 —— `server.ts:848-850` 那段注释原本就写着"文件写到 A、实例起在 B"的教训):
| 层 | 改动 |
|---|---|
| `src/fs/user-fs.ts` | 新增 `readHomeFile` / `writeHomeFile` + **文件名白名单** `HOME_FILE_NAMES = ['settings.yaml','.credentials.yaml']`(⛔ 只收裸文件名,等于"平台只管自己那两个配置") |
| `local-user-fs.ts` | 本机实现(`0600` + chown 给 home 属主);非白名单名 ⇒ `bad_path` |
| `remote-user-fs.ts` | 走 agent 的 `/fs/home-read` `/fs/home-write`,目标是 `hostIdFor` **钉住的那台机**(与实例同机) |
| `worker/agent.ts` | 新增两个端点(**只认白名单裸文件名**,非法即 400 `bad_path`) |
| `web/home-files.ts` | 抽出 `backupHomeFile()`(**平台侧**备份;`writeHomeFile` 改为复用它,命名规则逐字不变) |
| `web/server.ts` | `landModels` 的读写**全部改走 `userFs`**:`readHomeFile(...) ?? ''` ∧ `backupHomeFile(...)` + `userFs.writeHomeFile(...)` |
**为什么这是"必须做"而不是"顺手优化"**:不做 ⇒ 用户这条需求("admin 开了才能在会话中使用")
对 guest 这类用户**永远不成立**(授权改了、实例里什么也没变)。R11「只做正向迭代」不允许留这种"看起来做了"的状态。
**部署面**(`lib/` 是 gitignore 的 ⇒ 回滚点只能物化,⛔ 别指望 `git checkout`):
- **106** `/opt/dshs-cluster/lib`:`fs/*` + `worker/agent.*` 共 12 个(回滚点 `/opt/dsh/backups/seq138b-20260919-125345/opt/dshs-cluster/lib`,先抓旧版再覆盖,对账 0 不一致)
- **47** `/opt/dshs/lib`:42 + 3 + 3 个(同上,回滚点同目录 `/opt/dsh/backups/seq138b-…/opt/dshs/lib`);`restart dshs dshs-worker`
- 顺序:**先 worker(106)后 Manager(47)** —— 新端点必须先在位;两版并存时表现为"落地仍不生效"(= 改前状态),⛔ 不会更糟
- ⚠️ **106 上跑 `ensure-biz-plugins.cjs` 需要补丁**:它 `require('/opt/dshs/node_modules/better-sqlite3')`
而 106 只有 `/opt/dshs-cluster/node_modules`(且该原生模块在 106 上没为 node 22 编译)⇒ 用**临时副本**
`/tmp/ebp-106.cjs`(只改两处:require 置空、用户清单改由 `DSH_USERS_JSON` 注入,清单取自权威 PG)。
⛔ 正本 `scripts/ensure-biz-plugins.cjs` **未动**。
## 六、事故 / 踩坑
1. **用户卷在远端时,"落地"与"备份"要分开**:备份(平台自己的副本)留在控制面 `/opt/dsh/backups` 正合适;
⛔ 不要为了备份再往远端开第二条通道。
2. 🔴 **`pg.ts` 有自己的一份 `USER_COLS`**(与 `repo.ts` 那份是**两份**)⇒ 加列必须**两处都改**。
只改 `repo.ts` 时:SQLite 路径正常、**PG 路径(= 生产)读不到新列**,admin 用户列表**恒显示"未开启"**
(而 DB 里其实是 1)—— 这种"两个方言行为不一致"的缺陷只在生产暴露,本档已实测踩到。
3. **临时会话(`mksess.cjs`)TTL = 10 分钟** ⇒ 验证脚本必须"现建现用";跨多次手工调用必撞 401
(本轮撞了两次,写成了 `seq138-leg.sh` 一次跑完)。
4. `scp [email protected]:` 不通(无对应密钥/别名),要用别名 `test106`。
## 七、未做 / 待办
1. **UI 目视**留给用户(无浏览器验收已全绿):admin 用户列表多一列「共享模型」、未授权时设置页不出现共享区块。
2. **`.lock-131/132/133/134/138` 空目录堆积**(占号后没清)—— 文档库卫生,`docs-audit.py` 不报。
3. ✅ **已修(同轮收口时一并做掉)**:`/api/me/locale`(档案 102 的语言偏好)原先**也走本机 fs**
⇒ 对远端用户同样失效。改法与 `landModels` **完全同构**(`userFs.readHomeFile('settings.yaml')`
+ `backupHomeFile` + `userFs.writeHomeFile`)。
**实测**:guest(实例在 106)`POST /api/me/locale {locale:'en'}` ⇒ **200 `changed:true`**;
106 上 `settings.yaml` 出现 `locale: preference: en`、属主 = 实例属主 `dsh-<hash>`、
既有段(`agent-default-model` / `agent-presets` / `llm-pi-ai`)**逐字保留**;
平台侧备份同步落地 `settings-<userId>-<ts>.yaml`。
⇒ **本档之后,平台侧"写用户 home"只剩 `UserFs` 这一条路**(`readTextOrEmpty` / `writeHomeFile`
的**本地版**仍保留给 `deployMode=local` 的单机形态与 `home-files.ts` 内部复用)。
4. ⚠️ **`shared_model_enabled`(用户偏好)现在只在"已授权"时才有可见效果**:用户侧那条
`POST /api/me/models/shared` 路由**未加门禁**(有意 —— 见 `auth.ts` 该路由注释:让用户能先关掉自己不想用的、
也允许先打开,授权一到即生效;且不泄露"管理员是否授权了别人")。若将来要"授权后用户不得自关",需另立需求。
## 八、回滚
```bash
# ① 控制面代码:从本仓重建 HEAD 版 lib 并覆盖(lib/ 不入仓,⛔ 不能用 git checkout)
git -C <本机仓> show HEAD:src/... # 或直接在 47 侧用回滚点:
cp -r /opt/dsh/backups/seq138b-20260919-125345/opt/dshs/lib/* /opt/dshs/lib/ && systemctl restart dshs
# ② worker(106):同样用该回滚点下的 /opt/dshs-cluster/lib
# ③ 插件:确保产物回落 0.3.23(/opt/dsh/artifacts 保留旧包)
# ④ DB:v11 的 ADD COLUMN 不回滚(SQLite 无 down migration;空列不影响旧代码)
```
⚠️ 回滚 ① 会把 §五 的跨机落地修复一起回退 ⇒ 远端用户的模型落地重新变成空操作(不致命,但那是既有缺陷)。
@@ -0,0 +1,55 @@
# 139 · 品牌中文名:能力枢纽 → **能力网络**
- 日期:2026-09-19
- 状态:✅ **已上线**(静态资源直覆,无需重启;页面与接口双端实测)
- 触发(用户口径,原话):「**把页面上的能力枢纽 改为能力网络**」(同轮先说过「能力领域」,随即更正为「能力网络」)
- 上游:档案 **137**(品牌标识去 DeepSeek 图形,定名「能力枢纽」)—— 本档**只改名,不动结构**
- 落点:`web/i18n.js`、`web/admin.html`、`web/favicon.svg`、`web/design.css`(注释)、`test/i18n-brand.test.mjs`
> **TL;DR**|中文品牌名 **「能力枢纽」→「能力网络」**;**其他语言不变**(仍 `CapabilityNet`,
> 走 `en` 词条 = 所有其他语言的回退)。改名落在**四处**:i18n 的中文词条、`admin.html` 顶栏文字、
> **`favicon.svg` 的 `<title>` 与 `aria-label`**(浏览器标签页/读屏可见 —— 最容易漏)、`design.css` 注释。
> 回归测试与 `verify-static`(含 SVG 真解析)全绿;线上 4 文件两端 md5 一致。
---
## 一、为什么改这几处(判据:**用户可见面**,不是"字符串出现即改")
| 落点 | 是否用户可见 | 处置 |
|---|---|---|
| `web/i18n.js` 的 `zh` 表 `brand.name` | ✅ 登录 / 注册 / 唤醒 / 工作台页头 | **改** 为 `能力网络` |
| `web/i18n.js` 的 `en` 表 `brand.name` | ✅ 其他语言**全靠它回退** | **不动**(仍是 `CapabilityNet`) |
| `web/admin.html` 顶栏 `<span class="wordmark">` | ✅ 管理页页头(该页纯中文、未接 i18n) | **改** |
| `web/favicon.svg` 的 `<title>` / `aria-label` | ✅ 标签页提示 / 读屏 | **改** —— 只 grep 改 `.html/.js` 会漏掉它(本轮第一版就漏了) |
| `web/design.css` 第 53 行注释 | ❌ 不可见,但**写的是现行事实** | 顺手更正(留着会误导下一个人) |
| `test/i18n-brand.test.mjs` | — 测试断言 | 六条语言路径的期望值同步 |
| 档案 137 / 本档引用的历史记述 | — | **不改历史**(137 保持原样,仅由 TOP 指针指过来) |
## 二、验证
| 项 | 结果 |
|---|---|
| 品牌回归测试 | `test/i18n-brand.test.mjs` **2/2 通过**(中文三条路径 = 能力网络;英文与未支持语言 = CapabilityNet) |
| 静态校验 | `verify-static.mjs` 全合格(含 `favicon.svg` 段) |
| SVG 真解析 | `xml.etree.ElementTree.parse('web/favicon.svg')` = **OK**(⚠️ 137 那次踩过"注释含 `--` 导致整份 SVG 静默失效",本档只改文本节点,仍逐次复验) |
| 旧名残留 | `grep -rn 能力枢纽\|能力领域 web/ test/ scripts/ poc/ src/` ⇒ **仅剩 i18n 注释里一句"此前用过哪个名"的留痕**(有意保留) |
| 线上 | 4 文件(`admin.html` / `i18n.js` / `design.css` / `favicon.svg`)本机 ↔ `/opt/dshs/web/` **md5 逐一致**;回滚点 `/opt/dsh/backups/seq138-web-20260919-125345/`(4 个旧文件) |
| 零回归 | 全量 `npm test` **226 / 225 pass / 0 fail / 1 skipped** |
## 三、两个执行细节(都是既有经验的重用,不是新坑)
1. **行尾按"目标目录既有风格"逐文件对齐**(⛔ 不按 HEAD 判):服务器 `web/` 目录实测
`admin.html = CRLF` / `design.css = CRLF` / `i18n.js = LF` / `favicon.svg = LF` ⇒ 推 `admin.html` 前先用
脚本转 CRLF 并断言 `CR 数 == 行数`(本机工作树是 LF,直接 scp 会把整文件行尾翻掉)。
2. **改 i18n 的"改名类"改动必须配断言**:品牌词条漏配/写错是**静默失败**(中文用户看到英文名),
所以 `test/i18n-brand.test.mjs` 用 `node:vm` 跑**仓库里那份真实 `i18n.js`**,断言六条语言解析路径的
实际渲染结果 —— 本轮把期望值从「能力枢纽」改为「能力网络」,跑通即证。
## 四、未做 / 待办
1. 🔴 **`alotbuy.com` 的旧域页面仍会显示旧名?** 不会 —— 旧域已降级为 301 过渡装置(档案 135),
页面由同一份 `/opt/dshs/web/` 直出 ⇒ 旧域访客看到的也是新名。
2. ⚠️ **会话页面左上角**(用户实例内的 dsh 会话窗口)不属本仓 `web/`,由官方 `@deepseek-ai/dsh` 的 client bundle
渲染、受 **R2** 约束 ⇒ 本档不涉及(与档案 137 的范围声明一致)。
3. ⚠️ **词条语义澄清**:用户曾提到「小写 CapabilityNet」;本档按「中文=能力网络/其他语言=CapabilityNet」实现。
若原意是"再加一行小写英文",改动量 = `i18n.js` 一处 + `design.css` 一行(⛔ 不碰 HTML)。
+3 -1
View File
@@ -44,7 +44,7 @@
## 二、全量清单 ## 二、全量清单
> **状态摘要**(**机器生成,勿手改**):档案 **121** 份(`04-*`),另含根级编号 5 条(01/02/03/06/07),另有非编号行 14 条(README / INDEX / 技能 / poc 等)—— ✅ 20 | 🔄 5 | 🔍 1 | 📋 23 | 🟡 1 | 未标记 76。复跑 `python3 scripts/docs-index-stats.py` 取数,`--write` 就地刷新本行。 > **状态摘要**(**机器生成,勿手改**):档案 **123** 份(`04-*`),另含根级编号 5 条(01/02/03/06/07),另有非编号行 14 条(README / INDEX / 技能 / poc 等)—— ✅ 22 | 🔄 5 | 🔍 1 | 📋 23 | 🟡 1 | 未标记 76。复跑 `python3 scripts/docs-index-stats.py` 取数,`--write` 就地刷新本行。
> **分层与机读明细**(路径 / 日期 / 字符数 / 被引次数 / tier,可 `jq` 先筛后读):**`docs-manifest.json`**;复跑 `scripts/docs-manifest.py` 即刷新。 > **分层与机读明细**(路径 / 日期 / 字符数 / 被引次数 / tier,可 `jq` 先筛后读):**`docs-manifest.json`**;复跑 `scripts/docs-manifest.py` 即刷新。
> 图例:✅已落地 | 🔄维护中 | 🧪PoC | 📝待开发 | 🔍核查完成 | 📋评估 | 🟡保留兜底 | 🗄归档|🔧修复|🔴|🚧|❓|PoC 实证完成(插件 v0|已上线|生效|已实施|已实施 + API 全链路验|已冻结|已落地 + 验证通过|审计已完成 → 加固项待用户|已上线,页面全部 200|已封板,实施中|核查完成 → 收敛项 H1-|✅ 已实施并在 admin/|🔄 批次 1 因改走 bun|方案 A + handoff|根因已定位并修复(nginx|修复已编译(CI ✅),待重|已实施并上线|已修复并上线(服务已重启|清单建立|✅ 已实施并端到端验证|✅ 已实施并 live 验证|✅ 已实施并视觉验证|🔍 核查完成,P0 待决策|✅ 已实施(commit 见|✅ 已实施(commit `|✅ 已修复(commit `|🔍 取证完成|🔍 核查完成|✅ 已实施(`99de8c5|✅ 已落地|✅ 已修复|已落地并实测验证|已落地并端到端验证|🔄 第一批已修复|✅ 已实施(两批):第一批|✅ 已实施并端到端验证(co|✅ 已实施并验证(服务器已生|admin 侧已实施|✅ 已部署并生效(服务器 `|✅ 已实现、已验证、已部署|✅ 已完成并部署|✅ 全部完成并验证|✅ 已完成(2026-09-|✅ 已止损|✅ 已落地并部署(2026-|✅ 已完成并部署(2026-|✅ 已实现并单点验证|✅ 已落地并实测验证(配置|📋 标准已立|✅ 代码改造 + 组件级验证|✅ 已落地并验证(服务器 `|✅ 已部署并验证(2026-|✅ 已修复并部署|📋 评估中|🚧 执行中(R0 已完成|🚧 代码完成、产物已出|✅ 已完成并上线验证|✅ 已上线并端到端验证(后端|✅ 已修复并验证(源仓 `c|🔄 进行中(L2 机制级已验 > 图例:✅已落地 | 🔄维护中 | 🧪PoC | 📝待开发 | 🔍核查完成 | 📋评估 | 🟡保留兜底 | 🗄归档|🔧修复|🔴|🚧|❓|PoC 实证完成(插件 v0|已上线|生效|已实施|已实施 + API 全链路验|已冻结|已落地 + 验证通过|审计已完成 → 加固项待用户|已上线,页面全部 200|已封板,实施中|核查完成 → 收敛项 H1-|✅ 已实施并在 admin/|🔄 批次 1 因改走 bun|方案 A + handoff|根因已定位并修复(nginx|修复已编译(CI ✅),待重|已实施并上线|已修复并上线(服务已重启|清单建立|✅ 已实施并端到端验证|✅ 已实施并 live 验证|✅ 已实施并视觉验证|🔍 核查完成,P0 待决策|✅ 已实施(commit 见|✅ 已实施(commit `|✅ 已修复(commit `|🔍 取证完成|🔍 核查完成|✅ 已实施(`99de8c5|✅ 已落地|✅ 已修复|已落地并实测验证|已落地并端到端验证|🔄 第一批已修复|✅ 已实施(两批):第一批|✅ 已实施并端到端验证(co|✅ 已实施并验证(服务器已生|admin 侧已实施|✅ 已部署并生效(服务器 `|✅ 已实现、已验证、已部署|✅ 已完成并部署|✅ 全部完成并验证|✅ 已完成(2026-09-|✅ 已止损|✅ 已落地并部署(2026-|✅ 已完成并部署(2026-|✅ 已实现并单点验证|✅ 已落地并实测验证(配置|📋 标准已立|✅ 代码改造 + 组件级验证|✅ 已落地并验证(服务器 `|✅ 已部署并验证(2026-|✅ 已修复并部署|📋 评估中|🚧 执行中(R0 已完成|🚧 代码完成、产物已出|✅ 已完成并上线验证|✅ 已上线并端到端验证(后端|✅ 已修复并验证(源仓 `c|🔄 进行中(L2 机制级已验
@@ -182,6 +182,8 @@
| 04-135 | ✅ | **旧域 `alotbuy.com` 从项目移除**(2026-09-19 落地):代码 / 服务端 / 文档技能三层收敛为 `ai1net.com` 单域;旧域站点**降级为 301 过渡装置**(保留 ACME + `/dshs-relay` + `/dshs-overlay/bootstrap`,**30 天零流量**后退役);relay 候选 **5 → 3**(删掉两条“摘名不同、同落 47”的伪多样性);修 `wake.html` 转义点残留 + 演练脚本硬编码域造成的**假红**;参数表 2 键与 §10 指纹同步 | | 04-135 | ✅ | **旧域 `alotbuy.com` 从项目移除**(2026-09-19 落地):代码 / 服务端 / 文档技能三层收敛为 `ai1net.com` 单域;旧域站点**降级为 301 过渡装置**(保留 ACME + `/dshs-relay` + `/dshs-overlay/bootstrap`,**30 天零流量**后退役);relay 候选 **5 → 3**(删掉两条“摘名不同、同落 47”的伪多样性);修 `wake.html` 转义点残留 + 演练脚本硬编码域造成的**假红**;参数表 2 键与 §10 指纹同步 |
| 04-136 | 📋 | **控制面按两台中继取并集 —— 立项交接单**(2026-09-19 立项 · **未落地**):平台 web 层只取 47 中继 ⇒ 106 中继不被使用;根因链 7 行(取址路径 `src/web/server.ts` / `rendezvous.ts`)、**先红后绿**强制、候选 A/B 各带优缺点、验收 E1–E8 + 回滚 | | 04-136 | 📋 | **控制面按两台中继取并集 —— 立项交接单**(2026-09-19 立项 · **未落地**):平台 web 层只取 47 中继 ⇒ 106 中继不被使用;根因链 7 行(取址路径 `src/web/server.ts` / `rendezvous.ts`)、**先红后绿**强制、候选 A/B 各带优缺点、验收 E1–E8 + 回滚 |
| 04-137 | ✅ | **品牌标识改造 —— 去 DeepSeek 图形 → 平台自有标识**(2026-09-19 上线):**四页 + favicon 一并替换** —— `login` / `register` / `admin` 页头删 **DeepSeek 鲸鱼图标 +「DeepSeek」文字图形** 改平台标识(中文「**能力枢纽**」/英语及其他语言「**CapabilityNet**」,走 i18n 词条 `brand.name`,`en` 一条即覆盖其他语言回退);`portal` 顶栏换图标(页面名「管理门户」保留);**新建 `web/favicon.svg`**(平台自有 hub 图标,避开 DeepSeek 蓝);🔴 **会话页面左上角那处不动**(用户明示「先不替换」+它属官方 dsh client bundle,受 R2 约束);🔴 踩坑:**SVG 的 XML 注释不得含 ASCII 双连字符 `--`**(写 CSS 变量名即踩)⇒ 整份 SVG 解析失败、图标**静默不显示**,已加进 `verify-static.mjs` 并做反向自证;新增 `test/i18n-brand.test.mjs`|档案 `04-调整方案/137-品牌标识改造-去DeepSeek图形.md` | | 04-137 | ✅ | **品牌标识改造 —— 去 DeepSeek 图形 → 平台自有标识**(2026-09-19 上线):**四页 + favicon 一并替换** —— `login` / `register` / `admin` 页头删 **DeepSeek 鲸鱼图标 +「DeepSeek」文字图形** 改平台标识(中文「**能力枢纽**」/英语及其他语言「**CapabilityNet**」,走 i18n 词条 `brand.name`,`en` 一条即覆盖其他语言回退);`portal` 顶栏换图标(页面名「管理门户」保留);**新建 `web/favicon.svg`**(平台自有 hub 图标,避开 DeepSeek 蓝);🔴 **会话页面左上角那处不动**(用户明示「先不替换」+它属官方 dsh client bundle,受 R2 约束);🔴 踩坑:**SVG 的 XML 注释不得含 ASCII 双连字符 `--`**(写 CSS 变量名即踩)⇒ 整份 SVG 解析失败、图标**静默不显示**,已加进 `verify-static.mjs` 并做反向自证;新增 `test/i18n-brand.test.mjs`|档案 `04-调整方案/137-品牌标识改造-去DeepSeek图形.md` |
| 04-138 | ✅ | **平台共享模型 —— 管理员逐用户授权(默认关闭)**(2026-09-19 上线):新增列 `users.shared_model_granted`(v11,**默认 0**),**生效 = `granted ∧ enabled`** —— 前者 admin 在**用户列表**里逐个开(新增「共享模型」列),后者仍是用户自己的偏好(保留);未授权时设置页**连"平台共享模型"那一块都不渲染**(插件 0.3.24);🔴 **顺带修掉一个既有真缺陷**:落地层原先用**本机 fs** 写用户 home,而用户卷在 worker 上 ⇒ **模型落地对"实例不在本机"的用户一直是静默空操作**(托管清单被清空、目标文件一字节没动;档案 87 的模型设置页对 guest 这类用户从未生效)⇒ 改为走 `UserFs` + worker agent 新增**白名单**端点 `/fs/home-read|write`;两腿实测(未授权撤销 / 授权恢复)|档案 `04-调整方案/138-平台共享模型-管理员逐用户授权.md` |
| 04-139 | ✅ | **品牌中文名:能力枢纽 → 能力网络**(2026-09-19 上线):其他语言仍 `CapabilityNet`(en 词条即回退);落点四处 = i18n 中文词条 / `admin.html` 顶栏 / **`favicon.svg` 的 `<title>`+`aria-label`**(最易漏)/ `design.css` 注释;品牌回归测试期望值同步,`verify-static` + SVG 真解析全绿;🔴 行尾按**目标目录既有风格**逐文件对齐(服务器 `admin.html`=CRLF 而本机=LF,直传会翻掉整个文件)|档案 `04-调整方案/139-品牌中文名改为能力网络.md` |
| — | 🗄 | `archive/dsh-improvement-plan-20260909-full.md`:拆分前 19 章 | | — | 🗄 | `archive/dsh-improvement-plan-20260909-full.md`:拆分前 19 章 |
| — | ✅ | `skills/dsh-change-workflow/SKILL.md`:六阶段 + **红线 R1-R11**(工作副本在本机 `.workbuddy/skills/`)| | — | ✅ | `skills/dsh-change-workflow/SKILL.md`:六阶段 + **红线 R1-R11**(工作副本在本机 `.workbuddy/skills/`)|
| — | ✅ | `skills/dsh-decision-method/SKILL.md`:**改造决策方法论**(用户有效决策 U1-U12 / AI 有效决策 A1-A13 / 反例 X1-X8 + 确认最优解十问 + 交互 UI 专项清单)(工作副本在本机 `.workbuddy/skills/`)| | — | ✅ | `skills/dsh-decision-method/SKILL.md`:**改造决策方法论**(用户有效决策 U1-U12 / AI 有效决策 A1-A13 / 反例 X1-X8 + 确认最优解十问 + 交互 UI 专项清单)(工作副本在本机 `.workbuddy/skills/`)|
+43 -13
View File
@@ -1,20 +1,20 @@
{ {
"generatedFrom": "scripts/docs-manifest.py", "generatedFrom": "scripts/docs-manifest.py",
"counts": { "counts": {
"files": 194, "files": 196,
"chars": 2050190 "chars": 2062355
}, },
"tiers": { "tiers": {
"hot": 6, "hot": 6,
"cur": 25, "cur": 25,
"warm": 61, "warm": 61,
"cold": 42, "cold": 44,
"doc": 60 "doc": 60
}, },
"domains": { "domains": {
"platform": 75, "platform": 76,
"method": 47, "method": 47,
"ui": 17, "ui": 18,
"plugin": 27, "plugin": 27,
"ops": 16, "ops": 16,
"?": 8, "?": 8,
@@ -22,7 +22,7 @@
}, },
"layers": { "layers": {
"L0": 1, "L0": 1,
"L5": 149, "L5": 151,
"L4": 22, "L4": 22,
"L2": 3, "L2": 3,
"?": 4, "?": 4,
@@ -218,7 +218,7 @@
"date": "04-10", "date": "04-10",
"chars": 6261, "chars": 6261,
"lines": 129, "lines": 129,
"refs": 58, "refs": 60,
"refsCurrent": 4, "refsCurrent": 4,
"tier": "hot", "tier": "hot",
"layer": "L5", "layer": "L5",
@@ -713,7 +713,7 @@
"date": "04-13", "date": "04-13",
"chars": 2444, "chars": 2444,
"lines": 58, "lines": 58,
"refs": 9, "refs": 12,
"refsCurrent": 0, "refsCurrent": 0,
"tier": "warm", "tier": "warm",
"layer": "L5", "layer": "L5",
@@ -786,8 +786,8 @@
"title": "137-品牌标识改造(去 DeepSeek 图形 → 平台自有标识)", "title": "137-品牌标识改造(去 DeepSeek 图形 → 平台自有标识)",
"status": "✅", "status": "✅",
"date": "09-19", "date": "09-19",
"chars": 6287, "chars": 6401,
"lines": 119, "lines": 122,
"refs": 0, "refs": 0,
"refsCurrent": 0, "refsCurrent": 0,
"tier": "cold", "tier": "cold",
@@ -795,6 +795,36 @@
"domain": "ui", "domain": "ui",
"tldr": "" "tldr": ""
}, },
{
"path": "04-调整方案/138-平台共享模型-管理员逐用户授权.md",
"num": "138",
"title": "138 · 平台共享模型 —— 管理员**逐用户**授权(默认关闭)",
"status": "✅ 已上线并端到端验证(控制",
"date": "2026-09-19",
"chars": 8578,
"lines": 148,
"refs": 0,
"refsCurrent": 0,
"tier": "cold",
"layer": "L5",
"domain": "platform",
"tldr": "① 共享模型从「人人默认可用(用户可自关)」改成「**管理员逐用户授权**」——新增列"
},
{
"path": "04-调整方案/139-品牌中文名改为能力网络.md",
"num": "139",
"title": "139 · 品牌中文名:能力枢纽 → **能力网络**",
"status": "✅ 已上线(静态资源直覆,无",
"date": "2026-09-19",
"chars": 2666,
"lines": 56,
"refs": 0,
"refsCurrent": 0,
"tier": "cold",
"layer": "L5",
"domain": "ui",
"tldr": "中文品牌名 **「能力枢纽」→「能力网络」**;**其他语言不变**(仍 `CapabilityNet`,"
},
{ {
"path": "04-调整方案/14-实例会话敏感信息暴露面审计与加固.md", "path": "04-调整方案/14-实例会话敏感信息暴露面审计与加固.md",
"num": "14", "num": "14",
@@ -1898,7 +1928,7 @@
"date": "2026-09-13", "date": "2026-09-13",
"chars": 14028, "chars": 14028,
"lines": 218, "lines": 218,
"refs": 14, "refs": 18,
"refsCurrent": 0, "refsCurrent": 0,
"tier": "warm", "tier": "warm",
"layer": "L5", "layer": "L5",
@@ -2181,8 +2211,8 @@
"title": "dsh 平台文档导航(INDEX)", "title": "dsh 平台文档导航(INDEX)",
"status": "?", "status": "?",
"date": "", "date": "",
"chars": 24468, "chars": 25275,
"lines": 268, "lines": 270,
"refs": 0, "refs": 0,
"refsCurrent": 0, "refsCurrent": 0,
"tier": "doc", "tier": "doc",
+6 -1
View File
@@ -2919,7 +2919,12 @@ window.__ModuleLoader__.load({
jsxRuntime.jsx("h1", { className: "pa-hd", children: t("ms.title") }), jsxRuntime.jsx("h1", { className: "pa-hd", children: t("ms.title") }),
jsxRuntime.jsx("p", { className: "pa-sub", children: t("ms.sub") }), jsxRuntime.jsx("p", { className: "pa-sub", children: t("ms.sub") }),
// ── admin 配的「平台共享模型」(口径②:也列在这里、用户可开关)── // ── admin 配的「平台共享模型」(档案 138:**管理员逐用户开启后才有这一块**)──
// 判据 = `shared.granted`(后端 `users.shared_model_granted`,默认关闭)。
// 未授权 ⇒ 整块不渲染(用户口径:「admin 开启,用户才能使用 + 才在设置页展示」)。
// ⚠️ 与 `sharedOn`(`sharedModelEnabled`,用户自己的偏好)**不是一回事**:
// granted 决定"有没有",sharedOn 决定"你自己要不要用"。
!shared.granted ? null :
jsxRuntime.jsxs("div", { className: "pa-box", style: { marginBottom: 16 }, children: [ jsxRuntime.jsxs("div", { className: "pa-box", style: { marginBottom: 16 }, children: [
jsxRuntime.jsxs("div", { className: "pa-card-h", children: [ jsxRuntime.jsxs("div", { className: "pa-card-h", children: [
jsxRuntime.jsx("span", { children: t("ms.shared") }), jsxRuntime.jsx("span", { children: t("ms.shared") }),
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@dsh-local/business-plugins", "name": "@dsh-local/business-plugins",
"version": "0.3.23", "version": "0.3.24",
"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 最大可以浮动Line truncated "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 最大可以浮动Line truncated
"type": "module", "type": "module",
"main": "lib/index.js", "main": "lib/index.js",
+3 -1
View File
@@ -86,8 +86,10 @@ const FAKE_DATA = {
{ id: 2, name: 'Moonshot', route: 'moonshot', enabled: false, baseUrl: null, api: null }, { id: 2, name: 'Moonshot', route: 'moonshot', enabled: false, baseUrl: null, api: null },
{ id: 3, name: '我的中转', route: 'my-gw', enabled: true, baseUrl: 'https://gw.example/v1', api: 'openai-completions' }, { id: 3, name: '我的中转', route: 'my-gw', enabled: true, baseUrl: 'https://gw.example/v1', api: 'openai-completions' },
], ],
shared: { available: true, name: '内置 DeepSeek', owner: 'admin', ownerIsMe: true, count: 1 }, // 档案 138:`granted` = 管理员授权(前端据它决定是否渲染「平台共享模型」区块)
shared: { available: true, name: '内置 DeepSeek', owner: 'admin', ownerIsMe: true, granted: true, count: 1 },
sharedModelEnabled: true, sharedModelEnabled: true,
sharedModelGranted: true,
protocols: ['openai-completions', 'anthropic-messages'], protocols: ['openai-completions', 'anthropic-messages'],
} }
const FAKE_PROVIDERS = [ const FAKE_PROVIDERS = [
+19 -1
View File
@@ -61,8 +61,10 @@ const DATA = {
{ id: "k2", name: "我的中转网关", enabled: false, updatedAt: 2, route: "my-gw", baseUrl: "https://api.example.com/v1", api: "openai-completions", models: '["gpt-4o","gpt-4o-mini"]' }, { id: "k2", name: "我的中转网关", enabled: false, updatedAt: 2, route: "my-gw", baseUrl: "https://api.example.com/v1", api: "openai-completions", models: '["gpt-4o","gpt-4o-mini"]' },
], ],
effective: "own", effective: "own",
shared: { available: true, name: "shared-key", owner: "admin", ownerIsMe: false, enabled: true, count: 1 }, // `granted`(档案 138)= **管理员**有没有给这个人开;`enabled` = 用户自己的偏好。
shared: { available: true, name: "shared-key", owner: "admin", ownerIsMe: false, enabled: true, granted: true, count: 1 },
sharedModelEnabled: true, sharedModelEnabled: true,
sharedModelGranted: true,
protocols: ["openai-completions", "openai-responses", "anthropic-messages"], protocols: ["openai-completions", "openai-responses", "anthropic-messages"],
}, },
// 档案 87 补做:官方 pi-ai 厂家目录(「新增」选择框的数据源)—— 国内 + 国际各一家代表 // 档案 87 补做:官方 pi-ai 厂家目录(「新增」选择框的数据源)—— 国内 + 国际各一家代表
@@ -247,6 +249,22 @@ ok("非 admin:不出现任何功能项", !PORTAL_ITEMS.some(k => tUser.include
ok("模型设置:标题与副标题", tMs.includes("模型设置") && tMs.includes("模型选择器")); ok("模型设置:标题与副标题", tMs.includes("模型设置") && tMs.includes("模型选择器"));
// 口径②:共享模型也列入且可开关 // 口径②:共享模型也列入且可开关
ok("模型设置:平台共享模型区块 + 开关按钮", tMs.includes("平台共享模型") && tMs.includes("停用共享模型")); ok("模型设置:平台共享模型区块 + 开关按钮", tMs.includes("平台共享模型") && tMs.includes("停用共享模型"));
// ── 档案 138(2026-09-19 用户口径):**管理员逐用户开启后才有这一块** ──────────────
// 判据 = `shared.granted`。未授权时整块不渲染(用户原话:「admin 在用户列表中开启…
// 用户才能在会话中使用(以及在设置的模型设置页面展示)」)。
// ⚠️ 这条断言必须**正反两腿都跑**:只测"已授权时能看到"会漏掉"未授权时也没隐藏"这种
// 半实现(那就是门禁只做了一半 —— 用户看不到授权状态却仍能看到共享模型)。
{
const keep = DATA["/api/me/keys"].shared;
DATA["/api/me/keys"].shared = Object.assign({}, keep, { granted: false });
const tNo = text(await render(ms.Comp)).join(" | ");
ok("模型设置:**未授权**时「平台共享模型」整块不渲染(档案 138)",
!tNo.includes("平台共享模型") && !tNo.includes("停用共享模型"),
"-> 仍有共享字样 = 门禁没生效");
DATA["/api/me/keys"].shared = keep;
const tYes = text(await render(ms.Comp)).join(" | ");
ok("模型设置:恢复授权后该块回来(正反两腿对照)", tYes.includes("平台共享模型"));
}
// 口径①:条目各自开关 ⇒ 要有「启用/停用」而不是「设为当前」 // 口径①:条目各自开关 ⇒ 要有「启用/停用」而不是「设为当前」
ok("模型设置:条目按各自状态渲染徽章(已启用 / 已停用)", tMs.includes("已启用") && tMs.includes("已停用")); ok("模型设置:条目按各自状态渲染徽章(已启用 / 已停用)", tMs.includes("已启用") && tMs.includes("已停用"));
// 2026-09-15 用户口径:「内置 DeepSeek」**去掉「内置」**,就叫「DeepSeek」。 // 2026-09-15 用户口径:「内置 DeepSeek」**去掉「内置」**,就叫「DeepSeek」。
+6
View File
@@ -115,6 +115,12 @@ export interface DbAdapter {
/** 档案 86:用户是否使用「平台共享模型」(admin 配的那把)—— 用户侧偏好。 */ /** 档案 86:用户是否使用「平台共享模型」(admin 配的那把)—— 用户侧偏好。 */
getSharedModelEnabled(userId: string): Promise<boolean> getSharedModelEnabled(userId: string): Promise<boolean>
setSharedModelEnabled(userId: string, enabled: boolean): Promise<boolean> setSharedModelEnabled(userId: string, enabled: boolean): Promise<boolean>
/**
* 档案 138(v11):**管理员**是否已给该用户开启「平台共享模型」—— 逐用户门禁,默认关闭。
* 生效 = 本项 ∧ `getSharedModelEnabled`(缺一不给)。
*/
getSharedModelGranted(userId: string): Promise<boolean>
setSharedModelGranted(userId: string, granted: boolean): Promise<boolean>
deleteCredentialKey(userId: string, id: string): Promise<boolean> deleteCredentialKey(userId: string, id: string): Promise<boolean>
// instances (desired state the k8s controller reconciles against — docs/k8s.md §5.7) // instances (desired state the k8s controller reconciles against — docs/k8s.md §5.7)
upsertInstance(input: UpsertDshInstanceInput): Promise<void> upsertInstance(input: UpsertDshInstanceInput): Promise<void>
+23 -2
View File
@@ -54,7 +54,11 @@ import {
// both backends. This is process-global and idempotent. // both backends. This is process-global and idempotent.
types.setTypeParser(20, (value: string) => Number(value)) types.setTypeParser(20, (value: string) => Number(value))
const USER_COLS = 'id, username, pass_hash, role, home_dir, api_key_ref, created_at, approved_by, uid, email' // ⚠️ **本文件有一份自己的列清单**(PG 方言的查询都直接用它)—— ⛔ 与 `repo.ts` 的
// `USER_COLS` 是**两份**,加列时**两处都要改**(档案 138 实测踩到:只改了 repo.ts
// ⇒ PG 下 `listPublicUsers` 读不到 `shared_model_granted`,admin 用户列表恒显示"未开启")。
const USER_COLS =
'id, username, pass_hash, role, home_dir, api_key_ref, created_at, approved_by, uid, email, shared_model_granted'
const EMAIL_CODE_COLS = const EMAIL_CODE_COLS =
'id, email, purpose, code_hash, status, attempts, ip, username, reason, created_at, expires_at, consumed_at' 'id, email, purpose, code_hash, status, attempts, ip, username, reason, created_at, expires_at, consumed_at'
const DOMAIN_COLS = 'id, user_id, domain, verified, nginx_config, updated_at' const DOMAIN_COLS = 'id, user_id, domain, verified, nginx_config, updated_at'
@@ -137,6 +141,8 @@ export class PgAdapter implements DbAdapter {
approved_by: null, approved_by: null,
uid, uid,
email: input.email ?? null, email: input.email ?? null,
// v11:新用户未授权平台共享模型(默认关闭),见 repo.ts 同处注释。
shared_model_granted: false,
} }
}) })
} catch (e) { } catch (e) {
@@ -310,7 +316,7 @@ export class PgAdapter implements DbAdapter {
async findSessionWithUser(tokenHash: string): Promise<SessionUser | undefined> { async findSessionWithUser(tokenHash: string): Promise<SessionUser | undefined> {
const { rows } = await this.pool.query( const { rows } = await this.pool.query(
`SELECT u.id, u.username, u.pass_hash, u.role, u.home_dir, u.api_key_ref, u.created_at, u.approved_by, u.uid, `SELECT u.id, u.username, u.pass_hash, u.role, u.home_dir, u.api_key_ref, u.created_at, u.approved_by, u.uid,
s.expires_at u.shared_model_granted, u.email, s.expires_at
FROM sessions s JOIN users u ON s.user_id = u.id FROM sessions s JOIN users u ON s.user_id = u.id
WHERE s.token_hash = $1`, WHERE s.token_hash = $1`,
[tokenHash], [tokenHash],
@@ -589,6 +595,21 @@ export class PgAdapter implements DbAdapter {
return (result.rowCount ?? 0) > 0 return (result.rowCount ?? 0) > 0
} }
/** 管理员是否已给该用户开启「平台共享模型」(档案 138 · v11)。缺失行按 `false`(失败关闭)。 */
async getSharedModelGranted(userId: string): Promise<boolean> {
const { rows } = await this.pool.query('SELECT shared_model_granted FROM users WHERE id = $1', [userId])
const row = rows[0] as { shared_model_granted: number } | undefined
return row === undefined ? false : Number(row.shared_model_granted) !== 0
}
async setSharedModelGranted(userId: string, granted: boolean): Promise<boolean> {
const result = await this.pool.query('UPDATE users SET shared_model_granted = $1 WHERE id = $2', [
granted ? 1 : 0,
userId,
])
return (result.rowCount ?? 0) > 0
}
async deleteCredentialKey(userId: string, id: string): Promise<boolean> { async deleteCredentialKey(userId: string, id: string): Promise<boolean> {
const result = await this.pool.query('DELETE FROM credential_vault WHERE id = $1 AND user_id = $2', [id, userId]) const result = await this.pool.query('DELETE FROM credential_vault WHERE id = $1 AND user_id = $2', [id, userId])
return (result.rowCount ?? 0) > 0 return (result.rowCount ?? 0) > 0
+26 -2
View File
@@ -49,7 +49,8 @@ import {
type Workspace, type Workspace,
} from './types.js' } from './types.js'
const USER_COLS = 'id, username, pass_hash, role, home_dir, api_key_ref, created_at, approved_by, uid, email' const USER_COLS =
'id, username, pass_hash, role, home_dir, api_key_ref, created_at, approved_by, uid, email, shared_model_granted'
const EMAIL_CODE_COLS = const EMAIL_CODE_COLS =
'id, email, purpose, code_hash, status, attempts, ip, username, reason, created_at, expires_at, consumed_at' 'id, email, purpose, code_hash, status, attempts, ip, username, reason, created_at, expires_at, consumed_at'
const DOMAIN_COLS = 'id, user_id, domain, verified, nginx_config, updated_at' const DOMAIN_COLS = 'id, user_id, domain, verified, nginx_config, updated_at'
@@ -78,6 +79,9 @@ export function createUser(db: Database, input: CreateUserInput, baseUid: number
approved_by: null, approved_by: null,
uid, uid,
email: input.email ?? null, email: input.email ?? null,
// v11:新用户**未授权**平台共享模型(默认关闭)—— 与列默认值一致,这里显式写出来
// 是为了让"新用户拿到什么"在这一个地方就能读全,不必再去翻迁移 SQL。
shared_model_granted: false,
} }
})() })()
} }
@@ -481,6 +485,26 @@ export function setSharedModelEnabled(db: Database, userId: string, enabled: boo
return info.changes > 0 return info.changes > 0
} }
/**
* **管理员**是否已给该用户开启「平台共享模型」(档案 138 · v11)—— 逐用户门禁。
*
* 缺失行按 **`false`**(= 未授权):这与 `getSharedModelEnabled` 的"缺失按 true"**故意相反**,
* 因为两者方向不同 —— 用户偏好缺失时"多给"是无害的,而门禁缺失时"多给"就等于把门打开了。
* 门禁类判据一律**失败关闭**。
*/
export function getSharedModelGranted(db: Database, userId: string): boolean {
const row = prepare(db, 'SELECT shared_model_granted FROM users WHERE id = ?').get(userId) as
| { shared_model_granted: number }
| undefined
return row === undefined ? false : row.shared_model_granted !== 0
}
/** 设置「平台共享模型」的管理员授权(档案 138)。@returns 是否命中该用户。 */
export function setSharedModelGranted(db: Database, userId: string, granted: boolean): boolean {
const info = prepare(db, 'UPDATE users SET shared_model_granted = ? WHERE id = ?').run(granted ? 1 : 0, userId)
return info.changes > 0
}
/** Delete a named key (by id, scoped to the user). */ /** Delete a named key (by id, scoped to the user). */
export function deleteCredentialKey(db: Database, userId: string, id: string): boolean { export function deleteCredentialKey(db: Database, userId: string, id: string): boolean {
const info = prepare(db, 'DELETE FROM credential_vault WHERE id = ? AND user_id = ?').run(id, userId) const info = prepare(db, 'DELETE FROM credential_vault WHERE id = ? AND user_id = ?').run(id, userId)
@@ -530,7 +554,7 @@ export function findSessionWithUser(db: Database, tokenHash: string): SessionUse
const row = prepare( const row = prepare(
db, db,
`SELECT u.id, u.username, u.pass_hash, u.role, u.home_dir, u.api_key_ref, u.created_at, u.approved_by, u.uid, `SELECT u.id, u.username, u.pass_hash, u.role, u.home_dir, u.api_key_ref, u.created_at, u.approved_by, u.uid,
s.expires_at u.shared_model_granted, u.email, s.expires_at
FROM sessions s JOIN users u ON s.user_id = u.id FROM sessions s JOIN users u ON s.user_id = u.id
WHERE s.token_hash = ?`, WHERE s.token_hash = ?`,
).get(tokenHash) as Record<string, unknown> | undefined ).get(tokenHash) as Record<string, unknown> | undefined
+25
View File
@@ -442,6 +442,30 @@ CREATE INDEX IF NOT EXISTS idx_email_codes_email ON email_codes (email, purpose,
CREATE INDEX IF NOT EXISTS idx_email_codes_ip ON email_codes (ip, created_at); CREATE INDEX IF NOT EXISTS idx_email_codes_ip ON email_codes (ip, created_at);
` `
// v11: **平台共享模型改为「管理员逐用户授权」**(2026-09-19 用户口径)。
//
// 口径原文:「admin 设置的共享模型,需要 admin 在用户列表中开启(新增选项,默认关闭),
// 用户才能在会话中使用(以及在设置的模型设置页面展示)」。
//
// 为什么**新开一列**而不是把 v6 的 `shared_model_enabled` 改成默认 0:
// · v6 那一列的语义是**用户侧偏好**(用户能在「设置 → 模型设置」里自己关掉,默认开)——
// 需求要的是**管理员门禁**,两者是**不同的人、不同的意图**;
// · 若共用一列,用户在自己的设置里点一下就能把自己"授权"了 ⇒ 门禁形同不存在;
// · 故:`shared_model_granted` = **管理员授权**(本列,`DEFAULT 0` = 默认关闭,
// 用户自己改不了);`shared_model_enabled` = **用户偏好**(不变)。
// · **生效 = granted AND enabled**(`server.ts#sharedLandingRows`)。
//
// ⚠️ `DEFAULT 0` 让**存量行也一并变 0**(PG/SQLite 加列都用默认值回填)⇒ 迁移后
// **所有既有用户都处于"未授权"**,需要 admin 在用户列表里逐个开启。这正是"默认关闭"的字面语义,
// 也是这条门禁第一次生效的可见证据(验收见档案 138)。
const SQLITE_V11 = `
ALTER TABLE users ADD COLUMN shared_model_granted INTEGER NOT NULL DEFAULT 0;
`
const PG_V11 = `
ALTER TABLE users ADD COLUMN shared_model_granted INTEGER NOT NULL DEFAULT 0;
`
interface Migration { interface Migration {
version: number version: number
name: string name: string
@@ -460,6 +484,7 @@ const MIGRATIONS: readonly Migration[] = [
{ version: 8, name: 'host reachability via (覆盖网络 S2)', sqlite: SQLITE_V8, pg: PG_V8 }, { version: 8, name: 'host reachability via (覆盖网络 S2)', sqlite: SQLITE_V8, pg: PG_V8 },
{ version: 9, name: 'host network id (覆盖网络 P0-1)', sqlite: SQLITE_V9, pg: PG_V9 }, { version: 9, name: 'host network id (覆盖网络 P0-1)', sqlite: SQLITE_V9, pg: PG_V9 },
{ version: 10, name: 'user email + email verification codes', sqlite: SQLITE_V10, pg: PG_V10 }, { version: 10, name: 'user email + email verification codes', sqlite: SQLITE_V10, pg: PG_V10 },
{ version: 11, name: 'shared model admin grant (per-user, default off)', sqlite: SQLITE_V11, pg: PG_V11 },
] ]
/** Apply unapplied SQLite migrations inside a single transaction. */ /** Apply unapplied SQLite migrations inside a single transaction. */
+10
View File
@@ -44,6 +44,7 @@ import {
getEnabledPluginIds as getEnabledPluginIdsSync, getEnabledPluginIds as getEnabledPluginIdsSync,
getOrCreateWorkspace as getOrCreateWorkspaceSync, getOrCreateWorkspace as getOrCreateWorkspaceSync,
getSharedModelEnabled as getSharedModelEnabledSync, getSharedModelEnabled as getSharedModelEnabledSync,
getSharedModelGranted as getSharedModelGrantedSync,
latestSentEmailCode as latestSentEmailCodeSync, latestSentEmailCode as latestSentEmailCodeSync,
listBusinessPlugins as listBusinessPluginsSync, listBusinessPlugins as listBusinessPluginsSync,
listCredentialKeys as listCredentialKeysSync, listCredentialKeys as listCredentialKeysSync,
@@ -61,6 +62,7 @@ import {
setFolderPlugins as setFolderPluginsSync, setFolderPlugins as setFolderPluginsSync,
setInstanceStatus as setInstanceStatusSync, setInstanceStatus as setInstanceStatusSync,
setSharedModelEnabled as setSharedModelEnabledSync, setSharedModelEnabled as setSharedModelEnabledSync,
setSharedModelGranted as setSharedModelGrantedSync,
setUserRole as setUserRoleSync, setUserRole as setUserRoleSync,
setUserUid as setUserUidSync, setUserUid as setUserUidSync,
toggleCredentialKey as toggleCredentialKeySync, toggleCredentialKey as toggleCredentialKeySync,
@@ -331,6 +333,14 @@ export class SqliteAdapter implements DbAdapter {
return setSharedModelEnabledSync(this.db, userId, enabled) return setSharedModelEnabledSync(this.db, userId, enabled)
} }
async getSharedModelGranted(userId: string): Promise<boolean> {
return getSharedModelGrantedSync(this.db, userId)
}
async setSharedModelGranted(userId: string, granted: boolean): Promise<boolean> {
return setSharedModelGrantedSync(this.db, userId, granted)
}
async deleteCredentialKey(userId: string, id: string): Promise<boolean> { async deleteCredentialKey(userId: string, id: string): Promise<boolean> {
return deleteCredentialKeySync(this.db, userId, id) return deleteCredentialKeySync(this.db, userId, id)
} }
+20 -1
View File
@@ -25,6 +25,14 @@ export interface User {
uid: number | null uid: number | null
/** Registration e-mail (v10); null for the legacy rows created before v10. */ /** Registration e-mail (v10); null for the legacy rows created before v10. */
email: string | null email: string | null
/**
* **管理员**是否已给该用户开启「平台共享模型」(v11,**默认 false**)。
*
* 与 `shared_model_enabled`(用户侧偏好,默认 true)是**两回事**:本列是门禁(只有 admin
* 能在用户列表里改),两者**同时为真**才真正把 admin 配的模型落到该用户实例上。
* 详见 `schema.ts` 的 `SQLITE_V11` 注释。
*/
shared_model_granted: boolean
} }
/** One `email_codes` row: a sent code *or* a rejected/failed send attempt. */ /** One `email_codes` row: a sent code *or* a rejected/failed send attempt. */
@@ -52,6 +60,8 @@ export interface PublicUser {
username: string username: string
role: UserRole role: UserRole
createdAt: number createdAt: number
/** v11:管理员是否已为该用户开启「平台共享模型」(admin 用户列表要显示它)。 */
sharedModelGranted: boolean
} }
/** A persisted login session (token stored only as its hash). */ /** A persisted login session (token stored only as its hash). */
@@ -269,7 +279,13 @@ export interface UpsertDshInstanceInput {
} }
export function toPublicUser(user: User): PublicUser { export function toPublicUser(user: User): PublicUser {
return { id: user.id, username: user.username, role: user.role, createdAt: user.created_at } return {
id: user.id,
username: user.username,
role: user.role,
createdAt: user.created_at,
sharedModelGranted: user.shared_model_granted,
}
} }
// Row mappers. Shared by both adapters — they read the same column names, so the // Row mappers. Shared by both adapters — they read the same column names, so the
@@ -288,6 +304,9 @@ export function toUser(row: Record<string, unknown>): User {
approved_by: (row.approved_by as string | null) ?? null, approved_by: (row.approved_by as string | null) ?? null,
uid: (row.uid as number | null) ?? null, uid: (row.uid as number | null) ?? null,
email: (row.email as string | null) ?? null, email: (row.email as string | null) ?? null,
// ⚠️ 用 `?? 0`:`findSessionWithUser` 走的是自己的列清单,未取该列时这里是 `undefined`
// —— 若写成 `!== 0`,`undefined !== 0` 会得到 `true`(把没授权的人当成已授权)。
shared_model_granted: Number(row.shared_model_granted ?? 0) !== 0,
} }
} }
+35 -2
View File
@@ -7,12 +7,12 @@
*/ */
import { chownSync, chmodSync } from 'node:fs' import { chownSync, chmodSync } from 'node:fs'
import { mkdir, readFile, writeFile } from 'node:fs/promises' import { chmod, chown, mkdir, readFile, writeFile } from 'node:fs/promises'
import { stat } from 'node:fs/promises' import { stat } from 'node:fs/promises'
import { basename, join } from 'node:path' import { basename, join } from 'node:path'
import { PathEscapeError, resolveWithinRoot, safeFilename } from '../web/middleware/fs-guard.js' import { PathEscapeError, resolveWithinRoot, safeFilename } from '../web/middleware/fs-guard.js'
import { listInstalledPlugins, type PluginInfo } from './plugins.js' import { listInstalledPlugins, type PluginInfo } from './plugins.js'
import { UserFsError, type UserFs } from './user-fs.js' import { UserFsError, isHomeFileName, type HomeFileName, type UserFs } from './user-fs.js'
import { ensureDir, handoffPath, homeRoot, listDir, rejectSymlinkEscape, workspaceRoot, type FsEntry } from './workspace.js' import { ensureDir, handoffPath, homeRoot, listDir, rejectSymlinkEscape, workspaceRoot, type FsEntry } from './workspace.js'
/** Resolve a user id to that user's data root (`<dataRoot>/users/<id>`, or a /** Resolve a user id to that user's data root (`<dataRoot>/users/<id>`, or a
@@ -141,6 +141,39 @@ export class LocalUserFs implements UserFs {
await writeFile(handoffPath(root), content) await writeFile(handoffPath(root), content)
} }
/**
* 读 home 下的平台托管配置文件(档案 138)。
* 名字走**白名单**(只允许固定文件名)⇒ 不可能拼出 `../` 之类的越界路径。
*/
async readHomeFile(userId: string, name: HomeFileName): Promise<string | null> {
if (!isHomeFileName(name)) throw new UserFsError('bad_path')
try {
return await readFile(join(homeRoot(this.rootFor(userId)), name), 'utf8')
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null
throw err
}
}
/**
* 写 home 下的平台托管配置文件:`0600` + chown 给 **home 属主**。
* chown 不能省 —— 实例以 `dsh-<uid>` 身份读它(见 `home-files.ts` 头注释① ②)。
*/
async writeHomeFile(userId: string, name: HomeFileName, text: string): Promise<void> {
if (!isHomeFileName(name)) throw new UserFsError('bad_path')
const home = homeRoot(this.rootFor(userId))
ensureDir(home)
const file = join(home, name)
await writeFile(file, text, { mode: 0o600 })
try {
const st = await stat(home)
await chown(file, st.uid, st.gid)
await chmod(file, 0o600)
} catch {
/* chown 失败(非 root 运行等)不阻断 —— 与 home-files.ts 同款 */
}
}
/** Resolve a workspace-relative path, self-healing the root the way the /** Resolve a workspace-relative path, self-healing the root the way the
* pre-seam routes did (every one of them called `ensureWorkspaceRoot` first). */ * pre-seam routes did (every one of them called `ensureWorkspaceRoot` first). */
private resolve(userId: string, relPath: string): string { private resolve(userId: string, relPath: string): string {
+15 -1
View File
@@ -17,7 +17,7 @@
import { AGENT_TOKEN_HEADER } from '../worker/agent.js' import { AGENT_TOKEN_HEADER } from '../worker/agent.js'
import { PathEscapeError, resolveWithinRoot } from '../web/middleware/fs-guard.js' import { PathEscapeError, resolveWithinRoot } from '../web/middleware/fs-guard.js'
import type { PluginInfo } from './plugins.js' import type { PluginInfo } from './plugins.js'
import { UserFsError, isUserFsErrorCode, type UserFs } from './user-fs.js' import { UserFsError, isUserFsErrorCode, type HomeFileName, type UserFs } from './user-fs.js'
import type { FsEntry } from './workspace.js' import type { FsEntry } from './workspace.js'
import { userRoot, workspaceRoot } from './workspace.js' import { userRoot, workspaceRoot } from './workspace.js'
@@ -201,6 +201,20 @@ export class RemoteUserFs implements UserFs {
await this.post('/fs/handoff', { userId, content }) await this.post('/fs/handoff', { userId, content })
} }
/**
* 读用户 home 下的平台托管配置文件(档案 138)—— 走 agent 的 `/fs/home-read`,
* 目标是 `hostIdFor(userId)` 钉住的那台机(与实例同机,见 `user-fs.ts#readHomeFile` 注释)。
* 文件不存在 ⇒ `null`。
*/
async readHomeFile(userId: string, name: HomeFileName): Promise<string | null> {
const out = await this.post<{ text: string | null }>('/fs/home-read', { userId, name })
return out.text
}
async writeHomeFile(userId: string, name: HomeFileName, text: string): Promise<void> {
await this.post('/fs/home-write', { userId, name, text })
}
/** 探测 worker 的 dataRoot(用于启动时的基线一致性检查,见 `server.ts`)。 */ /** 探测 worker 的 dataRoot(用于启动时的基线一致性检查,见 `server.ts`)。 */
async probeWorkerRoot(): Promise<string | undefined> { async probeWorkerRoot(): Promise<string | undefined> {
try { try {
+35
View File
@@ -90,4 +90,39 @@ export interface UserFs {
listInstalledPlugins(userId: string): Promise<PluginInfo[]> listInstalledPlugins(userId: string): Promise<PluginInfo[]>
/** Write the post-restart command handoff the watchdog reads. */ /** Write the post-restart command handoff the watchdog reads. */
writeHandoff(userId: string, content: string): Promise<void> writeHandoff(userId: string, content: string): Promise<void>
/**
* 读用户 **home**(`$DSH_HOME`)下的**平台托管配置文件**(档案 138)。
*
* 为什么必须走本 seam 而不是直接 `fs.readFile(home_dir)`:**用户卷跟着实例走**
* —— 实例在 worker 上时,`home/` 就在那台机器上。平台侧直接 `readFile` 只会读到
* 自己盘上一个**不存在的路径**(返回空串、不报错)⇒ 落地层静默变成空操作
* (2026-09-19 实测:托管清单被清空、目标文件一个字节没动)。
* 归属的钉法与本 seam 的其它方法**完全一致**(`hostIdFor` 的粘性选机,见 `server.ts`
* 「文件写到 A、实例起在 B」那段注释)⇒ 落地与实例必然同机。
*
* `name` 只接受 {@link HOME_FILE_NAMES} 里的**固定文件名**(⛔ 不收路径):
* 这几个是平台自己写的配置,用户的其它文件不归平台碰。
* 文件不存在 ⇒ `null`(**不是**抛错:首次落地就该从"没有文件"开始)。
*/
readHomeFile(userId: string, name: HomeFileName): Promise<string | null>
/**
* 写用户 home 下的平台托管配置文件。
* ⚠️ 与 `writeHomeFile`(本地版)同一组约束:`0600` + **chown 给 home 属主**
* —— 实例以 `dsh-<uid>` 身份跑,root 写的 0600 文件它**读不了**(档案 43 / R10 同族)。
*/
writeHomeFile(userId: string, name: HomeFileName, text: string): Promise<void>
}
/**
* 平台托管的 home 配置文件名白名单(⛔ 只许这些**裸文件名**,不许带路径)。
*
* - `settings.yaml` —— dsh 自己的设置(平台只写 `llm-pi-ai.providers.*` 与 `locale.*`);
* - `.credentials.yaml` —— dsh 的凭据 refs(平台只写自己 refs 段)。
*/
export const HOME_FILE_NAMES = ['settings.yaml', '.credentials.yaml'] as const
export type HomeFileName = (typeof HOME_FILE_NAMES)[number]
/** 白名单校验(两端都调一次:agent 侧拦住非法入参,本地实现拦住越界调用)。 */
export function isHomeFileName(name: unknown): name is HomeFileName {
return typeof name === 'string' && (HOME_FILE_NAMES as readonly string[]).includes(name)
} }
+23 -11
View File
@@ -32,17 +32,7 @@ export async function readTextOrEmpty(file: string): Promise<string> {
* 实例以 dsh-<uid> 身份运行,root 写的 600 文件它读不了 ⇒ 最后一步不能省。 * 实例以 dsh-<uid> 身份运行,root 写的 600 文件它读不了 ⇒ 最后一步不能省。
*/ */
export async function writeHomeFile(homeDir: string, file: string, text: string): Promise<void> { export async function writeHomeFile(homeDir: string, file: string, text: string): Promise<void> {
try { await backupHomeFile(homeDir, file, text)
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 }) await writeFile(file, text, { mode: 0o600 })
try { try {
const st = await stat(homeDir) const st = await stat(homeDir)
@@ -51,3 +41,25 @@ export async function writeHomeFile(homeDir: string, file: string, text: string)
/* chown 失败(非 root 运行等)不阻断 */ /* chown 失败(非 root 运行等)不阻断 */
} }
} }
/**
* 只做**备份**那一步(写进平台备份目录),**不碰用户文件**。
*
* 为什么单独抽出来(档案 138):用户卷可能**不在本机**(实例在 worker 上)⇒ 写入必须走
* `UserFs`(会按归属路由到那台机),而备份是**平台自己**的副本 —— 落在控制面的
* `/opt/dsh/backups` 正合适,也不该为了备份再往远端开一条通道。
* 备份的命名规则与 {@link writeHomeFile} 的①步**逐字一致**(⛔ 别各写一套)。
*/
export async function backupHomeFile(homeDir: string, fileOrName: string, text: string): Promise<void> {
try {
const bakDir = process.env.DSH_PLATFORM_BACKUP_DIR ?? '/opt/dsh/backups'
await mkdir(bakDir, { recursive: true })
const label = basename(fileOrName).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 {
/* 备份失败不阻断 */
}
}
+54
View File
@@ -14,6 +14,16 @@ import { userRoot } from '../../fs/workspace.js'
const ENSURE_BIZ_PLUGINS = const ENSURE_BIZ_PLUGINS =
process.env.DSH_ENSURE_BIZ_PLUGINS ?? '/opt/dshs/scripts/ensure-biz-plugins.cjs' process.env.DSH_ENSURE_BIZ_PLUGINS ?? '/opt/dshs/scripts/ensure-biz-plugins.cjs'
/** 档案 138:「平台共享模型」逐用户授权的入参(只有开关本身)。 */
const sharedModelSchema = {
body: {
type: 'object',
required: ['enabled'],
additionalProperties: false,
properties: { enabled: { type: 'boolean' } },
},
} as const
export const adminRoutes: FastifyPluginAsync = async (app) => { export const adminRoutes: FastifyPluginAsync = async (app) => {
// 档案 28:存储用量面板(读取维护脚本生成的快照文件,避免每次请求都 du) // 档案 28:存储用量面板(读取维护脚本生成的快照文件,避免每次请求都 du)
app.get('/api/admin/storage', { preHandler: requireAdmin }, async () => { app.get('/api/admin/storage', { preHandler: requireAdmin }, async () => {
@@ -66,6 +76,50 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
return { ok: true } return { ok: true }
}) })
/**
* **逐个用户**开启/关闭「平台共享模型」(档案 138 · 2026-09-19 用户口径)。
*
* 口径原文:「admin 设置的共享模型,需要 admin 在用户列表中开启(新增选项,**默认关闭**),
* 用户才能在会话中使用(以及在设置的模型设置页面展示)」。
*
* 为什么放在 admin 路由而不是复用 `/api/me/models/shared`:那一条写的是**用户偏好**
* (`shared_model_enabled`,用户自己在设置页开关);本条的写入目标是**管理员授权**
* (`shared_model_granted`,默认 0)—— 两列、两个主体,⛔ 不能共用一个写入口,
* 否则用户点一下就等于给自己授权了(门禁失效)。
*
* ⚠️ 落地发生在 **spawn 时**(`server.ts#landModels`)⇒ 改完必须让目标用户的实例重启
* 才能看到变化。这里直接调 `restartMain(id)`:**只影响这一个用户**;他没在跑就是空操作
* (下次启动自然按新授权落地)。这与用户自己改条目的行为一致(档案 87 §四)。
*/
app.post(
'/api/admin/users/:id/models/shared',
{ preHandler: requireAdmin, schema: sharedModelSchema },
async (request, reply) => {
const { id } = request.params as { id: string }
const { enabled } = request.body as { enabled: boolean }
const user = await app.db.findUserById(id)
if (user === undefined) return reply.code(404).send({ error: 'not_found' })
if (!(await app.db.setSharedModelGranted(id, enabled))) return reply.code(404).send({ error: 'not_found' })
await app.db.audit(
request.user?.id ?? null,
'shared_model_grant',
JSON.stringify({ userId: id, username: user.username, enabled }),
)
// 重启目标实例是**尽力而为**:授权已经落库了,它才是这次操作的真结果。
// ⚠️ 两种"没重启"都不算失败:① 实例没在跑(`restartMain` 返 undefined)——
// 下次启动自然按新授权落地;② 归属租约被别的持有者占着(抛 LeaseBusyError)
// —— 那不是本次授权的问题,⛔ 不能把它报成"开启失败"(2026-09-19 实测:
// 报 500 时 admin 界面显示"操作失败",而库里其实已经改对了)。
let restarted = false
try {
restarted = (await app.supervisor.restartMain(id)) !== undefined
} catch (err) {
app.log.warn({ err, userId: id }, '共享模型授权已落库,但重启目标实例失败(下次启动生效)')
}
return { ok: true, sharedModelGranted: enabled, restarted }
},
)
// 永久删除用户:admin 不可删;删除 = 停实例 → DB 事务清全部关联行 → // 永久删除用户:admin 不可删;删除 = 停实例 → DB 事务清全部关联行 →
// 删数据目录(users/<id>/)→ 删 provision 创建的 OS 账号 dsh-<short>。 // 删数据目录(users/<id>/)→ 删 provision 创建的 OS 账号 dsh-<short>。
app.delete('/api/admin/users/:id', { preHandler: requireAdmin }, async (request, reply) => { app.delete('/api/admin/users/:id', { preHandler: requireAdmin }, async (request, reply) => {
+41 -8
View File
@@ -12,7 +12,7 @@ import { deriveKey, encrypt } from '../../crypto.js'
import { toPublicUser } from '../../db/types.js' import { toPublicUser } from '../../db/types.js'
import { catalogDiagnostics, isCatalogProvider, isCnProvider, listCatalogProviders } from '../model-catalog.js' import { catalogDiagnostics, isCatalogProvider, isCnProvider, listCatalogProviders } from '../model-catalog.js'
import { PROTOCOLS } from '../model-landing.js' import { PROTOCOLS } from '../model-landing.js'
import { readTextOrEmpty, writeHomeFile } from '../home-files.js' import { backupHomeFile } from '../home-files.js'
import { isLocaleId, reconcileLocalePreference } from '../locale-pref.js' import { isLocaleId, reconcileLocalePreference } from '../locale-pref.js'
import { import {
captchaActive, captchaActive,
@@ -380,23 +380,37 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
if ((await app.db.getEnabledCredentialKeyRef(userId)) !== null) return 'own' if ((await app.db.getEnabledCredentialKeyRef(userId)) !== null) return 'own'
// 关掉了共享开关的人**就是** none —— 这正是验收③要的语义。 // 关掉了共享开关的人**就是** none —— 这正是验收③要的语义。
if (!(await app.db.getSharedModelEnabled(userId))) return 'none' if (!(await app.db.getSharedModelEnabled(userId))) return 'none'
// 档案 138:**管理员没授权 ⇒ 就是 none**(门禁先于内容;用户自己的条目不受影响,
// 因为上面那一步已经判过了)。
if (!(await app.db.getSharedModelGranted(userId))) return 'none'
const admins = (await app.db.listPublicUsers()).filter((u) => u.role === 'admin') const admins = (await app.db.listPublicUsers()).filter((u) => u.role === 'admin')
if (admins.length === 0) return 'none' if (admins.length === 0) return 'none'
return (await app.db.getEnabledCredentialKeyRef(admins[0].id)) !== null ? 'shared' : 'none' return (await app.db.getEnabledCredentialKeyRef(admins[0].id)) !== null ? 'shared' : 'none'
} }
/** 平台共享模型的**非敏感**信息(名字 / 归属 / 条数;绝不返回密钥本身)。 */ /**
* 平台共享模型的**非敏感**信息(名字 / 归属 / 条数;绝不返回密钥本身)。
*
* `granted`(档案 138)= **管理员有没有给这个人开**;前端据它决定**要不要渲染**整个
* 「平台共享模型」区块 —— 未授权时连块都不出现(用户口径:开了才"在使用 + 在设置页展示")。
* ⚠️ 未授权时**仍然返回** `owner` / `count` 等字段(不额外做信息收窄),因为这条接口
* 只有**本人或 admin** 能看,且这些是"平台有哪些共享模型"这种非敏感目录信息。
*/
async function sharedKeyInfo(userId: string): Promise<{ async function sharedKeyInfo(userId: string): Promise<{
available: boolean available: boolean
name: string | null name: string | null
owner: string | null owner: string | null
ownerIsMe: boolean ownerIsMe: boolean
enabled: boolean enabled: boolean
granted: boolean
count: number count: number
}> { }> {
const admins = (await app.db.listPublicUsers()).filter((u) => u.role === 'admin') const admins = (await app.db.listPublicUsers()).filter((u) => u.role === 'admin')
const enabled = await app.db.getSharedModelEnabled(userId) const enabled = await app.db.getSharedModelEnabled(userId)
if (admins.length === 0) return { available: false, name: null, owner: null, ownerIsMe: false, enabled, count: 0 } const granted = await app.db.getSharedModelGranted(userId)
if (admins.length === 0) {
return { available: false, name: null, owner: null, ownerIsMe: false, enabled, granted, count: 0 }
}
// 档案 87:共享**不再假设只有一把** —— admin 也能配多条(与用户侧同一套口径)。 // 档案 87:共享**不再假设只有一把** —— admin 也能配多条(与用户侧同一套口径)。
const keys = await app.db.listEnabledCredentialKeys(admins[0].id) const keys = await app.db.listEnabledCredentialKeys(admins[0].id)
// `ownerIsMe`:admin 看的是**自己**配的那些 ⇒ 前端文案要区分「我配的」与「别人配的」。 // `ownerIsMe`:admin 看的是**自己**配的那些 ⇒ 前端文案要区分「我配的」与「别人配的」。
@@ -406,6 +420,7 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
owner: admins[0].username, owner: admins[0].username,
ownerIsMe: admins[0].id === userId, ownerIsMe: admins[0].id === userId,
enabled, enabled,
granted,
count: keys.length, count: keys.length,
} }
} }
@@ -418,9 +433,12 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
app.get('/api/me/keys', { preHandler: requireAuth }, async (request) => ({ app.get('/api/me/keys', { preHandler: requireAuth }, async (request) => ({
keys: await app.db.listCredentialKeys(request.user!.id), keys: await app.db.listCredentialKeys(request.user!.id),
effective: await keySourceOf(request.user!.id), effective: await keySourceOf(request.user!.id),
// `shared.granted`(档案 138)= admin 是否已授权;`shared.enabled` = 用户自己的偏好。
shared: await sharedKeyInfo(request.user!.id), shared: await sharedKeyInfo(request.user!.id),
// 档 87:把「共享开关」与「协议枚举」一并给出,免得前端各写一份常量然后漂掉。 // 档 87:把「共享开关」与「协议枚举」一并给出,免得前端各写一份常量然后漂掉。
sharedModelEnabled: await app.db.getSharedModelEnabled(request.user!.id), sharedModelEnabled: await app.db.getSharedModelEnabled(request.user!.id),
// 档 138:管理员授权(前端只在 true 时渲染「平台共享模型」区块)。
sharedModelGranted: await app.db.getSharedModelGranted(request.user!.id),
protocols: [...PROTOCOLS], protocols: [...PROTOCOLS],
})) }))
@@ -536,7 +554,15 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
return { ok: true } return { ok: true }
}) })
/** 平台共享模型的开关(档案 87 口径②)—— 只动**自己**的偏好,不碰 admin 的配置。 */ /**
* 平台共享模型的开关(档案 87 口径② + 档案 138)—— 只动**自己**的偏好,不碰 admin 的配置。
*
* ⚠️ 语义边界(别混):本路由写的是 `shared_model_enabled` = **用户偏好**("我要不要用");
* **能否用**由 admin 在用户列表里写的 `shared_model_granted` 先定(默认关闭)。
* 两者是**与**关系 ⇒ 用户把开关打开、但 admin 没授权时,仍然什么也不会落地。
* 所以这里**不校验授权**:让用户能先关掉自己不想用的、也允许他先打开(授权一到即生效),
* 且**不泄露**"管理员是否授权了别人"这类信息。
*/
app.post('/api/me/models/shared', { preHandler: requireAuth, schema: toggleSchema }, async (request, reply) => { app.post('/api/me/models/shared', { preHandler: requireAuth, schema: toggleSchema }, async (request, reply) => {
const { enabled } = request.body as { enabled: boolean } const { enabled } = request.body as { enabled: boolean }
if (!(await app.db.setSharedModelEnabled(request.user!.id, enabled))) { if (!(await app.db.setSharedModelEnabled(request.user!.id, enabled))) {
@@ -558,15 +584,22 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
* 本路由**替官方把它自己的设置写进它自己的文件**(顶层 `locale: → preference:`), * 本路由**替官方把它自己的设置写进它自己的文件**(顶层 `locale: → preference:`),
* ⇒ 官方语义不破(实例启动时读自己的设置即生效)+ 用户选择跨页面 / 跨重启保留。 * ⇒ 官方语义不破(实例启动时读自己的设置即生效)+ 用户选择跨页面 / 跨重启保留。
* ⚠️ 写文件沿用 `model-landing` 那套(备份到平台目录 + chown 给 home 属主),见 `home-files.ts`。 * ⚠️ 写文件沿用 `model-landing` 那套(备份到平台目录 + chown 给 home 属主),见 `home-files.ts`。
* 🔴 但**读写必须走 `app.userFs`**(档案 138 §五):用户卷跟着实例走 —— 实例在 worker 上时
* `home/` 就在那台机,直接 `join(home_dir, …)` + 本机 fs 只会读到自己盘上一个不存在的路径
* (**空串、不报错**)⇒ 这条路由对"实例不在控制面本机"的用户**静默失效**(语言选择永远存不上)。
*/ */
app.post('/api/me/locale', { preHandler: requireAuth, schema: localeSchema }, async (request, reply) => { app.post('/api/me/locale', { preHandler: requireAuth, schema: localeSchema }, async (request, reply) => {
const { locale } = request.body as { locale: string } const { locale } = request.body as { locale: string }
if (!isLocaleId(locale)) return reply.code(400).send({ error: 'invalid_locale' }) if (!isLocaleId(locale)) return reply.code(400).send({ error: 'invalid_locale' })
const homeDir = homeRoot(userRoot(app.config.dataRoot, request.user!.id)) const userId = request.user!.id
const file = join(homeDir, 'settings.yaml') const text = (await app.userFs.readHomeFile(userId, 'settings.yaml')) ?? ''
const text = await readTextOrEmpty(file)
const next = reconcileLocalePreference(text, locale) const next = reconcileLocalePreference(text, locale)
if (next.changed) await writeHomeFile(homeDir, file, next.text) if (next.changed) {
// 备份放**平台侧**(控制面的备份目录),写入走 `userFs`(可能落到远端那台机)——
// 与 `server.ts#landModels` 同一套口径,⛔ 别在这里自己拼绝对路径。
await backupHomeFile(homeRoot(userRoot(app.config.dataRoot, userId)), 'settings.yaml', text)
await app.userFs.writeHomeFile(userId, 'settings.yaml', next.text)
}
return { ok: true, locale, changed: next.changed } return { ok: true, locale, changed: next.changed }
}) })
+50 -10
View File
@@ -52,7 +52,7 @@ import {
refForEntry, refForEntry,
type SettingsEntry, type SettingsEntry,
} from './model-landing.js' } from './model-landing.js'
import { readTextOrEmpty, writeHomeFile } from './home-files.js' import { backupHomeFile } from './home-files.js'
import { rateLimit } from './middleware/rate-limit.js' import { rateLimit } from './middleware/rate-limit.js'
import { authRoutes } from './routes/auth.js' import { authRoutes } from './routes/auth.js'
import { adminRoutes } from './routes/admin.js' import { adminRoutes } from './routes/admin.js'
@@ -115,11 +115,16 @@ function isAllowedOrigin(origin: string, baseDomain: string): boolean {
export async function buildServer(config: ServerConfig): Promise<FastifyInstance> { export async function buildServer(config: ServerConfig): Promise<FastifyInstance> {
const db = await createDbAdapter(config) const db = await createDbAdapter(config)
const encryptionKey = deriveKey(config.encryptionSecret) const encryptionKey = deriveKey(config.encryptionSecret)
// ── 模型条目落地(档案 87)────────────────────────────────────────────────── // ── 模型条目落地(档案 87 + 138)────────────────────────────────────────────
// 用户口径(2026-09-13 定):条目**各自开关、可同时启用**;admin 配的**平台共享模型 // 用户口径(2026-09-13 定):条目**各自开关、可同时启用**;admin 配的**平台共享模型
// **也列入**、用户可开关(`users.shared_model_enabled`);平台只负责把「**已启用**」 // **也列入**、用户可开关(`users.shared_model_enabled`);平台只负责把「**已启用**」
// 的都配好 —— 具体用哪个模型在 dsh 对话框的模型选择器里挑。 // 的都配好 —— 具体用哪个模型在 dsh 对话框的模型选择器里挑。
// //
// 🔴 2026-09-19 追加门禁(档案 138):平台共享模型**不再是"人人默认可用"** ——
// admin 必须在**用户列表**里逐个开启(`users.shared_model_granted`,v11,默认关闭),
// 用户才能用(也才会在「设置 → 模型设置」里看到那一块)。
// ⇒ 落地判据 = `granted ∧ enabled`(两个开关分属**不同的人**:前者 admin,后者用户)。
//
// 为什么必须由平台写文件:官方「设置 → 模型」页在平台环境**必然报错**(该页要 Host // 为什么必须由平台写文件:官方「设置 → 模型」页在平台环境**必然报错**(该页要 Host
// settings 镜像,而平台是浏览器经域名访问远程服务器 ⇒ `isLoopback=false` ⇒ persistence // settings 镜像,而平台是浏览器经域名访问远程服务器 ⇒ `isLoopback=false` ⇒ persistence
// 降级 `memory` ⇒ 页面报「加载提供方目录失败」)。详见 `ensure-role-profile-patch.cjs`。 // 降级 `memory` ⇒ 页面报「加载提供方目录失败」)。详见 `ensure-role-profile-patch.cjs`。
@@ -160,8 +165,17 @@ export async function buildServer(config: ServerConfig): Promise<FastifyInstance
// `readTextOrEmpty` / `writeHomeFile` 已抽到 `./home-files.js`(2026-09-15:语言偏好 // `readTextOrEmpty` / `writeHomeFile` 已抽到 `./home-files.js`(2026-09-15:语言偏好
// 持久化也要用同一套「写 home 文件」语义 —— 与其复制一份,不如共用;约束见该模块头注释)。 // 持久化也要用同一套「写 home 文件」语义 —— 与其复制一份,不如共用;约束见该模块头注释)。
/** 平台共享条目(admin 配的、已启用的那些)—— 只在该用户开关打开、且他不是那个 admin 时纳入。 */ /**
* 平台共享条目(admin 配的、已启用的那些)—— 三条件**全满足**才纳入:
* ① **管理员已授权**该用户(档案 138 · v11,默认关闭,只能由 admin 在用户列表里开)
* ② **用户自己没有关掉**(档案 87 的用户侧偏好)
* ③ 该用户**不是那个 admin 本人**(admin 用的是他自己配的,再回落一次等于重复)
*
* ①② 是**两个不同的人的两个开关**,缺一不给 —— 判据是"生效 = 门禁 ∧ 偏好",
* ⛔ 别把任意一个当成"可以覆盖另一个"。
*/
const sharedLandingRows = async (userId: string): Promise<CredentialLandingRow[]> => { const sharedLandingRows = async (userId: string): Promise<CredentialLandingRow[]> => {
if (!(await db.getSharedModelGranted(userId))) return []
if (!(await db.getSharedModelEnabled(userId))) return [] if (!(await db.getSharedModelEnabled(userId))) return []
const admins = (await db.listPublicUsers()).filter((u) => u.role === 'admin') const admins = (await db.listPublicUsers()).filter((u) => u.role === 'admin')
if (admins.length === 0 || admins[0].id === userId) return [] if (admins.length === 0 || admins[0].id === userId) return []
@@ -213,10 +227,14 @@ export async function buildServer(config: ServerConfig): Promise<FastifyInstance
} }
} }
} }
const credFile = join(owner.home_dir, '.credentials.yaml') // 🔴 读写**必须走 `userFs`**(档案 138)—— 用户卷跟着实例走:实例在 worker 上时
const setFile = join(owner.home_dir, 'settings.yaml') // `home/` 就在那台机。原先这里直接 `join(owner.home_dir, …)` + 本机 fs ⇒
const credText = await readTextOrEmpty(credFile) // 控制面读到自己盘上一个不存在的路径(**空串、不报错**)⇒ 落地静默变成空操作:
const setText = await readTextOrEmpty(setFile) // 托管清单被清空、目标文件一个字节没动(2026-09-19 实测,guest 就是这种用户)。
// `userFs` 的归属与本 seam 其它方法**同一份**粘性选机(`hostIdForFile`)⇒
// "落地与实例同机"由这条路由保证,⛔ 别在这里自己拼路径。
const credText = (await userFs.readHomeFile(userId, '.credentials.yaml')) ?? ''
const setText = (await userFs.readHomeFile(userId, 'settings.yaml')) ?? ''
// 一次性交接(档案 87):老实现把平台共享 key 写进 `refs.DEEPSEEK_API_KEY` 时没有托管清单, // 一次性交接(档案 87):老实现把平台共享 key 写进 `refs.DEEPSEEK_API_KEY` 时没有托管清单,
// 新逻辑会把它当成"用户自己写的" ⇒ 关掉共享开关后那行仍留着("关掉即生效"不成立)。 // 新逻辑会把它当成"用户自己写的" ⇒ 关掉共享开关后那行仍留着("关掉即生效"不成立)。
// 首次运行(没有任何清单)且**文件里那行确实等于平台共享 key 明文**时,认领它; // 首次运行(没有任何清单)且**文件里那行确实等于平台共享 key 明文**时,认领它;
@@ -230,12 +248,28 @@ export async function buildServer(config: ServerConfig): Promise<FastifyInstance
} }
const nextCred = reconcileCredentials(credText, creds, prevRefs) const nextCred = reconcileCredentials(credText, creds, prevRefs)
const nextSet = reconcileSettings(setText, providers, previous.routes) const nextSet = reconcileSettings(setText, providers, previous.routes)
if (nextCred.text !== credText) await writeHomeFile(owner.home_dir, credFile, nextCred.text) // 备份在**平台侧**做(旧文本就在手上),写入走 `userFs`(可能落到远端那台机)。
if (nextSet.text !== setText) await writeHomeFile(owner.home_dir, setFile, nextSet.text) if (nextCred.text !== credText) {
await backupHomeFile(owner.home_dir, '.credentials.yaml', credText)
await userFs.writeHomeFile(userId, '.credentials.yaml', nextCred.text)
}
if (nextSet.text !== setText) {
await backupHomeFile(owner.home_dir, 'settings.yaml', setText)
await userFs.writeHomeFile(userId, 'settings.yaml', nextSet.text)
}
await writeManaged(userId, { refs: nextCred.managed, routes: nextSet.managed }) await writeManaged(userId, { refs: nextCred.managed, routes: nextSet.managed })
} }
/** 保底:平台共享的那把内置 DeepSeek key 明文 —— 只在写配置失败退回 env 注入时才用。 */ /**
* 平台共享的那把内置 DeepSeek key 明文。
*
* 🔴 **本函数刻意不判门禁**(档案 138 踩过):它有两个用途,其中一个**不该**被判。
* ① 「一次性交接」用它**认领**老实现写下的那一行(判断"这行是不是平台自己写的")
* —— 这是**归属判据**,与"该用户现在有没有授权"无关。若在这里判门禁,被撤销授权的
* 用户反而**认领不出来** ⇒ 那行永远删不掉 ⇒ "关掉即生效"不成立(红腿实测到了)。
* ② 写配置失败时退回 env 注入的保底值 —— 这一路**必须**判门禁,判在**调用点**
* (`resolveApiKey` 里),⛔ 别挪回这里。
*/
const sharedDeepseekKey = async (): Promise<string | null> => { const sharedDeepseekKey = async (): Promise<string | null> => {
const admins = (await db.listPublicUsers()).filter((u) => u.role === 'admin') const admins = (await db.listPublicUsers()).filter((u) => u.role === 'admin')
if (admins.length === 0) return null if (admins.length === 0) return null
@@ -260,6 +294,12 @@ export async function buildServer(config: ServerConfig): Promise<FastifyInstance
return null return null
} catch (err) { } catch (err) {
console.error('model landing failed, falling back to env injection', err) console.error('model landing failed, falling back to env injection', err)
// 🔴 **门禁判在这里**(不能挪进 `sharedDeepseekKey`):这是"落地失败"的应急路,
// 不判就等于给未授权用户在异常路径上开门 —— 门禁类判据一律失败关闭。
if (!(await db.getSharedModelGranted(userId))) return null
if (!(await db.getSharedModelEnabled(userId))) return null
const admins = (await db.listPublicUsers()).filter((u) => u.role === 'admin')
if (admins.length === 0 || admins[0].id === userId) return null
return await sharedDeepseekKey() return await sharedDeepseekKey()
} }
} }
+30 -1
View File
@@ -25,7 +25,7 @@ import Fastify, { type FastifyInstance, type FastifyReply } from 'fastify'
import type { ServerConfig } from '../config.js' import type { ServerConfig } from '../config.js'
import { LocalUserFs } from '../fs/local-user-fs.js' import { LocalUserFs } from '../fs/local-user-fs.js'
import { userRoot } from '../fs/workspace.js' import { userRoot } from '../fs/workspace.js'
import { isUserFsErrorCode, UserFsError } from '../fs/user-fs.js' import { isHomeFileName, isUserFsErrorCode, UserFsError, type HomeFileName } from '../fs/user-fs.js'
import { hashUid } from '../isolation.js' import { hashUid } from '../isolation.js'
import { LocalSpawner } from '../supervisor/orchestrator.js' import { LocalSpawner } from '../supervisor/orchestrator.js'
import { normalizeTunnelTarget, SshTunnel, type WorkerTunnel } from './tunnel.js' import { normalizeTunnelTarget, SshTunnel, type WorkerTunnel } from './tunnel.js'
@@ -585,6 +585,35 @@ export function buildWorkerAgent(
})) ?? reply })) ?? reply
}) })
/**
* **home 下的平台托管配置文件**读(档案 138)—— 为什么必须由 worker 提供:
* 用户卷(含 `home/`)在**本机**,控制面(Manager)隔着网络 ⇒ 它直接读只会读到
* 自己盘上一个不存在的路径(返回空串、还不报错 ⇒ 静默空操作)。
*
* ⛔ 只收**裸文件名**且必须在白名单里(`settings.yaml` / `.credentials.yaml`):
* 这是"平台写自己的两个配置文件",⛔ 不是"给远端一个任意文件读接口"。
* 越界/非法名一律 400 `bad_path`。
*/
app.post('/fs/home-read', async (request, reply) => {
const body = request.body as { userId?: string; name?: unknown }
if (body.userId === undefined) return reply.code(400).send({ error: 'userId is required' })
if (!isHomeFileName(body.name)) return reply.code(400).send({ error: 'bad_path' })
const out = await fsCall(reply, () => userFs.readHomeFile(body.userId as string, body.name as HomeFileName))
return out === undefined ? reply : { text: out }
})
app.post('/fs/home-write', async (request, reply) => {
const body = request.body as { userId?: string; name?: unknown; text?: unknown }
if (body.userId === undefined || typeof body.text !== 'string') {
return reply.code(400).send({ error: 'userId and text are required' })
}
if (!isHomeFileName(body.name)) return reply.code(400).send({ error: 'bad_path' })
return (await fsCall(reply, async () => {
await userFs.writeHomeFile(body.userId as string, body.name as HomeFileName, body.text as string)
return { ok: true }
})) ?? reply
})
/** 本机 dataRoot(Manager 的 RemoteUserFs 用它做 `resolvePath` 的路径数学)。 */ /** 本机 dataRoot(Manager 的 RemoteUserFs 用它做 `resolvePath` 的路径数学)。 */
app.get('/fs/root', async () => ({ dataRoot: config.dataRoot })) app.get('/fs/root', async () => ({ dataRoot: config.dataRoot }))
+34
View File
@@ -118,6 +118,40 @@ function register(backend, makeAdapter) {
} }
}) })
// 档案 138(v11):管理员逐用户授权 —— **默认关闭**、可开可关、且与用户偏好**互相独立**。
// 这三条判据是门禁的全部内容;它们任一被写反(尤其"默认"那条)都等于门禁不存在。
test(`${backend}: sharedModelGranted 默认关、可开可关,且与用户偏好互不影响(档案 138)`, async () => {
const db = await makeAdapter()
try {
await db.createUser(user('a', 'alice'))
assert.equal(await db.getSharedModelGranted('a'), false, 'v11 默认 false(授权默认关闭)')
assert.equal(await db.setSharedModelGranted('a', true), true)
assert.equal(await db.getSharedModelGranted('a'), true)
// 用户偏好仍是它自己的默认值 —— 授权不改变偏好(两列互不覆盖)。
assert.equal(await db.getSharedModelEnabled('a'), true, '授权不改动用户侧偏好')
assert.equal(await db.setSharedModelGranted('a', false), true)
assert.equal(await db.getSharedModelGranted('a'), false)
// 未知用户:授权按 false(**失败关闭**),偏好按 true(宁可多给)—— 两条故意相反,钉死它。
assert.equal(await db.getSharedModelGranted('nobody'), false)
assert.equal(await db.getSharedModelEnabled('nobody'), true)
} finally {
await db.close()
}
})
test(`${backend}: listPublicUsers 带出 sharedModelGranted(admin 列表要用)`, async () => {
const db = await makeAdapter()
try {
await db.createUser(user('a', 'alice'))
await db.setSharedModelGranted('a', true)
const users = await db.listPublicUsers()
assert.equal(users.length, 1)
assert.equal(users[0].sharedModelGranted, true)
} finally {
await db.close()
}
})
test(`${backend}: selectCredentialKey 不再关掉别的条目(档案 87)`, async () => { test(`${backend}: selectCredentialKey 不再关掉别的条目(档案 87)`, async () => {
const db = await makeAdapter() const db = await makeAdapter()
try { try {
+6 -6
View File
@@ -1,8 +1,8 @@
/** /**
* `web/i18n.js` 的品牌文案渲染回归(2026-09-19,档案 134 配套)。 * `web/i18n.js` 的品牌文案渲染回归(2026-09-19,档案 134 配套)。
* *
* 为什么单独钉这一条:品牌标识从 DeepSeek 图形换成了**文字**(中文「能力枢纽」/ * 为什么单独钉这一条:品牌标识从 DeepSeek 图形换成了**文字**(中文「能力网络」(2026-09-19
* 英文及其他语言 `CapabilityNet`)。词条漏配或 key 写错时,i18n 会**静默回退英文** * 定名)/英文及其他语言 `CapabilityNet`)。词条漏配或 key 写错时,i18n 会**静默回退英文**
* —— 页面上看不出报错,只有中文用户看到英文名。这类"静默失败"必须靠测试钉住。 * —— 页面上看不出报错,只有中文用户看到英文名。这类"静默失败"必须靠测试钉住。
* *
* 做法:用 `node:vm` 跑**仓库里那份真实的 `web/i18n.js`**(不是复制一遍逻辑), * 做法:用 `node:vm` 跑**仓库里那份真实的 `web/i18n.js`**(不是复制一遍逻辑),
@@ -69,13 +69,13 @@ function renderBrand({ search = '', cookie = '', language = 'en-US' } = {}) {
return { brand: brandNode.textContent, lang: sandbox.window.I18N.lang } return { brand: brandNode.textContent, lang: sandbox.window.I18N.lang }
} }
test('品牌文案:中文显示「能力枢纽」,英文及其他语言显示 CapabilityNet', () => { test('品牌文案:中文显示「能力网络」,英文及其他语言显示 CapabilityNet', () => {
const cases = [ const cases = [
['默认(浏览器 en-US)', { language: 'en-US' }, 'CapabilityNet'], ['默认(浏览器 en-US)', { language: 'en-US' }, 'CapabilityNet'],
['中文(浏览器 zh-CN)', { language: 'zh-CN' }, '能力枢纽'], ['中文(浏览器 zh-CN)', { language: 'zh-CN' }, '能力网络'],
['中文(?lang=zh)', { search: '?lang=zh', language: 'en-US' }, '能力枢纽'], ['中文(?lang=zh)', { search: '?lang=zh', language: 'en-US' }, '能力网络'],
['英文(?lang=en 覆盖中文浏览器)', { search: '?lang=en', language: 'zh-CN' }, 'CapabilityNet'], ['英文(?lang=en 覆盖中文浏览器)', { search: '?lang=en', language: 'zh-CN' }, 'CapabilityNet'],
['cookie dsh_lang=zh', { cookie: 'dsh_lang=zh', language: 'en-US' }, '能力枢纽'], ['cookie dsh_lang=zh', { cookie: 'dsh_lang=zh', language: 'en-US' }, '能力网络'],
['未支持语言(ja)⇒ 回退英文', { language: 'ja-JP' }, 'CapabilityNet'], ['未支持语言(ja)⇒ 回退英文', { language: 'ja-JP' }, 'CapabilityNet'],
] ]
for (const [label, opts, expected] of cases) { for (const [label, opts, expected] of cases) {
+21
View File
@@ -118,3 +118,24 @@ test("readFile: 符号链接逃逸被拒(不跟随工作区内的链接)", a
if (!tryLink("/etc/passwd", join(ws, "link.txt"), "file")) return if (!tryLink("/etc/passwd", join(ws, "link.txt"), "file")) return
await assert.rejects(() => fs.readFile(USER, "link.txt"), isBadPath) await assert.rejects(() => fs.readFile(USER, "link.txt"), isBadPath)
}) })
// ── 档案 138:home 下平台托管配置文件(跨机读写那一层的地基)────────────────────
// 这组判据存在的理由:落地层原先直接 `join(home_dir, name)` + 本机 fs ⇒ 对"实例在
// worker 上"的用户静默空操作(读回空串、写到自己盘的野路径)。改成走 UserFs 之后,
// 名字必须**只**能是那两个固定文件名,否则就等于给远端开了一个任意文件读写口。
test("home 文件:读写往返 + 不存在 ⇒ null(不是抛错)", async (t) => {
const { fs } = makeUser(t)
assert.equal(await fs.readHomeFile(USER, ".credentials.yaml"), null, "首次应为空")
await fs.writeHomeFile(USER, ".credentials.yaml", "version: 1\nrefs:\n A: 'b'\n")
assert.equal(await fs.readHomeFile(USER, ".credentials.yaml"), "version: 1\nrefs:\n A: 'b'\n")
// 两个白名单文件互不串(别把 settings 写到 credentials 上)
assert.equal(await fs.readHomeFile(USER, "settings.yaml"), null)
})
test("home 文件:⛔ 只收白名单里的裸文件名(路径/越界名一律 bad_path)", async (t) => {
const { fs } = makeUser(t)
for (const bad of ["../.credentials.yaml", "home/.credentials.yaml", "/etc/passwd", "id_rsa", "", "settings.yaml.bak"]) {
await assert.rejects(() => fs.readHomeFile(USER, bad), isBadPath, `读应拒:${bad}`)
await assert.rejects(() => fs.writeHomeFile(USER, bad, "x"), isBadPath, `写应拒:${bad}`)
}
})
+23 -3
View File
@@ -26,7 +26,7 @@
<div class="auth-card" style="max-width: 720px"> <div class="auth-card" style="max-width: 720px">
<div class="auth-brand"> <div class="auth-brand">
<span class="wordmark">能力枢纽</span> <span class="wordmark">能力网络</span>
</div> </div>
<p class="auth-sub" id="sub">仅限管理员</p> <p class="auth-sub" id="sub">仅限管理员</p>
@@ -43,10 +43,12 @@
<button class="btn ghost small" id="logoutBtn">退出</button> <button class="btn ghost small" id="logoutBtn">退出</button>
</div> </div>
<table class="admin"> <table class="admin">
<thead><tr><th>用户名</th><th>角色</th><th>注册时间</th><th>操作</th></tr></thead> <thead><tr><th>用户名</th><th>角色</th><th>共享模型</th><th>注册时间</th><th>操作</th></tr></thead>
<tbody id="users"></tbody> <tbody id="users"></tbody>
</table> </table>
<p class="auth-sub" style="margin:6px 0 0">「共享模型」= 你在「密钥管理」里配的平台共享模型,<strong>逐个用户开启后</strong>该用户才用得上(也才会在实例的「设置 → 模型设置」里出现);默认关闭。开启即时生效(会重启该用户实例)。</p>
<h3 style="margin:22px 0 8px">存储用量 <span class="auth-sub" id="storageAt" style="font-weight:400"></span></h3> <h3 style="margin:22px 0 8px">存储用量 <span class="auth-sub" id="storageAt" style="font-weight:400"></span></h3>
<table class="admin"> <table class="admin">
<thead><tr><th>用户</th><th>工作区</th><th>会话</th><th>回收站</th><th>可清理</th></tr></thead> <thead><tr><th>用户</th><th>工作区</th><th>会话</th><th>回收站</th><th>可清理</th></tr></thead>
@@ -75,7 +77,14 @@
const del = u.role !== 'admin' const del = u.role !== 'admin'
? `<button class="btn small ghost" style="color:var(--danger)" data-act="del" data-id="${u.id}" data-name="${esc(u.username)}" title="删除用户">🗑</button>` ? `<button class="btn small ghost" style="color:var(--danger)" data-act="del" data-id="${u.id}" data-name="${esc(u.username)}" title="删除用户">🗑</button>`
: '' : ''
tbody.insertAdjacentHTML('beforeend', `<tr><td>${esc(u.username)}</td><td><span class="badge ${u.role}">${badge[u.role] || u.role}</span></td><td>${new Date(u.createdAt).toLocaleString()}</td><td>${actions}${actions && del ? ' ' : ''}${del}</td></tr>`) // 共享模型:admin 本人不需要(他自己配的就是"共享"的源头)⇒ 显示「—」;
// 待审核的人还没实例,开了也没意义 ⇒ 也显示「—」。其余用户可以开/关。
const grant = u.role === 'admin' || u.role === 'pending'
? '<span class="badge">—</span>'
: u.sharedModelGranted
? `<button class="btn small danger" data-act="grant-off" data-id="${u.id}" title="关闭该用户的平台共享模型">已开启</button>`
: `<button class="btn small ghost" data-act="grant-on" data-id="${u.id}" title="允许该用户使用平台共享模型">已关闭</button>`
tbody.insertAdjacentHTML('beforeend', `<tr><td>${esc(u.username)}</td><td><span class="badge ${u.role}">${badge[u.role] || u.role}</span></td><td>${grant}</td><td>${new Date(u.createdAt).toLocaleString()}</td><td>${actions}${actions && del ? ' ' : ''}${del}</td></tr>`)
} }
tbody.onclick = async (event) => { tbody.onclick = async (event) => {
const btn = event.target.closest('button[data-act]') const btn = event.target.closest('button[data-act]')
@@ -89,6 +98,17 @@
else alert('删除失败(admin 账号不可删除)') else alert('删除失败(admin 账号不可删除)')
return return
} }
if (act === 'grant-on' || act === 'grant-off') {
const enabled = act === 'grant-on'
const res = await fetch(`/api/admin/users/${id}/models/shared`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ enabled }),
})
if (res.ok) await loadUsers()
else alert('操作失败')
return
}
const res = await fetch(`/api/admin/users/${id}/${act}`, { method: 'POST' }) const res = await fetch(`/api/admin/users/${id}/${act}`, { method: 'POST' })
if (res.ok) await loadUsers() if (res.ok) await loadUsers()
else alert('操作失败') else alert('操作失败')
+1 -1
View File
@@ -50,7 +50,7 @@ body {
.wordmark { display: inline-flex; align-items: center; } .wordmark { display: inline-flex; align-items: center; }
.wordmark svg { height: 100%; width: auto; display: block; } .wordmark svg { height: 100%; width: auto; display: block; }
/* 2026-09-19:登录 / 注册页的品牌标识由 **DeepSeek 图形**改为**本平台文字** /* 2026-09-19:登录 / 注册页的品牌标识由 **DeepSeek 图形**改为**本平台文字**
(中文「能力枢纽」/ 英文及其他语言 CapabilityNet,走 i18n 词条 `brand.name`)。 (中文「能力网络」(2026-09-19 定名)/ 英文及其他语言 CapabilityNet,走 i18n 词条 `brand.name`)。
上面那套定高 + svg 的规则对文字不适用 ⇒ 这里补字号 / 字重 / 行高(`height:auto` 覆盖旧定高)。 上面那套定高 + svg 的规则对文字不适用 ⇒ 这里补字号 / 字重 / 行高(`height:auto` 覆盖旧定高)。
⚠️ `.topbar .wordmark`(admin / portal 顶栏)**未动** —— 那两处仍用 svg,用户本轮只要求登录 / 注册页。 */ ⚠️ `.topbar .wordmark`(admin / portal 顶栏)**未动** —— 那两处仍用 svg,用户本轮只要求登录 / 注册页。 */
.auth-brand .wordmark { height: auto; font-size: 19px; font-weight: 600; line-height: 1.2; letter-spacing: 0.2px; color: var(--ink); } .auth-brand .wordmark { height: auto; font-size: 19px; font-weight: 600; line-height: 1.2; letter-spacing: 0.2px; color: var(--ink); }
+2 -2
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="能力枢纽"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="能力网络">
<title>能力枢纽</title> <title>能力网络</title>
<!-- 平台自有品牌图标(2026-09-19,档案 137)。 <!-- 平台自有品牌图标(2026-09-19,档案 137)。
取「枢纽 / hub」意象:中心节点 + 三条辐条 + 三个外环节点。 取「枢纽 / hub」意象:中心节点 + 三条辐条 + 三个外环节点。
配色取自平台自己的 token:ink #0f1c33 作底、accent-2 #38d6d0 作节点。 配色取自平台自己的 token:ink #0f1c33 作底、accent-2 #38d6d0 作节点。

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

+3 -2
View File
@@ -39,7 +39,7 @@
// 品牌名(2026-09-19):登录 / 注册页的品牌标识由 DeepSeek 图形改为**本平台文字标识**。 // 品牌名(2026-09-19):登录 / 注册页的品牌标识由 DeepSeek 图形改为**本平台文字标识**。
// ⚠️ 走词条而不是写死在 HTML 里:英文/其他语言 = CapabilityNet(en 也是**其他语言的回退**, // ⚠️ 走词条而不是写死在 HTML 里:英文/其他语言 = CapabilityNet(en 也是**其他语言的回退**,
// `lookup()` 缺 key 时正是回退到 en),中文 = 能力枢纽。 // `lookup()` 缺 key 时正是回退到 en),中文 = 能力网络(2026-09-19 定名)。
'brand.name': 'CapabilityNet', 'brand.name': 'CapabilityNet',
'index.title': 'Workspace', 'index.title': 'Workspace',
@@ -123,7 +123,8 @@
'common.retry': '重试', 'common.retry': '重试',
// 品牌名(2026-09-19):中文界面显示中文名;其余语言由 en 词条(CapabilityNet)兜底。 // 品牌名(2026-09-19):中文界面显示中文名;其余语言由 en 词条(CapabilityNet)兜底。
'brand.name': '能力枢纽', // ⚠️ 中文名 2026-09-19 定为「能力网络」(用户口径;此前短暂用过「能力枢纽」/「能力领域」)。
'brand.name': '能力网络',
'index.title': '工作台', 'index.title': '工作台',