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/08/31 15:10:10 UTC
(maka) 02/02: feat(desktop): add host-backed API key enrollment flow
This is an automated email from the ASF dual-hosted git repository.
me2seeks pushed a commit to branch feat/desktop-api-key-host-onboarding
in repository https://gitbox.apache.org/repos/asf/maka.git
commit 28e6f5f4c21f6e25f442e1fafb346a2e4cf77390
Author: me2seeks <me...@gmail.com>
AuthorDate: Mon Aug 31 23:04:20 2026 +0800
feat(desktop): add host-backed API key enrollment flow
---
.../src/renderer/locales/settings-provider-copy.ts | 12 +-
.../src/renderer/settings/provider-add-form.tsx | 335 ++++++++++++++++++++-
.../renderer/settings/provider-catalog-page.tsx | 18 +-
.../src/renderer/settings/providers-panel.tsx | 145 +++++++--
.../src/renderer/settings/settings-surface.tsx | 10 +
.../stories/settings/provider-settings.stories.tsx | 305 ++++++++++++++++++-
6 files changed, 773 insertions(+), 52 deletions(-)
diff --git a/apps/desktop/src/renderer/locales/settings-provider-copy.ts b/apps/desktop/src/renderer/locales/settings-provider-copy.ts
index c2ad504ded..d46309144e 100644
--- a/apps/desktop/src/renderer/locales/settings-provider-copy.ts
+++ b/apps/desktop/src/renderer/locales/settings-provider-copy.ts
@@ -177,7 +177,7 @@ const zhCopy = {
tabs: { all: '全部', recommended: '推荐', accounts: '账号', plans: '模型计划', api: 'API', aggregators: '聚合服务', local: '本地' },
loadFailed: '载入模型连接失败', loadingAria: '正在加载模型供应商', connections: '模型连接',
retry: '点击重试。', empty: '还没有模型连接', connectionRemoved: '原连接已被删除或移除,已返回模型连接列表。',
- connectedLoading: '账号已连接,正在载入新的模型连接…', connectedLoadFailed: '账号已连接,但暂时无法载入新的模型连接。',
+ connectedLoading: '连接已添加,正在载入详情…', connectedLoadFailed: '连接已添加,但暂时无法刷新连接列表。',
connectionIdentityChanged: '新连接的身份与登录结果不一致,请返回连接列表后重试。',
emptyHelp: '从下方选择一种连接方式开始。', default: '默认', setDefault: '设为默认', setDefaultTitle: '让新任务默认使用这个连接', setDefaultPending: '设置中…', setDefaultFailed: '设为默认失败', addHelp: '选择账号登录、模型计划、API、聚合服务或本地运行时。',
categoriesAria: '模型供应商分类', searchPlaceholder: '搜索服务商', searchAria: '搜索模型服务商', noMatch: '没有匹配的服务商', clearSearch: '清除搜索',
@@ -203,6 +203,10 @@ const zhCopy = {
saving: '保存中…', save: '保存供应商', keyRequired: (name: string) => `请填写 ${name} API Key`,
apiKeyLabel: 'API Key', accountIdLabel: 'Cloudflare Account ID', endpointLabel: '服务地址',
defaultModel: '默认模型', defaultModelPlaceholder: '留空即可,保存后自动拉取', defaultModelHelp: '保存后 Maka 会向该端点拉取模型目录。只有当端点不提供目录时,才需要在这里手填一个模型 ID。',
+ onboardingVerifyAndChoose: '验证并选择模型', onboardingVerifying: '正在验证密钥并获取模型…',
+ onboardingChooseModels: '选择此连接使用的模型', onboardingChooseModelsHelp: '添加后仍可在连接详情中启用其他模型。', onboardingEnabledModels: '启用的模型', onboardingSearchModels: '搜索模型', onboardingAddConnection: '添加连接', onboardingBack: '返回修改',
+ onboardingAuthFailed: '密钥验证失败,请检查后重试。', onboardingTimeout: '验证超时,请检查网络或代理后重试。', onboardingNetwork: '无法连接模型服务,请检查网络、代理或服务地址。', onboardingUnavailable: '模型连接服务暂时不可用,请稍后重试。', onboardingInvalidResponse: '模型服务返回了无法识别的结果,请稍后重试。', onboardingCatalogFull: '连接数量已达上限,请先删除不再使用的连接。', onboardingModelsChanged: '可用模型已发生变化,请重新验证后选择。', onboardingNoModels: '没有发现可用模型,当前未创建连接。', onboardingSelectModel: '请至少启用一个模型。',
+ onboardingOutcomeUnknown: '保存结果暂时无法确认', onboardingOutcomeUnknownDetail: '请勿再次添加,以免创建重复连接。重新加载连接列表并检查该连接是否已经出现;仍不确定时请先重连 Runtime Host。', onboardingReloadConnections: '重新加载连接列表', onboardingRestart: '仍要添加另一个连接',
...zhCapabilitiesCopy,
},
oauthFlow: {
@@ -333,7 +337,7 @@ const enCopy: ProviderSettingsCopy = {
tabs: { all: 'All', recommended: 'Recommended', accounts: 'Accounts', plans: 'Model plans', api: 'API', aggregators: 'Aggregators', local: 'Local' },
loadFailed: 'Failed to load model connections', loadingAria: 'Loading model providers', connections: 'Connections',
retry: 'Select to retry.', empty: 'No model connections yet', connectionRemoved: 'The original connection was deleted or removed. Returned to the connection list.',
- connectedLoading: 'Account connected. Loading the new model connection…', connectedLoadFailed: 'Account connected, but the new model connection could not be loaded yet.',
+ connectedLoading: 'Connection added. Loading its details…', connectedLoadFailed: 'Connection added, but the connection list could not be refreshed yet.',
connectionIdentityChanged: 'The new connection identity did not match the sign-in result. Return to the connection list and try again.',
emptyHelp: 'Choose a connection method below to begin.', default: 'Default', setDefault: 'Set as default', setDefaultTitle: 'New chats will use this connection', setDefaultPending: 'Setting…', setDefaultFailed: 'Could not set as default', addHelp: 'Choose account sign-in, a model plan, API, aggregator, or local runtime.',
categoriesAria: 'Model provider categories', searchPlaceholder: 'Search providers', searchAria: 'Search model providers', noMatch: 'No matching providers', clearSearch: 'Clear search',
@@ -359,6 +363,10 @@ const enCopy: ProviderSettingsCopy = {
saving: 'Saving…', save: 'Save provider', keyRequired: (name: string) => `Enter the ${name} API key`,
apiKeyLabel: 'API key', accountIdLabel: 'Cloudflare Account ID', endpointLabel: 'Service URL',
defaultModel: 'Default model', defaultModelPlaceholder: 'Leave empty — fetched after saving', defaultModelHelp: 'Maka fetches the model catalog from this endpoint after saving. Type a model id here only if the endpoint serves no catalog.',
+ onboardingVerifyAndChoose: 'Verify and choose models', onboardingVerifying: 'Verifying the key and loading models…',
+ onboardingChooseModels: 'Choose models for this connection', onboardingChooseModelsHelp: 'You can enable more models from the connection details later.', onboardingEnabledModels: 'Enabled models', onboardingSearchModels: 'Search models', onboardingAddConnection: 'Add connection', onboardingBack: 'Back to edit',
+ onboardingAuthFailed: 'The key could not be verified. Check it and try again.', onboardingTimeout: 'Verification timed out. Check the network or proxy and try again.', onboardingNetwork: 'Could not reach the model provider. Check the network, proxy, or service URL.', onboardingUnavailable: 'The model connection service is unavailable. Try again later.', onboardingInvalidResponse: 'The model provider returned an unrecognized response. Try again later.', onboardingCatalogFull: 'The 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/settings/provider-add-form.tsx b/apps/desktop/src/renderer/settings/provider-add-form.tsx
index a3f9c592d6..88a8af26af 100644
--- a/apps/desktop/src/renderer/settings/provider-add-form.tsx
+++ b/apps/desktop/src/renderer/settings/provider-add-form.tsx
@@ -27,7 +27,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,
@@ -44,7 +44,9 @@ import { useActionGuard } from './use-action-guard';
import {
categoryLabel,
providerPanelActionErrorMessage,
+ type ApiKeyOnboardingBridge,
type ConnectionsBridge,
+ type DesktopConnectionOnboardingIdentity,
} from './provider-panel-shared';
import { getProviderSettingsCopy } from '../locales/settings-provider-copy';
import {
@@ -55,6 +57,10 @@ import {
} from './request-customization-editor';
import {
createProviderWithDiscovery,
+ apiKeyOnboardingRoute,
+ initialOnboardingModelIds,
+ initialManagedOnboardingPhase,
+ stableOnboardingModels,
validateAddProviderDraft,
type AddProviderIssue,
} from './provider-add-submission';
@@ -70,12 +76,27 @@ type ProviderFormError = {
message: string;
};
+type ManagedOnboardingPhase =
+ | { readonly kind: 'input' }
+ | {
+ readonly kind: 'models';
+ readonly models: ReturnType<typeof stableOnboardingModels>;
+ readonly selectedIds: readonly string[];
+ }
+ | { readonly kind: 'outcome_unknown' };
+
export function AddProviderForm(props: {
bridge: ConnectionsBridge;
+ apiKeyOnboardingBridge?: ApiKeyOnboardingBridge;
providerType: ProviderType;
existingSlugs: string[];
onCancel(): void;
onCreated(slug: string, modelDiscoveryError?: unknown): Promise<void>;
+ onOnboarded?(identity: DesktopConnectionOnboardingIdentity): Promise<void>;
+ onOnboardingOutcomeUnknown?(): Promise<void>;
+ managedSaveDispatched?: boolean;
+ onManagedSaveDispatched?(): number | undefined;
+ onManagedSaveSettled?(attemptId: number | undefined): void;
}) {
const locale = useUiLocale();
const copy = getProviderSettingsCopy(locale).add;
@@ -93,7 +114,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: initialManagedOnboardingPhase(props.managedSaveDispatched === true) },
+ error: null,
+ }));
+ const { managedPhase, error } = formState;
const [busy, setBusy] = useState(false);
const submitGuard = useActionGuard<'submit'>();
const addProviderMountedRef = useMountedRef();
@@ -106,6 +134,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,
@@ -129,18 +178,141 @@ 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;
+ }
+
+ function enterOutcomeUnknown() {
+ setApiKey('');
+ setBusy(false);
+ setManagedPhase({ kind: 'outcome_unknown' });
+ }
+
+ async function verifyManagedApiKey(normalizedApiKey: string) {
+ const onboarding = props.apiKeyOnboardingBridge;
+ if (!onboarding) return;
+ submitGuard.begin('submit');
+ setBusy(true);
+ try {
+ const result = await onboarding.verify({
+ target: { kind: 'create', providerType: props.providerType },
+ apiKey: normalizedApiKey || null,
+ baseUrl: null,
+ });
+ if (!addProviderMountedRef.current) return;
+ if (result.kind !== 'verified') {
+ setError({
+ field:
+ result.kind === 'failed' && result.errorClass === 'auth'
+ ? 'apiKey'
+ : 'form',
+ message: onboardingFailureMessage(result),
+ });
+ return;
+ }
+ const models = stableOnboardingModels(result.models);
+ const selectedIds = initialOnboardingModelIds(models, recommendedDefaultModel);
+ if (selectedIds.length === 0) {
+ setError({ field: 'form', message: copy.onboardingNoModels });
+ return;
+ }
+ setManagedPhase({ kind: 'models', models, selectedIds });
+ } catch (err) {
+ if (addProviderMountedRef.current) {
+ setError({ field: 'form', message: providerPanelActionErrorMessage(err, locale) });
+ }
+ } finally {
+ submitGuard.finish();
+ if (addProviderMountedRef.current) setBusy(false);
+ }
+ }
+
+ async function saveManagedApiKey(
+ normalizedApiKey: string,
+ phase: Extract<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');
+ const attemptId = props.onManagedSaveDispatched?.();
+ setBusy(true);
+ try {
+ const outcome = await onboarding.save({
+ target: { kind: 'create', providerType: props.providerType },
+ apiKey: normalizedApiKey || null,
+ baseUrl: null,
+ enabledModelIds: stableIds,
+ });
+ if (outcome.kind !== 'outcome_unknown') props.onManagedSaveSettled?.(attemptId);
+ if (!addProviderMountedRef.current) return;
+ if (outcome.kind === 'outcome_unknown') {
+ enterOutcomeUnknown();
+ return;
+ }
+ if (outcome.kind === 'not_saved') {
+ setError({ field: 'form', message: copy.onboardingUnavailable });
+ return;
+ }
+ const result = outcome.result;
+ if (result.kind === 'saved') {
+ setApiKey('');
+ await props.onOnboarded?.(result.connection);
+ return;
+ }
+ if (
+ result.kind === 'rejected' &&
+ (result.reason === 'model_unavailable' || result.reason === 'superseded')
+ ) {
+ setManagedPhase({ kind: 'input' });
+ }
+ if (result.kind === 'failed' && result.errorClass === 'auth') {
+ setManagedPhase({ kind: 'input' });
+ }
+ setError({
+ field: result.kind === 'failed' && result.errorClass === 'auth' ? 'apiKey' : 'form',
+ message: onboardingFailureMessage(result),
+ });
+ } catch {
+ if (addProviderMountedRef.current) enterOutcomeUnknown();
+ } finally {
+ submitGuard.finish();
+ if (addProviderMountedRef.current && managedPhase.kind !== 'outcome_unknown') setBusy(false);
+ }
+ }
+
async function submit() {
if (submitGuard.current !== null) return;
setError(null);
- const issue = validateAddProviderDraft({
- providerType: props.providerType,
- slug,
- existingSlugs: props.existingSlugs,
- apiKey,
- cloudflareAccountId,
- baseUrl,
- });
- if (issue) return setError({ field: issue.field, message: issueMessage(issue) });
const normalizedApiKey = apiKey.trim();
const normalizedCloudflareAccountId = cloudflareAccountId.trim();
const normalizedDefaultModel = defaultModel.trim();
@@ -153,6 +325,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 {
@@ -209,11 +406,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}
@@ -235,6 +434,85 @@ 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 && managedPhase.kind === 'outcome_unknown') {
+ 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 (
@@ -243,6 +521,7 @@ export function AddProviderForm(props: {
value={apiKey}
onChange={(next) => {
setApiKey(next);
+ resetManagedVerification();
clearFieldError('apiKey');
}}
placeholder={copy.apiKeyPlaceholder}
@@ -258,12 +537,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>
);
@@ -283,6 +580,7 @@ export function AddProviderForm(props: {
value={apiKey}
onChange={(next) => {
setApiKey(next);
+ resetManagedVerification();
clearFieldError('apiKey');
}}
placeholder={copy.apiKeyPlaceholder}
@@ -301,6 +599,7 @@ export function AddProviderForm(props: {
value={slug}
onChange={(value) => {
setSlug(value);
+ resetManagedVerification();
clearFieldError('slug');
}}
placeholder="my-provider"
@@ -314,7 +613,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}
@@ -324,6 +626,7 @@ export function AddProviderForm(props: {
value={cloudflareAccountId}
onChange={(value) => {
setCloudflareAccountId(value);
+ resetManagedVerification();
clearFieldError('accountId');
}}
placeholder={copy.accountIdPlaceholder}
@@ -341,6 +644,7 @@ export function AddProviderForm(props: {
value={baseUrl}
onChange={(value) => {
setBaseUrl(value);
+ resetManagedVerification();
clearFieldError('baseUrl');
}}
placeholder={defaults.baseUrl || 'https://…'}
@@ -357,7 +661,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-catalog-page.tsx b/apps/desktop/src/renderer/settings/provider-catalog-page.tsx
index b1d3c8ecad..8b25a49388 100644
--- a/apps/desktop/src/renderer/settings/provider-catalog-page.tsx
+++ b/apps/desktop/src/renderer/settings/provider-catalog-page.tsx
@@ -39,7 +39,11 @@ 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 {
+ type ApiKeyOnboardingBridge,
+ type ConnectionsBridge,
+ type DesktopConnectionOnboardingIdentity,
+} from './provider-panel-shared';
import { getProviderSettingsCopy } from '../locales/settings-provider-copy';
type CatalogCategory = ProviderCatalogGroup | 'all' | 'recommended' | 'accounts';
@@ -225,10 +229,16 @@ 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>;
+ managedSaveDispatched?: boolean;
+ onManagedSaveDispatched?(): number | undefined;
+ onManagedSaveSettled?(attemptId: number | undefined): void;
onAccountCreated(connection?: CreatedOAuthConnectionIdentity): Promise<void>;
labelledBy?: string;
}) {
@@ -244,10 +254,16 @@ 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}
+ managedSaveDispatched={props.managedSaveDispatched}
+ onManagedSaveDispatched={props.onManagedSaveDispatched}
+ onManagedSaveSettled={props.onManagedSaveSettled}
/>
</div>
);
diff --git a/apps/desktop/src/renderer/settings/providers-panel.tsx b/apps/desktop/src/renderer/settings/providers-panel.tsx
index 9e95ed559b..4dd4d6db23 100644
--- a/apps/desktop/src/renderer/settings/providers-panel.tsx
+++ b/apps/desktop/src/renderer/settings/providers-panel.tsx
@@ -54,9 +54,17 @@ 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 {
+ providerPanelActionErrorMessage,
+ type ApiKeyOnboardingBridge,
+ type ConnectionsBridge,
+ type DesktopConnectionOnboardingIdentity,
+} from './provider-panel-shared';
import { getProviderSettingsCopy } from '../locales/settings-provider-copy';
-import { useRuntimeHostSettingsErrorReporter } from './runtime-host-settings-target.js';
+import {
+ RuntimeHostSettingsGenerationBoundary,
+ useRuntimeHostSettingsErrorReporter,
+} from './runtime-host-settings-target.js';
export type { ConnectionsBridge } from './provider-panel-shared';
@@ -80,8 +88,16 @@ 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';
+ managedSaveDispatched?: true;
+ }
+ | {
+ kind: 'adopting-connection';
+ identity: DesktopConnectionOnboardingIdentity | CreatedOAuthConnectionIdentity;
+ }
| { kind: 'detail'; connectionId: string };
function backTarget(route: PanelRoute): PanelRoute {
@@ -89,8 +105,9 @@ function backTarget(route: PanelRoute): PanelRoute {
return { kind: 'list' };
}
-export function ProvidersPanel({ bridge, initialPage = 'connections', initialConnectionSlug, initialCreateProviderType, onInitialCatalogConsumed, onInitialCreateProviderConsumed }: {
+export function ProvidersPanel({ bridge, apiKeyOnboardingBridge, initialPage = 'connections', initialConnectionSlug, initialCreateProviderType, onInitialCatalogConsumed, onInitialCreateProviderConsumed }: {
bridge: ConnectionsBridge;
+ apiKeyOnboardingBridge?: ApiKeyOnboardingBridge;
initialPage?: 'connections' | 'catalog';
/**
* When set, open this connection's detail once the list has loaded.
@@ -111,6 +128,8 @@ export function ProvidersPanel({ bridge, initialPage = 'connections', initialCon
const [connections, setConnections] = useState<IdentifiedLlmConnection[]>([]);
const [defaultSlug, setDefaultSlug] = useState<string | null>(null);
const [route, setRoute] = useState<PanelRoute>({ kind: 'list' });
+ const hasOnboardingUncertainty =
+ apiKeyOnboardingBridge?.saveUncertainty.isUncertain() === true;
// Browsing state, not navigation state: it outlives the catalog so that
// backing out of a provider returns the user to the search they typed.
const [catalogFilter, setCatalogFilter] = useState<CatalogFilter>(CATALOG_INITIAL_FILTER);
@@ -165,15 +184,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,
);
@@ -199,7 +223,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.
@@ -213,7 +242,7 @@ export function ProvidersPanel({ bridge, initialPage = 'connections', initialCon
},
});
onInitialCreateProviderConsumed?.();
- }, [loading, initialCreateProviderType, onInitialCreateProviderConsumed]);
+ }, [hasOnboardingUncertainty, loading, initialCreateProviderType, onInitialCreateProviderConsumed]);
function goBack() {
setRoute(backTarget(route));
@@ -236,12 +265,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'
@@ -394,6 +423,7 @@ export function ProvidersPanel({ bridge, initialPage = 'connections', initialCon
<VStack gap={5}>
<SettingsRouteHeader
onBack={goBack}
+ isBackDisabled={route.managedSaveDispatched === true}
backLabel={route.origin === 'catalog' ? copy.backToCatalog : copy.backToList}
logo={<ProviderLogo type={route.target.providerType} compact />}
titleId={setupTitleId}
@@ -404,18 +434,44 @@ 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}
+ managedSaveDispatched={route.managedSaveDispatched}
+ onManagedSaveDispatched={() => {
+ const attemptId = apiKeyOnboardingBridge?.saveUncertainty.markDispatched();
+ setRoute((current) => current.kind === 'setup'
+ ? { ...current, managedSaveDispatched: true }
+ : current);
+ return attemptId;
+ }}
+ onManagedSaveSettled={(attemptId) => {
+ if (attemptId !== undefined) {
+ apiKeyOnboardingBridge?.saveUncertainty.settle(attemptId);
+ }
+ setRoute((current) => current.kind === 'setup'
+ ? { kind: 'setup', target: current.target, origin: current.origin }
+ : current);
+ }}
+ 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
@@ -438,8 +494,9 @@ export function ProvidersPanel({ bridge, initialPage = 'connections', initialCon
),
);
}
- }}
- />
+ }}
+ />
+ </RuntimeHostSettingsGenerationBoundary>
</VStack>
) : (
<>
@@ -458,23 +515,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 ? (
@@ -489,7 +572,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/settings-surface.tsx b/apps/desktop/src/renderer/settings/settings-surface.tsx
index f202d45521..f4bf4a1870 100644
--- a/apps/desktop/src/renderer/settings/settings-surface.tsx
+++ b/apps/desktop/src/renderer/settings/settings-surface.tsx
@@ -91,6 +91,7 @@ import { WebSearchSettingsPage } from './web-search-settings-page';
import type { UiLocaleUpdateGate } from './ui-locale-update-gate';
import { getSettingsSharedCopy } from '../locales/settings-shared-copy.js';
import {
+ runtimeHostApiKeyOnboardingBridge,
runtimeHostConnectionsBridge,
type RuntimeHostSettingsConnectionsBridge,
} from './runtime-host-settings-bridge.js';
@@ -350,6 +351,12 @@ export function SettingsSurface(props: {
: undefined,
[selectedRuntimeHost],
);
+ const apiKeyOnboardingBridge = useMemo(
+ () => selectedRuntimeHost
+ ? runtimeHostApiKeyOnboardingBridge(selectedRuntimeHost)
+ : undefined,
+ [selectedRuntimeHost],
+ );
const selectedRuntimeHostKey = selectedRuntimeHost
? runtimeHostSettingsKey(selectedRuntimeHost)
: undefined;
@@ -1011,6 +1018,7 @@ export function SettingsSurface(props: {
}
connections={connections}
connectionsBridge={connectionsBridge}
+ apiKeyOnboardingBridge={apiKeyOnboardingBridge}
defaultSlug={defaultSlug}
runtimeHost={selectedRuntimeHost}
runtimeHostAvailabilityStatus={runtimeHostAvailabilityStatus}
@@ -1066,6 +1074,7 @@ function SettingsPageBody(props: {
usageStats: UsageStats | null;
connections: IdentifiedLlmConnection[];
connectionsBridge: RuntimeHostSettingsConnectionsBridge | undefined;
+ apiKeyOnboardingBridge: ReturnType<typeof runtimeHostApiKeyOnboardingBridge> | undefined;
defaultSlug: string | null;
runtimeHost: DesktopRuntimeHostRef | undefined;
runtimeHostAvailabilityStatus: RuntimeHostAvailabilityStatus;
@@ -1112,6 +1121,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/stories/settings/provider-settings.stories.tsx b/apps/desktop/stories/settings/provider-settings.stories.tsx
index d8b9b296ec..a8c2edb813 100644
--- a/apps/desktop/stories/settings/provider-settings.stories.tsx
+++ b/apps/desktop/stories/settings/provider-settings.stories.tsx
@@ -19,7 +19,7 @@
import { useEffect, useRef, type ReactNode } from 'react';
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 {
@@ -33,6 +33,7 @@ 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/settings/provider-panel-shared';
const NOW = Date.parse('2026-07-01T08:00:00Z');
@@ -245,16 +246,24 @@ const oauthConnections = [
}),
];
+interface StoryConnectionsBridge extends ConnectionsBridge {
+ addFixtureConnection(connection: IdentifiedLlmConnection): void;
+}
+
function createBridge(input: {
connections?: IdentifiedLlmConnection[];
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('模型连接服务暂时不可用');
@@ -341,6 +350,98 @@ 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 apiKeyOnboardingBridge: ApiKeyOnboardingBridge = {
+ saveUncertainty: {
+ isUncertain: () => uncertainAttemptId !== undefined,
+ markDispatched: () => {
+ const attemptId = nextAttemptId++;
+ uncertainAttemptId = attemptId;
+ return attemptId;
+ },
+ settle: (attemptId) => {
+ if (uncertainAttemptId === attemptId) uncertainAttemptId = undefined;
+ },
+ restart: () => { uncertainAttemptId = undefined; },
+ },
+ async verify() {
+ return {
+ kind: 'verified',
+ models: [
+ { id: 'deepseek-reasoner', displayName: 'DeepSeek Reasoner' },
+ { id: 'deepseek-chat', displayName: 'DeepSeek Chat' },
+ ],
+ };
+ },
+ async save() {
+ if (options.save === 'outcome_unknown') return { kind: 'outcome_unknown' };
+ if (options.save === 'auth_failed') {
+ 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;
+ 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;
@@ -449,6 +550,7 @@ function browserSubscriptionFixture(state: {
function ProviderStoryFrame(props: {
bridge: ConnectionsBridge;
+ apiKeyOnboardingBridge?: ApiKeyOnboardingBridge;
autoOpen?: AutoOpenTarget;
onOAuthComplete?: () => void;
}) {
@@ -511,7 +613,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>
)}
@@ -588,16 +693,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' })} />,
@@ -770,3 +890,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');
+ },
+};
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org