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

@@ -9,7 +9,7 @@ import { ModelsSection, needsSetup, removeProviderProfile } from '../src/client/
import type { ModelsSectionInjected, ModelsSectionProps } from '../src/client/ModelsSection.tsx'
import { pathOps } from '../src/client/ProviderEditor.tsx'
import {
DeepSeekModelsEditor, modelDrafts, validateDeepSeekModels,
DeepSeekModelsEditor, formatContextWindow, modelDrafts, parseContextWindow, validateDeepSeekModels,
} from '../src/client/DeepSeekModelsEditor.tsx'
import { deriveKeyRef, ModelsSettingsStore } from '../src/client/store.ts'
import type { ProviderRow } from '../src/client/store.ts'
@@ -332,6 +332,94 @@ describe('ModelsSection', () => {
expect(validateDeepSeekModels([{ id: 'model', contextWindow: 1 }])).toBeUndefined()
})
it('reads context windows written as counts, thousands, or millions', () => {
expect(parseContextWindow('')).toBeUndefined()
expect(parseContextWindow(' ')).toBeUndefined()
expect(parseContextWindow('131072')).toBe(131_072)
expect(parseContextWindow(' 256K ')).toBe(256_000)
expect(parseContextWindow('256k')).toBe(256_000)
expect(parseContextWindow('1M')).toBe(1_000_000)
expect(parseContextWindow('1m')).toBe(1_000_000)
// 1M is 1000K, not 1024K: capacities are quoted in decimal.
expect(parseContextWindow('1M')).toBe(parseContextWindow('1000K'))
// 2.3 * 1e6 is a few ULPs high in binary floating point; an integral
// intent must not become a fractional count the validator rejects.
expect(parseContextWindow('2.3M')).toBe(2_300_000)
expect(Number.isInteger(parseContextWindow('1.5M'))).toBe(true)
// A genuinely fractional count survives as one, for the validator to reject.
expect(parseContextWindow('0.0001K')).toBeCloseTo(0.1)
expect(parseContextWindow('abc')).toBeNaN()
expect(parseContextWindow('1G')).toBeNaN()
expect(parseContextWindow('1M1')).toBeNaN()
})
it('spells a stored count in the shortest form that round-trips', () => {
expect(formatContextWindow(1_000_000)).toBe('1M')
expect(formatContextWindow(256_000)).toBe('256K')
expect(formatContextWindow(1_500_000)).toBe('1500K')
expect(formatContextWindow(131_072)).toBe('131072')
// Values the validator will reject are shown as-is rather than dressed up.
expect(formatContextWindow(Number.NaN)).toBe('NaN')
expect(formatContextWindow(0)).toBe('0')
for (const text of ['1M', '256K', '131072', '1500K']) {
expect(formatContextWindow(parseContextWindow(text) as number)).toBe(text)
}
})
it('accepts a suffixed context window and stores the plain count', async () => {
const { mutate } = await mountSection({
mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
})
fireEvent.click(screen.getByText(en.customized))
const windows = screen.getAllByLabelText<HTMLInputElement>(new RegExp(en.contextWindow))
// The inherited 1000000 reads back short.
expect((windows[0] as HTMLInputElement).value).toBe('1M')
// Keystrokes stay verbatim while the row has focus, so typing `1000` does
// not rewrite itself to `1K` mid-word.
fireEvent.change(windows[0] as HTMLInputElement, { target: { value: '1000' } })
expect((windows[0] as HTMLInputElement).value).toBe('1000')
fireEvent.change(windows[0] as HTMLInputElement, { target: { value: '1000K' } })
expect((windows[0] as HTMLInputElement).value).toBe('1000K')
// Blur settles the row to the canonical spelling of the same count.
fireEvent.blur(windows[0] as HTMLInputElement)
expect((windows[0] as HTMLInputElement).value).toBe('1M')
fireEvent.change(windows[1] as HTMLInputElement, { target: { value: '256K' } })
fireEvent.blur(windows[1] as HTMLInputElement)
fireEvent.click(screen.getByText(en.apply))
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
expect(mutate.mock.calls[0]?.[0]).toEqual({
ns: 'llm-deepseek',
ops: [{
op: 'set',
path: ['models'],
value: [
{ ...DEFAULT_DEEPSEEK_MODELS[0], contextWindow: 1_000_000 },
{ ...DEFAULT_DEEPSEEK_MODELS[1], contextWindow: 256_000 },
],
}],
expectedRevision: 0,
})
})
it('keeps unreadable context-window text on screen and refuses the write', async () => {
const { mutate } = await mountSection()
fireEvent.click(screen.getByText(en.customized))
const windows = screen.getAllByLabelText<HTMLInputElement>(new RegExp(en.contextWindow))
fireEvent.change(windows[0] as HTMLInputElement, { target: { value: '1 gazillion' } })
// Blurring a row that is not the edited one leaves the buffer alone.
fireEvent.blur(windows[1] as HTMLInputElement)
fireEvent.blur(windows[0] as HTMLInputElement)
// The text the user typed is still there to correct.
expect((windows[0] as HTMLInputElement).value).toBe('1 gazillion')
fireEvent.click(screen.getByText(en.apply))
await screen.findByText(`Model 1: ${en.modelContextInvalid}`)
expect(mutate).not.toHaveBeenCalled()
})
it('renders malformed draft fallbacks without inventing catalog values', () => {
render(<DeepSeekModelsEditor
models={[{}]}