/** * Setup Wizard Page * First-time setup experience for new users */ import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { motion, AnimatePresence } from 'framer-motion'; import { Check, ChevronDown, ChevronLeft, ChevronRight, Loader2, AlertCircle, Eye, EyeOff, RefreshCw, CheckCircle2, XCircle, ExternalLink, Copy, } from 'lucide-react'; import { TitleBar } from '@/components/layout/TitleBar'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'; import { Label } from '@/components/ui/label'; import { cn } from '@/lib/utils'; import { useGatewayStore } from '@/stores/gateway'; import { useSettingsStore } from '@/stores/settings'; import { useTranslation } from 'react-i18next'; import type { TFunction } from 'i18next'; import { SUPPORTED_LANGUAGES } from '@/i18n'; import { toast } from 'sonner'; import { invokeIpc } from '@/lib/api-client'; import { hostApiFetch } from '@/lib/host-api'; import { subscribeHostEvent } from '@/lib/host-events'; interface SetupStep { id: string; title: string; description: string; } const STEP = { WELCOME: 0, RUNTIME: 1, PROVIDER: 2, INSTALLING: 3, COMPLETE: 4, } as const; const getSteps = (t: TFunction): SetupStep[] => [ { id: 'welcome', title: t('steps.welcome.title'), description: t('steps.welcome.description'), }, { id: 'runtime', title: t('steps.runtime.title'), description: t('steps.runtime.description'), }, { id: 'provider', title: t('steps.provider.title'), description: t('steps.provider.description'), }, { id: 'installing', title: t('steps.installing.title'), description: t('steps.installing.description'), }, { id: 'complete', title: t('steps.complete.title'), description: t('steps.complete.description'), }, ]; // Default skills to auto-install (no additional API keys required) interface DefaultSkill { id: string; name: string; description: string; } const getDefaultSkills = (t: TFunction): DefaultSkill[] => [ { id: 'opencode', name: t('defaultSkills.opencode.name'), description: t('defaultSkills.opencode.description') }, { id: 'python-env', name: t('defaultSkills.python-env.name'), description: t('defaultSkills.python-env.description') }, { id: 'code-assist', name: t('defaultSkills.code-assist.name'), description: t('defaultSkills.code-assist.description') }, { id: 'file-tools', name: t('defaultSkills.file-tools.name'), description: t('defaultSkills.file-tools.description') }, { id: 'terminal', name: t('defaultSkills.terminal.name'), description: t('defaultSkills.terminal.description') }, ]; import { SETUP_PROVIDERS, type ProviderAccount, type ProviderType, type ProviderTypeInfo, getProviderDocsUrl, getProviderIconUrl, normalizeProviderApiKeyInput, resolveProviderApiKeyForSave, resolveProviderModelForSave, shouldInvertInDark, shouldShowProviderModelId, } from '@/lib/providers'; import { buildProviderAccountId, fetchProviderSnapshot, hasConfiguredCredentials, pickPreferredAccount, } from '@/lib/provider-accounts'; import clawxIcon from '@/assets/logo.svg'; // Use the shared provider registry for setup providers const providers = SETUP_PROVIDERS; function getProtocolBaseUrlPlaceholder( apiProtocol: ProviderAccount['apiProtocol'], ): string { if (apiProtocol === 'anthropic-messages') { return 'https://api.example.com/anthropic'; } return 'https://api.example.com/v1'; } // NOTE: Channel types moved to Settings > Channels page // NOTE: Skill bundles moved to Settings > Skills page - auto-install essential skills during setup export function Setup() { const { t } = useTranslation(['setup', 'channels']); const navigate = useNavigate(); const [currentStep, setCurrentStep] = useState(STEP.WELCOME); // Setup state const [selectedProvider, setSelectedProvider] = useState(null); const [providerConfigured, setProviderConfigured] = useState(false); const [apiKey, setApiKey] = useState(''); // Installation state for the Installing step const [installedSkills, setInstalledSkills] = useState([]); // Runtime check status const [runtimeChecksPassed, setRuntimeChecksPassed] = useState(false); const steps = getSteps(t); const safeStepIndex = Number.isInteger(currentStep) ? Math.min(Math.max(currentStep, STEP.WELCOME), steps.length - 1) : STEP.WELCOME; const step = steps[safeStepIndex] ?? steps[STEP.WELCOME]; const isFirstStep = safeStepIndex === STEP.WELCOME; const isLastStep = safeStepIndex === steps.length - 1; const markSetupComplete = useSettingsStore((state) => state.markSetupComplete); // Derive canProceed based on current step - computed directly to avoid useEffect const canProceed = useMemo(() => { switch (safeStepIndex) { case STEP.WELCOME: return true; case STEP.RUNTIME: return runtimeChecksPassed; case STEP.PROVIDER: return providerConfigured; case STEP.INSTALLING: return false; // Cannot manually proceed, auto-proceeds when done case STEP.COMPLETE: return true; default: return true; } }, [safeStepIndex, providerConfigured, runtimeChecksPassed]); const handleNext = async () => { if (isLastStep) { // Complete setup markSetupComplete(); toast.success(t('complete.title')); navigate('/'); } else { setCurrentStep((i) => i + 1); } }; const handleBack = () => { setCurrentStep((i) => Math.max(i - 1, 0)); }; const handleSkip = () => { markSetupComplete(); navigate('/'); }; // Auto-proceed when installation is complete const handleInstallationComplete = useCallback((skills: string[]) => { setInstalledSkills(skills); // Auto-proceed to next step after a short delay setTimeout(() => { setCurrentStep((i) => i + 1); }, 1000); }, []); return (
{/* Progress Indicator */}
{steps.map((s, i) => (
{i < safeStepIndex ? ( ) : ( {i + 1} )}
{i < steps.length - 1 && (
)}
))}
{/* Step Content */}

{t(`steps.${step.id}.title`)}

{t(`steps.${step.id}.description`)}

{/* Step-specific content */}
{safeStepIndex === STEP.WELCOME && } {safeStepIndex === STEP.RUNTIME && } {safeStepIndex === STEP.PROVIDER && ( )} {safeStepIndex === STEP.INSTALLING && ( setCurrentStep((i) => i + 1)} /> )} {safeStepIndex === STEP.COMPLETE && ( )}
{/* Navigation - hidden during installation step */} {safeStepIndex !== STEP.INSTALLING && (
{!isFirstStep && ( )}
{!isLastStep && safeStepIndex !== STEP.RUNTIME && ( )}
)}
); } // ==================== Step Content Components ==================== function WelcomeContent() { const { t } = useTranslation(['setup', 'settings']); const { language, setLanguage } = useSettingsStore(); return (
ClawX

{t('welcome.title')}

{t('welcome.description')}

{/* Language Selector */}
{SUPPORTED_LANGUAGES.map((lang) => ( ))}
  • {t('welcome.features.noCommand')}
  • {t('welcome.features.modernUI')}
  • {t('welcome.features.bundles')}
  • {t('welcome.features.crossPlatform')}
); } interface RuntimeContentProps { onStatusChange: (canProceed: boolean) => void; } function RuntimeContent({ onStatusChange }: RuntimeContentProps) { const { t } = useTranslation('setup'); const gatewayStatus = useGatewayStore((state) => state.status); const startGateway = useGatewayStore((state) => state.start); const [checks, setChecks] = useState({ nodejs: { status: 'checking' as 'checking' | 'success' | 'error', message: '' }, openclaw: { status: 'checking' as 'checking' | 'success' | 'error', message: '' }, gateway: { status: 'checking' as 'checking' | 'success' | 'error', message: '' }, }); const [showLogs, setShowLogs] = useState(false); const [logContent, setLogContent] = useState(''); const [openclawDir, setOpenclawDir] = useState(''); const gatewayTimeoutRef = useRef(null); const runChecks = useCallback(async () => { // Reset checks setChecks({ nodejs: { status: 'checking', message: '' }, openclaw: { status: 'checking', message: '' }, gateway: { status: 'checking', message: '' }, }); // Check Node.js — always available in Electron setChecks((prev) => ({ ...prev, nodejs: { status: 'success', message: t('runtime.status.success') }, })); // Check OpenClaw package status try { const openclawStatus = await invokeIpc('openclaw:status') as { packageExists: boolean; isBuilt: boolean; dir: string; version?: string; }; setOpenclawDir(openclawStatus.dir); if (!openclawStatus.packageExists) { setChecks((prev) => ({ ...prev, openclaw: { status: 'error', message: `OpenClaw package not found at: ${openclawStatus.dir}` }, })); } else if (!openclawStatus.isBuilt) { setChecks((prev) => ({ ...prev, openclaw: { status: 'error', message: 'OpenClaw package found but dist is missing' }, })); } else { const versionLabel = openclawStatus.version ? ` v${openclawStatus.version}` : ''; setChecks((prev) => ({ ...prev, openclaw: { status: 'success', message: `OpenClaw package ready${versionLabel}` }, })); } } catch (error) { setChecks((prev) => ({ ...prev, openclaw: { status: 'error', message: `Check failed: ${error}` }, })); } // Check Gateway — read directly from store to avoid stale closure // Don't immediately report error; gateway may still be initializing const currentGateway = useGatewayStore.getState().status; if (currentGateway.state === 'running') { setChecks((prev) => ({ ...prev, gateway: { status: 'success', message: `Running on port ${currentGateway.port}` }, })); } else if (currentGateway.state === 'error') { setChecks((prev) => ({ ...prev, gateway: { status: 'error', message: currentGateway.error || t('runtime.status.error') }, })); } else { // Gateway is 'stopped', 'starting', or 'reconnecting' // Keep as 'checking' — the dedicated useEffect will update when status changes setChecks((prev) => ({ ...prev, gateway: { status: 'checking', message: currentGateway.state === 'starting' ? t('runtime.status.checking') : 'Waiting for gateway...' }, })); } }, [t]); useEffect(() => { runChecks(); }, [runChecks]); // Update canProceed when gateway status changes useEffect(() => { const allPassed = checks.nodejs.status === 'success' && checks.openclaw.status === 'success' && (checks.gateway.status === 'success' || gatewayStatus.state === 'running'); onStatusChange(allPassed); }, [checks, gatewayStatus, onStatusChange]); // Update gateway check when gateway status changes useEffect(() => { if (gatewayStatus.state === 'running') { setChecks((prev) => ({ ...prev, gateway: { status: 'success', message: t('runtime.status.gatewayRunning', { port: gatewayStatus.port }) }, })); } else if (gatewayStatus.state === 'error') { setChecks((prev) => ({ ...prev, gateway: { status: 'error', message: gatewayStatus.error || 'Failed to start' }, })); } else if (gatewayStatus.state === 'starting' || gatewayStatus.state === 'reconnecting') { setChecks((prev) => ({ ...prev, gateway: { status: 'checking', message: 'Starting...' }, })); } // 'stopped' state: keep current check status (likely 'checking') to allow startup time }, [gatewayStatus, t]); // Gateway startup timeout — show error only after giving enough time to initialize useEffect(() => { if (gatewayTimeoutRef.current) { clearTimeout(gatewayTimeoutRef.current); gatewayTimeoutRef.current = null; } // If gateway is already in a terminal state, no timeout needed if (gatewayStatus.state === 'running' || gatewayStatus.state === 'error') { return; } // Set timeout for non-terminal states (stopped, starting, reconnecting) gatewayTimeoutRef.current = setTimeout(() => { setChecks((prev) => { if (prev.gateway.status === 'checking') { return { ...prev, gateway: { status: 'error', message: 'Gateway startup timed out' }, }; } return prev; }); }, 600 * 1000); // 600 seconds — enough for gateway to fully initialize return () => { if (gatewayTimeoutRef.current) { clearTimeout(gatewayTimeoutRef.current); gatewayTimeoutRef.current = null; } }; }, [gatewayStatus.state]); const handleStartGateway = async () => { setChecks((prev) => ({ ...prev, gateway: { status: 'checking', message: 'Starting...' }, })); await startGateway(); }; const handleShowLogs = async () => { try { const logs = await hostApiFetch<{ content: string }>('/api/logs?tailLines=100'); setLogContent(logs.content); setShowLogs(true); } catch { setLogContent('(Failed to load logs)'); setShowLogs(true); } }; const handleOpenLogDir = async () => { try { const { dir: logDir } = await hostApiFetch<{ dir: string | null }>('/api/logs/dir'); if (logDir) { await invokeIpc('shell:showItemInFolder', logDir); } } catch { // ignore } }; const ERROR_TRUNCATE_LEN = 30; const renderStatus = (status: 'checking' | 'success' | 'error', message: string) => { if (status === 'checking') { return ( {message || 'Checking...'} ); } if (status === 'success') { return ( {message} ); } const isLong = message.length > ERROR_TRUNCATE_LEN; const displayMsg = isLong ? message.slice(0, ERROR_TRUNCATE_LEN) : message; return ( {displayMsg} {isLong && ( ... {message} )} ); }; return (

{t('runtime.title')}

{t('runtime.nodejs')}
{renderStatus(checks.nodejs.status, checks.nodejs.message)}
{t('runtime.openclaw')} {openclawDir && (

{openclawDir}

)}
{renderStatus(checks.openclaw.status, checks.openclaw.message)}
{t('runtime.gateway')} {checks.gateway.status === 'error' && ( )}
{renderStatus(checks.gateway.status, checks.gateway.message)}
{(checks.nodejs.status === 'error' || checks.openclaw.status === 'error') && (

{t('runtime.issue.title')}

{t('runtime.issue.desc')}

)} {/* Log viewer panel */} {showLogs && (

{t('runtime.logs.title')}

            {logContent || t('runtime.logs.noLogs')}
          
)}
); } interface ProviderContentProps { providers: ProviderTypeInfo[]; selectedProvider: string | null; onSelectProvider: (id: string | null) => void; apiKey: string; onApiKeyChange: (key: string) => void; onConfiguredChange: (configured: boolean) => void; } function ProviderContent({ providers, selectedProvider, onSelectProvider, apiKey, onApiKeyChange, onConfiguredChange, }: ProviderContentProps) { const { t, i18n } = useTranslation(['setup', 'settings']); const devModeUnlocked = useSettingsStore((state) => state.devModeUnlocked); const [showKey, setShowKey] = useState(false); const [validating, setValidating] = useState(false); const [keyValid, setKeyValid] = useState(null); const [selectedAccountId, setSelectedAccountId] = useState(null); const [baseUrl, setBaseUrl] = useState(''); const [modelId, setModelId] = useState(''); const [apiProtocol, setApiProtocol] = useState('openai-completions'); const [providerMenuOpen, setProviderMenuOpen] = useState(false); const providerMenuRef = useRef(null); const [authMode, setAuthMode] = useState<'oauth' | 'apikey'>('oauth'); const [arkMode, setArkMode] = useState<'apikey' | 'codeplan'>('apikey'); // OAuth Flow State const [oauthFlowing, setOauthFlowing] = useState(false); const [oauthData, setOauthData] = useState<{ mode: 'device'; verificationUri: string; userCode: string; expiresIn: number; } | { mode: 'manual'; authorizationUrl: string; message?: string; } | null>(null); const [manualCodeInput, setManualCodeInput] = useState(''); const [oauthError, setOauthError] = useState(null); const pendingOAuthRef = useRef<{ accountId: string; label: string } | null>(null); // Manage OAuth events useEffect(() => { const handleCode = (data: unknown) => { const payload = data as Record; if (payload?.mode === 'manual') { setOauthData({ mode: 'manual', authorizationUrl: String(payload.authorizationUrl || ''), message: typeof payload.message === 'string' ? payload.message : undefined, }); } else { setOauthData({ mode: 'device', verificationUri: String(payload.verificationUri || ''), userCode: String(payload.userCode || ''), expiresIn: Number(payload.expiresIn || 300), }); } setOauthError(null); }; const handleSuccess = async (data: unknown) => { setOauthFlowing(false); setOauthData(null); setManualCodeInput(''); setKeyValid(true); const payload = (data as { accountId?: string } | undefined) || undefined; const accountId = payload?.accountId || pendingOAuthRef.current?.accountId; if (accountId) { try { await hostApiFetch('/api/provider-accounts/default', { method: 'PUT', body: JSON.stringify({ accountId }), }); setSelectedAccountId(accountId); } catch (error) { console.error('Failed to set default provider account:', error); } } pendingOAuthRef.current = null; onConfiguredChange(true); toast.success(t('provider.valid')); }; const handleError = (data: unknown) => { setOauthError((data as { message: string }).message); setOauthData(null); pendingOAuthRef.current = null; }; const offCode = subscribeHostEvent('oauth:code', handleCode); const offSuccess = subscribeHostEvent('oauth:success', handleSuccess); const offError = subscribeHostEvent('oauth:error', handleError); return () => { offCode(); offSuccess(); offError(); }; }, [onConfiguredChange, t]); const handleStartOAuth = async () => { if (!selectedProvider) return; try { const snapshot = await fetchProviderSnapshot(); const existingVendorIds = new Set(snapshot.accounts.map((account) => account.vendorId)); if (selectedProvider === 'minimax-portal' && existingVendorIds.has('minimax-portal-cn')) { toast.error(t('settings:aiProviders.toast.minimaxConflict')); return; } if (selectedProvider === 'minimax-portal-cn' && existingVendorIds.has('minimax-portal')) { toast.error(t('settings:aiProviders.toast.minimaxConflict')); return; } } catch { // ignore check failure } setOauthFlowing(true); setOauthData(null); setManualCodeInput(''); setOauthError(null); try { const snapshot = await fetchProviderSnapshot(); const accountId = buildProviderAccountId( selectedProvider as ProviderType, selectedAccountId, snapshot.vendors, ); const label = selectedProviderData?.name || selectedProvider; pendingOAuthRef.current = { accountId, label }; await hostApiFetch('/api/providers/oauth/start', { method: 'POST', body: JSON.stringify({ provider: selectedProvider, accountId, label }), }); } catch (e) { setOauthError(String(e)); setOauthFlowing(false); pendingOAuthRef.current = null; } }; const handleCancelOAuth = async () => { setOauthFlowing(false); setOauthData(null); setManualCodeInput(''); setOauthError(null); pendingOAuthRef.current = null; await hostApiFetch('/api/providers/oauth/cancel', { method: 'POST' }); }; const handleSubmitManualOAuthCode = async () => { const value = manualCodeInput.trim(); if (!value) return; try { await hostApiFetch('/api/providers/oauth/submit', { method: 'POST', body: JSON.stringify({ code: value }), }); setOauthError(null); } catch (error) { setOauthError(String(error)); } }; // On mount, try to restore previously configured provider useEffect(() => { let cancelled = false; (async () => { try { const snapshot = await fetchProviderSnapshot(); const statusMap = new Map(snapshot.statuses.map((status) => [status.id, status])); const setupProviderTypes = new Set(providers.map((p) => p.id)); const setupCandidates = snapshot.accounts.filter((account) => setupProviderTypes.has(account.vendorId)); const preferred = (snapshot.defaultAccountId && setupCandidates.find((account) => account.id === snapshot.defaultAccountId)) || setupCandidates.find((account) => hasConfiguredCredentials(account, statusMap.get(account.id))) || setupCandidates[0]; if (preferred && !cancelled) { onSelectProvider(preferred.vendorId); setSelectedAccountId(preferred.id); const typeInfo = providers.find((p) => p.id === preferred.vendorId); const requiresKey = typeInfo?.requiresApiKey ?? false; onConfiguredChange(!requiresKey || hasConfiguredCredentials(preferred, statusMap.get(preferred.id))); const storedKey = (await hostApiFetch<{ apiKey: string | null }>( `/api/providers/${encodeURIComponent(preferred.id)}/api-key`, )).apiKey; onApiKeyChange(storedKey || ''); } else if (!cancelled) { onConfiguredChange(false); onApiKeyChange(''); } } catch (error) { if (!cancelled) { console.error('Failed to load provider list:', error); } } })(); return () => { cancelled = true; }; }, [onApiKeyChange, onConfiguredChange, onSelectProvider, providers]); // When provider changes, load stored key + reset base URL useEffect(() => { let cancelled = false; (async () => { if (!selectedProvider) return; setApiProtocol('openai-completions'); try { const snapshot = await fetchProviderSnapshot(); const statusMap = new Map(snapshot.statuses.map((status) => [status.id, status])); const preferredAccount = pickPreferredAccount( snapshot.accounts, snapshot.defaultAccountId, selectedProvider, statusMap, ); const accountIdForLoad = preferredAccount?.id || selectedProvider; setSelectedAccountId(preferredAccount?.id || null); const savedProvider = await hostApiFetch<{ baseUrl?: string; model?: string; apiProtocol?: ProviderAccount['apiProtocol'] } | null>( `/api/providers/${encodeURIComponent(accountIdForLoad)}`, ); const storedKey = (await hostApiFetch<{ apiKey: string | null }>( `/api/providers/${encodeURIComponent(accountIdForLoad)}/api-key`, )).apiKey; if (!cancelled) { onApiKeyChange(storedKey || ''); const info = providers.find((p) => p.id === selectedProvider); const nextBaseUrl = savedProvider?.baseUrl || info?.defaultBaseUrl || ''; const nextModelId = savedProvider?.model || info?.defaultModelId || ''; setBaseUrl(nextBaseUrl); setModelId(nextModelId); setApiProtocol(savedProvider?.apiProtocol || 'openai-completions'); if ( selectedProvider === 'ark' && info?.codePlanPresetBaseUrl && info?.codePlanPresetModelId && nextBaseUrl.trim() === info.codePlanPresetBaseUrl && nextModelId.trim() === info.codePlanPresetModelId ) { setArkMode('codeplan'); } else { setArkMode('apikey'); } } } catch (error) { if (!cancelled) { console.error('Failed to load provider key:', error); } } })(); return () => { cancelled = true; }; }, [onApiKeyChange, selectedProvider, providers]); useEffect(() => { if (!providerMenuOpen) return; const handlePointerDown = (event: MouseEvent) => { if (providerMenuRef.current && !providerMenuRef.current.contains(event.target as Node)) { setProviderMenuOpen(false); } }; const handleEscape = (event: KeyboardEvent) => { if (event.key === 'Escape') { setProviderMenuOpen(false); } }; document.addEventListener('mousedown', handlePointerDown); document.addEventListener('keydown', handleEscape); return () => { document.removeEventListener('mousedown', handlePointerDown); document.removeEventListener('keydown', handleEscape); }; }, [providerMenuOpen]); const selectedProviderData = providers.find((p) => p.id === selectedProvider); const providerDocsUrl = getProviderDocsUrl(selectedProviderData, i18n.language); const effectiveProviderDocsUrl = selectedProvider === 'ark' && arkMode === 'codeplan' ? (selectedProviderData?.codePlanDocsUrl || providerDocsUrl) : providerDocsUrl; const selectedProviderIconUrl = selectedProviderData ? getProviderIconUrl(selectedProviderData.id) : undefined; const showBaseUrlField = selectedProviderData?.showBaseUrl ?? false; const showModelIdField = shouldShowProviderModelId(selectedProviderData, devModeUnlocked); const codePlanPreset = selectedProviderData?.codePlanPresetBaseUrl && selectedProviderData?.codePlanPresetModelId ? { baseUrl: selectedProviderData.codePlanPresetBaseUrl, modelId: selectedProviderData.codePlanPresetModelId, } : null; const requiresKey = selectedProviderData?.requiresApiKey ?? false; const isOAuth = selectedProviderData?.isOAuth ?? false; const supportsApiKey = selectedProviderData?.supportsApiKey ?? false; const useOAuthFlow = isOAuth && (!supportsApiKey || authMode === 'oauth'); const normalizedApiKey = normalizeProviderApiKeyInput(apiKey); const handleValidateAndSave = async () => { if (!selectedProvider) return; try { const snapshot = await fetchProviderSnapshot(); const existingVendorIds = new Set(snapshot.accounts.map((account) => account.vendorId)); if (selectedProvider === 'minimax-portal' && existingVendorIds.has('minimax-portal-cn')) { toast.error(t('settings:aiProviders.toast.minimaxConflict')); return; } if (selectedProvider === 'minimax-portal-cn' && existingVendorIds.has('minimax-portal')) { toast.error(t('settings:aiProviders.toast.minimaxConflict')); return; } } catch { // ignore check failure } setValidating(true); setKeyValid(null); try { // Validate key if the provider requires one and a key was entered const isApiKeyRequired = requiresKey || (supportsApiKey && authMode === 'apikey'); if (isApiKeyRequired && !normalizedApiKey) { setKeyValid(false); onConfiguredChange(false); toast.error(t('provider.invalid')); setValidating(false); return; } if (isApiKeyRequired) { const result = await invokeIpc( 'provider:validateKey', selectedAccountId || selectedProvider, normalizedApiKey, { baseUrl: baseUrl.trim() || undefined, apiProtocol: (selectedProvider === 'custom' || selectedProvider === 'ollama') ? apiProtocol : undefined, } ) as { valid: boolean; error?: string }; setKeyValid(result.valid); if (!result.valid) { toast.error(result.error || t('provider.invalid')); setValidating(false); return; } } else { setKeyValid(true); } const effectiveModelId = resolveProviderModelForSave( selectedProviderData, modelId, devModeUnlocked ); const snapshot = await fetchProviderSnapshot(); const accountIdForSave = buildProviderAccountId( selectedProvider as ProviderType, selectedAccountId, snapshot.vendors, ); const effectiveApiKey = resolveProviderApiKeyForSave(selectedProvider, apiKey); const accountPayload: ProviderAccount = { id: accountIdForSave, vendorId: selectedProvider as ProviderType, label: selectedProvider === 'custom' ? t('settings:aiProviders.custom') : (selectedProviderData?.name || selectedProvider), authMode: selectedProvider === 'ollama' ? 'local' : 'api_key', baseUrl: baseUrl.trim() || undefined, apiProtocol: (selectedProvider === 'custom' || selectedProvider === 'ollama') ? apiProtocol : undefined, model: effectiveModelId, enabled: true, isDefault: false, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; const saveResult = selectedAccountId ? await hostApiFetch<{ success: boolean; error?: string }>( `/api/provider-accounts/${encodeURIComponent(accountIdForSave)}`, { method: 'PUT', body: JSON.stringify({ updates: { label: accountPayload.label, authMode: accountPayload.authMode, baseUrl: accountPayload.baseUrl, apiProtocol: accountPayload.apiProtocol, model: accountPayload.model, enabled: accountPayload.enabled, }, apiKey: effectiveApiKey, }), }, ) : await hostApiFetch<{ success: boolean; error?: string }>('/api/provider-accounts', { method: 'POST', body: JSON.stringify({ account: accountPayload, apiKey: effectiveApiKey }), }); if (!saveResult.success) { throw new Error(saveResult.error || 'Failed to save provider config'); } const defaultResult = await hostApiFetch<{ success: boolean; error?: string }>( '/api/provider-accounts/default', { method: 'PUT', body: JSON.stringify({ accountId: accountIdForSave }), }, ); if (!defaultResult.success) { throw new Error(defaultResult.error || 'Failed to set default provider'); } setSelectedAccountId(accountIdForSave); onConfiguredChange(true); toast.success(t('provider.valid')); } catch (error) { setKeyValid(false); onConfiguredChange(false); toast.error('Configuration failed: ' + String(error)); } finally { setValidating(false); } }; // Can the user submit? const isApiKeyRequired = requiresKey || (supportsApiKey && authMode === 'apikey'); const canSubmit = selectedProvider && (isApiKeyRequired ? normalizedApiKey.length > 0 : true) && (showModelIdField ? modelId.trim().length > 0 : true) && !useOAuthFlow; const handleSelectProvider = (providerId: string) => { onSelectProvider(providerId); setSelectedAccountId(null); onConfiguredChange(false); onApiKeyChange(''); setKeyValid(null); setProviderMenuOpen(false); setAuthMode('oauth'); setArkMode('apikey'); }; return (
{/* Provider selector — dropdown */}
{selectedProvider && effectiveProviderDocsUrl && ( {t('settings:aiProviders.dialog.customDoc')} )}
{providerMenuOpen && (
{providers.map((p) => { const iconUrl = getProviderIconUrl(p.id); const isSelected = selectedProvider === p.id; return ( ); })}
)}
{/* Dynamic config fields based on selected provider */} {selectedProvider && ( {codePlanPreset && (
{selectedProviderData?.codePlanDocsUrl && ( {t('provider.codePlanDoc')} )}
{arkMode === 'codeplan' && (

{t('provider.codePlanPresetDesc')}

)}
)} {/* Base URL field (for siliconflow, ollama, custom) */} {showBaseUrlField && (
{ setBaseUrl(e.target.value); onConfiguredChange(false); }} autoComplete="off" className="bg-background border-input" />
)} {/* Model ID field (for siliconflow etc.) */} {showModelIdField && (
{ setModelId(e.target.value); onConfiguredChange(false); }} autoComplete="off" className="bg-background border-input" />

{t('provider.modelIdDesc')}

)} {selectedProvider === 'custom' && (
)} {/* Auth mode toggle for providers supporting both */} {isOAuth && supportsApiKey && (
)} {/* API Key field (hidden for ollama) */} {(!isOAuth || (supportsApiKey && authMode === 'apikey')) && (
{ onApiKeyChange(e.target.value); onConfiguredChange(false); setKeyValid(null); }} autoComplete="off" className="pr-10 bg-background border-input" />
)} {/* Device OAuth Trigger */} {useOAuthFlow && (

This provider requires signing in via your browser.

{/* OAuth Active State Modal / Inline View */} {oauthFlowing && (
{/* Background pulse effect */}
{oauthError ? (

Authentication Failed

{oauthError}

) : !oauthData ? (

Requesting secure login code...

) : oauthData.mode === 'manual' ? (

Complete OpenAI Login

{oauthData.message || 'Open the authorization page, complete login, then paste the callback URL or code below.'}

setManualCodeInput(e.target.value)} />
) : (

Approve Login

1. Copy the authorization code below.

2. Open the login page in your browser.

3. Paste the code to approve access.

{oauthData.userCode}
Waiting for approval in browser...
)}
)}
)} {/* Validate & Save */} {keyValid !== null && (

{keyValid ? `✓ ${t('provider.valid')}` : `✗ ${t('provider.invalid')}`}

)}

{t('provider.storedLocally')}

)}
); } // NOTE: SkillsContent component removed - auto-install essential skills // Installation status for each skill type InstallStatus = 'pending' | 'installing' | 'completed' | 'failed'; interface SkillInstallState { id: string; name: string; description: string; status: InstallStatus; } interface InstallingContentProps { skills: DefaultSkill[]; onComplete: (installedSkills: string[]) => void; onSkip: () => void; } function InstallingContent({ skills, onComplete, onSkip }: InstallingContentProps) { const { t } = useTranslation('setup'); const [skillStates, setSkillStates] = useState( skills.map((s) => ({ ...s, status: 'pending' as InstallStatus })) ); const [overallProgress, setOverallProgress] = useState(0); const [errorMessage, setErrorMessage] = useState(null); const installStarted = useRef(false); // Real installation process useEffect(() => { if (installStarted.current) return; installStarted.current = true; const runRealInstall = async () => { try { // Step 1: Initialize all skills to 'installing' state for UI setSkillStates(prev => prev.map(s => ({ ...s, status: 'installing' }))); setOverallProgress(10); // Step 2: Call the backend to install uv and setup Python const result = await invokeIpc('uv:install-all') as { success: boolean; error?: string }; if (result.success) { setSkillStates(prev => prev.map(s => ({ ...s, status: 'completed' }))); setOverallProgress(100); await new Promise((resolve) => setTimeout(resolve, 800)); onComplete(skills.map(s => s.id)); } else { setSkillStates(prev => prev.map(s => ({ ...s, status: 'failed' }))); setErrorMessage(result.error || 'Unknown error during installation'); toast.error('Environment setup failed'); } } catch (err) { setSkillStates(prev => prev.map(s => ({ ...s, status: 'failed' }))); setErrorMessage(String(err)); toast.error('Installation error'); } }; runRealInstall(); }, [skills, onComplete]); const getStatusIcon = (status: InstallStatus) => { switch (status) { case 'pending': return
; case 'installing': return ; case 'completed': return ; case 'failed': return ; } }; const getStatusText = (skill: SkillInstallState) => { switch (skill.status) { case 'pending': return {t('installing.status.pending')}; case 'installing': return {t('installing.status.installing')}; case 'completed': return {t('installing.status.installed')}; case 'failed': return {t('installing.status.failed')}; } }; return (
⚙️

{t('installing.title')}

{t('installing.subtitle')}

{/* Progress bar */}
{t('installing.progress')} {overallProgress}%
{/* Skill list */}
{skillStates.map((skill) => (
{getStatusIcon(skill.status)}

{skill.name}

{skill.description}

{getStatusText(skill)}
))}
{/* Error Message Display */} {errorMessage && (

{t('installing.error')}

                {errorMessage}
              
)} {!errorMessage && (

{t('installing.wait')}

)}
); } interface CompleteContentProps { selectedProvider: string | null; installedSkills: string[]; } function CompleteContent({ selectedProvider, installedSkills }: CompleteContentProps) { const { t } = useTranslation(['setup', 'settings']); const gatewayStatus = useGatewayStore((state) => state.status); const providerData = providers.find((p) => p.id === selectedProvider); const installedSkillNames = getDefaultSkills(t) .filter((s: DefaultSkill) => installedSkills.includes(s.id)) .map((s: DefaultSkill) => s.name) .join(', '); return (
🎉

{t('complete.title')}

{t('complete.subtitle')}

{t('complete.provider')} {providerData ? {getProviderIconUrl(providerData.id) ? {providerData.name} : providerData.icon} {providerData.id === 'custom' ? t('settings:aiProviders.custom') : providerData.name} : '—'}
{t('complete.components')} {installedSkillNames || `${installedSkills.length} ${t('installing.status.installed')}`}
{t('complete.gateway')} {gatewayStatus.state === 'running' ? `✓ ${t('complete.running')}` : gatewayStatus.state}

{t('complete.footer')}

); } export default Setup;