Files
dsh_ai1net_server/dsh-server-docs/04-调整方案/91-模型设置页复刻官方交互-卡片行与两步新增.md
T
admin 5ad755116e chore(docs): 文档库并入代码仓(R4 选 a)+ 索引/台账跟进
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 一律写「远程服务器」。
2026-09-15 18:47:13 +08:00

134 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 91-模型设置页复刻官方交互——卡片行 + 两步新增(2026-09-14 落地)
- 日期:2026-09-14 | 状态:✅ **已上线**(`business-plugins` **0.3.15**,两实例已铺发)
- 触发:用户原话 ——「**admin模型设置和状态 一个名称换行了,另一个两个按钮换行了,交互体验需要优化。还有新增模型的操作交互和官方原始新增模型的交互差距很大,建议仔细参考 复刻官方的交互**」(附截图:`内置 DeepSeek` 折行 + `已启用` 折行 + `停用/删除` 折行)
> **TL;DR**
> **换行不是"窄了",是版式选错了** —— 原「我已添加的厂家」是 **4 列表格**(`pa-tbl`,列宽 auto):
> `内置 DeepSeek`、`已启用`、`停用|删除` 全是**按列宽被挤断**的。
> 改为**官方 `ui-settings-models` 的卡片行**(`rowHead` 身份左对齐 + `rowActions` `margin-left:auto`,
> 两侧 `white-space:nowrap`)⇒ **结构上不可能换行**,同时把整页拉齐到官方观感。
> 「新增」从「常开表单 + 下拉里混一项『自定义厂家…』」改成**官方两步式**:默认只露**两个虚线按钮**
> (`+ 添加提供方` / `+ 添加自定义提供方`),点击才展开卡片;卡片**主字段是单独一个「API 密钥」**,
> 显示名挪进收起的「自定义设置」`<details>`。
> 顺带修两处**真缺陷**:① admin 自己看共享卡片时写「由 admin 配置」;② 「内置 DeepSeek 密钥留空则回落平台共享密钥」是**错的**(留空必 400)。
> 另补**删除二次确认**(官方 `deleteDialog` 等价物)—— 原来「删除」紧挨「停用」且一点即删,连已存密钥一起丢。
> CSS 数值与文案**逐值照抄官方**(本档 §四 给出单一来源),并新增两个构建期校验脚本防回归。
---
## 一、问题定位(先把"为什么"钉住,别停在"看着窄")
| 现象(用户截图) | 根因 | 判据 |
|---|---|---|
| `内置 DeepSeek` 折成两行 | 表格 **4 列 auto 列宽**,第 1 列拿不到 110px | `.pa-tbl th` 有 `white-space:nowrap`,**`td` 没有**(`lib/client.js` 的 `.pa-tbl` 规则) |
| `已启用` 折成「已启/用」 | 同上(`状态` 列同样 auto) | `.pa-badge` 是 `inline-block`,**列宽不足时内部换行** |
| `停用`/`删除` 各占一行 | 同上;两按钮在**同一个 `td`** 里塞着 | 原代码 `jsxRuntime.jsxs("td", { children: [button, button] })` |
> ⚠️ 注意:这三个现象**不是同一个巧合**,而是同一个结构性原因(auto 列宽)的三个实例。
> 所以修复只能换版式,**加 `nowrap` 补丁不算修好**(第 2 列的长 endpoint 会把整表顶宽、又重新挤第 1 列)。
## 二、官方交互取证(`[email protected]`)
**取证位置(单一来源,勿凭记忆)**:
| 内容 | 路径 |
|---|---|
| 页面语义/交互说明(中文) | `/usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai/dsh-client-ui-settings-models/README.zh.md` |
| **CSS 模块(版式数值的唯一来源)** | 同包 `lib/client.js` 头部 `\0dsh-css:` 区块(`css$3`,类名带哈希前缀 `zGbnIq_`) |
| **中文文案词典** | 同包 `lib/client.js` 的 `zh` 对象(`add` / `customAdd` / `customRouteHint` / `fetchModels` …) |
| 组件结构 | 同包 `lib/client.js` 的 `ModelsSection` / `ProviderEditor` / `CustomProviderCard` |
**官方骨架(本轮照此复刻)**:
```
section(max-width:720px, flex column, gap 12)
├─ title / intro
├─ ul.rows(gap 8)
│ └─ li.rowCard(border .5px, radius 16, padding 12px 14px, flex column gap 12)
│ ├─ div.rowHead(flex, align-center, gap 10)
│ │ ├─ span.rowIdentity(inline-flex, gap 6, min-width 0)
│ │ │ ├─ span.rowName(14px/500/22)
│ │ │ ├─ span.rowTag(border .5px, radius 4, 11px/16)
│ │ │ └─ span.credentialDot(8px 圆点,绿=已配置 / 红=缺失)
│ │ └─ span.rowActions(margin-left:auto, gap 4)
│ │ ├─ button 编辑(28px/radius14/pad 0 10/12px)
│ │ └─ button 删除(danger,同尺寸、无边框、红字)
│ └─ [展开的编辑卡片]
├─ div.addBlock(flex column gap 12)
│ ├─ 若 adding → div.addCard(bg-module-platform, radius 12, pad 14px 16px)
│ │ ├─ field「提供方」+ select(休眠目录清单)
│ │ ├─ ProviderEditor:**主字段 = 单个「API 密钥」** + `<details>`「自定义设置」
│ │ └─ editorActions(右对齐:取消 / 保存)
│ ├─ 若 declaring→ div.addCard → CustomProviderCard(Provider ID→显示名→API 地址→协议→密钥→模型目录)
│ └─ 否则 → div.addActions(flex-wrap, gap 10) → **两个虚线按钮**
│ button.addButton(border 1px dashed, radius 16, flex 1 1 0, min-width 180px, h 44)
└─ 删除 = Modal,标题「删除 {provider}?」
```
**官方三条关键口径(原文照引,不要再自己发明)**:
1. 「编辑卡片上的主字段是单独一个 **API 密钥**输入框——**页面从不询问环境变量名**」;
2. 「收起的『自定义设置』折叠区承载精选的额外字段」;
3. 「**添加自定义提供方**声明一条 pi-ai 不提供的路由;创建卡片会索要唯一的 **Provider ID**、端点、协议与至少一个可唯一识别的模型,因为没有东西能为它们兜底」。
## 三、改了什么(`poc/business-plugins/lib/client.js`)
| # | 项 | 之前 | 之后 |
|---|---|---|---|
| 1 | 「我已添加的提供方」 | `div.pa-wrap > table.pa-tbl`(4 列,thead:厂家/说明/状态/操作) | **`ul.ms-rows > li.ms-rowCard`**(名称+标签+状态点 / 操作右对齐);状态文字下沉到**独占一行的副标题**(整行宽度 ⇒ 不可能折行,信息量不丢) |
| 2 | 新增入口 | 一个**常开**的 `pa-box`,里面 3 行 input + 下拉(下拉里混一项「自定义厂家(OpenAI 兼容网关)…」) | **两个虚线按钮**(`ms-addActions`)→ 点击展开对应卡片;一次只开一张 |
| 3 | 「添加提供方」卡片 | 名称 + API Key 两个并排 input | **「提供方」select + 单独一个「API 密钥」(主字段)**;显示名与模型目录收进 `<details>`「自定义设置」 |
| 4 | 自定义提供方 | 端点/标识并排、协议下拉、**模型用 textarea 一行一个** | 官方字段序;**模型改用行式编辑器**(`ms-modelEntry` 一行一个 + `+ 添加模型` + 行尾 ✕) |
| 5 | 按钮 | `pa-sm` / `pa-btn`(门户家族) | `ms-btn`(官方度量:36px/radius18/pad 0 14;行内 28px/radius14/pad 0 10) |
| 6 | 行标签 | 无 | `ms-rowTag`:**内置 / 目录 / 自定义**(官方 `rowTag` 位置) |
| 7 | 状态点 | 徽章文字 `已启用/已停用` | **8px 圆点**(`ms-dot`,绿=已启用 / 灰=已停用,带 `title`+`aria-label`)+ 副标题里的状态文字 |
| 8 | 删除 | 一点即删 | **二次确认弹窗**(点名该提供方,遮罩/取消可关) |
**样式落点**:新增 `.ms-*` 规则块,插在既有 `.pa-note` 之后;注释里写明「类名去哈希、前缀改 `ms-`,数值逐值照抄官方」。
**词典**:`ms.*` 增补到 **305 键**(zh/en 同键集);沿用官方中文文案(`自定义设置` / `Provider ID` / `API 协议` / `其余字段在 settings.yaml 中,请直接编辑对应段。` 等)。
## 四、顺带修掉的两处**真缺陷**(非样式)
| # | 缺陷 | 判据 | 修法 |
|---|---|---|---|
| 1 | admin 自己打开该页时,共享卡片副标题写「**由 admin 配置**」 | 后端 `sharedKeyInfo()` **早已返回 `ownerIsMe`**,前端却只用 `shared.owner` | `ownerIsMe === true` ⇒ 显示「**由你配置**」 |
| 2 | 提示「内置 DeepSeek:**密钥留空则回落到平台共享密钥**」 | `src/web/routes/auth.ts` 的 `keyAddSchema`:`apiKey` 是 **`required` + `minLength:1`**,且入口处有 `/^[A-Za-z0-9\-_.]{1,256}$/` ⇒ **留空必然 400 `invalid_api_key`** | 文案改为「输入你的 DeepSeek API 密钥」,并**前端禁用保存按钮**直到非空 |
> 第 2 条是**旧文案在骗用户**:照着填必然失败,而错误码会被翻成「API 密钥不合法」,用户只会以为自己抄错了密钥。
## 五、为什么**没做**官方的「获取可用模型」
官方那支是**服务端**经 Remote `llm/discoverModels` 去问端点(`fetchModels: "获取可用模型"` → 可搜索选择器 → 「添加所选」)。
平台侧要等价实现得**新开一个"服务端代为请求用户给的 URL"的接口** —— 那是**出网面扩大**(用户可控 URL ⇒ SSRF 面),
按 **R5(权限只准收窄)**必须先出权限影响评估并取得确认,**不是本轮该顺手做的事**。
采取的最小替代:目录提供方在「自定义设置」里**只读展示**该目录自带的模型清单(数据已在 `/api/me/model-providers` 里,无需新接口);
自定义提供方保留**手工填写模型**(官方的兜底路径,原文:「其余协议会报告自己无法被询问,其模型需手工填写」)。
> 若后续要做真发现,正确做法是**后端加白名单化探测**(限制协议/私网/超时 + 只回模型 id),并单独立档走 R5 评估。
## 六、验证(四层,逐层给证据)
| 层 | 手段 | 结果 |
|---|---|---|
| L1 语法 | `node --check lib/client.js` | ✅ |
| L2 词典 + 版式锚点 | **新脚本** `scripts/verify-models-dict.mjs` | ✅ 20 项全绿(含 nowrap/margin-left:auto 断言)。**本轮正是它抓到** `ms.needUrl` / `ms.needModels` **被引用未声明**(语法与构建都不报错的静默缺陷) |
| L3 渲染冒烟 | **新脚本** `scripts/verify-models-render.cjs` | ✅ 5 个分支(默认双按钮 / 添加提供方卡片 / 自定义卡片 / 删除弹窗 / 卡片行)真渲染,无运行期异常、无缺键 |
| L4 上线实证 | 产物 sha1 比对 + 实例进程启动时刻 vs 包落地时刻 | ✅ 本机 `838b4f11…` == 服务器同值;两 profile 均已 `0.3.15`,运行中实例启动时刻**晚于**包落地 ⇒ 加载的是新 bundle |
**铺发**(本平台唯一方式,不走候选池):`npm pack` → scp `/opt/dsh/artifacts/business-plugins-0.3.15.tgz` → `cd /opt/dshs && node scripts/ensure-biz-plugins.cjs --all --restart`(admin `bundles=7`、guest `bundles=6`,均含该包)。
> ⚠️ 该脚本按**产物文件名**取最大版本 ⇒ **升版本号前先确认该号没用过**(同号不同内容会被静默跳过)。
## 七、回滚
- **单点回退**:`/opt/dsh/artifacts/` 里删掉 `business-plugins-0.3.15.tgz`(保留 0.3.14 及以前)⇒ 再跑一次 `ensure-biz-plugins.cjs --all --restart`,脚本会选最大版本 `0.3.14`。
- 本机源码回退:`git -C D:\github\dsh_shenxian checkout -- poc/business-plugins/lib/client.js poc/business-plugins/package.json package.json`。
- 无数据迁移、无 SQLite 变更 ⇒ **回滚不需要动库**。
## 八、遗留
- ⚠️ **代码侧与文档库本轮仍未提交**(工作树留痕):`poc/business-plugins/{lib/client.js,package.json}`、`scripts/verify-models-dict.mjs`、`scripts/verify-models-render.cjs`、`package.json`。**用户明确说提交才提交**(根 `CODEBUDDY.md §4`)。
- 官方的两支能力**有意不做**:`获取可用模型`(见 §五)、`全选/取消全选` 候选选择器(依附前者)。
- 官方的「首次运行弹窗(内测声明 + DeepSeek 引导)」与「`settings.models.provider-card` 扩展席位」与本平台模型不符(我们是平台侧落地、spawn 时写实例配置),**不采纳**。
- `pa-tbl` 系列样式仍被其它页(系统管理各功能页)使用,**未删**。