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:
@@ -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"
|
||||
|
||||
@@ -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 官方模型,即可开始使用。',
|
||||
|
||||
Reference in New Issue
Block a user