From 293a308e2dfd1bd4658f0de148a8b309b3aea70d Mon Sep 17 00:00:00 2001 From: sunrioa <178722768+sunrioa@users.noreply.github.com> Date: Fri, 11 Sep 2026 00:46:37 +0800 Subject: [PATCH] feat(desktop): accept K/M context window inputs Fixes #5168 Generated-by: OpenAI Codex --- apps/desktop/e2e/context-window-save.spec.ts | 64 ++++++++++++++-- .../__tests__/context-window-input.test.ts | 48 ++++++++++++ .../context-window-input.ts | 31 ++++++++ .../features/connection-settings/index.ts | 1 + .../settings-provider-copy.ts | 3 + .../settings/provider-add-model-dialog.tsx | 23 +++--- .../settings/provider-connection-detail.tsx | 74 +++++++++---------- docs/astryx-surface-file-inventory.md | 4 +- 8 files changed, 191 insertions(+), 57 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/context-window-input.test.ts create mode 100644 apps/desktop/src/renderer/features/connection-settings/context-window-input.ts diff --git a/apps/desktop/e2e/context-window-save.spec.ts b/apps/desktop/e2e/context-window-save.spec.ts index a6094fda4b..91ef06ec49 100644 --- a/apps/desktop/e2e/context-window-save.spec.ts +++ b/apps/desktop/e2e/context-window-save.spec.ts @@ -23,25 +23,23 @@ import { getProviderSettingsCopy } from '../src/renderer/features/connection-set const copy = getProviderSettingsCopy('zh-CN').detail; const MODEL_ID = 'custom-reasoner'; -test('one save persists a context window that is still focused', async ({ +test('one save persists integer and abbreviated context windows while focused', async ({ requestHeaderRowWindow: page, -}) => { +}, testInfo) => { + const tokenField = (label: string) => page.getByLabel(label).and(page.locator('input')); await page.locator('[data-connection-slug="no-models"] button').first().click(); await page.getByRole('button', { name: copy.addModel }).click(); await page.getByRole('textbox', { name: copy.addModelIdField }).fill(MODEL_ID); - await page.getByRole('spinbutton', { name: copy.addModelContextWindow }).fill('128000'); + await tokenField(copy.addModelContextWindow).fill('128000'); await page.getByRole('button', { name: copy.addModelConfirm, exact: true }).click(); await expect( page.getByRole('button', { name: copy.declareCapabilitiesAria(MODEL_ID) }), ).toBeVisible(); await page.getByRole('button', { name: copy.declareCapabilitiesAria(MODEL_ID) }).click(); - const contextWindow = page.getByRole('spinbutton', { - name: `${copy.contextWindow} — ${MODEL_ID}`, - }); + const contextWindow = tokenField(`${copy.contextWindow} — ${MODEL_ID}`); await contextWindow.fill('258000'); const save = page.getByRole('button', { name: copy.save, exact: true }); - await expect(save).toBeDisabled(); // Keep the field focused and exercise the physical gesture: Save is below // the scroll viewport, so scroll it into view without letting Playwright's // locator click wait for the blur-driven enabled state. @@ -60,4 +58,56 @@ test('one save persists a context window that is still focused', async ({ }, MODEL_ID), ) .toBe(258_000); + + const suffixModel = 'custom-context-units'; + await page.getByRole('button', { name: copy.addModel }).click(); + await page.getByRole('textbox', { name: copy.addModelIdField }).fill(suffixModel); + for (const [input, message] of [ + ['1MB', copy.contextWindowInputInvalid], ['', copy.addModelContextWindowRequired], + ] as const) { + await tokenField(copy.addModelContextWindow).fill(input); + await page.getByRole('button', { name: copy.addModelConfirm, exact: true }).click(); + await expect(page.getByRole('dialog').getByText(message, { exact: true })).toBeVisible(); + await expect(page.getByRole('textbox', { name: copy.addModelIdField })).toHaveValue(suffixModel); + } + await tokenField(copy.addModelContextWindow).fill('1M'); + await page.getByRole('dialog').screenshot({ path: testInfo.outputPath('context-window-units-add.png') }); + await page.getByRole('button', { name: copy.addModelConfirm, exact: true }).click(); + await expect(page.getByRole('button', { name: copy.declareCapabilitiesAria(suffixModel) })).toBeVisible(); + const readWindow = () => page.evaluate(async (modelId) => { + const snapshot = await window.maka.connections.getSnapshot(); + return snapshot.connections.find((connection) => connection.slug === 'no-models') + ?.relayModelProfiles?.[modelId]?.contextWindow; + }, suffixModel); + await expect.poll(readWindow).toBe(1_000_000); + + const edit = page.getByRole('button', { name: copy.declareCapabilitiesAria(suffixModel) }); + await edit.click(); + const suffixWindow = tokenField(`${copy.contextWindow} — ${suffixModel}`); + await suffixWindow.fill(' 1.5m '); + await expect(suffixWindow).toBeFocused(); + await page.screenshot({ path: testInfo.outputPath('context-window-units-edit.png') }); + await save.click(); + await expect.poll(readWindow).toBe(1_500_000); + + await edit.click(); + await expect(suffixWindow).toHaveValue('1500000'); + await suffixWindow.fill('256K'); + await suffixWindow.fill('1MB'); + await suffixWindow.press('Tab'); + await expect(suffixWindow).toHaveValue('1MB'); + await expect(save).toBeDisabled(); + await expect(suffixWindow).toHaveAttribute('aria-invalid', 'true'); + await expect.poll(readWindow).toBe(1_500_000); + await page.getByRole('button', { name: copy.cancel, exact: true }).click(); + await edit.click(); + await expect(suffixWindow).toHaveValue('1500000'); + await suffixWindow.fill(''); + await save.click(); + await expect.poll(readWindow).toBeUndefined(); + await expect.poll(async () => page.evaluate(async (modelId) => { + const snapshot = await window.maka.connections.getSnapshot(); + return snapshot.connections.find((connection) => connection.slug === 'no-models') + ?.relayModelProfiles?.[modelId]?.contextWindow; + }, MODEL_ID)).toBe(258_000); }); diff --git a/apps/desktop/src/main/__tests__/context-window-input.test.ts b/apps/desktop/src/main/__tests__/context-window-input.test.ts new file mode 100644 index 0000000000..895fbf7eef --- /dev/null +++ b/apps/desktop/src/main/__tests__/context-window-input.test.ts @@ -0,0 +1,48 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { parseContextWindowInput } from '../../renderer/features/connection-settings/index.js'; + +test('context windows preserve integers and parse decimal K/M without rounding', () => { + const cases: Array<[string, number]> = [ + ['1', 1], ['128000', 128_000], ['000128000', 128_000], + ['128k', 128_000], ['128K', 128_000], ['1000k', 1_000_000], + ['1M', 1_000_000], ['1m', 1_000_000], ['1.5M', 1_500_000], + [' \t1.5m\n', 1_500_000], ['0.001k', 1], ['1.001K', 1001], + ['1.000001M', 1_000_001], ['128000.0', 128_000], ['1.0000k', 1000], + ['9007199254740991', Number.MAX_SAFE_INTEGER], + ['9007199254.740991M', Number.MAX_SAFE_INTEGER], + ]; + for (const [input, expected] of cases) { + assert.equal(parseContextWindowInput(input), expected, input); + } +}); + +test('context windows reject malformed, fractional, nonpositive and unsafe values', () => { + for (const input of [ + '', ' ', '0', '0M', '-1', '-1M', 'NaN', 'Infinity', + '1MB', '1MiB', '1e6', '1kk', '1 M', '1.5', '0.0001K', '1.0000001M', + '1.', '.5M', '1.2.3M', '9007199254740992', '9007199254.740992M', + '9007199254740991.1', '999999999999999999999M', + ]) { + assert.equal(parseContextWindowInput(input), null, input); + } +}); diff --git a/apps/desktop/src/renderer/features/connection-settings/context-window-input.ts b/apps/desktop/src/renderer/features/connection-settings/context-window-input.ts new file mode 100644 index 0000000000..99a825bd51 --- /dev/null +++ b/apps/desktop/src/renderer/features/connection-settings/context-window-input.ts @@ -0,0 +1,31 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** Decimal token counts, not binary sizes; the persisted value stays an integer. */ +export function parseContextWindowInput(input: string): number | null { + const match = /^(\d+)(?:\.(\d+))?([km]?)$/i.exec(input.trim()); + if (!match) return null; + const places = match[3]?.toLowerCase() === 'm' ? 6 : match[3]?.toLowerCase() === 'k' ? 3 : 0; + const fraction = match[2] ?? ''; + // Shift decimal digits before converting: 1.001 * 1000 is not exactly 1001 + // in floating point, and rounding would silently accept fractional tokens. + if (/[1-9]/.test(fraction.slice(places))) return null; + const value = Number(match[1] + fraction.padEnd(places, '0').slice(0, places)); + return Number.isSafeInteger(value) && value > 0 ? value : null; +} diff --git a/apps/desktop/src/renderer/features/connection-settings/index.ts b/apps/desktop/src/renderer/features/connection-settings/index.ts index 148fc0458b..4d98c0519f 100644 --- a/apps/desktop/src/renderer/features/connection-settings/index.ts +++ b/apps/desktop/src/renderer/features/connection-settings/index.ts @@ -44,3 +44,4 @@ export type { } from './provider-panel-shared.js'; export { GenericProviderMark } from './generic-provider-mark.js'; +export { parseContextWindowInput } from './context-window-input.js'; diff --git a/apps/desktop/src/renderer/features/connection-settings/settings-provider-copy.ts b/apps/desktop/src/renderer/features/connection-settings/settings-provider-copy.ts index 13b6193d02..e6d081b622 100644 --- a/apps/desktop/src/renderer/features/connection-settings/settings-provider-copy.ts +++ b/apps/desktop/src/renderer/features/connection-settings/settings-provider-copy.ts @@ -136,6 +136,7 @@ const zhCopy = { addModelContextWindow: '上下文窗口', addModelContextWindowHelp: '服务商模型页给出的最大 token 数。缺少它 Maka 只能按 32k 处理,长对话会被提前截断。', addModelContextWindowRequired: '请填写上下文窗口。', + contextWindowInputInvalid: '请输入正整数 token 数或 K/M 缩写,例如 128000、128K、1.5M。', credentials: '连接', dangerZone: '删除连接', deleteRowHelp: '此操作不可撤销。', credentialsHelp: '密钥只保存在本机。', credentialsHelpAccount: '登录令牌只保存在本机。', @@ -317,6 +318,7 @@ const zhTwCopy = { addModelContextWindow: '上下文視窗', addModelContextWindowHelp: '服務商模型頁給出的最大 token 數。缺少它 Maka 只能按 32k 處理,長對話會被提前截斷。', addModelContextWindowRequired: '請填寫上下文視窗。', + contextWindowInputInvalid: '請輸入正整數 token 數或 K/M 縮寫,例如 128000、128K、1.5M。', credentials: '連線', dangerZone: '刪除連線', deleteRowHelp: '此操作不可撤銷。', credentialsHelp: '金鑰只儲存在本機。', credentialsHelpAccount: '登入權杖只儲存在本機。', @@ -499,6 +501,7 @@ const enCopy: ProviderSettingsCopy = { addModelContextWindowHelp: "The maximum token count from the provider's model page. Without it Maka can only assume 32k, and long conversations get truncated early.", addModelContextWindowRequired: 'Enter a context window.', + contextWindowInputInvalid: 'Enter a positive whole token count or K/M value, such as 128000, 128K, or 1.5M.', credentials: 'Connection', dangerZone: 'Delete connection', deleteRowHelp: 'This cannot be undone.', credentialsHelp: 'The key stays on this machine.', credentialsHelpAccount: 'The sign-in token stays on this machine.', diff --git a/apps/desktop/src/renderer/settings/provider-add-model-dialog.tsx b/apps/desktop/src/renderer/settings/provider-add-model-dialog.tsx index 85ae5c35fc..c186bbe651 100644 --- a/apps/desktop/src/renderer/settings/provider-add-model-dialog.tsx +++ b/apps/desktop/src/renderer/settings/provider-add-model-dialog.tsx @@ -21,8 +21,8 @@ import { useState, type FormEvent } from 'react'; import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog'; import { FormLayout } from '@astryxdesign/core/FormLayout'; import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout'; -import { Button, HStack, NumberInput, TextInput, useUiLocale } from '@maka/ui'; -import { getProviderSettingsCopy } from '../features/connection-settings'; +import { Button, HStack, TextInput, useUiLocale } from '@maka/ui'; +import { getProviderSettingsCopy, parseContextWindowInput } from '../features/connection-settings'; /** * Introduce a model by exact id, for a provider whose catalog cannot grow on @@ -50,7 +50,8 @@ export function AddModelDialog(props: { }) { const copy = getProviderSettingsCopy(useUiLocale()).detail; const [id, setId] = useState(''); - const [contextWindow, setContextWindow] = useState(null); + const [contextWindowInput, setContextWindowInput] = useState(''); + const contextWindow = parseContextWindowInput(contextWindowInput); const [submitAttempted, setSubmitAttempted] = useState(false); const [isSaving, setSaving] = useState(false); @@ -64,11 +65,13 @@ export function AddModelDialog(props: { // budget, and guessing higher on the user's behalf would trade a wasted // window for requests the provider rejects outright. Whoever types an exact // model id is reading the provider's own model page, where this is stated. - const contextWindowError = contextWindow ? null : copy.addModelContextWindowRequired; + const contextWindowError = !contextWindowInput.trim() + ? copy.addModelContextWindowRequired + : contextWindow === null ? copy.contextWindowInputInvalid : null; function close() { setId(''); - setContextWindow(null); + setContextWindowInput(''); setSubmitAttempted(false); props.onOpenChange(false); } @@ -129,15 +132,15 @@ export function AddModelDialog(props: { submitAttempted && idError ? { type: 'error', message: idError } : undefined } /> - (null); const editingModelId = editingRow !== null && typeof editingRow === 'object' ? editingRow.model : null; + const contextWindowInput = editingRow !== null && typeof editingRow === 'object' + ? editingRow.contextWindowInput : undefined; + const contextWindowInputInvalid = contextWindowInput !== undefined && + contextWindowInput.trim() !== '' && parseContextWindowInput(contextWindowInput) === null; const [modelFilter, setModelFilter] = useState(''); const [savedHeaderNames, setSavedHeaderNames] = useState([]); const [headerDrafts, setHeaderDrafts] = useState([]); @@ -306,6 +310,13 @@ function ConnectionDetailInner(props: ConnectionDetailProps) { setEditingRow(row); } + function changeContextWindow(modelId: string, input: string) { + setEditingRow({ model: modelId, contextWindowInput: input }); + const value = parseContextWindowInput(input); + // Invalid text stays visible but never replaces a valid declaration. + if (value !== null || input.trim() === '') setDraftContextWindow(modelId, value ?? undefined); + } + async function saveRequestHeaders(): Promise { let updates; try { @@ -783,12 +794,14 @@ function ConnectionDetailInner(props: ConnectionDetailProps) { beforeAction={modelEnableSwitch(id, label)} isEditing={editingModelId === id} isDisabled={allActionsBusy} - canSave={hasRelayProfileChanges} + canSave={hasRelayProfileChanges && !contextWindowInputInvalid} saveLabel={copy.save} cancelLabel={copy.cancel} onEdit={() => openRow({ model: id })} onCancel={() => { resetDraftProfile(id); setEditingRow(null); }} - onSave={async () => { if (await saveRelayProfiles()) setEditingRow(null); }} + onSave={async () => { + if (!contextWindowInputInvalid && await saveRelayProfiles()) setEditingRow(null); + }} > {copy.capabilitiesHelp} model.id === id)?.contextWindow} onThinkingLevels={(levels) => setDraftThinkingLevels(id, levels)} onVision={(vision) => setDraftVision(id, vision)} - onContextWindow={(value) => setDraftContextWindow(id, value ?? undefined)} + onContextWindowInput={(input) => changeContextWindow(id, input)} onServiceTier={(tier) => setDraftServiceTier(id, tier)} /> @@ -925,13 +940,15 @@ function CapabilityEditor(props: { modelId: string; isRelay: boolean; declared: RelayModelProfile | undefined; + contextWindowInput: string; + contextWindowInputInvalid: boolean; disabled: boolean; showsFastMode: boolean; /** The window the provider's model list reports, offered as a one-click fill while nothing is declared. */ reportedContextWindow: number | undefined; onThinkingLevels(levels: ThinkingLevel[] | undefined): void; onVision(vision: boolean | undefined): void; - onContextWindow(value: number | null): void; + onContextWindowInput(value: string): void; onServiceTier(tier: 'fast' | undefined): void; }) { const { copy, modelId, declared } = props; @@ -1013,14 +1030,22 @@ function CapabilityEditor(props: { - {declared?.contextWindow === undefined && props.reportedContextWindow !== undefined && ( @@ -1032,7 +1057,7 @@ function CapabilityEditor(props: { size="sm" label={copy.contextWindowApplyHint} isDisabled={props.disabled} - onClick={() => props.onContextWindow(props.reportedContextWindow ?? null)} + onClick={() => props.onContextWindowInput(String(props.reportedContextWindow ?? ''))} /> )} @@ -1072,33 +1097,6 @@ function CapabilityField(props: { label: string; description: string; children: ); } -// NumberInput already owns the text draft and calls onChange only when the -// whole value commits on blur/Enter. Forward that commit directly to the -// row-level draft: adding another local draft here creates a second commit -// boundary, so the first blur only updates this component and Save can write -// the previous value. -function DeclaredContextWindowField(props: { - declared: number | undefined; - disabled: boolean; - label: string; - onCommit: (value: number | null) => void; -}) { - return ( - - ); -} - // The OAuth notice for a re-loginable connection. The 重新登录 button drives // the SAME shared browser-assisted OAuth flow the catalog cards use, so an // expired connection can be re-authorized right where the problem surfaces. diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index af7c8b7898..84ce9978a8 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -142,10 +142,10 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `apps/desktop/src/renderer/settings/personalization-settings-section.tsx` | settings-module | Selector, TextArea, TextInput | aligned — uses Astryx (Selector, TextArea, TextInput) | aligned | | `apps/desktop/src/renderer/settings/projects-settings-page.tsx` | settings-page | Badge, Banner, Button, EmptyState, HStack, List, ListItem, MoreMenu, TextInput | aligned — uses Astryx (Badge, Banner, Button, EmptyState, HStack, List, ListItem, MoreMenu) | aligned | | `apps/desktop/src/renderer/settings/provider-add-form.tsx` | settings-module | Banner, Button, CheckboxList, CheckboxListItem, Collapsible, EmptyState, FormLayout, HStack, Selector, Step, Stepper, Text, TextInput, VStack | aligned — uses Astryx (Banner, Button, CheckboxList, CheckboxListItem, Collapsible, EmptyState, FormLayout, HStack) | aligned | -| `apps/desktop/src/renderer/settings/provider-add-model-dialog.tsx` | settings-module | Button, Dialog, DialogHeader, FormLayout, HStack, Layout, LayoutContent, LayoutFooter, NumberInput, TextInput | aligned — uses Astryx (Button, Dialog, DialogHeader, FormLayout, HStack, Layout, LayoutContent, LayoutFooter) | aligned | +| `apps/desktop/src/renderer/settings/provider-add-model-dialog.tsx` | settings-module | Button, Dialog, DialogHeader, FormLayout, HStack, Layout, LayoutContent, LayoutFooter, TextInput | aligned — uses Astryx (Button, Dialog, DialogHeader, FormLayout, HStack, Layout, LayoutContent, LayoutFooter) | aligned | | `apps/desktop/src/renderer/settings/provider-brand-marks.tsx` | settings-module | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/settings/provider-catalog-page.tsx` | settings-page | Banner, Button, EmptyState, HStack, Heading, List, ListItem, TextInput, VStack | aligned — uses Astryx (Banner, Button, EmptyState, HStack, Heading, List, ListItem, TextInput) | aligned | -| `apps/desktop/src/renderer/settings/provider-connection-detail.tsx` | settings-module | Badge, Banner, Button, DropdownMenu, DropdownMenuCheckboxItem, HStack, Link, NumberInput, Selector, Switch, Text, TextInput, Token, VStack | aligned — uses Astryx (Badge, Banner, Button, DropdownMenu, DropdownMenuCheckboxItem, HStack, Link, NumberInput) | aligned | +| `apps/desktop/src/renderer/settings/provider-connection-detail.tsx` | settings-module | Badge, Banner, Button, DropdownMenu, DropdownMenuCheckboxItem, HStack, Link, Selector, Switch, Text, TextInput, Token, VStack | aligned — uses Astryx (Badge, Banner, Button, DropdownMenu, DropdownMenuCheckboxItem, HStack, Link, Selector) | aligned | | `apps/desktop/src/renderer/settings/provider-display.tsx` | settings-module | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/settings/provider-oauth-section.tsx` | settings-module | Banner, Button, HStack, Text, VStack | aligned — uses Astryx (Banner, Button, HStack, Text, VStack) | aligned | | `apps/desktop/src/renderer/settings/providers-panel.tsx` | settings-module | Badge, Banner, Button, Divider, EmptyState, HStack, Heading, List, ListItem, Skeleton, StatusDot, Text, VStack | aligned — uses Astryx (Badge, Banner, Button, Divider, EmptyState, HStack, Heading, List) | aligned |