From 4dfe8df4977f3f6b222453f33cea235da500a316 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 12 Sep 2026 14:09:45 +0800 Subject: [PATCH 01/19] feat(desktop): state what the vision declaration resolves to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The connection capability editor labelled the undeclared vision state "自动", which promised a decision the control never showed. A provider that reports nothing about image support — Command Code's model list has no modality fields at all — resolved to "no" invisibly, so "unresolved" and "does not accept images" read the same. The option is now "默认", and while it is selected the field states the resolved verdict. The verdict is read off the Host-resolved catalog entry, so it comes from the same authority a send uses and cannot disagree with it, and it is only stated while nothing is declared: a saved declaration is itself the answer, and the control already shows it. The help text no longer claims built-in metadata is the only source; the first source is the provider's own model-list report, and metadata is the fallback behind it. The new Electron spec covers the half that crosses the Host boundary: the verdict disappears when the user declares one, and the declaration is read back from the connection snapshot rather than from renderer state. Generated-by: Maka --- CHANGELOG.md | 7 ++ apps/desktop/e2e-budget.json | 4 ++ apps/desktop/e2e/vision-declaration.spec.ts | 66 +++++++++++++++++++ .../settings-provider-copy.ts | 18 +++-- .../settings/provider-connection-detail.tsx | 57 +++++++++++----- 5 files changed, 129 insertions(+), 23 deletions(-) create mode 100644 apps/desktop/e2e/vision-declaration.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d6af22878..2b43ccd5a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,13 @@ - 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. +- A model's vision field now offers `默认` where it offered `自动`, and while `默认` is + selected it states what Maka resolves for that model. "自动" promised a decision the + control never showed, so a provider whose model list reports nothing about image + support left "unresolved" indistinguishable from "does not accept images". The + verdict is read off the Host-resolved catalog entry, so the field cannot disagree + with what a send will do, and the help text no longer claims built-in metadata is + the only source. ## 0.1.11 - 2026-08-18 diff --git a/apps/desktop/e2e-budget.json b/apps/desktop/e2e-budget.json index 70ec0ce0f0..4018244989 100644 --- a/apps/desktop/e2e-budget.json +++ b/apps/desktop/e2e-budget.json @@ -13,6 +13,10 @@ "tests": 1, "electron": "the saved window is read back from the Host's connection snapshot, not from renderer state" }, + "vision-declaration.spec.ts": { + "tests": 1, + "electron": "the field's verdict is whatever the Host resolved for the saved declaration, so the assertion has to survive the save round trip and read the connection snapshot back" + }, "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/vision-declaration.spec.ts b/apps/desktop/e2e/vision-declaration.spec.ts new file mode 100644 index 0000000000..d6bb587a03 --- /dev/null +++ b/apps/desktop/e2e/vision-declaration.spec.ts @@ -0,0 +1,66 @@ +/* + * 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-vision'; + +test('the vision field states its verdict until a declaration replaces it', async ({ + requestHeaderRowWindow: page, +}) => { + 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 page.getByRole('button', { name: copy.addModelConfirm, exact: true }).click(); + await page.getByRole('button', { name: copy.declareCapabilitiesAria(MODEL_ID) }).click(); + + // This provider reports nothing about the id and no metadata describes it, so + // 默认 resolves to "no". The field says so rather than leaving the user to + // read an absent capability as a decision Maka reached. + const vision = page.getByRole('combobox', { name: `${copy.visionInput} — ${MODEL_ID}` }); + await expect(vision).toHaveText(copy.visionAuto); + await expect(page.getByText(copy.visionResolvedHint(false))).toBeVisible(); + + // Declaring one replaces the verdict: the control now carries the answer, and + // the field stops speaking for Maka. + await vision.click(); + await page + .getByRole('listbox') + .getByRole('option', { name: copy.visionEnabledOption }) + .click(); + await expect(page.getByText(copy.visionResolvedHint(false))).toBeHidden(); + await page.getByRole('button', { name: copy.save, exact: true }).click(); + + 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]?.vision; + }, MODEL_ID), + ) + .toBe(true); + // Read back from the Host, so what the row reports is the Host's resolution of + // the saved table rather than a renderer-side guess. + await expect(page.getByText(copy.visionResolvedHint(false))).toBeHidden(); +}); 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..351fb24892 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 @@ -42,8 +42,10 @@ const zhCapabilitiesCopy = { thinkingBulkCoverage: (declared: number, total: number) => declared === 0 ? '全部未声明' : `${declared}/${total} 个模型`, visionInput: '视觉输入(vision)', - visionInputHelp: '「自动」跟随内置元数据;「启用/禁用」是显式声明,覆盖自动判断。', - visionAuto: '自动', + visionInputHelp: '「默认」由 Maka 根据服务商上报和内置资料判断该模型能否看图;「启用/禁用」是显式声明,覆盖这个判断。', + visionResolvedHint: (supported: boolean) => + supported ? '当前判断:支持图片输入' : '当前判断:不支持图片输入', + visionAuto: '默认', visionEnabledOption: '启用', visionDisabledOption: '禁用', contextWindow: '上下文窗口(tokens)', @@ -66,8 +68,10 @@ const zhTwCapabilitiesCopy = { thinkingBulkCoverage: (declared: number, total: number) => declared === 0 ? '全部未宣告' : `${declared}/${total} 個模型`, visionInput: '視覺輸入(vision)', - visionInputHelp: '「自動」跟隨內建後設資料;「啟用/停用」是顯式宣告,覆蓋自動判斷。', - visionAuto: '自動', + visionInputHelp: '「預設」由 Maka 依服務商上報與內建資料判斷該模型能否看圖;「啟用/停用」是明確宣告,覆蓋這個判斷。', + visionResolvedHint: (supported: boolean) => + supported ? '目前判斷:支援圖片輸入' : '目前判斷:不支援圖片輸入', + visionAuto: '預設', visionEnabledOption: '啟用', visionDisabledOption: '停用', contextWindow: '上下文視窗(tokens)', @@ -89,8 +93,10 @@ const enCapabilitiesCopy = { 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', + visionInputHelp: 'Default lets Maka decide from the provider report and built-in metadata; Enabled/Disabled overrides that decision explicitly.', + visionResolvedHint: (supported: boolean) => + supported ? 'Currently resolves to: accepts images' : 'Currently resolves to: does not accept images', + visionAuto: 'Default', visionEnabledOption: 'Enabled', visionDisabledOption: 'Disabled', contextWindow: 'Context window (tokens)', diff --git a/apps/desktop/src/renderer/settings/provider-connection-detail.tsx b/apps/desktop/src/renderer/settings/provider-connection-detail.tsx index 2298c6d6ab..a8ff147126 100644 --- a/apps/desktop/src/renderer/settings/provider-connection-detail.tsx +++ b/apps/desktop/src/renderer/settings/provider-connection-detail.tsx @@ -753,6 +753,11 @@ function ConnectionDetailInner(props: ConnectionDetailProps) { const label = entry?.displayName?.trim() || id; const declared: RelayModelProfile | undefined = relayProfileDraft[id]; const declares = declaringModelIds.has(id); + // The table as saved, not the draft: the entry below was resolved + // against the saved declarations, so its vision answers "what does + // 默认 give" only while none is saved. A saved one IS the answer, + // and the control shows it without help. + const savedVision = connection.relayModelProfiles?.[id]?.vision; // One supporting line, the facts separated by dots: the id when it // differs from the name, then what the model can do. Plain text, // not a token per fact — three pills under a name and a badge read @@ -812,6 +817,7 @@ function ConnectionDetailInner(props: ConnectionDetailProps) { disabled={allActionsBusy} showsFastMode={supportsRelayFastServiceTier(connection.providerType, id)} reportedContextWindow={connection.models?.find((model) => model.id === id)?.contextWindow} + defaultVision={savedVision === undefined ? entry?.supportsVision : undefined} onThinkingLevels={(levels) => setDraftThinkingLevels(id, levels)} onVision={(vision) => setDraftVision(id, vision)} onContextWindowInput={(input) => changeContextWindow(id, input)} @@ -944,15 +950,25 @@ function CapabilityEditor(props: { showsFastMode: boolean; /** The window the provider's model list reports, offered as a one-click fill while nothing is declared. */ reportedContextWindow: number | undefined; + /** + * What 「默认」resolves to for this model, and only while nothing is declared. + * Absent means the answer is not knowable here — see the caller, which reads + * it off the resolved entry so the verdict comes from the Host's catalog + * rather than this build's bundled table. + */ + defaultVision: boolean | undefined; onThinkingLevels(levels: ThinkingLevel[] | undefined): void; onVision(vision: boolean | undefined): void; onContextWindowInput(value: string): void; onServiceTier(tier: 'fast' | undefined): void; }) { const { copy, modelId, declared } = props; - // Vision resolves to one of three states: absent (Auto), true (Enabled), - // false (explicitly Disabled). Only Auto is ever ambiguous, and three - // distinct options keep it honest. + // Vision resolves to one of three states: absent (Default), true (Enabled), + // false (explicitly Disabled). Only Default is ever ambiguous — the verdict + // comes from the provider's report and the metadata chain, and a provider + // that reports neither resolves to "no". So while Default is selected the + // field states the verdict, the way the context window field states the + // window the provider reported. const visionValue = declared?.vision === true ? 'enabled' : declared?.vision === false ? 'disabled' : 'auto'; const draftLevels = declared?.thinkingLevels ?? []; @@ -1011,20 +1027,27 @@ function CapabilityEditor(props: { )} - props.onVision(value === 'auto' ? undefined : value === 'enabled')} - isDisabled={props.disabled} - /> + + props.onVision(value === 'auto' ? undefined : value === 'enabled')} + isDisabled={props.disabled} + /> + {visionValue === 'auto' && props.defaultVision !== undefined && ( + + {copy.visionResolvedHint(props.defaultVision)} + + )} + From 1ffd5fb504b0dc0e1e4b38848b6d6d2626ddf955 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 12 Sep 2026 16:01:03 +0800 Subject: [PATCH 02/19] fix(desktop): clarify per-model parameter settings Show the Host-resolved image default before and after overrides, and arrange model parameters in a responsive form. Remove bulk thinking edits because model capabilities differ; retain existing per-model declarations. Bump the Host compatibility epoch for the catalog projection change. Generated-by: Codex --- CHANGELOG.md | 8 +- apps/desktop/e2e/vision-declaration.spec.ts | 42 ++-- apps/desktop/renderer-architecture.json | 12 -- .../__tests__/relay-thinking-bulk.test.ts | 178 --------------- .../settings-provider-copy.ts | 78 +++---- .../settings/provider-connection-detail.tsx | 202 +++++------------- .../renderer/settings/relay-thinking-bulk.ts | 153 ------------- .../settings/use-connection-detail.ts | 37 +--- .../renderer/styles/settings/connection.css | 30 ++- .../settings/provider-settings.stories.tsx | 75 +++---- .../core/src/__tests__/model-catalog.test.ts | 18 ++ packages/core/src/model-catalog.ts | 15 +- .../model-catalog-entry-codec.ts | 9 + packages/runtime-host/src/protocol/index.ts | 3 +- 14 files changed, 220 insertions(+), 640 deletions(-) delete mode 100644 apps/desktop/src/main/__tests__/relay-thinking-bulk.test.ts delete mode 100644 apps/desktop/src/renderer/settings/relay-thinking-bulk.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b43ccd5a0..a1f3ddbcb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,13 +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. -- A model's vision field now offers `默认` where it offered `自动`, and while `默认` is - selected it states what Maka resolves for that model. "自动" promised a decision the - control never showed, so a provider whose model list reports nothing about image - support left "unresolved" indistinguishable from "does not accept images". The - verdict is read off the Host-resolved catalog entry, so the field cannot disagree - with what a send will do, and the help text no longer claims built-in metadata is - the only source. +- Redesigned per-model parameter editing with responsive fields and an image-input default resolved by the Host, including when clearing a saved override. Removed bulk thinking-level edits; each model keeps its own declaration. ## 0.1.11 - 2026-08-18 diff --git a/apps/desktop/e2e/vision-declaration.spec.ts b/apps/desktop/e2e/vision-declaration.spec.ts index d6bb587a03..e24953c34d 100644 --- a/apps/desktop/e2e/vision-declaration.spec.ts +++ b/apps/desktop/e2e/vision-declaration.spec.ts @@ -23,44 +23,54 @@ import { getProviderSettingsCopy } from '../src/renderer/features/connection-set const copy = getProviderSettingsCopy('zh-CN').detail; const MODEL_ID = 'custom-vision'; -test('the vision field states its verdict until a declaration replaces it', async ({ +test('a saved vision override can return to the Host-resolved default', async ({ requestHeaderRowWindow: page, }) => { 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 page.getByRole('textbox', { name: copy.addModelContextWindow }).fill('128K'); await page.getByRole('button', { name: copy.addModelConfirm, exact: true }).click(); await page.getByRole('button', { name: copy.declareCapabilitiesAria(MODEL_ID) }).click(); - // This provider reports nothing about the id and no metadata describes it, so - // 默认 resolves to "no". The field says so rather than leaving the user to - // read an absent capability as a decision Maka reached. const vision = page.getByRole('combobox', { name: `${copy.visionInput} — ${MODEL_ID}` }); - await expect(vision).toHaveText(copy.visionAuto); - await expect(page.getByText(copy.visionResolvedHint(false))).toBeVisible(); + await expect(vision).toHaveText(copy.visionDefaultOption(false)); - // Declaring one replaces the verdict: the control now carries the answer, and - // the field stops speaking for Maka. await vision.click(); await page .getByRole('listbox') .getByRole('option', { name: copy.visionEnabledOption }) .click(); - await expect(page.getByText(copy.visionResolvedHint(false))).toBeHidden(); + await page.getByRole('button', { name: `${copy.thinkingEffort} — ${MODEL_ID}` }).click(); + await page.getByRole('menuitemcheckbox', { name: `${MODEL_ID} low` }).click(); + await page.getByRole('button', { name: `${copy.thinkingEffort} — ${MODEL_ID}` }).press('Escape'); await page.getByRole('button', { name: copy.save, exact: true }).click(); 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]?.vision; + return snapshot.connections.find((connection) => connection.slug === 'no-models') + ?.catalogEntries.find((entry) => entry.id === modelId)?.supportsVision; }, MODEL_ID), ) .toBe(true); - // Read back from the Host, so what the row reports is the Host's resolution of - // the saved table rather than a renderer-side guess. - await expect(page.getByText(copy.visionResolvedHint(false))).toBeHidden(); + await page.getByRole('button', { name: copy.declareCapabilitiesAria(MODEL_ID) }).click(); + await expect(vision).toHaveText(copy.visionEnabledOption); + await vision.click(); + await page.getByRole('listbox').getByRole('option', { name: copy.visionDefaultOption(false) }).click(); + await expect(vision).toHaveText(copy.visionDefaultOption(false)); + await page.getByRole('button', { name: copy.save, exact: true }).click(); + await expect.poll(async () => page.evaluate(async (modelId) => { + const snapshot = await window.maka.connections.getSnapshot(); + const connection = snapshot.connections.find((item) => item.slug === 'no-models'); + return { + declared: connection?.relayModelProfiles?.[modelId]?.vision ?? null, + resolved: connection?.catalogEntries.find((entry) => entry.id === modelId)?.supportsVision, + thinking: connection?.relayModelProfiles?.[modelId]?.thinkingLevels, + contextWindow: connection?.relayModelProfiles?.[modelId]?.contextWindow, + }; + }, MODEL_ID)).toEqual({ declared: null, resolved: false, thinking: ['low'], contextWindow: 128000 }); + await page.getByRole('button', { name: copy.declareCapabilitiesAria(MODEL_ID) }).click(); + await expect(vision).toHaveText(copy.visionDefaultOption(false)); }); diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index b8d5105b09..96397db088 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -166,7 +166,6 @@ "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", @@ -3061,7 +3060,6 @@ "./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, @@ -3190,15 +3188,6 @@ "@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": {}, @@ -3779,7 +3768,6 @@ "./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, 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/renderer/features/connection-settings/settings-provider-copy.ts b/apps/desktop/src/renderer/features/connection-settings/settings-provider-copy.ts index 351fb24892..93a2db6c24 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,77 +34,65 @@ type WidenCopy = T extends string // after the connection exists). const zhCapabilitiesCopy = { capabilities: '能力', - thinkingEffort: '思考档位(reasoning_effort)', - thinkingEffortHelp: '勾选需要的思考强度档位,不勾选即为不声明。', + thinkingEffort: '思考档位', + thinkingEffortHelp: '选择模型接受的思考强度,留空不覆盖。', thinkingUndeclared: '未声明', thinkingSelectedCount: (count: number) => `已选择 ${count} 个`, - thinkingBulk: '批量设置思考档位', - thinkingBulkCoverage: (declared: number, total: number) => - declared === 0 ? '全部未声明' : `${declared}/${total} 个模型`, - visionInput: '视觉输入(vision)', - visionInputHelp: '「默认」由 Maka 根据服务商上报和内置资料判断该模型能否看图;「启用/禁用」是显式声明,覆盖这个判断。', - visionResolvedHint: (supported: boolean) => - supported ? '当前判断:支持图片输入' : '当前判断:不支持图片输入', - visionAuto: '默认', + visionInput: '图片输入', + visionInputHelp: '默认跟随服务商与模型资料,也可手动覆盖。', + visionDefaultOption: (supported: boolean | undefined) => + supported === undefined ? '默认 · 待确认' : supported ? '默认 · 发送图片' : '默认 · 不发送图片', visionEnabledOption: '启用', visionDisabledOption: '禁用', - contextWindow: '上下文窗口(tokens)', - contextWindowHelp: '设置后作为 Maka 压缩的触发阈值。留空则不主动压缩,由供应商决定何时超限。', + contextWindow: '上下文窗口', + contextWindowHelp: '按 token 数设置自动压缩阈值;留空不主动压缩。', contextWindowHint: (tokens: number) => `该模型声明的窗口为 ${tokens} tokens`, contextWindowApplyHint: '填入', fastMode: 'Fast 模式', - fastModeHelp: '使用 OpenAI 的 fast service tier;留空跟随服务商默认值。', + fastModeHelp: '选择更快的服务档位,可能产生额外费用。', fastAuto: '自动', fastEnabled: 'Fast', }; const zhTwCapabilitiesCopy = { capabilities: '能力', - thinkingEffort: '思考檔位(reasoning_effort)', - thinkingEffortHelp: '勾選需要的思考強度檔位,不勾選即為不宣告。', + thinkingEffort: '思考檔位', + thinkingEffortHelp: '選擇模型接受的思考強度,留空不覆寫。', thinkingUndeclared: '未宣告', thinkingSelectedCount: (count: number) => `已選擇 ${count} 個`, - thinkingBulk: '批次設定思考檔位', - thinkingBulkCoverage: (declared: number, total: number) => - declared === 0 ? '全部未宣告' : `${declared}/${total} 個模型`, - visionInput: '視覺輸入(vision)', - visionInputHelp: '「預設」由 Maka 依服務商上報與內建資料判斷該模型能否看圖;「啟用/停用」是明確宣告,覆蓋這個判斷。', - visionResolvedHint: (supported: boolean) => - supported ? '目前判斷:支援圖片輸入' : '目前判斷:不支援圖片輸入', - visionAuto: '預設', + visionInput: '圖片輸入', + visionInputHelp: '預設依服務商與模型資料,也可手動覆寫。', + visionDefaultOption: (supported: boolean | undefined) => + supported === undefined ? '預設 · 待確認' : supported ? '預設 · 傳送圖片' : '預設 · 不傳送圖片', visionEnabledOption: '啟用', visionDisabledOption: '停用', - contextWindow: '上下文視窗(tokens)', - contextWindowHelp: '聲明後壓縮與預算按此值計算;留空跟隨內建後設資料。', + contextWindow: '上下文視窗', + contextWindowHelp: '以 token 數設定自動壓縮門檻;留空不主動壓縮。', contextWindowHint: (tokens: number) => `該模型宣告的視窗為 ${tokens} tokens`, contextWindowApplyHint: '填入', 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.', + thinkingEffort: 'Thinking levels', + thinkingEffortHelp: 'Choose supported reasoning levels. Leave empty to keep the defaults.', 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: 'Default lets Maka decide from the provider report and built-in metadata; Enabled/Disabled overrides that decision explicitly.', - visionResolvedHint: (supported: boolean) => - supported ? 'Currently resolves to: accepts images' : 'Currently resolves to: does not accept images', - visionAuto: 'Default', + visionInput: 'Image input', + visionInputHelp: 'Use provider and model information, or override it here.', + visionDefaultOption: (supported: boolean | undefined) => + supported === undefined ? 'Default · Unconfirmed' : supported ? 'Default · Send images' : 'Default · No images', 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.', + contextWindow: 'Context window', + contextWindowHelp: 'Token threshold for automatic compaction. Leave empty to disable it.', contextWindowHint: (tokens: number) => `This model declares a ${tokens}-token window`, contextWindowApplyHint: 'Use it', 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', }; @@ -148,7 +136,7 @@ const zhCopy = { credentialsHelpAccount: '登录令牌只保存在本机。', modelManagementHelp: '这些模型会出现在任务的模型选择器里。', ...zhCapabilitiesCopy, - capabilitiesHelp: '配置这个模型的上下文长度、视觉支持与思考档位,保存后生效。', + capabilitiesHelp: '仅应用于此连接中的这个模型,保存后生效。', // Row affordances (settings-sidebar 的 InfoRow / ExpandableRow 语言):一行 // 只报状态,改的时候才展开成输入框。 change: '更换', set: '设置', edit: '编辑', save: '保存', @@ -166,7 +154,7 @@ const zhCopy = { filterModels: '搜索模型', noModelsMatch: '未找到匹配的模型', enableModelAria: (name: string) => `启用模型 ${name}`, declareCapabilities: '配置参数', declareCapabilitiesAria: (name: string) => `配置模型参数:${name}`, - modelUndescribed: '缺少该模型的参数信息,请手动配置。', + modelUndescribed: '待配置参数', visionToken: '视觉', thinkingToken: '思考', contextToken: (value: string) => `${value} 上下文`, noModels: '暂无可选模型,请先更新模型目录。', keySet: '已设置', statusLoading: '正在读取状态', credentialUnknown: '凭据状态未知', keyMissing: '尚未设置密钥', @@ -330,7 +318,7 @@ const zhTwCopy = { credentialsHelpAccount: '登入權杖只儲存在本機。', modelManagementHelp: '這些模型會出現在任務的模型選擇器裡。', ...zhTwCapabilitiesCopy, - capabilitiesHelp: '宣告每個已啟用模型的思考檔位、視覺與上下文視窗;儲存後生效。', + capabilitiesHelp: '僅套用至此連線中的這個模型,儲存後生效。', // Row affordances (settings-sidebar 的 InfoRow / ExpandableRow 語言):一行 // 只報狀態,改的時候才展開成輸入框。 change: '更換', set: '設定', edit: '編輯', save: '儲存', @@ -348,7 +336,7 @@ const zhTwCopy = { filterModels: '搜尋模型', noModelsMatch: '找不到符合的模型', enableModelAria: (name: string) => `啟用模型 ${name}`, declareCapabilities: '設定參數', declareCapabilitiesAria: (name: string) => `設定模型參數:${name}`, - modelUndescribed: '缺少該模型的參數資訊,請手動設定。', + modelUndescribed: '待設定參數', visionToken: '视觉', thinkingToken: '思考', contextToken: (value: string) => `${value} 上下文`, noModels: '暫無可選模型,請先更新模型目錄。', keySet: '已設定', statusLoading: '正在讀取狀態', credentialUnknown: '憑據狀態未知', keyMissing: '尚未設定金鑰', @@ -513,7 +501,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', @@ -529,7 +517,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.', + modelUndescribed: 'Parameters not configured', visionToken: 'Vision', thinkingToken: 'Thinking', contextToken: (value: string) => `${value} context`, noModels: 'No models are available. Update the model catalog first.', keySet: 'Set', statusLoading: 'Reading status', credentialUnknown: 'Credential status unavailable', keyMissing: 'No key set', diff --git a/apps/desktop/src/renderer/settings/provider-connection-detail.tsx b/apps/desktop/src/renderer/settings/provider-connection-detail.tsx index a8ff147126..2faef8f19b 100644 --- a/apps/desktop/src/renderer/settings/provider-connection-detail.tsx +++ b/apps/desktop/src/renderer/settings/provider-connection-detail.tsx @@ -77,7 +77,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. */ @@ -197,7 +196,6 @@ function ConnectionDetailInner(props: ConnectionDetailProps) { relayProfileDraft, hasRelayProfileChanges, setDraftThinkingLevels, - saveThinkingLevelForAll, setDraftVision, setDraftContextWindow, setDraftServiceTier, @@ -236,10 +234,6 @@ function ConnectionDetailInner(props: ConnectionDetailProps) { 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 @@ -658,63 +652,6 @@ function ConnectionDetailInner(props: ConnectionDetailProps) {