Procházet zdrojové kódy

fix(web): isolate model settings tests and update input guide

Yichen Jiang před 1 týdnem
rodič
revize
f5683559a5

+ 2 - 2
.agents/notes/implemented/feature/2026-09-16-unified-model-input-controls.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-16-unified-model-input-controls.md
-2026-09-16-unified-model-input-controls.md: 8a9320a4d142c461b35de6d907685be66a5836ec
-2026-09-16-unified-model-input-controls.zh.md: c19f62b7ed0e05eee1f770ff01a2047e714773f3
+2026-09-16-unified-model-input-controls.md: 12f5f8ba7654115576f33e54d0c80bc54c5f1efd
+2026-09-16-unified-model-input-controls.zh.md: 4c5bd7fdcc7f3091da35865183cc19f25bd1c1d3

+ 2 - 0
.agents/notes/implemented/feature/2026-09-16-unified-model-input-controls.md

@@ -22,6 +22,8 @@ An undeclared or empty input field displays the installed model input types, the
 
 **Allow no checked types.** DeepSeek rejects an empty list and pi-ai treats it as inheritance. Keeping a nonempty selection prevents the same gesture from meaning different things across adapters.
 
+**Keep image limits when disabling DeepSeek images.** This leaves a configuration that the adapter refuses to save. Clearing the image-specific limits makes the selected text-only state valid while preserving unrelated model fields.
+
 ## Consequences
 
 Users can edit text-only, image-only, and combined declarations through one layout. Configuration declares upstream capabilities; it does not add image processing to a text-only model. Removing a pi-ai input declaration can change effective capabilities as catalog or provider defaults change. DeepSeek image limits need reconfiguration after images are disabled. Provider routing and [catalog recovery](../bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.md) retain their existing owners. Component checks cover defaults, exact selections, metadata preservation, and disabled controls; browser scenarios cover saved adapter capabilities, reopened selections, the separate input-type row, and installed vision metadata across discovery, adoption, and reopening.

+ 2 - 0
.agents/notes/implemented/feature/2026-09-16-unified-model-input-controls.zh.md

@@ -22,6 +22,8 @@ DeepSeek 和 pi-ai 分别渲染模型行,导致标签、图标和间距不一
 
 **允许全部取消。** DeepSeek 拒绝空列表,pi-ai 则将其视为继承。保留非空选择,避免同一个操作在两个适配器中具有不同含义。
 
+**禁用 DeepSeek 图片时保留图片限制。** 这会留下适配器拒绝保存的配置。清除图片专属限制,可以让选中的仅文本状态有效,同时保留模型的无关字段。
+
 ## 影响
 
 用户通过相同布局编辑仅文本、仅图片和组合声明。配置声明上游能力,不会为仅文本模型增加图片处理能力。移除 pi-ai 输入声明后,有效能力可能随模型目录或提供方默认值变化。禁用图片后,DeepSeek 图片限制需要重新配置。提供方路由和[模型目录恢复规则](../bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.zh.md)保留现有责任方。组件检查覆盖默认值、确切选择、元数据保留和禁用控件;浏览器场景覆盖保存后的适配器能力、重新打开后的选择,独占一行的输入类型,以及发现、采纳和重新打开过程中传递的内置视觉元数据。

+ 45 - 40
apps/web/tests/models-settings.e2e.ts

@@ -339,53 +339,58 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
     }])
     const dialog = page.getByRole('dialog', { name: '设置' })
     const edit = dialog.getByRole('button', { name: '编辑 openai', exact: true })
-    await edit.click()
-    await dialog.getByText('自定义设置').click()
-    await dialog.getByRole('button', { name: '模型选项 1' }).click()
-    const types = dialog.getByRole('group', { name: '输入类型 1' })
-    const image = types.getByRole('checkbox', { name: '图片', exact: true })
-    await expect.poll(() => image.isChecked()).toBe(true)
-    expect(await types.getByRole('checkbox', { name: '文本', exact: true }).isChecked()).toBe(true)
-    const before = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
-    await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'catalog-inputs.expected.md'),
-      await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd), MODE)
-    await dialog.getByRole('button', { name: '保存', exact: true }).click()
-    await types.waitFor({ state: 'detached' })
-    expect(await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')).toBe(before)
+    try {
+      await edit.click()
+      await dialog.getByText('自定义设置').click()
+      await dialog.getByRole('button', { name: '模型选项 1' }).click()
+      const types = dialog.getByRole('group', { name: '输入类型 1' })
+      const image = types.getByRole('checkbox', { name: '图片', exact: true })
+      await expect.poll(() => image.isChecked()).toBe(true)
+      expect(await types.getByRole('checkbox', { name: '文本', exact: true }).isChecked()).toBe(true)
+      const before = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
+      await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'catalog-inputs.expected.md'),
+        await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd), MODE)
+      await dialog.getByRole('button', { name: '保存', exact: true }).click()
+      await types.waitFor({ state: 'detached' })
+      expect(await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')).toBe(before)
 
-    await edit.click()
-    await dialog.getByText('自定义设置').click()
-    await dialog.getByRole('button', { name: '模型选项 1' }).click()
-    await expect.poll(() => image.isEnabled()).toBe(true)
-    await image.uncheck()
-    await dialog.getByRole('button', { name: '保存', exact: true }).click()
-    await types.waitFor({ state: 'detached' })
-    await expect(scaffold.ctx.llm.resolveModelInfo('openai', 'gpt-6-astra')).resolves.toMatchObject({ inputModalities: ['text'] })
-    await edit.click()
-    await dialog.getByText('自定义设置').click()
-    await dialog.getByRole('button', { name: '模型选项 1' }).click()
-    await expect.poll(() => image.isEnabled()).toBe(true)
-    expect(await image.isChecked()).toBe(false)
+      await edit.click()
+      await dialog.getByText('自定义设置').click()
+      await dialog.getByRole('button', { name: '模型选项 1' }).click()
+      await expect.poll(() => image.isEnabled()).toBe(true)
+      await image.uncheck()
+      await dialog.getByRole('button', { name: '保存', exact: true }).click()
+      await types.waitFor({ state: 'detached' })
+      await expect(scaffold.ctx.llm.resolveModelInfo('openai', 'gpt-6-astra')).resolves.toMatchObject({ inputModalities: ['text'] })
+      await edit.click()
+      await dialog.getByText('自定义设置').click()
+      await dialog.getByRole('button', { name: '模型选项 1' }).click()
+      await expect.poll(() => image.isEnabled()).toBe(true)
+      expect(await image.isChecked()).toBe(false)
 
-    await dialog.getByRole('button', { name: '删除模型 1' }).click()
-    await dialog.getByRole('button', { name: '获取可用模型' }).click()
-    const picker = page.getByRole('dialog', { name: '选择要添加的模型' })
-    await picker.getByRole('button', { name: '取消全选' }).click()
-    await picker.getByRole('searchbox', { name: '搜索模型' }).fill('gpt-6-astra')
-    await picker.getByRole('checkbox', { name: 'gpt-6-astra', exact: true }).check()
-    await picker.getByRole('button', { name: '添加所选' }).click()
-    await dialog.getByRole('button', { name: '模型选项 1' }).click()
-    expect(await image.isChecked()).toBe(true)
-    await dialog.getByRole('button', { name: '保存', exact: true }).click()
-    await types.waitFor({ state: 'detached' })
-    await expect(scaffold.ctx.llm.resolveModelInfo('openai', 'gpt-6-astra')).resolves.toMatchObject({ inputModalities: ['text', 'image'] })
-    await scaffold.ctx.settings.mutate('llm-pi-ai', [{ op: 'unset', path: ['providers', 'openai'] }])
-    await edit.waitFor({ state: 'detached' })
+      await dialog.getByRole('button', { name: '删除模型 1' }).click()
+      await dialog.getByRole('button', { name: '获取可用模型' }).click()
+      const picker = page.getByRole('dialog', { name: '选择要添加的模型' })
+      await picker.getByRole('button', { name: '取消全选' }).click()
+      await picker.getByRole('searchbox', { name: '搜索模型' }).fill('gpt-6-astra')
+      await picker.getByRole('checkbox', { name: 'gpt-6-astra', exact: true }).check()
+      await picker.getByRole('button', { name: '添加所选' }).click()
+      await dialog.getByRole('button', { name: '模型选项 1' }).click()
+      expect(await image.isChecked()).toBe(true)
+      await dialog.getByRole('button', { name: '保存', exact: true }).click()
+      await types.waitFor({ state: 'detached' })
+      await expect(scaffold.ctx.llm.resolveModelInfo('openai', 'gpt-6-astra')).resolves.toMatchObject({ inputModalities: ['text', 'image'] })
+    } finally {
+      await scaffold.ctx.settings.mutate('llm-pi-ai', [{ op: 'unset', path: ['providers', 'openai'] }])
+      await edit.waitFor({ state: 'detached' })
+      await page.getByRole('dialog', { name: '选择要添加的模型' }).waitFor({ state: 'detached' })
+    }
     expect(tripwire.pageErrors).toEqual([])
   }, 60_000)
 
   it('confirms an identified provider deletion before removing its profile and key', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-models-delete'))
+    expect(await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')).not.toContain('openai:')
     const settingsDialog = page.getByRole('dialog', { name: '设置' })
     await settingsDialog.getByRole('button', { name: '删除 minimax-cn', exact: true }).click()
     const deleteDialog = page.getByRole('dialog', { name: '删除 minimax-cn?' })

+ 2 - 2
docs/user/guide/providers.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/user/guide/providers.md
-providers.md: 90d3bb2c96a80185fbc5e3e70f1ff577f6923acc
-providers.zh.md: 2937d676253ee45dad48655398736d36fc37dff2
+providers.md: e578ff96a373c96ebe646126d6c60d7bcc62bbc1
+providers.zh.md: fd0859f0b235efb5fe8ecc3c248a2ac2bbaeb495

+ 11 - 7
docs/user/guide/providers.md

@@ -42,15 +42,15 @@ If a saved default names a provider that was deleted, the composer displays **Se
 
 The generated [plugin configuration catalog](../../config-catalog.md) lists every supported field and default for every plugin; [`dsh-llm-pi-ai`](../../config-catalog.md#deepseek-aidsh-llm-pi-ai) is the provider section this page configures. The [`dsh-llm-pi-ai`](../../../packages/llm/llm-pi-ai/README.md) and [`dsh-llm-deepseek`](../../../packages/llm/llm-deepseek/README.md) references own direct `settings.yaml` configuration, catalog resolution, reasoning controls, credentials, and adapter errors.
 
-::: tip The form is deliberately small
-The Models page exposes only what a route needs to exist: the API key, display name, base URL, API protocol, and for each model its id, display name, context window, and max output tokens. Every other field — reasoning effort levels, image input, request-compatibility switches, headers, timeouts, retry policy — is set in `$DSH_HOME/settings.yaml`, the same document the page writes. Edit it directly, or, when the browser runs on the same machine as the server, open it with **Open configuration file** in the Settings header; the adapters re-read it on the next request, so nothing needs a restart. The subsections below cover the fields most gateways need.
+::: tip Additional settings
+The Models page exposes the API key, display name, base URL, API protocol, and each model's id, display name, context window, max output tokens, and input types. Configure reasoning effort levels, request-compatibility switches, headers, timeouts, and retry policy in `$DSH_HOME/settings.yaml`, the same document the page writes. Edit it directly, or, when the browser runs on the same machine as the server, open it with **Open configuration file** in the Settings header; the adapters re-read it on the next request, so nothing needs a restart. The subsections below cover the fields most gateways need.
 :::
 
 ### Image input
 
-A model you enter by hand is treated as text-only until it says otherwise, because nothing can ask an endpoint which modalities it accepts. Attaching an image to such a model is refused before it is sent, naming the model.
+In **Settings → Models**, edit the provider, open **Customized settings**, and expand the model's **Model options**. **Input types** occupies its own row below the capacity fields. Select **Image** for a model that accepts images, and save. **Text** starts selected for a new custom model with no inherited image capability. At least one type must remain selected; select Image before clearing Text for an image-only model.
 
-A vision model on a custom provider therefore needs one line. The form has no field for it; add `input` to the model in `$DSH_HOME/settings.yaml`:
+The checkboxes save `input` for pi-ai models and `inputModalities` for the direct DeepSeek adapter. You can also edit the model in `$DSH_HOME/settings.yaml`; for example, this custom pi-ai provider declares one text-only model and one vision model:
 
 ```yaml
 llm-pi-ai:
@@ -65,7 +65,11 @@ llm-pi-ai:
           input: [text, image]
 ```
 
-`input` accepts `text` and `image`, and applies to that model alone, so one route can serve both kinds. Omitting it — or writing an empty list, which means the same thing — keeps whatever the installed catalog records for that model, and falls back to the route's `defaultInput` for a model the catalog does not describe.
+Pi-ai's `input` accepts `text` and `image` and applies to that model alone. An explicit nonempty selection takes priority. An omitted or empty `input` inherits the installed catalog's input types, then the route's `defaultInput`, which defaults to `[text]`. The checkboxes display these inherited values without saving an override when you merely open the row.
+
+DeepSeek treats an omitted `inputModalities` as text-only and rejects an empty list. Clearing Image also removes that model's `imagePixelBudget` and `imageMaxBytes`, because DeepSeek rejects image limits on a text-only model. Set those limits again if you later enable images and need custom limits.
+
+To restore inheritance after editing the checkboxes, remove the model's `input` or `inputModalities` field from `settings.yaml`. **Restore defaults** removes the entire model-catalog override, including other model edits, so use it only when you want to restore the whole catalog.
 
 If every model you entered by hand takes images, set the fallback once on the route instead of on each of them:
 
@@ -82,7 +86,7 @@ llm-pi-ai:
         - id: second-model
 ```
 
-`defaultInput` is a fallback, not an override, and defaults to `[text]`: on a built-in provider it answers only for models its catalog does not describe, so it never removes images from a catalog model that has them. Narrow one of those with that model's own `input`. A built-in provider has no `models` list to put it in, so write it under `modelOverrides`, keyed by model id:
+`defaultInput` is a fallback, not an override, and defaults to `[text]`: on a built-in provider it answers only for models its catalog does not describe, so it never removes images from a catalog model that has them. Narrow one of those with that model's own `input`. When a built-in provider has no explicit `models` list, write it under `modelOverrides`, keyed by model id:
 
 ```yaml
 llm-pi-ai:
@@ -93,7 +97,7 @@ llm-pi-ai:
           input: [text]
 ```
 
-Every list must name at least one modality except a model's own, where an empty list means the same as omitting it. An unknown modality is refused wherever it is written.
+In pi-ai configuration, every list must name at least one modality except a model's own `input`, where an empty list means the same as omitting it. An unknown modality is refused wherever it is written.
 
 Both fields state a claim about your endpoint rather than checking it. A model that declares images its endpoint does not serve is not caught here; the provider rejects the request instead.
 

+ 11 - 7
docs/user/guide/providers.zh.md

@@ -42,15 +42,15 @@ Provider ID 是永久的,因为请求、已保存会话、模型默认值和
 
 自动生成的[插件配置目录](../../config-catalog.zh.md)列出每个插件的所有受支持字段与默认值;[`dsh-llm-pi-ai`](../../config-catalog.zh.md#deepseek-aidsh-llm-pi-ai) 就是本页所配置的那个提供方段落。[`dsh-llm-pi-ai`](../../../packages/llm/llm-pi-ai/README.zh.md) 和 [`dsh-llm-deepseek`](../../../packages/llm/llm-deepseek/README.zh.md) 参考文档负责直接 `settings.yaml` 配置、目录解析、推理控制、凭据与适配器错误。
 
-::: tip 表单刻意保持精简
-模型页只开放让一条路由得以存在的字段:API 密钥、显示名称、API 地址、API 协议,以及每个模型的 ID、显示名称、上下文窗口和最大输出 token 数。其余所有字段——推理等级、图片输入、请求兼容性开关、请求头、超时、重试策略——都在 `$DSH_HOME/settings.yaml` 中设置,也就是模型页写入的同一份文档。可以直接编辑它;浏览器与服务器在同一台机器时,也可以点击设置页顶部的**打开配置文件**打开它。适配器会在下一次请求时重新读取,无需重启任何东西。下面各小节介绍多数网关会用到的字段。
+::: tip 其他设置
+模型页提供 API 密钥、显示名称、API 地址、API 协议,以及每个模型的 ID、显示名称、上下文窗口、最大输出 token 数和输入类型。推理等级、请求兼容性开关、请求头、超时和重试策略在 `$DSH_HOME/settings.yaml` 中设置,也就是模型页写入的同一份文档。可以直接编辑它;浏览器与服务器在同一台机器时,也可以点击设置页顶部的**打开配置文件**打开它。适配器会在下一次请求时重新读取,无需重启任何东西。下面各小节介绍多数网关会用到的字段。
 :::
 
 ### 图片输入
 
-手动输入的模型在自己声明之前一律按纯文本对待,因为没有任何环节能去询问端点接受哪些模态。给这类模型附加图片,会在发送前就被拒绝,并点名该模型。
+在**设置 → 模型**中编辑提供方,打开**自定义设置**并展开该模型的**模型选项**。**输入类型**独占容量字段下方的一行。对于支持图片的模型,勾选**图片**并保存。没有继承图片能力的新自定义模型默认勾选**文本**。至少保留一种输入类型;仅图片模型需先勾选图片,再取消文本。
 
-因此自定义提供方下的视觉模型需要加一行。表单没有对应字段;请在 `$DSH_HOME/settings.yaml` 中给该模型加上 `input`:
+复选框将 pi-ai 模型的选择保存为 `input`,将直连 DeepSeek 适配器的选择保存为 `inputModalities`。也可以在 `$DSH_HOME/settings.yaml` 中编辑模型;例如,以下自定义 pi-ai 提供方声明了一个纯文本模型和一个视觉模型:
 
 ```yaml
 llm-pi-ai:
@@ -65,7 +65,11 @@ llm-pi-ai:
           input: [text, image]
 ```
 
-`input` 接受 `text` 和 `image`,且只作用于该模型,因此一条路由可以同时服务两类模型。省略它——或写成空列表,两者同义——则保留已安装目录为该模型记录的模态;目录未描述的模型则回退到该路由的 `defaultInput`。
+Pi-ai 的 `input` 接受 `text` 和 `image`,且只作用于该模型。显式的非空选择优先。省略或为空的 `input` 先继承已安装目录的输入类型,再回退到路由的 `defaultInput`,后者默认为 `[text]`。复选框会显示这些继承值,仅打开模型行不会保存覆盖值。
+
+DeepSeek 将省略的 `inputModalities` 视为纯文本,并拒绝空列表。取消图片还会移除该模型的 `imagePixelBudget` 和 `imageMaxBytes`,因为 DeepSeek 拒绝纯文本模型上的图片限制。以后重新启用图片且需要自定义限制时,需再次设置这些限制。
+
+修改复选框后如需恢复继承,可在 `settings.yaml` 中移除模型的 `input` 或 `inputModalities` 字段。**恢复默认模型**会移除整个模型目录覆盖,包括其他模型编辑,因此仅在需要恢复整个目录时使用。
 
 如果你手动录入的模型全都接受图片,可以在路由上设置一次回退值,不必逐个模型写:
 
@@ -82,7 +86,7 @@ llm-pi-ai:
         - id: second-model
 ```
 
-`defaultInput` 是回退值而不是覆盖值,默认为 `[text]`:在内置提供方上,它只为其目录未描述的模型作答,因此绝不会把目录中本就具备图片能力的模型的该能力去掉。要收窄这类模型,请用它自己的 `input`。内置提供方没有可供填写的 `models` 列表,因此写在 `modelOverrides` 下,以模型 id 为键:
+`defaultInput` 是回退值而不是覆盖值,默认为 `[text]`:在内置提供方上,它只为其目录未描述的模型作答,因此绝不会把目录中本就具备图片能力的模型的该能力去掉。要收窄这类模型,请用它自己的 `input`。内置提供方没有显式 `models` 列表时,写在 `modelOverrides` 下,以模型 id 为键:
 
 ```yaml
 llm-pi-ai:
@@ -93,7 +97,7 @@ llm-pi-ai:
           input: [text]
 ```
 
-除模型自身的列表外,每个列表都至少要写一项模态;模型自身的空列表与省略它同义。未知模态在任何位置写入都会被拒绝。
+在 pi-ai 配置中,除模型自身的 `input` 外,每个列表都至少要写一项模态;模型自身的空列表与省略它同义。未知模态在任何位置写入都会被拒绝。
 
 这两个字段都是对你端点的断言,而不是对它的检查。声明了端点并不提供的图片能力的模型不会在这里被拦下,改由提供方拒绝该请求。