feat(settings): support reordering provider models

This commit is contained in:
Magolor committed 2026-10-21 18:49:34 +08:00
1 parent 1c6c558432
commit c10533a6ea
18 files changed
+514 -123

No files matched your search

@@ -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
@@ -32,6 +32,7 @@
- text: 模型目录 已自定义模型目录
- button "恢复默认模型"
- button "获取可用模型"
- button "调整模型顺序 1"
- textbox "模型 ID 1":
- /placeholder: 模型 ID
- text: gpt-6-astra
@@ -40,6 +40,7 @@
- text: 模型目录 已自定义模型目录
- button "恢复默认模型"
- button "获取可用模型"
- button "调整模型顺序 1"
- textbox "模型 ID 1":
- /placeholder: 模型 ID
- text: acme-large
@@ -40,6 +40,7 @@
- text: 模型目录 已自定义模型目录
- button "恢复默认模型"
- button "获取可用模型"
- button "调整模型顺序 1"
- textbox "模型 ID 1":
- /placeholder: 模型 ID
- text: acme-large
@@ -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 "添加模型提供商"
@@ -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
@@ -26,6 +26,7 @@
- region "模型目录":
- text: 模型目录 已自定义模型目录
- button "恢复默认模型"
- button "调整模型顺序 1"
- textbox "模型 ID 1":
- /placeholder: 模型 ID
- text: private-preview
+72 -1
View File
@@ -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',
])
})
})
@@ -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
@@ -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.
@@ -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 卡片沿用适配器的档位,不显示推理等级控件。
@@ -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<ReadonlyMap<string, string>>(() => new Map())
const [expanded, setExpanded] = useState<ReadonlySet<number>>(() => 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<string, string>()
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<number>()
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
? <p className={styles['modelEmpty']}>{props.t('modelsEmpty')}</p>
: (
<div className={styles['modelList']}>
{props.models.map((model, index) => (
{rowState.rows.map(({ model, key, expanded, order }, index) => (
<ModelRow
key={index}
key={key}
order={order}
model={model}
position={index + 1}
inputField="inputModalities"
expanded={expanded.has(index)}
expanded={expanded}
disabled={props.disabled}
t={props.t}
contextWindow={capacityInput(model, index, 'contextWindow', props.defaultContextWindow)}
@@ -287,7 +240,7 @@ export function DeepSeekModelsEditor(props: DeepSeekModelsEditorProps): ReactNod
if (trimmed !== value) update(index, 'id', trimmed)
}}
onChange={(next) => { props.onChange(props.models.map((row, at) => at === index ? next : row)) }}
onToggle={() => { toggle(index) }}
onToggle={() => { rowState.toggle(index) }}
onRemove={() => { remove(index) }}
/>
))}
@@ -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<ReadonlySet<string>>(new Set())
const [candidateQuery, setCandidateQuery] = useState('')
const [expanded, setExpanded] = useState<ReadonlySet<number>>(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<ReadonlyMap<string, string>>(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<number>): 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<number>()
for (const at of current) {
const to = moved(at)
if (to !== undefined) next.add(to)
}
return next
})
setEditing((current) => {
const next = new Map<string, string>()
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<string, string | number | undefined>): 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')}
</button>
@@ -393,16 +347,17 @@ export function ModelListEditor(props: ModelListEditorProps): ReactNode {
</div>
{models.length === 0 ? <p className={styles['modelEmpty']}>{t('modelsEmpty')}</p> : null}
<div className={styles['modelList']}>
{models.map((model, index) => (
{rowState.rows.map(({ model, key, expanded, order }, index) => (
<ModelRow
key={index}
key={key}
order={order}
model={model}
position={index + 1}
inputField="input"
inputFallback={inputDefaults.get(textOf(model, 'id')) ?? props.defaultInput}
inputLoading={catalogProvider !== undefined && catalog === undefined}
reasoningLevels={props.reasoningLevels}
expanded={expanded.has(index)}
expanded={expanded}
disabled={disabled}
t={t}
contextWindow={{
@@ -417,10 +372,10 @@ export function ModelListEditor(props: ModelListEditorProps): ReactNode {
}}
onFieldChange={(field, value) => { 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]))
}}
/>
))}
@@ -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 (
<div className={styles['modelEntry']}>
<div
className={styles['modelEntry']}
data-model-row=""
data-dragging={props.order.dragging || undefined}
data-drop-target={props.order.target && !props.order.dragging || undefined}
onDragOver={(event) => {
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()
}}
>
<div className={styles['modelRow']}>
<Tooltip label={t('reorderModelHelp')}>
<button
type="button"
className={`${styles['iconButton']} ${styles['modelDragHandle']}`}
aria-label={`${t('reorderModel')} ${String(position)}`}
aria-keyshortcuts="ArrowUp ArrowDown"
disabled={disabled}
draggable={!disabled}
onDragStart={(event) => {
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('text/plain', String(position))
props.order.onStart()
}}
onDragEnd={props.order.onEnd}
onKeyDown={(event) => {
if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return
event.preventDefault()
event.stopPropagation()
props.order.onMove(event.key === 'ArrowUp' ? -1 : 1)
}}
>
<IconChevronsUpDownOutlineRegular size={14} />
</button>
</Tooltip>
{(['id', 'name'] as const).map(field => (
<input
key={field}
@@ -513,11 +513,23 @@
.modelRow {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr) auto auto;
grid-template-columns: auto minmax(0, 1.4fr) minmax(0, 1fr) auto auto;
align-items: center;
gap: 6px;
}
.modelDragHandle:not(:disabled) {
cursor: grab;
}
.modelEntry[data-dragging] {
opacity: 0.5;
}
.modelEntry[data-drop-target] {
outline: 1px solid var(--dsw-alias-state-business-primary);
}
/* Square, label-free affordances: the row's own inputs carry the meaning, so
the actions stay glyphs and announce themselves through aria-label. */
.iconButton {
@@ -84,6 +84,8 @@ export const en = {
modelReasoningOffValuePlaceholder: 'Turns reasoning off',
addModel: 'Add model',
removeModel: 'Delete model',
reorderModel: 'Reorder model',
reorderModelHelp: 'Drag to reorder, or use the up and down arrow keys.',
modelsEmpty: 'No models will be shown in the selector. Unlisted IDs can still be sent directly.',
keyBlank: 'Enter the API key, or leave the field empty to keep the stored one.',
keyBlankNew: 'Enter the API key, or leave the field empty if this provider authenticates another way.',
@@ -226,6 +228,8 @@ export const zh: { [Key in keyof typeof en]: string } = {
modelReasoningOffValuePlaceholder: '关闭思考',
addModel: '添加模型',
removeModel: '删除模型',
reorderModel: '调整模型顺序',
reorderModelHelp: '拖动排序,或使用上下方向键调整顺序',
modelsEmpty: '模型选择器中将不显示任何模型;目录外 ID 仍可直接发送。',
keyBlank: '请输入 API 密钥;留空则保持已存储的密钥。',
keyBlankNew: '请输入 API 密钥;若该提供商以其他方式鉴权,可以留空。',
@@ -365,6 +369,8 @@ export const ja = {
'modelReasoningOffValuePlaceholder': '推論を無効にします',
'addModel': 'モデルを追加',
'removeModel': 'モデルを削除',
'reorderModel': 'モデルの順序を変更',
'reorderModelHelp': 'ドラッグ、または上下の矢印キーで順序を変更できます。',
'modelsEmpty': 'セレクターにモデルが表示されません。一覧にないIDも直接送信できます。',
'keyBlank': 'APIキーを入力してください。空欄のままにすると保存済みのキーが保持されます。',
'keyBlankNew': 'APIキーを入力してください。このプロバイダーが別の方法で認証する場合は空欄のままにしてください。',
@@ -0,0 +1,101 @@
/** Local model-row identities, disclosures, capacity text, and draft ordering. */
import { useRef, useState } from 'react'
import type { DeepSeekModelDraft } from './DeepSeekModelsEditor.tsx'
/** Text fields whose unfinished spelling belongs to one draft row. */
type CapacityField = 'contextWindow' | 'maxTokens'
interface RowState {
key: number
expanded: boolean
editing: Map<CapacityField, string>
}
/** 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<number>) => 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<number>()
const [target, setTarget] = useState<number>()
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<number>): void => {
setRows(rows.filter((_row, index) => !removed.has(index)))
endDrag()
},
reset: (): void => { setRows(models.map(() => createRow(nextKey.current++))); endDrag() },
}
}
@@ -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'
? <DeepSeekModelsEditor {...shared} defaultContextWindow={undefined} defaultMaxTokens={undefined} />
: <ModelListEditor
{...shared} probe={{ settingsNs: 'llm-pi-ai' }} onBusyChange={() => {}}
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(<Editor family={family} initial={[first, second, third]} changed={changed} />)
const identity = screen.getByLabelText<HTMLInputElement>(`${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<HTMLInputElement>(`${en.contextWindow} 3`).value).toBe('unfinished')
expect(screen.getByLabelText<HTMLInputElement>(`${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(<Editor family={family} initial={[{ id: 'first' }, { id: 'second' }]} changed={changed} />)
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(<Editor family={family} initial={initial} changed={changed} />)
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(<Editor family={family} initial={initial} changed={changed} disabled />)
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(<Editor family={family} initial={[{ id: '' }, { id: '' }]} changed={vi.fn()} />)
const first = screen.getByLabelText<HTMLInputElement>(`${en.modelId} 1`)
const second = screen.getByLabelText<HTMLInputElement>(`${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(<Editor family={family} initial={[{ id: 'first' }, { id: 'second' }]} changed={vi.fn()} />)
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<HTMLInputElement>(`${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<HTMLInputElement>(`${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(<Editor family="pi-ai" initial={[{ id: 'a' }, { id: 'b' }]} changed={firstChanged} />)
const second = render(<Editor family="deepseek" initial={[{ id: 'c' }, { id: 'd' }]} changed={secondChanged} />)
const source = within(first.container).getByRole<HTMLButtonElement>('button', { name: `${en.reorderModel} 1` })
const destination = within(second.container).getByRole<HTMLButtonElement>('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()
})