Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 57 additions & 7 deletions apps/desktop/e2e/context-window-save.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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);
});
48 changes: 48 additions & 0 deletions apps/desktop/src/main/__tests__/context-window-input.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Original file line number Diff line number Diff line change
Expand Up @@ -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: '登录令牌只保存在本机。',
Expand Down Expand Up @@ -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: '登入權杖只儲存在本機。',
Expand Down Expand Up @@ -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.',
Expand Down
23 changes: 13 additions & 10 deletions apps/desktop/src/renderer/settings/provider-add-model-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -50,7 +50,8 @@ export function AddModelDialog(props: {
}) {
const copy = getProviderSettingsCopy(useUiLocale()).detail;
const [id, setId] = useState('');
const [contextWindow, setContextWindow] = useState<number | null>(null);
const [contextWindowInput, setContextWindowInput] = useState('');
const contextWindow = parseContextWindowInput(contextWindowInput);
const [submitAttempted, setSubmitAttempted] = useState(false);
const [isSaving, setSaving] = useState(false);

Expand All @@ -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);
}
Expand Down Expand Up @@ -129,15 +132,15 @@ export function AddModelDialog(props: {
submitAttempted && idError ? { type: 'error', message: idError } : undefined
}
/>
<NumberInput
<TextInput
label={copy.addModelContextWindow}
description={copy.addModelContextWindowHelp}
isRequired
value={contextWindow}
value={contextWindowInput}
hasClear
isIntegerOnly
min={1}
onChange={setContextWindow}
placeholder="128000 / 128K / 1M"
onChange={setContextWindowInput}
isDisabled={isSaving}
status={
submitAttempted && contextWindowError
? { type: 'error', message: contextWindowError }
Expand Down
Loading