From a143187da14992d34d7b2eb9258a957c1bea7808 Mon Sep 17 00:00:00 2001 From: Jiayuan Zhang Date: Tue, 10 Feb 2026 23:02:45 +0800 Subject: [PATCH] feat(desktop): add provider connection test to API key dialog Save & Test flow: saves API key, then sends a minimal prompt to verify the provider is reachable. Shows phase-based status (saving/testing/ success/error) with auto-close on success. Co-Authored-By: Claude Opus 4.6 --- apps/desktop/src/main/electron-env.d.ts | 1 + apps/desktop/src/main/ipc/provider.ts | 25 ++++ apps/desktop/src/preload/index.ts | 3 + .../src/components/api-key-dialog.tsx | 118 ++++++++++++++---- 4 files changed, 124 insertions(+), 23 deletions(-) diff --git a/apps/desktop/src/main/electron-env.d.ts b/apps/desktop/src/main/electron-env.d.ts index 09ea4db7..a58fcdb2 100644 --- a/apps/desktop/src/main/electron-env.d.ts +++ b/apps/desktop/src/main/electron-env.d.ts @@ -194,6 +194,7 @@ interface ElectronAPI { isAvailable: (providerId: string) => Promise saveApiKey: (providerId: string, apiKey: string) => Promise<{ ok: boolean; error?: string }> importOAuth: (providerId: string) => Promise<{ ok: boolean; expiresAt?: number; error?: string }> + test: (providerId: string, modelId?: string) => Promise<{ ok: boolean; error?: string }> } channels: { listStates: () => Promise diff --git a/apps/desktop/src/main/ipc/provider.ts b/apps/desktop/src/main/ipc/provider.ts index 40a75eb8..da3719bd 100644 --- a/apps/desktop/src/main/ipc/provider.ts +++ b/apps/desktop/src/main/ipc/provider.ts @@ -307,4 +307,29 @@ export function registerProviderIpcHandlers(): void { } } ) + + /** + * Test a provider connection by sending a minimal prompt. + * Temporarily switches to the target provider, runs a test, then restores. + */ + ipcMain.handle( + 'provider:test', + async (_event, providerId: string, modelId?: string): Promise<{ ok: boolean; error?: string }> => { + const agent = getDefaultAgent() + if (!agent) { + return { ok: false, error: 'No agent available. Please wait for initialization.' } + } + + const meta = getProviderMeta(providerId) + if (!meta) { + return { ok: false, error: `Unknown provider: ${providerId}` } + } + + if (!isProviderAvailable(providerId)) { + return { ok: false, error: `Provider "${meta.name}" is not configured.` } + } + + return agent.testProvider(providerId, modelId) + } + ) } diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 60d6a2fc..70fe35bb 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -193,6 +193,9 @@ const electronAPI = { /** Import OAuth credentials from CLI tools (claude-code, codex) */ importOAuth: (providerId: string): Promise<{ ok: boolean; expiresAt?: number; error?: string }> => ipcRenderer.invoke('provider:importOAuth', providerId), + /** Test a provider connection with a minimal prompt */ + test: (providerId: string, modelId?: string): Promise<{ ok: boolean; error?: string }> => + ipcRenderer.invoke('provider:test', providerId, modelId), }, // Channel management (Telegram, Discord, etc.) diff --git a/apps/desktop/src/renderer/src/components/api-key-dialog.tsx b/apps/desktop/src/renderer/src/components/api-key-dialog.tsx index 165b956a..56a2187b 100644 --- a/apps/desktop/src/renderer/src/components/api-key-dialog.tsx +++ b/apps/desktop/src/renderer/src/components/api-key-dialog.tsx @@ -19,7 +19,9 @@ import { ComboboxEmpty, } from '@multica/ui/components/ui/combobox' import { HugeiconsIcon } from '@hugeicons/react' -import { Loading03Icon, Key01Icon } from '@hugeicons/core-free-icons' +import { Loading03Icon, Key01Icon, Tick02Icon } from '@hugeicons/core-free-icons' + +type Phase = 'input' | 'saving' | 'testing' | 'success' | 'error' interface ApiKeyDialogProps { open: boolean @@ -42,9 +44,11 @@ export function ApiKeyDialog({ }: ApiKeyDialogProps) { const [apiKey, setApiKey] = useState('') const [modelId, setModelId] = useState(null) - const [saving, setSaving] = useState(false) + const [phase, setPhase] = useState('input') const [error, setError] = useState(null) + const busy = phase === 'saving' || phase === 'testing' + const handleSave = async () => { if (!apiKey.trim()) { setError('API key is required') @@ -56,34 +60,60 @@ export function ApiKeyDialog({ return } - setSaving(true) setError(null) + setPhase('saving') try { const result = await window.electronAPI.provider.saveApiKey(providerId, apiKey.trim()) - if (result.ok) { + if (!result.ok) { + setError(result.error ?? 'Failed to save API key') + setPhase('error') + return + } + + // Test the connection + setPhase('testing') + const effectiveModel = showModelInput && modelId ? modelId : undefined + const testResult = await window.electronAPI.provider.test(providerId, effectiveModel) + + if (!testResult.ok) { + setError(testResult.error ?? 'Connection test failed') + setPhase('error') + return + } + + setPhase('success') + // Auto-close after brief success display + setTimeout(() => { setApiKey('') setModelId(null) + setPhase('input') + setError(null) onOpenChange(false) - onSuccess?.(showModelInput && modelId ? modelId : undefined) - } else { - setError(result.error ?? 'Failed to save API key') - } + onSuccess?.(effectiveModel) + }, 1000) } catch (err) { const message = err instanceof Error ? err.message : String(err) setError(message) - } finally { - setSaving(false) + setPhase('error') } } + const handleRetry = () => { + setError(null) + setPhase('input') + } + const handleClose = (isOpen: boolean) => { - if (!isOpen) { + if (!isOpen && !busy) { setApiKey('') setModelId(null) + setPhase('input') setError(null) } - onOpenChange(isOpen) + if (!busy) { + onOpenChange(isOpen) + } } return ( @@ -95,7 +125,7 @@ export function ApiKeyDialog({ Configure {providerName} - Enter your API key to enable {providerName}. The key will be saved securely in your credentials file. + Enter your API key to enable {providerName}. The key will be saved and tested automatically. @@ -108,8 +138,9 @@ export function ApiKeyDialog({ value={apiKey} onChange={(e) => setApiKey(e.target.value)} placeholder="sk-..." + disabled={busy || phase === 'success'} onKeyDown={(e) => { - if (e.key === 'Enter' && !saving) { + if (e.key === 'Enter' && !busy) { handleSave() } }} @@ -123,11 +154,15 @@ export function ApiKeyDialog({ value={modelId} onValueChange={(value) => setModelId(value)} > - + {models.map((model) => ( - + {model} ))} @@ -138,6 +173,28 @@ export function ApiKeyDialog({ )} + {/* Status messages */} + {phase === 'saving' && ( +
+ + Saving API key... +
+ )} + + {phase === 'testing' && ( +
+ + Testing connection... +
+ )} + + {phase === 'success' && ( +
+ + Connected successfully! +
+ )} + {error && (

{error}

)} @@ -148,13 +205,28 @@ export function ApiKeyDialog({ - - + {phase === 'error' ? ( + <> + + + + ) : ( + <> + + + + )}