feat(onboarding): allow adding multiple provider keys in BYOK

This commit is contained in:
Prakhar Pandey 2026-06-19 12:47:35 +05:30
parent 36da053b8d
commit 2fb0e7402a
2 changed files with 53 additions and 24 deletions

View file

@ -36,7 +36,8 @@ export function LlmSetupStep({ state }: LlmSetupStepProps) {
llmProvider, setLlmProvider, modelsLoading, modelsError, llmProvider, setLlmProvider, modelsLoading, modelsError,
activeConfig, testState, setTestState, showApiKey, activeConfig, testState, setTestState, showApiKey,
showBaseURL, canTest, showMoreProviders, setShowMoreProviders, showBaseURL, canTest, showMoreProviders, setShowMoreProviders,
updateProviderConfig, handleTestAndSaveLlmConfig, handleBack, updateProviderConfig, handleTestAndSaveLlmConfig, handleTestAndAddAnother,
connectedFlavors, handleNext, handleBack,
upsellDismissed, setUpsellDismissed, handleSwitchToRowboat, upsellDismissed, setUpsellDismissed, handleSwitchToRowboat,
} = state } = state
@ -77,6 +78,9 @@ export function LlmSetupStep({ state }: LlmSetupStepProps) {
<div className="text-sm font-semibold">{provider.name}</div> <div className="text-sm font-semibold">{provider.name}</div>
<div className="text-xs text-muted-foreground">{provider.description}</div> <div className="text-xs text-muted-foreground">{provider.description}</div>
</div> </div>
{connectedFlavors.has(provider.id) && (
<CheckCircle2 className="size-4 text-green-600 dark:text-green-400 ml-auto shrink-0" />
)}
</div> </div>
</motion.button> </motion.button>
) )
@ -232,14 +236,23 @@ export function LlmSetupStep({ state }: LlmSetupStepProps) {
</span> </span>
)} )}
<Button <Button
onClick={handleTestAndSaveLlmConfig} variant="outline"
onClick={handleTestAndAddAnother}
disabled={!canTest || testState.status === "testing"} disabled={!canTest || testState.status === "testing"}
>
Save & add another
</Button>
<Button
onClick={canTest ? handleTestAndSaveLlmConfig : handleNext}
disabled={testState.status === "testing" || (!canTest && connectedFlavors.size === 0)}
className="min-w-[140px]" className="min-w-[140px]"
> >
{testState.status === "testing" ? ( {testState.status === "testing" ? (
<><Loader2 className="size-4 animate-spin mr-2" />Testing...</> <><Loader2 className="size-4 animate-spin mr-2" />Testing...</>
) : ( ) : (canTest || connectedFlavors.size === 0) ? (
"Test & Continue" "Test & Continue"
) : (
"Continue"
)} )}
</Button> </Button>
</div> </div>

View file

@ -41,6 +41,7 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
const [testState, setTestState] = useState<{ status: "idle" | "testing" | "success" | "error"; error?: string }>({ const [testState, setTestState] = useState<{ status: "idle" | "testing" | "success" | "error"; error?: string }>({
status: "idle", status: "idle",
}) })
const [connectedFlavors, setConnectedFlavors] = useState<Set<LlmProviderFlavor>>(new Set())
const [showMoreProviders, setShowMoreProviders] = useState(false) const [showMoreProviders, setShowMoreProviders] = useState(false)
// OAuth provider states // OAuth provider states
@ -409,17 +410,15 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
onComplete() onComplete()
}, [onComplete]) }, [onComplete])
const handleTestAndSaveLlmConfig = useCallback(async () => { // Test the active provider's credentials and persist its config. Returns
if (!canTest) return // whether it succeeded so callers can decide whether to advance or stay.
const testAndSaveActiveProvider = useCallback(async (): Promise<boolean> => {
if (!canTest) return false
setTestState({ status: "testing" }) setTestState({ status: "testing" })
try { try {
const apiKey = activeConfig.apiKey.trim() || undefined const apiKey = activeConfig.apiKey.trim() || undefined
const baseURL = activeConfig.baseURL.trim() || undefined const baseURL = activeConfig.baseURL.trim() || undefined
const provider = { const provider = { flavor: llmProvider, apiKey, baseURL }
flavor: llmProvider,
apiKey,
baseURL,
}
// Fetch the provider's models from the key — this both validates the // Fetch the provider's models from the key — this both validates the
// credentials and gives us the list to populate the chat picker. // credentials and gives us the list to populate the chat picker.
@ -427,33 +426,48 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
if (!result.success) { if (!result.success) {
setTestState({ status: "error", error: result.error }) setTestState({ status: "error", error: result.error })
toast.error(result.error || "Connection test failed") toast.error(result.error || "Connection test failed")
return return false
} }
const models: string[] = result.models ?? [] const models: string[] = result.models ?? []
const preferred = preferredDefaults[llmProvider] const preferred = preferredDefaults[llmProvider]
const model = const model =
(preferred && models.includes(preferred) && preferred) || (preferred && models.includes(preferred) && preferred) ||
models[0] || models[0] || activeConfig.model.trim() || ""
activeConfig.model.trim() ||
""
const providerConfig = { await window.ipc.invoke("models:saveConfig", { provider, model, models })
provider,
model,
models,
}
setTestState({ status: "success" })
await window.ipc.invoke("models:saveConfig", providerConfig)
window.dispatchEvent(new Event('models-config-changed')) window.dispatchEvent(new Event('models-config-changed'))
handleNext() setTestState({ status: "success" })
setConnectedFlavors(prev => new Set(prev).add(llmProvider))
return true
} catch (error) { } catch (error) {
console.error("Connection test failed:", error) console.error("Connection test failed:", error)
setTestState({ status: "error", error: "Connection test failed" }) setTestState({ status: "error", error: "Connection test failed" })
toast.error("Connection test failed") toast.error("Connection test failed")
return false
} }
}, [activeConfig.apiKey, activeConfig.baseURL, activeConfig.model, canTest, llmProvider, handleNext]) }, [activeConfig.apiKey, activeConfig.baseURL, activeConfig.model, canTest, llmProvider])
// Save the active provider and advance to the next step.
const handleTestAndSaveLlmConfig = useCallback(async () => {
const ok = await testAndSaveActiveProvider()
if (ok) handleNext()
}, [testAndSaveActiveProvider, handleNext])
// Save the active provider but stay on the step. Switch to the next provider the
// user hasn't connected yet so the form is fresh and the buttons re-enable once
// they enter that key. (Clearing the current field instead left the buttons
// disabled on an empty form with no clear next step.)
const handleTestAndAddAnother = useCallback(async () => {
const ok = await testAndSaveActiveProvider()
if (!ok) return
// setConnectedFlavors is async, so include the just-saved provider here.
const connectedNow = new Set(connectedFlavors).add(llmProvider)
const order: LlmProviderFlavor[] = ["openai", "anthropic", "google", "openrouter", "aigateway", "ollama", "openai-compatible"]
const next = order.find(p => !connectedNow.has(p))
if (next) setLlmProvider(next)
setTestState({ status: "idle" })
}, [testAndSaveActiveProvider, connectedFlavors, llmProvider])
// Check connection status for all providers // Check connection status for all providers
const refreshAllStatuses = useCallback(async () => { const refreshAllStatuses = useCallback(async () => {
@ -639,10 +653,12 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
showBaseURL, showBaseURL,
isLocalProvider, isLocalProvider,
canTest, canTest,
connectedFlavors,
showMoreProviders, showMoreProviders,
setShowMoreProviders, setShowMoreProviders,
updateProviderConfig, updateProviderConfig,
handleTestAndSaveLlmConfig, handleTestAndSaveLlmConfig,
handleTestAndAddAnother,
// OAuth state // OAuth state
providers, providers,