diff --git a/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md b/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md index dc532d598d..120b33549a 100644 --- a/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md +++ b/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md @@ -25,6 +25,7 @@ - text: 请填写兼容 Anthropic Messages 协议的 API 地址。 - region "模型目录": - text: 模型目录 正在使用适配器默认模型 + - button "调整模型顺序 1" - textbox "模型 ID 1": - /placeholder: 模型 ID - text: deepseek-flash @@ -33,6 +34,7 @@ - text: DeepSeek-V4.1-Flash - button "模型选项 1" - button "删除模型 1" + - button "调整模型顺序 2" - textbox "模型 ID 2": - /placeholder: 模型 ID - text: deepseek-v4-pro diff --git a/apps/web/tests/expected/models-settings/catalog-inputs.expected.md b/apps/web/tests/expected/models-settings/catalog-inputs.expected.md index 9015847ef4..fd9cc97859 100644 --- a/apps/web/tests/expected/models-settings/catalog-inputs.expected.md +++ b/apps/web/tests/expected/models-settings/catalog-inputs.expected.md @@ -32,6 +32,7 @@ - text: 模型目录 已自定义模型目录 - button "恢复默认模型" - button "获取可用模型" + - button "调整模型顺序 1" - textbox "模型 ID 1": - /placeholder: 模型 ID - text: gpt-6-astra diff --git a/apps/web/tests/expected/models-settings/declared-edit.expected.md b/apps/web/tests/expected/models-settings/declared-edit.expected.md index f2aa8d44b9..be77f7ceee 100644 --- a/apps/web/tests/expected/models-settings/declared-edit.expected.md +++ b/apps/web/tests/expected/models-settings/declared-edit.expected.md @@ -40,6 +40,7 @@ - text: 模型目录 已自定义模型目录 - button "恢复默认模型" - button "获取可用模型" + - button "调整模型顺序 1" - textbox "模型 ID 1": - /placeholder: 模型 ID - text: acme-large diff --git a/apps/web/tests/expected/models-settings/image-only.expected.md b/apps/web/tests/expected/models-settings/image-only.expected.md index 07d8f4c169..ea3046ce5b 100644 --- a/apps/web/tests/expected/models-settings/image-only.expected.md +++ b/apps/web/tests/expected/models-settings/image-only.expected.md @@ -40,6 +40,7 @@ - text: 模型目录 已自定义模型目录 - button "恢复默认模型" - button "获取可用模型" + - button "调整模型顺序 1" - textbox "模型 ID 1": - /placeholder: 模型 ID - text: acme-large diff --git a/apps/web/tests/expected/models-settings/reordered.expected.md b/apps/web/tests/expected/models-settings/reordered.expected.md new file mode 100644 index 0000000000..130de2d25a --- /dev/null +++ b/apps/web/tests/expected/models-settings/reordered.expected.md @@ -0,0 +1,72 @@ +- dialog "设置": + - text: 设置 + - navigation: + - button "通用设置" + - button "模型" + - button "内置插件" + - button "Agent 预设" + - button "打开配置文件" + - button "关闭" + - heading "模型" [level=2] + - paragraph: 填入各提供商的 API 密钥即可使用其模型。 + - list: + - listitem: + - text: minimax-cn + - img "API 密钥已配置" + - button "编辑 minimax-cn": 编辑 + - button "删除 minimax-cn": 删除 + - listitem: + - text: Acme 网关 自定义 + - button "编辑 Acme 网关 (acme-gateway)": 编辑 + - button "删除 Acme 网关 (acme-gateway)": 删除 + - text: Acme 网关 acme-gateway API 密钥 + - textbox "API 密钥": + - /placeholder: 输入 API 密钥 + - group: + - text: 自定义设置 显示名称 + - textbox "显示名称": + - /placeholder: acme-gateway + - text: Acme 网关 + - text: API 地址 + - textbox "API 地址": + - /placeholder: https://gateway.acme.example/v1 + - text: https://gateway.acme.example/v1 + - text: API 协议 + - combobox "API 协议": + - option "OpenAI Chat Completions" + - option "OpenAI Responses" + - option "Anthropic Messages" [selected] + - region "模型目录": + - text: 模型目录 已自定义模型目录 + - button "恢复默认模型" + - button "获取可用模型" + - button "调整模型顺序 1" + - textbox "模型 ID 1": + - /placeholder: 模型 ID + - text: order-second + - textbox "显示名称 1": + - /placeholder: 显示名称 + - text: order-second + - button "模型选项 1" + - button "删除模型 1" + - button "调整模型顺序 2" + - textbox "模型 ID 2": + - /placeholder: 模型 ID + - text: order-third + - textbox "显示名称 2": + - /placeholder: 显示名称 + - text: order-third + - button "模型选项 2" + - button "删除模型 2" + - button "调整模型顺序 3" + - textbox "模型 ID 3": + - /placeholder: 模型 ID + - text: acme-large + - textbox "显示名称 3": + - /placeholder: 显示名称 + - button "模型选项 3" + - button "删除模型 3" + - button "添加模型" + - button "取消" + - button "保存" + - button "添加模型提供商" diff --git a/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md b/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md index 312a5cf8d9..6ea83d310a 100644 --- a/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md +++ b/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md @@ -25,6 +25,7 @@ - text: 请填写兼容 Anthropic Messages 协议的 API 地址。 - region "模型目录": - text: 模型目录 正在使用适配器默认模型 + - button "调整模型顺序 1" - textbox "模型 ID 1": - /placeholder: 模型 ID - text: deepseek-flash @@ -46,6 +47,7 @@ - text: 文本 - checkbox "图片" [checked] - text: 图片 + - button "调整模型顺序 2" - textbox "模型 ID 2": - /placeholder: 模型 ID - text: deepseek-v4-pro diff --git a/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md b/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md index f64f3276aa..55526bebec 100644 --- a/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md +++ b/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md @@ -26,6 +26,7 @@ - region "模型目录": - text: 模型目录 已自定义模型目录 - button "恢复默认模型" + - button "调整模型顺序 1" - textbox "模型 ID 1": - /placeholder: 模型 ID - text: private-preview diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts index 7363d59e97..b4cec75da7 100644 --- a/apps/web/tests/models-settings.e2e.ts +++ b/apps/web/tests/models-settings.e2e.ts @@ -536,6 +536,77 @@ describe('web e2e: Models settings page configures a dormant provider', () => { expect(tripwire.pageErrors).toEqual([]) }, 60_000) + it('saves model drag order, discards canceled moves, and preserves the other provider', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-models-order')) + const dialog = page.getByRole('dialog', { name: '设置' }) + const edit = dialog.getByRole('button', { name: '编辑 Acme 网关 (acme-gateway)' }) + const settings = () => scaffold.ctx.settings.describe({ redactSecrets: true }).find(row => row.ns === 'llm-pi-ai')?.value + await edit.click() + await dialog.getByText('自定义设置').click() + for (const [position, id] of [[2, 'order-second'], [3, 'order-third']] as const) { + await dialog.getByRole('button', { name: '添加模型', exact: true }).click() + await dialog.getByLabel(`模型 ID ${String(position)}`, { exact: true }).fill(id) + await dialog.getByLabel(`显示名称 ${String(position)}`, { exact: true }).fill(id) + } + await dialog.getByRole('button', { name: '保存', exact: true }).click() + await dialog.getByText('已保存 Acme 网关 (acme-gateway)。', { exact: true }).waitFor() + const before = structuredClone(settings()) + const path = join(scaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml') + const document = await readFile(path, 'utf8') + + for (const save of [false, true]) { + await edit.click() + await dialog.getByText('自定义设置').click() + const source = dialog.getByRole('button', { name: '调整模型顺序 1', exact: true }) + const target = dialog.getByRole('button', { name: '调整模型顺序 3', exact: true }) + await source.hover() + const from = await source.boundingBox() + if (from === null) throw new Error('Model drag handle is not visible') + await page.mouse.down() + await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2 + 10, { steps: 3 }) + // Native dragging needs an initial motion; the second hover follows any scroll at the dialog edge. + await target.hover() + await target.hover() + await page.mouse.up() + await expect.poll(() => dialog.getByLabel('模型 ID 3', { exact: true }).inputValue()).toBe('acme-large') + expect(await readFile(path, 'utf8')).toBe(document) + await dialog.getByRole('button', { name: save ? '保存' : '取消', exact: true }).click() + await dialog.getByLabel('模型 ID 1', { exact: true }).waitFor({ state: 'detached' }) + if (!save) expect(settings()).toEqual(before) + } + + await dialog.getByText('已保存 Acme 网关 (acme-gateway)。', { exact: true }).waitFor() + expect(settings()).toMatchObject({ providers: { + 'acme-gateway': { models: [ + { id: 'order-second', name: 'order-second' }, + { id: 'order-third', name: 'order-third' }, + { id: 'acme-large', input: ['image'], reasoningEfforts: { off: null, low: 'low', high: 'deep' } }, + ] }, + 'minimax-cn': { apiKeyEnv: 'MINIMAX_CN_API_KEY', baseURL: 'https://gateway.minimax.example/v1' }, + } }) + await edit.click() + await dialog.getByText('自定义设置').click() + expect(await dialog.getByLabel('模型 ID 1', { exact: true }).inputValue()).toBe('order-second') + await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'reordered.expected.md'), + await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd), MODE) + const viewport = page.viewportSize() + if (viewport === null) throw new Error('Model ordering checks require an explicit viewport') + try { + await page.setViewportSize({ width: 390, height: 844 }) + for (const name of ['调整模型顺序 1', '模型选项 1', '删除模型 1']) { + const box = await dialog.getByRole('button', { name, exact: true }).boundingBox() + expect(box).not.toBeNull() + if (box === null) throw new Error(`Model control is not visible: ${name}`) + expect(box.x).toBeGreaterThanOrEqual(0) + expect(box.x + box.width).toBeLessThanOrEqual(390) + } + } finally { + await page.setViewportSize(viewport) + } + await dialog.getByRole('button', { name: '取消', exact: true }).click() + expect(tripwire.pageErrors).toEqual([]) + }) + 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, 'profiles', 'scaffold', 'cordis.patch.yml'), 'utf8')).not.toContain('openai:') @@ -574,7 +645,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => { 'configured.expected.md', 'declared-edit.expected.md', 'declared.expected.md', 'delete.expected.md', 'empty.expected.md', 'model-picker.expected.md', 'native-delete.expected.md', 'native-keyless.expected.md', 'catalog-inputs.expected.md', - 'refreshing.expected.md', 'image-only.expected.md', + 'refreshing.expected.md', 'image-only.expected.md', 'reordered.expected.md', ]) }) }) diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml index fac034b195..fdca1da3d1 100644 --- a/packages/client/ui-settings-models/README.i18n.yaml +++ b/packages/client/ui-settings-models/README.i18n.yaml @@ -21,8 +21,8 @@ en: f4817eb2e3537ed9 zh: e589c193fa85473f /deepseek-ai-dsh-client-ui-settings-models/use-this-package/editing-a-provider: - en: 2ab40f99bd12dc17 - zh: 2b5fd79885cb85fb + en: 53ce9a4847e106fe + zh: 197e020b7f137442 /deepseek-ai-dsh-client-ui-settings-models/use-this-package/adding-and-deleting-providers: en: c0b04b2b11af60d3 zh: d195eec281e445cb diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md index 016dd7efd1..de068178b3 100644 --- a/packages/client/ui-settings-models/README.md +++ b/packages/client/ui-settings-models/README.md @@ -57,6 +57,8 @@ The collapsed 自定义设置 fold carries the curated extras: `baseURL` for bot The DeepSeek card edits the `llm-deepseek` endpoint, credentials, and model catalog. It uses Messages, with the default endpoint placeholder `https://api.deepseek.com/anthropic`. +Drag a model’s reorder handle to change its position within the provider, or focus the handle and use the up and down arrow keys. Apply saves the order; Cancel discards it. Each model keeps its settings, expanded options, and unfinished capacity text when moved. + Expand **Customized settings → Model options** to edit each model. Both provider families share the same row layout, labels, and icons: context window and max output tokens occupy two columns, and **Input types** occupies a separate row with **Text** and **Image** checkboxes. A row without an input declaration displays the installed model’s input types, then the provider default, then Text. Known pi-ai providers load their installed catalog without endpoint I/O; opening a row does not write an override. Explicit input selections take precedence, including text-only overrides of vision models. Checkbox edits save the selected types, with at least one type required. DeepSeek writes `inputModalities`; pi-ai writes `input`. Unchecking Image for DeepSeek also removes `imagePixelBudget` and `imageMaxBytes`, because the adapter rejects those limits without image input. Clearing the input field in `cordis.patch.yml` restores adapter inheritance; **Restore default models** resets the entire catalog override. Declare only input types the upstream model can actually process. A model row on a hand-declared pi-ai route also edits its **Reasoning levels**, the `reasoningEfforts` the adapter already reads from `cordis.patch.yml`; the **?** beside the label explains the two columns. With the switch off the field is absent, so the model does not reason and the model picker offers no level; a stored `false` is kept until the switch is used. Turning it on always starts from rows for off, low, medium, and high; it does not restore levels removed earlier, and **Cancel** discards the edit instead. Each row pairs a level select — the keys `llm-pi-ai`'s configuration schema accepts in `reasoningEfforts`, read from the namespace schema like the protocol choices and today `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, and `max`, each usable once — with the **API value** sent for it, which starts as the level name; a field cleared while typing stores the level name and shows it once the field is left. Off only turns reasoning off, so its value field is disabled; a value already written for it in `cordis.patch.yml` is shown and kept. Changing a row's level resets a value still spelled as the old level; a custom value carries over only between two thinking levels, so changing a row to or from Off resets it. A stored key the schema does not list keeps its row and stays selectable there. Rows can be added and deleted and are saved in row order, while the model picker lists the levels in escalation order. The section refuses what the adapter refuses: no level besides Off — including a `reasoningEfforts:` left without a value, which shows the switch on with no rows — or a value that is missing on a level besides Off or empty on any level. A refused value marks its field invalid, and leaving that field fills in the level name. The rows keep no state of their own, so a model row reused after a deletion, a reset, or a fold shows only its own model. Routes served from the installed catalog and the DeepSeek card keep the adapter's levels and show no reasoning control. diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md index 3937cc80d4..3b66d8404c 100644 --- a/packages/client/ui-settings-models/README.zh.md +++ b/packages/client/ui-settings-models/README.zh.md @@ -57,6 +57,8 @@ API 密钥输入框初始为空,并通过 `autocomplete="new-password"` 请求 `llm-deepseek` 的 DeepSeek 卡片编辑端点、凭据和模型目录。它使用 Messages,默认端点占位符为 `https://api.deepseek.com/anthropic`。 +拖动模型的排序手柄可调整它在当前提供商内的位置,也可以聚焦手柄后使用上下方向键。保存会保留顺序,取消会放弃修改。移动时,每个模型的参数、已展开选项和未完成的容量输入都会随模型保留。 + 展开**自定义设置 → 模型选项**编辑模型。两个提供商家族共用相同的模型行布局、标签和图标:上下文窗口与最大输出 token 数分为两列,**输入类型**独占下一行,提供**文本**和**图片**复选框。未声明输入类型的模型行优先显示已安装模型的输入类型,其次是提供商默认值,最后回退为文本。已知 pi-ai 提供商会加载已安装目录,不向端点发送请求;打开模型行不会写入覆盖值。显式输入选择具有优先权,包括为视觉模型设置的仅文本覆盖。修改复选框会保存所选类型,且至少保留一种。DeepSeek 写入 `inputModalities`,pi-ai 写入 `input`。DeepSeek 取消勾选图片时,还会移除 `imagePixelBudget` 和 `imageMaxBytes`,因为适配器在没有图片输入时拒绝这些限制。在 `cordis.patch.yml` 中清除输入字段可恢复适配器继承;**恢复默认模型**会重置整个模型目录覆盖。仅声明上游模型实际能够处理的输入类型。 手工声明的 pi-ai 路由上,模型行还会编辑**推理等级**,即适配器本来就从 `cordis.patch.yml` 读取的 `reasoningEfforts`;标签旁的 **?** 说明两列的含义。开关关闭时该字段不存在,模型不推理,模型选择器不提供任何档位;已存储的 `false` 在开关被操作前保持不变。打开开关总是从 off、low、medium 和 high 四行开始,不会恢复之前删掉的档位;要放弃修改请点**取消**。每行把一个档位选择框——可选值是 `llm-pi-ai` 配置 schema 允许作为 `reasoningEfforts` 键的档位,与协议选项一样从命名空间 schema 读取,目前为 `off`、`minimal`、`low`、`medium`、`high`、`xhigh`、`max`,每个档位只能用一次——与发送给它的 **API 参数值**配成一对,参数值初始为档位名;输入时清空的字段会存为档位名,离开字段后显示出来。Off 只表示关闭思考,因此它的参数值输入框是禁用的;`cordis.patch.yml` 中已为它写好的值会显示并保留。修改某行的档位时,仍是旧档位名的参数值会随之重置;自定义值只在两个思考档位之间保留,切到或切离 Off 都会重置。schema 未列出的已存键会保留自己的行,并可在该行中继续选择。行可以增删,并按行的顺序保存,模型选择器则按升级顺序列出档位。本节拒绝适配器会拒绝的配置:没有 Off 以外的档位(包括未写值的 `reasoningEfforts:`,此时开关显示为打开且没有任何行),或者 Off 以外的档位缺少参数值、任意档位的参数值为空字符串。被拒绝的值会把对应输入框标为无效,离开该输入框时会填入档位名。各行不保留自己的状态,因此删除、恢复默认或折叠之后被复用的模型行只显示它自己的模型。走已安装目录的路由和 DeepSeek 卡片沿用适配器的档位,不显示推理等级控件。 diff --git a/packages/client/ui-settings-models/src/client/DeepSeekModelsEditor.tsx b/packages/client/ui-settings-models/src/client/DeepSeekModelsEditor.tsx index 2cbbdf42fc..abe9f91499 100644 --- a/packages/client/ui-settings-models/src/client/DeepSeekModelsEditor.tsx +++ b/packages/client/ui-settings-models/src/client/DeepSeekModelsEditor.tsx @@ -5,11 +5,11 @@ * override; reset removes that override instead of copying defaults into it. */ -import { useState } from 'react' import type { ReactNode } from 'react' import { IconPlusOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' import type { en } from './locales.ts' import { ModelRow } from './ModelRow.tsx' +import { useModelRows } from './useModelRows.ts' import styles from './ModelsSection.module.css' /** One catalog entry kept structurally open so hidden or future fields survive an edit. */ @@ -21,11 +21,6 @@ type CatalogField = 'id' | 'name' | 'contextWindow' | 'maxTokens' /** The two token counts edited as K/M-suffixed text behind a row's disclosure. */ type CapacityField = 'contextWindow' | 'maxTokens' -/** Row index encoded in an editing-buffer key. */ -function rowOf(key: string): number { - return Number(key.slice(0, key.indexOf(':'))) -} - /** Accepted capacity spellings: a decimal count with an optional K/M suffix. */ const CAPACITY_PATTERN = /^(\d+(?:\.\d+)?)([km])?$/i @@ -148,19 +143,7 @@ export interface DeepSeekModelsEditorProps { * @returns the catalog editor. */ export function DeepSeekModelsEditor(props: DeepSeekModelsEditorProps): ReactNode { - // Capacities are edited as text, so a field's keystrokes are held here - // rather than re-derived from the parsed count on every change, which would - // rewrite `1000` to `1K` mid-word. Unreadable text is kept past blur so the - // save-time rejection names a row the user can still see — which is why - // this is one entry PER FIELD: a single active buffer would be displaced by - // editing any other field, and the abandoned one would fall back to - // rendering its stored NaN as the literal `NaN`. - // - // Keys carry the row index, so the two operations that move indexes maintain - // them: `remove` re-keys around the dropped row, and reset clears them all - // because the rows they annotated are gone. - const [editing, setEditing] = useState>(() => new Map()) - const [expanded, setExpanded] = useState>(() => new Set()) + const rowState = useModelRows(props.models, props.onChange, props.disabled) const update = (index: number, key: CatalogField, value: unknown): void => { const next = props.models.map((model, at) => { @@ -174,62 +157,31 @@ export function DeepSeekModelsEditor(props: DeepSeekModelsEditorProps): ReactNod } const remove = (index: number): void => { - setEditing((current) => { - const next = new Map() - for (const [key, text] of current) { - const at = rowOf(key) - 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, text) - } - return next - }) - setExpanded((current) => { - const next = new Set() - for (const at of current) { - if (at === index) continue - next.add(at > index ? at - 1 : at) - } - return next - }) + rowState.forget(new Set([index])) props.onChange(props.models.filter((_model, at) => at !== index).map(model => ({ ...model }))) } const reset = (): void => { - setEditing(new Map()) - setExpanded(new Set()) + rowState.reset() props.onReset() } - const toggle = (index: number): void => { - setExpanded((current) => { - const next = new Set(current) - if (!next.delete(index)) next.add(index) - return next - }) - } - /** The field's text: its live keystrokes, else the stored count spelled short. */ const capacityText = (model: DeepSeekModelDraft, index: number, field: CapacityField): string => { - const typed = editing.get(`${String(index)}:${field}`) + const typed = rowState.rows[index]?.editing.get(field) if (typed !== undefined) return typed const value = model[field] return typeof value === 'number' ? formatCapacity(value) : '' } const settleCapacity = (index: number, field: CapacityField): void => { - const key = `${String(index)}:${field}` - const typed = editing.get(key) + const typed = rowState.rows[index]?.editing.get(field) if (typed === undefined) return // Unreadable text stays on screen: the save-time rejection names a row the // user can still see and correct. const parsed = parseCapacity(typed) if (parsed !== undefined && Number.isNaN(parsed)) return - setEditing((current) => { - const next = new Map(current) - next.delete(key) - return next - }) + rowState.edit(index, field, undefined) } const capacityInput = (model: DeepSeekModelDraft, index: number, field: CapacityField, fallback: number | undefined) => ({ @@ -238,7 +190,7 @@ export function DeepSeekModelsEditor(props: DeepSeekModelsEditorProps): ReactNod ? props.t(field === 'contextWindow' ? 'contextWindowPlaceholder' : 'maxTokensPlaceholder') : formatCapacity(fallback), onChange: (text: string) => { - setEditing(current => new Map(current).set(`${String(index)}:${field}`, text)) + rowState.edit(index, field, text) update(index, field, parseCapacity(text)) }, onBlur: () => { settleCapacity(index, field) }, @@ -270,13 +222,14 @@ export function DeepSeekModelsEditor(props: DeepSeekModelsEditorProps): ReactNod ?

{props.t('modelsEmpty')}

: (
- {props.models.map((model, index) => ( + {rowState.rows.map(({ model, key, expanded, order }, index) => ( { props.onChange(props.models.map((row, at) => at === index ? next : row)) }} - onToggle={() => { toggle(index) }} + onToggle={() => { rowState.toggle(index) }} onRemove={() => { remove(index) }} /> ))} diff --git a/packages/client/ui-settings-models/src/client/ModelListEditor.tsx b/packages/client/ui-settings-models/src/client/ModelListEditor.tsx index 8f9dd4a68d..e76b3168e2 100644 --- a/packages/client/ui-settings-models/src/client/ModelListEditor.tsx +++ b/packages/client/ui-settings-models/src/client/ModelListEditor.tsx @@ -27,6 +27,7 @@ import type { ModelsOperations } from './operations.ts' import type { DeepSeekModelDraft } from './DeepSeekModelsEditor.tsx' import type { en } from './locales.ts' import { ModelRow } from './ModelRow.tsx' +import { useModelRows } from './useModelRows.ts' import styles from './ModelsSection.module.css' /** @@ -180,62 +181,15 @@ export function ModelListEditor(props: ModelListEditorProps): ReactNode { // 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 - // than re-derived from the parsed count on every change — that would rewrite - // `1000` to `1K` mid-word. Unreadable text is kept past blur so the refusal - // names a row the user can still see, which is why this is one entry PER - // FIELD: a single buffer would be displaced by editing any other field, and - // the abandoned one would render its stored NaN as the literal `NaN`. - const [editing, setEditing] = useState>(new Map()) - - /** Buffer key for one capacity field; the row half moves when rows do. */ - const bufferKey = (index: number, field: CapacityField): string => `${String(index)}:${field}` + const rowState = useModelRows(models, onChange, disabled) const editCapacity = (index: number, field: CapacityField, text: string): void => { - setEditing(current => new Map(current).set(bufferKey(index, field), text)) + rowState.edit(index, field, text) patch(index, { [field]: parseCapacity(text) }) } - /** What a capacity field shows: the buffer while typing, else the stored count. */ const capacityText = (model: ModelDraft, index: number, field: CapacityField): string => - editing.get(bufferKey(index, field)) ?? capacitySpelling(numberOf(model, field)) - - /** - * 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 => { - 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 - 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 => { - setExpanded((current) => { - const next = new Set(current) - if (!next.delete(index)) next.add(index) - return next - }) - } + rowState.rows[index]?.editing.get(field) ?? capacitySpelling(numberOf(model, field)) const patch = (index: number, next: Record): void => { onChange(models.map((model, at) => { @@ -317,7 +271,7 @@ export function ModelListEditor(props: ModelListEditorProps): ReactNode { const adoptPicked = (): void => { onChange([...keptRows, ...addedRows]) - forgetRows(removedRows) + rowState.forget(removedRows) closePicker() } @@ -373,7 +327,7 @@ export function ModelListEditor(props: ModelListEditorProps): ReactNode { type="button" className={styles['linkButton']} disabled={disabled} - onClick={props.onReset} + onClick={() => { rowState.reset(); props.onReset?.() }} > {t('resetModels')} @@ -393,16 +347,17 @@ export function ModelListEditor(props: ModelListEditorProps): ReactNode {
{models.length === 0 ?

{t('modelsEmpty')}

: null}
- {models.map((model, index) => ( + {rowState.rows.map(({ model, key, expanded, order }, index) => ( { patch(index, { [field]: value }) }} onChange={(next) => { onChange(models.map((row, at) => at === index ? next : row)) }} - onToggle={() => { toggleExpanded(index) }} + onToggle={() => { rowState.toggle(index) }} onRemove={() => { onChange(models.filter((_model, at) => at !== index)) - forgetRows(new Set([index])) + rowState.forget(new Set([index])) }} /> ))} diff --git a/packages/client/ui-settings-models/src/client/ModelRow.tsx b/packages/client/ui-settings-models/src/client/ModelRow.tsx index 22a1177a40..af567b65fe 100644 --- a/packages/client/ui-settings-models/src/client/ModelRow.tsx +++ b/packages/client/ui-settings-models/src/client/ModelRow.tsx @@ -2,12 +2,14 @@ import type { ReactNode } from 'react' import { - IconChevronDownOutlineRegular, IconChevronRightOutlineRegular, IconTrashOutlineRegular, + IconChevronDownOutlineRegular, IconChevronRightOutlineRegular, IconChevronsUpDownOutlineRegular, + IconTrashOutlineRegular, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' import type { DeepSeekModelDraft } from './DeepSeekModelsEditor.tsx' import type { ModelsKey } from './locales.ts' import { ModelInputTypes } from './ModelInputTypes.tsx' import { ModelReasoningEfforts } from './ModelReasoningEfforts.tsx' +import type { ModelRowOrder } from './useModelRows.ts' import styles from './ModelsSection.module.css' /** A capacity's editable text and adapter-specific inherited hint. */ @@ -22,6 +24,7 @@ interface CapacityInput { interface ModelRowProps { model: DeepSeekModelDraft position: number + order: ModelRowOrder inputField: 'inputModalities' | 'input' inputFallback?: readonly string[] | undefined inputLoading?: boolean @@ -47,8 +50,48 @@ interface ModelRowProps { export function ModelRow(props: ModelRowProps): ReactNode { const { model, position, t, disabled } = props return ( -
+
{ + if (!props.order.canDrop) return + event.preventDefault() + event.dataTransfer.dropEffect = 'move' + props.order.onOver() + }} + onDrop={(event) => { + if (!props.order.canDrop) return + event.preventDefault() + props.order.onDrop() + }} + >
+ + + {(['id', 'name'] as const).map(field => ( +} + +/** Drag state and actions for one model row within its editor. */ +export interface ModelRowOrder { + dragging: boolean + target: boolean + canDrop: boolean + onStart: () => void + onEnd: () => void + onOver: () => void + onDrop: () => void + onMove: (offset: -1 | 1) => void +} + +interface ModelRows { + rows: (RowState & { model: DeepSeekModelDraft; order: ModelRowOrder })[] + toggle: (index: number) => void + edit: (index: number, field: CapacityField, text: string | undefined) => void + forget: (removed: ReadonlySet) => void + reset: () => void +} + +/** One editor-local identity; editing a model ID never replaces its controls. */ +function createRow(key: number): RowState { + return { key, expanded: false, editing: new Map() } +} + +/** + * Keep row state with its model through draft moves and removals. + * @param models - current draft array, owned by the provider card. + * @param onChange - replaces only that card's draft array. + * @param disabled - prevents reordering while the card cannot be edited. + * @returns row state and local editing and ordering actions. + */ +export function useModelRows( + models: readonly DeepSeekModelDraft[], onChange: (models: DeepSeekModelDraft[]) => void, disabled: boolean, +): ModelRows { + const nextKey = useRef(0) + const [stored, setRows] = useState(() => models.map(() => createRow(nextKey.current++))) + const [dragged, setDragged] = useState() + const [target, setTarget] = useState() + const entries = models.map((model, index) => ({ model, state: stored[index] ?? createRow(nextKey.current++) })) + const rows = entries.map(entry => entry.state) + if (stored.length !== models.length) setRows(rows) + + const endDrag = (): void => { setDragged(undefined); setTarget(undefined) } + const move = (from: number, to: number): void => { + if (disabled || from === to || from < 0 || to < 0 || to >= models.length) return + const order = [...entries] + const moved = order.splice(from, 1) + order.splice(to, 0, ...moved) + setRows(order.map(entry => entry.state)) + onChange(order.map(entry => entry.model)) + } + + return { + rows: entries.map(({ model, state }, index) => ({ + ...state, + model, + order: { + dragging: dragged === state.key, + target: target === state.key, + canDrop: !disabled && dragged !== undefined, + onStart: (): void => { setDragged(state.key) }, + onEnd: endDrag, + onOver: (): void => { setTarget(state.key) }, + onDrop: (): void => { move(rows.findIndex(row => row.key === dragged), index); endDrag() }, + onMove: (offset: -1 | 1): void => { move(index, index + offset) }, + }, + })), + toggle: (index: number): void => { + setRows(rows.map((row, at) => at === index ? { ...row, expanded: !row.expanded } : row)) + }, + edit: (index: number, field: CapacityField, text: string | undefined): void => { + setRows(rows.map((row, at) => { + if (at !== index) return row + const editing = new Map(row.editing) + if (text === undefined) editing.delete(field) + else editing.set(field, text) + return { ...row, editing } + })) + }, + forget: (removed: ReadonlySet): void => { + setRows(rows.filter((_row, index) => !removed.has(index))) + endDrag() + }, + reset: (): void => { setRows(models.map(() => createRow(nextKey.current++))); endDrag() }, + } +} diff --git a/packages/client/ui-settings-models/tests/model-order.client.spec.tsx b/packages/client/ui-settings-models/tests/model-order.client.spec.tsx new file mode 100644 index 0000000000..1dfd141951 --- /dev/null +++ b/packages/client/ui-settings-models/tests/model-order.client.spec.tsx @@ -0,0 +1,166 @@ +// @vitest-environment jsdom +/** Provider-local ordering preserves row instances and unfinished model edits. */ +import { useState } from 'react' +import { cleanup, fireEvent, render, screen, within } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { DeepSeekModelsEditor } from '../src/client/DeepSeekModelsEditor.tsx' +import type { DeepSeekModelDraft } from '../src/client/DeepSeekModelsEditor.tsx' +import { ModelListEditor } from '../src/client/ModelListEditor.tsx' +import { en } from '../src/client/locales.ts' + +afterEach(cleanup) + +function Editor(props: { + family: 'deepseek' | 'pi-ai' + initial: DeepSeekModelDraft[] + changed: (models: DeepSeekModelDraft[]) => void + disabled?: boolean +}) { + const [models, setModels] = useState(props.initial) + const shared = { + models, disabled: props.disabled === true, overridden: true, + t: (key: keyof typeof en) => en[key], + onChange: (next: DeepSeekModelDraft[]): void => { props.changed(next); setModels(next) }, + onReset: (): void => { setModels(props.initial) }, + } + return props.family === 'deepseek' + ? + : {}} + operations={{ discoverModels: vi.fn(), storeCredential: vi.fn(), removeCredential: vi.fn(), writeSettings: vi.fn() }} + /> +} + +function handle(position: number): HTMLButtonElement { + return screen.getByRole('button', { name: `${en.reorderModel} ${String(position)}` }) +} + +function row(button: HTMLButtonElement): HTMLElement { + const entry = button.closest('[data-model-row]') + if (!(entry instanceof HTMLElement)) throw new Error('Model row is missing') + return entry +} + +function transfer() { + return { setData: vi.fn(), setDragImage: vi.fn(), effectAllowed: '', dropEffect: '' } +} + +describe.each(['deepseek', 'pi-ai'] as const)('%s model ordering', (family) => { + it('moves a complete model and its expanded capacity draft to the drop position', () => { + const first = { id: 'first', name: 'First', contextWindow: 4096, input: ['image'], hidden: { preserved: true } } + const second = { id: 'second', maxTokens: 1024 } + const third = { id: 'third' } + const changed = vi.fn() + render() + const identity = screen.getByLabelText(`${en.modelId} 1`) + const source = handle(1) + const destination = row(handle(3)) + fireEvent.click(screen.getByRole('button', { name: `${en.modelAdvanced} 1` })) + fireEvent.change(screen.getByLabelText(`${en.contextWindow} 1`), { target: { value: 'unfinished' } }) + fireEvent.change(screen.getByLabelText(`${en.maxTokens} 1`), { target: { value: '2000' } }) + changed.mockClear() + + const dataTransfer = transfer() + fireEvent.dragStart(source, { dataTransfer }) + fireEvent.dragOver(destination, { dataTransfer }) + expect(changed).not.toHaveBeenCalled() + fireEvent.drop(destination, { dataTransfer }) + fireEvent.dragEnd(source) + + expect(changed).toHaveBeenCalledExactlyOnceWith([ + second, third, { ...first, contextWindow: Number.NaN, maxTokens: 2000 }, + ]) + expect(screen.getByLabelText(`${en.modelId} 3`)).toBe(identity) + expect(screen.getByLabelText(`${en.contextWindow} 3`).value).toBe('unfinished') + expect(screen.getByLabelText(`${en.maxTokens} 3`).value).toBe('2000') + expect(screen.queryByLabelText(`${en.contextWindow} 1`)).toBeNull() + expect(first.contextWindow).toBe(4096) + }) + + it('moves with arrow keys, retains focus, and ignores moves past either end', () => { + const changed = vi.fn() + render() + const source = handle(1) + source.focus() + fireEvent.keyDown(source, { key: 'ArrowUp' }) + fireEvent.keyDown(source, { key: 'Enter' }) + expect(changed).not.toHaveBeenCalled() + fireEvent.keyDown(source, { key: 'ArrowDown' }) + expect(handle(2)).toBe(source) + expect(document.activeElement).toBe(source) + fireEvent.keyDown(source, { key: 'ArrowDown' }) + expect(changed).toHaveBeenCalledExactlyOnceWith([{ id: 'second' }, { id: 'first' }]) + fireEvent.keyDown(source, { key: 'ArrowUp' }) + expect(changed).toHaveBeenLastCalledWith([{ id: 'first' }, { id: 'second' }]) + }) + + it('keeps canceled, same-row, foreign, and disabled drops from changing the draft', () => { + const changed = vi.fn() + const initial = [{ id: 'first' }, { id: 'second' }] + const view = render() + const source = handle(1) + const destination = row(handle(2)) + const dataTransfer = transfer() + fireEvent.drop(destination, { dataTransfer }) + fireEvent.dragStart(source, { dataTransfer }) + fireEvent.drop(row(source), { dataTransfer }) + fireEvent.dragStart(source, { dataTransfer }) + fireEvent.dragOver(destination, { dataTransfer }) + fireEvent.dragEnd(source) + fireEvent.drop(destination, { dataTransfer }) + expect(changed).not.toHaveBeenCalled() + + view.rerender() + expect(source.disabled).toBe(true) + expect(source.draggable).toBe(false) + fireEvent.keyDown(source, { key: 'ArrowDown' }) + fireEvent.drop(destination, { dataTransfer }) + expect(changed).not.toHaveBeenCalled() + }) + + it('keeps blank rows distinct through editing, moves, removal, and addition', () => { + render() + const first = screen.getByLabelText(`${en.modelId} 1`) + const second = screen.getByLabelText(`${en.modelId} 2`) + first.focus() + fireEvent.change(first, { target: { value: 'renamed' } }) + expect(document.activeElement).toBe(first) + fireEvent.keyDown(handle(1), { key: 'ArrowDown' }) + expect(screen.getByLabelText(`${en.modelId} 2`)).toBe(first) + expect(screen.getByLabelText(`${en.modelId} 1`)).toBe(second) + fireEvent.click(screen.getByRole('button', { name: `${en.removeModel} 1` })) + expect(screen.getByLabelText(`${en.modelId} 1`)).toBe(first) + fireEvent.click(screen.getByRole('button', { name: en.addModel })) + expect(screen.getByLabelText(`${en.modelId} 2`)).not.toBe(second) + fireEvent.keyDown(handle(2), { key: 'ArrowUp' }) + expect(screen.getByLabelText(`${en.modelId} 2`)).toBe(first) + }) + + it('restores inherited order and clears row drafts when defaults are restored', () => { + render() + fireEvent.click(screen.getByRole('button', { name: `${en.modelAdvanced} 1` })) + fireEvent.change(screen.getByLabelText(`${en.contextWindow} 1`), { target: { value: 'bad' } }) + fireEvent.keyDown(handle(1), { key: 'ArrowDown' }) + fireEvent.click(screen.getByRole('button', { name: en.resetModels })) + expect(screen.getByLabelText(`${en.modelId} 1`).value).toBe('first') + expect(screen.queryByLabelText(`${en.contextWindow} 1`)).toBeNull() + fireEvent.click(screen.getByRole('button', { name: `${en.modelAdvanced} 1` })) + expect(screen.getByLabelText(`${en.contextWindow} 1`).value).toBe('') + }) +}) + +it('does not accept another provider editor\'s dragged model', () => { + const firstChanged = vi.fn() + const secondChanged = vi.fn() + const first = render() + const second = render() + const source = within(first.container).getByRole('button', { name: `${en.reorderModel} 1` }) + const destination = within(second.container).getByRole('button', { name: `${en.reorderModel} 2` }) + const dataTransfer = transfer() + fireEvent.dragStart(source, { dataTransfer }) + fireEvent.dragOver(row(destination), { dataTransfer }) + fireEvent.drop(row(destination), { dataTransfer }) + fireEvent.dragEnd(source) + expect(firstChanged).not.toHaveBeenCalled() + expect(secondChanged).not.toHaveBeenCalled() +})