1) dsh-server-docs/ 从工作区(原 E:\...\aliyun-dsh-server\dsh-server-docs)**整体并入本仓**,
保留目录名 ⇒ 仓库内 dsh-server-docs/... 的相对引用天然继续有效;旧目录(含其 .git)已归档到
工作区 _中间产物_待清理/,未随本提交带入。
2) .gitattributes:新增 `dsh-server-docs/** -text` —— 原文档库是 `* -text` + autocrlf=false,
必须保持纯 LF,否则会被本仓的 CRLF 规则翻掉。
3) 活引用里的绝对路径已全部改到新位置(docs 的 INDEX / README / scripts / skills + 用户级 skills
+ ~/.workbuddy/settings.json 的 hooks);历史档案(04-调整方案/、archive/)按「只增不改」未动。
⚠️ hooks 路径改动需「完全重启会话」才生效(配置是会话启动快照)。
4) 交接单/T08:新增 §16「生产整体切换执行记录」(形态 / 落地动作 / **4 个只有真上线才暴露的真 bug** /
验收证据 / 回滚命令 / 残留项);台账 T08 行 → 已完成并归档;03-路线图 §二 登记 T08 收尾项。
5) 统一称谓:**「本机」只指跑 WorkBuddy 的开发机**,47 / 106 一律写「远程服务器」。
22 KiB
87 · 平台自建「模型设置」—— 用户自配厂家 / 条目各自开关 / 共享模型开关
- 日期:2026-09-13
- 状态:✅ 已上线并端到端验证(后端已部署 + 插件 0.3.11 已铺发 admin/guest + 角色补丁已刷新)
- 触发(用户口径,2026-09-13 22:5x 三条连续):
- 官方「设置 → 模型」页在平台环境用不了 ⇒ 改走平台自建页;
- 条目要能各自开关(不再互斥),平台共享模型也要列进来、用户可开关;
- 具体用哪个模型在 dsh 对话框的模型选择器里选 —— 平台只负责把「已启用」的都配好。
- 落点:
src/db/{schema,types,repo,adapter,sqlite,pg}.ts、新文件src/web/model-landing.ts、src/web/server.ts、src/web/routes/auth.ts、poc/business-plugins/lib/client.js(0.3.11)、ensure-role-profile-patch.cjs
TL;DR|① 官方模型页必须对所有人(含 admin)禁用 —— 它要求 Host settings 镜像,而平台是"浏览器经域名访问远程服务器"⇒
isLoopback=false⇒ persistence 降级memory⇒ 页面必报「加载提供方目录失败」;② 用户自配改走平台自建「设置 → 模型设置」(插件 0.3.11),后端/api/me/keys等 4 条路由;③ 平台在 spawn 时把「已启用条目」落地成实例的$DSH_HOME/.credentials.yaml(refs.<REF>)与$DSH_HOME/settings.yaml(llm-pi-ai.providers.<route>);④ 落地层只碰自己写过的(平台托管清单/opt/dsh/state/model-landing/<userId>.json),用户自己配的绝不覆盖、绝不删除;⑤ 顺手修掉两个真缺陷:角色补丁脚本的整文件覆盖会抹掉 admin 另外两个平台块、以及归档 86 重构后遗留的陈旧断言。
一、为什么不给用户官方「模型」页(本轮取证)
| 事实 | 证据 |
|---|---|
| 官方页要求 Host settings 镜像 | dsh-client-ui-settings 的持久化判定 isLoopback = transport.ownsHost || pageLocation === undefined || isLoopbackHostname(page);平台是浏览器经域名访问远程服务器 ⇒ 三条都不成立 |
| 官方 README 原文(权威出处) | 该包 README.md:97:"Non-loopback pages get no durable settings — this Client keeps Host persistence disabled there, so a scope starts unavailable and never crosses the wire; every row it backs is inert even though Connection authentication covers the API." |
| 后果 | persistence 降级为 memory ⇒ ensure() 直接返回不读 ⇒ 页面必报 加载提供方目录失败: settings are unavailable in this browser |
| ⇒ 处置 | ensure-role-profile-patch.cjs 对 non-admin 与 admin 都写入 - id: ui-settings-models / disabled: true |
1.1 与「03-路线图 决策 3」的辨析(两层,都成立,别再互相推翻)
03-路线图与待办.md 决策 3 记着 2026-09-09 的实测:「proxy.ts 把 Host 伪装成 127.0.0.1:<port> ⇒ dsh 的 /api trust fence 与 loopback 特权校验全放行 ⇒ Settings 面板在门户代理后可用」。
两者不矛盾,因为不是同一层:
| 层 | 谁在判 | 平台的表现 |
|---|---|---|
host 侧 /api trust fence / loopback 特权校验 |
看请求的 Host | proxy.ts:108 把 out.host 改写成 127.0.0.1:<port> ⇒ 放行(09-09 观察到的就是这一层,故 modelCatalog 能 200) |
| 浏览器侧 settings 持久化 | 看浏览器页面的 location(isLoopbackHostname(page)) |
页面是 https://<user>.alotbuy.com(真实域名)⇒ 不是 loopback ⇒ unavailable、只读、永不落盘 |
⇒ 所以「目录接口可达」不等于「保存能生效」(归档 85 §九 只验证了前者,本轮补上后者并因此推翻了 85 §九「放开官方页」的口径)。
⚠️ 这条判据的唯一落盘处是代码注释(
ensure-role-profile-patch.cjs的DISABLE_MODELS_BLOCK/ADMIN_MODELS_BLOCK)——本档即其正式出处,别再只写在注释里。
二、口径(用户定,不得再当选择题)
- 条目各自开关、可同时启用(互斥已删);
- admin 配的平台共享模型也列入列表,用户可开关(
users.shared_model_enabled,用户侧偏好,不碰 admin 配置); - 具体用哪个模型在 dsh 对话框的模型选择器里选 —— 平台只负责把「已启用」的都配好。
三、官方 schema 取证(本轮最关键的外部事实,2026-09-13 读官方包实测)
读的是服务器上 @deepseek-ai/[email protected] 的 lib/index.js 与 lib/types/*.d.ts。字段名与 85 §九 的措辞不同,按本节为准:
| 项 | 结论 |
|---|---|
| 设置分区名 | llm-pi-ai(const NS = "llm-pi-ai") |
| 结构 | llm-pi-ai: → providers:(缩进 2)→ <route>:(缩进 4)→ 字段(缩进 6) |
| 凭据字段 | apiKeyEnv(值 = 凭据 ref 名,schema 标 role("credential-ref"))——不是 apiKey |
| endpoint 字段 | baseURL |
| 协议字段 | api ——⚠️ 不是 protocol。合法值只有三个:openai-completions / openai-responses / anthropic-messages(PROTOCOLS 的键,顺序即默认优先级) |
| 模型清单 | models: [{ id, … }],id 必填 |
| 其它可用字段 | displayName / timeoutMs / transport / headers / retryPolicy … |
| ⛔ 会直接抛错的字段 | provider(已移到 dict 键)、maxRetries / maxRetryDelayMs(已移除) |
| 凭据 ref 名语法 | @deepseek-ai/dsh-credentials 的 REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/(POSIX shell 标识符) |
| 插件是否在跑 | ⚠️ 是:@deepseek-ai/dsh-llm-pi-ai 就在 base profile 的 @deepseek-ai/dsh-base bundle 列表里 ⇒ 写 llm-pi-ai.providers 真的会被加载(另:内置 DeepSeek 走的是 dsh-llm-deepseek,其 route 是 deepseek-official,与本节无关) |
| 为什么必须写文件 | 官方页不可用(§一),而设置就是文件(dsh-settings-file:<harness home>/settings.yaml)⇒ 平台只能自己写 |
四、设计
4.1 DB 层(迁移 V6 + 语义重定义)
SQLITE_V6 / PG_V6:
ALTER TABLE credential_vault ADD COLUMN route TEXT; -- settings.yaml 的 dict 键
ALTER TABLE credential_vault ADD COLUMN base_url TEXT; -- 空 = 内置 DeepSeek
ALTER TABLE credential_vault ADD COLUMN api TEXT; -- 线协议
ALTER TABLE credential_vault ADD COLUMN models TEXT; -- 模型 id 的 JSON 数组
ALTER TABLE users ADD COLUMN shared_model_enabled INTEGER NOT NULL DEFAULT 1;
setCredentialKey(...):签名改选项对象(CredentialKeyMeta),并删掉UPDATE … SET enabled = 0(老实现"写新条目就把别的全关",与口径①冲突)。- 新增/改:
listEnabledCredentialKeys·toggleCredentialKey·getSharedModelEnabled·setSharedModelEnabled·listCredentialLandingRows(多返回密文,仅供落地层 ——CredentialKey会被/api/me/keys原样返回给浏览器,所以密文不能挂在它上面)。 - 🔴
getEnabledCredentialKeyRef语义重定义(本轮最重要的一处正确性修复):老实现是WHERE user_id=? AND enabled=1且无ORDER BY,当时靠"写入时全关"保证唯一;互斥删掉后可能命中多行 ⇒ 任取一条 ⇒auth.ts的keySourceOf()与server.ts的resolveApiKey()会随机飘。现钉死为「已启用、且是内置 DeepSeek(base_url为空)的最新一条」。 selectCredentialKey保留函数名但退化为"只开这一个"(不再先全关);路由侧已标记@deprecated。
4.2 落地层(新文件 src/web/model-landing.ts,纯文本变换、无 IO)
为什么单独成文件:它干的是改写实例自己的配置文件,错了不报错、只会静默少一个厂家 ⇒ 必须能被 scripts/verify-model-landing.mjs 用固定样例钉住(13 组断言)。
| 函数 | 作用 |
|---|---|
routeRef(route) |
<ROUTE 大写、非字母数字折成 _>_API_KEY,保证匹配官方 REF_PATTERN |
refForEntry({route,baseUrl}) |
内置 ⇒ DEEPSEEK_API_KEY;自定义 ⇒ routeRef(route) |
reconcileCredentials(text, desired, managed) |
对账 refs: 段:新增/刷新自己写过的、删除自己写过的、用户自己写的绝不碰;认不出的布局原样返回 |
reconcileSettings(text, desired, managed) |
对账 llm-pi-ai.providers.<route>:用成对标记 # dshs:model-route <route> begin/end 夹住自己那块 ⇒ 关掉厂家 = 删掉自己那对标记之间;不解析、不重排别人的 YAML |
readRefValue(text, ref) |
读文件里某 ref 的当前值 —— 只用于一次性交接(见 4.3) |
parseModels(json) |
宽容解析(坏值只丢弃,绝不让一个坏值把整份 settings.yaml 变成 dsh 拒绝加载的文档) |
安全约束(与既有 ensureRefInCredentials 同族):只认 version: 1 的凭据文档;备份必须落 /opt/dsh/backups/(⛔ 绝不能进 home —— dsh 用 chokidar watch 整个 home,一个实例读不了的文件会让它 EACCES 崩溃循环,85 §9.4 真踩过);写完 chown 给 home 属主;只在真有变化时写盘(避免每次 spawn 白抖一下 watcher)。
4.3 server.ts:spawn 时落地 + 托管清单
resolveApiKey(userId)恒返回null(不注入 env —— env 会让assertUnshadowed()把用户保存打回错误,85 §9.2);失败时退回 env 注入保底(宁可暂时用共享 key,也不能因为写文件出错让实例起不来)。landModels(userId):取「自己的已启用条目」+(开关打开时)「admin 的已启用条目」,自己的优先(同 ref 只留第一条)⇒ 用户自己配的 key 永远赢过共享的。- 托管清单
/opt/dsh/state/model-landing/<userId>.json={refs, routes}(不在 home)。 - 🔴 一次性交接:老实现把共享 key 直接写进
refs.DEEPSEEK_API_KEY却没有清单 ⇒ 新逻辑会把它当"用户自己写的"而永不清理 ⇒ 关掉共享开关后那行仍留着("关掉即生效"不成立,验收③会挂)。所以首次运行(无声清单)时,仅当文件里那行确实等于平台共享 key 的明文才认领它;不相等 = 用户自己配的,绝不碰。
4.4 接口(src/web/routes/auth.ts)
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /api/me/keys |
回 keys(含 route/baseUrl/api/models)+ effective + shared(含 count/enabled)+ sharedModelEnabled + protocols(协议枚举由后端下发,免得前端各写一份常量然后漂) |
| POST | /api/me/keys |
收 {name, apiKey, route?, baseUrl?, api?, models?};baseUrl 空 = 内置。展示名允许中文(寻址用的是 route);自定义必填 ≥1 个模型;route 撞车回 409 |
| POST | /api/me/keys/:id/toggle |
开关单个条目(口径①) |
| POST | /api/me/models/shared |
平台共享模型开关(口径②,只动自己的偏好) |
| DELETE | /api/me/keys/:id |
删除 |
全部改动后都走 refreshAfterKeyChange(admin 动共享内容 ⇒ 广播重启;其他人只重启自己)—— 因为落地发生在 spawn 时,不重启不生效。
4.5 前端(poc/business-plugins 0.3.11)
新增 settings.section 分区 model-settings(order 100,全角色可见),标签「模型设置」:
- 平台共享模型区块(名称 / 归属 / 条数 + 启停按钮);
- 新增表单(名称 / API Key / Endpoint / 厂家标识 / 协议下拉 / 模型清单,一行一个);
- 「我的模型条目」表格(名称 / 厂家标识 / Endpoint / 协议 / 模型数 / 状态徽章 / 启用·停用·删除);
- 文案明说**「保存后需重启实例生效」**。
UI 只复用既有 .pa-*(门户组件家族)类,未新增 CSS —— 「系统管理」那套是照抄门户的,同族复用即可。
五、落地与出厂
| 项 | 内容 |
|---|---|
| 代码 | tar 管道推 src/** + ensure-role-profile-patch.cjs(LF 化)→ 服务器 npm run build(干净通过)→ systemctl restart dshs |
| 迁移 | 重启即应用 V6(实测 credential_vault 多出 route/base_url/api/models,users 多出 shared_model_enabled) |
| 插件 | 0.3.11(48,590 B / md5 15c8083f5db85b3fce6a8a15db133319)→ /opt/dsh/artifacts/ → ensure-biz-plugins.cjs --all --restart(两实例 bundles=6 ✓) |
| 角色补丁 | node ensure-role-profile-patch.cjs --force --restart(--force 用于刷新旧注释 + 前提校验;已加回滚点 /opt/dsh/backups/patch-20260913-234511/) |
| 本地全套 | npm run verify 等价物(Node 22):单测 49 项 0 失败 + verify-inject / verify-static / verify-platform-admin-section / verify-mem-model / verify-model-landing 全绿 |
🔴 顺带修掉的两个真缺陷(都在本 lane 内)
ensure-role-profile-patch.cjs的整文件覆盖会抹掉 admin 的另外两个平台块(disable-hmr+workspace-scoped-picker)。它的--force升级分支原本是writeFileSync(patchPath, block)—— admin 的cordis.patch.yml里住着三个平台块,一覆盖:disable-hmr 丢掉 = 生产重新连 HMR;picker 块丢掉 = 目录选择器退回"可改任意路径"的无限制版(档案 18 v3 收敛作废)。本轮实测确认:修前若触发升级就会写坏(当前只是恰好因"已含 models 行"而未触发)。修法 = 新增stripManagedBlock(),升级只替换自己那一段;实机 diff 验证 admin 另两块完整保留。verify-mem-model.mjs里一条陈旧断言:档案 86 把 status 主体抽成statusForUser(app,user)后,request.user!.id变成user.id⇒ 该断言一直红着(npm run verify长期失败)。断言意图不变,改为不绑定形参名。
六、验收(用户点名的四件事)
| # | 验收项 | 证据 |
|---|---|---|
| ① | 官方「模型」栏两账号都不再出现 | 两账号 cordis.patch.yml 各含 1 处 - id: ui-settings-models + disabled: true(已 --restart,客户端 bundle 在实例启动时重打);页面实看留用户确认(本机浏览器 daemon 起不来,沿用档案 86 §七-3 的做法) |
| ② | 新页能配、能开关、能删 | HTTP 实测:新增内置条目 → effective: shared→own;新增自定义厂家 → route/base_url/api/models 全部落库;toggle 关/开 → DB enabled 0/1;非法入参全被拒(非 http 的 baseUrl 400 / 缺 models 400 / route 撞车 409 / 非法协议 400);前端无浏览器验收全绿(含 8 条新断言:共享区块、条目徽章、两类条目、厂家标识/endpoint/协议、表单栏位、重启文案、门户组件类) |
| ③ | 共享模型开关真的影响实例落地 | 关掉共享 + 重启 → 实例 .credentials.yaml 里 DEEPSEEK_API_KEY 消失(老实现写下的那行被"一次性交接"正确认领并撤掉),且实例进程 env 里该变量 = 0 条;再打开 → 重启 → 该行回来,与自定义厂家的 ref 并存(口径①实测) |
| ④ | 自定义厂家被 dsh 模型选择器认到 | 实例 settings.yaml 落地为官方 schema 原文:llm-pi-ai: providers: acctest-gw: {apiKeyEnv, baseURL, api: openai-completions, models: [gpt-4o, gpt-4o-mini]};实例启动成功、journalctl 无 llm-pi-ai / settings-rejected 报错;删除该条目后重启 → 整块消失(只删自己那对标记之间) |
附加证据:备份落点 /opt/dsh/backups/{credentials,settings}-<userId>-<ts>.yaml(不在 home,且本次起带上用户 id);home 内无平台写的残留文件;托管清单 {"refs":["DEEPSEEK_API_KEY"],"routes":[]} 与文件状态一致。
七、回滚
# 后端:还原 src/** → 重建
cd /opt/dshs && npm run build && systemctl restart dshs
# 插件:回落 0.3.10
# 角色补丁(注意副作用!):
cp /opt/dsh/backups/patch-20260913-234511/*.yml 还原到各自 home/profiles/web/cordis.patch.yml
⚠️ 若把角色补丁回滚到「放开 models」那一版,等于给用户一个必然报错的页面(§一)——除非同时接受这个副作用,否则不要回滚该项。
⚠️ V6 的 ALTER TABLE 列不会被回滚(SQLite 无 down migration);空列不影响旧代码。
八、未做 / 待观察
- UI 的最终视觉确认留给用户(本机
agent-browserdaemon 起不来)——②已用无浏览器 harness 全绿 + HTTP 实测覆盖,但"肉眼看着像不像门户"只有用户能拍。 settings.yaml里会留一个空的llm-pi-ai: providers:(厂家全删光后):空 dict 与 schema 的.default({})等价,无害;若要更整洁可再加"空链回收",低价值故未做。- 平台不校验用户填的 endpoint(实例可出网):这是"用户自己的 key、自己的选择",若要限制出网目标需另立需求(承 85 §9.7)。
selectCredentialKey/POST /api/me/keys/:id/select已成 deprecated 同义实现:待老客户端确认不再调用后可删(本轮保留以免 404)。.lock-78/85/86/87空目录堆积(占号后没清):属文档库卫生,docs-audit.py不报,建议下次收口时统一清。- 文档库「档案 86」仍未见提交(在途,属别人 lane)——本档与 86 无重叠。
九、补做(2026-09-14 00:0x–00:3x):接上官方厂家目录 + 页面改成官方结构(插件 → 0.3.12)
用户当天实测反馈(原话):「新增模型条目怎么看不懂呢,是按照 dsh 自带的模型设置交互开发的吗,而且模型厂商选择怎么这么少、国内的一家都没有」,随后追加:「页面就按照 dsh 模型设置的页面(使用项目 ui 规范),增加 admin 设置的模型管理就行」。
9.1 缺陷认定(是我的设计做窄了,不是 dsh 没有)
第一版只支持「内置 DeepSeek + 手填一个 OpenAI 兼容网关」两类 —— 用户要自己写 endpoint / 协议 / 协议值 / 模型 id,而且选择框里没有任何厂家。而实际情况是:
| 事实 | 证据 |
|---|---|
官方 pi-ai 自带 38 个厂家 |
<dsh>/node_modules/@earendil-works/pi-ai/dist/providers/data/*.json(本次实测枚举) |
| 其中中国大陆 11 家 | 蚂蚁 ant-ling · 通义千问 qwen-token-plan{,-cn,-individual} · 小米 xiaomi{,-token-plan-cn} · 月之暗面 moonshotai-cn + kimi-coding · 智谱 zai + zai-coding-cn · MiniMax minimax-cn |
| 命中的 route 无需手写端点 | 官方 dsh-llm-pi-ai 的 config.d.ts 原文:"A route key is not required to name an installed pi-ai provider. When it does, that provider's endpoint, protocol, display name, and model catalog are the profile's defaults and the profile overrides them field by field" ⇒ 只需 apiKeyEnv |
⇒ 所以正确做法是:先选厂家(从目录),目录厂家只填 API 密钥;只有目录里没有的才走「自定义」手填。
9.2 改了什么
| 层 | 内容 |
|---|---|
| 新文件 | src/web/model-catalog.ts:只读官方目录(data/*.json,10 分钟缓存,不导入 pi-ai 运行时避免把 40 个厂家模块拉进平台进程);厂家中文名走显示用静态表(表里没有 ⇒ Title Case 化 id,选取范围永远以目录为准,不会因表落后漏厂家);isCnProvider() 供界面分组 |
| 接口 | 新增 GET /api/me/model-providers(回 38 家 + cn 分组标记 + modelCount + 前 60 个模型名;排除 deepseek —— 平台已有「内置 DeepSeek」入口,再列同名只会让用户分不清哪个生效);POST /api/me/keys 新增 provider 参数(只收 apiKey,非法厂家 400) |
| 落地 | refForEntry() 判据从「baseUrl 为空」改为「没有 route」—— 否则目录厂家(也没 baseUrl)的 key 会被写进 DEEPSEEK_API_KEY;SettingsEntry 的 baseURL/api/models 改为可选,目录厂家只写 apiKeyEnv(写得越少越不容易漂) |
| 前端 | 按官方页结构重写:「我已添加的厂家」提供方行(厂家 / 说明 / 状态 / 操作)+ 「新增厂家」先选厂家(<optgroup> 中国大陆 / 国际 / 自定义)+ 选中目录厂家时只需填密钥并提示「端点与 N 个模型由官方目录提供」+ 可展开看模型清单;保留并强化 admin 的「平台共享模型」区块(用户明确要求"增加 admin 设置的模型管理") |
9.3 验收(真环境实测)
| 检查 | 结果 |
|---|---|
GET /api/me/model-providers |
38 家;国内 11 家(蚂蚁 / 通义千问×3 / 小米×2 / 月之暗面×2 / 智谱×2 / MiniMax);deepseek 已排除 ✓ |
加一条目录厂家(moonshotai-cn,只给 apiKey) |
{route:"moonshotai-cn", baseUrl:null, api:null, models:null} ✓ |
| 不存在的厂家 | 400 ✓ |
实例 .credentials.yaml |
出现 MOONSHOTAI_CN_API_KEY ✓(与 DEEPSEEK_API_KEY 并存 —— 口径①实测) |
实例 settings.yaml |
providers.moonshotai-cn: 只有 apiKeyEnv,无 baseURL / api / models ✓(正是官方语义) |
| 删除该条目 → 重启 | 整块消失、agent-default-model 未受影响 ✓ |
| 本地验收 | verify-platform-admin-section(含 8 条新断言)+ verify-model-landing 全绿;zh/en 词典 282/282 键集一一对应 |
9.4 仍未做
- 官方页的「获取可用模型」(
llm/discoverModels)未做 —— 那是"按端点探测模型清单",只对自定义网关有意义,本轮先要求手填模型 id。 - 官方页的「自定义设置」折叠区(
baseURL/ 协议 / 模型目录逐项覆盖)只做了必要的三项,未做逐模型覆盖(modelOverrides)。 - 目录厂家的模型数量/价格已从目录读出并展示,但未做模型级勾选(用哪个模型仍由 dsh 对话框的模型选择器决定,符合口径③)。