feat(wizard): start bot setup from scenarios

This commit is contained in:
huanghuoguoguo
2026-07-11 11:45:39 +08:00
parent c3aa4cfc97
commit 0c0f5d8061
10 changed files with 963 additions and 208 deletions
+1
View File
@@ -445,6 +445,7 @@ export interface SystemLimitation {
export interface WizardProgress {
step: number;
selected_scenario?: string | null;
selected_adapter: string | null;
created_bot_uuid: string | null;
bot_saved: boolean;
+1
View File
@@ -1072,6 +1072,7 @@ export class BackendClient extends BaseHttpClient {
public saveWizardProgress(progress: {
step: number;
selected_scenario?: string | null;
selected_adapter: string | null;
created_bot_uuid: string | null;
bot_saved: boolean;
+442 -191
View File
@@ -12,6 +12,11 @@ import {
Sparkles,
PartyPopper,
Loader2,
MessageSquare,
ShieldCheck,
UserCheck,
UserMinus,
UserPlus,
X,
ExternalLink,
} from 'lucide-react';
@@ -75,6 +80,73 @@ import {
const TOTAL_STEPS = 4;
type WizardScenarioId =
| 'message_reply'
| 'welcome_members'
| 'handle_departures'
| 'review_friend_requests'
| 'handle_moderation';
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: 'review_friend_requests' as const,
eventType: 'friend.request_received',
processorKind: 'agent' as const,
labelKey: 'wizard.scenario.reviewFriendRequests',
descriptionKey: 'wizard.scenario.reviewFriendRequestsDescription',
icon: UserCheck,
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)
// ---------------------------------------------------------------------------
@@ -85,6 +157,8 @@ export default function WizardPage() {
// ---- Wizard state ----
const [currentStep, setCurrentStep] = useState(0);
const [selectedScenario, setSelectedScenario] =
useState<WizardScenarioId | null>(null);
const [selectedAdapter, setSelectedAdapter] = useState<string | null>(null);
const [selectedRunner, setSelectedRunner] = useState<string | null>(null);
const [botName, setBotName] = useState('');
@@ -113,16 +187,36 @@ export default function WizardPage() {
(overrides: Partial<WizardProgress> = {}) => {
const progress: WizardProgress = {
step: overrides.step ?? currentStep,
selected_adapter: overrides.selected_adapter ?? selectedAdapter,
created_bot_uuid: overrides.created_bot_uuid ?? createdBotUuid,
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 ?? selectedRunner,
selected_runner:
overrides.selected_runner !== undefined
? overrides.selected_runner
: selectedRunner,
};
httpClient.saveWizardProgress(progress).catch((err) => {
console.error('Failed to save wizard progress', err);
});
},
[currentStep, selectedAdapter, createdBotUuid, botSaved, selectedRunner],
[
currentStep,
selectedScenario,
selectedAdapter,
createdBotUuid,
botSaved,
selectedRunner,
],
);
// ---- Fetch remote data & restore progress ----
@@ -151,6 +245,10 @@ export default function WizardPage() {
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);
@@ -167,13 +265,14 @@ export default function WizardPage() {
(runtimeValues?.extra_webhook_full_url as string) || '',
);
// Restore step (cap at step 2 — step 3 means done)
setCurrentStep(Math.min(progress.step, 2));
// 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,
@@ -213,6 +312,11 @@ export default function WizardPage() {
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);
@@ -278,7 +382,7 @@ export default function WizardPage() {
const canProceed = useCallback((): boolean => {
switch (currentStep) {
case 0:
return selectedAdapter !== null;
return selectedScenario !== null && selectedAdapter !== null;
case 1:
return createdBotUuid !== null && botSaved;
case 2:
@@ -286,7 +390,32 @@ export default function WizardPage() {
default:
return false;
}
}, [currentStep, selectedAdapter, createdBotUuid, botSaved, selectedRunner]);
}, [
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()) {
@@ -353,6 +482,7 @@ export default function WizardPage() {
// Persist progress
saveProgress({
step: 1,
selected_scenario: selectedScenario,
selected_adapter: selectedAdapter,
created_bot_uuid: resp.uuid,
bot_saved: false,
@@ -366,7 +496,7 @@ export default function WizardPage() {
} finally {
setIsCreatingBot(false);
}
}, [selectedAdapter, adapters, t, saveProgress]);
}, [selectedScenario, selectedAdapter, adapters, t, saveProgress]);
// ---- Save Bot Config & Enable (Step 1) ----
// Updates the bot's adapter config and enables it.
@@ -422,62 +552,75 @@ export default function WizardPage() {
// ---- Create Pipeline & Link (Step 2 finish) ----
const handleFinish = useCallback(async () => {
if (!selectedRunner || !createdBotUuid) return;
if (!selectedRunner || !createdBotUuid || !selectedScenarioDefinition)
return;
setIsSubmitting(true);
let processorUuid = '';
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);
let targetType: 'agent' | '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 as unknown as Record<
string,
unknown
>;
const fullAiConfig =
fullConfig.ai && typeof fullConfig.ai === 'object'
? (fullConfig.ai as Record<string, unknown>)
: {};
const existingRunner =
fullAiConfig.runner && typeof fullAiConfig.runner === 'object'
? (fullAiConfig.runner as Record<string, unknown>)
: {};
const existingRunnerConfigs =
fullAiConfig.runner_config &&
typeof fullAiConfig.runner_config === 'object'
? (fullAiConfig.runner_config as Record<string, unknown>)
: {};
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<string, unknown>)
: {};
const existingRunner =
fullAiConfig.runner && typeof fullAiConfig.runner === 'object'
? (fullAiConfig.runner as Record<string, unknown>)
: {};
const existingRunnerConfigs =
fullAiConfig.runner_config &&
typeof fullAiConfig.runner_config === 'object'
? (fullAiConfig.runner_config as Record<string, unknown>)
: {};
const mergedConfig = {
...fullConfig,
ai: {
...fullAiConfig,
runner: {
...existingRunner,
id: selectedRunner,
await httpClient.updatePipeline(pipelineResp.uuid, {
name: `${botName} Pipeline`,
description: botDescription || '',
config: {
...fullConfig,
ai: {
...fullAiConfig,
runner: { ...existingRunner, id: selectedRunner },
runner_config: {
...existingRunnerConfigs,
[selectedRunner]: runnerConfig,
},
},
},
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';
}
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, {
@@ -488,9 +631,9 @@ export default function WizardPage() {
enable: existingBot.enable,
event_bindings: [
{
event_pattern: 'message.received',
target_type: 'pipeline',
target_uuid: pipelineResp.uuid,
event_pattern: selectedScenarioDefinition.eventType,
target_type: targetType,
target_uuid: processorUuid,
filters: [],
priority: 0,
enabled: true,
@@ -500,7 +643,15 @@ export default function WizardPage() {
});
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}` : ''),
@@ -511,10 +662,12 @@ export default function WizardPage() {
}, [
selectedRunner,
createdBotUuid,
selectedScenarioDefinition,
botName,
botDescription,
runnerConfig,
t,
saveProgress,
]);
// ---- Space auth redirect ----
@@ -549,6 +702,7 @@ export default function WizardPage() {
// 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,
@@ -576,7 +730,7 @@ export default function WizardPage() {
}
const stepLabels = [
t('wizard.step.platform'),
t('wizard.step.scenarioChannel'),
t('wizard.step.botConfig'),
t('wizard.step.aiEngine'),
t('wizard.step.done'),
@@ -665,6 +819,8 @@ export default function WizardPage() {
{currentStep === 0 && (
<StepPlatform
adapters={adapters}
selectedScenario={selectedScenario}
onSelectScenario={handleSelectScenario}
selected={selectedAdapter}
onSelect={setSelectedAdapter}
/>
@@ -777,10 +933,14 @@ export default function WizardPage() {
function StepPlatform({
adapters,
selectedScenario,
onSelectScenario,
selected,
onSelect,
}: {
adapters: Adapter[];
selectedScenario: WizardScenarioId | null;
onSelectScenario: (scenarioId: WizardScenarioId) => void;
selected: string | null;
onSelect: (name: string) => void;
}) {
@@ -788,12 +948,26 @@ function StepPlatform({
const [showLegacy, setShowLegacy] = useState(false);
const activeAdapters = useMemo(
() => adapters.filter((a) => !a.spec.legacy),
[adapters],
() =>
selectedScenario
? adapters.filter(
(adapter) =>
!adapter.spec.legacy &&
adapterSupportsScenario(adapter, selectedScenario),
)
: [],
[adapters, selectedScenario],
);
const legacyAdapters = useMemo(
() => adapters.filter((a) => a.spec.legacy),
[adapters],
() =>
selectedScenario
? adapters.filter(
(adapter) =>
adapter.spec.legacy &&
adapterSupportsScenario(adapter, selectedScenario),
)
: [],
[adapters, selectedScenario],
);
const groupedAdapters = useMemo(() => {
@@ -805,139 +979,215 @@ function StepPlatform({
}, [activeAdapters]);
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}
<div className="mx-auto max-w-5xl space-y-8">
<section className="space-y-3">
<div>
<h2 className="text-xl font-semibold">
{t('wizard.scenario.title')}
</h2>
<p className="mt-1 text-sm text-muted-foreground">
{t('wizard.scenario.description')}
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{WIZARD_SCENARIOS.map((scenario) => {
const Icon = scenario.icon;
const isSelected = selectedScenario === scenario.id;
return (
<button
key={scenario.id}
type="button"
onClick={() => onSelectScenario(scenario.id)}
className={cn(
'cursor-pointer transition-all hover:shadow-md',
selected === adapter.name
? 'ring-2 ring-primary shadow-md'
: 'hover:border-primary/50',
'rounded-md border bg-card p-3 text-left transition-colors',
isSelected
? 'border-primary ring-2 ring-primary/20'
: 'hover:border-primary/60',
)}
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>
))}
{legacyAdapters.length > 0 && (
<div className="border-t pt-4 space-y-3">
<button
type="button"
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground"
onClick={() => setShowLegacy((v) => !v)}
>
{showLegacy ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
{t('bots.legacyAdapters')}
<span className="rounded bg-muted px-1.5 py-0.5 text-xs">
{legacyAdapters.length}
</span>
</button>
{showLegacy && (
<>
<p className="text-xs text-muted-foreground">
{t('bots.legacyAdaptersHint')}
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 opacity-60">
{legacyAdapters.map((adapter) => (
<Card
key={adapter.name}
<div className="flex items-start gap-3">
<span
className={cn(
'cursor-pointer transition-all hover:shadow-md',
selected === adapter.name
? 'ring-2 ring-primary shadow-md'
: 'hover:border-primary/50',
'flex h-8 w-8 shrink-0 items-center justify-center rounded-md',
isSelected
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground',
)}
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 grayscale"
/>
<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>
</Card>
))}
</div>
</>
)}
<Icon className="h-4 w-4" />
</span>
<span className="min-w-0 flex-1">
<span className="flex items-center justify-between gap-2">
<span className="font-medium">
{t(scenario.labelKey)}
</span>
<span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
{t(
scenario.processorKind === 'pipeline'
? 'wizard.scenario.pipelineBadge'
: 'wizard.scenario.agentBadge',
)}
</span>
</span>
<span className="mt-1 block text-sm leading-snug text-muted-foreground">
{t(scenario.descriptionKey)}
</span>
</span>
</div>
</button>
);
})}
</div>
)}
</section>
<section className="space-y-5 border-t pt-6">
<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>
{!selectedScenario && (
<div className="rounded-md border border-dashed p-5 text-center text-sm text-muted-foreground">
{t('wizard.platform.chooseScenarioFirst')}
</div>
)}
{selectedScenario &&
activeAdapters.length === 0 &&
legacyAdapters.length === 0 && (
<div className="rounded-md border border-dashed p-5 text-center text-sm text-muted-foreground">
{t('wizard.platform.noCompatiblePlatforms')}
</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>
))}
{legacyAdapters.length > 0 && (
<div className="border-t pt-4 space-y-3">
<button
type="button"
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground"
onClick={() => setShowLegacy((v) => !v)}
>
{showLegacy ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
{t('bots.legacyAdapters')}
<span className="rounded bg-muted px-1.5 py-0.5 text-xs">
{legacyAdapters.length}
</span>
</button>
{showLegacy && (
<>
<p className="text-xs text-muted-foreground">
{t('bots.legacyAdaptersHint')}
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 opacity-60">
{legacyAdapters.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 grayscale"
/>
<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>
</Card>
))}
</div>
</>
)}
</div>
)}
</section>
</div>
);
}
@@ -1316,6 +1566,7 @@ function StepDone() {
// 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,