diff --git a/apps/desktop/src/main/__tests__/connections-ipc-main.test.ts b/apps/desktop/src/main/__tests__/connections-ipc-main.test.ts index e0b2cd1585..5a97cb8d97 100644 --- a/apps/desktop/src/main/__tests__/connections-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/connections-ipc-main.test.ts @@ -350,6 +350,76 @@ describe('connection IPC credential boundary', () => { assert.equal(persistedPatch?.baseUrl, 'https://chatgpt.com/backend-api/codex'); }); + test('create passes relay model profiles through to the store unchanged', async () => { + let persistedInput: CreateConnectionInput | undefined; + const handlers = registerHandlers({ + connectionStore: { + create: async (input: CreateConnectionInput) => { + persistedInput = input; + return { + ...input, + defaultModel: 'my-reasoning-model', + enabled: true, + createdAt: 1, + updatedAt: 1, + }; + }, + remove: async () => {}, + }, + }); + + const create = handlers.get('connections:create'); + assert.ok(create); + const relayModelProfiles = { + 'my-reasoning-model': { + thinkingLevels: ['low', 'medium', 'high', 'max'], + vision: true, + }, + } as const; + await create({}, { + slug: 'my-relay', + name: 'My Relay', + providerType: 'openai-compatible', + baseUrl: 'https://relay.example/v1', + defaultModel: 'my-reasoning-model', + relayModelProfiles, + }); + assert.deepEqual(persistedInput?.relayModelProfiles, relayModelProfiles); + assert.equal(persistedInput?.extras, undefined); + }); + + test('update passes relay model profiles through to the store unchanged', async () => { + let persistedPatch: UpdateConnectionInput | undefined; + const existing: LlmConnection = { + slug: 'my-relay', + name: 'My Relay', + providerType: 'openai-compatible', + baseUrl: 'https://relay.example/v1', + defaultModel: 'my-reasoning-model', + enabled: true, + createdAt: 1, + updatedAt: 1, + }; + const handlers = registerHandlers({ + connectionStore: { + get: async () => existing, + update: async (_slug: string, patch: UpdateConnectionInput) => { + persistedPatch = patch; + return { ...existing, ...patch }; + }, + }, + }); + + const update = handlers.get('connections:update'); + assert.ok(update); + const relayModelProfiles = { + 'my-reasoning-model': { thinkingLevels: ['low', 'high', 'max'] }, + } as const; + await update({}, existing.slug, { relayModelProfiles }); + assert.deepEqual(persistedPatch?.relayModelProfiles, relayModelProfiles); + assert.equal(persistedPatch?.extras, undefined); + }); + test('hasSecret uses the read-only credential probe', async () => { const connection = { slug: 'openai-codex', diff --git a/apps/desktop/src/main/__tests__/relay-profile-draft.test.ts b/apps/desktop/src/main/__tests__/relay-profile-draft.test.ts new file mode 100644 index 0000000000..6395e6c4c7 --- /dev/null +++ b/apps/desktop/src/main/__tests__/relay-profile-draft.test.ts @@ -0,0 +1,49 @@ +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__/runtime-host-config-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-config-ipc-main.test.ts new file mode 100644 index 0000000000..c56d12c203 --- /dev/null +++ b/apps/desktop/src/main/__tests__/runtime-host-config-ipc-main.test.ts @@ -0,0 +1,106 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { + ConnectionCatalogEntry, + ConnectionCatalogSnapshot, +} from '@maka/core/runtime-policy'; +import type { LlmConnection } from '@maka/core/llm-connections'; +import { saveConnection } from '../runtime-host-config-ipc-main.js'; + +function existingConnection(overrides: Partial = {}): ConnectionCatalogEntry { + return { + connectionId: 'connection-1', + revision: 4, + slug: 'my-relay', + name: 'Relay', + providerType: 'openai-compatible', + baseUrl: 'https://relay.example/v1', + enabled: true, + enabledModelIds: ['model-1'], + models: [{ id: 'model-1' }], + ...overrides, + }; +} + +function snapshot(connections: ConnectionCatalogEntry[]): ConnectionCatalogSnapshot { + return { revision: 7, defaultTarget: null, connections }; +} + +function fakeClient(existing: ConnectionCatalogEntry) { + const updatePatches: Record[] = []; + let connections = [existing]; + const client = { + async loadConnectionCatalog() { + return snapshot(connections); + }, + async updateConnection( + expected: { connectionId: string; revision: number }, + patch: Record, + ) { + updatePatches.push(patch); + connections = [ + { + ...existing, + revision: existing.revision + 1, + ...(patch.relayModelProfiles === null || patch.relayModelProfiles === undefined + ? {} + : { + relayModelProfiles: patch.relayModelProfiles as ConnectionCatalogEntry['relayModelProfiles'], + }), + }, + ]; + return { kind: 'committed' as const }; + }, + }; + return { client, updatePatches }; +} + +test('import overwrite with a profile-free snapshot CLEARS existing relay profiles', async () => { + // Importing is snapshot replacement: the Host update contract treats an + // ABSENT relayModelProfiles as "untouched", which would resurrect the old + // declarations after a "no profiles here" backup. + const { client, updatePatches } = fakeClient( + existingConnection({ relayModelProfiles: { 'model-1': { vision: true } } }), + ); + const incoming: LlmConnection = { + slug: 'my-relay', + name: 'Relay', + providerType: 'openai-compatible', + baseUrl: 'https://relay.example/v1', + defaultModel: 'model-1', + enabled: true, + enabledModelIds: ['model-1'], + createdAt: 0, + updatedAt: 0, + }; + + await saveConnection(client as never, incoming); + + assert.equal(updatePatches.length, 1); + assert.equal(updatePatches[0]?.relayModelProfiles, null); +}); + +test('import overwrite with profiles REPLACES the existing table', async () => { + const { client, updatePatches } = fakeClient( + existingConnection({ relayModelProfiles: { 'model-1': { vision: true } } }), + ); + const incoming: LlmConnection = { + slug: 'my-relay', + name: 'Relay', + providerType: 'openai-compatible', + baseUrl: 'https://relay.example/v1', + defaultModel: 'model-1', + enabled: true, + enabledModelIds: ['model-1'], + createdAt: 0, + updatedAt: 0, + relayModelProfiles: { 'model-1': { contextWindow: 64_000 } }, + }; + + await saveConnection(client as never, incoming); + + assert.equal(updatePatches.length, 1); + assert.deepEqual(updatePatches[0]?.relayModelProfiles, { + 'model-1': { contextWindow: 64_000 }, + }); +}); diff --git a/apps/desktop/src/main/__tests__/runtime-host-github-copilot-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-github-copilot-ipc-main.test.ts index 8aa6c47d39..79031f6866 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-github-copilot-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-github-copilot-ipc-main.test.ts @@ -58,9 +58,11 @@ test('imports a local GitHub credential through the shared Host account path', a connectionId: current.connectionId, revision: current.revision, }); + const { relayModelProfiles, ...restChanges } = changes; const updated: ConnectionCatalogEntry = { ...current, - ...changes, + ...restChanges, + ...(relayModelProfiles === null ? {} : { relayModelProfiles }), revision: current.revision + 1, }; catalog = { diff --git a/apps/desktop/src/main/connections-ipc-validation.ts b/apps/desktop/src/main/connections-ipc-validation.ts index a01dfcd31b..41666a47cf 100644 --- a/apps/desktop/src/main/connections-ipc-validation.ts +++ b/apps/desktop/src/main/connections-ipc-validation.ts @@ -4,6 +4,7 @@ import { type UpdateConnectionInput, } from '@maka/core'; import { PROVIDER_DEFAULTS } from '@maka/core/llm-connections'; +import { normalizeRelayModelProfiles } from '@maka/core/model-thinking'; const IPC_CONNECTION_SLUG_MAX_LENGTH = 64; const IPC_CONNECTION_SECRET_MAX_LENGTH = 4096; @@ -51,10 +52,15 @@ export function normalizeCreateConnectionInputForIpc(value: unknown): CreateConn ? undefined : normalizeConnectionApiKeyForIpc(input.apiKey, 'apiKey'); const slug = normalizeConnectionSlugForIpc(input.slug, 'connection slug'); + const relayModelProfiles = + input.relayModelProfiles === undefined + ? undefined + : normalizeRelayModelProfiles(input.relayModelProfiles); const normalized = { ...input, slug, ...(apiKey === undefined ? {} : { apiKey }), + ...(relayModelProfiles === undefined ? {} : { relayModelProfiles }), } as CreateConnectionInput; return normalizeConnectionBaseUrlForIpc(normalized); } diff --git a/apps/desktop/src/main/desktop-backend-tool-surface.ts b/apps/desktop/src/main/desktop-backend-tool-surface.ts index 8b115eea41..f61ae0e2ed 100644 --- a/apps/desktop/src/main/desktop-backend-tool-surface.ts +++ b/apps/desktop/src/main/desktop-backend-tool-surface.ts @@ -1,5 +1,6 @@ import { activePlanExecution, + relayModelProfile, DEFAULT_SESSION_NAME, defaultWebSearchSettings, isDeepResearchSession, @@ -348,7 +349,12 @@ function replaceParentAgentTools( } function modelSupportsVision(connection: LlmConnection, model: string): boolean { - return resolveModelVisionSupport(connection.providerType, connection.models, model); + return resolveModelVisionSupport( + connection.providerType, + connection.models, + model, + relayModelProfile(connection, model)?.vision, + ); } function resolveDurableChildTools( diff --git a/apps/desktop/src/main/runtime-host-account-connection.ts b/apps/desktop/src/main/runtime-host-account-connection.ts index 9d85144854..2ac41193df 100644 --- a/apps/desktop/src/main/runtime-host-account-connection.ts +++ b/apps/desktop/src/main/runtime-host-account-connection.ts @@ -192,6 +192,10 @@ function accountConnectionChanges( ...(connection.baseUrl === undefined ? {} : { baseUrl: connection.baseUrl }), enabled, enabledModelIds: [...enabledModelIds], + // Account (OAuth) connections never declare relay capabilities, and the + // omission is the point: with tri-state update semantics an absent key + // leaves the stored table untouched, so this path can never clobber + // declarations another writer made. }; } 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 fb47148d4c..a75ead306b 100644 --- a/apps/desktop/src/main/runtime-host-config-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-config-ipc-main.ts @@ -173,7 +173,9 @@ function runtimeHostTransferDeps( }; } -async function saveConnection( +// Exported for the import-overwrite tests: this adapter is where snapshot +// semantics meet the Host's tri-state update contract. +export async function saveConnection( client: DesktopRuntimeHostClient, connection: LlmConnection, ): Promise { @@ -198,12 +200,17 @@ async function saveConnection( ...(connection.baseUrl ? { baseUrl: connection.baseUrl } : {}), enabled: connection.enabled, enabledModelIds: [...(connection.enabledModelIds ?? [])], + // 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, }, ); if (updated.kind !== 'committed') { throw new Error(`Unable to update imported Connection: ${updated.kind}`); } } else { + const importedProfiles = connection.relayModelProfiles; const created = await client.createConnection(catalog.revision, { slug: connection.slug, name: connection.name, @@ -211,6 +218,7 @@ async function saveConnection( ...(connection.baseUrl ? { baseUrl: connection.baseUrl } : {}), enabled: connection.enabled, enabledModelIds: [...(connection.enabledModelIds ?? [])], + ...(importedProfiles === undefined ? {} : { relayModelProfiles: importedProfiles }), }); if (created.kind !== 'committed') { throw new Error(`Unable to create imported Connection: ${created.kind}`); 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 d4d307d7a3..2e5345075d 100644 --- a/apps/desktop/src/main/runtime-host-connections-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-connections-ipc-main.ts @@ -9,6 +9,7 @@ import { PROVIDER_DEFAULTS, providerAuthRequiresSecret, } from '@maka/core/llm-connections'; +import { normalizeRelayModelProfiles } from '@maka/core/model-thinking'; import type { ConnectionCatalogEntry, ConnectionCatalogSnapshot, @@ -86,6 +87,9 @@ export function registerRuntimeHostConnectionsIpc( deps.ipcMain.handle('connections:create', async (_event, raw: unknown) => { const input = normalizeCreateInput(raw); 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 created = await deps.client.createConnection(catalog.revision, { slug: input.slug, name: input.name, @@ -93,6 +97,7 @@ export function registerRuntimeHostConnectionsIpc( ...(input.baseUrl === undefined ? {} : { baseUrl: input.baseUrl }), enabled: true, enabledModelIds: input.defaultModel ? [input.defaultModel] : [], + ...(relayModelProfiles === undefined ? {} : { relayModelProfiles }), }); if (created.kind !== 'committed') { throw new Error(`Unable to create Connection: ${created.kind}`); @@ -129,6 +134,12 @@ export function registerRuntimeHostConnectionsIpc( : { baseUrl: patch.baseUrl }), enabled: patch.enabled ?? current.enabled, enabledModelIds: patch.enabledModelIds ?? current.enabledModelIds, + // 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 + ? {} + : { relayModelProfiles: normalizeRelayModelProfiles(patch.relayModelProfiles) ?? null }), }, ); if (updated.kind !== 'committed') { @@ -229,6 +240,9 @@ export function projectHostConnections(catalog: ConnectionCatalogSnapshot): LlmC defaultModel, enabledModelIds: [...connection.enabledModelIds], models: [...connection.models], + ...(connection.relayModelProfiles === undefined + ? {} + : { relayModelProfiles: connection.relayModelProfiles }), ...(connection.modelSource === undefined ? {} : { modelSource: connection.modelSource }), ...(connection.modelsFetchedAt === undefined ? {} diff --git a/apps/desktop/src/main/sessions-ipc-main.ts b/apps/desktop/src/main/sessions-ipc-main.ts index 086da76d1d..82200b32ff 100644 --- a/apps/desktop/src/main/sessions-ipc-main.ts +++ b/apps/desktop/src/main/sessions-ipc-main.ts @@ -10,7 +10,7 @@ import { isSideConversationSession, isThinkingLevel, sanitizeTaskLedgerTask, - thinkingVariantsForModel, + thinkingVariantsForConnection, } from '@maka/core'; import type { CreateSessionRequestInput, @@ -24,7 +24,7 @@ import type { GitReviewMutationAction, GitReviewSource, } from '@maka/core'; -import type { ProviderType } from '@maka/core/llm-connections'; +import type { ConnectionThinkingContext } from '@maka/core/model-thinking'; import type { WorkspacePrivacyContext } from '@maka/core/incognito'; import { defaultShellPlan, @@ -166,7 +166,7 @@ function latestStoredMessageTs(messages: readonly StoredMessage[]): number | und function normalizeSupportedSessionThinkingLevel( input: unknown, - providerType: ProviderType, + connection: ConnectionThinkingContext, model: string, ): ThinkingLevel | undefined { const thinkingLevel = input === undefined || input === null ? undefined : input; @@ -174,7 +174,7 @@ function normalizeSupportedSessionThinkingLevel( if (!isThinkingLevel(thinkingLevel)) { throw new Error(`Invalid thinking level: ${String(input)}`); } - if (!thinkingVariantsForModel(providerType, model).includes(thinkingLevel)) { + if (!thinkingVariantsForConnection(connection, model).includes(thinkingLevel)) { throw new Error(`当前模型不支持思考级别:${thinkingLevel}`); } return thinkingLevel; @@ -515,7 +515,7 @@ export function registerSessionsIpc( const requestedSlug = input?.llmConnectionSlug ?? (await connectionStore.getDefault()); const { connection, model } = await getReadyConnection(requestedSlug, input?.model); - const thinkingLevel = normalizeSupportedSessionThinkingLevel(input?.thinkingLevel, connection.providerType, model); + const thinkingLevel = normalizeSupportedSessionThinkingLevel(input?.thinkingLevel, connection, model); const session = await createSession({ ...(input?.cwd ? { cwd: input.cwd } : {}), @@ -946,7 +946,7 @@ export function registerSessionsIpc( if (!connection) { throw new Error(`Unknown connection: ${header.llmConnectionSlug}`); } - const nextThinkingLevel = normalizeSupportedSessionThinkingLevel(input, connection.providerType, header.model); + const nextThinkingLevel = normalizeSupportedSessionThinkingLevel(input, connection, header.model); const next = await runtime.updateSession(sessionId, nextThinkingLevel === undefined ? { thinkingLevel: undefined } : { thinkingLevel: nextThinkingLevel }); emitSessionsChanged('updated', sessionId); return next; diff --git a/apps/desktop/src/renderer/locales/settings-provider-copy.ts b/apps/desktop/src/renderer/locales/settings-provider-copy.ts index 1aa4b06e6b..644af0fa3e 100644 --- a/apps/desktop/src/renderer/locales/settings-provider-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-provider-copy.ts @@ -6,6 +6,40 @@ type WidenCopy = T extends string ? (...args: Args) => string : { [K in keyof T]: WidenCopy }; +// Capability-section strings for the connection detail page — the add-provider +// form deliberately carries no declaration controls (capabilities are edited +// after the connection exists). +const zhCapabilitiesCopy = { + capabilities: '能力', + thinkingEffort: '思考档位(reasoning_effort)', + thinkingEffortHelp: '勾选需要的思考强度档位,不勾选即为不声明。', + thinkingUndeclared: '未声明', + thinkingSelectedCount: (count: number) => `已选择 ${count} 个`, + visionInput: '视觉输入(vision)', + visionInputHelp: '「自动」跟随内置元数据;「启用/禁用」是显式声明,覆盖自动判断。', + visionAuto: '自动', + visionEnabledOption: '启用', + visionDisabledOption: '禁用', + contextWindow: '上下文窗口(tokens)', + contextWindowHelp: '声明后压缩与预算按此值计算;留空跟随内置元数据。', + saveCapabilities: '保存能力声明', +}; +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`, + 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, compaction and budgets use this value; when empty, built-in metadata decides.', + saveCapabilities: 'Save capability declarations', +}; + const zhCopy = { detail: { delete: '删除', cancel: '取消', deleteUnused: '不再需要,删除连接', @@ -25,6 +59,8 @@ const zhCopy = { credentialsHelp: '密钥只保存在本机。', credentialsHelpAccount: '登录令牌只保存在本机。', modelManagementHelp: '这些模型会出现在对话的模型选择器里。', + ...zhCapabilitiesCopy, + capabilitiesHelp: '声明每个已启用模型的思考档位、视觉与上下文窗口;保存后生效。', // Row affordances (settings-sidebar 的 InfoRow / ExpandableRow 语言):一行 // 只报状态,改的时候才展开成输入框。 change: '更换', set: '设置', edit: '编辑', save: '保存', @@ -102,6 +138,7 @@ const zhCopy = { saving: '保存中…', save: '保存供应商', keyRequired: (name: string) => `请填写 ${name} API Key`, apiKeyLabel: 'API Key', accountIdLabel: 'Cloudflare Account ID', endpointLabel: '服务地址', defaultModel: '默认模型', defaultModelPlaceholder: '填写你的中转站模型 ID,例如 gpt-4o、claude-sonnet-4-5 或自定义模型名', defaultModelHelp: '用于首次连接测试和模型选择器兜底;保存后仍会自动拉取模型目录。', defaultModelRequired: '请填写默认模型 ID。保存后仍会自动拉取模型目录。', + ...zhCapabilitiesCopy, }, oauthFlow: { refreshFailed: '刷新登录状态失败', accountActionFailed: (name: string) => `${name} 账号操作失败`, loginFailedRetry: '登录失败,请稍后重试。', @@ -168,6 +205,8 @@ const enCopy: ProviderSettingsCopy = { credentialsHelp: 'The key stays on this machine.', credentialsHelpAccount: 'The sign-in token stays on this machine.', modelManagementHelp: 'These models appear in the chat model picker.', + ...enCapabilitiesCopy, + capabilitiesHelp: 'Declares thinking levels, vision, and context window per enabled model; applies on save.', change: 'Change', set: 'Set', edit: 'Edit', save: 'Save', endpointDefault: 'The provider default', modelManagement: 'Models', @@ -243,6 +282,7 @@ const enCopy: ProviderSettingsCopy = { saving: 'Saving…', save: 'Save provider', keyRequired: (name: string) => `Enter the ${name} API key`, apiKeyLabel: 'API key', accountIdLabel: 'Cloudflare Account ID', endpointLabel: 'Service URL', defaultModel: 'Default model', defaultModelPlaceholder: 'Enter your relay model id, e.g. gpt-4o, claude-sonnet-4-5, or a custom model name', defaultModelHelp: 'Used as the first connection-test and picker fallback; Maka still fetches the model catalog after saving.', defaultModelRequired: 'Enter a default model id. Maka still fetches the model catalog after saving.', + ...enCapabilitiesCopy, }, oauthFlow: { refreshFailed: 'Failed to refresh sign-in status', accountActionFailed: (name: string) => `${name} account action failed`, loginFailedRetry: 'Sign-in failed. Try again later.', diff --git a/apps/desktop/src/renderer/settings/provider-add-form.tsx b/apps/desktop/src/renderer/settings/provider-add-form.tsx index bbe411505e..305de4635a 100644 --- a/apps/desktop/src/renderer/settings/provider-add-form.tsx +++ b/apps/desktop/src/renderer/settings/provider-add-form.tsx @@ -15,7 +15,14 @@ import { providerSupportsModelDiscovery, } from '@maka/core/llm-connections'; import { Banner, HStack, VStack } from '@astryxdesign/core'; -import { Button, FormLayout, TextInput, useMountedRef, useUiLocale } from '@maka/ui'; +import { + Button, + FormLayout, + TextInput, + useMountedRef, + useUiLocale, +} from '@maka/ui'; + import { buildCatalogRecommendedDefaultModel } from '../model-catalog-choices'; import { PasswordInput } from './password-input'; import { providerDisplay } from './provider-display'; @@ -137,12 +144,13 @@ export function AddProviderForm(props: { encodeURIComponent(normalizedCloudflareAccountId), ) : baseUrl || undefined; + const createdDefaultModel = normalizedDefaultModel || recommendedDefaultModel; const connection = await props.bridge.create({ slug, name: name || display.name, providerType: props.providerType, baseUrl: resolvedBaseUrl, - defaultModel: normalizedDefaultModel || recommendedDefaultModel, + defaultModel: createdDefaultModel, ...(props.providerType === 'opencode-free' ? { enabledModelIds: [...OPENCODE_FREE_DEFAULT_ENABLED_MODELS] } : {}), diff --git a/apps/desktop/src/renderer/settings/provider-connection-detail.tsx b/apps/desktop/src/renderer/settings/provider-connection-detail.tsx index c7c4f7101e..cbb1de610a 100644 --- a/apps/desktop/src/renderer/settings/provider-connection-detail.tsx +++ b/apps/desktop/src/renderer/settings/provider-connection-detail.tsx @@ -1,9 +1,28 @@ -import { useState, type ReactNode } from 'react'; -import { Banner, Divider, Grid, Heading, HStack, Link, Text, VStack } from '@astryxdesign/core'; +import { useEffect, useState, type ReactNode } from 'react'; +import { + Banner, + Divider, + DropdownMenu, + DropdownMenuCheckboxItem, + Grid, + Heading, + HStack, + Link, + Text, + VStack, +} from '@astryxdesign/core'; import { PROVIDER_DEFAULTS } from '@maka/core/llm-connections'; +import { + DECLARABLE_RELAY_THINKING_LEVELS, + THINKING_LEVELS, + type RelayModelProfile, + type ThinkingLevel, +} from '@maka/core/model-thinking'; import { Button, + NumberInput, RelativeTime, + Selector, TextInput, useMountedRef, useToast, @@ -118,11 +137,28 @@ function ConnectionDetailInner(props: ConnectionDetailProps) { savedBaseUrl, save, updateEnabledModels, + relayProfileDraft, + hasRelayProfileChanges, + setDraftThinkingLevels, + setDraftVision, + setDraftContextWindow, + saveRelayProfiles, runTest, refreshModels, remove, refreshAfterRelogin, } = useConnectionDetail(props); + // Capability switches only exist for openai-compatible relays: built-in + // providers declare their thinking support in model metadata, a custom + // relay's backing model is unknown until the user says what it can do. The + // declaration is per model — a relay can front both a reasoner and a plain + // instruct model. + const showsCapabilities = connection.providerType === 'openai-compatible'; + // Rows are the enabled models, exactly — the store prunes a model's profile + // the moment it is disabled, so no declaration can ever belong to a row + // this list does not show. The editor edits the per-model draft; 保存 + // commits the whole table in one write. + const capabilityModelIds = enabledModelIds; // 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 @@ -306,6 +342,134 @@ function ConnectionDetailInner(props: ConnectionDetailProps) { )} + {showsCapabilities && ( + <> + + + + {capabilityModelIds.map((modelId, modelIndex) => { + const declared: RelayModelProfile | undefined = relayProfileDraft[modelId]; + // Vision resolves to one of three states: absent (Auto), + // true (Enabled), false (explicitly Disabled). Only Auto is + // ever ambiguous, and three distinct controls keep it honest. + 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 ( + + {modelIndex > 0 && } + {modelId} + {/* One row per declaration: label + what it does on the + left, one compact control on the right (the 模型功能 + row language). A CheckboxList wall was the reason this + section looked like a form from a different app. */} + + {/* 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. Checkbox items + stay open between toggles; the trigger carries the + 已选择 N 个 state line. */} + 0 + ? copy.thinkingSelectedCount(draftLevels.length) + : copy.thinkingUndeclared, + 'aria-label': `${copy.thinkingEffort} — ${modelId}`, + isDisabled: detailActionBusy, + }} + hasChevron + menuWidth={224} + > + {menuLevels.map((level) => ( + { + setDraftThinkingLevels( + modelId, + checked + ? [...draftLevels, level] + : draftLevels.filter((existing) => existing !== level), + ); + }} + isDisabled={detailActionBusy} + /> + ))} + + + + + setDraftVision( + modelId, + value === 'auto' ? undefined : value === 'enabled', + ) + } + isDisabled={detailActionBusy} + /> + + + + setDraftContextWindow(modelId, value ?? undefined) + } + /> + + + ); + })} + {/* One commit for the whole table. Draft edits stay local until + this lands; the button only arms when the draft truly differs + from the saved table (both sides pass the write-path + normalizer, so reordering levels doesn't arm it falsely). */} + +