import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { UUID } from 'uuidjs'; import { toast } from 'sonner'; import { ArrowLeft, ArrowRight, AlertTriangle, Check, ChevronDown, ChevronRight, Sparkles, PartyPopper, Loader2, MessageSquare, ShieldCheck, UserMinus, UserPlus, X, ExternalLink, Download, RefreshCw, CircleAlert, Copy, Send, Webhook, } from 'lucide-react'; import { httpClient } from '@/app/infra/http/HttpClient'; import { systemInfo, bootstrapWorkspaceSession, initializeSystemInfo, getCloudServiceClientSync, userInfo, } from '@/app/infra/http'; import { Adapter, Bot, Pipeline, WizardProgress, } from '@/app/infra/entities/api'; import { DynamicFormItemType, 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 { PluginV4 } from '@/app/infra/entities/plugin'; import { RunnerMarketplaceError, getErrorMessage, installMarketplaceRunner, loadRunnerCatalog as fetchRunnerCatalog, marketplacePluginId, readPendingRunnerInstall, resumePendingRunnerInstall, runnerPluginPrefix, } from '@/app/home/agents/runner-marketplace'; import { ensureHttpBotSigningSecret, isRequiredRunnerConfigComplete, isWebhookModeEnabled, } from '@/app/wizard/utils'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; 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; const WIZARD_RUNNER_INSTALL_SCOPE = 'wizard'; type WizardScenarioId = | 'message_reply' | 'welcome_members' | 'handle_departures' | 'handle_moderation'; const WIZARD_SCENARIO_PROMPT_KEYS: Partial> = { welcome_members: 'wizard.scenario.welcomeMembersPrompt', handle_departures: 'wizard.scenario.handleDeparturesPrompt', handle_moderation: 'wizard.scenario.handleModerationPrompt', }; const WIZARD_SCENARIOS = [ { id: 'message_reply' as const, eventType: 'message.received', processorKind: 'pipeline' as const, labelKey: 'wizard.scenario.messageReply', descriptionKey: 'wizard.scenario.messageReplyDescription', icon: MessageSquare, emoji: '💬', }, { id: 'welcome_members' as const, eventType: 'group.member_joined', processorKind: 'agent' as const, labelKey: 'wizard.scenario.welcomeMembers', descriptionKey: 'wizard.scenario.welcomeMembersDescription', icon: UserPlus, emoji: '👋', }, { id: 'handle_departures' as const, eventType: 'group.member_left', processorKind: 'agent' as const, labelKey: 'wizard.scenario.handleDepartures', descriptionKey: 'wizard.scenario.handleDeparturesDescription', icon: UserMinus, emoji: '👤', }, { id: 'handle_moderation' as const, eventType: 'group.member_banned', processorKind: 'agent' as const, labelKey: 'wizard.scenario.handleModeration', descriptionKey: 'wizard.scenario.handleModerationDescription', icon: ShieldCheck, emoji: '🛡️', }, ]; function adapterSupportsScenario( adapter: Adapter, scenarioId: WizardScenarioId, ) { const scenario = WIZARD_SCENARIOS.find((item) => item.id === scenarioId); if (!scenario) return false; const supportedEvents = adapter.spec.supported_events?.length ? adapter.spec.supported_events : ['message.received']; return supportedEvents.includes(scenario.eventType); } // --------------------------------------------------------------------------- // Main Wizard Page (full-screen, no sidebar) // --------------------------------------------------------------------------- export default function WizardPage() { const { t } = useTranslation(); const navigate = useNavigate(); // ---- Wizard state ---- const [currentStep, setCurrentStep] = useState(0); const [selectedScenario, setSelectedScenario] = useState(null); const [selectedAdapter, setSelectedAdapter] = useState(null); const [selectedRunner, setSelectedRunner] = useState(null); const [botName, setBotName] = useState(''); const [botDescription, _setBotDescription] = useState(''); const [adapterConfig, setAdapterConfig] = useState>( {}, ); const [runnerConfig, setRunnerConfig] = useState>({}); const [createdBotUuid, setCreatedBotUuid] = useState(null); const [createdPipelineUuid, setCreatedPipelineUuid] = useState( null, ); const [webhookUrl, setWebhookUrl] = useState(''); const [extraWebhookUrl, setExtraWebhookUrl] = useState(''); // ---- Remote data ---- const [adapters, setAdapters] = useState([]); const [aiConfigTab, setAiConfigTab] = useState( null, ); const [marketplaceRunners, setMarketplaceRunners] = useState([]); const [installedPluginIds, setInstalledPluginIds] = useState([]); const [isRunnerCatalogLoading, setIsRunnerCatalogLoading] = useState(true); const [runnerCatalogError, setRunnerCatalogError] = useState(false); const [installingRunnerPluginId, setInstallingRunnerPluginId] = useState< string | null >(null); const [runnerInstallError, setRunnerInstallError] = 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); const [pageBotPreviewRequest, setPageBotPreviewRequest] = useState(0); const [messageReceived, setMessageReceived] = useState(false); const loadRunnerCatalog = useCallback(async () => { setIsRunnerCatalogLoading(true); setRunnerCatalogError(false); try { const catalog = await fetchRunnerCatalog('agent'); setMarketplaceRunners(catalog.marketplaceRunners); setInstalledPluginIds(catalog.installedPluginIds); } catch (error) { console.error('Failed to load Runner catalog', error); setRunnerCatalogError(true); } finally { setIsRunnerCatalogLoading(false); } }, []); useEffect(() => { void loadRunnerCatalog(); }, [loadRunnerCatalog]); // ---- Helper: persist wizard progress to backend (fire-and-forget) ---- const saveProgress = useCallback( (overrides: Partial = {}) => { const progress: WizardProgress = { step: overrides.step ?? currentStep, selected_scenario: overrides.selected_scenario !== undefined ? overrides.selected_scenario : selectedScenario, selected_adapter: overrides.selected_adapter !== undefined ? overrides.selected_adapter : selectedAdapter, created_bot_uuid: overrides.created_bot_uuid !== undefined ? overrides.created_bot_uuid : createdBotUuid, created_pipeline_uuid: overrides.created_pipeline_uuid !== undefined ? overrides.created_pipeline_uuid : createdPipelineUuid, bot_saved: overrides.bot_saved ?? botSaved, message_received: overrides.message_received ?? messageReceived, selected_runner: overrides.selected_runner !== undefined ? overrides.selected_runner : selectedRunner, }; httpClient.saveWizardProgress(progress).catch((err) => { console.error('Failed to save wizard progress', err); }); }, [ currentStep, selectedScenario, selectedAdapter, createdBotUuid, createdPipelineUuid, botSaved, messageReceived, selectedRunner, ], ); // ---- Fetch remote data & restore progress ---- useEffect(() => { let cancelled = false; (async () => { try { // Resolve the Account's Workspace before loading scoped wizard data. const workspaceResult = await bootstrapWorkspaceSession(); if (workspaceResult.status === 'selection-required') { navigate('/workspaces/select?returnTo=%2Fwizard', { replace: true }); return; } if (workspaceResult.status === 'unavailable') { throw new Error('No Workspace is available for this Account'); } await initializeSystemInfo({ throwOnError: true }); 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; const restoredAdapter = progress.selected_adapter ?? botData.bot.adapter; const restoredConfig = (botData.bot.adapter_config ?? {}) as Record< string, unknown >; const configToRestore = ensureHttpBotSigningSecret( restoredAdapter, restoredConfig, ); const configNeedsSave = configToRestore !== restoredConfig; setSelectedAdapter(restoredAdapter); setSelectedScenario( (progress.selected_scenario as WizardScenarioId | null) ?? 'message_reply', ); setCreatedBotUuid(progress.created_bot_uuid); setCreatedPipelineUuid( progress.created_pipeline_uuid ?? botData.bot.event_bindings?.find( (binding) => binding.event_pattern === 'message.received' && binding.target_type === 'pipeline', )?.target_uuid ?? null, ); setBotSaved( configNeedsSave ? false : (progress.bot_saved ?? false), ); setMessageReceived(progress.message_received ?? false); setSelectedRunner(progress.selected_runner); // Restore bot name from fetched bot data setBotName(botData.bot.name); setAdapterConfig(configToRestore); // 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) || '', ); // Step 3 is resumable so a refresh cannot create a duplicate processor. setCurrentStep(Math.min(progress.step, 3)); } catch { // Bot no longer exists — clear stale progress and start fresh httpClient .saveWizardProgress({ step: 0, selected_scenario: null, selected_adapter: null, created_bot_uuid: null, created_pipeline_uuid: null, bot_saved: false, message_received: 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; }; }, [navigate, 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 === 'id'); return runnerField?.options ?? []; }, [runnerStage]); const selectedRunnerConfigStage: PipelineConfigStage | undefined = useMemo(() => { if (!selectedRunner || !aiConfigTab) return undefined; return aiConfigTab.stages.find((s) => s.name === selectedRunner); }, [selectedRunner, aiConfigTab]); const selectedScenarioDefinition = useMemo( () => WIZARD_SCENARIOS.find((item) => item.id === selectedScenario), [selectedScenario], ); // 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, login_platform: item.login_platform, url: item.url, download_filename: item.download_filename, help_links: item.help_links, help_label: item.help_label, }), ); }, [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, login_platform: item.login_platform, url: item.url, download_filename: item.download_filename, help_links: item.help_links, help_label: item.help_label, }), ); }, [selectedRunnerConfigStage]); const isRunnerConfigComplete = useMemo( () => isRequiredRunnerConfigComplete(selectedRunnerConfigItems, runnerConfig), [selectedRunnerConfigItems, runnerConfig], ); // ---- Runner selection with progress saving ---- const handleSelectRunner = useCallback( (runner: string, configTab: PipelineConfigTab | null = aiConfigTab) => { setSelectedRunner(runner); const configStage = configTab?.stages.find((s) => s.name === runner); const defaults = configStage ? getDefaultValues(configStage.config) : {}; const promptKey = selectedScenario ? WIZARD_SCENARIO_PROMPT_KEYS[selectedScenario] : undefined; const supportsPromptEditor = configStage?.config.some( (item) => item.type === DynamicFormItemType.PROMPT_EDITOR, ); if (promptKey && supportsPromptEditor) { defaults.prompt = [{ role: 'system', content: t(promptKey) }]; } setRunnerConfig(defaults); saveProgress({ step: 2, selected_runner: runner }); }, [aiConfigTab, saveProgress, selectedScenario, t], ); const handleInstallRunner = useCallback( async (plugin: PluginV4) => { const pluginId = marketplacePluginId(plugin); setInstallingRunnerPluginId(pluginId); setRunnerInstallError(null); try { const installed = await installMarketplaceRunner(plugin, { scope: WIZARD_RUNNER_INSTALL_SCOPE, }); setAiConfigTab(installed.configTab); setInstalledPluginIds((current) => current.includes(pluginId) ? current : [...current, pluginId], ); handleSelectRunner(installed.runner.name, installed.configTab); toast.success( t('wizard.aiEngine.installSuccess', { runner: extractI18nObject(plugin.label), }), ); } catch (error) { let message = getErrorMessage(error); if (error instanceof RunnerMarketplaceError) { const key = error.code === 'version-unavailable' ? 'wizard.aiEngine.versionUnavailable' : error.code === 'install-timeout' ? 'wizard.aiEngine.installTimeout' : 'wizard.aiEngine.registrationTimeout'; message = t(key); } message ||= t('wizard.aiEngine.installFailed'); setRunnerInstallError(message); toast.error(message); } finally { setInstallingRunnerPluginId(null); } }, [handleSelectRunner, t], ); useEffect(() => { if (isLoading) return; const pending = readPendingRunnerInstall(WIZARD_RUNNER_INSTALL_SCOPE); if (!pending) return; let cancelled = false; setInstallingRunnerPluginId(pending.pluginId); setRunnerInstallError(null); void resumePendingRunnerInstall(WIZARD_RUNNER_INSTALL_SCOPE) .then((installed) => { if (cancelled || !installed) return; setAiConfigTab(installed.configTab); setInstalledPluginIds((current) => current.includes(pending.pluginId) ? current : [...current, pending.pluginId], ); handleSelectRunner(installed.runner.name, installed.configTab); toast.success( t('wizard.aiEngine.installSuccess', { runner: pending.pluginLabel, }), ); }) .catch((error) => { if (cancelled) return; const message = getErrorMessage(error) || t('wizard.aiEngine.installFailed'); setRunnerInstallError(message); toast.error(message); }) .finally(() => { if (!cancelled) setInstallingRunnerPluginId(null); }); return () => { cancelled = true; }; }, [handleSelectRunner, isLoading, t]); // ---- Navigation helpers ---- const canProceed = useCallback((): boolean => { switch (currentStep) { case 0: return selectedScenario !== null && selectedAdapter !== null; case 1: return ( createdBotUuid !== null && botSaved && (selectedScenario !== 'message_reply' || messageReceived) ); case 2: return selectedRunner !== null && isRunnerConfigComplete; default: return false; } }, [ currentStep, selectedScenario, selectedAdapter, createdBotUuid, botSaved, messageReceived, selectedRunner, isRunnerConfigComplete, ]); const handleSelectScenario = useCallback( (scenarioId: WizardScenarioId) => { const adapter = adapters.find((item) => item.name === selectedAdapter); const nextAdapter = adapter && adapterSupportsScenario(adapter, scenarioId) ? selectedAdapter : null; setSelectedScenario(scenarioId); setSelectedAdapter(nextAdapter); saveProgress({ step: 0, selected_scenario: scenarioId, selected_adapter: nextAdapter, }); }, [adapters, selectedAdapter, saveProgress], ); 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 initialConfig = ensureHttpBotSigningSecret( selectedAdapter, defaultConfig, ); setAdapterConfig(initialConfig); const bot: Bot = { name: defaultName, description: '', adapter: selectedAdapter, adapter_config: initialConfig, enable: false, }; const resp = await httpClient.createBot(bot); setCreatedBotUuid(resp.uuid); setCreatedPipelineUuid(null); // 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_scenario: selectedScenario, selected_adapter: selectedAdapter, created_bot_uuid: resp.uuid, created_pipeline_uuid: null, bot_saved: false, message_received: false, selected_runner: null, }); } catch (err) { const apiErr = err as { msg?: string }; toast.error( t('wizard.createError') + (apiErr?.msg ? `: ${apiErr.msg}` : ''), ); } finally { setIsCreatingBot(false); } }, [selectedScenario, 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); let previewPipelineUuid = createdPipelineUuid; let createdPreviewPipelineUuid: string | null = null; try { const configToSave = ensureHttpBotSigningSecret( selectedAdapter, adapterConfig, ); setAdapterConfig(configToSave); if ( selectedScenarioDefinition?.processorKind === 'pipeline' && !previewPipelineUuid ) { const pipelineResp = await httpClient.createPipeline({ name: `${botName} Pipeline`, description: botDescription || '', config: {}, }); previewPipelineUuid = pipelineResp.uuid; createdPreviewPipelineUuid = pipelineResp.uuid; } const botUpdate: Partial = { name: botName, description: botDescription || '', adapter: selectedAdapter, adapter_config: configToSave, enable: true, }; if ( selectedScenarioDefinition?.processorKind === 'pipeline' && previewPipelineUuid ) { botUpdate.event_bindings = [ { event_pattern: selectedScenarioDefinition.eventType, target_type: 'pipeline', target_uuid: previewPipelineUuid, filters: [], priority: 0, enabled: true, description: '', }, ]; } await httpClient.updateBot(createdBotUuid, botUpdate); if (previewPipelineUuid !== createdPipelineUuid) { setCreatedPipelineUuid(previewPipelineUuid); } setBotSaved(true); if (selectedAdapter === 'web_page_bot') { setPageBotPreviewRequest((request) => request + 1); } setMessageReceived(false); // 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, created_pipeline_uuid: previewPipelineUuid, bot_saved: true, message_received: false, }); } catch (err) { if (createdPreviewPipelineUuid) { try { await httpClient.deletePipeline(createdPreviewPipelineUuid); } catch (rollbackError) { console.warn( 'Failed to roll back wizard preview pipeline', rollbackError, ); } } const apiErr = err as { msg?: string }; toast.error( t('wizard.createError') + (apiErr?.msg ? `: ${apiErr.msg}` : ''), ); } finally { setIsSavingBot(false); } }, [ createdBotUuid, selectedAdapter, botName, botDescription, adapterConfig, createdPipelineUuid, selectedScenarioDefinition, t, saveProgress, ]); const handleMessageReceived = useCallback(() => { if (messageReceived) return; setMessageReceived(true); saveProgress({ step: 1, message_received: true }); }, [messageReceived, saveProgress]); // ---- Create Pipeline & Link (Step 2 finish) ---- const handleFinish = useCallback(async () => { if ( !selectedRunner || !isRunnerConfigComplete || !createdBotUuid || !selectedScenarioDefinition ) return; setIsSubmitting(true); let processorUuid = ''; let processorCreatedThisAttempt = false; let targetType: 'agent' | 'pipeline' | null = null; try { if (selectedScenarioDefinition.processorKind === 'pipeline') { targetType = 'pipeline'; processorUuid = createdPipelineUuid ?? ''; if (!processorUuid) { const pipeline: Pipeline = { name: `${botName} Pipeline`, description: botDescription || '', config: {}, }; const pipelineResp = await httpClient.createPipeline(pipeline); processorUuid = pipelineResp.uuid; processorCreatedThisAttempt = true; } const createdPipeline = await httpClient.getPipeline(processorUuid); const fullConfig = createdPipeline.pipeline.config as unknown as Record< string, unknown >; const fullAiConfig = fullConfig.ai && typeof fullConfig.ai === 'object' ? (fullConfig.ai as Record) : {}; const existingRunner = fullAiConfig.runner && typeof fullAiConfig.runner === 'object' ? (fullAiConfig.runner as Record) : {}; const existingRunnerConfigs = fullAiConfig.runner_config && typeof fullAiConfig.runner_config === 'object' ? (fullAiConfig.runner_config as Record) : {}; await httpClient.updatePipeline(processorUuid, { name: `${botName} Pipeline`, description: botDescription || '', config: { ...fullConfig, ai: { ...fullAiConfig, runner: { ...existingRunner, id: selectedRunner }, runner_config: { ...existingRunnerConfigs, [selectedRunner]: runnerConfig, }, }, }, }); } else { targetType = 'agent'; const agentResp = await httpClient.createAgent({ kind: 'agent', name: `${botName} - ${t(selectedScenarioDefinition.labelKey)}`, description: botDescription || '', emoji: selectedScenarioDefinition.emoji, component_ref: selectedRunner, config: { runner: { id: selectedRunner, 'expire-time': 0 }, runner_config: { [selectedRunner]: runnerConfig }, }, supported_event_patterns: [selectedScenarioDefinition.eventType], }); processorUuid = agentResp.uuid; processorCreatedThisAttempt = true; } 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, event_bindings: [ { event_pattern: selectedScenarioDefinition.eventType, target_type: targetType, target_uuid: processorUuid, filters: [], priority: 0, enabled: true, description: '', }, ], }); setCurrentStep(3); if (targetType === 'pipeline') { setCreatedPipelineUuid(processorUuid); } saveProgress({ step: 3, created_pipeline_uuid: targetType === 'pipeline' ? processorUuid : null, }); } catch (err) { if (processorCreatedThisAttempt && processorUuid) { try { if (targetType === 'pipeline') { await httpClient.deletePipeline(processorUuid); } else { await httpClient.deleteAgent(processorUuid); } } catch (rollbackError) { console.warn('Failed to roll back wizard processor', rollbackError); } } const apiErr = err as { msg?: string }; toast.error( t('wizard.createError') + (apiErr?.msg ? `: ${apiErr.msg}` : ''), ); } finally { setIsSubmitting(false); } }, [ selectedRunner, isRunnerConfigComplete, createdBotUuid, createdPipelineUuid, selectedScenarioDefinition, botName, botDescription, runnerConfig, t, saveProgress, ]); // ---- 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_scenario: null, selected_adapter: null, created_bot_uuid: null, created_pipeline_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); navigate('/home'); }, [navigate, t]); // ---- Render ---- if (isLoading) { return (
); } const stepLabels = [ t('wizard.step.scenarioChannel'), 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, selectedScenario, onSelectScenario, selected, onSelect, }: { adapters: Adapter[]; selectedScenario: WizardScenarioId | null; onSelectScenario: (scenarioId: WizardScenarioId) => void; selected: string | null; onSelect: (name: string) => void; }) { const { t } = useTranslation(); const [showLegacy, setShowLegacy] = useState(false); const activeAdapters = useMemo( () => selectedScenario ? adapters.filter( (adapter) => !adapter.spec.legacy && adapterSupportsScenario(adapter, selectedScenario), ) : [], [adapters, selectedScenario], ); const legacyAdapters = useMemo( () => selectedScenario ? adapters.filter( (adapter) => adapter.spec.legacy && adapterSupportsScenario(adapter, selectedScenario), ) : [], [adapters, selectedScenario], ); const groupedAdapters = useMemo(() => { const withCategories = activeAdapters.map((a) => ({ ...a, categories: a.spec.categories, })); return groupByCategory(withCategories); }, [activeAdapters]); return (

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

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

{WIZARD_SCENARIOS.map((scenario) => { const Icon = scenario.icon; const isSelected = selectedScenario === scenario.id; return ( ); })}

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

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

{!selectedScenario && (
{t('wizard.platform.chooseScenarioFirst')}
)} {selectedScenario && activeAdapters.length === 0 && legacyAdapters.length === 0 && (
{t('wizard.platform.noCompatiblePlatforms')}
)} {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; })()}
))}
))} {legacyAdapters.length > 0 && (
{showLegacy && ( <>

{t('bots.legacyAdaptersHint')}

{legacyAdapters.map((adapter) => ( onSelect(adapter.name)} >
{extractI18nObject(adapter.label)}
{selected === adapter.name && (
)}
))}
)}
)}
); } // --------------------------------------------------------------------------- // Step 1: Bot Configuration + Logs // --------------------------------------------------------------------------- function PageBotFloatingWidget({ botUuid, title, testNotice, openRequest, }: { botUuid: string; title?: string; testNotice: string; openRequest: number; }) { useEffect(() => { const script = document.createElement('script'); script.src = `${window.location.origin}/api/v1/embed/${botUuid}/widget.js?preview=wizard&v=${Date.now()}`; script.dataset.title = title || 'LangBot'; script.dataset.testNotice = testNotice; script.dataset.autoOpen = 'true'; document.body.appendChild(script); return () => { script.remove(); const root = document.getElementById('langbot-widget-root') as | (HTMLElement & { langbotDestroy?: () => void; langbotOpen?: () => void; }) | null; if (root?.langbotDestroy) { root.langbotDestroy(); } else { root?.remove(); } }; }, [botUuid, testNotice, title]); useEffect(() => { if (openRequest <= 0) return; const root = document.getElementById('langbot-widget-root') as | (HTMLElement & { langbotOpen?: () => void }) | null; root?.langbotOpen?.(); }, [openRequest]); return null; } function StepBotConfig({ adapterConfigItems, adapterConfigValues, onAdapterConfigChange, selectedAdapterName, adapters, createdBotUuid, isSavingBot, botSaved, pageBotPreviewRequest, messageReceived, requiresMessageVerification, onMessageReceived, onSaveBot, webhookUrl, extraWebhookUrl, }: { adapterConfigItems: IDynamicFormItemSchema[]; adapterConfigValues: Record; onAdapterConfigChange: (v: Record) => void; selectedAdapterName: string | null; adapters: Adapter[]; createdBotUuid: string | null; isSavingBot: boolean; botSaved: boolean; pageBotPreviewRequest: number; messageReceived: boolean; requiresMessageVerification: boolean; onMessageReceived: () => void; onSaveBot: () => void; webhookUrl: string; extraWebhookUrl: string; }) { const { t } = useTranslation(); const [testMessage, setTestMessage] = useState( t('wizard.botConfig.httpTestDefaultMessage'), ); const [isSendingTest, setIsSendingTest] = useState(false); const adapterLabel = useMemo(() => { const a = adapters.find((ad) => ad.name === selectedAdapterName); return a ? extractI18nObject(a.label) : (selectedAdapterName ?? ''); }, [adapters, selectedAdapterName]); const webhookModeEnabled = useMemo( () => isWebhookModeEnabled(adapterConfigItems, adapterConfigValues) && Boolean(webhookUrl), [adapterConfigItems, adapterConfigValues, webhookUrl], ); const receivedMessageWithoutLangBotAccount = messageReceived && userInfo?.account_type !== 'space'; const receivedMessageSuccessfully = messageReceived && !receivedMessageWithoutLangBotAccount; // Stable callback ref const onAdapterConfigRef = useRef(onAdapterConfigChange); onAdapterConfigRef.current = onAdapterConfigChange; const stableAdapterConfigCb = useCallback( (val: object) => onAdapterConfigRef.current(val as Record), [], ); const copyWebhookUrl = useCallback(async () => { if (!webhookUrl) return; await navigator.clipboard.writeText(webhookUrl); toast.success(t('common.copySuccess')); }, [t, webhookUrl]); const sendHttpBotTest = useCallback(async () => { if (!createdBotUuid || !testMessage.trim()) return; setIsSendingTest(true); try { await httpClient.testHttpBotInbound(createdBotUuid, testMessage.trim()); toast.success(t('wizard.botConfig.httpTestAccepted')); } catch (error) { toast.error( t('wizard.botConfig.httpTestFailed', { error: getErrorMessage(error), }), ); } finally { setIsSendingTest(false); } }, [createdBotUuid, testMessage, t]); return (
{selectedAdapterName === 'web_page_bot' && botSaved && createdBotUuid && ( )}

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

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

{botSaved && requiresMessageVerification && (
{receivedMessageWithoutLangBotAccount ? ( ) : messageReceived ? ( ) : selectedAdapterName === 'web_page_bot' ? ( ) : selectedAdapterName === 'http_bot' ? ( ) : webhookModeEnabled ? ( ) : ( )}

{messageReceived ? t( receivedMessageWithoutLangBotAccount ? 'wizard.botConfig.messageReceivedLocalAccountWarning' : 'wizard.botConfig.messageReceived', ) : selectedAdapterName === 'web_page_bot' ? t('wizard.botConfig.pageBotTestPrompt') : selectedAdapterName === 'http_bot' ? t('wizard.botConfig.httpTestPrompt') : webhookModeEnabled ? t('wizard.botConfig.webhookTestPrompt') : t('wizard.botConfig.waitingForMessage')}

{!messageReceived && webhookModeEnabled && (
{webhookUrl}
{selectedAdapterName === 'http_bot' && (
setTestMessage(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter') void sendHttpBotTest(); }} className="bg-background" />
)}
)}
)}
{/* Left column: Adapter config form */}
{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; })()}
{adapterConfigItems.length > 0 && ( } onSubmit={stableAdapterConfigCb} systemContext={{ is_wizard: true, webhook_url: webhookUrl, extra_webhook_url: extraWebhookUrl, outbound_ips: systemInfo.outbound_ips, }} /> )}
{/* Bot saved indicator */} {botSaved && !requiresMessageVerification && (
{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, marketplaceRunners, installedPluginIds, isRunnerCatalogLoading, runnerCatalogError, installingRunnerPluginId, runnerInstallError, selected, onSelect, onInstall, onRetryCatalog, runnerConfigItems, runnerConfigValues, onRunnerConfigChange, }: { runnerOptions: { name: string; label: { en_US: string; zh_Hans: string } }[]; marketplaceRunners: PluginV4[]; installedPluginIds: string[]; isRunnerCatalogLoading: boolean; runnerCatalogError: boolean; installingRunnerPluginId: string | null; runnerInstallError: string | null; selected: string | null; onSelect: (name: string) => void; onInstall: (plugin: PluginV4) => void; onRetryCatalog: () => 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]); const marketplaceRunnerIds = useMemo( () => new Set(marketplaceRunners.map(marketplacePluginId)), [marketplaceRunners], ); const standaloneRunnerOptions = useMemo( () => runnerOptions.filter((option) => { if (!option.name.startsWith('plugin:')) return true; const pluginId = option.name.slice('plugin:'.length).split('/'); return !marketplaceRunnerIds.has(`${pluginId[0]}/${pluginId[1]}`); }), [marketplaceRunnerIds, runnerOptions], ); // Before any runner is selected: centered grid layout if (!selected) { return (

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

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

{runnerCatalogError && (

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

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

)} {runnerInstallError && (
{runnerInstallError}
)} {isRunnerCatalogLoading && marketplaceRunners.length === 0 && (
{t('wizard.aiEngine.loadingCatalog')}
)}
{marketplaceRunners.map((plugin) => { const pluginId = marketplacePluginId(plugin); const prefix = runnerPluginPrefix(plugin); const registeredOptions = runnerOptions.filter((option) => option.name.startsWith(prefix), ); const preferredOption = registeredOptions.find((option) => option.name.endsWith('/default'), ) ?? registeredOptions[0]; const isInstalled = installedPluginIds.includes(pluginId); const isInstalling = installingRunnerPluginId === pluginId; const iconUrl = getCloudServiceClientSync().resolveMarketplaceIconURL( plugin.type, plugin.author, plugin.name, plugin.icon, ); return (
{extractI18nObject(plugin.label) || plugin.name} {plugin.author}/{plugin.name}

{extractI18nObject(plugin.description)}

{preferredOption ? ( ) : isInstalled ? ( ) : ( )}
); })} {standaloneRunnerOptions.map((opt) => ( onSelect(opt.name)} >
{extractI18nObject(opt.label)} {opt.name}
))}
{!isRunnerCatalogLoading && marketplaceRunners.length === 0 && standaloneRunnerOptions.length === 0 && !runnerCatalogError && (

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

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

)}
); } // 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 && (
)}
); })}
{/* 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 navigate = useNavigate(); 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_scenario: null, selected_adapter: null, created_bot_uuid: null, created_pipeline_uuid: null, bot_saved: false, selected_runner: null, }); systemInfo.wizard_progress = null; } catch { toast.error(t('wizard.completeSaveError')); setIsCompleting(false); return; } setIsCompleting(false); navigate('/home/bots'); }, [navigate, t]); return (
{/* Confetti particles */}
{particles.map((p) => (
))}

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

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

); }