feat(web): accept K and M suffixes in the context window field

The catalog's context window is now a text field that reads a decimal K or M
suffix — 1M is 1000K, matching how model capacities are quoted — and stores
the plain token count, so settings.yaml and the adapter are unchanged.

A stored count reads back in the shortest form that round-trips: 1000000 as
1M, 256000 as 256K, and 131072 written out, because it is not a whole number
of thousands. The field holds the typed text while its row has focus, since
re-deriving it from the parsed count on every keystroke would rewrite 1000
to 1K mid-word; text that does not parse stays on screen so the save-time
rejection names a row the user can still see and correct.
This commit is contained in:
Yichen Jiang
2026-07-31 14:36:16 +08:00
parent 2701862bf1
commit 935578ed98
10 changed files with 177 additions and 24 deletions

View File

@@ -5,6 +5,7 @@
* override; reset removes that override instead of copying defaults into it.
*/
import { useState } from 'react'
import type { ReactNode } from 'react'
import { IconPlusOutline16, IconTrashOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { en } from './locales.ts'
@@ -13,6 +14,47 @@ 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
/** 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
/**
* 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.
* @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 {
const trimmed = text.trim()
if (trimmed.length === 0) return undefined
const match = CONTEXT_WINDOW_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 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.
const rounded = Math.round(scaled)
return Math.abs(scaled - rounded) < 1e-6 ? rounded : scaled
}
/**
* 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
* thousands stays written out.
* @param value - stored context window.
* @returns the field text.
*/
export function formatContextWindow(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`
return String(value)
}
/** A localized validation failure for one user-owned model array. */
export interface DeepSeekModelsValidationFailure {
/** Zero-based model position. */
@@ -81,6 +123,11 @@ export interface DeepSeekModelsEditorProps {
* @returns the catalog editor.
*/
export function DeepSeekModelsEditor(props: DeepSeekModelsEditorProps): ReactNode {
// The context-window field is edited as text, so the keystrokes are held
// here while one row has focus: re-deriving the text from the parsed count
// on every change would rewrite `1000` to `1K` mid-word.
const [editing, setEditing] = useState<{ index: number; text: string } | undefined>(undefined)
const update = (index: number, key: 'id' | 'name' | 'contextWindow', value: unknown): void => {
const next = props.models.map((model, at) => {
const copy = { ...model }
@@ -93,9 +140,27 @@ export function DeepSeekModelsEditor(props: DeepSeekModelsEditorProps): ReactNod
}
const remove = (index: number): void => {
setEditing(undefined)
props.onChange(props.models.filter((_model, at) => at !== index).map(model => ({ ...model })))
}
/** The row's field text: the live keystrokes, else the stored count spelled short. */
const contextText = (model: DeepSeekModelDraft, index: number): string => {
if (editing?.index === index) return editing.text
const value = model['contextWindow']
return typeof value === 'number' ? formatContextWindow(value) : ''
}
const settleContext = (index: number): void => {
setEditing((current) => {
if (current?.index !== index) return current
// Unreadable text stays on screen: the save-time rejection names a row
// the user can still see and correct.
const parsed = parseContextWindow(current.text)
return parsed !== undefined && Number.isNaN(parsed) ? current : undefined
})
}
return (
<section className={styles['modelCatalog']} aria-label={props.t('models')}>
<div className={styles['modelCatalogHeader']}>
@@ -152,22 +217,18 @@ export function DeepSeekModelsEditor(props: DeepSeekModelsEditorProps): ReactNod
/>
<input
className={styles['input']}
type="number"
min={1}
step={1}
value={typeof model['contextWindow'] === 'number' ? model['contextWindow'] : ''}
type="text"
value={contextText(model, index)}
placeholder={props.defaultContextWindow === undefined
? props.t('contextWindowPlaceholder')
: String(props.defaultContextWindow)}
: formatContextWindow(props.defaultContextWindow)}
aria-label={`${props.t('contextWindow')} ${String(index + 1)}`}
disabled={props.disabled}
onChange={(event) => {
update(
index,
'contextWindow',
event.target.value === '' ? undefined : Number(event.target.value),
)
setEditing({ index, text: event.target.value })
update(index, 'contextWindow', parseContextWindow(event.target.value))
}}
onBlur={() => { settleContext(index) }}
/>
<button
type="button"

View File

@@ -46,7 +46,7 @@ export const en = {
modelIdRequired: 'Model ID is required.',
modelIdDuplicate: 'Model ID must be unique.',
modelNameInvalid: 'Display name cannot be empty.',
modelContextInvalid: 'Context window must be a positive integer.',
modelContextInvalid: 'Context window must be a positive count, like 131072, 256K, 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.',
@@ -103,7 +103,7 @@ export const zh: typeof en = {
modelIdRequired: '模型 ID 不能为空。',
modelIdDuplicate: '模型 ID 不能重复。',
modelNameInvalid: '显示名称不能为空。',
modelContextInvalid: '上下文窗口必须是正整数。',
modelContextInvalid: '上下文窗口必须是正数,例如 131072、256K 或 1M。',
advancedHint: '其余字段在 settings.yaml 中,请直接编辑对应段。',
onboardingTitle: '添加一个 API Key 开始使用',
onboardingDescription: '配置 DeepSeek 官方模型,即可开始使用。',