平台共享模型改为「管理员逐用户授权」+ 品牌中文名改「能力网络」+ 修掉跨机模型落地/语言偏好静默失效
三条线合并入库(同一次部署批次,源码与生产此前已一致):
一、档案 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:
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)。
|
||||||
@@ -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/`)|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,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",
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
@@ -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」。
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
/* 备份失败不阻断 */
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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 }))
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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': '工作台',
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user