From 72a607dfee4988953b927e240d5fc197c999eedf Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 15 Oct 2026 19:52:23 +0800 Subject: [PATCH 01/58] fix(ui-settings-models): open model picker on the configured models The fetched-model picker now starts with the configured models checked and Update list adds newly checked candidates and removes unchecked configured ones. Configured models the provider does not list stay. Select all and Deselect all change only visible candidates so a hidden configured model is not removed unseen. Refs #6290 --- .../models-settings/model-picker.expected.md | 8 +- apps/web/tests/models-settings.e2e.ts | 15 +-- docs/user/guide/providers.i18n.yaml | 4 +- docs/user/guide/providers.md | 2 +- docs/user/guide/providers.zh.md | 2 +- .../ui-settings-models/README.i18n.yaml | 4 +- packages/client/ui-settings-models/README.md | 2 +- .../client/ui-settings-models/README.zh.md | 2 +- .../src/client/ModelListEditor.tsx | 95 +++++++++++-------- .../ui-settings-models/src/client/locales.ts | 12 +-- .../tests/provider-form.client.spec.tsx | 42 ++++++-- 11 files changed, 116 insertions(+), 72 deletions(-) diff --git a/apps/web/tests/expected/models-settings/model-picker.expected.md b/apps/web/tests/expected/models-settings/model-picker.expected.md index 01fd8e7166..a46d19c495 100644 --- a/apps/web/tests/expected/models-settings/model-picker.expected.md +++ b/apps/web/tests/expected/models-settings/model-picker.expected.md @@ -1,7 +1,7 @@ -- dialog "选择要添加的模型": - - heading "选择要添加的模型" [level=2] +- dialog "选择模型": + - heading "选择模型" [level=2] - button "关闭" - - paragraph: 以下是模型提供商的可用模型,勾选要添加的模型。 + - paragraph: 以下是模型提供商的可用模型。已勾选的模型会保留在列表中,取消勾选已配置的模型即可移除。 - searchbox "搜索模型" - button "取消全选" - list: @@ -15,4 +15,4 @@ - checkbox "MiniMax-M3" [checked] - text: MiniMax-M3 - button "取消" - - button "添加所选" + - button "更新列表" diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts index 0abc01dcd1..b110eeaf8f 100644 --- a/apps/web/tests/models-settings.e2e.ts +++ b/apps/web/tests/models-settings.e2e.ts @@ -241,14 +241,14 @@ describe('web e2e: Models settings page configures a dormant provider', () => { expect(tripwire.pageErrors).toEqual([]) }, 60_000) - it('filters the discovered model catalog and clears hidden selections', async () => { + it('filters the discovered model catalog and deselects only visible candidates', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-models-picker')) const settingsDialog = page.getByRole('dialog', { name: '设置' }) await settingsDialog.getByRole('button', { name: '编辑 minimax-cn' }).click() await settingsDialog.getByText('自定义设置').click() await settingsDialog.getByRole('button', { name: '获取可用模型' }).click() - const picker = page.getByRole('dialog', { name: '选择要添加的模型' }) + const picker = page.getByRole('dialog', { name: '选择模型' }) await picker.waitFor({ timeout: 10_000 }) const boxes = picker.getByRole('checkbox') const count = await boxes.count() @@ -268,12 +268,13 @@ describe('web e2e: Models settings page configures a dormant provider', () => { await search.fill('') await expect.poll(async () => boxes.count()).toBe(count) const restored = await boxes.evaluateAll(nodes => nodes.map(node => (node as HTMLInputElement).checked)) - expect(restored).toEqual(Array.from({ length: count }, () => false)) + expect(restored.filter(checked => !checked)).toHaveLength(1) + expect(await picker.getByRole('checkbox', { name: 'MiniMax-M2.7-highspeed', exact: true }).isChecked()).toBe(false) await picker.getByRole('button', { name: '全选' }).waitFor() await picker.getByRole('button', { name: '全选' }).click() const snapshot = await captureStableAria( page, - '[role="dialog"][aria-label="选择要添加的模型"]', + '[role="dialog"][aria-label="选择模型"]', scaffold.workspaceCwd, ) await compareOrRefreshGolden(MODEL_PICKER_EXPECTED, snapshot, MODE) @@ -514,11 +515,11 @@ describe('web e2e: Models settings page configures a dormant provider', () => { await dialog.getByRole('button', { name: '删除模型 1' }).click() await dialog.getByRole('button', { name: '获取可用模型' }).click() - const picker = page.getByRole('dialog', { name: '选择要添加的模型' }) + 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 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() @@ -527,7 +528,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => { } 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' }) + await page.getByRole('dialog', { name: '选择模型' }).waitFor({ state: 'detached' }) } expect(tripwire.pageErrors).toEqual([]) }, 60_000) diff --git a/docs/user/guide/providers.i18n.yaml b/docs/user/guide/providers.i18n.yaml index e96b8abe57..6f7e665a4f 100644 --- a/docs/user/guide/providers.i18n.yaml +++ b/docs/user/guide/providers.i18n.yaml @@ -15,8 +15,8 @@ en: 17867305a3e1b52a zh: 876fedf1f6f563e4 /configure-models/add-a-custom-model-api/discover-models: - en: 97318c5a115199ff - zh: 0c455e4232cf0265 + en: ec10ba53a00eddc6 + zh: 857373a4e0fefee4 /configure-models/select-a-model: en: 6a94264c99535caf zh: f97bb0dc6a40c0d3 diff --git a/docs/user/guide/providers.md b/docs/user/guide/providers.md index 4a57c7b7bc..c1118e469f 100644 --- a/docs/user/guide/providers.md +++ b/docs/user/guide/providers.md @@ -32,7 +32,7 @@ The Provider ID is permanent because requests, saved sessions, model defaults, a ### Discover models -Under **Model catalog**, choose **Fetch available models** to ask the endpoint which models it serves. The request uses the base URL, protocol, and key currently in the form, or a saved provider's stored key, and the reply opens a searchable picker: search, tick the models you want, and choose **Add selected**. Nothing is stored until you save or create the provider. +Under **Model catalog**, choose **Fetch available models** to ask the endpoint which models it serves. The request uses the base URL, protocol, and key currently in the form, or a saved provider's stored key, and the reply opens a searchable picker: models already in the list start ticked, so search, tick the models to add, untick the ones to remove, and choose **Update list**. Configured models the endpoint does not list stay. Nothing is stored until you save or create the provider. Discovery reads the listing formats common gateways publish, but not every endpoint answers in one of them, so treat it as a convenience rather than a guarantee: when it fails or lists nothing, add the model ids by hand and they work just the same. A built-in provider is always answered from the installed catalog, even when its base URL points at a gateway, so fetch through a custom provider to see what the gateway really serves. diff --git a/docs/user/guide/providers.zh.md b/docs/user/guide/providers.zh.md index 9189f66815..ebf6147681 100644 --- a/docs/user/guide/providers.zh.md +++ b/docs/user/guide/providers.zh.md @@ -32,7 +32,7 @@ Provider ID 是永久的,因为请求、已保存会话、模型默认值和 ### 探测模型 -在**模型目录**中选择**获取可用模型**,即可询问端点它提供哪些模型。请求使用表单当前的 API 地址、协议和密钥,已保存的提供商则用已存储的密钥;响应会打开一个可搜索的选择框,搜索、勾选想要的模型,再点**添加所选**。保存或创建提供商之前不会存储任何内容。 +在**模型目录**中选择**获取可用模型**,即可询问端点它提供哪些模型。请求使用表单当前的 API 地址、协议和密钥,已保存的提供商则用已存储的密钥;响应会打开一个可搜索的选择框,列表中已有的模型默认勾选,搜索后勾选要添加的模型、取消勾选要移除的模型,再点**更新列表**。端点未列出的已配置模型保持不变。保存或创建提供商之前不会存储任何内容。 探测读取的是常见网关公开的列表格式,但并非每个端点都用这些格式作答,所以它只是便利手段而非保证:探测失败或列表为空时,手动添加模型 ID 即可,效果完全一样。内置提供商一律由已安装目录作答,即使其 API 地址指向网关也是如此,要查看网关实际提供的模型,请通过自定义提供商探测。 diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml index 58750ae463..f6e2853f79 100644 --- a/packages/client/ui-settings-models/README.i18n.yaml +++ b/packages/client/ui-settings-models/README.i18n.yaml @@ -24,8 +24,8 @@ en: 3af73644655c8b7b zh: d689eba772100c63 /deepseek-ai-dsh-client-ui-settings-models/use-this-package/adding-and-deleting-providers: - en: 9d533c65c82cd4a4 - zh: 0a5da334e127c035 + en: 5eec53c85e2ef492 + zh: 162904aa543d30ac /deepseek-ai-dsh-client-ui-settings-models/use-this-package/first-run-dialogs: en: 589397a35f82244e zh: 342fbceee2c173a2 diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md index 1b07faff50..5bcb805ebb 100644 --- a/packages/client/ui-settings-models/README.md +++ b/packages/client/ui-settings-models/README.md @@ -63,7 +63,7 @@ A model row on a hand-declared pi-ai route also edits its **Reasoning levels**, ### Adding and deleting providers -**Add model provider** is the one entry to the add card, offered while any editor-backed settings namespace is mounted and enabled while either mode below can proceed. The card opens on a segmented mode switch with a one-line purpose beneath it: **Third-party model provider** carries the dormant-directory provider select over the provider editor — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists — and **Custom model API** carries the create form for a route pi-ai does not ship: a relay, a self-hosted server, or any other OpenAI- or Anthropic-compatible endpoint. A mode is offered only while its namespace is mounted; with one mode the card shows that form alone under the mode's name, and a mode whose namespace has nothing left to adopt or no protocol to declare is disabled with the reason as its hover text. A panel mounts the first time its mode is shown and stays mounted, hidden, while the card is open and its mode stays offered, so switching modes discards neither draft; the switch locks while either panel has a write or an endpoint interrogation in flight, since a switch underneath one would orphan its answer. Each tab controls its panel through ids, and closing either panel forgets the catalog draft's target. The custom form asks for a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model, because nothing can default those. The protocol picker names each protocol by its product name — OpenAI Chat Completions, OpenAI Responses, Anthropic Messages — while the stored value stays the schema identifier; a protocol the adapter adds before this page names it shows that identifier. The endpoint placeholder follows the selected protocol: OpenAI Chat Completions and Responses show `https://gateway.example/v1`, while Anthropic Messages shows `https://gateway.example` because its SDK appends `/v1/messages`. Changing the protocol preserves any address already entered. The endpoint must be a parseable HTTP or HTTPS URL; localhost, IPv4 and IPv6 literals, and custom ports remain valid. A syntax error blocks both discovery and creation at the field, while a request failure remains a separate provider error. **Fetch available models** asks the `llm/discoverModels` Remote about the endpoint the form shows, so adding a provider is one pass instead of save-then-return; the reply opens a searchable picker rather than being written, and nothing is written until **Add selected**. Each candidate displays its original model id in a monospace font to distinguish models with the same name. Rows stay on one line; hovering reveals the model name, falling back to the full id when no name is supplied. Selection and configuration retain the original model id. Each selected candidate copies its id, display name, context window, output-token cap, and disclosed input types into the editable row when disclosed, while an existing row retains its user-tuned values. Search matches model ids and optional display names without clearing hidden selections. **Select all** adds the visible results, while **Deselect all** clears the entire selection so hidden results cannot be adopted accidentally. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its confirmation dialog names the provider. +**Add model provider** is the one entry to the add card, offered while any editor-backed settings namespace is mounted and enabled while either mode below can proceed. The card opens on a segmented mode switch with a one-line purpose beneath it: **Third-party model provider** carries the dormant-directory provider select over the provider editor — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists — and **Custom model API** carries the create form for a route pi-ai does not ship: a relay, a self-hosted server, or any other OpenAI- or Anthropic-compatible endpoint. A mode is offered only while its namespace is mounted; with one mode the card shows that form alone under the mode's name, and a mode whose namespace has nothing left to adopt or no protocol to declare is disabled with the reason as its hover text. A panel mounts the first time its mode is shown and stays mounted, hidden, while the card is open and its mode stays offered, so switching modes discards neither draft; the switch locks while either panel has a write or an endpoint interrogation in flight, since a switch underneath one would orphan its answer. Each tab controls its panel through ids, and closing either panel forgets the catalog draft's target. The custom form asks for a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model, because nothing can default those. The protocol picker names each protocol by its product name — OpenAI Chat Completions, OpenAI Responses, Anthropic Messages — while the stored value stays the schema identifier; a protocol the adapter adds before this page names it shows that identifier. The endpoint placeholder follows the selected protocol: OpenAI Chat Completions and Responses show `https://gateway.example/v1`, while Anthropic Messages shows `https://gateway.example` because its SDK appends `/v1/messages`. Changing the protocol preserves any address already entered. The endpoint must be a parseable HTTP or HTTPS URL; localhost, IPv4 and IPv6 literals, and custom ports remain valid. A syntax error blocks both discovery and creation at the field, while a request failure remains a separate provider error. **Fetch available models** asks the `llm/discoverModels` Remote about the endpoint the form shows, so adding a provider is one pass instead of save-then-return; the reply opens a searchable picker rather than being written, and nothing changes until **Update list**. The picker opens with the configured models checked, or every candidate when the list is empty and the route serves its whole catalog; **Update list** appends newly checked candidates and removes unchecked configured models from the draft, while configured models the reply does not list stay. Each candidate displays its original model id in a monospace font to distinguish models with the same name. Rows stay on one line; hovering reveals the model name, falling back to the full id when no name is supplied. Selection and configuration retain the original model id. Each selected candidate copies its id, display name, context window, output-token cap, and disclosed input types into the editable row when disclosed, while an existing row retains its user-tuned values. Search matches model ids and optional display names without clearing hidden selections. **Select all** and **Deselect all** change only the visible results, so a hidden configured model is not removed unseen. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its confirmation dialog names the provider. ### First-run dialogs diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md index f272908232..1731aca2b6 100644 --- a/packages/client/ui-settings-models/README.zh.md +++ b/packages/client/ui-settings-models/README.zh.md @@ -63,7 +63,7 @@ API 密钥输入框初始为空,并通过 `autocomplete="new-password"` 请求 ### 新增与删除提供商 -**添加模型提供商**是新增卡片的唯一入口:只要任一带编辑器的设置 namespace 已挂载就会出现,下述两种方式任一可继续时才可点击。卡片打开后顶部是分段式的方式切换,下方一行小字说明所选方式的用途:**第三方模型提供商**承载休眠目录提供商选择框与提供商编辑器——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog;**自定义模型 API**承载为 pi-ai 不提供的路由(中转站、自部署服务或其他兼容 OpenAI / Anthropic 协议的接口)准备的创建表单。某种方式只在其 namespace 已挂载时提供;只剩一种方式时卡片以该方式为标题直接显示表单;namespace 已无可采用的提供商或没有可声明的协议时,对应方式会被禁用并把原因作为悬停提示。面板在其方式首次显示时挂载,之后在卡片打开且该方式仍被提供期间保持挂载但隐藏,因此切换方式不会丢掉任何一边的草稿;任一面板有写入或端点探测进行中时滑块锁定,因为此时切换会让结果落到看不见的面板上。每个 tab 通过 id 控制其面板,关闭任一面板都会忘记目录草稿的目标。自定义表单会索要唯一的 **Provider ID**、端点、协议与至少一个可唯一识别的模型,因为没有东西能为它们兜底。协议选择框以产品名称显示各协议——OpenAI Chat Completions、OpenAI Responses、Anthropic Messages——存储的值仍是 schema 标识符;适配器新增而本页尚未命名的协议直接显示其标识符。端点占位示例随所选协议变化:OpenAI Chat Completions 与 Responses 显示 `https://gateway.example/v1`,Anthropic Messages 显示 `https://gateway.example`,因为其 SDK 会追加 `/v1/messages`。切换协议会保留已输入的地址。端点必须是可解析的 HTTP 或 HTTPS URL;localhost、IPv4 与 IPv6 字面地址以及自定义端口仍然有效。语法错误会在字段处阻止询问与创建,请求失败则继续作为独立的提供商错误显示。**获取可用模型**通过 `llm/discoverModels` Remote 查询表单显示的端点,因此新增提供商一次即可完成,而非先保存再返回;回复打开的是可搜索选择器而非直接写入,只有点击**添加所选**才会写入。候选项使用等宽字体显示原始模型 id,以区分同名模型。每行保持单行,悬停可查看模型名称,未提供名称时回退到完整 id。勾选与配置仍使用原始模型 id。每个选中候选会在提供商公布相应信息时,把 id、显示名、上下文窗口、最大输出 token 数和已公布的输入类型复制进可编辑行;已经存在的行保留用户调整过的值。搜索会匹配模型 id 与可选显示名称,且不会清除隐藏项的勾选状态。**全选**会加入可见结果,而**取消全选**会清空全部勾选,以免意外采用隐藏结果。只有用户层单独携带某行时,该行才可删除(删除会恢复组合基线),其确认对话框会指名该提供商。 +**添加模型提供商**是新增卡片的唯一入口:只要任一带编辑器的设置 namespace 已挂载就会出现,下述两种方式任一可继续时才可点击。卡片打开后顶部是分段式的方式切换,下方一行小字说明所选方式的用途:**第三方模型提供商**承载休眠目录提供商选择框与提供商编辑器——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog;**自定义模型 API**承载为 pi-ai 不提供的路由(中转站、自部署服务或其他兼容 OpenAI / Anthropic 协议的接口)准备的创建表单。某种方式只在其 namespace 已挂载时提供;只剩一种方式时卡片以该方式为标题直接显示表单;namespace 已无可采用的提供商或没有可声明的协议时,对应方式会被禁用并把原因作为悬停提示。面板在其方式首次显示时挂载,之后在卡片打开且该方式仍被提供期间保持挂载但隐藏,因此切换方式不会丢掉任何一边的草稿;任一面板有写入或端点探测进行中时滑块锁定,因为此时切换会让结果落到看不见的面板上。每个 tab 通过 id 控制其面板,关闭任一面板都会忘记目录草稿的目标。自定义表单会索要唯一的 **Provider ID**、端点、协议与至少一个可唯一识别的模型,因为没有东西能为它们兜底。协议选择框以产品名称显示各协议——OpenAI Chat Completions、OpenAI Responses、Anthropic Messages——存储的值仍是 schema 标识符;适配器新增而本页尚未命名的协议直接显示其标识符。端点占位示例随所选协议变化:OpenAI Chat Completions 与 Responses 显示 `https://gateway.example/v1`,Anthropic Messages 显示 `https://gateway.example`,因为其 SDK 会追加 `/v1/messages`。切换协议会保留已输入的地址。端点必须是可解析的 HTTP 或 HTTPS URL;localhost、IPv4 与 IPv6 字面地址以及自定义端口仍然有效。语法错误会在字段处阻止询问与创建,请求失败则继续作为独立的提供商错误显示。**获取可用模型**通过 `llm/discoverModels` Remote 查询表单显示的端点,因此新增提供商一次即可完成,而非先保存再返回;回复打开的是可搜索选择器而非直接写入,只有点击**更新列表**才会改变草稿。选择器打开时勾选已配置的模型;列表为空、路由提供完整 catalog 时勾选全部候选。**更新列表**会追加新勾选的候选,并从草稿中移除取消勾选的已配置模型;回复中未列出的已配置模型保持不变。候选项使用等宽字体显示原始模型 id,以区分同名模型。每行保持单行,悬停可查看模型名称,未提供名称时回退到完整 id。勾选与配置仍使用原始模型 id。每个选中候选会在提供商公布相应信息时,把 id、显示名、上下文窗口、最大输出 token 数和已公布的输入类型复制进可编辑行;已经存在的行保留用户调整过的值。搜索会匹配模型 id 与可选显示名称,且不会清除隐藏项的勾选状态。**全选**与**取消全选**只改变可见结果,以免在看不见时移除已配置的模型。只有用户层单独携带某行时,该行才可删除(删除会恢复组合基线),其确认对话框会指名该提供商。 ### 首次运行弹窗 diff --git a/packages/client/ui-settings-models/src/client/ModelListEditor.tsx b/packages/client/ui-settings-models/src/client/ModelListEditor.tsx index c80f638f7c..b85f71c053 100644 --- a/packages/client/ui-settings-models/src/client/ModelListEditor.tsx +++ b/packages/client/ui-settings-models/src/client/ModelListEditor.tsx @@ -7,7 +7,10 @@ * catalog, so a row is only ever added deliberately. Fetching asks the endpoint * **the form currently shows** — including a key typed but not yet saved — so * adding a provider is one pass instead of save-then-return; the reply is - * candidates the user picks from, never configuration written behind them. + * candidates the user picks from, never configuration written behind them. The + * picker opens with the configured models checked, so confirming it adds the + * newly checked candidates and removes the unchecked configured ones in the + * draft; configured rows the reply does not list are kept. * * A provider that cannot be interrogated (an unreachable endpoint, a protocol * with no readable listing) is not a dead end: the failure is shown next to the @@ -197,19 +200,31 @@ export function ModelListEditor(props: ModelListEditorProps): ReactNode { const capacityText = (model: ModelDraft, index: number, field: CapacityField): string => editing.get(bufferKey(index, field)) ?? capacitySpelling(numberOf(model, field)) - /** Drop one row's entries and shift the rows after it down, in one pass. */ - const reindexOnRemove = ( - current: ReadonlyMap, - index: number, - ): Map => { - const next = new Map() - for (const [key, value] of current) { - const at = Number(key.slice(0, key.indexOf(':'))) - if (at === index) continue - // Only the row number moves; the field half of the key is untouched. - next.set(at > index ? key.replace(/^\d+/, String(at - 1)) : key, value) - } - return next + /** + * Drop the removed rows' disclosure and buffer entries and shift the rows + * after them down, so per-row state follows its row. + */ + const forgetRows = (removed: ReadonlySet): void => { + /** The surviving row's new position, or `undefined` for a removed row. */ + const moved = (at: number): number | undefined => + removed.has(at) ? undefined : at - [...removed].filter(gone => gone < at).length + setExpanded((current) => { + const next = new Set() + for (const at of current) { + const to = moved(at) + if (to !== undefined) next.add(to) + } + return next + }) + setEditing((current) => { + const next = new Map() + for (const [key, value] of current) { + const to = moved(Number(key.slice(0, key.indexOf(':')))) + // Only the row number moves; the field half of the key is untouched. + if (to !== undefined) next.set(key.replace(/^\d+/, String(to)), value) + } + return next + }) } const toggleExpanded = (index: number): void => { @@ -257,12 +272,13 @@ export function ModelListEditor(props: ModelListEditorProps): ReactNode { setFailure(t('fetchEmpty')) return } - // Everything already configured starts unchecked, so adopting a - // selection never silently rewrites a capacity the user corrected. + // The picker opens on the current list so a few models can be added or + // removed in one place. An empty list serves the whole catalog, so every + // candidate starts checked. const known = new Set(models.map(model => textOf(model, 'id'))) setCandidateQuery('') setCandidates(found) - setPicked(new Set(found.filter(model => !known.has(model.id)).map(model => model.id))) + setPicked(new Set(found.filter(model => models.length === 0 || known.has(model.id)).map(model => model.id))) } finally { setBusy(false) } @@ -277,16 +293,20 @@ export function ModelListEditor(props: ModelListEditorProps): ReactNode { const adoptPicked = (): void => { /* v8 ignore next -- the dialog only renders with candidates loaded */ if (candidates === undefined) return - const byId = new Map(models.map(model => [textOf(model, 'id'), model])) - for (const candidate of candidates) { - if (!picked.has(candidate.id)) continue - // A row the user already tuned wins over the provider's own numbers. - // Keyed by id, so a half-typed row whose id is still empty is not a - // match and the candidate joins as its own row — correct, since a row - // without an id is not yet a model and the create/apply gates refuse it. - byId.set(candidate.id, byId.get(candidate.id) ?? adopt(candidate)) - } - onChange([...byId.values()]) + const listed = new Set(candidates.map(candidate => candidate.id)) + // An unchecked candidate leaves the list. A row the listing does not name, + // including a half-typed row without an id, is outside the picker and stays. + const removed = new Set() + models.forEach((model, at) => { + const id = textOf(model, 'id') + if (listed.has(id) && !picked.has(id)) removed.add(at) + }) + const kept = models.filter((_model, at) => !removed.has(at)) + const keptIds = new Set(kept.map(model => textOf(model, 'id'))) + // A kept row the user already tuned wins over the provider's own numbers. + const added = candidates.filter(candidate => picked.has(candidate.id) && !keptIds.has(candidate.id)) + onChange([...kept, ...added.map(adopt)]) + forgetRows(removed) closePicker() } @@ -308,12 +328,15 @@ export function ModelListEditor(props: ModelListEditorProps): ReactNode { && visibleCandidates.every(candidate => picked.has(candidate.id)) const toggleVisibleCandidates = (): void => { + // Only visible candidates change: a hidden checked one may be a configured + // model, and clearing it would remove that model unseen. setPicked((current) => { - if (visibleCandidates.every(candidate => current.has(candidate.id))) { - return new Set() - } const next = new Set(current) - for (const candidate of visibleCandidates) next.add(candidate.id) + const clear = visibleCandidates.every(candidate => current.has(candidate.id)) + for (const candidate of visibleCandidates) { + if (clear) next.delete(candidate.id) + else next.add(candidate.id) + } return next }) } @@ -387,15 +410,7 @@ export function ModelListEditor(props: ModelListEditorProps): ReactNode { onToggle={() => { toggleExpanded(index) }} onRemove={() => { onChange(models.filter((_model, at) => at !== index)) - setExpanded((current) => { - const next = new Set() - for (const at of current) { - if (at < index) next.add(at) - else if (at > index) next.add(at - 1) - } - return next - }) - setEditing(current => reindexOnRemove(current, index)) + forgetRows(new Set([index])) }} /> ))} diff --git a/packages/client/ui-settings-models/src/client/locales.ts b/packages/client/ui-settings-models/src/client/locales.ts index 9bcfa18e27..9dba0e5b68 100644 --- a/packages/client/ui-settings-models/src/client/locales.ts +++ b/packages/client/ui-settings-models/src/client/locales.ts @@ -100,13 +100,13 @@ export const en = { fetching: 'Asking the provider\u2026', fetchNeedsBaseUrl: 'Enter the base URL first, then fetch.', fetchEmpty: 'The provider listed no models. Add them by hand.', - fetchTitle: 'Choose models to add', - fetchDescription: 'These are the models this provider has available. Choose the ones to add.', + fetchTitle: 'Choose models', + fetchDescription: 'These are the models this provider has available. Checked models will be in the list; uncheck a configured model to remove it.', fetchSearch: 'Search models', fetchNoMatches: 'No matching models.', fetchSelectAll: 'Select all', fetchDeselectAll: 'Deselect all', - fetchAdopt: 'Add selected', + fetchAdopt: 'Update list', customTag: 'Custom', customRoute: 'Provider ID', customRouteHint: 'Lowercase identifier, starting with a letter, that uniquely names this provider in requests and as its credential name.', @@ -241,13 +241,13 @@ export const zh: { [Key in keyof typeof en]: string } = { fetching: '正在询问提供商\u2026', fetchNeedsBaseUrl: '请先填写 API 地址,再获取。', fetchEmpty: '该提供商没有列出任何模型,请手动添加。', - fetchTitle: '选择要添加的模型', - fetchDescription: '以下是模型提供商的可用模型,勾选要添加的模型。', + fetchTitle: '选择模型', + fetchDescription: '以下是模型提供商的可用模型。已勾选的模型会保留在列表中,取消勾选已配置的模型即可移除。', fetchSearch: '搜索模型', fetchNoMatches: '没有匹配的模型。', fetchSelectAll: '全选', fetchDeselectAll: '取消全选', - fetchAdopt: '添加所选', + fetchAdopt: '更新列表', customTag: '自定义', customRoute: 'Provider ID', customRouteHint: '以小写字母开头的标识,在请求中唯一标识该提供商,并用于派生凭据名。', diff --git a/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx b/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx index 7a49fa4505..47e25dac5a 100644 --- a/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx @@ -621,7 +621,7 @@ describe('endpoint interrogation', () => { }) }) - it('adopts only the picked candidates, keeping a row the user already tuned', async () => { + it('opens on the configured models and appends a newly checked one, keeping a row the user already tuned', async () => { const discover = vi.fn(() => Promise.resolve(ok([ { id: 'kept', contextWindow: 999, inputModalities: ['text', 'image'] }, { id: 'fresh', contextWindow: 4096, maxTokens: 2048, name: 'Fresh', inputModalities: ['text', 'image'] }, @@ -634,11 +634,10 @@ describe('endpoint interrogation', () => { fireEvent.click(screen.getByText(en.fetchModels)) await screen.findByText(en.fetchTitle) - expect(screen.getByRole('checkbox', { name: 'fresh' }).checked).toBe(true) expect(screen.queryByRole('checkbox', { name: 'Fresh' })).toBeNull() - // The already-configured row starts unchecked; the new one starts checked. const boxes = [...document.querySelectorAll('input[type="checkbox"]')] - expect(boxes.map(box => box.checked)).toEqual([false, true]) + expect(boxes.map(box => box.checked)).toEqual([true, false]) + fireEvent.click(screen.getByRole('checkbox', { name: 'fresh' })) fireEvent.click(screen.getByText(en.fetchAdopt)) expect(screen.getByLabelText(`${en.modelId} 2`).value).toBe('fresh') @@ -656,6 +655,31 @@ describe('endpoint interrogation', () => { ]) }) + it('removes an unchecked configured model and keeps one the provider does not list', async () => { + const discover = vi.fn(() => Promise.resolve(ok([{ id: 'gone' }, { id: 'stays' }, { id: 'new' }]))) + const { mutate } = await mountSection({ + discover, + providers: { openai: { baseURL: 'https://proxy.example/v1', models: [ + { id: 'gone' }, { id: 'unlisted', maxTokens: 512 }, { id: 'stays', contextWindow: 111 }, + ] } }, + }) + openEditor('openai') + expandModel(3) + + fireEvent.click(screen.getByText(en.fetchModels)) + await screen.findByText(en.fetchTitle) + fireEvent.click(screen.getByRole('checkbox', { name: 'gone' })) + fireEvent.click(screen.getByText(en.fetchAdopt)) + + // The disclosure follows its row up the list. + expect(screen.getByLabelText(`${en.contextWindow} 2`).value).toBe('111') + fireEvent.click(screen.getByText(en.apply)) + await waitFor(() => { expect(mutate).toHaveBeenCalled() }) + expect(firstMutate(mutate).ops[0]?.value).toEqual([ + { id: 'unlisted', maxTokens: 512 }, { id: 'stays', contextWindow: 111 }, + ]) + }) + it('keeps the rows editable when the provider cannot be interrogated', async () => { const discover = vi.fn(() => Promise.resolve( fail('https://proxy.example/v1/models answered 401; check the API key', 'llm/model-discovery-rejected'), @@ -765,7 +789,7 @@ describe('endpoint interrogation', () => { expect(firstMutate(mutate).ops[0]?.value).toEqual([{ id: 'a' }, { id: 'b', maxTokens: 2048 }]) }) - it('filters by model id or name, selects visible candidates, and clears every selection', async () => { + it('filters by model id or name and selects or deselects only visible candidates', async () => { const discover = vi.fn(() => Promise.resolve(ok([ { id: 'alpha' }, { id: 'opaque-id', name: 'Beta Display' }, { id: 'gamma' }, ]))) @@ -796,10 +820,14 @@ describe('endpoint interrogation', () => { expect([...dialog.querySelectorAll('input[type="checkbox"]')] .map(box => box.checked)).toEqual([false]) - // Deselecting a filtered result must also clear hidden selections so they - // cannot be adopted accidentally. + // Hidden candidates keep their state, so a configured model is never + // removed unseen. fireEvent.change(search, { target: { value: '' } }) const boxes = [...dialog.querySelectorAll('input[type="checkbox"]')] + expect(boxes.map(box => box.checked)).toEqual([true, false, true]) + fireEvent.click(within_(dialog, en.fetchSelectAll)) + expect(boxes.map(box => box.checked)).toEqual([true, true, true]) + fireEvent.click(within_(dialog, en.fetchDeselectAll)) expect(boxes.map(box => box.checked)).toEqual([false, false, false]) // Selecting while filtered adds only visible candidates. From f150f2178d95a201ae131f047da9768d39903fc2 Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 15 Oct 2026 20:17:52 +0800 Subject: [PATCH 02/58] fix(ui-settings-models): keep model picker selection current and non-empty Derive the picker's checked state from the list at render so a row drafted during the fetch is not removed. Disable Update list when it would empty a non-empty list, because an empty list serves the whole catalog. List a repeated candidate id once and treat rows without an id as unconfigured. --- .../models-settings/model-picker.expected.md | 2 +- .../ui-settings-models/README.i18n.yaml | 4 +- packages/client/ui-settings-models/README.md | 2 +- .../client/ui-settings-models/README.zh.md | 2 +- .../src/client/ModelListEditor.tsx | 86 ++++++++++++------- .../ui-settings-models/src/client/locales.ts | 6 +- .../tests/provider-form.client.spec.tsx | 63 +++++++++++++- 7 files changed, 124 insertions(+), 41 deletions(-) diff --git a/apps/web/tests/expected/models-settings/model-picker.expected.md b/apps/web/tests/expected/models-settings/model-picker.expected.md index a46d19c495..2d5d1cf617 100644 --- a/apps/web/tests/expected/models-settings/model-picker.expected.md +++ b/apps/web/tests/expected/models-settings/model-picker.expected.md @@ -1,7 +1,7 @@ - dialog "选择模型": - heading "选择模型" [level=2] - button "关闭" - - paragraph: 以下是模型提供商的可用模型。已勾选的模型会保留在列表中,取消勾选已配置的模型即可移除。 + - paragraph: 以下是模型提供商的可用模型,勾选的模型即为模型列表。 - searchbox "搜索模型" - button "取消全选" - list: diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml index f6e2853f79..aa703c0bfc 100644 --- a/packages/client/ui-settings-models/README.i18n.yaml +++ b/packages/client/ui-settings-models/README.i18n.yaml @@ -24,8 +24,8 @@ en: 3af73644655c8b7b zh: d689eba772100c63 /deepseek-ai-dsh-client-ui-settings-models/use-this-package/adding-and-deleting-providers: - en: 5eec53c85e2ef492 - zh: 162904aa543d30ac + en: c0b04b2b11af60d3 + zh: d195eec281e445cb /deepseek-ai-dsh-client-ui-settings-models/use-this-package/first-run-dialogs: en: 589397a35f82244e zh: 342fbceee2c173a2 diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md index 5bcb805ebb..acb0b40a65 100644 --- a/packages/client/ui-settings-models/README.md +++ b/packages/client/ui-settings-models/README.md @@ -63,7 +63,7 @@ A model row on a hand-declared pi-ai route also edits its **Reasoning levels**, ### Adding and deleting providers -**Add model provider** is the one entry to the add card, offered while any editor-backed settings namespace is mounted and enabled while either mode below can proceed. The card opens on a segmented mode switch with a one-line purpose beneath it: **Third-party model provider** carries the dormant-directory provider select over the provider editor — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists — and **Custom model API** carries the create form for a route pi-ai does not ship: a relay, a self-hosted server, or any other OpenAI- or Anthropic-compatible endpoint. A mode is offered only while its namespace is mounted; with one mode the card shows that form alone under the mode's name, and a mode whose namespace has nothing left to adopt or no protocol to declare is disabled with the reason as its hover text. A panel mounts the first time its mode is shown and stays mounted, hidden, while the card is open and its mode stays offered, so switching modes discards neither draft; the switch locks while either panel has a write or an endpoint interrogation in flight, since a switch underneath one would orphan its answer. Each tab controls its panel through ids, and closing either panel forgets the catalog draft's target. The custom form asks for a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model, because nothing can default those. The protocol picker names each protocol by its product name — OpenAI Chat Completions, OpenAI Responses, Anthropic Messages — while the stored value stays the schema identifier; a protocol the adapter adds before this page names it shows that identifier. The endpoint placeholder follows the selected protocol: OpenAI Chat Completions and Responses show `https://gateway.example/v1`, while Anthropic Messages shows `https://gateway.example` because its SDK appends `/v1/messages`. Changing the protocol preserves any address already entered. The endpoint must be a parseable HTTP or HTTPS URL; localhost, IPv4 and IPv6 literals, and custom ports remain valid. A syntax error blocks both discovery and creation at the field, while a request failure remains a separate provider error. **Fetch available models** asks the `llm/discoverModels` Remote about the endpoint the form shows, so adding a provider is one pass instead of save-then-return; the reply opens a searchable picker rather than being written, and nothing changes until **Update list**. The picker opens with the configured models checked, or every candidate when the list is empty and the route serves its whole catalog; **Update list** appends newly checked candidates and removes unchecked configured models from the draft, while configured models the reply does not list stay. Each candidate displays its original model id in a monospace font to distinguish models with the same name. Rows stay on one line; hovering reveals the model name, falling back to the full id when no name is supplied. Selection and configuration retain the original model id. Each selected candidate copies its id, display name, context window, output-token cap, and disclosed input types into the editable row when disclosed, while an existing row retains its user-tuned values. Search matches model ids and optional display names without clearing hidden selections. **Select all** and **Deselect all** change only the visible results, so a hidden configured model is not removed unseen. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its confirmation dialog names the provider. +**Add model provider** is the one entry to the add card, offered while any editor-backed settings namespace is mounted and enabled while either mode below can proceed. The card opens on a segmented mode switch with a one-line purpose beneath it: **Third-party model provider** carries the dormant-directory provider select over the provider editor — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists — and **Custom model API** carries the create form for a route pi-ai does not ship: a relay, a self-hosted server, or any other OpenAI- or Anthropic-compatible endpoint. A mode is offered only while its namespace is mounted; with one mode the card shows that form alone under the mode's name, and a mode whose namespace has nothing left to adopt or no protocol to declare is disabled with the reason as its hover text. A panel mounts the first time its mode is shown and stays mounted, hidden, while the card is open and its mode stays offered, so switching modes discards neither draft; the switch locks while either panel has a write or an endpoint interrogation in flight, since a switch underneath one would orphan its answer. Each tab controls its panel through ids, and closing either panel forgets the catalog draft's target. The custom form asks for a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model, because nothing can default those. The protocol picker names each protocol by its product name — OpenAI Chat Completions, OpenAI Responses, Anthropic Messages — while the stored value stays the schema identifier; a protocol the adapter adds before this page names it shows that identifier. The endpoint placeholder follows the selected protocol: OpenAI Chat Completions and Responses show `https://gateway.example/v1`, while Anthropic Messages shows `https://gateway.example` because its SDK appends `/v1/messages`. Changing the protocol preserves any address already entered. The endpoint must be a parseable HTTP or HTTPS URL; localhost, IPv4 and IPv6 literals, and custom ports remain valid. A syntax error blocks both discovery and creation at the field, while a request failure remains a separate provider error. **Fetch available models** asks the `llm/discoverModels` Remote about the endpoint the form shows, so adding a provider is one pass instead of save-then-return; the reply opens a searchable picker rather than being written, and nothing changes until **Update list**. The picker opens with the configured models checked, or every candidate when the list is empty and the route serves its whole catalog; **Update list** appends newly checked candidates and removes unchecked configured models from the draft, while configured models the reply does not list stay. Rows without an id do not count as configured, a repeated candidate id is listed once, and **Update list** is disabled when it would empty a non-empty list, since an empty list serves the whole catalog. Each candidate displays its original model id in a monospace font to distinguish models with the same name. Rows stay on one line; hovering reveals the model name, falling back to the full id when no name is supplied. Selection and configuration retain the original model id. Each selected candidate copies its id, display name, context window, output-token cap, and disclosed input types into the editable row when disclosed, while an existing row retains its user-tuned values. Search matches model ids and optional display names without clearing hidden selections. **Select all** and **Deselect all** change only the visible results, so a hidden configured model is not removed unseen. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its confirmation dialog names the provider. ### First-run dialogs diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md index 1731aca2b6..55856756bf 100644 --- a/packages/client/ui-settings-models/README.zh.md +++ b/packages/client/ui-settings-models/README.zh.md @@ -63,7 +63,7 @@ API 密钥输入框初始为空,并通过 `autocomplete="new-password"` 请求 ### 新增与删除提供商 -**添加模型提供商**是新增卡片的唯一入口:只要任一带编辑器的设置 namespace 已挂载就会出现,下述两种方式任一可继续时才可点击。卡片打开后顶部是分段式的方式切换,下方一行小字说明所选方式的用途:**第三方模型提供商**承载休眠目录提供商选择框与提供商编辑器——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog;**自定义模型 API**承载为 pi-ai 不提供的路由(中转站、自部署服务或其他兼容 OpenAI / Anthropic 协议的接口)准备的创建表单。某种方式只在其 namespace 已挂载时提供;只剩一种方式时卡片以该方式为标题直接显示表单;namespace 已无可采用的提供商或没有可声明的协议时,对应方式会被禁用并把原因作为悬停提示。面板在其方式首次显示时挂载,之后在卡片打开且该方式仍被提供期间保持挂载但隐藏,因此切换方式不会丢掉任何一边的草稿;任一面板有写入或端点探测进行中时滑块锁定,因为此时切换会让结果落到看不见的面板上。每个 tab 通过 id 控制其面板,关闭任一面板都会忘记目录草稿的目标。自定义表单会索要唯一的 **Provider ID**、端点、协议与至少一个可唯一识别的模型,因为没有东西能为它们兜底。协议选择框以产品名称显示各协议——OpenAI Chat Completions、OpenAI Responses、Anthropic Messages——存储的值仍是 schema 标识符;适配器新增而本页尚未命名的协议直接显示其标识符。端点占位示例随所选协议变化:OpenAI Chat Completions 与 Responses 显示 `https://gateway.example/v1`,Anthropic Messages 显示 `https://gateway.example`,因为其 SDK 会追加 `/v1/messages`。切换协议会保留已输入的地址。端点必须是可解析的 HTTP 或 HTTPS URL;localhost、IPv4 与 IPv6 字面地址以及自定义端口仍然有效。语法错误会在字段处阻止询问与创建,请求失败则继续作为独立的提供商错误显示。**获取可用模型**通过 `llm/discoverModels` Remote 查询表单显示的端点,因此新增提供商一次即可完成,而非先保存再返回;回复打开的是可搜索选择器而非直接写入,只有点击**更新列表**才会改变草稿。选择器打开时勾选已配置的模型;列表为空、路由提供完整 catalog 时勾选全部候选。**更新列表**会追加新勾选的候选,并从草稿中移除取消勾选的已配置模型;回复中未列出的已配置模型保持不变。候选项使用等宽字体显示原始模型 id,以区分同名模型。每行保持单行,悬停可查看模型名称,未提供名称时回退到完整 id。勾选与配置仍使用原始模型 id。每个选中候选会在提供商公布相应信息时,把 id、显示名、上下文窗口、最大输出 token 数和已公布的输入类型复制进可编辑行;已经存在的行保留用户调整过的值。搜索会匹配模型 id 与可选显示名称,且不会清除隐藏项的勾选状态。**全选**与**取消全选**只改变可见结果,以免在看不见时移除已配置的模型。只有用户层单独携带某行时,该行才可删除(删除会恢复组合基线),其确认对话框会指名该提供商。 +**添加模型提供商**是新增卡片的唯一入口:只要任一带编辑器的设置 namespace 已挂载就会出现,下述两种方式任一可继续时才可点击。卡片打开后顶部是分段式的方式切换,下方一行小字说明所选方式的用途:**第三方模型提供商**承载休眠目录提供商选择框与提供商编辑器——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog;**自定义模型 API**承载为 pi-ai 不提供的路由(中转站、自部署服务或其他兼容 OpenAI / Anthropic 协议的接口)准备的创建表单。某种方式只在其 namespace 已挂载时提供;只剩一种方式时卡片以该方式为标题直接显示表单;namespace 已无可采用的提供商或没有可声明的协议时,对应方式会被禁用并把原因作为悬停提示。面板在其方式首次显示时挂载,之后在卡片打开且该方式仍被提供期间保持挂载但隐藏,因此切换方式不会丢掉任何一边的草稿;任一面板有写入或端点探测进行中时滑块锁定,因为此时切换会让结果落到看不见的面板上。每个 tab 通过 id 控制其面板,关闭任一面板都会忘记目录草稿的目标。自定义表单会索要唯一的 **Provider ID**、端点、协议与至少一个可唯一识别的模型,因为没有东西能为它们兜底。协议选择框以产品名称显示各协议——OpenAI Chat Completions、OpenAI Responses、Anthropic Messages——存储的值仍是 schema 标识符;适配器新增而本页尚未命名的协议直接显示其标识符。端点占位示例随所选协议变化:OpenAI Chat Completions 与 Responses 显示 `https://gateway.example/v1`,Anthropic Messages 显示 `https://gateway.example`,因为其 SDK 会追加 `/v1/messages`。切换协议会保留已输入的地址。端点必须是可解析的 HTTP 或 HTTPS URL;localhost、IPv4 与 IPv6 字面地址以及自定义端口仍然有效。语法错误会在字段处阻止询问与创建,请求失败则继续作为独立的提供商错误显示。**获取可用模型**通过 `llm/discoverModels` Remote 查询表单显示的端点,因此新增提供商一次即可完成,而非先保存再返回;回复打开的是可搜索选择器而非直接写入,只有点击**更新列表**才会改变草稿。选择器打开时勾选已配置的模型;列表为空、路由提供完整 catalog 时勾选全部候选。**更新列表**会追加新勾选的候选,并从草稿中移除取消勾选的已配置模型;回复中未列出的已配置模型保持不变。没有 id 的行不算已配置,重复的候选 id 只列出一次;若会把非空列表清空,**更新列表**会被禁用,因为空列表表示提供完整 catalog。候选项使用等宽字体显示原始模型 id,以区分同名模型。每行保持单行,悬停可查看模型名称,未提供名称时回退到完整 id。勾选与配置仍使用原始模型 id。每个选中候选会在提供商公布相应信息时,把 id、显示名、上下文窗口、最大输出 token 数和已公布的输入类型复制进可编辑行;已经存在的行保留用户调整过的值。搜索会匹配模型 id 与可选显示名称,且不会清除隐藏项的勾选状态。**全选**与**取消全选**只改变可见结果,以免在看不见时移除已配置的模型。只有用户层单独携带某行时,该行才可删除(删除会恢复组合基线),其确认对话框会指名该提供商。 ### 首次运行弹窗 diff --git a/packages/client/ui-settings-models/src/client/ModelListEditor.tsx b/packages/client/ui-settings-models/src/client/ModelListEditor.tsx index b85f71c053..3c2999db88 100644 --- a/packages/client/ui-settings-models/src/client/ModelListEditor.tsx +++ b/packages/client/ui-settings-models/src/client/ModelListEditor.tsx @@ -10,7 +10,8 @@ * candidates the user picks from, never configuration written behind them. The * picker opens with the configured models checked, so confirming it adds the * newly checked candidates and removes the unchecked configured ones in the - * draft; configured rows the reply does not list are kept. + * draft; configured rows the reply does not list are kept, and a confirmation + * that would empty the list is refused. * * A provider that cannot be interrogated (an unreachable endpoint, a protocol * with no readable listing) is not a dead end: the failure is shown next to the @@ -177,7 +178,10 @@ export function ModelListEditor(props: ModelListEditorProps): ReactNode { const catalog = inheritedCatalog?.provider === catalogProvider ? inheritedCatalog?.models : undefined const inputDefaults = useMemo(() => new Map(catalog?.map(model => [model.id, model.inputModalities])), [catalog]) const [candidates, setCandidates] = useState(undefined) - const [picked, setPicked] = useState>(new Set()) + // Candidates whose checkbox the user flipped from its default. The default + // is read from the list at render, so a row drafted while the fetch was in + // flight opens checked rather than being removed by a stale selection. + const [flipped, setFlipped] = useState>(new Set()) const [candidateQuery, setCandidateQuery] = useState('') const [expanded, setExpanded] = useState>(new Set()) // Capacities are edited as text, so a field's keystrokes are held here rather @@ -205,6 +209,7 @@ export function ModelListEditor(props: ModelListEditorProps): ReactNode { * after them down, so per-row state follows its row. */ const forgetRows = (removed: ReadonlySet): void => { + if (removed.size === 0) return /** The surviving row's new position, or `undefined` for a removed row. */ const moved = (at: number): number | undefined => removed.has(at) ? undefined : at - [...removed].filter(gone => gone < at).length @@ -272,13 +277,10 @@ export function ModelListEditor(props: ModelListEditorProps): ReactNode { setFailure(t('fetchEmpty')) return } - // The picker opens on the current list so a few models can be added or - // removed in one place. An empty list serves the whole catalog, so every - // candidate starts checked. - const known = new Set(models.map(model => textOf(model, 'id'))) setCandidateQuery('') - setCandidates(found) - setPicked(new Set(found.filter(model => models.length === 0 || known.has(model.id)).map(model => model.id))) + setFlipped(new Set()) + // A listing may repeat an id; the picker and the list hold each model once. + setCandidates([...new Map(found.map(model => [model.id, model])).values()]) } finally { setBusy(false) } @@ -286,55 +288,66 @@ export function ModelListEditor(props: ModelListEditorProps): ReactNode { const closePicker = (): void => { setCandidates(undefined) - setPicked(new Set()) + setFlipped(new Set()) setCandidateQuery('') } + // The picker opens on the current list so a few models can be added or + // removed in one place. A list with no identified model serves the whole + // catalog or nothing yet, so every candidate starts checked. + const configuredIds = new Set(models.map(model => textOf(model, 'id')).filter(id => id.length > 0)) + const pickedByDefault = (id: string): boolean => configuredIds.size === 0 || configuredIds.has(id) + const isPicked = (id: string): boolean => pickedByDefault(id) !== flipped.has(id) + + const activeCandidates = candidates ?? [] + const listed = new Set(activeCandidates.map(candidate => candidate.id)) + // An unchecked candidate leaves the list. A row the listing does not name, + // including a half-typed row without an id, is outside the picker and stays. + const removedRows = new Set() + models.forEach((model, at) => { + const id = textOf(model, 'id') + if (listed.has(id) && !isPicked(id)) removedRows.add(at) + }) + // A kept row the user already tuned wins over the provider's own numbers. + const keptRows = models.filter((_model, at) => !removedRows.has(at)) + const keptIds = new Set(keptRows.map(model => textOf(model, 'id'))) + const addedRows = activeCandidates + .filter(candidate => isPicked(candidate.id) && !keptIds.has(candidate.id)) + .map(adopt) + // An empty list serves the whole catalog, so emptying a non-empty list + // would restore every model instead of removing the unchecked ones. + const emptiesList = models.length > 0 && keptRows.length === 0 && addedRows.length === 0 + const adoptPicked = (): void => { - /* v8 ignore next -- the dialog only renders with candidates loaded */ - if (candidates === undefined) return - const listed = new Set(candidates.map(candidate => candidate.id)) - // An unchecked candidate leaves the list. A row the listing does not name, - // including a half-typed row without an id, is outside the picker and stays. - const removed = new Set() - models.forEach((model, at) => { - const id = textOf(model, 'id') - if (listed.has(id) && !picked.has(id)) removed.add(at) - }) - const kept = models.filter((_model, at) => !removed.has(at)) - const keptIds = new Set(kept.map(model => textOf(model, 'id'))) - // A kept row the user already tuned wins over the provider's own numbers. - const added = candidates.filter(candidate => picked.has(candidate.id) && !keptIds.has(candidate.id)) - onChange([...kept, ...added.map(adopt)]) - forgetRows(removed) + onChange([...keptRows, ...addedRows]) + forgetRows(removedRows) closePicker() } const toggle = (id: string): void => { - setPicked((current) => { + setFlipped((current) => { const next = new Set(current) if (!next.delete(id)) next.add(id) return next }) } - const activeCandidates = candidates ?? [] const normalizedCandidateQuery = candidateQuery.trim().toLowerCase() const visibleCandidates = normalizedCandidateQuery.length === 0 ? activeCandidates : activeCandidates.filter(candidate => candidate.id.toLowerCase().includes(normalizedCandidateQuery) || candidate.name?.toLowerCase().includes(normalizedCandidateQuery) === true) const allVisibleCandidatesPicked = visibleCandidates.length > 0 - && visibleCandidates.every(candidate => picked.has(candidate.id)) + && visibleCandidates.every(candidate => isPicked(candidate.id)) const toggleVisibleCandidates = (): void => { // Only visible candidates change: a hidden checked one may be a configured // model, and clearing it would remove that model unseen. - setPicked((current) => { + const target = !allVisibleCandidatesPicked + setFlipped((current) => { const next = new Set(current) - const clear = visibleCandidates.every(candidate => current.has(candidate.id)) for (const candidate of visibleCandidates) { - if (clear) next.delete(candidate.id) + if (pickedByDefault(candidate.id) === target) next.delete(candidate.id) else next.add(candidate.id) } return next @@ -435,7 +448,14 @@ export function ModelListEditor(props: ModelListEditorProps): ReactNode { footer={( <> - + )} > @@ -466,7 +486,7 @@ export function ModelListEditor(props: ModelListEditorProps): ReactNode {