feat(web): align the model catalog with the pi-ai provider form

Both editors live in `ui-models` and list the same thing, so they now share
one row shape rather than diverging when #1368 lands: a bordered entry per
model, id and display name on the row, and the capacities behind the row's
own disclosure. The context window is joined there by the per-model output
cap the adapter just gained; both read a decimal K/M suffix.

The shared class names carry this file's token spellings, not that branch's.
`--dsw-alias-border-subtle`, `--dsw-alias-text-tertiary`, and
`--dsw-alias-text-primary` are undeclared, so they resolve to the light-mode
literals in their fallback slots — the defect this section was moved off. A
styles test now rejects any `--dsw-*` name the token sheet does not declare,
so the next editor to name one fails instead of shipping a light-only
surface.

The keystroke buffer is now per capacity field rather than per row, since a
row holds two of them.
This commit is contained in:
Yichen Jiang
2026-08-04 14:53:17 +08:00
parent 1d2ea70e9b
commit 17b480de51
14 changed files with 400 additions and 204 deletions
@@ -7,33 +7,46 @@
import { useState } from 'react'
import type { ReactNode } from 'react'
import { IconPlusOutline16, IconTrashOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import {
IconChevronDownOutline14, IconChevronRightOutline14, IconPlusOutline16, IconTrashOutline16,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { en } from './locales.ts'
import styles from './ModelsSection.module.css'
/** One catalog entry kept structurally open so hidden or future fields survive an edit. */
export type DeepSeekModelDraft = Record<string, unknown>
/** Accepted context-window spellings: a decimal count with an optional K/M suffix. */
const CONTEXT_WINDOW_PATTERN = /^(\d+(?:\.\d+)?)([km])?$/i
/** The catalog fields this editor writes. */
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
/** Decimal suffix scales — `1M` is 1000K, matching how model capacities are quoted. */
const CONTEXT_WINDOW_SCALE = { k: 1_000, m: 1_000_000 } as const
const CAPACITY_SCALE = { k: 1_000, m: 1_000_000 } as const
/**
* Read a typed context window, so a user can write `256K` or `1M` instead of
* counting zeroes. The stored value stays a plain token count.
* Read a typed capacity, so a user can write `256K` or `1M` instead of counting
* zeroes. The stored value stays a plain token count.
* @param text - raw field text.
* @returns the count; `undefined` when blank (inherit), `NaN` when unreadable
* (rejected by {@link validateDeepSeekModels} before any write).
*/
export function parseContextWindow(text: string): number | undefined {
export function parseCapacity(text: string): number | undefined {
const trimmed = text.trim()
if (trimmed.length === 0) return undefined
const match = CONTEXT_WINDOW_PATTERN.exec(trimmed)
const match = CAPACITY_PATTERN.exec(trimmed)
if (match === null) return Number.NaN
const suffix = match[2]?.toLowerCase()
const scale = suffix === 'k' || suffix === 'm' ? CONTEXT_WINDOW_SCALE[suffix] : 1
const scale = suffix === 'k' || suffix === 'm' ? CAPACITY_SCALE[suffix] : 1
const scaled = Number(match[1]) * scale
// A decimal multiple is exact in intent but not in binary floating point
// (2.3 * 1e6 lands a few ULPs high), so an integral intent snaps back.
@@ -43,15 +56,15 @@ export function parseContextWindow(text: string): number | undefined {
/**
* Spell a stored count back in the shortest form that survives a round trip
* through {@link parseContextWindow}; a count that is not a whole number of
* through {@link parseCapacity}; a count that is not a whole number of
* thousands stays written out.
* @param value - stored context window.
* @param value - stored capacity.
* @returns the field text.
*/
export function formatContextWindow(value: number): string {
export function formatCapacity(value: number): string {
if (!Number.isInteger(value) || value <= 0) return String(value)
if (value % CONTEXT_WINDOW_SCALE.m === 0) return `${String(value / CONTEXT_WINDOW_SCALE.m)}M`
if (value % CONTEXT_WINDOW_SCALE.k === 0) return `${String(value / CONTEXT_WINDOW_SCALE.k)}K`
if (value % CAPACITY_SCALE.m === 0) return `${String(value / CAPACITY_SCALE.m)}M`
if (value % CAPACITY_SCALE.k === 0) return `${String(value / CAPACITY_SCALE.k)}K`
return String(value)
}
@@ -61,6 +74,7 @@ export interface DeepSeekModelsValidationFailure {
index: number
/** Message key owned by the Models settings section. */
key: 'modelIdRequired' | 'modelIdDuplicate' | 'modelNameInvalid' | 'modelContextInvalid'
| 'modelMaxTokensInvalid'
}
/** Convert a schema-validated catalog value into records without dropping hidden fields. */
@@ -99,6 +113,11 @@ export function validateDeepSeekModels(value: unknown): DeepSeekModelsValidation
&& (typeof contextWindow !== 'number' || !Number.isInteger(contextWindow) || contextWindow <= 0)) {
return { index, key: 'modelContextInvalid' }
}
const maxTokens = model['maxTokens']
if (maxTokens !== undefined
&& (typeof maxTokens !== 'number' || !Number.isInteger(maxTokens) || maxTokens <= 0)) {
return { index, key: 'modelMaxTokensInvalid' }
}
}
return undefined
}
@@ -109,8 +128,10 @@ export interface DeepSeekModelsEditorProps {
models: readonly DeepSeekModelDraft[]
/** Whether the user layer currently owns the whole array. */
overridden: boolean
/** Fallback capacity used when a row omits its exact value. */
/** Fallback context capacity used when a row omits its exact value. */
defaultContextWindow: number | undefined
/** Fallback output cap used when a row omits its exact value. */
defaultMaxTokens: number | undefined
/** Section copy. */
t: (key: keyof typeof en) => string
/** Disable every mutation. */
@@ -122,25 +143,27 @@ export interface DeepSeekModelsEditorProps {
}
/**
* Render the direct DeepSeek adapter's id/name/context-window catalog.
* Render the direct DeepSeek adapter's model catalog: id and display name on
* each row, capacities behind the row's own disclosure.
* @param props - effective rows plus the array-level override actions.
* @returns the catalog editor.
*/
export function DeepSeekModelsEditor(props: DeepSeekModelsEditorProps): ReactNode {
// Context windows are edited as text, so a row's keystrokes are held here
// 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 ROW: a single active buffer would be displaced by
// editing any other row, and the abandoned row would fall back to rendering
// its stored NaN as the literal `NaN`.
// 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`.
//
// Entries are keyed by 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<number, string>>(() => new Map())
// 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 update = (index: number, key: 'id' | 'name' | 'contextWindow', value: unknown): void => {
const update = (index: number, key: CatalogField, value: unknown): void => {
const next = props.models.map((model, at) => {
const copy = { ...model }
if (at !== index) return copy
@@ -153,10 +176,20 @@ export function DeepSeekModelsEditor(props: DeepSeekModelsEditorProps): ReactNod
const remove = (index: number): void => {
setEditing((current) => {
const next = new Map<number, string>()
for (const [at, text] of current) {
const next = new Map<string, string>()
for (const [key, text] of current) {
const at = rowOf(key)
if (at === index) continue
next.set(at > index ? at - 1 : at, text)
// 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
})
@@ -165,34 +198,73 @@ export function DeepSeekModelsEditor(props: DeepSeekModelsEditorProps): ReactNod
const reset = (): void => {
setEditing(new Map())
setExpanded(new Set())
props.onReset()
}
/** The row's field text: its live keystrokes, else the stored count spelled short. */
const contextText = (model: DeepSeekModelDraft, index: number): string => {
const typed = editing.get(index)
if (typed !== undefined) return typed
const value = model['contextWindow']
return typeof value === 'number' ? formatContextWindow(value) : ''
}
const settleContext = (index: number): void => {
const typed = editing.get(index)
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 = parseContextWindow(typed)
if (parsed !== undefined && Number.isNaN(parsed)) return
setEditing((current) => {
const next = new Map(current)
next.delete(index)
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}`)
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)
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
})
}
/** One capacity field of one row, rendered inside the row's disclosure. */
const capacityField = (
model: DeepSeekModelDraft,
index: number,
field: CapacityField,
fallback: number | undefined,
): ReactNode => (
<label className={styles['modelField']}>
<span className={styles['modelFieldLabel']}>{props.t(field === 'contextWindow' ? 'contextWindow' : 'maxTokens')}</span>
<input
className={styles['input']}
type="text"
inputMode="numeric"
value={capacityText(model, index, field)}
placeholder={fallback === undefined
? props.t(field === 'contextWindow' ? 'contextWindowPlaceholder' : 'maxTokensPlaceholder')
: formatCapacity(fallback)}
aria-label={`${props.t(field === 'contextWindow' ? 'contextWindow' : 'maxTokens')} ${String(index + 1)}`}
disabled={props.disabled}
onChange={(event) => {
const text = event.target.value
setEditing(current => new Map(current).set(`${String(index)}:${field}`, text))
update(index, field, parseCapacity(text))
}}
onBlur={() => { settleCapacity(index, field) }}
/>
</label>
)
return (
<section className={styles['modelCatalog']} aria-label={props.t('models')}>
<div className={styles['modelCatalogHeader']}>
<div className={styles['modelListHead']}>
<div className={styles['modelCatalogHeading']}>
<span className={styles['modelCatalogTitle']}>{props.t('models')}</span>
<span className={styles['modelCatalogMeta']}>
@@ -215,66 +287,65 @@ export function DeepSeekModelsEditor(props: DeepSeekModelsEditorProps): ReactNod
{props.models.length === 0
? <p className={styles['modelEmpty']}>{props.t('modelsEmpty')}</p>
: (
<div className={styles['modelTable']}>
{/* Captions sit above the rows and are hidden from assistive tech:
every field already carries the indexed `aria-label` naming it. */}
<div className={styles['modelColumns']} aria-hidden="true">
<span>{props.t('modelId')}</span>
<span>{props.t('modelName')}</span>
<span>{props.t('contextWindow')}</span>
</div>
<div className={styles['modelList']}>
{props.models.map((model, index) => (
<div className={styles['modelRow']} key={index}>
<input
className={styles['input']}
type="text"
value={typeof model['id'] === 'string' ? model['id'] : ''}
aria-label={`${props.t('modelId')} ${String(index + 1)}`}
disabled={props.disabled}
onChange={(event) => { update(index, 'id', event.target.value) }}
onBlur={(event) => {
// Settle a pasted id rather than trimming per keystroke,
// which would stop the user typing an interior space.
const trimmed = event.target.value.trim()
if (trimmed !== event.target.value) update(index, 'id', trimmed)
}}
/>
<input
className={styles['input']}
type="text"
value={typeof model['name'] === 'string' ? model['name'] : ''}
placeholder={props.t('modelNamePlaceholder')}
aria-label={`${props.t('modelName')} ${String(index + 1)}`}
disabled={props.disabled}
onChange={(event) => {
update(index, 'name', event.target.value === '' ? undefined : event.target.value)
}}
/>
<input
className={styles['input']}
type="text"
value={contextText(model, index)}
placeholder={props.defaultContextWindow === undefined
? props.t('contextWindowPlaceholder')
: formatContextWindow(props.defaultContextWindow)}
aria-label={`${props.t('contextWindow')} ${String(index + 1)}`}
disabled={props.disabled}
onChange={(event) => {
const text = event.target.value
setEditing(current => new Map(current).set(index, text))
update(index, 'contextWindow', parseContextWindow(text))
}}
onBlur={() => { settleContext(index) }}
/>
<button
type="button"
className={styles['rowDelete']}
disabled={props.disabled}
onClick={() => { remove(index) }}
>
<IconTrashOutline16 size={14} />
<span className={styles['hiddenLabel']}>{props.t('removeModel')}</span>
</button>
<div className={styles['modelEntry']} key={index}>
<div className={styles['modelRow']}>
<input
className={styles['input']}
type="text"
value={typeof model['id'] === 'string' ? model['id'] : ''}
placeholder={props.t('modelId')}
aria-label={`${props.t('modelId')} ${String(index + 1)}`}
disabled={props.disabled}
onChange={(event) => { update(index, 'id', event.target.value) }}
onBlur={(event) => {
// Settle a pasted id rather than trimming per keystroke,
// which would stop the user typing an interior space.
const trimmed = event.target.value.trim()
if (trimmed !== event.target.value) update(index, 'id', trimmed)
}}
/>
<input
className={styles['input']}
type="text"
value={typeof model['name'] === 'string' ? model['name'] : ''}
placeholder={props.t('modelName')}
aria-label={`${props.t('modelName')} ${String(index + 1)}`}
disabled={props.disabled}
onChange={(event) => {
update(index, 'name', event.target.value === '' ? undefined : event.target.value)
}}
/>
<button
type="button"
className={styles['iconButton']}
aria-label={`${props.t('modelAdvanced')} ${String(index + 1)}`}
aria-expanded={expanded.has(index)}
title={props.t('modelAdvanced')}
onClick={() => { toggle(index) }}
>
{expanded.has(index) ? <IconChevronDownOutline14 /> : <IconChevronRightOutline14 />}
</button>
<button
type="button"
className={`${styles['iconButton']} ${styles['iconButtonDanger']}`}
aria-label={`${props.t('removeModel')} ${String(index + 1)}`}
title={props.t('removeModel')}
disabled={props.disabled}
onClick={() => { remove(index) }}
>
<IconTrashOutline16 size={14} />
</button>
</div>
{expanded.has(index)
? (
<div className={styles['modelAdvanced']}>
{capacityField(model, index, 'contextWindow', props.defaultContextWindow)}
{capacityField(model, index, 'maxTokens', props.defaultMaxTokens)}
</div>
)
: null}
</div>
))}
</div>
@@ -156,8 +156,7 @@
.dangerButton:disabled,
.addButton:disabled,
.linkButton:disabled,
.addModelButton:disabled,
.rowDelete:disabled {
.addModelButton:disabled {
opacity: 0.4;
cursor: default;
}
@@ -168,7 +167,7 @@
.addButton:focus-visible,
.linkButton:focus-visible,
.addModelButton:focus-visible,
.rowDelete:focus-visible,
.iconButton:focus-visible,
.customizedSummary:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--dsw-alias-border-l3);
@@ -347,13 +346,6 @@
border-top: 1px solid var(--dsw-alias-border-l2);
}
.modelCatalogHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.modelCatalogHeading {
display: flex;
flex-direction: column;
@@ -375,54 +367,89 @@
line-height: 18px;
}
.modelTable {
/* Model list, shared with the pi-ai provider form (PR #1368): one bordered
entry per model, id and display name on the row, capacities behind the
row's own disclosure. The token names are this file's, not that branch's —
`--dsw-alias-border-subtle`, `--dsw-alias-text-tertiary`, and
`--dsw-alias-text-primary` are undefined here and resolve to their
light-mode literals, which is the defect this section was just moved off. */
.modelList {
display: flex;
flex-direction: column;
gap: 6px;
}
/* Captions and rows share one track list so the columns line up. */
.modelColumns,
.modelRow {
display: grid;
grid-template-columns: minmax(0, 1.25fr) minmax(0, 1.25fr) minmax(88px, 0.75fr) 28px;
align-items: center;
gap: 8px;
}
.modelColumns {
color: var(--dsw-alias-label-tertiary);
font-size: 12px;
line-height: 18px;
.modelListHead {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
/* The inset belongs on the caption cell, not the strip: padding on the grid
container would narrow its tracks against the rows' and walk the captions
left column by column. 1px border + 10px padding is the field text inset. */
.modelColumns > span {
padding-left: 11px;
.modelEntry {
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 8px;
padding: 6px;
}
.rowDelete {
box-sizing: border-box;
position: relative;
.modelRow {
display: grid;
place-items: center;
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr) auto auto;
align-items: center;
gap: 6px;
}
/* Square, label-free affordances: the row's own inputs carry the meaning, so
the actions stay glyphs and announce themselves through aria-label. */
.iconButton {
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: none;
border-radius: 8px;
border-radius: 6px;
background: transparent;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
}
.rowDelete:hover:not(:disabled) {
.iconButton:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
.iconButton:disabled {
cursor: default;
opacity: 0.4;
}
/* The delete glyph keeps the danger tint the rest of the section uses. */
.iconButtonDanger:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover-danger);
color: var(--dsw-alias-state-error-primary);
}
.modelAdvanced {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 8px;
padding: 8px 4px 2px;
}
.modelField {
display: flex;
flex-direction: column;
gap: 4px;
}
.modelFieldLabel {
color: var(--dsw-alias-label-tertiary);
font-size: 12px;
line-height: 18px;
}
.modelEmpty {
padding: 12px;
border: 1px dashed var(--dsw-alias-border-l3);
@@ -262,6 +262,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
const modelsOverridden = hasPath(draft, ['models'])
const models = modelDrafts(modelsOverridden ? customModels : inheritedModels())
const defaultContextWindow = getPath(fallback, ['defaultContextWindow'])
const defaultMaxTokens = getPath(fallback, ['maxTokens'])
return (
<>
<div className={styles['field']}>
@@ -323,6 +324,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
defaultContextWindow={typeof defaultContextWindow === 'number'
? defaultContextWindow
: undefined}
defaultMaxTokens={typeof defaultMaxTokens === 'number' ? defaultMaxTokens : undefined}
t={t}
disabled={disabled}
onChange={(next) => { setDraft(current => setPath(current, ['models'], next)) }}
@@ -40,6 +40,9 @@ export const en = {
modelNamePlaceholder: 'Uses the model ID when empty',
contextWindow: 'Context window',
contextWindowPlaceholder: 'Uses the provider default',
maxTokens: 'Max output tokens',
maxTokensPlaceholder: 'Uses the provider default',
modelAdvanced: 'Capacities',
addModel: 'Add model',
removeModel: 'Delete model',
modelsEmpty: 'No models will be shown in the selector. Unlisted IDs can still be sent directly.',
@@ -47,6 +50,7 @@ export const en = {
modelIdDuplicate: 'Model ID must be unique.',
modelNameInvalid: 'Display name cannot be empty.',
modelContextInvalid: 'Context window must be a positive count, like 131072, 256K, or 1M.',
modelMaxTokensInvalid: 'Max output tokens must be a positive count, like 8192, 64K, or 1M.',
advancedHint: 'Other fields live in settings.yaml; edit that section directly.',
onboardingTitle: 'Add an API key to get started',
onboardingDescription: 'Configure the official DeepSeek provider to start building.',
@@ -97,6 +101,9 @@ export const zh: typeof en = {
modelNamePlaceholder: '留空时使用模型 ID',
contextWindow: '上下文窗口',
contextWindowPlaceholder: '使用提供方默认值',
maxTokens: '最大输出 token 数',
maxTokensPlaceholder: '使用提供方默认值',
modelAdvanced: '容量',
addModel: '添加模型',
removeModel: '删除模型',
modelsEmpty: '模型选择器中将不显示任何模型;目录外 ID 仍可直接发送。',
@@ -104,6 +111,7 @@ export const zh: typeof en = {
modelIdDuplicate: '模型 ID 不能重复。',
modelNameInvalid: '显示名称不能为空。',
modelContextInvalid: '上下文窗口必须是正数,例如 131072、256K 或 1M。',
modelMaxTokensInvalid: '最大输出 token 数必须是正数,例如 8192、64K 或 1M。',
advancedHint: '其余字段在 settings.yaml 中,请直接编辑对应段。',
onboardingTitle: '添加一个 API Key 开始使用',
onboardingDescription: '配置 DeepSeek 官方模型,即可开始使用。',