From 2c130c253aa8104eddf3e04c7957253ee098155d Mon Sep 17 00:00:00 2001 From: me2seeks Date: Mon, 31 Aug 2026 23:04:12 +0800 Subject: [PATCH 01/10] feat(desktop): route standard API key onboarding through host --- apps/desktop/renderer-architecture.json | 4 +- .../__tests__/provider-add-submission.test.ts | 72 +++++++++ .../runtime-host-connections-ipc-main.test.ts | 146 ++++++++++++++++++ apps/desktop/src/main/runtime-host-client.ts | 12 ++ .../main/runtime-host-connections-ipc-main.ts | 38 ++++- apps/desktop/src/preload/bridge-contract.d.ts | 21 +++ apps/desktop/src/preload/preload.ts | 6 + .../settings/provider-add-submission.ts | 54 +++++++ .../settings/provider-panel-shared.ts | 73 +++++++++ .../settings/runtime-host-settings-bridge.ts | 36 ++++- 10 files changed, 459 insertions(+), 3 deletions(-) diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index db13adebe6..d74d340707 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -3972,12 +3972,14 @@ "window.maka.connections.getRequestHeaders": 1, "window.maka.connections.getSnapshot": 1, "window.maka.connections.hasSecret": 1, + "window.maka.connections.saveOnboarding": 1, "window.maka.connections.setDefault": 1, "window.maka.connections.setDefaultModel": 1, "window.maka.connections.setRequestHeaders": 1, "window.maka.connections.subscribeEvents": 1, "window.maka.connections.test": 1, - "window.maka.connections.update": 1 + "window.maka.connections.update": 1, + "window.maka.connections.verifyOnboarding": 1 }, "environmentCapabilities": {}, "hookCalls": {}, diff --git a/apps/desktop/src/main/__tests__/provider-add-submission.test.ts b/apps/desktop/src/main/__tests__/provider-add-submission.test.ts index 81c0f2a611..05e1689cc2 100644 --- a/apps/desktop/src/main/__tests__/provider-add-submission.test.ts +++ b/apps/desktop/src/main/__tests__/provider-add-submission.test.ts @@ -21,10 +21,15 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { createProviderWithDiscovery, + apiKeyOnboardingRoute, + initialOnboardingModelIds, + initialManagedOnboardingPhase, + stableOnboardingModels, validateAddProviderDraft, type AddProviderDraft, type AddProviderField, } from '../../renderer/settings/provider-add-submission.js'; +import { runtimeHostApiKeyOnboardingBridge } from '../../renderer/settings/runtime-host-settings-bridge.js'; import { PROVIDER_REGISTRY, providerSupportsModelDiscovery, @@ -223,3 +228,70 @@ test('a duplicate slug outranks a missing key, so one fix is asked for at a time { field: 'slug', reason: 'duplicate' }, ); }); + +test('routes only fixed-endpoint API-key drafts without request customization to Host onboarding', () => { + assert.deepEqual(apiKeyOnboardingRoute({ + providerType: 'openai', + requestHeaderCount: 0, + hasRequestBodyOverlay: false, + }), { kind: 'host' }); + assert.deepEqual(apiKeyOnboardingRoute({ + providerType: 'openai', + requestHeaderCount: 1, + hasRequestBodyOverlay: false, + }), { kind: 'legacy', reason: 'request_headers' }); + assert.deepEqual(apiKeyOnboardingRoute({ + providerType: 'openai', + requestHeaderCount: 0, + hasRequestBodyOverlay: true, + }), { kind: 'legacy', reason: 'request_body' }); + assert.deepEqual(apiKeyOnboardingRoute({ + providerType: 'openai-compatible', + requestHeaderCount: 0, + hasRequestBodyOverlay: false, + }), { kind: 'legacy', reason: 'custom_endpoint' }); + assert.deepEqual(apiKeyOnboardingRoute({ + providerType: 'cloudflare-workers-ai', + requestHeaderCount: 0, + hasRequestBodyOverlay: false, + }), { kind: 'legacy', reason: 'cloudflare' }); +}); + +test('uses a stable discovered-model order and prefers the registered recommendation', () => { + const models = [ + { id: 'z-model', displayName: 'Zulu' }, + { id: 'a-model', displayName: 'Alpha' }, + ]; + assert.deepEqual(stableOnboardingModels(models).map(({ id }) => id), [ + 'a-model', + 'z-model', + ]); + assert.deepEqual(initialOnboardingModelIds(models, 'z-model'), ['z-model']); + assert.deepEqual(initialOnboardingModelIds(models, 'missing'), ['a-model']); + assert.deepEqual(initialOnboardingModelIds([], 'missing'), []); +}); + +test('a remounted setup becomes uncertain only after save dispatch', () => { + assert.equal(initialManagedOnboardingPhase(false), 'input'); + assert.equal(initialManagedOnboardingPhase(true), 'outcome_unknown'); +}); + +test('a late old save cannot clear the uncertainty lease for a newer attempt', () => { + const firstView = runtimeHostApiKeyOnboardingBridge({ + profileId: 'local', + hostId: 'same-host', + }); + const oldAttempt = firstView.saveUncertainty.markDispatched(); + firstView.saveUncertainty.restart(); + + const replacementView = runtimeHostApiKeyOnboardingBridge({ + profileId: 'local', + hostId: 'same-host', + }); + const newAttempt = replacementView.saveUncertainty.markDispatched(); + firstView.saveUncertainty.settle(oldAttempt); + assert.equal(replacementView.saveUncertainty.isUncertain(), true); + + replacementView.saveUncertainty.settle(newAttempt); + assert.equal(firstView.saveUncertainty.isUncertain(), false); +}); 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 6ef207b31c..afd6bdaf7b 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 @@ -24,6 +24,10 @@ import type { RuntimeHostConnectionCatalogEntry as ConnectionCatalogEntry, RuntimeHostConnectionCatalogSnapshot as ConnectionCatalogSnapshot, } from '@maka/runtime-host/client'; +import { + RuntimeHostOperationError, + RuntimeHostRequestInterruptedError, +} from '@maka/runtime-host/client'; import { projectHostConnections, projectHostConnectionTest, @@ -75,9 +79,151 @@ test('registers pure Connection reads for replacement-Host retry', () => { 'connections:hasSecret', ]); assert.ok(effects.has('connections:create')); + assert.ok(effects.has('connections:onboardingVerify')); + assert.ok(effects.has('connections:onboardingSave')); assert.ok(effects.has('connections:test')); }); +test('forwards managed onboarding and emits only after a canonical save', async () => { + const handlers = new Map unknown>(); + const calls: unknown[] = []; + let changed = 0; + registerRuntimeHostConnectionsIpc({ + ipcMain: { + handle: (channel, handler) => { + handlers.set(channel, handler as (...args: unknown[]) => unknown); + }, + }, + client: { + verifyConnectionOnboarding: async (input: unknown) => { + calls.push(['verify', input]); + return { kind: 'verified', models: [{ id: 'gpt-5' }] }; + }, + saveConnectionOnboarding: async (input: unknown) => { + calls.push(['save', input]); + return { + kind: 'saved', + connection: { + connectionId: 'connection-openai-2', + revision: 1, + slug: 'openai-2', + providerType: 'openai', + }, + }; + }, + } as never, + emitConnectionListChanged() { + changed += 1; + }, + }); + + const base = { + target: { kind: 'create', providerType: 'openai' }, + apiKey: 'test-key', + baseUrl: null, + } as const; + assert.deepEqual(await handlers.get('connections:onboardingVerify')?.({}, base), { + kind: 'verified', + models: [{ id: 'gpt-5' }], + }); + assert.deepEqual( + await handlers.get('connections:onboardingSave')?.({}, { + ...base, + enabledModelIds: ['gpt-5'], + }), + { + kind: 'result', + result: { + kind: 'saved', + connection: { + connectionId: 'connection-openai-2', + revision: 1, + slug: 'openai-2', + providerType: 'openai', + }, + }, + }, + ); + assert.equal(changed, 1); + assert.equal(calls.length, 2); +}); + +test('keeps a dispatched onboarding save interruption outcome unknown', async () => { + const handlers = new Map unknown>(); + registerRuntimeHostConnectionsIpc({ + ipcMain: { + handle: (channel, handler) => { + handlers.set(channel, handler as (...args: unknown[]) => unknown); + }, + }, + client: { + saveConnectionOnboarding: async () => { + throw new RuntimeHostRequestInterruptedError( + 'connection.onboarding.save', + 'command', + 'dispatched', + 'connection_lost', + ); + }, + } as never, + emitConnectionListChanged() {}, + }); + + assert.deepEqual( + await handlers.get('connections:onboardingSave')?.({}, { + target: { kind: 'create', providerType: 'openai' }, + apiKey: 'test-key', + baseUrl: null, + enabledModelIds: ['gpt-5'], + }), + { kind: 'outcome_unknown' }, + ); +}); + +test('keeps Host commit_outcome_unknown distinct from a safe non-save', async () => { + const handlers = new Map unknown>(); + let attempt = 0; + registerRuntimeHostConnectionsIpc({ + ipcMain: { + handle: (channel, handler) => { + handlers.set(channel, handler as (...args: unknown[]) => unknown); + }, + }, + client: { + saveConnectionOnboarding: async () => { + attempt += 1; + if (attempt === 1) { + throw new RuntimeHostOperationError( + 'connection.onboarding.save', + 'commit_outcome_unknown', + 'unknown', + ); + } + throw new RuntimeHostRequestInterruptedError( + 'connection.onboarding.save', + 'command', + 'not_dispatched', + 'connection_lost', + ); + }, + } as never, + emitConnectionListChanged() {}, + }); + const input = { + target: { kind: 'create', providerType: 'openai' }, + apiKey: 'test-key', + baseUrl: null, + enabledModelIds: ['gpt-5'], + }; + + assert.deepEqual(await handlers.get('connections:onboardingSave')?.({}, input), { + kind: 'outcome_unknown', + }); + assert.deepEqual(await handlers.get('connections:onboardingSave')?.({}, input), { + kind: 'not_saved', + }); +}); + test('retries connection delete after a stale revision instead of failing permanently', async () => { const handlers = new Map unknown>(); let revision = 1; diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts index ca3a5d6b6b..6a202ce727 100644 --- a/apps/desktop/src/main/runtime-host-client.ts +++ b/apps/desktop/src/main/runtime-host-client.ts @@ -496,6 +496,18 @@ export class DesktopRuntimeHostClient { }); } + verifyConnectionOnboarding( + input: OperationInput<"connection.onboarding.verify">, + ): Promise> { + return this.request("connection.onboarding.verify", input); + } + + saveConnectionOnboarding( + input: OperationInput<"connection.onboarding.save">, + ): Promise> { + return this.request("connection.onboarding.save", input); + } + startOAuthLogin( attemptId: string, target: OperationInput<"oauth.login.start">["target"], 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 df1e8c5696..f33228756c 100644 --- a/apps/desktop/src/main/runtime-host-connections-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-connections-ipc-main.ts @@ -40,7 +40,14 @@ import type { RuntimeHostConnectionCatalogSnapshot as ConnectionCatalogSnapshot, } from '@maka/runtime-host/client'; import { normalizeRequestHeaderUpdates } from '@maka/core/runtime-policy'; -import type { ConnectionTestRunResult } from '@maka/runtime-host/protocol'; +import { + CONNECTION_EFFECT_OPERATION_SPECS, + type ConnectionTestRunResult, +} from '@maka/runtime-host/protocol'; +import { + RuntimeHostOperationError, + RuntimeHostRequestInterruptedError, +} from '@maka/runtime-host/client'; import type { DesktopRuntimeHostClient } from './runtime-host-client.js'; import { handleReconnectableRead, @@ -56,6 +63,7 @@ import type { DesktopConnectionIdentity, DesktopConnectionSnapshot, } from '../shared/desktop-connection-snapshot.js'; +import type { DesktopConnectionOnboardingSaveOutcome } from '../preload/bridge-contract.js'; type HostConnectionsClient = Pick< DesktopRuntimeHostClient, @@ -71,6 +79,8 @@ type HostConnectionsClient = Pick< | 'setDefaultConnectionTarget' | 'testConnection' | 'updateConnection' + | 'verifyConnectionOnboarding' + | 'saveConnectionOnboarding' >; export interface RuntimeHostConnectionsIpcDeps { @@ -156,6 +166,32 @@ export function registerRuntimeHostConnectionsIpc( ); deps.emitConnectionListChanged(); }); + deps.ipcMain.handle('connections:onboardingVerify', async (_event, raw: unknown) => { + const input = CONNECTION_EFFECT_OPERATION_SPECS[ + 'connection.onboarding.verify' + ].decodeInput(raw); + return deps.client.verifyConnectionOnboarding(input); + }); + deps.ipcMain.handle('connections:onboardingSave', async (_event, raw: unknown) => { + const input = CONNECTION_EFFECT_OPERATION_SPECS[ + 'connection.onboarding.save' + ].decodeInput(raw); + try { + const result = await deps.client.saveConnectionOnboarding(input); + if (result.kind === 'saved') deps.emitConnectionListChanged(); + return { kind: 'result', result } satisfies DesktopConnectionOnboardingSaveOutcome; + } catch (error) { + if ( + (error instanceof RuntimeHostOperationError && + error.code === 'commit_outcome_unknown') || + (error instanceof RuntimeHostRequestInterruptedError && + error.dispatch === 'dispatched') + ) { + return { kind: 'outcome_unknown' } satisfies DesktopConnectionOnboardingSaveOutcome; + } + return { kind: 'not_saved' } satisfies DesktopConnectionOnboardingSaveOutcome; + } + }); deps.ipcMain.handle('connections:create', async (_event, raw: unknown) => { const input = normalizeCreateInput(raw); const catalog = await snapshot(); diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 95f2c2a3bf..f1936b1f97 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -354,6 +354,19 @@ export interface DesktopRuntimeHostRef { readonly hostId: string; } +export type DesktopConnectionOnboardingIdentity = Extract< + OperationOutput<'connection.onboarding.save'>, + { readonly kind: 'saved' } +>['connection']; + +export type DesktopConnectionOnboardingSaveOutcome = + | { + readonly kind: 'result'; + readonly result: OperationOutput<'connection.onboarding.save'>; + } + | { readonly kind: 'not_saved' } + | { readonly kind: 'outcome_unknown' }; + /** Desktop-local OAuth intent. Runtime Host remains the identity allocator. */ export type DesktopOAuthLoginTarget = | { readonly kind: 'create' } @@ -1360,6 +1373,14 @@ export interface MakaBridge { setDefault(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity | string | null, host?: DesktopRuntimeHostRef): Promise; setDefaultModel(input: { slug: string; model: string } | null, host?: DesktopRuntimeHostRef): Promise; create(input: CreateConnectionInput, host?: DesktopRuntimeHostRef): Promise; + verifyOnboarding( + input: OperationInput<'connection.onboarding.verify'>, + host?: DesktopRuntimeHostRef, + ): Promise>; + saveOnboarding( + input: OperationInput<'connection.onboarding.save'>, + host?: DesktopRuntimeHostRef, + ): Promise; update(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity, patch: UpdateConnectionInput, host?: DesktopRuntimeHostRef): Promise; delete(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity, host?: DesktopRuntimeHostRef): Promise; test(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity | string, opts?: { model?: string }, host?: DesktopRuntimeHostRef): Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 226ce44a52..c3a7726846 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -2626,6 +2626,12 @@ const makaBridge = { create(input: CreateConnectionInput, host?: DesktopRuntimeHostRef): Promise { return invokeSelectedRuntimeHost(host, 'connections:create', input); }, + verifyOnboarding(input, host) { + return invokeSelectedRuntimeHost(host, 'connections:onboardingVerify', input); + }, + saveOnboarding(input, host) { + return invokeSelectedRuntimeHost(host, 'connections:onboardingSave', input); + }, update(connection: import('../shared/desktop-connection-snapshot.js').DesktopConnectionIdentity, patch: UpdateConnectionInput, host?: DesktopRuntimeHostRef): Promise { return invokeSelectedRuntimeHost(host, 'connections:update', connection, patch); }, diff --git a/apps/desktop/src/renderer/settings/provider-add-submission.ts b/apps/desktop/src/renderer/settings/provider-add-submission.ts index c82bbdca2b..dfc0b7ba39 100644 --- a/apps/desktop/src/renderer/settings/provider-add-submission.ts +++ b/apps/desktop/src/renderer/settings/provider-add-submission.ts @@ -23,9 +23,63 @@ import { providerAuthSupportsApiKey, providerSupportsModelDiscovery, validateSlug, + type ModelInfo, type ProviderType, } from '@maka/core/llm-connections'; import type { CreateConnectionInput, IdentifiedLlmConnection } from '@maka/core/llm-connections'; +export type ApiKeyOnboardingRoute = + | { readonly kind: 'host' } + | { + readonly kind: 'legacy'; + readonly reason: + | 'provider_auth' + | 'custom_endpoint' + | 'cloudflare' + | 'request_headers' + | 'request_body'; + }; + +export function initialManagedOnboardingPhase( + saveDispatched: boolean, +): 'input' | 'outcome_unknown' { + return saveDispatched ? 'outcome_unknown' : 'input'; +} + +/** Decide the only writer before either writer performs a side effect. */ +export function apiKeyOnboardingRoute(input: { + readonly providerType: ProviderType; + readonly requestHeaderCount: number; + readonly hasRequestBodyOverlay: boolean; +}): ApiKeyOnboardingRoute { + const definition = PROVIDER_DEFAULTS[input.providerType]; + if (!providerAuthSupportsApiKey(input.providerType) || definition.authKind !== 'api_key') { + return { kind: 'legacy', reason: 'provider_auth' }; + } + if (input.providerType === 'cloudflare-workers-ai') { + return { kind: 'legacy', reason: 'cloudflare' }; + } + if (!definition.baseUrl) return { kind: 'legacy', reason: 'custom_endpoint' }; + if (input.requestHeaderCount > 0) return { kind: 'legacy', reason: 'request_headers' }; + if (input.hasRequestBodyOverlay) return { kind: 'legacy', reason: 'request_body' }; + return { kind: 'host' }; +} + +export function stableOnboardingModels(models: readonly ModelInfo[]): ModelInfo[] { + return [...models].sort((left, right) => { + const leftLabel = left.displayName?.trim() || left.id; + const rightLabel = right.displayName?.trim() || right.id; + return leftLabel.localeCompare(rightLabel) || left.id.localeCompare(right.id); + }); +} + +export function initialOnboardingModelIds( + models: readonly ModelInfo[], + recommendedModelId: string, +): string[] { + if (models.some((model) => model.id === recommendedModelId)) return [recommendedModelId]; + const first = stableOnboardingModels(models)[0]; + return first ? [first.id] : []; +} /** * The two decisions 添加连接 makes that are not layout: which fields a provider diff --git a/apps/desktop/src/renderer/settings/provider-panel-shared.ts b/apps/desktop/src/renderer/settings/provider-panel-shared.ts index 2f1c40df3f..0a72af26b8 100644 --- a/apps/desktop/src/renderer/settings/provider-panel-shared.ts +++ b/apps/desktop/src/renderer/settings/provider-panel-shared.ts @@ -24,6 +24,7 @@ import { type IdentifiedLlmConnection, type LlmConnection, type ModelDiscoveryResult, + type ModelInfo, type RequestHeaderUpdate, type SavedRequestHeaders, type ProviderCategory, @@ -38,6 +39,78 @@ import type { DesktopConnectionSnapshot, } from '../../shared/desktop-connection-snapshot.js'; +export type DesktopConnectionOnboardingIdentity = { + readonly connectionId: string; + readonly revision: number; + readonly slug: string; + readonly providerType: ProviderType; +}; + +export type ApiKeyOnboardingTarget = + | { readonly kind: 'create'; readonly providerType: ProviderType } + | { readonly kind: 'existing'; readonly connectionId: string }; + +export type ApiKeyOnboardingVerifyResult = + | { readonly kind: 'verified'; readonly models: readonly ModelInfo[] } + | { + readonly kind: 'rejected'; + readonly reason: + | 'provider_unsupported' + | 'connection_not_found' + | 'credential_not_configured' + | 'base_url_not_configured' + | 'catalog_full'; + } + | { + readonly kind: 'failed'; + readonly errorClass: + | 'auth' + | 'timeout' + | 'provider_unavailable' + | 'network' + | 'invalid_response' + | 'unknown'; + }; + +export type ApiKeyOnboardingSaveResult = + | { readonly kind: 'saved'; readonly connection: DesktopConnectionOnboardingIdentity } + | { + readonly kind: 'rejected'; + readonly reason: + | 'provider_unsupported' + | 'connection_not_found' + | 'credential_not_configured' + | 'base_url_not_configured' + | 'catalog_full' + | 'model_unavailable' + | 'superseded'; + } + | Exclude; + +export interface ApiKeyOnboardingBridge { + readonly saveUncertainty: { + isUncertain(): boolean; + markDispatched(): number; + settle(attemptId: number): void; + restart(): void; + }; + verify(input: { + readonly target: ApiKeyOnboardingTarget; + readonly apiKey: string | null; + readonly baseUrl: string | null; + }): Promise; + save(input: { + readonly target: ApiKeyOnboardingTarget; + readonly apiKey: string | null; + readonly baseUrl: string | null; + readonly enabledModelIds: readonly string[]; + }): Promise< + | { readonly kind: 'result'; readonly result: ApiKeyOnboardingSaveResult } + | { readonly kind: 'not_saved' } + | { readonly kind: 'outcome_unknown' } + >; +} + export interface ConnectionsBridge { getSnapshot(): Promise; setDefault(connection: DesktopConnectionIdentity | null): Promise; diff --git a/apps/desktop/src/renderer/settings/runtime-host-settings-bridge.ts b/apps/desktop/src/renderer/settings/runtime-host-settings-bridge.ts index 041bd4d09c..0a4aa702d7 100644 --- a/apps/desktop/src/renderer/settings/runtime-host-settings-bridge.ts +++ b/apps/desktop/src/renderer/settings/runtime-host-settings-bridge.ts @@ -22,7 +22,10 @@ import type { DesktopRuntimeHostRef, MakaBridge, } from '../../preload/bridge-contract.js'; -import type { ConnectionsBridge } from './provider-panel-shared.js'; +import type { + ApiKeyOnboardingBridge, + ConnectionsBridge, +} from './provider-panel-shared.js'; import type { OAuthAccountFlowBridge, OAuthAuthorizationFlowBridge, @@ -53,6 +56,37 @@ export function runtimeHostConnectionsBridge( }; } +export function runtimeHostApiKeyOnboardingBridge( + host: DesktopRuntimeHostRef, +): ApiKeyOnboardingBridge { + const targetKey = `${host.profileId}\u0000${host.hostId}`; + return { + saveUncertainty: { + isUncertain: () => uncertainApiKeyOnboardingTargets.has(targetKey), + markDispatched: () => { + const attemptId = nextApiKeyOnboardingAttemptId++; + uncertainApiKeyOnboardingTargets.set(targetKey, attemptId); + return attemptId; + }, + settle: (attemptId) => { + if (uncertainApiKeyOnboardingTargets.get(targetKey) === attemptId) { + uncertainApiKeyOnboardingTargets.delete(targetKey); + } + }, + restart: () => uncertainApiKeyOnboardingTargets.delete(targetKey), + }, + verify: (input) => window.maka.connections.verifyOnboarding(input, host), + save: (input) => window.maka.connections.saveOnboarding(input, host), + }; +} + +// A Host replacement remounts the settings projection while a dispatched save +// may still settle. Keep the safety gate target-scoped instead of form-scoped; +// durable reconciliation across a Desktop process restart belongs to the +// follow-up Host journal protocol. +const uncertainApiKeyOnboardingTargets = new Map(); +let nextApiKeyOnboardingAttemptId = 1; + type RuntimeHostOAuthBridge = MakaBridge['openAiCodex'] | MakaBridge['xaiOAuth']; export function runtimeHostOAuthAuthorizationBridge( From bca4333b059c60ca39e8dd7407433419bc802ade Mon Sep 17 00:00:00 2001 From: me2seeks Date: Mon, 31 Aug 2026 23:04:20 +0800 Subject: [PATCH 02/10] feat(desktop): add host-backed API key enrollment flow --- .../locales/settings-provider-copy.ts | 12 +- .../renderer/settings/provider-add-form.tsx | 335 +++++++++++++++++- .../settings/provider-catalog-page.tsx | 18 +- .../src/renderer/settings/providers-panel.tsx | 145 ++++++-- .../renderer/settings/settings-surface.tsx | 10 + .../settings/provider-settings.stories.tsx | 305 +++++++++++++++- 6 files changed, 773 insertions(+), 52 deletions(-) diff --git a/apps/desktop/src/renderer/locales/settings-provider-copy.ts b/apps/desktop/src/renderer/locales/settings-provider-copy.ts index c2ad504ded..d46309144e 100644 --- a/apps/desktop/src/renderer/locales/settings-provider-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-provider-copy.ts @@ -177,7 +177,7 @@ const zhCopy = { tabs: { all: '全部', recommended: '推荐', accounts: '账号', plans: '模型计划', api: 'API', aggregators: '聚合服务', local: '本地' }, loadFailed: '载入模型连接失败', loadingAria: '正在加载模型供应商', connections: '模型连接', retry: '点击重试。', empty: '还没有模型连接', connectionRemoved: '原连接已被删除或移除,已返回模型连接列表。', - connectedLoading: '账号已连接,正在载入新的模型连接…', connectedLoadFailed: '账号已连接,但暂时无法载入新的模型连接。', + connectedLoading: '连接已添加,正在载入详情…', connectedLoadFailed: '连接已添加,但暂时无法刷新连接列表。', connectionIdentityChanged: '新连接的身份与登录结果不一致,请返回连接列表后重试。', emptyHelp: '从下方选择一种连接方式开始。', default: '默认', setDefault: '设为默认', setDefaultTitle: '让新任务默认使用这个连接', setDefaultPending: '设置中…', setDefaultFailed: '设为默认失败', addHelp: '选择账号登录、模型计划、API、聚合服务或本地运行时。', categoriesAria: '模型供应商分类', searchPlaceholder: '搜索服务商', searchAria: '搜索模型服务商', noMatch: '没有匹配的服务商', clearSearch: '清除搜索', @@ -203,6 +203,10 @@ const zhCopy = { saving: '保存中…', save: '保存供应商', keyRequired: (name: string) => `请填写 ${name} API Key`, apiKeyLabel: 'API Key', accountIdLabel: 'Cloudflare Account ID', endpointLabel: '服务地址', defaultModel: '默认模型', defaultModelPlaceholder: '留空即可,保存后自动拉取', defaultModelHelp: '保存后 Maka 会向该端点拉取模型目录。只有当端点不提供目录时,才需要在这里手填一个模型 ID。', + onboardingVerifyAndChoose: '验证并选择模型', onboardingVerifying: '正在验证密钥并获取模型…', + onboardingChooseModels: '选择此连接使用的模型', onboardingChooseModelsHelp: '添加后仍可在连接详情中启用其他模型。', onboardingEnabledModels: '启用的模型', onboardingSearchModels: '搜索模型', onboardingAddConnection: '添加连接', onboardingBack: '返回修改', + onboardingAuthFailed: '密钥验证失败,请检查后重试。', onboardingTimeout: '验证超时,请检查网络或代理后重试。', onboardingNetwork: '无法连接模型服务,请检查网络、代理或服务地址。', onboardingUnavailable: '模型连接服务暂时不可用,请稍后重试。', onboardingInvalidResponse: '模型服务返回了无法识别的结果,请稍后重试。', onboardingCatalogFull: '连接数量已达上限,请先删除不再使用的连接。', onboardingModelsChanged: '可用模型已发生变化,请重新验证后选择。', onboardingNoModels: '没有发现可用模型,当前未创建连接。', onboardingSelectModel: '请至少启用一个模型。', + onboardingOutcomeUnknown: '保存结果暂时无法确认', onboardingOutcomeUnknownDetail: '请勿再次添加,以免创建重复连接。重新加载连接列表并检查该连接是否已经出现;仍不确定时请先重连 Runtime Host。', onboardingReloadConnections: '重新加载连接列表', onboardingRestart: '仍要添加另一个连接', ...zhCapabilitiesCopy, }, oauthFlow: { @@ -333,7 +337,7 @@ const enCopy: ProviderSettingsCopy = { tabs: { all: 'All', recommended: 'Recommended', accounts: 'Accounts', plans: 'Model plans', api: 'API', aggregators: 'Aggregators', local: 'Local' }, loadFailed: 'Failed to load model connections', loadingAria: 'Loading model providers', connections: 'Connections', retry: 'Select to retry.', empty: 'No model connections yet', connectionRemoved: 'The original connection was deleted or removed. Returned to the connection list.', - connectedLoading: 'Account connected. Loading the new model connection…', connectedLoadFailed: 'Account connected, but the new model connection could not be loaded yet.', + connectedLoading: 'Connection added. Loading its details…', connectedLoadFailed: 'Connection added, but the connection list could not be refreshed yet.', connectionIdentityChanged: 'The new connection identity did not match the sign-in result. Return to the connection list and try again.', emptyHelp: 'Choose a connection method below to begin.', default: 'Default', setDefault: 'Set as default', setDefaultTitle: 'New chats will use this connection', setDefaultPending: 'Setting…', setDefaultFailed: 'Could not set as default', addHelp: 'Choose account sign-in, a model plan, API, aggregator, or local runtime.', categoriesAria: 'Model provider categories', searchPlaceholder: 'Search providers', searchAria: 'Search model providers', noMatch: 'No matching providers', clearSearch: 'Clear search', @@ -359,6 +363,10 @@ 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: 'Leave empty — fetched after saving', defaultModelHelp: 'Maka fetches the model catalog from this endpoint after saving. Type a model id here only if the endpoint serves no catalog.', + onboardingVerifyAndChoose: 'Verify and choose models', onboardingVerifying: 'Verifying the key and loading models…', + onboardingChooseModels: 'Choose models for this connection', onboardingChooseModelsHelp: 'You can enable more models from the connection details later.', onboardingEnabledModels: 'Enabled models', onboardingSearchModels: 'Search models', onboardingAddConnection: 'Add connection', onboardingBack: 'Back to edit', + onboardingAuthFailed: 'The key could not be verified. Check it and try again.', onboardingTimeout: 'Verification timed out. Check the network or proxy and try again.', onboardingNetwork: 'Could not reach the model provider. Check the network, proxy, or service URL.', onboardingUnavailable: 'The model connection service is unavailable. Try again later.', onboardingInvalidResponse: 'The model provider returned an unrecognized response. Try again later.', onboardingCatalogFull: 'The connection limit has been reached. Remove an unused connection first.', onboardingModelsChanged: 'The available models changed. Verify again and make a new selection.', onboardingNoModels: 'No usable models were found. No connection was created.', onboardingSelectModel: 'Enable at least one model.', + onboardingOutcomeUnknown: 'The save result cannot be confirmed', onboardingOutcomeUnknownDetail: 'Do not add it again yet, because that could create a duplicate connection. Reload the connection list and check whether this connection appeared; reconnect the Runtime Host if the result is still unclear.', onboardingReloadConnections: 'Reload connection list', onboardingRestart: 'Add another connection anyway', ...enCapabilitiesCopy, }, oauthFlow: { diff --git a/apps/desktop/src/renderer/settings/provider-add-form.tsx b/apps/desktop/src/renderer/settings/provider-add-form.tsx index a287f0180f..61086e68bd 100644 --- a/apps/desktop/src/renderer/settings/provider-add-form.tsx +++ b/apps/desktop/src/renderer/settings/provider-add-form.tsx @@ -24,7 +24,7 @@ import { providerAuthRequiresSecret, providerAuthSupportsApiKey, } from '@maka/core/llm-connections'; -import { Banner, HStack, VStack } from '@astryxdesign/core'; +import { Banner, HStack, MultiSelector, Text, VStack } from '@astryxdesign/core'; import { Collapsible } from '@astryxdesign/core/Collapsible'; import { Button, @@ -41,7 +41,9 @@ import { useActionGuard } from './use-action-guard'; import { categoryLabel, providerPanelActionErrorMessage, + type ApiKeyOnboardingBridge, type ConnectionsBridge, + type DesktopConnectionOnboardingIdentity, } from './provider-panel-shared'; import { getProviderSettingsCopy } from '../locales/settings-provider-copy'; import { @@ -52,6 +54,10 @@ import { } from './request-customization-editor'; import { createProviderWithDiscovery, + apiKeyOnboardingRoute, + initialOnboardingModelIds, + initialManagedOnboardingPhase, + stableOnboardingModels, validateAddProviderDraft, type AddProviderIssue, } from './provider-add-submission'; @@ -67,12 +73,27 @@ type ProviderFormError = { message: string; }; +type ManagedOnboardingPhase = + | { readonly kind: 'input' } + | { + readonly kind: 'models'; + readonly models: ReturnType; + readonly selectedIds: readonly string[]; + } + | { readonly kind: 'outcome_unknown' }; + export function AddProviderForm(props: { bridge: ConnectionsBridge; + apiKeyOnboardingBridge?: ApiKeyOnboardingBridge; providerType: ProviderType; existingSlugs: string[]; onCancel(): void; onCreated(slug: string, modelDiscoveryError?: unknown): Promise; + onOnboarded?(identity: DesktopConnectionOnboardingIdentity): Promise; + onOnboardingOutcomeUnknown?(): Promise; + managedSaveDispatched?: boolean; + onManagedSaveDispatched?(): number | undefined; + onManagedSaveSettled?(attemptId: number | undefined): void; }) { const locale = useUiLocale(); const copy = getProviderSettingsCopy(locale).add; @@ -90,7 +111,14 @@ export function AddProviderForm(props: { const [requestHeaders, setRequestHeaders] = useState([]); const [requestBodyText, setRequestBodyText] = useState(''); const [advancedOpen, setAdvancedOpen] = useState(false); - const [error, setError] = useState(null); + const [formState, setFormState] = useState<{ + readonly managedPhase: ManagedOnboardingPhase; + readonly error: ProviderFormError | null; + }>(() => ({ + managedPhase: { kind: initialManagedOnboardingPhase(props.managedSaveDispatched === true) }, + error: null, + })); + const { managedPhase, error } = formState; const [busy, setBusy] = useState(false); const submitGuard = useActionGuard<'submit'>(); const addProviderMountedRef = useMountedRef(); @@ -103,6 +131,27 @@ export function AddProviderForm(props: { const requiresApiKey = providerAuthRequiresSecret(props.providerType) && supportsApiKey; const usesApiKeyDialog = usesQuickApiKeyDialog(props.providerType); + function setManagedPhase(next: ManagedOnboardingPhase) { + setFormState((current) => ({ ...current, managedPhase: next })); + } + + function setError( + next: + | ProviderFormError + | null + | ((current: ProviderFormError | null) => ProviderFormError | null), + ) { + setFormState((current) => ({ + ...current, + error: typeof next === 'function' ? next(current.error) : next, + })); + } + + function resetManagedVerification(options?: { clearKey?: boolean }) { + setManagedPhase({ kind: 'input' }); + if (options?.clearKey) setApiKey(''); + } + function clearFieldError(field: ProviderFormField) { setError((current) => current?.field === field ? null : current, @@ -126,18 +175,141 @@ export function AddProviderForm(props: { return copy.accountLogin; } + function onboardingFailureMessage( + result: + | Exclude>, { kind: 'verified' }> + | Exclude< + Extract>, { kind: 'result' }>['result'], + { kind: 'saved' } + >, + ): string { + if (result.kind === 'failed') { + if (result.errorClass === 'auth') return copy.onboardingAuthFailed; + if (result.errorClass === 'timeout') return copy.onboardingTimeout; + if (result.errorClass === 'network') return copy.onboardingNetwork; + if (result.errorClass === 'provider_unavailable') return copy.onboardingUnavailable; + return copy.onboardingInvalidResponse; + } + if (result.reason === 'catalog_full') return copy.onboardingCatalogFull; + if (result.reason === 'model_unavailable' || result.reason === 'superseded') { + return copy.onboardingModelsChanged; + } + if (result.reason === 'credential_not_configured') return copy.keyRequired(display.name); + return copy.onboardingUnavailable; + } + + function enterOutcomeUnknown() { + setApiKey(''); + setBusy(false); + setManagedPhase({ kind: 'outcome_unknown' }); + } + + async function verifyManagedApiKey(normalizedApiKey: string) { + const onboarding = props.apiKeyOnboardingBridge; + if (!onboarding) return; + submitGuard.begin('submit'); + setBusy(true); + try { + const result = await onboarding.verify({ + target: { kind: 'create', providerType: props.providerType }, + apiKey: normalizedApiKey || null, + baseUrl: null, + }); + if (!addProviderMountedRef.current) return; + if (result.kind !== 'verified') { + setError({ + field: + result.kind === 'failed' && result.errorClass === 'auth' + ? 'apiKey' + : 'form', + message: onboardingFailureMessage(result), + }); + return; + } + const models = stableOnboardingModels(result.models); + const selectedIds = initialOnboardingModelIds(models, recommendedDefaultModel); + if (selectedIds.length === 0) { + setError({ field: 'form', message: copy.onboardingNoModels }); + return; + } + setManagedPhase({ kind: 'models', models, selectedIds }); + } catch (err) { + if (addProviderMountedRef.current) { + setError({ field: 'form', message: providerPanelActionErrorMessage(err, locale) }); + } + } finally { + submitGuard.finish(); + if (addProviderMountedRef.current) setBusy(false); + } + } + + async function saveManagedApiKey( + normalizedApiKey: string, + phase: Extract, + ) { + const onboarding = props.apiKeyOnboardingBridge; + if (!onboarding || phase.selectedIds.length === 0) { + setError({ field: 'form', message: copy.onboardingSelectModel }); + return; + } + const selected = new Set(phase.selectedIds); + const stableIds = phase.models + .map((model) => model.id) + .filter((modelId) => selected.has(modelId)); + if (selected.has(recommendedDefaultModel)) { + stableIds.splice(stableIds.indexOf(recommendedDefaultModel), 1); + stableIds.unshift(recommendedDefaultModel); + } + submitGuard.begin('submit'); + const attemptId = props.onManagedSaveDispatched?.(); + setBusy(true); + try { + const outcome = await onboarding.save({ + target: { kind: 'create', providerType: props.providerType }, + apiKey: normalizedApiKey || null, + baseUrl: null, + enabledModelIds: stableIds, + }); + if (outcome.kind !== 'outcome_unknown') props.onManagedSaveSettled?.(attemptId); + if (!addProviderMountedRef.current) return; + if (outcome.kind === 'outcome_unknown') { + enterOutcomeUnknown(); + return; + } + if (outcome.kind === 'not_saved') { + setError({ field: 'form', message: copy.onboardingUnavailable }); + return; + } + const result = outcome.result; + if (result.kind === 'saved') { + setApiKey(''); + await props.onOnboarded?.(result.connection); + return; + } + if ( + result.kind === 'rejected' && + (result.reason === 'model_unavailable' || result.reason === 'superseded') + ) { + setManagedPhase({ kind: 'input' }); + } + if (result.kind === 'failed' && result.errorClass === 'auth') { + setManagedPhase({ kind: 'input' }); + } + setError({ + field: result.kind === 'failed' && result.errorClass === 'auth' ? 'apiKey' : 'form', + message: onboardingFailureMessage(result), + }); + } catch { + if (addProviderMountedRef.current) enterOutcomeUnknown(); + } finally { + submitGuard.finish(); + if (addProviderMountedRef.current && managedPhase.kind !== 'outcome_unknown') setBusy(false); + } + } + async function submit() { if (submitGuard.current !== null) return; setError(null); - const issue = validateAddProviderDraft({ - providerType: props.providerType, - slug, - existingSlugs: props.existingSlugs, - apiKey, - cloudflareAccountId, - baseUrl, - }); - if (issue) return setError({ field: issue.field, message: issueMessage(issue) }); const normalizedApiKey = apiKey.trim(); const normalizedCloudflareAccountId = cloudflareAccountId.trim(); const normalizedDefaultModel = defaultModel.trim(); @@ -150,6 +322,31 @@ export function AddProviderForm(props: { setAdvancedOpen(true); return setError({ field: 'advancedRequest', message: copy.requestCustomizationInvalid }); } + const onboardingRoute = apiKeyOnboardingRoute({ + providerType: props.providerType, + requestHeaderCount: Object.keys(normalizedRequestHeaders).length, + hasRequestBodyOverlay: requestBodyOverlay !== undefined, + }); + if (onboardingRoute.kind === 'host' && props.apiKeyOnboardingBridge) { + if (requiresApiKey && !normalizedApiKey) { + return setError({ field: 'apiKey', message: copy.keyRequired(display.name) }); + } + if (managedPhase.kind === 'models') { + await saveManagedApiKey(normalizedApiKey, managedPhase); + } else if (managedPhase.kind === 'input') { + await verifyManagedApiKey(normalizedApiKey); + } + return; + } + const issue = validateAddProviderDraft({ + providerType: props.providerType, + slug, + existingSlugs: props.existingSlugs, + apiKey, + cloudflareAccountId, + baseUrl, + }); + if (issue) return setError({ field: issue.field, message: issueMessage(issue) }); submitGuard.begin('submit'); setBusy(true); try { @@ -203,11 +400,13 @@ export function AddProviderForm(props: { headers={requestHeaders} onHeadersChange={(headers) => { setRequestHeaders(headers); + resetManagedVerification(); clearFieldError('advancedRequest'); }} bodyText={requestBodyText} onBodyTextChange={(value) => { setRequestBodyText(value); + resetManagedVerification(); clearFieldError('advancedRequest'); }} disabled={busy} @@ -229,6 +428,85 @@ export function AddProviderForm(props: { ); + const quickUsesManagedOnboarding = Boolean( + props.apiKeyOnboardingBridge && + apiKeyOnboardingRoute({ + providerType: props.providerType, + requestHeaderCount: requestHeaders.length, + hasRequestBodyOverlay: requestBodyText.trim().length > 0, + }).kind === 'host', + ); + + if (usesApiKeyDialog && managedPhase.kind === 'outcome_unknown') { + return ( + + + +