You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by zh...@apache.org on 2026/09/01 15:41:19 UTC
(maka) branch main updated: feat(desktop): route standard API key onboarding through Runtime Host (#4377)
This is an automated email from the ASF dual-hosted git repository.
me2seeks pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/maka.git
The following commit(s) were added to refs/heads/main by this push:
new 0571d7b379 feat(desktop): route standard API key onboarding through Runtime Host (#4377)
0571d7b379 is described below
commit 0571d7b379d21b3a1dec411d6870ed1bc2a09e26
Author: Zhang <96...@users.noreply.github.com>
AuthorDate: Tue Sep 1 23:41:13 2026 +0800
feat(desktop): route standard API key onboarding through Runtime Host (#4377)
* feat(desktop): route standard API key onboarding through host
* feat(desktop): add host-backed API key enrollment flow
* refactor(desktop): move connection settings behind feature adapter
* docs(desktop): refresh Astryx surface inventory
* refactor(desktop): narrow connection settings exports
* fix(desktop): make connection settings entry ESM-safe
* chore(desktop): refresh renderer architecture ledger
* fix(desktop): observe onboarding save uncertainty
* fix(desktop): fail closed on onboarding response errors
* fix(desktop): rebase onboarding on host model catalog
---
.../e2e/request-header-row-contract.spec.ts | 2 +-
apps/desktop/renderer-architecture.json | 76 +----
.../main/__tests__/provider-add-submission.test.ts | 218 +++++++++++++-
.../runtime-host-connections-ipc-main.test.ts | 187 ++++++++++++
apps/desktop/src/main/runtime-host-client.ts | 12 +
.../src/main/runtime-host-connections-ipc-main.ts | 43 ++-
apps/desktop/src/preload/bridge-contract.d.ts | 16 +
apps/desktop/src/preload/preload.ts | 6 +
.../contracts/connection-error-cleaner.ts | 37 +++
.../composition/desktop-feature-services.tsx | 13 +-
.../renderer/features/connection-settings/index.ts | 42 +++
.../renderer/features/connection-settings/ports.ts | 93 ++++++
.../connection-settings}/provider-panel-shared.ts | 39 +--
.../connection-settings/services-context.tsx | 61 ++++
.../connection-settings}/settings-provider-copy.ts | 12 +-
.../src/renderer/model-connection-errors.ts | 29 +-
.../desktop/create-connection-settings-services.ts | 88 ++++++
.../renderer/settings/general-settings-page.tsx | 2 +-
.../src/renderer/settings/provider-add-form.tsx | 331 ++++++++++++++++++++-
.../settings/provider-add-model-dialog.tsx | 2 +-
.../renderer/settings/provider-add-submission.ts | 55 ++++
.../renderer/settings/provider-catalog-page.tsx | 16 +-
.../settings/provider-connection-detail.tsx | 4 +-
.../settings/provider-connection-status.ts | 2 +-
.../settings/provider-enabled-model-manager.tsx | 2 +-
.../renderer/settings/provider-oauth-section.tsx | 2 +-
.../src/renderer/settings/providers-panel.tsx | 149 ++++++++--
.../settings/runtime-host-settings-bridge.ts | 26 --
.../src/renderer/settings/settings-surface.tsx | 41 ++-
.../src/renderer/settings/use-connection-detail.ts | 4 +-
.../src/renderer/settings/use-oauth-login-flow.ts | 2 +-
.../stories/settings/provider-settings.stories.tsx | 323 +++++++++++++++++++-
.../stories/settings/settings-pages.stories.tsx | 49 +--
docs/astryx-surface-file-inventory.md | 5 +-
docs/astryx-surface-file-inventory.paths | 1 +
35 files changed, 1733 insertions(+), 257 deletions(-)
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<T>() {
+ let resolve!: (value: T) => void;
+ const promise = new Promise<T>((accept) => {
+ resolve = accept;
+ });
+ return { promise, resolve };
+}
+
+const onboardingSaveInput: Parameters<ApiKeyOnboardingBridge['save']>[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> = {}): 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<NonNullable<Parameters<typeof createDesktopConnectionSettingsServices>[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<NonNullable<Parameters<typeof createDesktopConnectionSettingsServices>[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<NonNullable<Parameters<typeof createDesktopConnectionSettingsServices>[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<ApiKeyOnboardingBridge['save']>;
+ 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<NonNullable<Parameters<typeof createDesktopConnectionSettingsServices>[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<string, (...args: unknown[]) => 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<string, (...args: unknown[]) => 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<string, (...args: unknown[]) => 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<string, (...args: unknown[]) => 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<string, (...args: unknown[]) => 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<OperationOutput<"connection.onboarding.verify">> {
+ return this.request("connection.onboarding.verify", input);
+ }
+
+ saveConnectionOnboarding(
+ input: OperationInput<"connection.onboarding.save">,
+ ): Promise<OperationOutput<"connection.onboarding.save">> {
+ 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<void>;
setDefaultModel(input: { slug: string; model: string } | null, host?: DesktopRuntimeHostRef): Promise<void>;
create(input: CreateConnectionInput, host?: DesktopRuntimeHostRef): Promise<import('@maka/core/llm-connections').IdentifiedLlmConnection>;
+ verifyOnboarding(
+ input: OperationInput<'connection.onboarding.verify'>,
+ host?: DesktopRuntimeHostRef,
+ ): Promise<OperationOutput<'connection.onboarding.verify'>>;
+ saveOnboarding(
+ input: OperationInput<'connection.onboarding.save'>,
+ host?: DesktopRuntimeHostRef,
+ ): Promise<DesktopConnectionOnboardingSaveOutcome>;
update(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity, patch: UpdateConnectionInput, host?: DesktopRuntimeHostRef): Promise<LlmConnection>;
delete(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity, host?: DesktopRuntimeHostRef): Promise<void>;
test(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity | string, opts?: { model?: string }, host?: DesktopRuntimeHostRef): Promise<ConnectionTestResult>;
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<import('@maka/core/llm-connections').IdentifiedLlmConnection> {
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<LlmConnection> {
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 (
- <RuntimeHostManagementServicesProvider services={props.services.runtimeHostManagement}>
- <SessionCollaborationServicesProvider services={props.services.sessionCollaboration}>
+ <ConnectionSettingsServicesProvider services={props.services.connectionSettings}>
+ <RuntimeHostManagementServicesProvider services={props.services.runtimeHostManagement}>
+ <SessionCollaborationServicesProvider services={props.services.sessionCollaboration}>
<SessionNavigationServicesProvider services={props.services.sessionNavigation}>
<TaskEntryServicesProvider services={props.services.taskEntry}>
<ModuleHubServicesProvider services={props.services.moduleHub}>
@@ -63,7 +67,8 @@ export function DesktopFeatureServicesProvider(props: {
</ModuleHubServicesProvider>
</TaskEntryServicesProvider>
</SessionNavigationServicesProvider>
- </SessionCollaborationServicesProvider>
- </RuntimeHostManagementServicesProvider>
+ </SessionCollaborationServicesProvider>
+ </RuntimeHostManagementServicesProvider>
+ </ConnectionSettingsServicesProvider>
);
}
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<DesktopConnectionSnapshot>;
+ setDefault(connection: DesktopConnectionIdentity | null): Promise<void>;
+ create(input: CreateConnectionInput): Promise<IdentifiedLlmConnection>;
+ update(connection: DesktopConnectionIdentity, patch: UpdateConnectionInput): Promise<LlmConnection>;
+ delete(connection: DesktopConnectionIdentity): Promise<void>;
+ test(connection: DesktopConnectionIdentity, opts?: { model?: string }): Promise<ConnectionTestResult>;
+ fetchModels(connection: DesktopConnectionIdentity): Promise<
+ Pick<ModelDiscoveryResult, 'models' | 'source'>
+ >;
+ hasSecret(connection: DesktopConnectionIdentity): Promise<boolean>;
+ getRequestHeaders(connection: DesktopConnectionIdentity): Promise<SavedRequestHeaders>;
+ setRequestHeaders(
+ connection: DesktopConnectionIdentity,
+ headers: readonly RequestHeaderUpdate[],
+ ): Promise<SavedRequestHeaders>;
+ subscribeEvents?(handler: () => void): () => void;
+}
+
+export interface RuntimeHostSettingsConnectionsBridge extends ConnectionsBridge {
+ setDefaultModel(input: { slug: string; model: string } | null): Promise<void>;
+ subscribeEvents(handler: () => void): () => void;
+}
+
+export interface ApiKeyOnboardingBridge {
+ readonly saveUncertainty: {
+ getSnapshot(): boolean;
+ subscribe(listener: () => void): () => void;
+ restart(): void;
+ };
+ verify(input: ConnectionOnboardingVerifyInput): Promise<ConnectionOnboardingVerifyResult>;
+ 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<DesktopConnectionSnapshot>;
- setDefault(connection: DesktopConnectionIdentity | null): Promise<void>;
- create(input: CreateConnectionInput): Promise<IdentifiedLlmConnection>;
- update(connection: DesktopConnectionIdentity, patch: UpdateConnectionInput): Promise<LlmConnection>;
- delete(connection: DesktopConnectionIdentity): Promise<void>;
- test(connection: DesktopConnectionIdentity, opts?: { model?: string }): Promise<ConnectionTestResult>;
- /**
- * 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<Pick<ModelDiscoveryResult, 'models' | 'source'>>;
- hasSecret(connection: DesktopConnectionIdentity): Promise<boolean>;
- getRequestHeaders(connection: DesktopConnectionIdentity): Promise<SavedRequestHeaders>;
- setRequestHeaders(
- connection: DesktopConnectionIdentity,
- headers: readonly RequestHeaderUpdate[],
- ): Promise<SavedRequestHeaders>;
- 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<ConnectionSettingsServices | null>(null);
+
+export function ConnectionSettingsServicesProvider(props: {
+ readonly services: ConnectionSettingsServices;
+ readonly children?: ReactNode;
+}) {
+ return (
+ <ConnectionSettingsServicesContext.Provider value={props.services}>
+ {props.children}
+ </ConnectionSettingsServicesContext.Provider>
+ );
+}
+
+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 con [...]
+ 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<MakaBridge, 'connections'> = () => window.maka,
+): ConnectionSettingsServices {
+ const uncertainTargets = new Map<string, number>();
+ const uncertaintyListeners = new Map<string, Set<() => 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<typeof stableOnboardingModels>;
+ readonly selectedIds: readonly string[];
+ };
+
export function AddProviderForm(props: {
bridge: ConnectionsBridge;
+ apiKeyOnboardingBridge?: ApiKeyOnboardingBridge;
providerType: ProviderType;
existingSlugs: string[];
onCancel(): void;
onCreated(slug: string, modelDiscoveryError?: unknown): Promise<void>;
+ onOnboarded?(identity: DesktopConnectionOnboardingIdentity): Promise<void>;
+ onOnboardingOutcomeUnknown?(): Promise<void>;
+ hasSaveUncertainty?: boolean;
}) {
const locale = useUiLocale();
const copy = getProviderSettingsCopy(locale).add;
@@ -90,7 +108,14 @@ export function AddProviderForm(props: {
const [requestHeaders, setRequestHeaders] = useState<RequestHeaderDraft[]>([]);
const [requestBodyText, setRequestBodyText] = useState('');
const [advancedOpen, setAdvancedOpen] = useState(false);
- const [error, setError] = useState<ProviderFormError | null>(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<Awaited<ReturnType<ApiKeyOnboardingBridge['verify']>>, { kind: 'verified' }>
+ | Exclude<
+ Extract<Awaited<ReturnType<ApiKeyOnboardingBridge['save']>>, { 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<ManagedOnboardingPhase, { kind: 'models' }>,
+ ) {
+ 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: {
</VStack>
</Collapsible>
);
+ 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 (
+ <VStack gap={3} data-maka-contract="api-key-onboarding-outcome-unknown">
+ <Banner
+ status="warning"
+ role="status"
+ title={copy.onboardingOutcomeUnknown}
+ description={copy.onboardingOutcomeUnknownDetail}
+ />
+ <HStack gap={2} justify="end">
+ <Button
+ variant="secondary"
+ label={copy.onboardingReloadConnections}
+ clickAction={async () => props.onOnboardingOutcomeUnknown?.()}
+ />
+ </HStack>
+ </VStack>
+ );
+ }
+
+ if (usesApiKeyDialog && managedPhase.kind === 'models') {
+ const options = managedPhase.models.map((model) => ({
+ value: model.id,
+ label: model.displayName?.trim() || model.id,
+ }));
+ return (
+ <VStack as="form" gap={3} onSubmit={submitApiKey} data-maka-contract="api-key-onboarding-models">
+ <VStack gap={1}>
+ <Text weight="semibold">{copy.onboardingChooseModels}</Text>
+ <Text type="supporting" color="secondary">{copy.onboardingChooseModelsHelp}</Text>
+ </VStack>
+ <MultiSelector
+ label={copy.onboardingEnabledModels}
+ options={options}
+ value={[...managedPhase.selectedIds]}
+ onChange={(selectedIds) => {
+ setManagedPhase({ ...managedPhase, selectedIds });
+ clearFieldError('form');
+ }}
+ isDisabled={busy}
+ placeholder={copy.onboardingSelectModel}
+ triggerDisplay="labels"
+ hasSearch
+ searchPlaceholder={copy.onboardingSearchModels}
+ width="100%"
+ />
+ <div role="status" aria-live="polite">
+ {busy ? <Text type="supporting">{copy.saving}</Text> : null}
+ </div>
+ {error?.field === 'form' && <Banner status="error" title={error.message} />}
+ <HStack gap={2} justify="end">
+ <Button
+ variant="ghost"
+ isDisabled={busy}
+ onClick={() => {
+ resetManagedVerification();
+ setError(null);
+ }}
+ label={copy.onboardingBack}
+ />
+ <Button
+ variant="primary"
+ type="submit"
+ isDisabled={busy || managedPhase.selectedIds.length === 0}
+ label={busy ? copy.saving : copy.onboardingAddConnection}
+ />
+ </HStack>
+ </VStack>
+ );
+ }
if (usesApiKeyDialog) {
return (
@@ -237,6 +507,7 @@ export function AddProviderForm(props: {
value={apiKey}
onChange={(next) => {
setApiKey(next);
+ resetManagedVerification();
clearFieldError('apiKey');
}}
placeholder={copy.apiKeyPlaceholder}
@@ -252,12 +523,30 @@ export function AddProviderForm(props: {
hasAutoFocus
/>
{advancedRequestEditor}
+ <div role="status" aria-live="polite">
+ {busy ? (
+ <Text type="supporting">
+ {quickUsesManagedOnboarding ? copy.onboardingVerifying : copy.saving}
+ </Text>
+ ) : null}
+ </div>
{error?.field === 'form' && (
<Banner status="error" title={error.message} />
)}
<HStack gap={2} justify="end">
<Button variant="ghost" isDisabled={busy} onClick={props.onCancel} label={copy.cancel} />
- <Button variant="primary" type="submit" isDisabled={busy} label={busy ? copy.saving : copy.save} />
+ <Button
+ variant="primary"
+ type="submit"
+ isDisabled={busy}
+ label={quickUsesManagedOnboarding
+ ? busy
+ ? copy.onboardingVerifying
+ : copy.onboardingVerifyAndChoose
+ : busy
+ ? copy.saving
+ : copy.save}
+ />
</HStack>
</VStack>
);
@@ -277,6 +566,7 @@ export function AddProviderForm(props: {
value={apiKey}
onChange={(next) => {
setApiKey(next);
+ resetManagedVerification();
clearFieldError('apiKey');
}}
placeholder={copy.apiKeyPlaceholder}
@@ -295,6 +585,7 @@ export function AddProviderForm(props: {
value={slug}
onChange={(value) => {
setSlug(value);
+ resetManagedVerification();
clearFieldError('slug');
}}
placeholder="my-provider"
@@ -308,7 +599,10 @@ export function AddProviderForm(props: {
/>
<TextInput
value={name}
- onChange={(value) => setName(value)}
+ onChange={(value) => {
+ setName(value);
+ resetManagedVerification();
+ }}
placeholder={display.name}
isDisabled={isExperimental || busy}
label={copy.name}
@@ -318,6 +612,7 @@ export function AddProviderForm(props: {
value={cloudflareAccountId}
onChange={(value) => {
setCloudflareAccountId(value);
+ resetManagedVerification();
clearFieldError('accountId');
}}
placeholder={copy.accountIdPlaceholder}
@@ -335,6 +630,7 @@ export function AddProviderForm(props: {
value={baseUrl}
onChange={(value) => {
setBaseUrl(value);
+ resetManagedVerification();
clearFieldError('baseUrl');
}}
placeholder={defaults.baseUrl || 'https://…'}
@@ -351,7 +647,10 @@ export function AddProviderForm(props: {
{showsDefaultModel && (
<TextInput
value={defaultModel}
- onChange={setDefaultModel}
+ onChange={(value) => {
+ setDefaultModel(value);
+ resetManagedVerification();
+ }}
placeholder={copy.defaultModelPlaceholder}
isDisabled={isExperimental || busy}
label={copy.defaultModel}
diff --git a/apps/desktop/src/renderer/settings/provider-add-model-dialog.tsx b/apps/desktop/src/renderer/settings/provider-add-model-dialog.tsx
index 794ee915f1..85ae5c35fc 100644
--- a/apps/desktop/src/renderer/settings/provider-add-model-dialog.tsx
+++ b/apps/desktop/src/renderer/settings/provider-add-model-dialog.tsx
@@ -22,7 +22,7 @@ import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
import { FormLayout } from '@astryxdesign/core/FormLayout';
import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
import { Button, HStack, NumberInput, TextInput, useUiLocale } from '@maka/ui';
-import { getProviderSettingsCopy } from '../locales/settings-provider-copy';
+import { getProviderSettingsCopy } from '../features/connection-settings';
/**
* Introduce a model by exact id, for a provider whose catalog cannot grow on
diff --git a/apps/desktop/src/renderer/settings/provider-add-submission.ts b/apps/desktop/src/renderer/settings/provider-add-submission.ts
index c82bbdca2b..0006ff54fe 100644
--- a/apps/desktop/src/renderer/settings/provider-add-submission.ts
+++ b/apps/desktop/src/renderer/settings/provider-add-submission.ts
@@ -23,9 +23,64 @@ 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 shouldShowManagedOnboardingOutcomeUnknown(
+ hasSaveUncertainty: boolean,
+ busy: boolean,
+): boolean {
+ return hasSaveUncertainty && !busy;
+}
+
+/** 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_REGISTRY[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-catalog-page.tsx b/apps/desktop/src/renderer/settings/provider-catalog-page.tsx
index 34d2782500..10e7346cc0 100644
--- a/apps/desktop/src/renderer/settings/provider-catalog-page.tsx
+++ b/apps/desktop/src/renderer/settings/provider-catalog-page.tsx
@@ -39,8 +39,12 @@ import { Button, TextInput, useUiLocale } from '@maka/ui';
import { AddProviderForm } from './provider-add-form';
import { ProviderLogo, providerDisplay } from './provider-display';
import { OAuthLoginPanel, useOAuthCards, type OAuthCardId } from './provider-oauth-section';
-import { type ConnectionsBridge } from './provider-panel-shared';
-import { getProviderSettingsCopy } from '../locales/settings-provider-copy';
+import {
+ getProviderSettingsCopy,
+ type ApiKeyOnboardingBridge,
+ type ConnectionsBridge,
+ type DesktopConnectionOnboardingIdentity,
+} from '../features/connection-settings';
type CatalogCategory = ProviderCatalogGroup | 'all' | 'recommended' | 'accounts';
@@ -225,10 +229,14 @@ export function ProviderCatalogPage(props: {
*/
export function ProviderSetupPage(props: {
bridge: ConnectionsBridge;
+ apiKeyOnboardingBridge?: ApiKeyOnboardingBridge;
target: SetupTarget;
existingSlugs: string[];
onCancel(): void;
onCreated(slug: string, modelDiscoveryError?: unknown): Promise<void>;
+ onOnboarded?(identity: DesktopConnectionOnboardingIdentity): Promise<void>;
+ onOnboardingOutcomeUnknown?(): Promise<void>;
+ hasSaveUncertainty?: boolean;
onAccountCreated(connection?: CreatedOAuthConnectionIdentity): Promise<void>;
labelledBy?: string;
}) {
@@ -244,10 +252,14 @@ export function ProviderSetupPage(props: {
<AddProviderForm
key={props.target.providerType}
bridge={props.bridge}
+ apiKeyOnboardingBridge={props.apiKeyOnboardingBridge}
providerType={props.target.providerType}
existingSlugs={props.existingSlugs}
onCancel={props.onCancel}
onCreated={props.onCreated}
+ onOnboarded={props.onOnboarded}
+ onOnboardingOutcomeUnknown={props.onOnboardingOutcomeUnknown}
+ hasSaveUncertainty={props.hasSaveUncertainty}
/>
</div>
);
diff --git a/apps/desktop/src/renderer/settings/provider-connection-detail.tsx b/apps/desktop/src/renderer/settings/provider-connection-detail.tsx
index 504478186d..3c78609a31 100644
--- a/apps/desktop/src/renderer/settings/provider-connection-detail.tsx
+++ b/apps/desktop/src/renderer/settings/provider-connection-detail.tsx
@@ -52,7 +52,6 @@ import {
import { PasswordInput } from './password-input';
import { SettingsExpandableRow } from './settings-expandable-row';
import { SettingsRow } from './settings-section';
-import { getProviderSettingsCopy } from '../locales/settings-provider-copy';
import { providerDisplay } from './provider-display';
import { AddModelDialog } from './provider-add-model-dialog';
import { EnabledModelManager } from './provider-enabled-model-manager';
@@ -64,9 +63,10 @@ import {
} from './runtime-host-settings-target.js';
import { useOAuthLoginFlow } from './use-oauth-login-flow';
import {
+ getProviderSettingsCopy,
providerPanelActionErrorMessage,
type CredentialPresenceStatus,
-} from './provider-panel-shared';
+} from '../features/connection-settings';
import type { StatusSemantic } from '@maka/ui';
import {
useConnectionDetail,
diff --git a/apps/desktop/src/renderer/settings/provider-connection-status.ts b/apps/desktop/src/renderer/settings/provider-connection-status.ts
index 1fbb461a5c..b74f2c3bab 100644
--- a/apps/desktop/src/renderer/settings/provider-connection-status.ts
+++ b/apps/desktop/src/renderer/settings/provider-connection-status.ts
@@ -26,7 +26,7 @@ import type { LlmConnection } from '@maka/core/llm-connections';
import { isRetiredProvider } from '@maka/core/provider-registry';
import type { UiLocale } from '@maka/core/ui-locale';
-import { getProviderSettingsCopy } from '../locales/settings-provider-copy.js';
+import { getProviderSettingsCopy } from '../features/connection-settings/index.js';
import type { StatusSemantic } from '@maka/ui';
export interface ConnectionChipStatus {
diff --git a/apps/desktop/src/renderer/settings/provider-enabled-model-manager.tsx b/apps/desktop/src/renderer/settings/provider-enabled-model-manager.tsx
index 4ae778fd61..fa8776d9ae 100644
--- a/apps/desktop/src/renderer/settings/provider-enabled-model-manager.tsx
+++ b/apps/desktop/src/renderer/settings/provider-enabled-model-manager.tsx
@@ -20,7 +20,7 @@
import { MultiSelector } from '@astryxdesign/core';
import type { ModelCatalogEntry } from '@maka/core/model-catalog';
import { useUiLocale } from '@maka/ui';
-import { getProviderSettingsCopy } from '../locales/settings-provider-copy';
+import { getProviderSettingsCopy } from '../features/connection-settings';
/**
* Adapts Maka's model catalog to Astryx's multi-select field.
diff --git a/apps/desktop/src/renderer/settings/provider-oauth-section.tsx b/apps/desktop/src/renderer/settings/provider-oauth-section.tsx
index d1eb34de33..781de7e3a3 100644
--- a/apps/desktop/src/renderer/settings/provider-oauth-section.tsx
+++ b/apps/desktop/src/renderer/settings/provider-oauth-section.tsx
@@ -27,7 +27,7 @@ import {
useMountedRef,
useUiLocale,
} from '@maka/ui';
-import { getProviderSettingsCopy, type ProviderSettingsCopy } from '../locales/settings-provider-copy';
+import { getProviderSettingsCopy, type ProviderSettingsCopy } from '../features/connection-settings';
import {
useOAuthLoginFlow,
subscriptionActionErrorMessage,
diff --git a/apps/desktop/src/renderer/settings/providers-panel.tsx b/apps/desktop/src/renderer/settings/providers-panel.tsx
index b4d7767b9f..ffa68347b0 100644
--- a/apps/desktop/src/renderer/settings/providers-panel.tsx
+++ b/apps/desktop/src/renderer/settings/providers-panel.tsx
@@ -55,11 +55,20 @@ import { useSettingsRouteFocus } from './settings-route-focus';
import { SettingsRouteHeader } from './settings-route-header';
import { ProviderLogo, providerDisplay } from './provider-display';
import { oauthPanelSubtitle } from './provider-oauth-section';
-import { providerPanelActionErrorMessage, type ConnectionsBridge } from './provider-panel-shared';
-import { getProviderSettingsCopy } from '../locales/settings-provider-copy';
-import { useRuntimeHostSettingsErrorReporter } from './runtime-host-settings-target.js';
+import {
+ getProviderSettingsCopy,
+ providerPanelActionErrorMessage,
+ ConnectionSaveUncertaintyObserver,
+ type ApiKeyOnboardingBridge,
+ type ConnectionsBridge,
+ type DesktopConnectionOnboardingIdentity,
+} from '../features/connection-settings';
+import {
+ RuntimeHostSettingsGenerationBoundary,
+ useRuntimeHostSettingsErrorReporter,
+} from './runtime-host-settings-target.js';
-export type { ConnectionsBridge } from './provider-panel-shared';
+export type { ConnectionsBridge } from '../features/connection-settings';
/**
* Where the panel is. Four levels, one container, one back affordance:
@@ -81,8 +90,15 @@ export type { ConnectionsBridge } from './provider-panel-shared';
type PanelRoute =
| { kind: 'list' }
| { kind: 'catalog' }
- | { kind: 'setup'; target: SetupTarget; origin: 'list' | 'catalog' }
- | { kind: 'adopting-oauth'; identity: CreatedOAuthConnectionIdentity }
+ | {
+ kind: 'setup';
+ target: SetupTarget;
+ origin: 'list' | 'catalog';
+ }
+ | {
+ kind: 'adopting-connection';
+ identity: DesktopConnectionOnboardingIdentity | CreatedOAuthConnectionIdentity;
+ }
| { kind: 'detail'; connectionId: string };
function backTarget(route: PanelRoute): PanelRoute {
@@ -90,8 +106,9 @@ function backTarget(route: PanelRoute): PanelRoute {
return { kind: 'list' };
}
-export function ProvidersPanel({ bridge, initialPage = 'connections', initialConnectionSlug, initialCreateProviderType, onInitialCatalogConsumed, onInitialCreateProviderConsumed }: {
+type ProvidersPanelProps = {
bridge: ConnectionsBridge;
+ apiKeyOnboardingBridge?: ApiKeyOnboardingBridge;
initialPage?: 'connections' | 'catalog';
/**
* When set, open this connection's detail once the list has loaded.
@@ -107,6 +124,23 @@ export function ProvidersPanel({ bridge, initialPage = 'connections', initialCon
onInitialCatalogConsumed?: () => void;
/** Called once the setup level has been entered. */
onInitialCreateProviderConsumed?: () => void;
+};
+
+export function ProvidersPanel(props: ProvidersPanelProps) {
+ return (
+ <ConnectionSaveUncertaintyObserver store={props.apiKeyOnboardingBridge?.saveUncertainty}>
+ {(hasOnboardingUncertainty) => (
+ <ProvidersPanelContent
+ {...props}
+ hasOnboardingUncertainty={hasOnboardingUncertainty}
+ />
+ )}
+ </ConnectionSaveUncertaintyObserver>
+ );
+}
+
+function ProvidersPanelContent({ bridge, apiKeyOnboardingBridge, initialPage = 'connections', initialConnectionSlug, initialCreateProviderType, onInitialCatalogConsumed, onInitialCreateProviderConsumed, hasOnboardingUncertainty }: ProvidersPanelProps & {
+ hasOnboardingUncertainty: boolean;
}) {
const reportHostError = useRuntimeHostSettingsErrorReporter();
// Projected, not merely identified: the detail editor renders the Host's
@@ -169,15 +203,20 @@ export function ProvidersPanel({ bridge, initialPage = 'connections', initialCon
const initialCatalogOpenedRef = useRef(false);
useEffect(() => {
- if (loading || initialPage !== 'catalog' || initialCatalogOpenedRef.current) return;
+ if (
+ loading ||
+ hasOnboardingUncertainty ||
+ initialPage !== 'catalog' ||
+ initialCatalogOpenedRef.current
+ ) return;
initialCatalogOpenedRef.current = true;
setRoute({ kind: 'catalog' });
onInitialCatalogConsumed?.();
- }, [initialPage, loading, onInitialCatalogConsumed]);
+ }, [hasOnboardingUncertainty, initialPage, loading, onInitialCatalogConsumed]);
const initialConnectionDetailOpenedRef = useRef(false);
useEffect(() => {
- if (route.kind === 'adopting-oauth') {
+ if (route.kind === 'adopting-connection') {
const created = connections.find(
(connection) => connection.connectionId === route.identity.connectionId,
);
@@ -203,7 +242,12 @@ export function ProvidersPanel({ bridge, initialPage = 'connections', initialCon
const initialCreateOpenedRef = useRef(false);
useEffect(() => {
- if (loading || !initialCreateProviderType || initialCreateOpenedRef.current) return;
+ if (
+ loading ||
+ hasOnboardingUncertainty ||
+ !initialCreateProviderType ||
+ initialCreateOpenedRef.current
+ ) return;
initialCreateOpenedRef.current = true;
// Straight to the form, so `origin` is the list: the user never saw a
// catalog and must not be dropped into one on the way out.
@@ -217,7 +261,7 @@ export function ProvidersPanel({ bridge, initialPage = 'connections', initialCon
},
});
onInitialCreateProviderConsumed?.();
- }, [loading, initialCreateProviderType, onInitialCreateProviderConsumed]);
+ }, [hasOnboardingUncertainty, loading, initialCreateProviderType, onInitialCreateProviderConsumed]);
function goBack() {
setRoute(backTarget(route));
@@ -240,12 +284,12 @@ export function ProvidersPanel({ bridge, initialPage = 'connections', initialCon
const selected = route.kind === 'detail'
? connections.find((connection) => connection.connectionId === route.connectionId) ?? null
: null;
- const pendingOAuthIdentity = route.kind === 'adopting-oauth' ? route.identity : null;
+ const pendingConnectionIdentity = route.kind === 'adopting-connection' ? route.identity : null;
// A detail route whose connection vanished (deleted in another window) is an
// unsatisfiable route, not a state to correct: the list is what it renders
// as. Deriving that beats scheduling a setState from inside render.
- const level: Exclude<PanelRoute['kind'], 'adopting-oauth'> = route.kind === 'adopting-oauth'
+ const level: Exclude<PanelRoute['kind'], 'adopting-connection'> = route.kind === 'adopting-connection'
? 'list'
: route.kind === 'detail' && !selected
? 'list'
@@ -398,6 +442,7 @@ export function ProvidersPanel({ bridge, initialPage = 'connections', initialCon
<VStack gap={5}>
<SettingsRouteHeader
onBack={goBack}
+ isBackDisabled={hasOnboardingUncertainty}
backLabel={route.origin === 'catalog' ? copy.backToCatalog : copy.backToList}
logo={<ProviderLogo type={route.target.providerType} compact />}
titleId={setupTitleId}
@@ -408,18 +453,29 @@ export function ProvidersPanel({ bridge, initialPage = 'connections', initialCon
? oauthPanelSubtitle(route.target.cardId, providerCopy.oauthSection)
: copy.createSubtitle}
/>
- <ProviderSetupPage
- bridge={bridge}
- target={route.target}
- existingSlugs={connections.map((connection) => connection.slug)}
- labelledBy={setupTitleId}
- onCancel={goBack}
- onAccountCreated={async (identity) => {
- if (!identity) return;
- setRoute({ kind: 'adopting-oauth', identity });
- await reload();
- }}
- onCreated={async (slug, modelDiscoveryError) => {
+ <RuntimeHostSettingsGenerationBoundary>
+ <ProviderSetupPage
+ bridge={bridge}
+ apiKeyOnboardingBridge={apiKeyOnboardingBridge}
+ target={route.target}
+ existingSlugs={connections.map((connection) => connection.slug)}
+ labelledBy={setupTitleId}
+ hasSaveUncertainty={hasOnboardingUncertainty}
+ onCancel={goBack}
+ onAccountCreated={async (identity) => {
+ if (!identity) return;
+ setRoute({ kind: 'adopting-connection', identity });
+ await reload();
+ }}
+ onOnboarded={async (identity) => {
+ setRoute({ kind: 'adopting-connection', identity });
+ await reload();
+ }}
+ onOnboardingOutcomeUnknown={async () => {
+ setRoute({ kind: 'list' });
+ await reload();
+ }}
+ onCreated={async (slug, modelDiscoveryError) => {
const snapshot = await reload();
if (!snapshot || !providersPanelMountedRef.current) return;
// The new connection's detail, not the list: creating it is the
@@ -442,8 +498,9 @@ export function ProvidersPanel({ bridge, initialPage = 'connections', initialCon
),
);
}
- }}
- />
+ }}
+ />
+ </RuntimeHostSettingsGenerationBoundary>
</VStack>
) : (
<>
@@ -462,23 +519,49 @@ export function ProvidersPanel({ bridge, initialPage = 'connections', initialCon
variant="primary"
label={copy.addConnection}
onClick={openCatalog}
- isDisabled={pendingOAuthIdentity !== null}
+ isDisabled={pendingConnectionIdentity !== null || hasOnboardingUncertainty}
data-maka-contract="add-connection"
/>
</HStack>
- {pendingOAuthIdentity && loadError ? (
+ {hasOnboardingUncertainty && (
+ <Banner
+ status="warning"
+ role="status"
+ title={providerCopy.add.onboardingOutcomeUnknown}
+ description={providerCopy.add.onboardingOutcomeUnknownDetail}
+ endContent={(
+ <HStack gap={2}>
+ <Button
+ variant="ghost"
+ label={providerCopy.add.onboardingReloadConnections}
+ onClick={() => void reload()}
+ />
+ <Button
+ variant="secondary"
+ label={providerCopy.add.onboardingRestart}
+ onClick={() => {
+ apiKeyOnboardingBridge?.saveUncertainty.restart();
+ openCatalog();
+ }}
+ />
+ </HStack>
+ )}
+ />
+ )}
+ {pendingConnectionIdentity && loadError ? (
<Banner
status="warning"
role="status"
title={copy.connectedLoadFailed}
- description={loadError}
+ description={`${pendingConnectionIdentity.slug} · ${loadError}`}
endContent={<Button variant="ghost" label={copy.retry} onClick={() => void reload()} />}
/>
- ) : pendingOAuthIdentity ? (
+ ) : pendingConnectionIdentity ? (
<Banner
status="info"
role="status"
title={copy.connectedLoading}
+ description={pendingConnectionIdentity.slug}
endContent={<Button variant="ghost" label={copy.retry} onClick={() => void reload()} />}
/>
) : loadError ? (
@@ -493,7 +576,7 @@ export function ProvidersPanel({ bridge, initialPage = 'connections', initialCon
icon={<Cpu size={ICON_SIZE.empty} />}
title={copy.empty}
description={copy.emptyHelp}
- actions={<Button variant="primary" label={copy.addConnection} onClick={openCatalog} isDisabled={pendingOAuthIdentity !== null} />}
+ actions={<Button variant="primary" label={copy.addConnection} onClick={openCatalog} isDisabled={pendingConnectionIdentity !== null || hasOnboardingUncertainty} />}
/>
) : (
<List hasDividers>
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..de724af825 100644
--- a/apps/desktop/src/renderer/settings/runtime-host-settings-bridge.ts
+++ b/apps/desktop/src/renderer/settings/runtime-host-settings-bridge.ts
@@ -22,37 +22,11 @@ import type {
DesktopRuntimeHostRef,
MakaBridge,
} from '../../preload/bridge-contract.js';
-import type { ConnectionsBridge } from './provider-panel-shared.js';
import type {
OAuthAccountFlowBridge,
OAuthAuthorizationFlowBridge,
} from './use-oauth-login-flow.js';
-export type RuntimeHostSettingsConnectionsBridge = ConnectionsBridge & {
- setDefaultModel(input: { slug: string; model: string } | null): Promise<void>;
-};
-
-export function runtimeHostConnectionsBridge(
- host: DesktopRuntimeHostRef,
-): RuntimeHostSettingsConnectionsBridge {
- return {
- getSnapshot: () => window.maka.connections.getSnapshot(undefined, host),
- setDefault: (connection) => window.maka.connections.setDefault(connection, host),
- setDefaultModel: (input) => window.maka.connections.setDefaultModel(input, host),
- create: (input) => window.maka.connections.create(input, host),
- update: (connection, patch) => window.maka.connections.update(connection, patch, host),
- delete: (connection) => window.maka.connections.delete(connection, host),
- test: (connection, options) => window.maka.connections.test(connection, options, host),
- fetchModels: (connection) => window.maka.connections.fetchModels(connection, host),
- hasSecret: (connection) => window.maka.connections.hasSecret(connection, host),
- getRequestHeaders: (connection) => window.maka.connections.getRequestHeaders(connection, host),
- setRequestHeaders: (connection, headers) =>
- window.maka.connections.setRequestHeaders(connection, headers, host),
- subscribeEvents: (handler) =>
- window.maka.connections.subscribeEvents(handler, host),
- };
-}
-
type RuntimeHostOAuthBridge = MakaBridge['openAiCodex'] | MakaBridge['xaiOAuth'];
export function runtimeHostOAuthAuthorizationBridge(
diff --git a/apps/desktop/src/renderer/settings/settings-surface.tsx b/apps/desktop/src/renderer/settings/settings-surface.tsx
index 9b29393de4..73517879bf 100644
--- a/apps/desktop/src/renderer/settings/settings-surface.tsx
+++ b/apps/desktop/src/renderer/settings/settings-surface.tsx
@@ -95,9 +95,10 @@ import { WebSearchSettingsPage } from './web-search-settings-page';
import type { UiLocaleUpdateGate } from './ui-locale-update-gate';
import { getSettingsSharedCopy } from '../locales/settings-shared-copy.js';
import {
- runtimeHostConnectionsBridge,
+ ConnectionSettingsServicesConsumer,
+ type ConnectionSettingsServices,
type RuntimeHostSettingsConnectionsBridge,
-} from './runtime-host-settings-bridge.js';
+} from '../features/connection-settings';
import {
hasRuntimeHostSettingsPatch,
projectClientOwnedSettings,
@@ -148,7 +149,7 @@ function readyRuntimeHost(
return { profileId: entry.profile.id, hostId: entry.hostId };
}
-export function SettingsSurface(props: {
+type SettingsSurfaceProps = {
onClose(): void;
themePref: ThemePreference;
onThemeChange(pref: ThemePreference): void;
@@ -171,7 +172,26 @@ export function SettingsSurface(props: {
onRemoteHostAdded(profileId: string): void;
onSelectedRuntimeHostProfileIdChange(profileId: string | undefined): void;
snapshotCache?: SettingsSnapshotCache;
-}) {
+};
+
+export function SettingsSurface(props: SettingsSurfaceProps) {
+ return (
+ <ConnectionSettingsServicesConsumer>
+ {(connectionSettingsServices) => (
+ <SettingsSurfaceContent
+ {...props}
+ connectionSettingsServices={connectionSettingsServices}
+ />
+ )}
+ </ConnectionSettingsServicesConsumer>
+ );
+}
+
+function SettingsSurfaceContent(
+ props: SettingsSurfaceProps & {
+ readonly connectionSettingsServices: ConnectionSettingsServices;
+ },
+) {
const locale = useUiLocale();
const copy = getSettingsSharedCopy(locale);
const localizedNav = groupedNav(locale);
@@ -348,12 +368,14 @@ export function SettingsSurface(props: {
),
[runtimeHostLifecycleByProfile, runtimeHosts, selectedProfileId],
);
- const connectionsBridge = useMemo(
+ const connectionBridges = useMemo(
() => selectedRuntimeHost
- ? runtimeHostConnectionsBridge(selectedRuntimeHost)
+ ? props.connectionSettingsServices.forHost(selectedRuntimeHost)
: undefined,
- [selectedRuntimeHost],
+ [props.connectionSettingsServices, selectedRuntimeHost],
);
+ const connectionsBridge = connectionBridges?.connections;
+ const apiKeyOnboardingBridge = connectionBridges?.apiKeyOnboarding;
const selectedRuntimeHostKey = selectedRuntimeHost
? runtimeHostSettingsKey(selectedRuntimeHost)
: undefined;
@@ -1015,6 +1037,7 @@ export function SettingsSurface(props: {
}
connections={connections}
connectionsBridge={connectionsBridge}
+ apiKeyOnboardingBridge={apiKeyOnboardingBridge}
defaultSlug={defaultSlug}
runtimeHost={selectedRuntimeHost}
runtimeHostAvailabilityStatus={runtimeHostAvailabilityStatus}
@@ -1070,6 +1093,9 @@ function SettingsPageBody(props: {
usageStats: UsageStats | null;
connections: ProjectedLlmConnection[];
connectionsBridge: RuntimeHostSettingsConnectionsBridge | undefined;
+ apiKeyOnboardingBridge:
+ | ReturnType<ConnectionSettingsServices['forHost']>['apiKeyOnboarding']
+ | undefined;
defaultSlug: string | null;
runtimeHost: DesktopRuntimeHostRef | undefined;
runtimeHostAvailabilityStatus: RuntimeHostAvailabilityStatus;
@@ -1116,6 +1142,7 @@ function SettingsPageBody(props: {
<SettingsPage className="settingsModelsPage">
<ProvidersPanel
bridge={props.connectionsBridge}
+ apiKeyOnboardingBridge={props.apiKeyOnboardingBridge}
initialPage={props.openProviderCatalog ? 'catalog' : 'connections'}
initialConnectionSlug={props.initialConnectionSlug}
initialCreateProviderType={props.initialCreateProviderType}
diff --git a/apps/desktop/src/renderer/settings/use-connection-detail.ts b/apps/desktop/src/renderer/settings/use-connection-detail.ts
index dcbbfb709b..75ac12bf57 100644
--- a/apps/desktop/src/renderer/settings/use-connection-detail.ts
+++ b/apps/desktop/src/renderer/settings/use-connection-detail.ts
@@ -46,7 +46,6 @@ import {
providerSupportsModelDiscovery,
} from '@maka/core/llm-connections';
import { useMountedRef, useToast, useUiLocale } from '@maka/ui';
-import { getProviderSettingsCopy } from '../locales/settings-provider-copy';
import { connectionChipStatus } from './provider-connection-status';
import { relayProfileDraftReseedPlan, relayProfileDraftSeed } from './relay-profile-draft';
import { applyBulkThinkingLevel, relayProfileWithThinkingLevels } from './relay-thinking-bulk';
@@ -58,10 +57,11 @@ import type {
import {
connectionLastTestMessageDisplay,
connectionTestFailureMessage,
+ getProviderSettingsCopy,
providerPanelActionErrorMessage,
type ConnectionsBridge,
type CredentialPresenceStatus,
-} from './provider-panel-shared';
+} from '../features/connection-settings';
import {
useRuntimeHostSettingsErrorReporter,
useRuntimeHostSettingsTarget,
diff --git a/apps/desktop/src/renderer/settings/use-oauth-login-flow.ts b/apps/desktop/src/renderer/settings/use-oauth-login-flow.ts
index 42ab18ee47..034b16d365 100644
--- a/apps/desktop/src/renderer/settings/use-oauth-login-flow.ts
+++ b/apps/desktop/src/renderer/settings/use-oauth-login-flow.ts
@@ -26,7 +26,7 @@ import {
useUiLocale,
} from '@maka/ui';
import { createOneShotActionGuard, teardownPendingAuthorization } from './oauth-login-flow-guard';
-import { getProviderSettingsCopy } from '../locales/settings-provider-copy';
+import { getProviderSettingsCopy } from '../features/connection-settings';
import { useRuntimeHostSettingsErrorReporter } from './runtime-host-settings-target.js';
// Shared browser-assisted OAuth login-flow controller (device-code polling).
diff --git a/apps/desktop/stories/settings/provider-settings.stories.tsx b/apps/desktop/stories/settings/provider-settings.stories.tsx
index 39649ee0e4..41953596da 100644
--- a/apps/desktop/stories/settings/provider-settings.stories.tsx
+++ b/apps/desktop/stories/settings/provider-settings.stories.tsx
@@ -21,20 +21,20 @@ import { useEffect, useRef, type ReactNode } from 'react';
import { resolveConnectionModelCatalog } from '@maka/core/model-catalog';
import type { ProjectedLlmConnection } from '@maka/core/llm-connections';
import type { Meta, StoryObj } from '@storybook/react-vite';
-import { expect, userEvent, within } from 'storybook/test';
+import { expect, userEvent, waitFor, within } from 'storybook/test';
import { Layout, LayoutContent, LayoutHeader } from '@astryxdesign/core';
import { ToastProvider } from '@maka/ui';
import type {
ConnectionTestResult,
IdentifiedLlmConnection,
LlmConnection,
- ModelDiscoveryResult,
ProviderType,
} from '@maka/core/llm-connections';
import { buildChatModelChoices } from '@maka/core/chat-model-choice';
import { ProvidersPanel, type ConnectionsBridge } from '../../src/renderer/settings/providers-panel';
import { RuntimeHostSettingsTarget } from '../../src/renderer/settings/runtime-host-settings-target';
import { SettingsPage } from '../../src/renderer/settings/settings-section';
+import type { ApiKeyOnboardingBridge } from '../../src/renderer/features/connection-settings';
const NOW = Date.parse('2026-07-01T08:00:00Z');
@@ -247,16 +247,24 @@ const oauthConnections = [
}),
];
+interface StoryConnectionsBridge extends ConnectionsBridge {
+ addFixtureConnection(connection: ProjectedLlmConnection): void;
+}
+
function createBridge(input: {
connections?: ProjectedLlmConnection[];
defaultSlug?: string | null;
failLoad?: boolean;
loading?: boolean;
-}): ConnectionsBridge {
+}): StoryConnectionsBridge {
let connections = [...(input.connections ?? [])];
let defaultSlug: string | null = input.defaultSlug ?? connections[0]?.slug ?? null;
return {
+ addFixtureConnection(connection) {
+ connections = [...connections, connection];
+ defaultSlug ??= connection.slug;
+ },
async getSnapshot() {
if (input.loading) return new Promise<never>(() => undefined);
if (input.failLoad) throw new Error('模型连接服务暂时不可用');
@@ -318,14 +326,13 @@ function createBridge(input: {
}
return { ok: true, latencyMs: 328, modelTested: 'glm-4.7' };
},
- async fetchModels(identity): Promise<ModelDiscoveryResult> {
+ async fetchModels(identity) {
return {
models: [
{ id: identity.slug.includes('openai') ? 'gpt-5' : 'glm-4.7' },
{ id: identity.slug.includes('openai') ? 'gpt-4o' : 'glm-4.6' },
],
source: 'fetched',
- fetchedAt: NOW,
};
},
async hasSecret() {
@@ -343,6 +350,112 @@ function createBridge(input: {
};
}
+function createApiKeyOnboardingFixture(options: {
+ save?: 'saved' | 'outcome_unknown' | 'auth_failed';
+ failRefreshAfterSave?: boolean;
+ emptyCatalog?: boolean;
+} = {}) {
+ const bridge = createBridge({
+ connections: options.emptyCatalog
+ ? []
+ : [
+ ...configuredConnections,
+ makeConnection({
+ slug: 'deepseek',
+ name: 'DeepSeek',
+ providerType: 'deepseek',
+ defaultModel: 'deepseek-chat',
+ }),
+ ],
+ defaultSlug: options.emptyCatalog ? undefined : 'zai-live',
+ });
+ let failNextSnapshot = false;
+ const projectedBridge: ConnectionsBridge = {
+ ...bridge,
+ async getSnapshot() {
+ if (failNextSnapshot) {
+ failNextSnapshot = false;
+ throw new Error('模型连接服务暂时不可用');
+ }
+ return bridge.getSnapshot();
+ },
+ };
+ let uncertainAttemptId: number | undefined;
+ let nextAttemptId = 1;
+ const uncertaintyListeners = new Set<() => void>();
+ const settleUncertainty = (attemptId: number) => {
+ if (uncertainAttemptId !== attemptId) return;
+ uncertainAttemptId = undefined;
+ for (const listener of [...uncertaintyListeners]) listener();
+ };
+ const apiKeyOnboardingBridge: ApiKeyOnboardingBridge = {
+ saveUncertainty: {
+ getSnapshot: () => uncertainAttemptId !== undefined,
+ subscribe: (listener) => {
+ uncertaintyListeners.add(listener);
+ return () => uncertaintyListeners.delete(listener);
+ },
+ restart: () => {
+ if (uncertainAttemptId === undefined) return;
+ uncertainAttemptId = undefined;
+ for (const listener of [...uncertaintyListeners]) listener();
+ },
+ },
+ async verify() {
+ return {
+ kind: 'verified',
+ models: [
+ { id: 'deepseek-reasoner', displayName: 'DeepSeek Reasoner' },
+ { id: 'deepseek-chat', displayName: 'DeepSeek Chat' },
+ ],
+ };
+ },
+ async save() {
+ const attemptId = nextAttemptId++;
+ const wasUncertain = uncertainAttemptId !== undefined;
+ uncertainAttemptId = attemptId;
+ if (!wasUncertain) {
+ for (const listener of [...uncertaintyListeners]) listener();
+ }
+ if (options.save === 'outcome_unknown') return { kind: 'outcome_unknown' };
+ if (options.save === 'auth_failed') {
+ settleUncertainty(attemptId);
+ return {
+ kind: 'result',
+ result: { kind: 'failed', errorClass: 'auth' },
+ };
+ }
+ const connection = makeConnection({
+ slug: 'deepseek-2',
+ name: 'DeepSeek',
+ providerType: 'deepseek',
+ defaultModel: '',
+ models: [
+ { id: 'deepseek-reasoner', displayName: 'DeepSeek Reasoner' },
+ { id: 'deepseek-chat', displayName: 'DeepSeek Chat' },
+ ],
+ modelSource: 'fetched',
+ });
+ bridge.addFixtureConnection(connection);
+ failNextSnapshot = options.failRefreshAfterSave === true;
+ settleUncertainty(attemptId);
+ return {
+ kind: 'result',
+ result: {
+ kind: 'saved',
+ connection: {
+ connectionId: connection.connectionId,
+ revision: 1,
+ slug: connection.slug,
+ providerType: connection.providerType,
+ },
+ },
+ };
+ },
+ };
+ return { bridge: projectedBridge, apiKeyOnboardingBridge };
+}
+
function createOAuthSuccessLifecycleFixture() {
const bridge = createBridge({ connections: oauthConnections });
let eventHandler: (() => void) | undefined;
@@ -451,6 +564,7 @@ function browserSubscriptionFixture(state: {
function ProviderStoryFrame(props: {
bridge: ConnectionsBridge;
+ apiKeyOnboardingBridge?: ApiKeyOnboardingBridge;
autoOpen?: AutoOpenTarget;
onOAuthComplete?: () => void;
}) {
@@ -513,7 +627,10 @@ function ProviderStoryFrame(props: {
content={(
<LayoutContent padding={6} isScrollable={false}>
<SettingsPage className="settingsModelsPage">
- <ProvidersPanel bridge={props.bridge} />
+ <ProvidersPanel
+ bridge={props.bridge}
+ apiKeyOnboardingBridge={props.apiKeyOnboardingBridge}
+ />
</SettingsPage>
</LayoutContent>
)}
@@ -590,16 +707,31 @@ function clickAutoOpenTarget(root: HTMLElement, target: AutoOpenTarget): boolean
function ProviderStory(props: {
bridge: ConnectionsBridge;
+ apiKeyOnboardingBridge?: ApiKeyOnboardingBridge;
autoOpen?: AutoOpenTarget;
onOAuthComplete?: () => void;
}): ReactNode {
return (
<RuntimeHostSettingsTarget host={{ profileId: 'local', hostId: 'storybook-local-host' }}>
- <ProviderStoryFrame bridge={props.bridge} autoOpen={props.autoOpen} onOAuthComplete={props.onOAuthComplete} />
+ <ProviderStoryFrame
+ bridge={props.bridge}
+ apiKeyOnboardingBridge={props.apiKeyOnboardingBridge}
+ autoOpen={props.autoOpen}
+ onOAuthComplete={props.onOAuthComplete}
+ />
</RuntimeHostSettingsTarget>
);
}
+async function findApiKeyInput(canvasElement: HTMLElement): Promise<HTMLInputElement> {
+ let input: HTMLInputElement | null = null;
+ await waitFor(() => {
+ input = canvasElement.querySelector<HTMLInputElement>('input[type="password"]');
+ expect(input).not.toBeNull();
+ }, { timeout: 5_000 });
+ return input!;
+}
+
// Real path: same page with several healthy connections and one of them set as default.
export const ConfiguredProviders: Story = {
render: () => <ProviderStory bridge={createBridge({ connections: configuredConnections, defaultSlug: 'zai-live' })} />,
@@ -772,3 +904,180 @@ export const AddProvider: Story = {
/>
),
};
+
+// Real path: 设置 → 模型 → 添加连接 → DeepSeek. The common fixed-endpoint
+// API-key path is Host-owned before any write happens.
+export const ApiKeyOnboardingInput: Story = {
+ render: () => {
+ const fixture = createApiKeyOnboardingFixture();
+ return (
+ <ProviderStory
+ bridge={fixture.bridge}
+ apiKeyOnboardingBridge={fixture.apiKeyOnboardingBridge}
+ autoOpen="add"
+ />
+ );
+ },
+};
+
+export const ApiKeyOnboardingModels: Story = {
+ render: () => {
+ const fixture = createApiKeyOnboardingFixture();
+ return (
+ <ProviderStory
+ bridge={fixture.bridge}
+ apiKeyOnboardingBridge={fixture.apiKeyOnboardingBridge}
+ autoOpen="add"
+ />
+ );
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await userEvent.type(await findApiKeyInput(canvasElement), 'sk-storybook');
+ await userEvent.click(await canvas.findByRole('button', { name: '验证并选择模型' }));
+ await expect(canvas.findByText('选择此连接使用的模型')).resolves.toBeTruthy();
+ await expect(canvas.findByRole('button', { name: '添加连接' })).resolves.toBeTruthy();
+ },
+};
+
+export const ApiKeyOnboardingBackInvalidatesVerification: Story = {
+ render: () => {
+ const fixture = createApiKeyOnboardingFixture();
+ return (
+ <ProviderStory
+ bridge={fixture.bridge}
+ apiKeyOnboardingBridge={fixture.apiKeyOnboardingBridge}
+ autoOpen="add"
+ />
+ );
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const key = await findApiKeyInput(canvasElement);
+ await userEvent.type(key, 'sk-first');
+ await userEvent.click(await canvas.findByRole('button', { name: '验证并选择模型' }));
+ await userEvent.click(await canvas.findByRole('button', { name: '返回修改' }));
+ await userEvent.clear(await findApiKeyInput(canvasElement));
+ await userEvent.type(await findApiKeyInput(canvasElement), 'sk-second');
+ await expect(canvas.queryByText('选择此连接使用的模型')).toBeNull();
+ await expect(canvas.findByRole('button', { name: '验证并选择模型' })).resolves.toBeTruthy();
+ },
+};
+
+export const ApiKeyOnboardingAdoptsExactConnection: Story = {
+ render: () => {
+ const fixture = createApiKeyOnboardingFixture();
+ return (
+ <ProviderStory
+ bridge={fixture.bridge}
+ apiKeyOnboardingBridge={fixture.apiKeyOnboardingBridge}
+ autoOpen="add"
+ />
+ );
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await userEvent.type(await findApiKeyInput(canvasElement), 'sk-storybook');
+ await userEvent.click(await canvas.findByRole('button', { name: '验证并选择模型' }));
+ await userEvent.click(await canvas.findByRole('button', { name: '添加连接' }));
+ await expect(canvas.findByRole('region', { name: 'DeepSeek · deepseek-2' })).resolves.toBeTruthy();
+ },
+};
+
+export const ApiKeyOnboardingRefreshWarning: Story = {
+ render: () => {
+ const fixture = createApiKeyOnboardingFixture({ failRefreshAfterSave: true });
+ return (
+ <ProviderStory
+ bridge={fixture.bridge}
+ apiKeyOnboardingBridge={fixture.apiKeyOnboardingBridge}
+ autoOpen="add"
+ />
+ );
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await userEvent.type(await findApiKeyInput(canvasElement), 'sk-storybook');
+ await userEvent.click(await canvas.findByRole('button', { name: '验证并选择模型' }));
+ await userEvent.click(await canvas.findByRole('button', { name: '添加连接' }));
+ await expect(canvas.findByText('连接已添加,但暂时无法刷新连接列表。')).resolves.toBeTruthy();
+ },
+};
+
+export const ApiKeyOnboardingOutcomeUnknown: Story = {
+ render: () => {
+ const fixture = createApiKeyOnboardingFixture({ save: 'outcome_unknown' });
+ return (
+ <ProviderStory
+ bridge={fixture.bridge}
+ apiKeyOnboardingBridge={fixture.apiKeyOnboardingBridge}
+ autoOpen="add"
+ />
+ );
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await userEvent.type(await findApiKeyInput(canvasElement), 'sk-storybook');
+ await userEvent.click(await canvas.findByRole('button', { name: '验证并选择模型' }));
+ await userEvent.click(await canvas.findByRole('button', { name: '添加连接' }));
+ await expect(canvas.findByText('保存结果暂时无法确认')).resolves.toBeTruthy();
+ await userEvent.click(await canvas.findByRole('button', { name: '重新加载连接列表' }));
+ await expect(canvas.findByRole('button', { name: '添加连接' })).resolves.toBeDisabled();
+ await expect(canvas.findByRole('button', { name: '仍要添加另一个连接' })).resolves.toBeTruthy();
+ const row = canvasElement.querySelector<HTMLElement>('[data-connection-slug="deepseek"]');
+ await expect(row).not.toBeNull();
+ await userEvent.click(within(row!).getByRole('button'));
+ await userEvent.click(await canvas.findByRole('button', { name: '返回模型连接' }));
+ await expect(canvas.findByText('保存结果暂时无法确认')).resolves.toBeTruthy();
+ await expect(canvas.findByRole('button', { name: '添加连接' })).resolves.toBeDisabled();
+ },
+};
+
+export const ApiKeyOnboardingOutcomeUnknownEmptyCatalog: Story = {
+ render: () => {
+ const fixture = createApiKeyOnboardingFixture({
+ save: 'outcome_unknown',
+ emptyCatalog: true,
+ });
+ return (
+ <ProviderStory
+ bridge={fixture.bridge}
+ apiKeyOnboardingBridge={fixture.apiKeyOnboardingBridge}
+ autoOpen="add"
+ />
+ );
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await userEvent.type(await findApiKeyInput(canvasElement), 'sk-storybook');
+ await userEvent.click(await canvas.findByRole('button', { name: '验证并选择模型' }));
+ await userEvent.click(await canvas.findByRole('button', { name: '添加连接' }));
+ await userEvent.click(await canvas.findByRole('button', { name: '重新加载连接列表' }));
+ const addButtons = await canvas.findAllByRole('button', { name: '添加连接' });
+ for (const button of addButtons) await expect(button).toBeDisabled();
+ },
+};
+
+export const ApiKeyOnboardingSaveAuthFailure: Story = {
+ render: () => {
+ const fixture = createApiKeyOnboardingFixture({ save: 'auth_failed' });
+ return (
+ <ProviderStory
+ bridge={fixture.bridge}
+ apiKeyOnboardingBridge={fixture.apiKeyOnboardingBridge}
+ autoOpen="add"
+ />
+ );
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await userEvent.type(await findApiKeyInput(canvasElement), 'sk-storybook');
+ await userEvent.click(await canvas.findByRole('button', { name: '验证并选择模型' }));
+ await userEvent.click(await canvas.findByRole('button', { name: '添加连接' }));
+ const key = await findApiKeyInput(canvasElement);
+ await waitFor(() => {
+ expect(canvas.queryAllByText('密钥验证失败,请检查后重试。')).toHaveLength(1);
+ });
+ await expect(key).toHaveValue('sk-storybook');
+ },
+};
diff --git a/apps/desktop/stories/settings/settings-pages.stories.tsx b/apps/desktop/stories/settings/settings-pages.stories.tsx
index 8fcc2fa5a3..5195fe534f 100644
--- a/apps/desktop/stories/settings/settings-pages.stories.tsx
+++ b/apps/desktop/stories/settings/settings-pages.stories.tsx
@@ -55,6 +55,8 @@ import { buildHealthSnapshot } from '@maka/core/health';
import { createDefaultSettings, mergeSettings } from '@maka/core/settings';
import { DEFAULT_DAILY_REVIEW_CONFIG } from '@maka/core/daily-review';
import { SettingsSurface } from '../../src/renderer/settings/settings-surface';
+import { ConnectionSettingsServicesProvider } from '../../src/renderer/features/connection-settings';
+import { createDesktopConnectionSettingsServices } from '../../src/renderer/platform/desktop/create-connection-settings-services';
import { createUiLocaleUpdateGate } from '../../src/renderer/settings/ui-locale-update-gate';
import {
createSettingsSnapshotCache,
@@ -1599,6 +1601,9 @@ function SettingsStoryFrame(props: SettingsStoryProps) {
// holds both in AppShell state and applies them optimistically on click.
const [themePref, setThemePref] = useState<ThemePreference>('auto');
const [themePalette, setThemePalette] = useState<ThemePalette>('default');
+ const [connectionSettingsServices] = useState(
+ createDesktopConnectionSettingsServices,
+ );
return (
<>
@@ -1616,27 +1621,29 @@ function SettingsStoryFrame(props: SettingsStoryProps) {
minHeight: 640,
}}
>
- <SettingsSurface
- onClose={noop}
- themePref={themePref}
- onThemeChange={setThemePref}
- themePalette={themePalette}
- onThemePaletteChange={setThemePalette}
- onUiLocalePreferenceChange={noop}
- uiLocaleUpdateGate={uiLocaleUpdateGate}
- onDefaultPermissionModeChange={noop}
- request={{ section: props.section }}
- openProviderCatalog={props.openProviderCatalog}
- initialConnectionSlug={props.initialConnectionSlug}
- initialFocusRef={initialFocusRef}
- onOpenDailyReview={noop}
- onOpenSession={noop}
- archivedTasks={archivedTasks}
- onTaskImported={noop}
- onRemoteHostAdded={noop}
- onSelectedRuntimeHostProfileIdChange={noop}
- snapshotCache={snapshotCache}
- />
+ <ConnectionSettingsServicesProvider services={connectionSettingsServices}>
+ <SettingsSurface
+ onClose={noop}
+ themePref={themePref}
+ onThemeChange={setThemePref}
+ themePalette={themePalette}
+ onThemePaletteChange={setThemePalette}
+ onUiLocalePreferenceChange={noop}
+ uiLocaleUpdateGate={uiLocaleUpdateGate}
+ onDefaultPermissionModeChange={noop}
+ request={{ section: props.section }}
+ openProviderCatalog={props.openProviderCatalog}
+ initialConnectionSlug={props.initialConnectionSlug}
+ initialFocusRef={initialFocusRef}
+ onOpenDailyReview={noop}
+ onOpenSession={noop}
+ archivedTasks={archivedTasks}
+ onTaskImported={noop}
+ onRemoteHostAdded={noop}
+ onSelectedRuntimeHostProfileIdChange={noop}
+ snapshotCache={snapshotCache}
+ />
+ </ConnectionSettingsServicesProvider>
</div>
</>
);
diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md
index f6ed5c7566..84509c2cb2 100644
--- a/docs/astryx-surface-file-inventory.md
+++ b/docs/astryx-surface-file-inventory.md
@@ -6,7 +6,7 @@ Generated against `@astryxdesign/core@0.5.2` (194 component exports).
Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding.
-**Totals:** 235 files — blocker 0, reimplementation 0, polish 1, aligned 234.
+**Totals:** 236 files — blocker 0, reimplementation 0, polish 1, aligned 235.
## Exclusions (explicit)
@@ -42,6 +42,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi
| `apps/desktop/src/renderer/composition/desktop-feature-services.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned |
| `apps/desktop/src/renderer/custom-pet-companion.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned |
| `apps/desktop/src/renderer/error-boundary.tsx` | other | Button, Card | aligned — uses Astryx (Button, Card) | aligned |
+| `apps/desktop/src/renderer/features/connection-settings/services-context.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned |
| `apps/desktop/src/renderer/features/goals/services-context.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned |
| `apps/desktop/src/renderer/features/goals/ui/goal-dialog.tsx` | dialog-overlay | Button, Dialog, DialogHeader, HStack, Layout, LayoutContent, LayoutFooter, Text, TextArea, TextInput, VStack | aligned — uses Astryx (Button, Dialog, DialogHeader, HStack, Layout, LayoutContent, LayoutFooter, Text) | aligned |
| `apps/desktop/src/renderer/features/goals/ui/goal-host.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned |
@@ -103,7 +104,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi
| `apps/desktop/src/renderer/settings/permission-center-page.tsx` | settings-page | Banner, Button, Collapsible, CollapsibleGroup, HStack, List, ListItem, MetadataList, MetadataListItem, StatusDot, Text, VStack | aligned — uses Astryx (Banner, Button, Collapsible, CollapsibleGroup, HStack, List, ListItem, MetadataList) | aligned |
| `apps/desktop/src/renderer/settings/personalization-settings-section.tsx` | settings-module | SegmentedControl, SegmentedControlItem, TextArea, TextInput | aligned — uses Astryx (SegmentedControl, SegmentedControlItem, TextArea, TextInput) | aligned |
| `apps/desktop/src/renderer/settings/projects-settings-page.tsx` | settings-page | Badge, Banner, Button, EmptyState, HStack, List, ListItem, MoreMenu, TextInput | aligned — uses Astryx (Badge, Banner, Button, EmptyState, HStack, List, ListItem, MoreMenu) | aligned |
-| `apps/desktop/src/renderer/settings/provider-add-form.tsx` | settings-module | Banner, Button, Collapsible, FormLayout, HStack, TextInput, VStack | aligned — uses Astryx (Banner, Button, Collapsible, FormLayout, HStack, TextInput, VStack) | aligned |
+| `apps/desktop/src/renderer/settings/provider-add-form.tsx` | settings-module | Banner, Button, Collapsible, FormLayout, HStack, MultiSelector, Text, TextInput, VStack | aligned — uses Astryx (Banner, Button, Collapsible, FormLayout, HStack, MultiSelector, Text, TextInput) | aligned |
| `apps/desktop/src/renderer/settings/provider-add-model-dialog.tsx` | settings-module | Button, Dialog, DialogHeader, FormLayout, HStack, Layout, LayoutContent, LayoutFooter, NumberInput, TextInput | aligned — uses Astryx (Button, Dialog, DialogHeader, FormLayout, HStack, Layout, LayoutContent, LayoutFooter) | aligned |
| `apps/desktop/src/renderer/settings/provider-brand-marks.tsx` | settings-module | none | aligned — no raw controls; no Astryx JSX usage | aligned |
| `apps/desktop/src/renderer/settings/provider-catalog-page.tsx` | settings-page | Banner, Button, EmptyState, HStack, List, ListItem, Section, Selector, TextInput, VStack | aligned — uses Astryx (Banner, Button, EmptyState, HStack, List, ListItem, Section, Selector) | aligned |
diff --git a/docs/astryx-surface-file-inventory.paths b/docs/astryx-surface-file-inventory.paths
index 1371e38522..cbd8244e02 100644
--- a/docs/astryx-surface-file-inventory.paths
+++ b/docs/astryx-surface-file-inventory.paths
@@ -13,6 +13,7 @@ apps/desktop/src/renderer/composer-mentions.tsx
apps/desktop/src/renderer/composition/desktop-feature-services.tsx
apps/desktop/src/renderer/custom-pet-companion.tsx
apps/desktop/src/renderer/error-boundary.tsx
+apps/desktop/src/renderer/features/connection-settings/services-context.tsx
apps/desktop/src/renderer/features/goals/services-context.tsx
apps/desktop/src/renderer/features/goals/ui/goal-dialog.tsx
apps/desktop/src/renderer/features/goals/ui/goal-host.tsx
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org