'use client'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useRouter } from 'next/navigation'; import { useTranslation } from 'react-i18next'; import { UUID } from 'uuidjs'; import { toast } from 'sonner'; import { ArrowLeft, ArrowRight, Check, Sparkles, PartyPopper, Loader2, X, ExternalLink, } from 'lucide-react'; import { httpClient } from '@/app/infra/http/HttpClient'; import { userInfo, systemInfo, initializeUserInfo, initializeSystemInfo, } from '@/app/infra/http'; import { Adapter, Bot, Pipeline, WizardProgress, } from '@/app/infra/entities/api'; import { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic'; import { PipelineConfigTab, PipelineConfigStage, } from '@/app/infra/entities/pipeline'; import { DynamicFormItemConfig, getDefaultValues, parseDynamicFormItemType, } from '@/app/home/components/dynamic-form/DynamicFormItemConfig'; import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent'; import { BotLogListComponent } from '@/app/home/bots/components/bot-log/view/BotLogListComponent'; import { extractI18nObject } from '@/i18n/I18nProvider'; import { groupByCategory, getCategoryLabel, } from '@/app/infra/entities/adapter-categories'; import { getAdapterDocUrl } from '@/app/infra/entities/adapter-docs'; import i18n from 'i18next'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from '@/components/ui/card'; import { LoadingSpinner } from '@/components/ui/loading-spinner'; import { cn } from '@/lib/utils'; import { LanguageSelector } from '@/components/ui/language-selector'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- const TOTAL_STEPS = 4; // --------------------------------------------------------------------------- // Main Wizard Page (full-screen, no sidebar) // --------------------------------------------------------------------------- export default function WizardPage() { const { t } = useTranslation(); const router = useRouter(); // ---- Wizard state ---- const [currentStep, setCurrentStep] = useState(0); const [selectedAdapter, setSelectedAdapter] = useState(null); const [selectedRunner, setSelectedRunner] = useState(null); const [botName, setBotName] = useState(''); // eslint-disable-next-line @typescript-eslint/no-unused-vars const [botDescription, _setBotDescription] = useState(''); const [adapterConfig, setAdapterConfig] = useState>( {}, ); const [runnerConfig, setRunnerConfig] = useState>({}); const [createdBotUuid, setCreatedBotUuid] = useState(null); const [webhookUrl, setWebhookUrl] = useState(''); const [extraWebhookUrl, setExtraWebhookUrl] = useState(''); // ---- Remote data ---- const [adapters, setAdapters] = useState([]); const [aiConfigTab, setAiConfigTab] = useState( null, ); const [isLoading, setIsLoading] = useState(true); const [isCreatingBot, setIsCreatingBot] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); const [isSavingBot, setIsSavingBot] = useState(false); const [botSaved, setBotSaved] = useState(false); // ---- Helper: persist wizard progress to backend (fire-and-forget) ---- const saveProgress = useCallback( (overrides: Partial = {}) => { const progress: WizardProgress = { step: overrides.step ?? currentStep, selected_adapter: overrides.selected_adapter ?? selectedAdapter, created_bot_uuid: overrides.created_bot_uuid ?? createdBotUuid, bot_saved: overrides.bot_saved ?? botSaved, selected_runner: overrides.selected_runner ?? selectedRunner, }; httpClient.saveWizardProgress(progress).catch((err) => { console.error('Failed to save wizard progress', err); }); }, [currentStep, selectedAdapter, createdBotUuid, botSaved, selectedRunner], ); // ---- Fetch remote data & restore progress ---- useEffect(() => { let cancelled = false; (async () => { try { // Initialize user/system info (wizard is outside /home layout) await Promise.all([initializeUserInfo(), initializeSystemInfo()]); const [adaptersResp, metadataResp] = await Promise.all([ httpClient.getAdapters(), httpClient.getGeneralPipelineMetadata(), ]); if (cancelled) return; setAdapters(adaptersResp.adapters); const aiTab = metadataResp.configs.find((c) => c.name === 'ai'); if (aiTab) setAiConfigTab(aiTab); // Restore wizard progress if available const progress = systemInfo.wizard_progress; if (progress && progress.created_bot_uuid) { // Verify the bot still exists before restoring try { const botData = await httpClient.getBot(progress.created_bot_uuid); if (cancelled) return; setSelectedAdapter(progress.selected_adapter); setCreatedBotUuid(progress.created_bot_uuid); setBotSaved(progress.bot_saved ?? false); setSelectedRunner(progress.selected_runner); // Restore bot name from fetched bot data setBotName(botData.bot.name); // Restore webhook URLs const runtimeValues = botData.bot.adapter_runtime_values as | Record | undefined; setWebhookUrl((runtimeValues?.webhook_full_url as string) || ''); setExtraWebhookUrl( (runtimeValues?.extra_webhook_full_url as string) || '', ); // Restore step (cap at step 2 — step 3 means done) setCurrentStep(Math.min(progress.step, 2)); } catch { // Bot no longer exists — clear stale progress and start fresh httpClient .saveWizardProgress({ step: 0, selected_adapter: null, created_bot_uuid: null, bot_saved: false, selected_runner: null, }) .catch(() => {}); } } } catch (err) { console.error('Failed to load wizard data', err); toast.error(t('wizard.loadError')); } finally { if (!cancelled) setIsLoading(false); } })(); return () => { cancelled = true; }; }, [t]); // ---- Derived data ---- const runnerStage: PipelineConfigStage | undefined = useMemo( () => aiConfigTab?.stages.find((s) => s.name === 'runner'), [aiConfigTab], ); const runnerOptions = useMemo(() => { if (!runnerStage) return []; const runnerField = runnerStage.config.find((c) => c.name === 'runner'); return runnerField?.options ?? []; }, [runnerStage]); const selectedRunnerConfigStage: PipelineConfigStage | undefined = useMemo(() => { if (!selectedRunner || !aiConfigTab) return undefined; return aiConfigTab.stages.find((s) => s.name === selectedRunner); }, [selectedRunner, aiConfigTab]); // Adapter spec config for the selected adapter const selectedAdapterConfig: IDynamicFormItemSchema[] = useMemo(() => { const adapter = adapters.find((a) => a.name === selectedAdapter); if (!adapter) return []; return adapter.spec.config.map( (item) => new DynamicFormItemConfig({ default: item.default, id: UUID.generate(), label: item.label, description: item.description, name: item.name, required: item.required, type: parseDynamicFormItemType(item.type), options: item.options, show_if: item.show_if, }), ); }, [adapters, selectedAdapter]); // Runner config items const selectedRunnerConfigItems: IDynamicFormItemSchema[] = useMemo(() => { if (!selectedRunnerConfigStage) return []; return selectedRunnerConfigStage.config.map( (item) => new DynamicFormItemConfig({ default: item.default, id: UUID.generate(), label: item.label, description: item.description, name: item.name, required: item.required, type: parseDynamicFormItemType(item.type), options: item.options, show_if: item.show_if, }), ); }, [selectedRunnerConfigStage]); // ---- Runner selection with progress saving ---- const handleSelectRunner = useCallback( (runner: string) => { setSelectedRunner(runner); saveProgress({ step: 2, selected_runner: runner }); }, [saveProgress], ); // ---- Navigation helpers ---- const canProceed = useCallback((): boolean => { switch (currentStep) { case 0: return selectedAdapter !== null; case 1: return createdBotUuid !== null && botSaved; case 2: return selectedRunner !== null; default: return false; } }, [currentStep, selectedAdapter, createdBotUuid, botSaved, selectedRunner]); const goNext = useCallback(() => { if (currentStep < TOTAL_STEPS - 1 && canProceed()) { const nextStep = currentStep + 1; setCurrentStep(nextStep); saveProgress({ step: nextStep }); } }, [currentStep, canProceed, saveProgress]); const goPrev = useCallback(() => { if (currentStep > 0) { const prevStep = currentStep - 1; setCurrentStep(prevStep); saveProgress({ step: prevStep }); } }, [currentStep, saveProgress]); // ---- Create Bot (Step 0) ---- // Creates a disabled bot using the adapter label as name. const handleCreateBot = useCallback(async () => { if (!selectedAdapter) return; setIsCreatingBot(true); try { // Use adapter label as default bot name const adapter = adapters.find((a) => a.name === selectedAdapter); const defaultName = adapter ? extractI18nObject(adapter.label) : selectedAdapter; setBotName(defaultName); const defaultConfig = adapter ? getDefaultValues(adapter.spec.config) : {}; const bot: Bot = { name: defaultName, description: '', adapter: selectedAdapter, adapter_config: defaultConfig, enable: false, }; const resp = await httpClient.createBot(bot); setCreatedBotUuid(resp.uuid); // Fetch runtime info to get webhook URL(s) try { const botData = await httpClient.getBot(resp.uuid); const runtimeValues = botData.bot.adapter_runtime_values as | Record | undefined; setWebhookUrl((runtimeValues?.webhook_full_url as string) || ''); setExtraWebhookUrl( (runtimeValues?.extra_webhook_full_url as string) || '', ); } catch { // Non-critical — webhook URL display is optional } // Advance to Step 1 setCurrentStep(1); // Persist progress saveProgress({ step: 1, selected_adapter: selectedAdapter, created_bot_uuid: resp.uuid, bot_saved: false, selected_runner: null, }); } catch (err) { const apiErr = err as { msg?: string }; toast.error( t('wizard.createError') + (apiErr?.msg ? `: ${apiErr.msg}` : ''), ); } finally { setIsCreatingBot(false); } }, [selectedAdapter, adapters, t, saveProgress]); // ---- Save Bot Config & Enable (Step 1) ---- // Updates the bot's adapter config and enables it. const handleSaveBot = useCallback(async () => { if (!createdBotUuid || !selectedAdapter) return; setIsSavingBot(true); try { await httpClient.updateBot(createdBotUuid, { name: botName, description: botDescription || '', adapter: selectedAdapter, adapter_config: adapterConfig, enable: true, }); setBotSaved(true); // Re-fetch runtime info to get updated webhook URL(s) try { const botData = await httpClient.getBot(createdBotUuid); const runtimeValues = botData.bot.adapter_runtime_values as | Record | undefined; setWebhookUrl((runtimeValues?.webhook_full_url as string) || ''); setExtraWebhookUrl( (runtimeValues?.extra_webhook_full_url as string) || '', ); } catch { // Non-critical } // Persist progress saveProgress({ step: 1, bot_saved: true }); } catch (err) { const apiErr = err as { msg?: string }; toast.error( t('wizard.createError') + (apiErr?.msg ? `: ${apiErr.msg}` : ''), ); } finally { setIsSavingBot(false); } }, [ createdBotUuid, selectedAdapter, botName, botDescription, adapterConfig, t, saveProgress, ]); // ---- Create Pipeline & Link (Step 2 finish) ---- const handleFinish = useCallback(async () => { if (!selectedRunner || !createdBotUuid) return; setIsSubmitting(true); try { // 1. Create pipeline (backend fills config from default template) const pipeline: Pipeline = { name: `${botName} Pipeline`, description: botDescription || '', config: {}, }; const pipelineResp = await httpClient.createPipeline(pipeline); // 2. Fetch the created pipeline to get the full default config // (includes trigger, safety, ai, output sections). // Then merge only the AI section with the wizard's runner config. const createdPipeline = await httpClient.getPipeline(pipelineResp.uuid); const fullConfig = createdPipeline.pipeline.config; const mergedConfig = { ...fullConfig, ai: { ...fullConfig.ai, runner: { runner: selectedRunner }, [selectedRunner]: runnerConfig, }, }; await httpClient.updatePipeline(pipelineResp.uuid, { name: `${botName} Pipeline`, description: botDescription || '', config: mergedConfig, }); // 3. Link pipeline to the bot created in Step 1 const botData = await httpClient.getBot(createdBotUuid); const existingBot = botData.bot; await httpClient.updateBot(createdBotUuid, { name: existingBot.name, description: existingBot.description, adapter: existingBot.adapter, adapter_config: existingBot.adapter_config, enable: existingBot.enable, use_pipeline_uuid: pipelineResp.uuid, }); setCurrentStep(3); } catch (err) { const apiErr = err as { msg?: string }; toast.error( t('wizard.createError') + (apiErr?.msg ? `: ${apiErr.msg}` : ''), ); } finally { setIsSubmitting(false); } }, [ selectedRunner, createdBotUuid, botName, botDescription, runnerConfig, t, ]); // ---- Space auth redirect ---- const handleSpaceAuth = useCallback(async () => { try { const callbackUrl = `${window.location.origin}/auth/space/callback`; const resp = await httpClient.getSpaceAuthorizeUrl(callbackUrl); window.location.href = resp.authorize_url; } catch (err) { console.error('Failed to get space authorize URL', err); toast.error(t('wizard.spaceAuthError')); } }, [t]); // ---- Check if local account ---- // Re-evaluated after remote data fetch (when userInfo is populated) const isLocalAccount = !isLoading && (!userInfo || userInfo.account_type === 'local'); // ---- Skip handler ---- const [showSkipConfirm, setShowSkipConfirm] = useState(false); const [isSkipping, setIsSkipping] = useState(false); const handleSkipConfirm = useCallback(async () => { setIsSkipping(true); try { if (systemInfo.wizard_status === 'none') { await httpClient.updateWizardStatus('skipped'); systemInfo.wizard_status = 'skipped'; } // Always clear persisted progress so re-entering starts fresh await httpClient.saveWizardProgress({ step: 0, selected_adapter: null, created_bot_uuid: null, bot_saved: false, selected_runner: null, }); systemInfo.wizard_progress = null; } catch { toast.error(t('wizard.skipSaveError')); setIsSkipping(false); return; } setIsSkipping(false); setShowSkipConfirm(false); router.push('/home'); }, [router, t]); // ---- Render ---- if (isLoading) { return (
); } const stepLabels = [ t('wizard.step.platform'), t('wizard.step.botConfig'), t('wizard.step.aiEngine'), t('wizard.step.done'), ]; return (
{/* Top bar: Skip button */}
{t('sidebar.quickStart')}
{currentStep < 3 && ( )}
{/* Stepper header */}
{stepLabels.map((label, idx) => (
{idx < currentStep ? ( ) : ( idx + 1 )}
{idx < TOTAL_STEPS - 1 && (
)}
))}
{/* Step content */}
{currentStep === 0 && ( )} {currentStep === 1 && ( )} {currentStep === 2 && ( )} {currentStep === 3 && }
{/* Footer navigation */} {currentStep < 3 && (
{currentStep === 0 ? ( ) : currentStep === 1 ? ( ) : ( )}
)} {/* Skip confirmation dialog */} {t('wizard.skip')} {t('wizard.skipConfirmMessage')}
); } // --------------------------------------------------------------------------- // Step 0: Select Platform // --------------------------------------------------------------------------- function StepPlatform({ adapters, selected, onSelect, }: { adapters: Adapter[]; selected: string | null; onSelect: (name: string) => void; }) { const { t } = useTranslation(); const groupedAdapters = useMemo(() => { const withCategories = adapters.map((a) => ({ ...a, categories: a.spec.categories, })); return groupByCategory(withCategories); }, [adapters]); return (

{t('wizard.platform.title')}

{t('wizard.platform.description')}

{groupedAdapters.map((group) => (
{group.categoryId && (

{getCategoryLabel(t, group.categoryId)}

)}
{group.items.map((adapter) => ( onSelect(adapter.name)} >
{extractI18nObject(adapter.label)}
{selected === adapter.name && (
)}

{extractI18nObject(adapter.description)}

{(() => { const docUrl = getAdapterDocUrl( adapter.spec.help_links, i18n.language, ); return docUrl ? ( e.stopPropagation()} > {t('bots.viewAdapterDocs')} ) : null; })()}
))}
))}
); } // --------------------------------------------------------------------------- // Step 1: Bot Configuration + Logs // --------------------------------------------------------------------------- function StepBotConfig({ adapterConfigItems, adapterConfigValues, onAdapterConfigChange, selectedAdapterName, adapters, createdBotUuid, isSavingBot, botSaved, onSaveBot, webhookUrl, extraWebhookUrl, }: { adapterConfigItems: IDynamicFormItemSchema[]; adapterConfigValues: Record; onAdapterConfigChange: (v: Record) => void; selectedAdapterName: string | null; adapters: Adapter[]; createdBotUuid: string | null; isSavingBot: boolean; botSaved: boolean; onSaveBot: () => void; webhookUrl: string; extraWebhookUrl: string; }) { const { t } = useTranslation(); const adapterLabel = useMemo(() => { const a = adapters.find((ad) => ad.name === selectedAdapterName); return a ? extractI18nObject(a.label) : (selectedAdapterName ?? ''); }, [adapters, selectedAdapterName]); // Stable callback ref const onAdapterConfigRef = useRef(onAdapterConfigChange); onAdapterConfigRef.current = onAdapterConfigChange; const stableAdapterConfigCb = useCallback( (val: object) => onAdapterConfigRef.current(val as Record), [], ); return (

{t('wizard.botConfig.title')}

{t('wizard.botConfig.description')}

{/* Left column: Adapter config form */}
{adapterConfigItems.length > 0 && (
{t('wizard.config.platformConfig', { platform: adapterLabel, })} {selectedAdapterName && (() => { const selectedAdapter = adapters.find( (a) => a.name === selectedAdapterName, ); const docUrl = getAdapterDocUrl( selectedAdapter?.spec.help_links, i18n.language, ); return docUrl ? ( {t('bots.viewAdapterDocs')} ) : null; })()}
} onSubmit={stableAdapterConfigCb} systemContext={{ is_wizard: true, webhook_url: webhookUrl, extra_webhook_url: extraWebhookUrl, }} />
)} {/* Bot saved indicator */} {botSaved && (
{t('wizard.botConfig.botSaved')}
)}
{/* Right column: Bot logs */} {createdBotUuid && ( {t('wizard.botConfig.logsTitle')} {t('wizard.botConfig.logsDescription')} )}
); } // --------------------------------------------------------------------------- // Step 2: Select & Configure AI Engine // --------------------------------------------------------------------------- function StepAIEngine({ runnerOptions, selected, onSelect, isLocalAccount, onSpaceAuth, runnerConfigItems, runnerConfigValues, onRunnerConfigChange, }: { runnerOptions: { name: string; label: { en_US: string; zh_Hans: string } }[]; selected: string | null; onSelect: (name: string) => void; isLocalAccount: boolean; onSpaceAuth: () => void; runnerConfigItems: IDynamicFormItemSchema[]; runnerConfigValues: Record; onRunnerConfigChange: (v: Record) => void; }) { const { t } = useTranslation(); // Stable callback ref const onRunnerConfigRef = useRef(onRunnerConfigChange); onRunnerConfigRef.current = onRunnerConfigChange; const stableRunnerConfigCb = useCallback( (val: object) => onRunnerConfigRef.current(val as Record), [], ); const runnerLabel = useMemo(() => { const r = runnerOptions.find((o) => o.name === selected); return r ? extractI18nObject(r.label) : (selected ?? ''); }, [runnerOptions, selected]); // Before any runner is selected: centered grid layout if (!selected) { return (

{t('wizard.aiEngine.title')}

{t('wizard.aiEngine.description')}

{runnerOptions.map((opt) => ( onSelect(opt.name)} >
{extractI18nObject(opt.label)} {opt.name}
))}
); } // After a runner is selected: left-right split layout // On mobile (< lg): single column, normal scroll from parent // On desktop (>= lg): side-by-side with independent scroll per column return (

{t('wizard.aiEngine.title')}

{t('wizard.aiEngine.description')}

{/* Left: runner list */}
{/* p-1 provides space for ring-2 (4px) to render without clipping */}
{runnerOptions.map((opt) => { const isSelected = selected === opt.name; return ( onSelect(opt.name)} >
{extractI18nObject(opt.label)} {opt.name}
{isSelected && (
)}
); })} {/* Space promotion banner */} {selected === 'local-agent' && isLocalAccount && (

{t('wizard.spaceBanner.message')}

)}
{/* Right: runner configuration — fixed width on desktop */}
{runnerConfigItems.length > 0 && ( {t('wizard.config.aiConfig', { engine: runnerLabel })} } onSubmit={stableRunnerConfigCb} systemContext={{ is_wizard: true }} /> )}
); } // --------------------------------------------------------------------------- // Step 3: Done // --------------------------------------------------------------------------- function StepDone() { const { t } = useTranslation(); const router = useRouter(); const [particles] = useState(() => Array.from({ length: 30 }, (_, i) => ({ id: i, left: Math.random() * 100, delay: Math.random() * 2, duration: 2 + Math.random() * 2, size: 4 + Math.random() * 6, color: [ 'bg-purple-400', 'bg-pink-400', 'bg-orange-400', 'bg-blue-400', 'bg-green-400', 'bg-yellow-400', ][Math.floor(Math.random() * 6)], })), ); const [isCompleting, setIsCompleting] = useState(false); const handleBack = useCallback(async () => { setIsCompleting(true); try { if (systemInfo.wizard_status === 'none') { await httpClient.updateWizardStatus('completed'); systemInfo.wizard_status = 'completed'; } // Always clear persisted progress so re-entering starts fresh await httpClient.saveWizardProgress({ step: 0, selected_adapter: null, created_bot_uuid: null, bot_saved: false, selected_runner: null, }); systemInfo.wizard_progress = null; } catch { toast.error(t('wizard.completeSaveError')); setIsCompleting(false); return; } setIsCompleting(false); router.push('/home/bots'); }, [router, t]); return (
{/* Confetti particles */}
{particles.map((p) => (
))}

{t('wizard.done.title')}

{t('wizard.done.description')}

); }