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, Check, ChevronDown, ChevronRight, Sparkles, PartyPopper, Loader2, MessageSquare, ShieldCheck, UserMinus, UserPlus, X, ExternalLink, Download, RefreshCw, CircleAlert, } from 'lucide-react'; import { httpClient } from '@/app/infra/http/HttpClient'; import { systemInfo, bootstrapWorkspaceSession, initializeSystemInfo, getCloudServiceClient, getCloudServiceClientSync, } 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 { 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; const RUNNER_COMPONENT_FILTER = 'AgentRunner'; const RUNNER_CATALOG_PAGE_SIZE = 100; const RUNNER_INSTALL_TIMEOUT_MS = 120_000; const RUNNER_REGISTRATION_TIMEOUT_MS = 60_000; function wait(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } function getErrorMessage(error: unknown): string { if (error && typeof error === 'object') { const value = error as { msg?: string; message?: string }; return value.msg || value.message || ''; } return typeof error === 'string' ? error : ''; } function marketplacePluginId(plugin: Pick) { return `${plugin.author}/${plugin.name}`; } function runnerPluginPrefix(plugin: Pick) { return `plugin:${plugin.author}/${plugin.name}/`; } 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 [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 loadRunnerCatalog = useCallback(async () => { setIsRunnerCatalogLoading(true); setRunnerCatalogError(false); try { const cloudClient = await getCloudServiceClient(); const [firstSearchResult, recommendationResult, installedResult] = await Promise.all([ cloudClient.searchMarketplaceExtensions({ query: '', page: 1, page_size: RUNNER_CATALOG_PAGE_SIZE, type_filter: 'plugin', component_filter: RUNNER_COMPONENT_FILTER, }), cloudClient.getRecommendationLists().catch(() => ({ lists: [] })), httpClient.getPlugins().catch(() => ({ plugins: [] })), ]); const remainingPageCount = Math.max( 0, Math.ceil((firstSearchResult.total || 0) / RUNNER_CATALOG_PAGE_SIZE) - 1, ); const remainingResults = await Promise.all( Array.from({ length: remainingPageCount }, (_, index) => cloudClient.searchMarketplaceExtensions({ query: '', page: index + 2, page_size: RUNNER_CATALOG_PAGE_SIZE, type_filter: 'plugin', component_filter: RUNNER_COMPONENT_FILTER, }), ), ); const catalogPlugins = [ ...(firstSearchResult.plugins || []), ...remainingResults.flatMap((result) => result.plugins || []), ]; const recommendationOrder = new Map(); let nextOrder = 0; for (const list of recommendationResult.lists || []) { for (const plugin of list.plugins || []) { if (!plugin.components?.[RUNNER_COMPONENT_FILTER]) continue; const id = marketplacePluginId(plugin); if (!recommendationOrder.has(id)) { recommendationOrder.set(id, nextOrder); nextOrder += 1; } } } const runners = catalogPlugins .filter((plugin) => plugin.components?.[RUNNER_COMPONENT_FILTER]) .sort((left, right) => { const leftOrder = recommendationOrder.get(marketplacePluginId(left)); const rightOrder = recommendationOrder.get( marketplacePluginId(right), ); if (leftOrder !== undefined && rightOrder !== undefined) { return leftOrder - rightOrder; } if (leftOrder !== undefined) return -1; if (rightOrder !== undefined) return 1; return right.install_count - left.install_count; }); setMarketplaceRunners(runners); setInstalledPluginIds( installedResult.plugins.map((plugin) => { const metadata = plugin.manifest.manifest.metadata; return `${metadata.author ?? ''}/${metadata.name}`; }), ); } catch (error) { console.error('Failed to load AgentRunner 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, bot_saved: overrides.bot_saved ?? botSaved, 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, botSaved, 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; setSelectedAdapter(progress.selected_adapter); setSelectedScenario( (progress.selected_scenario as WizardScenarioId | null) ?? 'message_reply', ); 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) || '', ); // 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, 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; }; }, [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]); // ---- 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 { if (!plugin.latest_version) { throw new Error(t('wizard.aiEngine.versionUnavailable')); } const { task_id: taskId } = await httpClient.installPluginFromMarketplace( plugin.author, plugin.name, plugin.latest_version, ); const installDeadline = Date.now() + RUNNER_INSTALL_TIMEOUT_MS; let installCompleted = false; while (Date.now() < installDeadline) { const task = await httpClient.getAsyncTask(taskId); if (task.runtime.done) { if (task.runtime.exception) { throw new Error(task.runtime.exception); } installCompleted = true; break; } await wait(1000); } if (!installCompleted) { throw new Error(t('wizard.aiEngine.installTimeout')); } const registrationDeadline = Date.now() + RUNNER_REGISTRATION_TIMEOUT_MS; const prefix = runnerPluginPrefix(plugin); while (Date.now() < registrationDeadline) { const metadata = await httpClient.getGeneralPipelineMetadata(); const nextAiTab = metadata.configs.find((config) => config.name === 'ai') ?? null; const nextRunnerStage = nextAiTab?.stages.find( (stage) => stage.name === 'runner', ); const nextRunnerOptions = nextRunnerStage?.config.find((item) => item.name === 'id') ?.options ?? []; const pluginRunnerOptions = nextRunnerOptions.filter((option) => option.name.startsWith(prefix), ); const preferredRunner = pluginRunnerOptions.find((option) => option.name.endsWith('/default'), ) ?? pluginRunnerOptions[0]; if (nextAiTab && preferredRunner) { setAiConfigTab(nextAiTab); setInstalledPluginIds((current) => current.includes(pluginId) ? current : [...current, pluginId], ); handleSelectRunner(preferredRunner.name, nextAiTab); toast.success( t('wizard.aiEngine.installSuccess', { runner: extractI18nObject(plugin.label), }), ); return; } await wait(1000); } throw new Error(t('wizard.aiEngine.registrationTimeout')); } catch (error) { const message = getErrorMessage(error) || t('wizard.aiEngine.installFailed'); setRunnerInstallError(message); toast.error(message); } finally { setInstallingRunnerPluginId(null); } }, [handleSelectRunner, t], ); // ---- Navigation helpers ---- const canProceed = useCallback((): boolean => { switch (currentStep) { case 0: return selectedScenario !== null && selectedAdapter !== null; case 1: return createdBotUuid !== null && botSaved; case 2: return selectedRunner !== null; default: return false; } }, [ currentStep, selectedScenario, selectedAdapter, createdBotUuid, botSaved, selectedRunner, ]); 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 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_scenario: selectedScenario, 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); } }, [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); 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 || !selectedScenarioDefinition) return; setIsSubmitting(true); let processorUuid = ''; try { let targetType: 'agent' | 'pipeline'; if (selectedScenarioDefinition.processorKind === 'pipeline') { const pipeline: Pipeline = { name: `${botName} Pipeline`, description: botDescription || '', config: {}, }; const pipelineResp = await httpClient.createPipeline(pipeline); processorUuid = pipelineResp.uuid; const createdPipeline = await httpClient.getPipeline(pipelineResp.uuid); 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(pipelineResp.uuid, { name: `${botName} Pipeline`, description: botDescription || '', config: { ...fullConfig, ai: { ...fullAiConfig, runner: { ...existingRunner, id: selectedRunner }, runner_config: { ...existingRunnerConfigs, [selectedRunner]: runnerConfig, }, }, }, }); targetType = 'pipeline'; } else { 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 }, }, enabled: true, supported_event_patterns: [selectedScenarioDefinition.eventType], }); processorUuid = agentResp.uuid; targetType = 'agent'; } 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); saveProgress({ step: 3 }); } catch (err) { if (processorUuid) { try { 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, createdBotUuid, 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, 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 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, outbound_ips: systemInfo.outbound_ips, }} />
)} {/* 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, 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, 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')}

); }