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 <noreply@anthropic.com>
This commit is contained in:
Jiayuan Zhang 2026-02-10 23:02:45 +08:00
parent d7ccbf066e
commit a143187da1
4 changed files with 124 additions and 23 deletions

View file

@ -194,6 +194,7 @@ interface ElectronAPI {
isAvailable: (providerId: string) => Promise<boolean>
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<ChannelAccountStateInfo[]>

View file

@ -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)
}
)
}

View file

@ -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.)

View file

@ -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<string | null>(null)
const [saving, setSaving] = useState(false)
const [phase, setPhase] = useState<Phase>('input')
const [error, setError] = useState<string | null>(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}
</DialogTitle>
<DialogDescription>
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.
</DialogDescription>
</DialogHeader>
@ -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)}
>
<ComboboxInput placeholder="Search models..." showClear />
<ComboboxInput
placeholder="Search models..."
showClear
disabled={busy || phase === 'success'}
/>
<ComboboxContent>
<ComboboxList>
{models.map((model) => (
<ComboboxItem key={model} value={model} textValue={model}>
<ComboboxItem key={model} value={model}>
{model}
</ComboboxItem>
))}
@ -138,6 +173,28 @@ export function ApiKeyDialog({
</div>
)}
{/* Status messages */}
{phase === 'saving' && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<HugeiconsIcon icon={Loading03Icon} className="size-4 animate-spin" />
Saving API key...
</div>
)}
{phase === 'testing' && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<HugeiconsIcon icon={Loading03Icon} className="size-4 animate-spin" />
Testing connection...
</div>
)}
{phase === 'success' && (
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
<HugeiconsIcon icon={Tick02Icon} className="size-4" />
Connected successfully!
</div>
)}
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
@ -148,13 +205,28 @@ export function ApiKeyDialog({
</div>
<DialogFooter>
<Button variant="outline" onClick={() => handleClose(false)} disabled={saving}>
Cancel
</Button>
<Button onClick={handleSave} disabled={saving || !apiKey.trim() || (showModelInput && !modelId)}>
{saving && <HugeiconsIcon icon={Loading03Icon} className="size-4 animate-spin mr-2" />}
Save
</Button>
{phase === 'error' ? (
<>
<Button variant="outline" onClick={() => handleClose(false)}>
Cancel
</Button>
<Button onClick={handleRetry}>
Try again
</Button>
</>
) : (
<>
<Button variant="outline" onClick={() => handleClose(false)} disabled={busy || phase === 'success'}>
Cancel
</Button>
<Button
onClick={handleSave}
disabled={busy || phase === 'success' || !apiKey.trim() || (showModelInput && !modelId)}
>
Save & Test
</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>