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:
parent
d7ccbf066e
commit
a143187da1
4 changed files with 124 additions and 23 deletions
1
apps/desktop/src/main/electron-env.d.ts
vendored
1
apps/desktop/src/main/electron-env.d.ts
vendored
|
|
@ -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[]>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue