diff --git a/apps/desktop/e2e/request-header-row-contract.spec.ts b/apps/desktop/e2e/request-header-row-contract.spec.ts index 743afe201d..c820b17743 100644 --- a/apps/desktop/e2e/request-header-row-contract.spec.ts +++ b/apps/desktop/e2e/request-header-row-contract.spec.ts @@ -18,7 +18,7 @@ */ import { expect, test } from './fixtures'; -import { getProviderSettingsCopy } from '../src/renderer/locales/settings-provider-copy'; +import { getProviderSettingsCopy } from '../src/renderer/features/connection-settings'; /** * The custom request header row's remove button centres on the FIELD, not on diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index db13adebe6..14d7786e93 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -80,7 +80,6 @@ "src/renderer/locales/settings-navigation-copy.ts", "src/renderer/locales/settings-preferences-copy.ts", "src/renderer/locales/settings-projects-copy.ts", - "src/renderer/locales/settings-provider-copy.ts", "src/renderer/locales/settings-shared-copy.ts", "src/renderer/locales/settings-subagents-copy.ts", "src/renderer/locales/settings-tasks-copy.ts", @@ -169,7 +168,6 @@ "src/renderer/settings/provider-enabled-model-manager.tsx", "src/renderer/settings/provider-endpoint-presentation.ts", "src/renderer/settings/provider-oauth-section.tsx", - "src/renderer/settings/provider-panel-shared.ts", "src/renderer/settings/providers-panel.tsx", "src/renderer/settings/relay-profile-draft.ts", "src/renderer/settings/relay-thinking-bulk.ts", @@ -1968,17 +1966,6 @@ "@maka/runtime-host/operator": 1 } }, - "src/renderer/locales/settings-provider-copy.ts": { - "bridgePaths": {}, - "environmentCapabilities": {}, - "hookCalls": {}, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": { - "@maka/core/ui-locale": 1 - } - }, "src/renderer/locales/settings-shared-copy.ts": { "bridgePaths": {}, "environmentCapabilities": {}, @@ -2220,10 +2207,10 @@ "unresolvedDependencies": 0, "actionFactories": [], "dependencyPaths": { + "./application/contracts/connection-error-cleaner.js": 1, "./locales/conversation-copy.js": 1, "./locales/shell-copy.js": 1, "./session-error-presentation.js": 1, - "@maka/core/connection-error-copy": 1, "@maka/core/connection-readiness": 1, "@maka/core/events": 1, "@maka/core/ui-locale": 1 @@ -3079,6 +3066,7 @@ "unresolvedDependencies": 0, "actionFactories": [], "dependencyPaths": { + "../features/connection-settings": 1, "../locales/settings-preferences-copy.js": 1, "../locales/settings-shared-copy.js": 1, "../locales/settings-test-result-copy.js": 1, @@ -3086,7 +3074,6 @@ "./password-input": 1, "./personalization-settings-section": 1, "./provider-brand-marks": 1, - "./runtime-host-settings-bridge.js": 1, "./runtime-host-settings-target.js": 1, "./settings-error-copy": 1, "./settings-resource-state.js": 1, @@ -3447,12 +3434,11 @@ "unresolvedDependencies": 0, "actionFactories": [], "dependencyPaths": { - "../locales/settings-provider-copy": 1, + "../features/connection-settings": 1, "../model-catalog-choices": 1, "./password-input": 1, "./provider-add-submission": 1, "./provider-display": 1, - "./provider-panel-shared": 1, "./request-customization-editor": 1, "./use-action-guard": 1, "@astryxdesign/core": 1, @@ -3473,7 +3459,7 @@ "unresolvedDependencies": 0, "actionFactories": [], "dependencyPaths": { - "../locales/settings-provider-copy": 1, + "../features/connection-settings": 1, "@astryxdesign/core/Dialog": 1, "@astryxdesign/core/FormLayout": 1, "@astryxdesign/core/Layout": 1, @@ -3540,11 +3526,10 @@ "unresolvedDependencies": 0, "actionFactories": [], "dependencyPaths": { - "../locales/settings-provider-copy": 1, + "../features/connection-settings": 1, "./provider-add-form": 1, "./provider-display": 1, "./provider-oauth-section": 1, - "./provider-panel-shared": 1, "@astryxdesign/core": 1, "@maka/core/llm-connections": 1, "@maka/core/provider-registry": 1, @@ -3573,13 +3558,12 @@ "unresolvedDependencies": 0, "actionFactories": [], "dependencyPaths": { - "../locales/settings-provider-copy": 1, + "../features/connection-settings": 1, "./password-input": 1, "./provider-add-model-dialog": 1, "./provider-display": 1, "./provider-enabled-model-manager": 1, "./provider-endpoint-presentation": 1, - "./provider-panel-shared": 1, "./relay-thinking-bulk": 1, "./request-customization-editor": 1, "./runtime-host-settings-target.js": 1, @@ -3604,7 +3588,7 @@ "unresolvedDependencies": 0, "actionFactories": [], "dependencyPaths": { - "../locales/settings-provider-copy.js": 1, + "../features/connection-settings/index.js": 1, "@maka/core/llm-connections": 1, "@maka/core/provider-registry": 1, "@maka/core/ui-locale": 1, @@ -3646,7 +3630,7 @@ "unresolvedDependencies": 0, "actionFactories": [], "dependencyPaths": { - "../locales/settings-provider-copy": 1, + "../features/connection-settings": 1, "@astryxdesign/core": 1, "@maka/core/model-catalog": 1, "@maka/ui": 1 @@ -3690,7 +3674,7 @@ "actionFactories": [], "dependencyPaths": { "../../preload/bridge-contract.js": 1, - "../locales/settings-provider-copy": 1, + "../features/connection-settings": 1, "./runtime-host-settings-bridge.js": 1, "./runtime-host-settings-target.js": 1, "./use-oauth-login-flow": 1, @@ -3700,22 +3684,6 @@ "react": 1 } }, - "src/renderer/settings/provider-panel-shared.ts": { - "bridgePaths": {}, - "environmentCapabilities": {}, - "hookCalls": {}, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": { - "../../shared/desktop-connection-snapshot.js": 1, - "../locales/settings-provider-copy.js": 1, - "../model-connection-errors.js": 1, - "@maka/core/llm-connections": 1, - "@maka/core/redaction": 1, - "@maka/core/ui-locale": 1 - } - }, "src/renderer/settings/providers-panel.tsx": { "bridgePaths": {}, "environmentCapabilities": { @@ -3734,13 +3702,12 @@ "unresolvedDependencies": 0, "actionFactories": [], "dependencyPaths": { - "../locales/settings-provider-copy": 1, + "../features/connection-settings": 2, "./provider-catalog-page": 1, "./provider-connection-detail": 1, "./provider-connection-status": 1, "./provider-display": 1, "./provider-oauth-section": 1, - "./provider-panel-shared": 2, "./runtime-host-settings-target.js": 1, "./settings-error-copy": 1, "./settings-route-focus": 1, @@ -3965,20 +3932,7 @@ } }, "src/renderer/settings/runtime-host-settings-bridge.ts": { - "bridgePaths": { - "window.maka.connections.create": 1, - "window.maka.connections.delete": 1, - "window.maka.connections.fetchModels": 1, - "window.maka.connections.getRequestHeaders": 1, - "window.maka.connections.getSnapshot": 1, - "window.maka.connections.hasSecret": 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 - }, + "bridgePaths": {}, "environmentCapabilities": {}, "hookCalls": {}, "lifecycleMethods": {}, @@ -3986,7 +3940,6 @@ "actionFactories": [], "dependencyPaths": { "../../preload/bridge-contract.js": 1, - "./provider-panel-shared.js": 1, "./use-oauth-login-flow.js": 1 } }, @@ -4278,6 +4231,7 @@ "../../preload/bridge-contract.js": 1, "../../shared/settings-ownership.js": 1, "../browser-storage": 1, + "../features/connection-settings": 1, "../locales/settings-navigation-copy.js": 1, "../locales/settings-shared-copy.js": 1, "./about-settings-page": 1, @@ -4293,7 +4247,6 @@ "./projects-settings-page": 1, "./providers-panel": 1, "./runtime-host-interaction-boundary.js": 1, - "./runtime-host-settings-bridge.js": 1, "./runtime-host-settings-target.js": 1, "./settings-error-copy": 1, "./settings-nav": 1, @@ -4492,10 +4445,9 @@ "actionFactories": [], "dependencyPaths": { "../../preload/bridge-contract.js": 1, - "../locales/settings-provider-copy": 1, + "../features/connection-settings": 1, "./connection-name-draft.js": 1, "./provider-connection-status": 1, - "./provider-panel-shared": 1, "./relay-profile-draft": 1, "./relay-thinking-bulk": 1, "./runtime-host-settings-bridge.js": 1, @@ -4574,7 +4526,7 @@ "unresolvedDependencies": 0, "actionFactories": [], "dependencyPaths": { - "../locales/settings-provider-copy": 1, + "../features/connection-settings": 1, "./oauth-login-flow-guard": 1, "./runtime-host-settings-target.js": 1, "@maka/core/redaction": 1, 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..6e5432cc48 100644 --- a/apps/desktop/src/main/__tests__/provider-add-submission.test.ts +++ b/apps/desktop/src/main/__tests__/provider-add-submission.test.ts @@ -18,13 +18,24 @@ */ import assert from 'node:assert/strict'; -import { test } from 'node:test'; +import { afterEach, test } from 'node:test'; +import { act, createElement } from 'react'; import { createProviderWithDiscovery, + apiKeyOnboardingRoute, + initialOnboardingModelIds, + shouldShowManagedOnboardingOutcomeUnknown, + stableOnboardingModels, validateAddProviderDraft, type AddProviderDraft, type AddProviderField, } from '../../renderer/settings/provider-add-submission.js'; +import { createDesktopConnectionSettingsServices } from '../../renderer/platform/desktop/create-connection-settings-services.js'; +import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; +import { + ConnectionSaveUncertaintyObserver, + type ApiKeyOnboardingBridge, +} from '../../renderer/features/connection-settings/index.js'; import { PROVIDER_REGISTRY, providerSupportsModelDiscovery, @@ -39,6 +50,23 @@ type NoModelRule = 'defaultModel' extends AddProviderField ? never : true; const _fieldGateHasNoModelRule: NoModelRule = true; void _fieldGateHasNoModelRule; +afterEach(cleanupFakeDom); + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((accept) => { + resolve = accept; + }); + return { promise, resolve }; +} + +const onboardingSaveInput: Parameters[0] = { + target: { kind: 'create', providerType: 'openai' }, + apiKey: 'test-key', + baseUrl: null, + enabledModelIds: ['gpt-5'], +}; + const RELAY_TYPES: readonly ProviderType[] = ['openai-compatible', 'openai-responses-compatible']; function draft(over: Partial = {}): AddProviderDraft { @@ -223,3 +251,191 @@ 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('only an idle form projects a dispatched save as outcome unknown', () => { + assert.equal(shouldShowManagedOnboardingOutcomeUnknown(false, false), false); + assert.equal(shouldShowManagedOnboardingOutcomeUnknown(true, true), false); + assert.equal(shouldShowManagedOnboardingOutcomeUnknown(true, false), true); +}); + +test('a late old save cannot clear the uncertainty lease for a newer attempt', async () => { + const saves = [ + deferred<{ kind: 'not_saved' }>(), + deferred<{ kind: 'not_saved' }>(), + ]; + let saveIndex = 0; + const services = createDesktopConnectionSettingsServices(() => ({ + connections: { + saveOnboarding: () => saves[saveIndex++]!.promise, + }, + } as unknown as ReturnType[0]>>)); + const firstView = services.forHost({ + profileId: 'local', + hostId: 'same-host', + }).apiKeyOnboarding; + const oldSave = firstView.save(onboardingSaveInput); + firstView.saveUncertainty.restart(); + + const replacementView = services.forHost({ + profileId: 'local', + hostId: 'same-host', + }).apiKeyOnboarding; + const newSave = replacementView.save(onboardingSaveInput); + saves[0]!.resolve({ kind: 'not_saved' }); + await oldSave; + assert.equal(replacementView.saveUncertainty.getSnapshot(), true); + + saves[1]!.resolve({ kind: 'not_saved' }); + await newSave; + assert.equal(firstView.saveUncertainty.getSnapshot(), false); +}); + +test('save uncertainty is isolated between Runtime Host targets', async () => { + const pending = deferred<{ kind: 'outcome_unknown' }>(); + const services = createDesktopConnectionSettingsServices(() => ({ + connections: { saveOnboarding: () => pending.promise }, + } as unknown as ReturnType[0]>>)); + const firstHost = services.forHost({ profileId: 'local', hostId: 'host-a' }).apiKeyOnboarding; + const secondHost = services.forHost({ profileId: 'local', hostId: 'host-b' }).apiKeyOnboarding; + + const save = firstHost.save(onboardingSaveInput); + + assert.equal(firstHost.saveUncertainty.getSnapshot(), true); + assert.equal(secondHost.saveUncertainty.getSnapshot(), false); + pending.resolve({ kind: 'outcome_unknown' }); + await save; +}); + +test('a definitive save settlement reaches a generation-remounted React consumer', async () => { + const pending = deferred<{ kind: 'not_saved' }>(); + const services = createDesktopConnectionSettingsServices(() => ({ + connections: { saveOnboarding: () => pending.promise }, + } as unknown as ReturnType[0]>>)); + const onboarding = services.forHost({ + profileId: 'local', + hostId: 'same-host', + }).apiKeyOnboarding; + const { root } = installReactRenderer(); + let showsUnknown = false; + + function Probe(props: { + uncertainty: ApiKeyOnboardingBridge['saveUncertainty']; + busy: boolean; + }) { + return createElement( + ConnectionSaveUncertaintyObserver, + { + store: props.uncertainty, + children: (uncertain: boolean) => { + showsUnknown = shouldShowManagedOnboardingOutcomeUnknown(uncertain, props.busy); + return null; + }, + }, + ); + } + + let save!: ReturnType; + await act(async () => { + root.render(createElement(Probe, { + key: 'generation-a', + uncertainty: onboarding.saveUncertainty, + busy: true, + })); + save = onboarding.save(onboardingSaveInput); + }); + assert.equal(onboarding.saveUncertainty.getSnapshot(), true); + assert.equal(showsUnknown, false, 'the form that owns the pending save keeps showing progress'); + + await act(async () => { + root.render(createElement(Probe, { + key: 'generation-b', + uncertainty: onboarding.saveUncertainty, + busy: false, + })); + }); + assert.equal(showsUnknown, true, 'a generation-remounted form fails closed'); + + await act(async () => { + pending.resolve({ kind: 'not_saved' }); + await save; + }); + assert.equal(onboarding.saveUncertainty.getSnapshot(), false); + assert.equal(showsUnknown, false, 'definitive settlement actively restores the remounted form'); +}); + +test('onboarding transport forwards the selected Runtime Host target', async () => { + const calls: unknown[] = []; + const services = createDesktopConnectionSettingsServices(() => ({ + connections: { + verifyOnboarding: async (_input: unknown, host: unknown) => { + calls.push(['verify', host]); + return { kind: 'rejected', reason: 'provider_unsupported' } as const; + }, + saveOnboarding: async (_input: unknown, host: unknown) => { + calls.push(['save', host]); + return { kind: 'rejected', reason: 'provider_unsupported' } as const; + }, + }, + } as unknown as ReturnType[0]>>)); + const host = { profileId: 'remote', hostId: 'host-remote' }; + const onboarding = services.forHost(host).apiKeyOnboarding; + + await onboarding.verify({ + target: { kind: 'create', providerType: 'openai' }, + apiKey: 'test-key', + baseUrl: null, + }); + await onboarding.save({ + target: { kind: 'create', providerType: 'openai' }, + apiKey: 'test-key', + baseUrl: null, + enabledModelIds: ['gpt-5'], + }); + + assert.deepEqual(calls, [ + ['verify', host], + ['save', host], + ]); +}); 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..8f62b36c3d 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,192 @@ 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('fails closed when a dispatched onboarding response cannot be decoded', 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) { + // RuntimeHostConnection surfaces a malformed command response as + // an ordinary protocol error after the frame was dispatched. + throw new Error('Invalid connection onboarding save result'); + } + throw new RuntimeHostOperationError( + 'connection.onboarding.save', + 'invalid_request', + 'The Host definitively rejected the save', + ); + }, + } 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..deeb731da8 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,37 @@ 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) { + return { + kind: error.code === 'commit_outcome_unknown' ? 'outcome_unknown' : 'not_saved', + } satisfies DesktopConnectionOnboardingSaveOutcome; + } + if (error instanceof RuntimeHostRequestInterruptedError) { + return { + kind: error.dispatch === 'not_dispatched' ? 'not_saved' : 'outcome_unknown', + } satisfies DesktopConnectionOnboardingSaveOutcome; + } + // A protocol/decode failure can arrive only after the command response + // has started coming back. Without affirmative evidence that the Host + // did not commit, allowing another create risks duplicating the account. + return { kind: 'outcome_unknown' } 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..2bc4ce1aad 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -354,6 +354,14 @@ export interface DesktopRuntimeHostRef { readonly hostId: string; } +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 +1368,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/application/contracts/connection-error-cleaner.ts b/apps/desktop/src/renderer/application/contracts/connection-error-cleaner.ts new file mode 100644 index 0000000000..03672fa0d0 --- /dev/null +++ b/apps/desktop/src/renderer/application/contracts/connection-error-cleaner.ts @@ -0,0 +1,37 @@ +/* + * 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 { parseNoRealConnectionError } from '@maka/core/connection-error-copy'; + +export { parseNoRealConnectionError }; + +export const NO_REAL_CONNECTION_CODE = 'NO_REAL_CONNECTION'; +const NO_REAL_CONNECTION_REASON_RE = /NO_REAL_CONNECTION:([a-z_]+): /; + +export function cleanErrorMessage(error: unknown): string { + const raw = error instanceof Error ? error.message : String(error); + return cleanEventMessage(raw); +} + +function cleanEventMessage(message: string): string { + return message + .replace(/^Error invoking remote method '[^']+': (?:[A-Za-z_$][\w$]*)?Error: /, '') + .replace(NO_REAL_CONNECTION_REASON_RE, '') + .replace(`${NO_REAL_CONNECTION_CODE}: `, ''); +} diff --git a/apps/desktop/src/renderer/composition/desktop-feature-services.tsx b/apps/desktop/src/renderer/composition/desktop-feature-services.tsx index 47c188d6e6..1e968c0880 100644 --- a/apps/desktop/src/renderer/composition/desktop-feature-services.tsx +++ b/apps/desktop/src/renderer/composition/desktop-feature-services.tsx @@ -18,6 +18,7 @@ */ import type { ReactNode } from 'react'; +import { ConnectionSettingsServicesProvider } from '../features/connection-settings'; import { GoalServicesProvider } from '../features/goals'; import { ModuleHubServicesProvider } from '../features/module-hub'; import { RuntimeHostManagementServicesProvider } from '../features/runtime-host-management'; @@ -26,6 +27,7 @@ import { SessionNavigationServicesProvider } from '../features/session-navigatio import { TaskEntryServicesProvider } from '../features/task-entry'; import { WorkbarServicesProvider } from '../features/workbar'; import { createDesktopGoalServices } from '../platform/desktop/create-goal-services'; +import { createDesktopConnectionSettingsServices } from '../platform/desktop/create-connection-settings-services'; import { createDesktopModuleHubServices } from '../platform/desktop/create-module-hub-services'; import { createDesktopRuntimeHostManagementServices } from '../platform/desktop/create-runtime-host-management-services'; import { createDesktopSessionCollaborationServices } from '../platform/desktop/create-session-collaboration-services'; @@ -35,6 +37,7 @@ import { createDesktopWorkbarServices } from '../platform/desktop/create-workbar export function createDesktopFeatureServices() { return { + connectionSettings: createDesktopConnectionSettingsServices(), goal: createDesktopGoalServices(), moduleHub: createDesktopModuleHubServices(), runtimeHostManagement: createDesktopRuntimeHostManagementServices(), @@ -50,8 +53,9 @@ export function DesktopFeatureServicesProvider(props: { readonly children?: ReactNode; }) { return ( - - + + + @@ -63,7 +67,8 @@ export function DesktopFeatureServicesProvider(props: { - - + + + ); } diff --git a/apps/desktop/src/renderer/features/connection-settings/index.ts b/apps/desktop/src/renderer/features/connection-settings/index.ts new file mode 100644 index 0000000000..c323608c01 --- /dev/null +++ b/apps/desktop/src/renderer/features/connection-settings/index.ts @@ -0,0 +1,42 @@ +/* + * 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. + */ + +export { + ConnectionSettingsServicesProvider, + ConnectionSettingsServicesConsumer, + ConnectionSaveUncertaintyObserver, +} from './services-context.js'; +export type { + ApiKeyOnboardingBridge, + ConnectionSettingsServices, + ConnectionsBridge, + DesktopConnectionOnboardingIdentity, + RuntimeHostSettingsConnectionsBridge, +} from './ports.js'; +export { + categoryLabel, + connectionLastTestMessageDisplay, + connectionTestFailureMessage, + providerPanelActionErrorMessage, +} from './provider-panel-shared.js'; +export { getProviderSettingsCopy } from './settings-provider-copy.js'; +export type { ProviderSettingsCopy } from './settings-provider-copy.js'; +export type { + CredentialPresenceStatus, +} from './provider-panel-shared.js'; diff --git a/apps/desktop/src/renderer/features/connection-settings/ports.ts b/apps/desktop/src/renderer/features/connection-settings/ports.ts new file mode 100644 index 0000000000..d6980a953b --- /dev/null +++ b/apps/desktop/src/renderer/features/connection-settings/ports.ts @@ -0,0 +1,93 @@ +/* + * 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 type { + ConnectionTestResult, + CreateConnectionInput, + IdentifiedLlmConnection, + LlmConnection, + ModelDiscoveryResult, + RequestHeaderUpdate, + SavedRequestHeaders, + UpdateConnectionInput, +} from '@maka/core/llm-connections'; +import type { + ConnectionOnboardingSaveInput, + ConnectionOnboardingSaveResult, + ConnectionOnboardingVerifyInput, + ConnectionOnboardingVerifyResult, +} from '@maka/runtime-host/protocol'; +import type { + DesktopConnectionIdentity, + DesktopConnectionSnapshot, +} from '../../../shared/desktop-connection-snapshot.js'; +export interface ConnectionSettingsHost { + readonly profileId: string; + readonly hostId: string; +} + +export type DesktopConnectionOnboardingIdentity = Extract< + ConnectionOnboardingSaveResult, + { readonly kind: 'saved' } +>['connection']; + +export interface ConnectionsBridge { + getSnapshot(): Promise; + setDefault(connection: DesktopConnectionIdentity | null): Promise; + create(input: CreateConnectionInput): Promise; + update(connection: DesktopConnectionIdentity, patch: UpdateConnectionInput): Promise; + delete(connection: DesktopConnectionIdentity): Promise; + test(connection: DesktopConnectionIdentity, opts?: { model?: string }): Promise; + fetchModels(connection: DesktopConnectionIdentity): Promise< + Pick + >; + hasSecret(connection: DesktopConnectionIdentity): Promise; + getRequestHeaders(connection: DesktopConnectionIdentity): Promise; + setRequestHeaders( + connection: DesktopConnectionIdentity, + headers: readonly RequestHeaderUpdate[], + ): Promise; + subscribeEvents?(handler: () => void): () => void; +} + +export interface RuntimeHostSettingsConnectionsBridge extends ConnectionsBridge { + setDefaultModel(input: { slug: string; model: string } | null): Promise; + subscribeEvents(handler: () => void): () => void; +} + +export interface ApiKeyOnboardingBridge { + readonly saveUncertainty: { + getSnapshot(): boolean; + subscribe(listener: () => void): () => void; + restart(): void; + }; + verify(input: ConnectionOnboardingVerifyInput): Promise; + save(input: ConnectionOnboardingSaveInput): Promise< + | { readonly kind: 'result'; readonly result: ConnectionOnboardingSaveResult } + | { readonly kind: 'not_saved' } + | { readonly kind: 'outcome_unknown' } + >; +} + +export interface ConnectionSettingsServices { + forHost(host: ConnectionSettingsHost): { + readonly connections: RuntimeHostSettingsConnectionsBridge; + readonly apiKeyOnboarding: ApiKeyOnboardingBridge; + }; +} diff --git a/apps/desktop/src/renderer/settings/provider-panel-shared.ts b/apps/desktop/src/renderer/features/connection-settings/provider-panel-shared.ts similarity index 74% rename from apps/desktop/src/renderer/settings/provider-panel-shared.ts rename to apps/desktop/src/renderer/features/connection-settings/provider-panel-shared.ts index 2f1c40df3f..92dc3a9231 100644 --- a/apps/desktop/src/renderer/settings/provider-panel-shared.ts +++ b/apps/desktop/src/renderer/features/connection-settings/provider-panel-shared.ts @@ -20,46 +20,11 @@ import { generalizedErrorMessage, generalizedErrorMessageChinese, redactSecrets } from '@maka/core/redaction'; import { type ConnectionTestResult, - type CreateConnectionInput, - type IdentifiedLlmConnection, - type LlmConnection, - type ModelDiscoveryResult, - type RequestHeaderUpdate, - type SavedRequestHeaders, type ProviderCategory, - type ProviderType, - type UpdateConnectionInput, } from '@maka/core/llm-connections'; import { type UiLocale } from '@maka/core/ui-locale'; -import { getProviderSettingsCopy } from '../locales/settings-provider-copy.js'; -import { cleanErrorMessage } from '../model-connection-errors.js'; -import type { - DesktopConnectionIdentity, - DesktopConnectionSnapshot, -} from '../../shared/desktop-connection-snapshot.js'; - -export interface ConnectionsBridge { - getSnapshot(): Promise; - setDefault(connection: DesktopConnectionIdentity | null): Promise; - create(input: CreateConnectionInput): Promise; - update(connection: DesktopConnectionIdentity, patch: UpdateConnectionInput): Promise; - delete(connection: DesktopConnectionIdentity): Promise; - test(connection: DesktopConnectionIdentity, opts?: { model?: string }): Promise; - /** - * What the discovery run found, minus when it ran: the Host records that - * timestamp for its own invalidation, and no surface here shows it. - */ - fetchModels( - connection: DesktopConnectionIdentity, - ): Promise>; - hasSecret(connection: DesktopConnectionIdentity): Promise; - getRequestHeaders(connection: DesktopConnectionIdentity): Promise; - setRequestHeaders( - connection: DesktopConnectionIdentity, - headers: readonly RequestHeaderUpdate[], - ): Promise; - subscribeEvents?(handler: () => void): () => void; -} +import { getProviderSettingsCopy } from './settings-provider-copy.js'; +import { cleanErrorMessage } from '../../application/contracts/connection-error-cleaner.js'; export type CredentialPresenceStatus = boolean | 'loading' | 'error'; diff --git a/apps/desktop/src/renderer/features/connection-settings/services-context.tsx b/apps/desktop/src/renderer/features/connection-settings/services-context.tsx new file mode 100644 index 0000000000..695fae2dbd --- /dev/null +++ b/apps/desktop/src/renderer/features/connection-settings/services-context.tsx @@ -0,0 +1,61 @@ +/* + * 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 { createContext, useContext, useSyncExternalStore, type ReactNode } from 'react'; +import type { ApiKeyOnboardingBridge, ConnectionSettingsServices } from './ports.js'; + +const ConnectionSettingsServicesContext = createContext(null); + +export function ConnectionSettingsServicesProvider(props: { + readonly services: ConnectionSettingsServices; + readonly children?: ReactNode; +}) { + return ( + + {props.children} + + ); +} + +export function useConnectionSettingsServices(): ConnectionSettingsServices { + const services = useContext(ConnectionSettingsServicesContext); + if (!services) throw new Error('ConnectionSettingsServicesProvider is missing'); + return services; +} + +export function ConnectionSettingsServicesConsumer(props: { + readonly children: (services: ConnectionSettingsServices) => ReactNode; +}) { + return props.children(useConnectionSettingsServices()); +} + +const noSaveUncertainty = () => false; +const subscribeNoSaveUncertainty = () => () => {}; + +export function ConnectionSaveUncertaintyObserver(props: { + readonly store?: ApiKeyOnboardingBridge['saveUncertainty']; + readonly children: (hasSaveUncertainty: boolean) => ReactNode; +}) { + const hasSaveUncertainty = useSyncExternalStore( + props.store?.subscribe ?? subscribeNoSaveUncertainty, + props.store?.getSnapshot ?? noSaveUncertainty, + noSaveUncertainty, + ); + return props.children(hasSaveUncertainty); +} diff --git a/apps/desktop/src/renderer/locales/settings-provider-copy.ts b/apps/desktop/src/renderer/features/connection-settings/settings-provider-copy.ts similarity index 92% rename from apps/desktop/src/renderer/locales/settings-provider-copy.ts rename to apps/desktop/src/renderer/features/connection-settings/settings-provider-copy.ts index c2ad504ded..d46309144e 100644 --- a/apps/desktop/src/renderer/locales/settings-provider-copy.ts +++ b/apps/desktop/src/renderer/features/connection-settings/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/model-connection-errors.ts b/apps/desktop/src/renderer/model-connection-errors.ts index ca0c7550d1..fe6785ba72 100644 --- a/apps/desktop/src/renderer/model-connection-errors.ts +++ b/apps/desktop/src/renderer/model-connection-errors.ts @@ -20,14 +20,14 @@ import type { ChatConfigurationReason } from '@maka/core/connection-readiness'; import type { SessionEvent } from '@maka/core/events'; import type { UiLocale } from '@maka/core/ui-locale'; -import { parseNoRealConnectionError } from '@maka/core/connection-error-copy'; +import { + NO_REAL_CONNECTION_CODE, + parseNoRealConnectionError, +} from './application/contracts/connection-error-cleaner.js'; import { getDesktopConversationCopy } from './locales/conversation-copy.js'; import { localizedShellErrorMessage } from './locales/shell-copy.js'; import { describeSessionErrorReason } from './session-error-presentation.js'; -const NO_REAL_CONNECTION_CODE = 'NO_REAL_CONNECTION'; -const NO_REAL_CONNECTION_REASON_RE = /NO_REAL_CONNECTION:([a-z_]+): /; - export function isNoRealConnectionError(error: unknown): boolean { return parseNoRealConnectionError(error).matched; } @@ -73,27 +73,6 @@ export function sessionEventErrorMessage( return localizedShellErrorMessage(new Error(event.message), fallback, locale); } -/** - * Canonical raw-error cleaner: strips the Electron IPC wrapper so error - * classifiers see the main-process message, not the channel name (which can - * contain classifier keywords, e.g. the "fetch" in 'connections:fetchModels'). - * Raw output must not reach a toast unclassified — see - * providerPanelActionErrorMessage for the display-side contract. - */ -export function cleanErrorMessage(error: unknown): string { - const raw = error instanceof Error ? error.message : String(error); - return cleanEventMessage(raw); -} - -export function cleanEventMessage(message: string): string { - // Electron serializes a rejected handler as `${error.name}: ${error.message}`, - // so custom classes arrive as e.g. "ConnectionModelDiscoveryPreconditionError: …". - return message - .replace(/^Error invoking remote method '[^']+': (?:[A-Za-z_$][\w$]*)?Error: /, '') - .replace(NO_REAL_CONNECTION_REASON_RE, '') - .replace(`${NO_REAL_CONNECTION_CODE}: `, ''); -} - export function modelSetupToastCopy( reason: string | undefined, fallback: string, diff --git a/apps/desktop/src/renderer/platform/desktop/create-connection-settings-services.ts b/apps/desktop/src/renderer/platform/desktop/create-connection-settings-services.ts new file mode 100644 index 0000000000..781261546b --- /dev/null +++ b/apps/desktop/src/renderer/platform/desktop/create-connection-settings-services.ts @@ -0,0 +1,88 @@ +/* + * 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 type { MakaBridge } from '../../../preload/bridge-contract.js'; +import type { ConnectionSettingsServices } from '../../features/connection-settings'; + +export function createDesktopConnectionSettingsServices( + bridge: () => Pick = () => window.maka, +): ConnectionSettingsServices { + const uncertainTargets = new Map(); + const uncertaintyListeners = new Map void>>(); + let nextAttemptId = 1; + + const notifyUncertaintyChanged = (targetKey: string) => { + for (const listener of [...(uncertaintyListeners.get(targetKey) ?? [])]) listener(); + }; + + return { + forHost: (host) => { + const targetKey = `${host.profileId}\u0000${host.hostId}`; + return { + connections: { + getSnapshot: () => bridge().connections.getSnapshot(undefined, host), + setDefault: (connection) => bridge().connections.setDefault(connection, host), + setDefaultModel: (input) => bridge().connections.setDefaultModel(input, host), + create: (input) => bridge().connections.create(input, host), + update: (connection, patch) => bridge().connections.update(connection, patch, host), + delete: (connection) => bridge().connections.delete(connection, host), + test: (connection, options) => bridge().connections.test(connection, options, host), + fetchModels: (connection) => bridge().connections.fetchModels(connection, host), + hasSecret: (connection) => bridge().connections.hasSecret(connection, host), + getRequestHeaders: (connection) => bridge().connections.getRequestHeaders(connection, host), + setRequestHeaders: (connection, headers) => + bridge().connections.setRequestHeaders(connection, headers, host), + subscribeEvents: (handler) => bridge().connections.subscribeEvents(handler, host), + }, + apiKeyOnboarding: { + saveUncertainty: { + getSnapshot: () => uncertainTargets.has(targetKey), + subscribe: (listener) => { + const listeners = uncertaintyListeners.get(targetKey) ?? new Set<() => void>(); + listeners.add(listener); + uncertaintyListeners.set(targetKey, listeners); + return () => { + listeners.delete(listener); + if (listeners.size === 0) uncertaintyListeners.delete(targetKey); + }; + }, + restart: () => { + if (uncertainTargets.delete(targetKey)) notifyUncertaintyChanged(targetKey); + }, + }, + verify: (input) => bridge().connections.verifyOnboarding(input, host), + save: async (input) => { + const attemptId = nextAttemptId++; + const wasUncertain = uncertainTargets.has(targetKey); + uncertainTargets.set(targetKey, attemptId); + if (!wasUncertain) notifyUncertaintyChanged(targetKey); + const outcome = await bridge().connections.saveOnboarding(input, host); + if (outcome.kind !== 'outcome_unknown') { + if (uncertainTargets.get(targetKey) === attemptId) { + uncertainTargets.delete(targetKey); + notifyUncertaintyChanged(targetKey); + } + } + return outcome; + }, + }, + }; + }, + }; +} diff --git a/apps/desktop/src/renderer/settings/general-settings-page.tsx b/apps/desktop/src/renderer/settings/general-settings-page.tsx index 62498c493b..42b0a0dd17 100644 --- a/apps/desktop/src/renderer/settings/general-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/general-settings-page.tsx @@ -66,7 +66,7 @@ import { useOptimisticSettingsDraft } from "./use-optimistic-settings-draft"; import { getSettingsPreferencesCopy } from "../locales/settings-preferences-copy.js"; import { settingsTestResultMessage } from "../locales/settings-test-result-copy.js"; import { getShellCopy } from "../locales/shell-copy.js"; -import type { RuntimeHostSettingsConnectionsBridge } from './runtime-host-settings-bridge.js'; +import type { RuntimeHostSettingsConnectionsBridge } from '../features/connection-settings'; import { getSettingsSharedCopy } from '../locales/settings-shared-copy.js'; import { useOptionalRuntimeHostSettingsTarget, diff --git a/apps/desktop/src/renderer/settings/provider-add-form.tsx b/apps/desktop/src/renderer/settings/provider-add-form.tsx index a287f0180f..78b0ecc6de 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, @@ -40,10 +40,12 @@ import { providerDisplay } from './provider-display'; import { useActionGuard } from './use-action-guard'; import { categoryLabel, + getProviderSettingsCopy, providerPanelActionErrorMessage, + type ApiKeyOnboardingBridge, type ConnectionsBridge, -} from './provider-panel-shared'; -import { getProviderSettingsCopy } from '../locales/settings-provider-copy'; + type DesktopConnectionOnboardingIdentity, +} from '../features/connection-settings'; import { newRequestHeaders, parseRequestBodyOverlay, @@ -52,6 +54,10 @@ import { } from './request-customization-editor'; import { createProviderWithDiscovery, + apiKeyOnboardingRoute, + initialOnboardingModelIds, + shouldShowManagedOnboardingOutcomeUnknown, + stableOnboardingModels, validateAddProviderDraft, type AddProviderIssue, } from './provider-add-submission'; @@ -67,12 +73,24 @@ type ProviderFormError = { message: string; }; +type ManagedOnboardingPhase = + | { readonly kind: 'input' } + | { + readonly kind: 'models'; + readonly models: ReturnType; + readonly selectedIds: readonly string[]; + }; + 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; + hasSaveUncertainty?: boolean; }) { const locale = useUiLocale(); const copy = getProviderSettingsCopy(locale).add; @@ -90,7 +108,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: 'input' }, + error: null, + })); + const { managedPhase, error } = formState; const [busy, setBusy] = useState(false); const submitGuard = useActionGuard<'submit'>(); const addProviderMountedRef = useMountedRef(); @@ -103,6 +128,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 +172,133 @@ 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; + } + + 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'); + setBusy(true); + try { + const outcome = await onboarding.save({ + target: { kind: 'create', providerType: props.providerType }, + apiKey: normalizedApiKey || null, + baseUrl: null, + enabledModelIds: stableIds, + }); + if (!addProviderMountedRef.current) return; + if (outcome.kind === 'outcome_unknown') { + setApiKey(''); + 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) setApiKey(''); + } finally { + submitGuard.finish(); + if (addProviderMountedRef.current) 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 +311,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 +389,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 +417,88 @@ 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 && + shouldShowManagedOnboardingOutcomeUnknown(props.hasSaveUncertainty === true, busy) + ) { + return ( + + + +