From 12e22d27b7797f597d76c046203235f184556aed Mon Sep 17 00:00:00 2001 From: tian202611 <345238852@qq.com> Date: Sat, 30 May 2026 00:43:53 +0800 Subject: [PATCH] fix(onboarding): replace broken LLM config step with streamlined welcome flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The onboarding page's LlmConfigurationStep calls removed backend API endpoints (/api/config/provider, /api/config/test-connection), causing users to be stuck in an infinite loop — unable to configure their LLM provider and thus never able to enter the main interface. This fix: 1. Rewrites Onboarding.tsx as a concise welcome page with a 3-step quick start guide, guiding users to configure their LLM provider in Settings instead. 2. Fixes the /api/user/complete-onboarding endpoint to persist the completion flag to the database. 3. Updates /api/user/onboarding-status to check both the DB flag and the YAML config for backwards compatibility. 4. Removes the now-unused LlmConfigurationStep.tsx. Users can now enter the main interface immediately and configure their model provider in Settings at any time. --- ui/server/routes/user.js | 7 +- .../components/onboarding/view/Onboarding.tsx | 105 +++- .../subcomponents/LlmConfigurationStep.tsx | 496 ------------------ 3 files changed, 100 insertions(+), 508 deletions(-) delete mode 100644 ui/src/components/onboarding/view/subcomponents/LlmConfigurationStep.tsx diff --git a/ui/server/routes/user.js b/ui/server/routes/user.js index 6fb8439c..5fcc3d1a 100644 --- a/ui/server/routes/user.js +++ b/ui/server/routes/user.js @@ -122,6 +122,7 @@ router.post('/git-config', authenticateToken, async (req, res) => { router.post('/complete-onboarding', authenticateToken, async (req, res) => { try { + userDb.completeOnboarding(req.user.id); res.json({ success: true, message: 'Onboarding completed successfully' @@ -134,7 +135,11 @@ router.post('/complete-onboarding', authenticateToken, async (req, res) => { router.get('/onboarding-status', authenticateToken, async (req, res) => { try { - const hasCompleted = hasUsablePilotDeckConfig(); + // Check DB flag first (set when user clicks "Enter PilotDeck" in onboarding) + const dbCompleted = userDb.hasCompletedOnboarding(req.user.id); + // Fall back to checking YAML config for backwards compatibility + const configCompleted = hasUsablePilotDeckConfig(); + const hasCompleted = dbCompleted || configCompleted; res.json({ success: true, diff --git a/ui/src/components/onboarding/view/Onboarding.tsx b/ui/src/components/onboarding/view/Onboarding.tsx index b4d66709..5d8bbe08 100644 --- a/ui/src/components/onboarding/view/Onboarding.tsx +++ b/ui/src/components/onboarding/view/Onboarding.tsx @@ -1,16 +1,16 @@ import { useState } from 'react'; import { authenticatedFetch } from '../../../utils/api'; -import LlmConfigurationStep from './subcomponents/LlmConfigurationStep'; +import { Settings, ArrowRight, Sparkles } from 'lucide-react'; type OnboardingProps = { onComplete?: () => void | Promise; }; export default function Onboarding({ onComplete }: OnboardingProps) { - const [errorMessage, setErrorMessage] = useState(''); + const [isCompleting, setIsCompleting] = useState(false); - const handleSaved = async () => { - setErrorMessage(''); + const handleComplete = async () => { + setIsCompleting(true); try { const response = await authenticatedFetch('/api/user/complete-onboarding', { method: 'POST' }); if (!response.ok) { @@ -19,21 +19,104 @@ export default function Onboarding({ onComplete }: OnboardingProps) { } await onComplete?.(); } catch (caughtError) { - setErrorMessage(caughtError instanceof Error ? caughtError.message : 'Failed to complete onboarding'); + console.error('Onboarding completion failed:', caughtError); + } finally { + setIsCompleting(false); } }; return (
-
- +
+ {/* Header */} +
+
+ +
+

Welcome to PilotDeck

+

+ Your AI agent operating system for productive, multi-project workflows. +

+
+ +
- {errorMessage && ( -
-

{errorMessage}

+ {/* Quick Start Guide */} +
+

Quick Start

+ +
+
+
+ 1 +
+

Complete Setup

+

+ Click the button below to enter the main interface +

+
+ +
+
+ 2 +
+

Configure Model

+

+ Open Settings and add your LLM provider API key +

+
+ +
+
+ 3 +
+

Start Creating

+

+ Create a WorkSpace and begin your AI-powered workflow +

+
- )} +
+ + {/* Supported Providers */} +
+

Supported LLM Providers

+
+ {['OpenAI', 'Anthropic', 'DeepSeek', 'Google AI', 'MiniMax', 'SiliconFlow', 'OpenRouter'].map((provider) => ( + + {provider} + + ))} +
+
+ + {/* Action */} +
+ + +

+ + You can configure your LLM provider in Settings anytime +

+
diff --git a/ui/src/components/onboarding/view/subcomponents/LlmConfigurationStep.tsx b/ui/src/components/onboarding/view/subcomponents/LlmConfigurationStep.tsx deleted file mode 100644 index 3d529fec..00000000 --- a/ui/src/components/onboarding/view/subcomponents/LlmConfigurationStep.tsx +++ /dev/null @@ -1,496 +0,0 @@ -import { useCallback, useEffect, useState } from 'react'; -import { Check, ChevronDown, Loader2, Plus } from 'lucide-react'; -import { authenticatedFetch } from '../../../../utils/api'; -import { CATALOG_PROVIDERS, findCatalogProviderByUrl, type CatalogProvider } from '../../../../shared/catalogProviders'; - -type LlmConfigurationStepProps = { - onSaved: () => void | Promise; -}; - -type TestStatus = 'idle' | 'testing' | 'success' | 'error'; - -const PLACEHOLDER_API_KEY = 'PLACEHOLDER_RUN_ONBOARDING_TO_REPLACE'; -const MASKED_SECRET = '********'; - -// Sentinel id for the "+" tile. When selected, the form swaps in extra inputs -// (provider id, protocol, base URL) so the user can describe a provider that -// isn't in the catalog. -const CUSTOM_PROVIDER_ID = '__custom__'; - -const CUSTOM_PROVIDER: CatalogProvider = { - id: CUSTOM_PROVIDER_ID, - displayName: 'Custom', - protocol: 'openai', - defaultUrl: '', - models: [], -}; - -const DEFAULT_PROVIDER = CATALOG_PROVIDERS.find((provider) => provider.id === 'openrouter') ?? CATALOG_PROVIDERS[0]; - -function defaultModelForProvider(provider: CatalogProvider | null) { - if (!provider) return ''; - return provider.models.find((model) => model.id === 'deepseek/deepseek-v4-flash')?.id - ?? provider.models[0]?.id - ?? ''; -} - -function hasUsableApiKey(value: unknown) { - if (typeof value !== 'string') return false; - const key = value.trim(); - return Boolean(key) && key !== PLACEHOLDER_API_KEY && key !== MASKED_SECRET && !key.startsWith('PLACEHOLDER_'); -} - -export default function LlmConfigurationStep({ onSaved }: LlmConfigurationStepProps) { - const [selectedProvider, setSelectedProvider] = useState(DEFAULT_PROVIDER); - const [selectedModelId, setSelectedModelId] = useState(() => defaultModelForProvider(DEFAULT_PROVIDER)); - const [customModelId, setCustomModelId] = useState(''); - const [apiKey, setApiKey] = useState(''); - const [customUrl, setCustomUrl] = useState(''); - const [showAdvanced, setShowAdvanced] = useState(false); - - const [testStatus, setTestStatus] = useState('idle'); - const [testMessage, setTestMessage] = useState(''); - const [saving, setSaving] = useState(false); - - // Inputs that are only relevant when the user picks the "+" (custom) tile. - const [customProviderId, setCustomProviderId] = useState(''); - const [customProtocol, setCustomProtocol] = useState<'openai' | 'anthropic'>('openai'); - - const isCustomMode = selectedProvider?.id === CUSTOM_PROVIDER_ID; - const selectedModels = selectedProvider?.models ?? []; - const selectedDefaultUrl = selectedProvider?.defaultUrl ?? ''; - - useEffect(() => { - (async () => { - try { - const res = await authenticatedFetch('/api/config/provider'); - if (!res.ok) return; - const data = await res.json(); - if (!data.exists || !data.provider) return; - - const p = data.provider; - const existingKeyIsUsable = hasUsableApiKey(p.apiKey); - if (!existingKeyIsUsable) return; - setApiKey(p.apiKey); - if (p.baseUrl) { - const match = findCatalogProviderByUrl(p.baseUrl); - if (match) { - setSelectedProvider(match); - setSelectedModelId(p.model || defaultModelForProvider(match)); - } - } - } catch { /* no existing config */ } - })(); - }, []); - - const effectiveUrl = customUrl.trim() || selectedProvider?.defaultUrl || ''; - const effectiveModelId = customModelId.trim() || selectedModelId; - const effectiveProtocol: 'openai' | 'anthropic' = isCustomMode - ? customProtocol - : (selectedProvider?.protocol ?? 'openai'); - const effectiveProviderId = isCustomMode ? customProviderId.trim() : (selectedProvider?.id ?? ''); - const canTest = Boolean( - selectedProvider && - apiKey.trim() && - effectiveModelId && - effectiveProviderId && - (!isCustomMode || effectiveUrl.trim()), - ); - - const handleProviderSelect = useCallback((provider: CatalogProvider) => { - setSelectedProvider((prev) => { - // Switching to a different provider should not carry over the API key - // from the previously selected one (otherwise users adding a 2nd - // provider re-save their Anthropic key under OpenAI). - if (prev?.id !== provider.id) { - setApiKey(''); - } - return provider; - }); - setSelectedModelId(defaultModelForProvider(provider)); - setCustomModelId(''); - setCustomUrl(''); - setCustomProviderId(''); - setCustomProtocol('openai'); - setTestStatus('idle'); - setTestMessage(''); - }, []); - - const handleTest = useCallback(async () => { - if (!canTest || !selectedProvider) return; - setTestStatus('testing'); - setTestMessage(''); - try { - const res = await authenticatedFetch('/api/config/test-connection', { - method: 'POST', - body: JSON.stringify({ - providerType: effectiveProtocol, - baseUrl: effectiveUrl, - apiKey: apiKey.trim(), - model: effectiveModelId, - }), - }); - const data = await res.json(); - if (data.ok) { - setTestStatus('success'); - setTestMessage(data.message || 'Connected successfully.'); - } else { - setTestStatus('error'); - setTestMessage(data.error || 'Connection failed.'); - } - } catch (err) { - setTestStatus('error'); - setTestMessage(err instanceof Error ? err.message : 'Connection failed.'); - } - }, [canTest, selectedProvider, effectiveUrl, apiKey, effectiveModelId, effectiveProtocol]); - - const handleSave = useCallback(async () => { - if (!selectedProvider) return; - setSaving(true); - try { - const { stringify: stringifyYaml, parse: parseYaml } = await import('yaml'); - - let existingConfig: Record = {}; - try { - const res = await authenticatedFetch('/api/config'); - if (res.ok) { - const data = await res.json(); - if (data.raw) existingConfig = parseYaml(data.raw) || {}; - } - } catch { /* start fresh */ } - - const providerId = effectiveProviderId; - const modelId = effectiveModelId; - if (!providerId) throw new Error('Provider ID is required.'); - - if (!existingConfig.schemaVersion) { - (existingConfig as Record).schemaVersion = 1; - } - if (!existingConfig.model || typeof existingConfig.model !== 'object') { - (existingConfig as Record).model = { providers: {} }; - } - const modelSection = existingConfig.model as Record; - if (!modelSection.providers || typeof modelSection.providers !== 'object') { - modelSection.providers = {}; - } - - const yamlProviders = modelSection.providers as Record>; - const existingProvider = (yamlProviders[providerId] || {}) as Record; - const existingModels = ( - existingProvider.models && typeof existingProvider.models === 'object' - ? existingProvider.models - : {} - ) as Record; - - yamlProviders[providerId] = { - ...existingProvider, - protocol: effectiveProtocol, - url: effectiveUrl, - apiKey: apiKey.trim(), - timeoutMs: typeof existingProvider.timeoutMs === 'number' ? existingProvider.timeoutMs : 120000, - models: { - ...existingModels, - [modelId]: existingModels[modelId] || {}, - }, - }; - - if (!existingConfig.agent || typeof existingConfig.agent !== 'object') { - (existingConfig as Record).agent = {}; - } - (existingConfig.agent as Record).model = `${providerId}/${modelId}`; - - delete (existingConfig as Record).models; - delete (existingConfig as Record).agents; - delete (existingConfig as Record).version; - - const saveRes = await authenticatedFetch('/api/config', { - method: 'PUT', - body: JSON.stringify({ raw: stringifyYaml(existingConfig, { indent: 2, lineWidth: 0 }) }), - }); - - if (!saveRes.ok) { - const err = await saveRes.json(); - throw new Error(err.error || 'Failed to save configuration'); - } - - await onSaved(); - } catch (err) { - setTestStatus('error'); - setTestMessage(err instanceof Error ? err.message : 'Failed to save.'); - } finally { - setSaving(false); - } - }, [selectedProvider, effectiveUrl, effectiveModelId, apiKey, effectiveProtocol, effectiveProviderId, onSaved]); - - return ( -
-
-

LLM Provider Setup

-

- Select your provider and enter your API key. Model capabilities are auto-configured. -

-
- -
- - {/* Provider grid */} -
-
- Provider -
-
- {CATALOG_PROVIDERS.map((provider) => ( - - ))} - -
-
- - {isCustomMode && ( -
-
- - { setCustomProviderId(e.target.value); setTestStatus('idle'); setTestMessage(''); }} - placeholder="e.g. my-llm" - className="w-full rounded-lg border border-border bg-background px-3 py-2.5 font-mono text-sm text-foreground placeholder:text-muted-foreground/50 focus:border-foreground/40 focus:outline-none" - autoComplete="off" - spellCheck={false} - /> -

- Used as the YAML key. Lowercase, no spaces. -

-
-
-
- -
- - -
-
-
- - { setCustomUrl(e.target.value); setTestStatus('idle'); setTestMessage(''); }} - placeholder="https://api.example.com/v1" - className="w-full rounded-lg border border-border bg-background px-3 py-2.5 font-mono text-sm text-foreground placeholder:text-muted-foreground/50 focus:border-foreground/40 focus:outline-none" - autoComplete="off" - spellCheck={false} - /> - {customProtocol === 'openai' && ( -

- OpenAI-compatible base URLs should include the API version path, for example ending in /v1. -

- )} -
-
-
- )} - - {/* API Key */} -
- - { setApiKey(e.target.value); setTestStatus('idle'); setTestMessage(''); }} - placeholder="sk-..." - className="w-full rounded-lg border border-border bg-background px-3 py-2.5 font-mono text-sm text-foreground placeholder:text-muted-foreground/50 focus:border-foreground/40 focus:outline-none" - autoComplete="off" - spellCheck={false} - /> -
- - {/* Model picker */} -
- - {selectedModels.length > 0 ? ( -
- - -
- ) : ( - { setCustomModelId(e.target.value); setTestStatus('idle'); setTestMessage(''); }} - placeholder="Enter model ID..." - className="w-full rounded-lg border border-border bg-background px-3 py-2.5 text-sm text-foreground placeholder:text-muted-foreground/50 focus:border-foreground/40 focus:outline-none" - autoComplete="off" - spellCheck={false} - /> - )} - {selectedModels.length > 0 && ( -
- { setCustomModelId(e.target.value); setTestStatus('idle'); setTestMessage(''); }} - placeholder="Or type a custom model ID..." - className="w-full rounded-lg border border-border/60 bg-background px-3 py-2 text-xs text-foreground placeholder:text-muted-foreground/40 focus:border-foreground/40 focus:outline-none" - autoComplete="off" - spellCheck={false} - /> -
- )} -
- - {/* Advanced (catalog providers only — custom already shows URL above) */} - {!isCustomMode && ( -
- - {showAdvanced && ( -
-
- - { setCustomUrl(e.target.value); setTestStatus('idle'); setTestMessage(''); }} - placeholder={selectedDefaultUrl} - className="w-full rounded-lg border border-border/60 bg-background px-3 py-2 text-xs text-foreground placeholder:text-muted-foreground/40 focus:border-foreground/40 focus:outline-none" - autoComplete="off" - spellCheck={false} - /> - {(selectedProvider?.protocol ?? customProtocol) === 'openai' && ( -

- OpenAI-compatible base URLs should include the API version path, for example ending in /v1. -

- )} -
-
- Protocol: {selectedProvider?.protocol ?? customProtocol} · Default URL: {selectedDefaultUrl} -
-
- )} -
- )} - - {/* Actions */} -
- {testStatus !== 'success' && ( - Test connection first. - )} - - -
- - {testMessage && ( -
- {testStatus === 'success' ? '✓ ' : '✗ '}{testMessage} -
- )} -
- ); -}