Files
LangBot/web/src/app/wizard/page.tsx
T
langbot-dev e82918faef fix(wizard): prevent duplicate models at API level
Before adding models, fetch existing provider models and skip
names that already exist. Show error toast for manual add duplicates.
2026-08-12 02:38:28 +08:00

1891 lines
64 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { UUID } from 'uuidjs';
import { toast } from 'sonner';
import {
ArrowLeft,
ArrowRight,
Check,
Sparkles,
PartyPopper,
Loader2,
X,
ExternalLink,
Rocket,
Wrench,
} from 'lucide-react';
import { httpClient } from '@/app/infra/http/HttpClient';
import {
userInfo,
systemInfo,
bootstrapWorkspaceSession,
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 ProviderForm from '@/app/home/components/models-dialog/component/provider-form/ProviderForm';
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 { 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;
// ---------------------------------------------------------------------------
// 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 [selectedAdapter, setSelectedAdapter] = useState<string | null>(null);
const [selectedRunner, setSelectedRunner] = useState<string | null>(null);
const [botName, setBotName] = useState('');
const [botDescription, _setBotDescription] = useState('');
const [adapterConfig, setAdapterConfig] = useState<Record<string, unknown>>(
{},
);
const [runnerConfig, setRunnerConfig] = useState<Record<string, unknown>>({});
const [aiEngineMode, setAiEngineMode] = useState<
'orchestrated' | 'llm' | null
>(null);
const [modelSource, setModelSource] = useState<'space' | 'custom' | null>(
null,
);
const [providerCreated, setProviderCreated] = useState(false);
const [createdProviderUuid, setCreatedProviderUuid] = useState<string | null>(
null,
);
const [modelsAdded, setModelsAdded] = useState(false);
// Sub-layer within Step 2: 0=main, 1=sub-choice, 2=provider, 3=scan, 4=config
const [step2Layer, setStep2Layer] = useState(0);
const [maxReachedLayer, setMaxReachedLayer] = useState(0);
const goToStep2Layer = useCallback(
(layer: number) => {
setStep2Layer(layer);
setMaxReachedLayer((prev) => Math.max(prev, layer));
},
[],
);
// Saved provider form data for back navigation
const [savedProviderForm, setSavedProviderForm] = useState<{
name?: string;
requester?: string;
base_url?: string;
api_key?: string;
}>({});
// Saved selected scanned models for back navigation
const [savedSelectedModels, setSavedSelectedModels] = useState<Set<string>>(
new Set(),
);
const [createdBotUuid, setCreatedBotUuid] = useState<string | null>(null);
const [webhookUrl, setWebhookUrl] = useState<string>('');
const [extraWebhookUrl, setExtraWebhookUrl] = useState<string>('');
// ---- Remote data ----
const [adapters, setAdapters] = useState<Adapter[]>([]);
const [aiConfigTab, setAiConfigTab] = useState<PipelineConfigTab | null>(
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<WizardProgress> = {}) => {
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 {
// 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);
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<string, unknown>
| 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;
};
}, [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 === '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,
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) => {
setSelectedRunner(runner);
goToStep2Layer(4);
saveProgress({ step: 2, selected_runner: runner });
},
[saveProgress],
);
const handleAiEngineModeSelect = useCallback(
(mode: 'orchestrated' | 'llm') => {
setAiEngineMode(mode);
setSelectedRunner(null);
setModelSource(null);
setProviderCreated(false);
setCreatedProviderUuid(null);
setModelsAdded(false);
setSavedProviderForm({});
setSavedSelectedModels(new Set());
setMaxReachedLayer(0);
goToStep2Layer(1);
},
[goToStep2Layer],
);
// ---- Navigation helpers ----
const canProceed = useCallback((): boolean => {
switch (currentStep) {
case 0:
return selectedAdapter !== null;
case 1:
return createdBotUuid !== null && botSaved;
case 2:
if (aiEngineMode === 'orchestrated') {
return selectedRunner !== null;
}
if (aiEngineMode === 'llm') {
return (
modelSource === 'space' ||
(modelSource === 'custom' && modelsAdded)
);
}
return false;
default:
return false;
}
}, [
currentStep,
selectedAdapter,
createdBotUuid,
botSaved,
aiEngineMode,
selectedRunner,
modelSource,
modelsAdded,
]);
const goNext = useCallback(() => {
if (currentStep === 2 && step2Layer < maxReachedLayer) {
setStep2Layer(maxReachedLayer);
return;
}
if (currentStep < TOTAL_STEPS - 1 && canProceed()) {
const nextStep = currentStep + 1;
setCurrentStep(nextStep);
saveProgress({ step: nextStep });
}
}, [currentStep, step2Layer, maxReachedLayer, canProceed, saveProgress]);
const goPrev = useCallback(() => {
if (currentStep === 2 && step2Layer > 0) {
setStep2Layer(step2Layer - 1);
return;
}
if (currentStep > 0) {
const prevStep = currentStep - 1;
setCurrentStep(prevStep);
saveProgress({ step: prevStep });
}
}, [currentStep, step2Layer, 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<string, unknown>
| 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<string, unknown>
| 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]);
const handleModelSourceSelect = useCallback(
(source: 'space' | 'custom') => {
setModelSource(source);
if (source === 'space') {
handleSpaceAuth();
} else if (providerCreated && modelsAdded) {
goToStep2Layer(4);
} else if (providerCreated) {
goToStep2Layer(3);
} else {
goToStep2Layer(2);
}
},
[handleSpaceAuth, providerCreated, modelsAdded],
);
// ---- 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);
navigate('/home');
}, [navigate, t]);
// ---- Render ----
if (isLoading) {
return (
<div className="fixed inset-0 z-50 bg-background flex items-center justify-center">
<LoadingSpinner text={t('wizard.loading')} />
</div>
);
}
const stepLabels = [
t('wizard.step.platform'),
t('wizard.step.botConfig'),
t('wizard.step.aiEngine'),
t('wizard.step.done'),
];
return (
<div className="fixed inset-0 z-50 bg-background flex flex-col">
{/* Top bar: Skip button */}
<div className="shrink-0 flex items-center justify-between px-4 sm:px-6 py-3 border-b">
<div className="flex items-center gap-2">
<Sparkles className="w-5 h-5 text-primary" />
<span className="font-semibold text-base sm:text-lg">
{t('sidebar.quickStart')}
</span>
</div>
<div className="flex items-center gap-2">
<LanguageSelector />
{currentStep < 3 && (
<Button
variant="ghost"
size="sm"
onClick={() => setShowSkipConfirm(true)}
>
{t('wizard.skip')}
<X className="w-4 h-4 ml-1" />
</Button>
)}
</div>
</div>
{/* Stepper header */}
<div className="shrink-0 py-3 sm:py-4 px-4 sm:px-6">
<div className="flex items-center justify-center gap-1.5 sm:gap-2">
{stepLabels.map((label, idx) => (
<div key={label} className="flex items-center gap-1.5 sm:gap-2">
<div className="flex items-center gap-1 sm:gap-1.5">
<div
className={cn(
'w-6 h-6 sm:w-7 sm:h-7 rounded-full flex items-center justify-center text-xs font-medium transition-colors',
idx < currentStep
? 'bg-blue-600 text-white'
: idx === currentStep
? 'bg-blue-600 text-white'
: 'bg-muted text-muted-foreground',
)}
>
{idx < currentStep ? (
<Check className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
) : (
idx + 1
)}
</div>
<span
className={cn(
'text-sm hidden sm:inline',
idx === currentStep
? 'font-medium text-blue-600'
: 'text-muted-foreground',
)}
>
{label}
</span>
</div>
{idx < TOTAL_STEPS - 1 && (
<div
className={cn(
'w-4 sm:w-8 h-px',
idx < currentStep ? 'bg-blue-600' : 'bg-border',
)}
/>
)}
</div>
))}
</div>
</div>
{/* Step content */}
<div
className={cn(
'flex-1 min-h-0 px-4 sm:px-6 pb-4 sm:pb-6',
currentStep === 2 && selectedRunner
? 'lg:flex lg:flex-col lg:overflow-hidden overflow-y-auto'
: 'overflow-y-auto',
)}
>
{currentStep === 0 && (
<StepPlatform
adapters={adapters}
selected={selectedAdapter}
onSelect={setSelectedAdapter}
/>
)}
{currentStep === 1 && (
<StepBotConfig
adapterConfigItems={selectedAdapterConfig}
adapterConfigValues={adapterConfig}
onAdapterConfigChange={setAdapterConfig}
selectedAdapterName={selectedAdapter}
adapters={adapters}
createdBotUuid={createdBotUuid}
isSavingBot={isSavingBot}
botSaved={botSaved}
onSaveBot={handleSaveBot}
webhookUrl={webhookUrl}
extraWebhookUrl={extraWebhookUrl}
/>
)}
{currentStep === 2 && (
<StepAIEngine
runnerOptions={runnerOptions}
step2Layer={step2Layer}
aiEngineMode={aiEngineMode}
onAiEngineModeSelect={handleAiEngineModeSelect}
selectedRunner={selectedRunner}
onSelectRunner={handleSelectRunner}
isLocalAccount={isLocalAccount}
onSpaceAuth={handleSpaceAuth}
modelSource={modelSource}
onModelSourceSelect={handleModelSourceSelect}
providerCreated={providerCreated}
createdProviderUuid={createdProviderUuid}
modelsAdded={modelsAdded}
onProviderCreated={(uuid) => {
setProviderCreated(true);
setCreatedProviderUuid(uuid ?? null);
goToStep2Layer(3);
}}
onModelsAdded={() => {
setModelsAdded(true);
setSelectedRunner('local-agent');
goToStep2Layer(4);
saveProgress({ step: 2, selected_runner: 'local-agent' });
}}
onResetModelSource={() => setModelSource(null)}
savedProviderForm={savedProviderForm}
onSavedProviderFormChange={setSavedProviderForm}
savedSelectedModels={savedSelectedModels}
onSavedSelectedModelsChange={setSavedSelectedModels}
onGoToNextLayer={() => {
if (modelsAdded) {
goToStep2Layer(4);
} else {
goToStep2Layer(3);
}
}}
runnerConfigItems={selectedRunnerConfigItems}
runnerConfigValues={runnerConfig}
onRunnerConfigChange={setRunnerConfig}
/>
)}
{currentStep === 3 && <StepDone />}
</div>
{/* Footer navigation */}
{currentStep < 3 && (
<div className="shrink-0 flex justify-between items-center px-4 sm:px-6 py-3 sm:py-4 border-t">
<Button
variant="outline"
onClick={goPrev}
disabled={currentStep === 0}
>
<ArrowLeft className="w-4 h-4 mr-1.5" />
{t('wizard.prev')}
</Button>
{currentStep === 0 ? (
<Button
onClick={handleCreateBot}
disabled={!canProceed() || isCreatingBot}
>
{isCreatingBot && (
<Loader2 className="w-4 h-4 mr-1.5 animate-spin" />
)}
{t('wizard.confirmCreateBot')}
<ArrowRight className="w-4 h-4 ml-1.5" />
</Button>
) : currentStep === 1 ? (
<Button onClick={goNext} disabled={!canProceed()}>
{t('wizard.next')}
<ArrowRight className="w-4 h-4 ml-1.5" />
</Button>
) : (
<Button
onClick={handleFinish}
disabled={!canProceed() || isSubmitting}
>
{isSubmitting && (
<Loader2 className="w-4 h-4 mr-1.5 animate-spin" />
)}
{t('wizard.finish')}
</Button>
)}
</div>
)}
{/* Skip confirmation dialog */}
<Dialog open={showSkipConfirm} onOpenChange={setShowSkipConfirm}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('wizard.skip')}</DialogTitle>
<DialogDescription>
{t('wizard.skipConfirmMessage')}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowSkipConfirm(false)}
disabled={isSkipping}
>
{t('wizard.prev')}
</Button>
<Button onClick={handleSkipConfirm} disabled={isSkipping}>
{isSkipping && (
<Loader2 className="w-4 h-4 mr-1.5 animate-spin" />
)}
{t('wizard.skipConfirmOk')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
// ---------------------------------------------------------------------------
// 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 (
<div className="space-y-6 max-w-4xl mx-auto">
<div className="text-center">
<h2 className="text-xl font-semibold">{t('wizard.platform.title')}</h2>
<p className="text-sm text-muted-foreground mt-1">
{t('wizard.platform.description')}
</p>
</div>
{groupedAdapters.map((group) => (
<div key={group.categoryId ?? 'uncategorized'} className="space-y-3">
{group.categoryId && (
<h3 className="text-sm font-medium text-muted-foreground">
{getCategoryLabel(t, group.categoryId)}
</h3>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{group.items.map((adapter) => (
<Card
key={adapter.name}
className={cn(
'cursor-pointer transition-all hover:shadow-md',
selected === adapter.name
? 'ring-2 ring-primary shadow-md'
: 'hover:border-primary/50',
)}
onClick={() => onSelect(adapter.name)}
>
<CardHeader className="flex flex-row items-center gap-3 pb-2">
<img
src={httpClient.getAdapterIconURL(adapter.name)}
alt=""
className="w-10 h-10 rounded-lg shrink-0"
/>
<div className="min-w-0">
<CardTitle className="text-base truncate">
{extractI18nObject(adapter.label)}
</CardTitle>
</div>
{selected === adapter.name && (
<div className="ml-auto shrink-0">
<div className="w-5 h-5 rounded-full bg-primary flex items-center justify-center">
<Check className="w-3 h-3 text-primary-foreground" />
</div>
</div>
)}
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground line-clamp-2">
{extractI18nObject(adapter.description)}
</p>
{(() => {
const docUrl = getAdapterDocUrl(
adapter.spec.help_links,
i18n.language,
);
return docUrl ? (
<a
href={docUrl}
target="_blank"
rel="noopener noreferrer"
className="mt-2 inline-flex items-center text-xs text-primary hover:underline"
onClick={(e) => e.stopPropagation()}
>
<ExternalLink className="mr-1 h-3 w-3" />
{t('bots.viewAdapterDocs')}
</a>
) : null;
})()}
</CardContent>
</Card>
))}
</div>
</div>
))}
</div>
);
}
// ---------------------------------------------------------------------------
// Step 1: Bot Configuration + Logs
// ---------------------------------------------------------------------------
function StepBotConfig({
adapterConfigItems,
adapterConfigValues,
onAdapterConfigChange,
selectedAdapterName,
adapters,
createdBotUuid,
isSavingBot,
botSaved,
onSaveBot,
webhookUrl,
extraWebhookUrl,
}: {
adapterConfigItems: IDynamicFormItemSchema[];
adapterConfigValues: Record<string, unknown>;
onAdapterConfigChange: (v: Record<string, unknown>) => 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<string, unknown>),
[],
);
return (
<div className="max-w-5xl mx-auto space-y-6">
<div className="text-center">
<h2 className="text-xl font-semibold">{t('wizard.botConfig.title')}</h2>
<p className="text-sm text-muted-foreground mt-1">
{t('wizard.botConfig.description')}
</p>
</div>
<div className="grid gap-6 grid-cols-1 lg:grid-cols-2">
{/* Left column: Adapter config form */}
<div className="space-y-4">
{adapterConfigItems.length > 0 && (
<Card>
<CardHeader className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2">
<div className="flex items-center gap-2">
<CardTitle className="text-base">
{t('wizard.config.platformConfig', {
platform: adapterLabel,
})}
</CardTitle>
{selectedAdapterName &&
(() => {
const selectedAdapter = adapters.find(
(a) => a.name === selectedAdapterName,
);
const docUrl = getAdapterDocUrl(
selectedAdapter?.spec.help_links,
i18n.language,
);
return docUrl ? (
<a
href={docUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center text-xs text-primary hover:underline"
>
<ExternalLink className="mr-1 h-3 w-3" />
{t('bots.viewAdapterDocs')}
</a>
) : null;
})()}
</div>
<Button
size="sm"
onClick={onSaveBot}
disabled={isSavingBot}
className="w-full sm:w-auto shrink-0"
>
{isSavingBot && (
<Loader2 className="w-4 h-4 mr-1.5 animate-spin" />
)}
{botSaved
? t('wizard.botConfig.resaveBot')
: t('wizard.botConfig.saveBot')}
</Button>
</CardHeader>
<CardContent>
<DynamicFormComponent
itemConfigList={adapterConfigItems}
initialValues={adapterConfigValues as Record<string, object>}
onSubmit={stableAdapterConfigCb}
systemContext={{
is_wizard: true,
webhook_url: webhookUrl,
extra_webhook_url: extraWebhookUrl,
outbound_ips: systemInfo.outbound_ips,
}}
/>
</CardContent>
</Card>
)}
{/* Bot saved indicator */}
{botSaved && (
<div className="flex items-center gap-2 px-4 py-3 rounded-lg border border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-950/30">
<div className="w-5 h-5 rounded-full bg-green-500 flex items-center justify-center shrink-0">
<Check className="w-3 h-3 text-white" />
</div>
<span className="text-sm text-green-700 dark:text-green-300">
{t('wizard.botConfig.botSaved')}
</span>
</div>
)}
</div>
{/* Right column: Bot logs */}
{createdBotUuid && (
<Card className="flex flex-col min-h-[400px]">
<CardHeader className="shrink-0">
<CardTitle>{t('wizard.botConfig.logsTitle')}</CardTitle>
<CardDescription>
{t('wizard.botConfig.logsDescription')}
</CardDescription>
</CardHeader>
<CardContent className="flex-1 min-h-0 overflow-hidden">
<BotLogListComponent
botId={createdBotUuid}
autoExpandImages
hideToolbar
/>
</CardContent>
</Card>
)}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Model scan sub-component (used within Step 2)
// ---------------------------------------------------------------------------
function WizardModelScan({
providerUuid,
initialSelectedModels,
onSelectedModelsChange,
onModelsAdded,
}: {
providerUuid: string | null;
initialSelectedModels: Set<string>;
onSelectedModelsChange: (v: Set<string>) => void;
onModelsAdded: () => void;
}) {
const { t } = useTranslation();
const [scanning, setScanning] = useState(false);
const [scannedModels, setScannedModels] = useState<
{ name: string; context_length?: number | null }[]
>([]);
const [selectedModels, setSelectedModels] = useState<Set<string>>(
initialSelectedModels,
);
const [adding, setAdding] = useState(false);
const [scanDone, setScanDone] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [manualModelName, setManualModelName] = useState('');
const [manualAdding, setManualAdding] = useState(false);
const [addedModelNames, setAddedModelNames] = useState<Set<string>>(
new Set(initialSelectedModels),
);
// Auto-scan on mount
useEffect(() => {
if (!providerUuid) return;
let cancelled = false;
(async () => {
setScanning(true);
try {
const resp = await httpClient.scanProviderModels(
providerUuid,
'llm',
);
if (!cancelled) {
setScannedModels(resp.models ?? []);
setScanDone(true);
}
} catch {
if (!cancelled) setScanDone(true);
} finally {
if (!cancelled) setScanning(false);
}
})();
return () => {
cancelled = true;
};
}, [providerUuid]);
const filteredModels = searchQuery
? scannedModels.filter((m) =>
m.name.toLowerCase().includes(searchQuery.toLowerCase()),
)
: scannedModels;
const toggleModel = (name: string) => {
setSelectedModels((prev) => {
const next = new Set(prev);
if (next.has(name)) next.delete(name);
else next.add(name);
onSelectedModelsChange(next);
return next;
});
};
const getExistingModelNames = async (): Promise<Set<string>> => {
if (!providerUuid) return new Set();
try {
const resp = await httpClient.getProviderLLMModels(providerUuid);
return new Set((resp.models ?? []).map((m: { name: string }) => m.name));
} catch {
return new Set();
}
};
const handleAddSelected = async () => {
if (!providerUuid || selectedModels.size === 0) return;
setAdding(true);
try {
const existing = await getExistingModelNames();
let added = 0;
for (const name of selectedModels) {
if (existing.has(name)) continue;
const model = scannedModels.find((m) => m.name === name);
await httpClient.createProviderLLMModel({
name,
provider_uuid: providerUuid,
abilities: (model as { abilities?: string[] })?.abilities || ['llm'],
reasoning_config: { level: 'provider_default' },
context_length: model?.context_length ?? null,
extra_args: {},
} as never);
existing.add(name);
added++;
}
if (added > 0) {
toast.success(t('wizard.provider.modelsAdded', { count: added }));
}
setAddedModelNames((prev) => {
const next = new Set(prev);
for (const name of selectedModels) next.add(name);
return next;
});
setSelectedModels(new Set());
onSelectedModelsChange(new Set());
} catch {
toast.error(t('wizard.provider.modelsAddError'));
} finally {
setAdding(false);
}
};
const handleManualAdd = async () => {
const name = manualModelName.trim();
if (!providerUuid || !name) return;
setManualAdding(true);
try {
const existing = await getExistingModelNames();
if (existing.has(name)) {
toast.error(t('wizard.provider.modelAlreadyExists', { name }));
setManualAdding(false);
return;
}
await httpClient.createProviderLLMModel({
name,
provider_uuid: providerUuid,
abilities: ['llm'],
reasoning_config: { level: 'provider_default' },
context_length: null,
extra_args: {},
} as never);
toast.success(t('wizard.provider.modelsAdded', { count: 1 }));
setAddedModelNames((prev) => new Set(prev).add(name));
setManualModelName('');
} catch {
toast.error(t('wizard.provider.modelsAddError'));
} finally {
setManualAdding(false);
}
};
return (
<div className="max-w-2xl mx-auto w-full animate-in fade-in slide-in-from-bottom-2 duration-300">
<div className="text-center mb-6">
<h2 className="text-xl font-semibold">
{t('wizard.provider.scanTitle')}
</h2>
<p className="text-sm text-muted-foreground mt-1">
{t('wizard.provider.scanDescription')}
</p>
</div>
<div className="border rounded-lg p-6 bg-card space-y-4">
{scanning && (
<div className="flex items-center justify-center gap-2 py-8 text-muted-foreground">
<Loader2 className="w-5 h-5 animate-spin" />
<span>{t('wizard.provider.scanning')}</span>
</div>
)}
{!scanning && scanDone && (
<>
{/* Search input for scanned models */}
{scannedModels.length > 0 && (
<Input
placeholder={t('wizard.provider.searchModels')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="mb-2"
/>
)}
{/* Scanned models list */}
{scannedModels.length > 0 ? (
<>
<div className="max-h-48 overflow-y-auto space-y-2">
{filteredModels.map((model) => {
const isAdded = addedModelNames.has(model.name);
return (
<label
key={model.name}
className={cn(
'flex items-center gap-3 p-3 rounded-lg border transition-colors',
isAdded
? 'opacity-50 cursor-not-allowed bg-muted/50'
: 'cursor-pointer hover:bg-accent',
)}
>
<input
type="checkbox"
checked={selectedModels.has(model.name) || isAdded}
disabled={isAdded}
onChange={() => toggleModel(model.name)}
className="w-4 h-4 rounded"
/>
<div className="flex-1 min-w-0">
<span className="text-sm font-medium truncate block">
{model.name}
</span>
{model.context_length && (
<span className="text-xs text-muted-foreground">
ctx: {model.context_length.toLocaleString()}
</span>
)}
</div>
{isAdded && (
<span className="text-xs text-green-600 font-medium shrink-0">
{t('wizard.provider.alreadyAdded')}
</span>
)}
</label>
);
})}
{filteredModels.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-4">
{t('wizard.provider.noMatch')}
</p>
)}
</div>
<div className="flex gap-3 pt-2">
<Button
onClick={handleAddSelected}
disabled={selectedModels.size === 0 || adding}
className="flex-1"
>
{adding && (
<Loader2 className="w-4 h-4 mr-1.5 animate-spin" />
)}
{t('wizard.provider.addSelected', {
count: selectedModels.size,
})}
</Button>
<Button variant="outline" onClick={onModelsAdded}>
{t('wizard.provider.skipModelAdd')}
</Button>
</div>
</>
) : (
<div className="text-center py-4 text-muted-foreground">
<p>{t('wizard.provider.noModelsFound')}</p>
</div>
)}
{/* Divider */}
<div className="flex items-center gap-3 py-2">
<div className="flex-1 h-px bg-border" />
<span className="text-xs text-muted-foreground">
{t('wizard.provider.orDivider')}
</span>
<div className="flex-1 h-px bg-border" />
</div>
{/* Manual add */}
<div className="flex gap-2">
<Input
placeholder={t('wizard.provider.manualModelPlaceholder')}
value={manualModelName}
onChange={(e) => setManualModelName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleManualAdd()}
/>
<Button
onClick={handleManualAdd}
disabled={!manualModelName.trim() || manualAdding}
>
{manualAdding && (
<Loader2 className="w-4 h-4 mr-1.5 animate-spin" />
)}
{t('wizard.provider.addManual')}
</Button>
</div>
</>
)}
{/* Next button when models have been added */}
{addedModelNames.size > 0 && (
<div className="flex justify-end pt-2 border-t">
<Button onClick={onModelsAdded}>
{t('wizard.next')}
<ArrowRight className="w-4 h-4 ml-1.5" />
</Button>
</div>
)}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Step 2: Select & Configure AI Engine
// ---------------------------------------------------------------------------
function StepAIEngine({
runnerOptions,
step2Layer,
aiEngineMode,
onAiEngineModeSelect,
selectedRunner,
onSelectRunner,
isLocalAccount,
onSpaceAuth,
modelSource,
onModelSourceSelect,
providerCreated,
createdProviderUuid,
modelsAdded,
onProviderCreated,
onModelsAdded,
onResetModelSource,
savedProviderForm,
onSavedProviderFormChange,
savedSelectedModels,
onSavedSelectedModelsChange,
onGoToNextLayer,
runnerConfigItems,
runnerConfigValues,
onRunnerConfigChange,
}: {
runnerOptions: { name: string; label: { en_US: string; zh_Hans: string } }[];
step2Layer: number;
aiEngineMode: 'orchestrated' | 'llm' | null;
onAiEngineModeSelect: (mode: 'orchestrated' | 'llm') => void;
selectedRunner: string | null;
onSelectRunner: (name: string) => void;
isLocalAccount: boolean;
onSpaceAuth: () => void;
modelSource: 'space' | 'custom' | null;
onModelSourceSelect: (source: 'space' | 'custom') => void;
providerCreated: boolean;
createdProviderUuid: string | null;
modelsAdded: boolean;
onProviderCreated: (uuid?: string) => void;
onModelsAdded: () => void;
onResetModelSource: () => void;
savedProviderForm: { name?: string; requester?: string; base_url?: string; api_key?: string };
onSavedProviderFormChange: (v: { name?: string; requester?: string; base_url?: string; api_key?: string }) => void;
savedSelectedModels: Set<string>;
onSavedSelectedModelsChange: (v: Set<string>) => void;
onGoToNextLayer: () => void;
runnerConfigItems: IDynamicFormItemSchema[];
runnerConfigValues: Record<string, unknown>;
onRunnerConfigChange: (v: Record<string, unknown>) => void;
}) {
const { t } = useTranslation();
// Stable callback ref
const onRunnerConfigRef = useRef(onRunnerConfigChange);
onRunnerConfigRef.current = onRunnerConfigChange;
const stableRunnerConfigCb = useCallback(
(val: object) => onRunnerConfigRef.current(val as Record<string, unknown>),
[],
);
const runnerLabel = useMemo(() => {
const r = runnerOptions.find((o) => o.name === selectedRunner);
return r ? extractI18nObject(r.label) : (selectedRunner ?? '');
}, [runnerOptions, selectedRunner]);
// Orchestrated runners: everything except local-agent
const orchestratedRunners = runnerOptions.filter(
(o) => o.name !== 'local-agent',
);
// ---- Layer 0: Main choice ----
if (step2Layer === 0) {
return (
<div className="space-y-6 max-w-3xl mx-auto">
<div className="text-center">
<h2 className="text-xl font-semibold">
{t('wizard.aiEngine.title')}
</h2>
<p className="text-sm text-muted-foreground mt-1">
{t('wizard.aiEngine.description')}
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
{/* Orchestrated Agent Apps */}
<div
className="relative rounded-xl border-2 border-border cursor-pointer transition-all hover:shadow-lg hover:border-primary/50 hover:scale-[1.02]"
onClick={() => onAiEngineModeSelect('orchestrated')}
>
<div className="p-6 flex flex-col items-center gap-4 text-center h-full">
<div className="w-14 h-14 rounded-full bg-muted flex items-center justify-center">
<Wrench className="w-7 h-7 text-muted-foreground" />
</div>
<div>
<h3 className="text-lg font-semibold">
{t('wizard.aiEngine.orchestrated.title')}
</h3>
<p className="text-sm text-muted-foreground mt-1">
{t('wizard.aiEngine.orchestrated.description')}
</p>
</div>
<Button variant="outline" size="lg" className="w-full">
{t('wizard.aiEngine.orchestrated.action')}
</Button>
</div>
</div>
{/* Direct LLM */}
<div
className="relative rounded-xl border-2 border-border cursor-pointer transition-all hover:shadow-lg hover:border-primary/50 hover:scale-[1.02]"
onClick={() => onAiEngineModeSelect('llm')}
>
<div className="p-6 flex flex-col items-center gap-4 text-center h-full">
<div className="w-14 h-14 rounded-full bg-muted flex items-center justify-center">
<Rocket className="w-7 h-7 text-muted-foreground" />
</div>
<div>
<h3 className="text-lg font-semibold">
{t('wizard.aiEngine.llm.title')}
</h3>
<p className="text-sm text-muted-foreground mt-1">
{t('wizard.aiEngine.llm.description')}
</p>
</div>
<Button variant="outline" size="lg" className="w-full">
{t('wizard.aiEngine.llm.action')}
</Button>
</div>
</div>
</div>
</div>
);
}
// ---- Layer 1a: Orchestrated runners ----
if (step2Layer === 1 && aiEngineMode === 'orchestrated') {
return (
<div className="space-y-6 max-w-4xl mx-auto">
<div className="text-center">
<h2 className="text-xl font-semibold">
{t('wizard.aiEngine.orchestrated.selectTitle')}
</h2>
<p className="text-sm text-muted-foreground mt-1">
{t('wizard.aiEngine.orchestrated.selectDescription')}
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{orchestratedRunners.map((opt) => (
<Card
key={opt.name}
className="cursor-pointer transition-all hover:shadow-md hover:border-primary/50"
onClick={() => onSelectRunner(opt.name)}
>
<CardHeader className="flex flex-row items-center gap-3">
<div className="min-w-0 flex-1">
<CardTitle className="text-base">
{extractI18nObject(opt.label)}
</CardTitle>
<CardDescription className="mt-1 text-xs font-mono text-muted-foreground">
{opt.name}
</CardDescription>
</div>
</CardHeader>
</Card>
))}
</div>
</div>
);
}
// ---- Layer 1b: LLM model source selection ----
if (step2Layer === 1 && aiEngineMode === 'llm') {
return (
<div className="space-y-6 max-w-3xl mx-auto">
<div className="text-center">
<h2 className="text-xl font-semibold">
{t('wizard.modelSource.title')}
</h2>
<p className="text-sm text-muted-foreground mt-1">
{t('wizard.modelSource.description')}
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
{/* LangBot Service */}
<div
className="relative rounded-xl border-2 border-border cursor-pointer transition-all hover:shadow-lg hover:border-primary/50 hover:scale-[1.02]"
onClick={() => onModelSourceSelect('space')}
>
<div className="p-6 flex flex-col items-center gap-4 text-center h-full">
<div className="w-14 h-14 rounded-full bg-muted flex items-center justify-center">
<Rocket className="w-7 h-7 text-muted-foreground" />
</div>
<div>
<h3 className="text-lg font-semibold">
{t('wizard.modelSource.space.title')}
</h3>
<p className="text-sm text-muted-foreground mt-1">
{t('wizard.modelSource.space.description')}
</p>
</div>
<Button variant="outline" size="lg" className="w-full">
{t('wizard.modelSource.space.action')}
</Button>
</div>
</div>
{/* Custom provider */}
<div
className="relative rounded-xl border-2 border-border cursor-pointer transition-all hover:shadow-lg hover:border-primary/50 hover:scale-[1.02]"
onClick={() => onModelSourceSelect('custom')}
>
<div className="p-6 flex flex-col items-center gap-4 text-center h-full">
<div className="w-14 h-14 rounded-full bg-muted flex items-center justify-center">
<Wrench className="w-7 h-7 text-muted-foreground" />
</div>
<div>
<h3 className="text-lg font-semibold">
{t('wizard.modelSource.custom.title')}
</h3>
<p className="text-sm text-muted-foreground mt-1">
{t('wizard.modelSource.custom.description')}
</p>
</div>
<Button variant="outline" size="lg" className="w-full">
{t('wizard.modelSource.custom.action')}
</Button>
</div>
</div>
</div>
</div>
);
}
// ---- Layer 2: Custom provider creation form ----
if (step2Layer === 2) {
return (
<div className="max-w-2xl mx-auto w-full animate-in fade-in slide-in-from-bottom-2 duration-300">
<div className="text-center mb-6">
<h2 className="text-xl font-semibold">
{t('wizard.provider.title')}
</h2>
<p className="text-sm text-muted-foreground mt-1">
{t('wizard.provider.description')}
</p>
</div>
<div className="border rounded-lg p-6 bg-card">
<ProviderForm
initialValues={savedProviderForm}
onValuesChange={onSavedProviderFormChange}
submitButtonText={t('wizard.provider.saveAndNext')}
onFormSubmit={(uuid) => {
onProviderCreated(uuid);
}}
onFormCancel={onResetModelSource}
/>
</div>
</div>
);
}
// ---- Layer 2.5: Model scan & add after provider creation ----
if (step2Layer === 3) {
return (
<WizardModelScan
providerUuid={createdProviderUuid}
initialSelectedModels={savedSelectedModels}
onSelectedModelsChange={onSavedSelectedModelsChange}
onModelsAdded={onModelsAdded}
/>
);
}
// ---- Layer 3: Runner configuration form ----
return (
<div className="flex flex-col lg:flex-1 lg:min-h-0 max-w-6xl mx-auto w-full">
<div className="text-center shrink-0 mb-4">
<h2 className="text-xl font-semibold">{t('wizard.aiEngine.title')}</h2>
<p className="text-sm text-muted-foreground mt-1">
{t('wizard.aiEngine.description')}
</p>
</div>
<div className="flex flex-col lg:flex-row lg:justify-center gap-6 lg:flex-1 lg:min-h-0 animate-in fade-in slide-in-from-bottom-2 duration-300">
{/* Left: runner list (only for orchestrated mode) */}
{aiEngineMode === 'orchestrated' && (
<div className="w-full lg:w-[280px] shrink-0 lg:overflow-y-auto lg:pr-3">
<div className="space-y-3 p-1">
{orchestratedRunners.map((opt) => {
const isSelected = selectedRunner === opt.name;
return (
<Card
key={opt.name}
className={cn(
'cursor-pointer transition-all',
isSelected
? 'ring-2 ring-primary shadow-md'
: 'opacity-50 hover:opacity-80 hover:border-primary/50',
)}
onClick={() => onSelectRunner(opt.name)}
>
<CardHeader className="flex flex-row items-center gap-3 py-3 px-4">
<div className="min-w-0 flex-1">
<CardTitle
className={cn(
'text-sm',
!isSelected && 'text-muted-foreground',
)}
>
{extractI18nObject(opt.label)}
</CardTitle>
<CardDescription className="text-xs font-mono text-muted-foreground">
{opt.name}
</CardDescription>
</div>
{isSelected && (
<div className="shrink-0">
<div className="w-5 h-5 rounded-full bg-primary flex items-center justify-center">
<Check className="w-3 h-3 text-primary-foreground" />
</div>
</div>
)}
</CardHeader>
</Card>
);
})}
</div>
</div>
)}
{/* Right: runner configuration */}
<div
className={cn(
'shrink-0 lg:overflow-y-auto lg:pr-3 animate-in fade-in slide-in-from-right-2 duration-300',
aiEngineMode === 'orchestrated'
? 'w-full lg:w-[560px]'
: 'w-full max-w-2xl mx-auto',
)}
>
<div className="p-1">
{runnerConfigItems.length > 0 && (
<Card>
<CardHeader>
<CardTitle>
{t('wizard.config.aiConfig', { engine: runnerLabel })}
</CardTitle>
</CardHeader>
<CardContent>
<DynamicFormComponent
key={selectedRunner ?? 'llm'}
itemConfigList={runnerConfigItems}
initialValues={runnerConfigValues as Record<string, object>}
onSubmit={stableRunnerConfigCb}
systemContext={{ is_wizard: true }}
/>
</CardContent>
</Card>
)}
</div>
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// 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_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 (
<div className="relative flex flex-col items-center justify-center h-full min-h-[400px]">
{/* Confetti particles */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
{particles.map((p) => (
<div
key={p.id}
className={cn('absolute rounded-full opacity-0', p.color)}
style={{
left: `${p.left}%`,
width: p.size,
height: p.size,
animation: `wizardConfetti ${p.duration}s ease-out ${p.delay}s forwards`,
}}
/>
))}
</div>
<PartyPopper className="w-16 h-16 text-primary mb-4" />
<h2 className="text-2xl font-bold">{t('wizard.done.title')}</h2>
<p className="text-muted-foreground mt-2 text-center max-w-md">
{t('wizard.done.description')}
</p>
<Button className="mt-6" onClick={handleBack} disabled={isCompleting}>
{isCompleting && <Loader2 className="w-4 h-4 mr-1.5 animate-spin" />}
{t('wizard.done.backToWorkbench')}
</Button>
<style>{`
@keyframes wizardConfetti {
0% {
transform: translateY(100vh) rotate(0deg);
opacity: 1;
}
100% {
transform: translateY(-20vh) rotate(720deg);
opacity: 0;
}
}
`}</style>
</div>
);
}