diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d6af22878..465ea2dccc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,7 @@ - Moved Read image snapshots into the durable context-offload store with Runtime-owned lifecycle identity, exact branch and revision copying, recovery-safe cleanup, and bounded physical garbage collection after Session retirement. +- Unified manual model registration and parameter overrides in the connection catalog, isolated by connection and saved atomically. Model capacity and automatic compaction now use separate fields. Redesigned the shared add/edit form, kept disabled models configurable, and removed bulk thinking edits and client catalog rebuilding. Legacy declarations migrate on the next save; Clients and Hosts must update together. ## 0.1.11 - 2026-08-18 diff --git a/apps/desktop/e2e-budget.json b/apps/desktop/e2e-budget.json index 70ec0ce0f0..36b1d50033 100644 --- a/apps/desktop/e2e-budget.json +++ b/apps/desktop/e2e-budget.json @@ -9,10 +9,6 @@ "tests": 1, "electron": "the folder reference has to survive a renderer reload and still agree with the Host's session record" }, - "context-window-save.spec.ts": { - "tests": 1, - "electron": "the saved window is read back from the Host's connection snapshot, not from renderer state" - }, "new-task-reload.spec.ts": { "tests": 2, "electron": "renderer reload must preserve an explicit new task and rebuild archived-only Host history as an empty, usable new-task surface" diff --git a/apps/desktop/e2e/context-window-save.spec.ts b/apps/desktop/e2e/context-window-save.spec.ts deleted file mode 100644 index 91ef06ec49..0000000000 --- a/apps/desktop/e2e/context-window-save.spec.ts +++ /dev/null @@ -1,113 +0,0 @@ -/* - * 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 { expect, test } from './fixtures'; -import { getProviderSettingsCopy } from '../src/renderer/features/connection-settings'; - -const copy = getProviderSettingsCopy('zh-CN').detail; -const MODEL_ID = 'custom-reasoner'; - -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 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 = tokenField(`${copy.contextWindow} — ${MODEL_ID}`); - await contextWindow.fill('258000'); - const save = page.getByRole('button', { name: copy.save, exact: true }); - // 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. - await save.scrollIntoViewIfNeeded(); - const saveBox = await save.boundingBox(); - expect(saveBox).not.toBeNull(); - await page.mouse.click(saveBox!.x + saveBox!.width / 2, saveBox!.y + saveBox!.height / 2); - - 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); - - 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/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts index aafeb481bc..ae2964f05d 100644 --- a/apps/desktop/e2e/fixtures.ts +++ b/apps/desktop/e2e/fixtures.ts @@ -532,7 +532,6 @@ type E2eTestFixtures = { railRenderWindow: Page; promptRailWindow: Page; partialHistoryWindow: Page; - requestHeaderRowWindow: Page; newTaskTargetWindow: Page; directoryReferenceWindow: { page: Page; folder: string }; accessibilityNarrativeWindow: Page; @@ -677,19 +676,6 @@ export const test = base.extend({ showWindow: true, }, use); }, - // Settings → 模型, where `no-models` is the seeded openai-compatible relay — - // the connection type whose detail page owns the custom request headers - // editor. Shown, because what this window is for is a rendered box - // measurement and a throttled compositor is not a layout the user has. - requestHeaderRowWindow: async ({}, use) => { - await withE2eWindow({ - seed: false, - readinessSelector: '.settingsSurface', - e2eFixtureScenario: 'settings-models', - locale: 'zh-CN', - showWindow: true, - }, use); - }, // A data-backed conversation with settled tool evidence and the workbar open // beside it. Shown because the accessibility journey follows real native // focus order through the transcript into the composer controls. diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index b8d5105b09..67012f305e 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -154,7 +154,6 @@ "src/renderer/settings/personalization-settings-section.tsx", "src/renderer/settings/projects-settings-page.tsx", "src/renderer/settings/provider-add-form.tsx", - "src/renderer/settings/provider-add-model-dialog.tsx", "src/renderer/settings/provider-add-submission.ts", "src/renderer/settings/provider-brand-marks.tsx", "src/renderer/settings/provider-catalog-page.tsx", @@ -165,8 +164,6 @@ "src/renderer/settings/provider-endpoint-presentation.ts", "src/renderer/settings/provider-oauth-section.tsx", "src/renderer/settings/providers-panel.tsx", - "src/renderer/settings/relay-profile-draft.ts", - "src/renderer/settings/relay-thinking-bulk.ts", "src/renderer/settings/request-customization-editor.tsx", "src/renderer/settings/runtime-host-interaction-boundary.tsx", "src/renderer/settings/runtime-host-management-dialog.tsx", @@ -2951,25 +2948,6 @@ "react": 1 } }, - "src/renderer/settings/provider-add-model-dialog.tsx": { - "bridgePaths": {}, - "environmentCapabilities": {}, - "hookCalls": { - "useState": 4, - "useUiLocale": 1 - }, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": { - "../features/connection-settings": 1, - "@astryxdesign/core/Dialog": 1, - "@astryxdesign/core/FormLayout": 1, - "@astryxdesign/core/Layout": 1, - "@maka/ui": 1, - "react": 1 - } - }, "src/renderer/settings/provider-add-submission.ts": { "bridgePaths": {}, "environmentCapabilities": {}, @@ -3056,12 +3034,10 @@ "unresolvedDependencies": 0, "actionFactories": [], "dependencyPaths": { - "../features/connection-settings": 1, + "../features/connection-settings": 2, "./password-input": 1, - "./provider-add-model-dialog": 1, "./provider-display": 1, "./provider-endpoint-presentation": 1, - "./relay-thinking-bulk": 1, "./request-customization-editor": 1, "./runtime-host-settings-target.js": 1, "./settings-expandable-row": 1, @@ -3179,26 +3155,6 @@ "react": 1 } }, - "src/renderer/settings/relay-profile-draft.ts": { - "bridgePaths": {}, - "environmentCapabilities": {}, - "hookCalls": {}, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": { - "@maka/core/model-thinking": 1 - } - }, - "src/renderer/settings/relay-thinking-bulk.ts": { - "bridgePaths": {}, - "environmentCapabilities": {}, - "hookCalls": {}, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": {} - }, "src/renderer/settings/request-customization-editor.tsx": { "bridgePaths": {}, "environmentCapabilities": {}, @@ -3762,12 +3718,12 @@ "bridgePaths": {}, "environmentCapabilities": {}, "hookCalls": { - "useEffect": 6, + "useEffect": 5, "useKeyedActionGuard": 1, "useMountedRef": 1, - "useRef": 4, + "useRef": 3, "useRuntimeHostSettingsErrorReporter": 1, - "useState": 14, + "useState": 11, "useToast": 1, "useUiLocale": 1 }, @@ -3778,12 +3734,9 @@ "../features/connection-settings": 1, "./connection-name-draft.js": 1, "./provider-connection-status": 1, - "./relay-profile-draft": 1, - "./relay-thinking-bulk": 1, "./runtime-host-settings-target.js": 1, "./use-action-guard": 1, "@maka/core/llm-connections": 2, - "@maka/core/model-catalog": 1, "@maka/core/model-thinking": 1, "@maka/core/provider-registry": 1, "@maka/ui": 1, diff --git a/apps/desktop/src/main/__tests__/connection-settings-locale-render.test.ts b/apps/desktop/src/main/__tests__/connection-settings-locale-render.test.ts index cf1413a89c..def0683f4c 100644 --- a/apps/desktop/src/main/__tests__/connection-settings-locale-render.test.ts +++ b/apps/desktop/src/main/__tests__/connection-settings-locale-render.test.ts @@ -396,7 +396,6 @@ function relayConnection(): ProjectedLlmConnection { isDefault: true, supportsVision: false, thinkingLevels: [], - describedByMetadata: false, }], }; } diff --git a/apps/desktop/src/main/__tests__/model-catalog-choices.test.ts b/apps/desktop/src/main/__tests__/model-catalog-choices.test.ts index 3111fad724..8f2deeba3b 100644 --- a/apps/desktop/src/main/__tests__/model-catalog-choices.test.ts +++ b/apps/desktop/src/main/__tests__/model-catalog-choices.test.ts @@ -25,7 +25,6 @@ import type { } from '@maka/core/llm-connections'; import { resolveConnectionModelCatalog, - resolveDraftConnectionModelCatalog, type ModelCatalogEntry, } from '@maka/core/model-catalog'; import { buildChatModelChoices } from '@maka/core/chat-model-choice'; @@ -122,53 +121,6 @@ describe('model catalog picker helpers', () => { assert.ok(choices.every((choice) => !(choice.connectionName ?? '').includes('@'))); }); - it('renders the Host entry, not a local rebuild, while the editor is unedited', () => { - // A Host that knows this model and a Desktop that does not: the entry says - // the model cannot serve as a chat default and carries a name this build - // has never heard. An unedited editor must show what the Host decided — - // rebuilding locally is exactly the version disagreement the projection - // ends, and here it would also offer a model the Host ruled out. - const stored = { - connectionId: 'connection-relay', - slug: 'relay', - name: 'Relay', - providerType: 'openai-compatible' as const, - defaultModel: 'host-only-model', - enabled: true, - enabledModelIds: ['host-only-model'], - models: [{ id: 'host-only-model' }], - modelSource: 'fetched' as const, - createdAt: 1, - updatedAt: 1, - }; - const hostEntry: ModelCatalogEntry = { - ...resolveConnectionModelCatalog(stored)[0], - displayName: 'Host-only image model', - canUseAsChatDefault: false, - }; - const connection: ProjectedLlmConnection = { ...stored, catalogEntries: [hostEntry] }; - const draft = { - models: stored.models, - modelSource: stored.modelSource, - enabledModelIds: stored.enabledModelIds, - }; - - const unedited = resolveDraftConnectionModelCatalog(connection, draft); - assert.deepEqual(unedited, [hostEntry]); - - // And the exception still applies: a draft the Host has not seen is the - // one thing the client resolves for itself. - const edited = resolveDraftConnectionModelCatalog(connection, { - ...draft, - models: [...stored.models, { id: 'just-fetched' }], - }); - assert.deepEqual( - edited.map((entry) => entry.id).sort(), - ['host-only-model', 'just-fetched'], - ); - assert.notEqual(edited[0]?.displayName, 'Host-only image model'); - }); - it('does not offer Daily Review a Codex model the subscription cannot serve', () => { // A connection saved while `gpt-5-codex` was still picker-visible keeps it // in `enabledModelIds`. The inventory filter alone left it there, and the diff --git a/apps/desktop/src/main/__tests__/relay-profile-draft.test.ts b/apps/desktop/src/main/__tests__/relay-profile-draft.test.ts deleted file mode 100644 index 4499abfbf0..0000000000 --- a/apps/desktop/src/main/__tests__/relay-profile-draft.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -/* - * 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 { - relayProfileDraftReseedPlan, - relayProfileDraftSeed, -} from '../../renderer/settings/relay-profile-draft.js'; - -test('a clean draft reseeds on every reload of its own connection', () => { - assert.deepEqual(relayProfileDraftReseedPlan({ slug: 'relay-a', dirty: false }, 'relay-a'), { - reseed: true, - clearDirty: false, - }); -}); - -test('a dirty draft survives same-connection reloads', () => { - assert.deepEqual(relayProfileDraftReseedPlan({ slug: 'relay-a', dirty: true }, 'relay-a'), { - reseed: false, - clearDirty: false, - }); -}); - -test('a connection switch reseeds regardless of unsaved edits — and owns the result', () => { - // Dirty belongs to the slug that produced it: A's unsaved declarations - // must neither render under B nor be saved into B. - for (const dirty of [true, false]) { - assert.deepEqual(relayProfileDraftReseedPlan({ slug: 'relay-a', dirty }, 'relay-b'), { - reseed: true, - clearDirty: true, - }); - } -}); - -test('the draft seed sanitizes a hand-edited saved table', () => { - // Runtime reads sanitize through relayModelProfile; the editor must show - // the same canonical view — a malformed local file degrades to no - // declaration, not to UI state TypeScript does not model. - assert.deepEqual( - relayProfileDraftSeed({ - reasoner: { thinkingLevels: 'low' as never, contextWindow: '128000' as never }, - ghost: { thinkingLevels: ['off', 'low'] }, - visual: { vision: true }, - }), - { - ghost: { thinkingLevels: ['low'] }, - visual: { vision: true }, - }, - ); - assert.deepEqual(relayProfileDraftSeed(undefined), {}); -}); diff --git a/apps/desktop/src/main/__tests__/relay-thinking-bulk.test.ts b/apps/desktop/src/main/__tests__/relay-thinking-bulk.test.ts deleted file mode 100644 index 7361860c94..0000000000 --- a/apps/desktop/src/main/__tests__/relay-thinking-bulk.test.ts +++ /dev/null @@ -1,178 +0,0 @@ -/* - * 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 { - applyBulkThinkingLevel, - bulkThinkingLevelStates, - relayProfileWithThinkingLevels, -} from '../../renderer/settings/relay-thinking-bulk.js'; -import { DECLARABLE_RELAY_THINKING_LEVELS } from '@maka/core/model-thinking'; -import type { RelayModelProfile } from '@maka/core/model-thinking'; - -const MODELS = ['alpha', 'beta', 'gamma']; - -test('a level nobody declares reads as absent, and one everybody declares ticks the box', () => { - const draft: Record = { - alpha: { thinkingLevels: ['high'] }, - beta: { thinkingLevels: ['high'] }, - gamma: { thinkingLevels: ['high'] }, - }; - const states = bulkThinkingLevelStates(MODELS, draft, ['high', 'low']); - assert.deepEqual(states[0], { level: 'high', declaredCount: 3, total: 3, checked: true }); - assert.deepEqual(states[1], { level: 'low', declaredCount: 0, total: 3, checked: false }); -}); - -test('partial coverage does not tick the box — only the count separates it from none', () => { - // The box is the affordance for "give this to everyone". Ticking at - // partial coverage would make the next click take the level AWAY from the - // rows that have it, which is the opposite of what the user just asked for. - const draft: Record = { - alpha: { thinkingLevels: ['high'] }, - beta: { thinkingLevels: ['high'] }, - }; - const [state] = bulkThinkingLevelStates(MODELS, draft, ['high']); - assert.equal(state?.checked, false); - assert.equal(state?.declaredCount, 2); - assert.equal(state?.total, 3); -}); - -test('a repeated model id is one model, not two', () => { - const draft: Record = { alpha: { thinkingLevels: ['high'] } }; - const [state] = bulkThinkingLevelStates(['alpha', 'alpha'], draft, ['high']); - assert.deepEqual(state, { level: 'high', declaredCount: 1, total: 1, checked: true }); -}); - -test('an empty selection ticks nothing rather than reading as fully covered', () => { - // 0 === 0 is the trap: `declaredCount === total` is true of an empty - // selection, which would present every level as declared everywhere. - for (const state of bulkThinkingLevelStates([], {}, DECLARABLE_RELAY_THINKING_LEVELS)) { - assert.equal(state.checked, false); - assert.equal(state.total, 0); - } -}); - -test('ticking a level adds it to every model, including ones with no entry yet', () => { - const next = applyBulkThinkingLevel(MODELS, { alpha: { vision: true } }, 'high', true); - assert.deepEqual(next, { - alpha: { vision: true, thinkingLevels: ['high'] }, - beta: { thinkingLevels: ['high'] }, - gamma: { thinkingLevels: ['high'] }, - }); -}); - -test('a bulk add leaves the levels a model already declared alone', () => { - const next = applyBulkThinkingLevel( - MODELS, - { alpha: { thinkingLevels: ['low', 'medium'] } }, - 'high', - true, - ); - assert.deepEqual(next.alpha?.thinkingLevels, ['low', 'medium', 'high']); -}); - -test('ticking a level a model already has does not duplicate it', () => { - const next = applyBulkThinkingLevel( - ['alpha'], - { alpha: { thinkingLevels: ['high'] } }, - 'high', - true, - ); - assert.deepEqual(next.alpha?.thinkingLevels, ['high']); -}); - -test('unticking removes only that level, and only from the selection', () => { - const next = applyBulkThinkingLevel( - ['alpha', 'beta'], - { - alpha: { thinkingLevels: ['low', 'high'] }, - beta: { thinkingLevels: ['high'] }, - gamma: { thinkingLevels: ['high'] }, - }, - 'high', - false, - ); - assert.deepEqual(next.alpha?.thinkingLevels, ['low']); - // beta held nothing but `high`: an entry with no keys left is not an - // entry, or the row keeps reading as declared and 保存 stays armed. - assert.equal('beta' in next, false); - // gamma is outside the selection — a bulk edit is scoped to the rows the - // control sits above. - assert.deepEqual(next.gamma?.thinkingLevels, ['high']); -}); - -test('unticking keeps the other declarations on a model whose levels it empties', () => { - const next = applyBulkThinkingLevel( - ['alpha'], - { alpha: { thinkingLevels: ['high'], vision: true, contextWindow: 128_000 } }, - 'high', - false, - ); - assert.deepEqual(next.alpha, { vision: true, contextWindow: 128_000 }); -}); - -test('a bulk edit does not reshuffle the draft under the rows being edited', () => { - const next = applyBulkThinkingLevel( - ['gamma', 'alpha'], - { alpha: { vision: true }, beta: { vision: false }, gamma: { vision: true } }, - 'high', - true, - ); - assert.deepEqual(Object.keys(next), ['alpha', 'beta', 'gamma']); -}); - -test('a model id colliding with a prototype key stores an entry, not a prototype write', () => { - // Ids come off the relay's /models response. `draft['constructor']` on a - // plain object answers with Object's constructor rather than "absent", - // and assigning `__proto__` writes through the prototype. - const ids = ['__proto__', 'constructor', 'toString']; - const next = applyBulkThinkingLevel(ids, {}, 'high', true); - for (const id of ids) { - assert.deepEqual(Object.getOwnPropertyDescriptor(next, id)?.value, { - thinkingLevels: ['high'], - }); - } - assert.equal(({} as Record).thinkingLevels, undefined); - // And the read side sees all three as declaring it, rather than answering - // "absent" for keys that resolve on Object.prototype. - const [state] = bulkThinkingLevelStates(ids, next, ['high']); - assert.deepEqual(state, { level: 'high', declaredCount: 3, total: 3, checked: true }); -}); - -test('an emptied declaration collapses to undefined so the caller drops the key', () => { - assert.equal(relayProfileWithThinkingLevels({ thinkingLevels: ['high'] }, []), undefined); - assert.equal(relayProfileWithThinkingLevels({ thinkingLevels: ['high'] }, undefined), undefined); - assert.deepEqual(relayProfileWithThinkingLevels({ vision: true }, ['high']), { - vision: true, - thinkingLevels: ['high'], - }); -}); - -test('clearing a level a model never declared leaves the draft untouched', () => { - const draft: Record = { alpha: { vision: true } }; - const next = applyBulkThinkingLevel(MODELS, draft, 'high', false); - assert.deepEqual(next, { alpha: { vision: true } }); -}); - -test('the bulk edit does not mutate the draft it was handed', () => { - const draft: Record = { alpha: { thinkingLevels: ['low'] } }; - applyBulkThinkingLevel(MODELS, draft, 'high', true); - assert.deepEqual(draft, { alpha: { thinkingLevels: ['low'] } }); -}); diff --git a/apps/desktop/src/main/__tests__/runtime-host-connections-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-connections-ipc-main.test.ts index 8f62b36c3d..f133f1d8ca 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-connections-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-connections-ipc-main.test.ts @@ -19,6 +19,14 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { resolveStorageRoot, tryAcquireInteractiveRootOwner } from '@maka/storage/root-authority'; +import { openInteractiveRuntimePolicyStoresForWrite } from '@maka/storage/runtime-policy-stores'; +import { resolveConnectionModelCatalog } from '@maka/core/model-catalog'; +import type { UpdateCatalogConnectionInput } from '@maka/core/runtime-policy'; +import { createDesktopConnectionSettingsServices } from '../../renderer/platform/desktop/create-connection-settings-services.js'; import { defaultEnabledModelIdsWhenOmitted } from '@maka/core/llm-connections'; import type { RuntimeHostConnectionCatalogEntry as ConnectionCatalogEntry, @@ -622,3 +630,62 @@ function catalog(): ConnectionCatalogSnapshot { function connectionIdentity() { return { connectionId: 'connection-1', slug: 'openrouter' } as const; } + +test('renderer service saves through IPC into the canonical catalog and reads it back', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-model-save-')); + const capability = await resolveStorageRoot({ path: root, kind: 'interactive' }); + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + if (!owner) throw new Error('root is already owned'); + try { + const stores = await openInteractiveRuntimePolicyStoresForWrite(owner.lease); + const created = await stores.connectionCatalog.create({ + expectedCatalogRevision: 0, + connection: { slug: 'local', name: 'Local', providerType: 'ollama', enabled: true, + enabledModelIds: ['other'], modelOverrides: { other: { vision: true } } }, + }); + assert.equal(created.kind, 'committed'); + const handlers = new Map unknown>(); + registerRuntimeHostConnectionsIpc({ + ipcMain: { handle: (channel, handler) => { handlers.set(channel, handler as (...args: unknown[]) => unknown); } }, + client: { + loadConnectionCatalog: async () => { + const snapshot = await stores.connectionCatalog.getSnapshot(); + return { ...snapshot, connections: snapshot.connections.map(connection => ({ + ...connection, + catalogEntries: resolveConnectionModelCatalog({ ...connection, defaultModel: '', models: [...connection.models], enabledModelIds: [...connection.enabledModelIds] }), + })) }; + }, + updateConnection: (expected: UpdateCatalogConnectionInput['expected'], changes: UpdateCatalogConnectionInput['changes']) => stores.connectionCatalog.update({ expected, changes }), + } as never, + emitConnectionListChanged() {}, + }); + const host = { profileId: 'profile', hostId: 'host' }; + const services = createDesktopConnectionSettingsServices(() => ({ connections: { + update: (identity: unknown, patch: unknown, target: unknown) => { + assert.deepEqual(target, host); + return handlers.get('connections:update')!({}, identity, patch); + }, + getSnapshot: (_options: unknown, target: unknown) => { + assert.deepEqual(target, host); + return handlers.get('connections:getSnapshot')!({}); + }, + } }) as never).forHost(host).connections; + const connection = (await services.getSnapshot()).connections[0]!; + const identity = { connectionId: connection.connectionId, slug: connection.slug }; + const value = { contextWindow: 128000, inputLimit: 64000, compactionThreshold: 48000, vision: true }; + await services.update(identity, { modelOverride: { modelId: 'manual', expected: null, value } }); + const saved = (await stores.connectionCatalog.getSnapshot()).connections[0]!; + assert.deepEqual(saved.modelOverrides, { other: { vision: true }, manual: value }); + const reopened = (await services.getSnapshot()).connections[0]!; + assert.deepEqual(reopened.modelOverrides?.manual, value); + assert.deepEqual(reopened.enabledModelIds, ['other']); + await assert.rejects(services.update(identity, { modelOverride: { modelId: 'manual', expected: {}, value: { vision: false } } }), /Model parameters changed/); + assert.deepEqual((await stores.connectionCatalog.getSnapshot()).connections[0], saved); + await services.update(identity, { modelOverride: { modelId: 'manual', expected: value, value: {} } }); + assert.deepEqual((await services.getSnapshot()).connections[0]?.modelOverrides, { other: { vision: true }, manual: {} }); + } finally { + await owner.close(); + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/apps/desktop/src/main/connections-ipc-validation.ts b/apps/desktop/src/main/connections-ipc-validation.ts index 3c0b7a3e71..7d7824b9e7 100644 --- a/apps/desktop/src/main/connections-ipc-validation.ts +++ b/apps/desktop/src/main/connections-ipc-validation.ts @@ -24,7 +24,7 @@ import { } from '@maka/core/llm-connections'; import { normalizeOptionalRequestBodyOverlay, normalizeRequestHeaders } from '@maka/core/runtime-policy'; import { PROVIDER_REGISTRY, providerDefaultsOf } from '@maka/core/llm-connections'; -import { normalizeRelayModelProfiles } from '@maka/core/model-thinking'; +import { normalizeModelOverrides } from '@maka/core/model-thinking'; const IPC_CONNECTION_SLUG_MAX_LENGTH = 64; const IPC_CONNECTION_SECRET_MAX_LENGTH = 4096; @@ -74,10 +74,10 @@ export function normalizeCreateConnectionInputForIpc(value: unknown): CreateConn ? undefined : normalizeConnectionApiKeyForIpc(input.apiKey, 'apiKey'); const slug = normalizeConnectionSlugForIpc(input.slug, 'connection slug'); - const relayModelProfiles = - input.relayModelProfiles === undefined + const modelOverrides = + input.modelOverrides === undefined ? undefined - : normalizeRelayModelProfiles(input.relayModelProfiles); + : normalizeModelOverrides(input.modelOverrides); const requestHeaders = input.requestHeaders === undefined ? undefined : normalizeRequestHeaders(input.requestHeaders); const requestBodyOverlay = @@ -88,7 +88,7 @@ export function normalizeCreateConnectionInputForIpc(value: unknown): CreateConn ...input, slug, ...(apiKey === undefined ? {} : { apiKey }), - ...(relayModelProfiles === undefined ? {} : { relayModelProfiles }), + ...(modelOverrides === undefined ? {} : { modelOverrides }), ...(requestHeaders === undefined ? {} : { requestHeaders }), ...(requestBodyOverlay === undefined ? {} : { requestBodyOverlay }), } as CreateConnectionInput; diff --git a/apps/desktop/src/main/runtime-host-config-ipc-main.ts b/apps/desktop/src/main/runtime-host-config-ipc-main.ts index a4458df2b7..e73452a12e 100644 --- a/apps/desktop/src/main/runtime-host-config-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-config-ipc-main.ts @@ -351,7 +351,7 @@ export async function saveConnection( // Import-overwrite is snapshot replacement: absent in the snapshot // must CLEAR, not inherit — the update contract's "absent means // untouched" would otherwise resurrect the old profiles. - relayModelProfiles: connection.relayModelProfiles ?? null, + modelOverrides: connection.modelOverrides ?? null, requestBodyOverlay: connection.requestBodyOverlay ?? null, }, ); @@ -359,7 +359,7 @@ export async function saveConnection( throw new Error(`Unable to update imported Connection: ${updated.kind}`); } } else { - const importedProfiles = connection.relayModelProfiles; + const importedProfiles = connection.modelOverrides; const created = await client.createConnection(catalog.revision, { slug: connection.slug, name: connection.name, @@ -367,7 +367,7 @@ export async function saveConnection( ...(connection.baseUrl ? { baseUrl: connection.baseUrl } : {}), enabled: connection.enabled, enabledModelIds: [...(connection.enabledModelIds ?? [])], - ...(importedProfiles === undefined ? {} : { relayModelProfiles: importedProfiles }), + ...(importedProfiles === undefined ? {} : { modelOverrides: importedProfiles }), ...(connection.requestBodyOverlay === undefined ? {} : { requestBodyOverlay: connection.requestBodyOverlay }), diff --git a/apps/desktop/src/main/runtime-host-connections-ipc-main.ts b/apps/desktop/src/main/runtime-host-connections-ipc-main.ts index deeb731da8..771e65b77f 100644 --- a/apps/desktop/src/main/runtime-host-connections-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-connections-ipc-main.ts @@ -33,7 +33,7 @@ import { PROVIDER_REGISTRY, providerAuthRequiresSecret, } from '@maka/core/llm-connections'; -import { normalizeRelayModelProfiles } from '@maka/core/model-thinking'; +import { normalizeModelOverrides } from '@maka/core/model-thinking'; import type { CredentialLocator } from '@maka/core/runtime-policy'; import type { RuntimeHostConnectionCatalogEntry as ConnectionCatalogEntry, @@ -202,7 +202,7 @@ export function registerRuntimeHostConnectionsIpc( const catalog = await snapshot(); // Profiles ride as the typed field end to end — nothing free-form // crosses to the host. - const relayModelProfiles = input.relayModelProfiles; + const modelOverrides = input.modelOverrides; const created = await deps.client.createConnection(catalog.revision, { slug: input.slug, name: input.name, @@ -213,7 +213,7 @@ export function registerRuntimeHostConnectionsIpc( defaultModel: input.defaultModel, enabledModelIds: defaultEnabledModelIdsWhenOmitted(input.providerType), }), - ...(relayModelProfiles === undefined ? {} : { relayModelProfiles }), + ...(modelOverrides === undefined ? {} : { modelOverrides }), ...(input.requestBodyOverlay === undefined ? {} : { requestBodyOverlay: input.requestBodyOverlay }), @@ -269,9 +269,9 @@ export function registerRuntimeHostConnectionsIpc( // Tri-state: a patch that mentions profiles re-normalizes them (empty // normalization = clear); a patch without profiles omits the key // entirely, which the store reads as "leave the table alone". - ...(patch.relayModelProfiles === undefined + ...(patch.modelOverrides === undefined ? {} - : { relayModelProfiles: normalizeRelayModelProfiles(patch.relayModelProfiles) ?? null }), + : { modelOverrides: normalizeModelOverrides(patch.modelOverrides) ?? null }), ...(patch.requestBodyOverlay === undefined ? {} : { requestBodyOverlay: patch.requestBodyOverlay }), @@ -412,9 +412,9 @@ export function projectHostConnections( enabledModelIds: [...connection.enabledModelIds], models: [...connection.models], catalogEntries: connection.catalogEntries, - ...(connection.relayModelProfiles === undefined + ...(connection.modelOverrides === undefined ? {} - : { relayModelProfiles: connection.relayModelProfiles }), + : { modelOverrides: connection.modelOverrides }), ...(connection.requestBodyOverlay === undefined ? {} : { requestBodyOverlay: connection.requestBodyOverlay }), @@ -583,6 +583,20 @@ function normalizeUpdateInput( value: unknown, ): UpdateConnectionInput { const patch = normalizeConnectionPatchSecretsForIpc(value); + if (patch.modelOverride !== undefined) { + const { modelId, expected, value: override, enable } = patch.modelOverride; + if (typeof modelId !== 'string' || !modelId.trim() || modelId !== modelId.trim() || + typeof override !== 'object' || override === null || Array.isArray(override) || + (enable !== undefined && typeof enable !== 'boolean') || patch.modelOverrides !== undefined) { + throw new Error('Invalid model override'); + } + const normalize = (value: unknown) => normalizeModelOverrides({ model: value })?.model ?? null; + if (expected === undefined || JSON.stringify(normalize(expected)) !== JSON.stringify(normalize(current.modelOverrides?.[modelId]))) { + throw new Error('Model parameters changed. Reopen the editor before saving again.'); + } + patch.modelOverrides = { ...current.modelOverrides, [modelId]: override }; + if (enable) patch.enabledModelIds = [...new Set([...current.enabledModelIds, modelId])]; + } if (patch.enabledModelIds !== undefined && !patch.enabledModelIds.every((id) => typeof id === 'string')) { throw new Error('Invalid enabled model list'); } diff --git a/apps/desktop/src/renderer/features/connection-settings/index.ts b/apps/desktop/src/renderer/features/connection-settings/index.ts index 4d98c0519f..2a886a4654 100644 --- a/apps/desktop/src/renderer/features/connection-settings/index.ts +++ b/apps/desktop/src/renderer/features/connection-settings/index.ts @@ -45,3 +45,5 @@ export type { export { GenericProviderMark } from './generic-provider-mark.js'; export { parseContextWindowInput } from './context-window-input.js'; +export { CapabilityEditor } from './provider-capability-editor.js'; +export { AddModelDialog, ModelParametersDialog } from './provider-add-model-dialog.js'; diff --git a/apps/desktop/src/renderer/features/connection-settings/provider-add-model-dialog.tsx b/apps/desktop/src/renderer/features/connection-settings/provider-add-model-dialog.tsx new file mode 100644 index 0000000000..cf312a9318 --- /dev/null +++ b/apps/desktop/src/renderer/features/connection-settings/provider-add-model-dialog.tsx @@ -0,0 +1,220 @@ +/* + * 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 { useId, useState, type ReactNode } from 'react'; +import { isRelayProviderType, type ProviderType } from '@maka/core/llm-connections'; +import { supportsRelayFastServiceTier, modelLimitsConflict, type ModelOverride } from '@maka/core/model-thinking'; +import { CapabilityEditor } from './provider-capability-editor.js'; +import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog'; +import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout'; +import { Button, HStack, TextInput, useUiLocale } from '@maka/ui'; +import { getProviderSettingsCopy } from './settings-provider-copy.js'; +import { parseContextWindowInput } from './context-window-input.js'; + +export function AddModelDialog(props: { + isOpen: boolean; + providerType: ProviderType; + existingModelIds: readonly string[]; + /** Another write is in flight; the store would drop this one on the floor. */ + isSubmitDisabled?: boolean; + onOpenChange(open: boolean): void; + /** Resolves to whether the write landed; the draft is held until it did. */ + onSubmit(id: string, profile: ModelOverride): Promise; +}) { + const copy = getProviderSettingsCopy(useUiLocale()).detail; + const [id, setId] = useState(''); + const [profile, setProfile] = useState({}); + const [contextWindowInput, setContextWindowInput] = useState(''); + const contextWindow = parseContextWindowInput(contextWindowInput); + const [numericInputs, setNumericInputs] = useState< + Partial> + >({}); + const numericInvalid = Object.values(numericInputs).some( + (input) => input.trim() !== '' && parseContextWindowInput(input) === null, + ); + const [submitAttempted, setSubmitAttempted] = useState(false); + const limitsConflict = modelLimitsConflict({ contextWindow: contextWindow ?? undefined, inputLimit: profile.inputLimit }); + const [isSaving, setSaving] = useState(false); + + const trimmedId = id.trim(); + const idError = !trimmedId + ? copy.addModelIdRequired + : props.existingModelIds.includes(trimmedId) + ? copy.addModelIdDuplicate + : null; + const contextWindowError = + contextWindowInput.trim() !== '' && contextWindow === null + ? copy.contextWindowInputInvalid + : null; + + function close() { + setId(''); + setProfile({}); + setNumericInputs({}); + setContextWindowInput(''); + setSubmitAttempted(false); + props.onOpenChange(false); + } + + // Closing on submit would clear the draft before the write settles, and an + // exact model id is not something a user can reproduce from memory. The + // failure is reported by the caller's toast; what this owes them is the + // typed text, still there to retry from. + async function submit() { + setSubmitAttempted(true); + if (idError || contextWindowError || numericInvalid || limitsConflict || isSaving) return; + setSaving(true); + try { + const { serviceTier, ...parameters } = profile; + if ( + await props.onSubmit(trimmedId, { + ...parameters, + ...(contextWindow === null ? {} : { contextWindow }), + ...(supportsRelayFastServiceTier(props.providerType, trimmedId) && serviceTier + ? { serviceTier } + : {}), + }) + ) + close(); + } finally { + setSaving(false); + } + } + + return ( + + setProfile((current) => ({ ...current, ...patch }))} + contextWindowInput={contextWindowInput} + contextWindowInputInvalid={submitAttempted && contextWindowError !== null} + contextWindowError={contextWindowError ?? undefined} + numericInputs={numericInputs} + onNumericInput={(field, input) => { + setNumericInputs((current) => ({ ...current, [field]: input })); + const value = parseContextWindowInput(input); + if (value !== null || input.trim() === '') + setProfile((current) => ({ ...current, [field]: value ?? undefined })); + }} + disabled={isSaving} + showsFastMode={supportsRelayFastServiceTier(props.providerType, trimmedId)} + defaultVision={undefined} + onContextWindowInput={setContextWindowInput} + > + + + + ); +} + +export function ModelParametersDialog(props: { + isOpen: boolean; + title: string; + subtitle?: string; + confirmLabel: string; + isSaving: boolean; + isSubmitDisabled?: boolean; + onClose(): void; + onSubmit(): Promise; + children: ReactNode; +}) { + const formId = useId(); + const copy = getProviderSettingsCopy(useUiLocale()).detail; + const close = () => { + if (!props.isSaving) props.onClose(); + }; + return ( + { + if (!open) close(); + }} + purpose="form" + width={440} + > + { + if (!open) close(); + }} + /> + } + content={ + +
{ + event.preventDefault(); + if (!props.isSaving && !props.isSubmitDisabled) void props.onSubmit(); + }} + > + {props.children} +
+
+ } + footer={ + + +
+ ); +} diff --git a/apps/desktop/src/renderer/features/connection-settings/provider-capability-editor.tsx b/apps/desktop/src/renderer/features/connection-settings/provider-capability-editor.tsx new file mode 100644 index 0000000000..082bf55aee --- /dev/null +++ b/apps/desktop/src/renderer/features/connection-settings/provider-capability-editor.tsx @@ -0,0 +1,200 @@ +/* + * 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 { useId, type ReactNode } from 'react'; +import { parseContextWindowInput } from './context-window-input.js'; +import { DropdownMenu, DropdownMenuCheckboxItem, Field, FormLayout } from '@astryxdesign/core'; +import { + DECLARABLE_RELAY_THINKING_LEVELS, + THINKING_LEVELS, + type ModelOverride, + type ThinkingLevel, +} from '@maka/core/model-thinking'; +import { Selector, TextInput } from '@maka/ui'; +import { getProviderSettingsCopy } from './settings-provider-copy.js'; + +export function CapabilityEditor(props: { + children?: ReactNode; + copy: ReturnType['detail']; + modelId: string; + isRelay: boolean; + declared: ModelOverride | undefined; + contextWindowInput: string; + contextWindowInputInvalid: boolean; + numericInputs?: Partial>; + onNumericInput(field: 'inputLimit' | 'compactionThreshold' | 'maxOutputTokens', input: string): void; + defaultContextWindow?: number; + defaultInputLimit?: number; + limitsConflict?: boolean; + contextWindowError?: string; + disabled: boolean; + showsFastMode: boolean; + defaultVision: boolean | undefined; + onContextWindowInput(value: string): void; + onChange(patch: Partial): void; +}) { + const { copy, modelId, declared } = props; + const thinkingId = useId(); + const visionValue = + declared?.vision === true ? 'enabled' : declared?.vision === false ? 'disabled' : 'auto'; + const draftLevels = declared?.thinkingLevels ?? []; + // The menu offers the five declarable levels PLUS anything the stored table + // already claims — a level saved while it was still declarable (or + // hand-written into the document) must stay visible and un-checkable, never + // an invisible selection the trigger counts but the menu cannot show. + const menuLevels: readonly ThinkingLevel[] = THINKING_LEVELS.filter( + (level) => + (DECLARABLE_RELAY_THINKING_LEVELS as readonly ThinkingLevel[]).includes(level) || + draftLevels.includes(level), + ); + return ( + + {props.children} + + props.onChange({ displayName: displayName.trim() ? displayName : undefined }) + } + /> + + + props.onChange({ vision: value === 'auto' ? undefined : value === 'enabled' }) + } + isDisabled={props.disabled} + /> + + + {(['inputLimit', 'compactionThreshold', 'maxOutputTokens'] as const).map((field) => { + const input = props.numericInputs?.[field] ?? String(declared?.[field] ?? ''); + const invalid = input.trim() !== '' && parseContextWindowInput(input) === null; + return ( + props.onNumericInput(field, value)} + isDisabled={props.disabled} + hasClear + placeholder={field === 'inputLimit' && props.defaultInputLimit !== undefined ? String(props.defaultInputLimit) : field === 'maxOutputTokens' ? '8192 / 8K' : '128000 / 128K / 1M'} + status={ + invalid ? { type: 'error', message: copy.contextWindowInputInvalid } : field === 'inputLimit' && props.limitsConflict ? { type: 'error', message: copy.modelLimitsConflict } : undefined + } + /> + ); + })} + {/* Only relays accept a reasoning_effort declaration. */} + {props.isRelay && ( + + {/* DropdownMenu, not MultiSelector: levels have a canonical order + (low → max) that must not shuffle — MultiSelector pins the + selected-at-open options to the top with no opt-out, which + misread as the declaration being order-sensitive. */} + 0 + ? copy.thinkingSelectedCount(draftLevels.length) + : copy.thinkingUndeclared, + id: thinkingId, + 'aria-label': copy.thinkingEffort, + isDisabled: props.disabled, + }} + hasChevron + menuWidth={224} + > + {menuLevels.map((level) => ( + { + props.onChange({ + thinkingLevels: checked + ? [...draftLevels, level] + : draftLevels.filter((existing) => existing !== level), + }); + }} + isDisabled={props.disabled} + /> + ))} + + + )} + {props.showsFastMode && ( + + props.onChange({ serviceTier: value === 'fast' ? 'fast' : undefined }) + } + isDisabled={props.disabled} + /> + )} + + ); +} 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 e6d081b622..fff4970017 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 @@ -34,71 +34,86 @@ type WidenCopy = T extends string // after the connection exists). const zhCapabilitiesCopy = { capabilities: '能力', - thinkingEffort: '思考档位(reasoning_effort)', - thinkingEffortHelp: '勾选需要的思考强度档位,不勾选即为不声明。', - thinkingUndeclared: '未声明', - thinkingSelectedCount: (count: number) => `已选择 ${count} 个`, - thinkingBulk: '批量设置思考档位', - thinkingBulkCoverage: (declared: number, total: number) => - declared === 0 ? '全部未声明' : `${declared}/${total} 个模型`, - visionInput: '视觉输入(vision)', - visionInputHelp: '「自动」跟随内置元数据;「启用/禁用」是显式声明,覆盖自动判断。', - visionAuto: '自动', - visionEnabledOption: '启用', - visionDisabledOption: '禁用', - contextWindow: '上下文窗口(tokens)', - contextWindowHelp: '设置后作为 Maka 压缩的触发阈值。留空则不主动压缩,由供应商决定何时超限。', - contextWindowHint: (tokens: number) => `该模型声明的窗口为 ${tokens} tokens`, - contextWindowApplyHint: '填入', + modelDisplayName: '显示名称', + modelDisplayNameHelp: '仅用于显示;请求仍使用模型 ID。', + thinkingEffort: '思考强度', + thinkingEffortHelp: '勾选服务商支持的强度,供对话时选择。留空时按模型资料设置。', + thinkingUndeclared: '自动', + thinkingSelectedCount: (count: number) => `已选 ${count} 项`, + visionInput: '图片识别', + visionInputHelp: '此服务商的模型是否支持图片。自动按模型资料判断,缺少资料时不发送图片。', + visionDefaultOption: (supported: boolean | undefined) => + supported === undefined ? '自动' : supported ? '自动 · 支持' : '自动 · 不支持', + visionEnabledOption: '支持', + visionDisabledOption: '不支持', + contextWindow: '上下文窗口', + inputLimit: '输入上限', + inputLimitHelp: '单次请求可输入的 token 数。留空时按模型资料设置。', + modelLimitsConflict: '输入上限不能超过上下文窗口。', + contextWindowHelp: '模型可处理的 token 总量。留空时按模型资料设置。', + compactionThreshold: '压缩阈值', + compactionThresholdHelp: '达到此 token 数时压缩上下文。留空则不主动压缩。', + maxOutputTokens: '输出上限', + maxOutputTokensHelp: '单次回复的输出 token 上限,含思考。留空自动设置。', fastMode: 'Fast 模式', - fastModeHelp: '使用 OpenAI 的 fast service tier;留空跟随服务商默认值。', + fastModeHelp: '选择更快的服务档位,可能产生额外费用。', fastAuto: '自动', fastEnabled: 'Fast', }; const zhTwCapabilitiesCopy = { capabilities: '能力', - thinkingEffort: '思考檔位(reasoning_effort)', - thinkingEffortHelp: '勾選需要的思考強度檔位,不勾選即為不宣告。', - thinkingUndeclared: '未宣告', - thinkingSelectedCount: (count: number) => `已選擇 ${count} 個`, - thinkingBulk: '批次設定思考檔位', - thinkingBulkCoverage: (declared: number, total: number) => - declared === 0 ? '全部未宣告' : `${declared}/${total} 個模型`, - visionInput: '視覺輸入(vision)', - visionInputHelp: '「自動」跟隨內建後設資料;「啟用/停用」是顯式宣告,覆蓋自動判斷。', - visionAuto: '自動', - visionEnabledOption: '啟用', - visionDisabledOption: '停用', - contextWindow: '上下文視窗(tokens)', - contextWindowHelp: '聲明後壓縮與預算按此值計算;留空跟隨內建後設資料。', - contextWindowHint: (tokens: number) => `該模型宣告的視窗為 ${tokens} tokens`, - contextWindowApplyHint: '填入', + modelDisplayName: '顯示名稱', + modelDisplayNameHelp: '僅供顯示;請求仍使用模型 ID。', + thinkingEffort: '思考強度', + thinkingEffortHelp: '勾選服務商支援的強度,供對話時選擇。留空時依模型資料設定。', + thinkingUndeclared: '自動', + thinkingSelectedCount: (count: number) => `已選 ${count} 項`, + visionInput: '圖片辨識', + visionInputHelp: '此服務商的模型是否支援圖片。自動依模型資料判斷,缺少資料時不傳送圖片。', + visionDefaultOption: (supported: boolean | undefined) => + supported === undefined ? '自動' : supported ? '自動 · 支援' : '自動 · 不支援', + visionEnabledOption: '支援', + visionDisabledOption: '不支援', + contextWindow: '上下文視窗', + inputLimit: '輸入上限', + inputLimitHelp: '單次請求可輸入的 token 數。留空時依模型資料設定。', + modelLimitsConflict: '輸入上限不能超過上下文視窗。', + contextWindowHelp: '模型可處理的 token 總量。留空時依模型資料設定。', + compactionThreshold: '壓縮門檻', + compactionThresholdHelp: '達到此 token 數時壓縮上下文。留空則不主動壓縮。', + maxOutputTokens: '輸出上限', + maxOutputTokensHelp: '單次回覆的輸出 token 上限,含思考。留空自動設定。', fastMode: 'Fast 模式', - fastModeHelp: '使用 OpenAI 的 fast service tier;留空跟隨服務商預設值。', + fastModeHelp: '選擇更快的服務檔位,可能產生額外費用。', fastAuto: '自動', fastEnabled: 'Fast', }; const enCapabilitiesCopy = { capabilities: 'Capabilities', - thinkingEffort: 'Thinking levels (reasoning_effort)', - thinkingEffortHelp: 'Tick the thinking levels this model supports; none ticked means undeclared.', - thinkingUndeclared: 'Undeclared', - thinkingSelectedCount: (count: number) => `${count} selected`, - thinkingBulk: 'Set thinking levels for all models', - thinkingBulkCoverage: (declared: number, total: number) => - declared === 0 ? 'On no model' : `On ${declared} of ${total} models`, - visionInput: 'Vision input', - visionInputHelp: 'Auto follows built-in metadata; Enabled/Disabled overrides it explicitly.', - visionAuto: 'Auto', - visionEnabledOption: 'Enabled', - visionDisabledOption: 'Disabled', - contextWindow: 'Context window (tokens)', - contextWindowHelp: 'When set, Maka compacts once the previous request\'s real usage exceeds it. Leave empty to never compact proactively; the provider decides.', - contextWindowHint: (tokens: number) => `This model declares a ${tokens}-token window`, - contextWindowApplyHint: 'Use it', + modelDisplayName: 'Display name', + modelDisplayNameHelp: 'A label for this model. Requests still use the exact model ID.', + thinkingEffort: 'Available thinking levels', + thinkingEffortHelp: 'These levels appear in the conversation’s thinking selector. Select only levels supported by this provider. Leave all unchecked to use existing model information.', + thinkingUndeclared: 'Use model options', + thinkingSelectedCount: (count: number) => `${count} levels available`, + visionInput: 'Send images to the model', + visionInputHelp: 'Use provider and model information to decide whether to send images. Images are not sent when information is missing. Before choosing “Allow images”, confirm this provider supports images for this model.', + visionDefaultOption: (supported: boolean | undefined) => + supported === undefined ? 'Use model information' : supported ? 'Model information: allow images' : 'Model information: no images', + visionEnabledOption: 'Allow images', + visionDisabledOption: 'Do not send images', + contextWindow: 'Context window', + inputLimit: 'Input limit', + inputLimitHelp: 'Maximum input tokens per request. Leave empty to use model information.', + modelLimitsConflict: 'The input limit cannot exceed the context window.', + contextWindowHelp: 'Model capacity offered by this provider. Leave empty to use known information.', + compactionThreshold: 'Compaction threshold', + compactionThresholdHelp: 'Compact at this token count. Leave empty to disable proactive compaction.', + maxOutputTokens: 'Maximum output', + maxOutputTokensHelp: 'Output token budget per reply, including thinking. Leave empty for automatic limits.', fastMode: 'Fast mode', - fastModeHelp: "Use OpenAI's fast service tier; empty follows the provider default.", + fastModeHelp: 'Use the faster service tier. Additional charges may apply.', fastAuto: 'Auto', fastEnabled: 'Fast', }; @@ -130,7 +145,7 @@ const zhCopy = { addModelConfirm: '添加', addModelIdField: '模型 ID', addModelIdFieldHelp: '需与服务商完全一致,区分大小写。', - addModelIdPlaceholder: 'deepseek-v4-pro-beta', + addModelIdPlaceholder: 'deepseek-v4-flash-0731', addModelIdRequired: '请填写模型 ID。', addModelIdDuplicate: '该模型已在列表中。', addModelContextWindow: '上下文窗口', @@ -142,7 +157,7 @@ const zhCopy = { credentialsHelpAccount: '登录令牌只保存在本机。', modelManagementHelp: '这些模型会出现在任务的模型选择器里。', ...zhCapabilitiesCopy, - capabilitiesHelp: '配置这个模型的上下文长度、视觉支持与思考档位,保存后生效。', + capabilitiesHelp: '仅应用于此连接中的这个模型,保存后生效。', // Row affordances (settings-sidebar 的 InfoRow / ExpandableRow 语言):一行 // 只报状态,改的时候才展开成输入框。 change: '更换', set: '设置', edit: '编辑', save: '保存', @@ -160,8 +175,7 @@ const zhCopy = { filterModels: '搜索模型', noModelsMatch: '未找到匹配的模型', enableModelAria: (name: string) => `启用模型 ${name}`, declareCapabilities: '配置参数', declareCapabilitiesAria: (name: string) => `配置模型参数:${name}`, - modelUndescribed: '缺少该模型的参数信息,请手动配置。', - visionToken: '视觉', thinkingToken: '思考', contextToken: (value: string) => `${value} 上下文`, + modelUndescribed: '待配置参数', noModels: '暂无可选模型,请先更新模型目录。', keySet: '已设置', statusLoading: '正在读取状态', credentialUnknown: '凭据状态未知', keyMissing: '尚未设置密钥', keyTroubleshooting: '模型密钥 / 服务地址 / 代理设置', endpointTroubleshooting: '本地服务 / 服务地址 / 代理设置', oauthTroubleshooting: 'OAuth 登录 / 代理设置', @@ -312,7 +326,7 @@ const zhTwCopy = { addModelConfirm: '新增', addModelIdField: '模型 ID', addModelIdFieldHelp: '需與服務商完全一致,區分大小寫。', - addModelIdPlaceholder: 'deepseek-v4-pro-beta', + addModelIdPlaceholder: 'deepseek-v4-flash-0731', addModelIdRequired: '請填寫模型 ID。', addModelIdDuplicate: '該模型已在列表中。', addModelContextWindow: '上下文視窗', @@ -324,7 +338,7 @@ const zhTwCopy = { credentialsHelpAccount: '登入權杖只儲存在本機。', modelManagementHelp: '這些模型會出現在任務的模型選擇器裡。', ...zhTwCapabilitiesCopy, - capabilitiesHelp: '宣告每個已啟用模型的思考檔位、視覺與上下文視窗;儲存後生效。', + capabilitiesHelp: '僅套用至此連線中的這個模型,儲存後生效。', // Row affordances (settings-sidebar 的 InfoRow / ExpandableRow 語言):一行 // 只報狀態,改的時候才展開成輸入框。 change: '更換', set: '設定', edit: '編輯', save: '儲存', @@ -342,8 +356,7 @@ const zhTwCopy = { filterModels: '搜尋模型', noModelsMatch: '找不到符合的模型', enableModelAria: (name: string) => `啟用模型 ${name}`, declareCapabilities: '設定參數', declareCapabilitiesAria: (name: string) => `設定模型參數:${name}`, - modelUndescribed: '缺少該模型的參數資訊,請手動設定。', - visionToken: '视觉', thinkingToken: '思考', contextToken: (value: string) => `${value} 上下文`, + modelUndescribed: '待設定參數', noModels: '暫無可選模型,請先更新模型目錄。', keySet: '已設定', statusLoading: '正在讀取狀態', credentialUnknown: '憑據狀態未知', keyMissing: '尚未設定金鑰', keyTroubleshooting: '模型金鑰 / 服務地址 / 代理設定', endpointTroubleshooting: '本地服務 / 服務地址 / 代理設定', oauthTroubleshooting: 'OAuth 登入 / 代理設定', @@ -494,7 +507,7 @@ const enCopy: ProviderSettingsCopy = { addModelConfirm: 'Add', addModelIdField: 'Model ID', addModelIdFieldHelp: 'Must match the provider exactly, including case.', - addModelIdPlaceholder: 'deepseek-v4-pro-beta', + addModelIdPlaceholder: 'deepseek-v4-flash-0731', addModelIdRequired: 'Enter a model ID.', addModelIdDuplicate: 'This model is already in the list.', addModelContextWindow: 'Context window', @@ -507,7 +520,7 @@ const enCopy: ProviderSettingsCopy = { credentialsHelpAccount: 'The sign-in token stays on this machine.', modelManagementHelp: 'These models appear in the chat model picker.', ...enCapabilitiesCopy, - capabilitiesHelp: "Set this model's context length, vision support, and thinking levels; applies on save.", + capabilitiesHelp: 'Applies to this model on this connection. Changes take effect on save.', change: 'Change', set: 'Set', edit: 'Edit', save: 'Save', endpointManaged: 'Managed by account sign-in or the provider', endpointMissing: 'No service URL configured', @@ -523,8 +536,7 @@ const enCopy: ProviderSettingsCopy = { filterModels: 'Search models', noModelsMatch: 'No matching models', enableModelAria: (name: string) => `Enable model ${name}`, declareCapabilities: 'Set parameters', declareCapabilitiesAria: (name: string) => `Set model parameters: ${name}`, - modelUndescribed: 'No parameters known for this model. Set them by hand.', - visionToken: 'Vision', thinkingToken: 'Thinking', contextToken: (value: string) => `${value} context`, + modelUndescribed: 'Parameters not configured', noModels: 'No models are available. Update the model catalog first.', keySet: 'Set', statusLoading: 'Reading status', credentialUnknown: 'Credential status unavailable', keyMissing: 'No key set', keyTroubleshooting: 'model key, service URL, and proxy settings', endpointTroubleshooting: 'local service, service URL, and proxy settings', oauthTroubleshooting: 'OAuth sign-in and proxy settings', diff --git a/apps/desktop/src/renderer/settings/provider-add-model-dialog.tsx b/apps/desktop/src/renderer/settings/provider-add-model-dialog.tsx deleted file mode 100644 index c186bbe651..0000000000 --- a/apps/desktop/src/renderer/settings/provider-add-model-dialog.tsx +++ /dev/null @@ -1,174 +0,0 @@ -/* - * 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 { 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, 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 - * its own: without a model-list endpoint, refresh replays the array this build - * shipped, so a model the user's plan already serves has no other way in - * (#1584). - * - * Two fields. The id enters `enabledModelIds`, which is the authorization. The - * context window is the one fact nothing else can supply: an id Maka has never - * seen resolves no window, and the history budget falls back to a flat 32k - * (context-budget-policy.ts) — three percent of a 1M-token window. - * - * Everything else a user can declare is edited in the capability section below - * the model list, which shows a row for exactly the models Maka cannot - * describe — every model added here, the moment it is added. - */ -export function AddModelDialog(props: { - isOpen: boolean; - existingModelIds: readonly string[]; - /** Another write is in flight; the store would drop this one on the floor. */ - isSubmitDisabled?: boolean; - onOpenChange(open: boolean): void; - /** Resolves to whether the write landed; the draft is held until it did. */ - onSubmit(id: string, contextWindow: number): Promise; -}) { - const copy = getProviderSettingsCopy(useUiLocale()).detail; - const [id, setId] = useState(''); - const [contextWindowInput, setContextWindowInput] = useState(''); - const contextWindow = parseContextWindowInput(contextWindowInput); - const [submitAttempted, setSubmitAttempted] = useState(false); - const [isSaving, setSaving] = useState(false); - - const trimmedId = id.trim(); - const idError = !trimmedId - ? copy.addModelIdRequired - : props.existingModelIds.includes(trimmedId) - ? copy.addModelIdDuplicate - : null; - // Required, not defaulted: an unknown window falls back to a flat 32k history - // 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 = !contextWindowInput.trim() - ? copy.addModelContextWindowRequired - : contextWindow === null ? copy.contextWindowInputInvalid : null; - - function close() { - setId(''); - setContextWindowInput(''); - setSubmitAttempted(false); - props.onOpenChange(false); - } - - // Closing on submit would clear the draft before the write settles, and an - // exact model id is not something a user can reproduce from memory. The - // failure is reported by the caller's toast; what this owes them is the - // typed text, still there to retry from. - async function submit(event: FormEvent) { - event.preventDefault(); - setSubmitAttempted(true); - if (idError || !contextWindow || isSaving) return; - setSaving(true); - try { - if (await props.onSubmit(trimmedId, contextWindow)) close(); - } finally { - setSaving(false); - } - } - - return ( - { - // A write in flight owns the draft until it settles: dismissing here - // would discard the very text the retry needs. - if (!open && !isSaving) close(); - }} - purpose="form" - width={480} - > - { - if (!open && !isSaving) close(); - }} - /> - } - content={ - -
void submit(event)}> - - {/* The exact id, kept verbatim through selection and inference - — `deepseek-v4-pro-beta` is a different model from - `deepseek-v4-pro`, and only the user knows which one their - plan actually serves. */} - - - -
-
- } - footer={ - - {/* One button, as in scheduled-task-form-dialog: the header's close - control and Escape are already two ways out, so a footer cancel - would be a third route to the same place. */} - -
- ); -} diff --git a/apps/desktop/src/renderer/settings/provider-connection-detail.tsx b/apps/desktop/src/renderer/settings/provider-connection-detail.tsx index 2298c6d6ab..98010a1fee 100644 --- a/apps/desktop/src/renderer/settings/provider-connection-detail.tsx +++ b/apps/desktop/src/renderer/settings/provider-connection-detail.tsx @@ -17,13 +17,13 @@ * under the License. */ -import { useEffect, useState, type ReactNode } from 'react'; +import { useEffect, useState } from 'react'; import { Badge, Banner, - DropdownMenu, - DropdownMenuCheckboxItem, HStack, + Icon, + IconButton, Link, Switch, Text, @@ -32,16 +32,13 @@ import { } from '@astryxdesign/core'; import { isRelayProviderType, PROVIDER_REGISTRY } from '@maka/core/llm-connections'; import { - DECLARABLE_RELAY_THINKING_LEVELS, - THINKING_LEVELS, supportsRelayFastServiceTier, - type RelayModelProfile, - type ThinkingLevel, + modelLimitsConflict, + type ModelOverride, } from '@maka/core/model-thinking'; import { Button, RelativeTime, - Selector, TextInput, useMountedRef, useToast, @@ -51,7 +48,7 @@ import { PasswordInput } from './password-input'; import { SettingsExpandableRow } from './settings-expandable-row'; import { SettingsActions, SettingsRow, SettingsSection } from './settings-section'; import { providerDisplay } from './provider-display'; -import { AddModelDialog } from './provider-add-model-dialog'; +import { CapabilityEditor, AddModelDialog, ModelParametersDialog } from '../features/connection-settings'; import { RuntimeHostSettingsGenerationBoundary, useRuntimeHostSettingsErrorReporter, @@ -77,7 +74,6 @@ import { savedRequestHeaderDrafts, type RequestHeaderDraft, } from './request-customization-editor'; -import { bulkThinkingLevelStates } from './relay-thinking-bulk'; import { endpointCarriesCredentials, providerEndpointPresentation } from './provider-endpoint-presentation'; /** Past this many model rows the list needs a filter to be usable. */ @@ -149,7 +145,7 @@ type EditingRow = | 'endpoint' | 'headers' | 'body' - | { model: string; contextWindowInput?: string } + | { model: string; contextWindowInput?: string; numericInputs?: Partial> } /* The 添加模型 dialog: one thing is open at a time, so it is a row here. */ | 'add-model' | null; @@ -194,52 +190,18 @@ function ConnectionDetailInner(props: ConnectionDetailProps) { save, updateEnabledModels, addDeclaredModel, - relayProfileDraft, - hasRelayProfileChanges, - setDraftThinkingLevels, - saveThinkingLevelForAll, - setDraftVision, - setDraftContextWindow, - setDraftServiceTier, + modelParameters, + hasModelChanges, resetDraftProfile, - saveRelayProfiles, + setDraftParameters, + saveModelParameters, runTest, refreshModels, remove, refreshAfterRelogin, } = useConnectionDetail(props); - // A model gets a capability editor when Maka cannot describe it otherwise. - // On a custom OpenAI relay that is every model: the id is whatever the - // operator chose, so even one that collides with a known name may front - // something else entirely. Elsewhere it is the models the Host-resolved - // catalog entry reports no metadata for — one the user typed in on a provider - // whose key cannot call a model-list endpoint, which no refresh will ever - // describe (#1584). The entry answers this, not the renderer's bundled table: - // the Host owns the catalog and may have refreshed it since this build (#4496). - // - // A model that already carries a declaration always keeps its editor, or a - // stale declaration would be uneditable and unclearable. const isRelay = isRelayProviderType(connection.providerType); const entryById = new Map(modelChoices.map((entry) => [entry.id, entry])); - // Only enabled models declare — the store prunes a model's profile the - // moment it is disabled, so no declaration can ever belong to a row that is - // off. - const capabilityModelIds = enabledModelIds.filter((modelId) => { - if (isRelay || relayProfileDraft[modelId] !== undefined) return true; - // A missing entry is a model the catalog dropped — a quarantined id the - // provider registry filters out of the list but `enabledModelIds` still - // carries so the user can untick it — not one the Host failed to describe. - // Treating absence as "no metadata" would grow an editor `main` never - // showed; only a present-but-uncovered entry needs the hand editor (the - // #1584 typed id, which `savedModelIds` always gives an entry). - const entry = entryById.get(modelId); - return entry !== undefined && !entry.describedByMetadata; - }); - const declaringModelIds = new Set(capabilityModelIds); - // The bulk control edits the relay-only thinking declaration and needs - // repetition to be worth a control at all: with one row it would be a second - // widget doing what the row under it already does. - const showsThinkingBulk = isRelay && capabilityModelIds.length > 1; // One row is a form at a time, the way the settings-sidebar template does it. // Opening a row discards the other's draft: leaving an abandoned draft in // state meant it reappeared when the user came back to that row, and — until @@ -293,6 +255,15 @@ function ConnectionDetailInner(props: ConnectionDetailProps) { }; }, [connection.slug, props.bridge, toast]); + const numericInputs = typeof editingRow === 'object' && editingRow?.model === editingModelId ? editingRow.numericInputs : undefined; + const numericInvalid = Object.values(numericInputs ?? {}).some((input) => input.trim() !== '' && parseContextWindowInput(input) === null); + const declared: ModelOverride | undefined = editingModelId === null ? undefined : modelParameters[editingModelId]; + const modelEntry = connection.catalogEntries.find((model) => model.id === editingModelId); + const limitsConflict = modelLimitsConflict({ + contextWindow: declared?.contextWindow ?? modelEntry?.defaultContextWindow, + inputLimit: declared?.inputLimit ?? modelEntry?.defaultInputLimit, + }); + function openRow(row: Exclude) { // Opening one row abandons whatever another row was holding: only one is // editable at a time, so a draft left behind would be saved by a later @@ -311,10 +282,10 @@ function ConnectionDetailInner(props: ConnectionDetailProps) { } function changeContextWindow(modelId: string, input: string) { - setEditingRow({ model: modelId, contextWindowInput: input }); + setEditingRow((current) => ({ ...(typeof current === 'object' && current ? current : {}), 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); + if (value !== null || input.trim() === '') setDraftParameters(modelId, { contextWindow: value ?? undefined }); } async function saveRequestHeaders(): Promise { @@ -655,66 +626,9 @@ function ConnectionDetailInner(props: ConnectionDetailProps) { options object: handing it the click event would pass a MouseEvent as `opts`. */} {supportsRemoteDiscovery && ( -