Feat/space cta optimization (#2089)

* feat(wizard): persist wizard progress to backend for session resumption

Store wizard step, selected adapter, created bot UUID, and runner
selection in the metadata table. On revisit, the wizard restores
progress and verifies the bot still exists. Progress is cleared
automatically when the wizard is completed or skipped.

* feat(dynamic-form): optimize LLM model selection with space login CTA and improve localization strings

* feat(web): add LangBot Cloud CTA for webhook URL fields in community edition

Show a subtle hint below webhook URL fields prompting users about
LangBot Cloud's public endpoint, only visible in community edition.
Covers all 8 webhook-based adapters with i18n support (4 locales).
This commit is contained in:
Junyan Chin
2026-03-28 17:24:39 +08:00
committed by GitHub
parent 4c904c2375
commit 71e44f0e54
11 changed files with 623 additions and 86 deletions

View File

@@ -17,7 +17,8 @@ import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Copy, Check } from 'lucide-react';
import { Copy, Check, Globe } from 'lucide-react';
import { systemInfo } from '@/app/infra/http';
/**
* Resolve the value referenced by a `show_if.field` string.
@@ -61,6 +62,7 @@ function WebhookUrlField({
}) {
const [copied, setCopied] = useState(false);
const [extraCopied, setExtraCopied] = useState(false);
const { t } = useTranslation();
const handleCopy = (text: string, setter: (v: boolean) => void) => {
if (navigator.clipboard && navigator.clipboard.writeText) {
@@ -122,6 +124,22 @@ function WebhookUrlField({
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
{systemInfo.edition === 'community' && (
<div className="flex items-start gap-2.5 rounded-md border border-border/60 bg-muted/40 px-3 py-2.5 mt-1 max-w-2xl">
<Globe className="h-4 w-4 text-muted-foreground shrink-0 mt-0.5" />
<p className="text-sm text-muted-foreground leading-relaxed">
{t('bots.webhookSaasHint')}{' '}
<a
href="https://space.langbot.app/cloud?utm_source=local_webui&utm_medium=webhook_alert&utm_campaign=saas_conversion"
target="_blank"
rel="noopener noreferrer"
className="text-primary underline-offset-4 hover:underline font-medium"
>
{t('bots.webhookSaasLink')}
</a>
</p>
</div>
)}
</FormItem>
);
}

View File

@@ -37,7 +37,22 @@ import {
DialogFooter,
} from '@/components/ui/dialog';
import { Checkbox } from '@/components/ui/checkbox';
import { Plus, X, Eye, Wrench, Trash2 } from 'lucide-react';
import {
Plus,
X,
Eye,
Wrench,
Trash2,
Sparkles,
Info,
Settings,
} from 'lucide-react';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import ModelsDialog from '@/app/home/components/models-dialog/ModelsDialog';
export default function DynamicFormItemComponent({
config,
@@ -57,6 +72,25 @@ export default function DynamicFormItemComponent({
const [kbDialogOpen, setKbDialogOpen] = useState(false);
const [tempSelectedKBIds, setTempSelectedKBIds] = useState<string[]>([]);
const { t } = useTranslation();
const [modelsDialogOpen, setModelsDialogOpen] = useState(false);
const fetchLlmModels = () => {
httpClient
.getProviderLLMModels()
.then((resp) => {
setLlmModels(resp.models);
})
.catch((err) => {
toast.error(t('models.getModelListError') + err.msg);
});
};
const handleModelsDialogChange = (open: boolean) => {
setModelsDialogOpen(open);
if (!open) {
fetchLlmModels();
}
};
const handleFileUpload = async (file: File): Promise<IFileConfig | null> => {
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
@@ -88,26 +122,35 @@ export default function DynamicFormItemComponent({
}
};
// Whether to show Space login CTA in model selectors
const showSpaceLoginCTA =
!systemInfo.disable_models_service && userInfo?.account_type !== 'space';
const handleSpaceLogin = () => {
try {
const token = localStorage.getItem('token');
if (!token) {
toast.error(t('common.error'));
return;
}
const currentOrigin = window.location.origin;
const redirectUri = `${currentOrigin}/auth/space/callback?mode=bind`;
httpClient
.getSpaceAuthorizeUrl(redirectUri, token)
.then((response) => {
window.location.href = response.authorize_url;
})
.catch(() => {
toast.error(t('common.spaceLoginFailed'));
});
} catch {
toast.error(t('common.spaceLoginFailed'));
}
};
useEffect(() => {
if (config.type === DynamicFormItemType.LLM_MODEL_SELECTOR) {
httpClient
.getProviderLLMModels()
.then((resp) => {
let models = resp.models;
// Filter out space-chat-completions models when not logged in with space account or when models service is disabled
if (
systemInfo.disable_models_service ||
userInfo?.account_type !== 'space'
) {
models = models.filter(
(m) => m.provider?.requester !== 'space-chat-completions',
);
}
setLlmModels(models);
})
.catch((err) => {
toast.error(t('models.getModelListError') + err.msg);
});
fetchLlmModels();
}
}, [config.type]);
@@ -126,23 +169,7 @@ export default function DynamicFormItemComponent({
useEffect(() => {
if (config.type === DynamicFormItemType.MODEL_FALLBACK_SELECTOR) {
httpClient
.getProviderLLMModels()
.then((resp) => {
let models = resp.models;
if (
systemInfo.disable_models_service ||
userInfo?.account_type !== 'space'
) {
models = models.filter(
(m) => m.provider?.requester !== 'space-chat-completions',
);
}
setLlmModels(models);
})
.catch((err) => {
toast.error('Failed to get LLM model list: ' + err.msg);
});
fetchLlmModels();
}
}, [config.type]);
@@ -259,8 +286,16 @@ export default function DynamicFormItemComponent({
);
case DynamicFormItemType.LLM_MODEL_SELECTOR:
// Group models by provider
const groupedModels = llmModels.reduce(
// Separate space models from regular models
const spaceModels = llmModels.filter(
(m) => m.provider?.requester === 'space-chat-completions',
);
const regularModels = llmModels.filter(
(m) => m.provider?.requester !== 'space-chat-completions',
);
// Group regular models by provider
const groupedModels = regularModels.reduce(
(acc, model) => {
const providerName =
model.provider?.name || model.provider?.requester || 'Unknown';
@@ -271,33 +306,180 @@ export default function DynamicFormItemComponent({
{} as Record<string, LLMModel[]>,
);
// Group space models by provider (for logged-in users)
const groupedSpaceModels = spaceModels.reduce(
(acc, model) => {
const providerName =
model.provider?.name || model.provider?.requester || 'Unknown';
if (!acc[providerName]) acc[providerName] = [];
acc[providerName].push(model);
return acc;
},
{} as Record<string, LLMModel[]>,
);
// Hardcoded preview model names for CTA when no space models are synced
const previewModelNames = [
'gpt-4o',
'claude-sonnet-4-20250514',
'deepseek-chat',
'gemini-2.5-flash',
'qwen-plus',
];
return (
<div className="max-w-md">
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue placeholder={t('models.selectModel')} />
</SelectTrigger>
<SelectContent>
{Object.entries(groupedModels).map(([providerName, models]) => (
<SelectGroup key={providerName}>
<SelectLabel>{providerName}</SelectLabel>
{models.map((model) => (
<SelectItem key={model.uuid} value={model.uuid}>
<span className="inline-flex items-center gap-1">
{model.name}
{model.abilities?.includes('vision') && (
<Eye className="h-3 w-3 text-muted-foreground" />
)}
{model.abilities?.includes('func_call') && (
<Wrench className="h-3 w-3 text-muted-foreground" />
)}
<div className="max-w-md flex items-center gap-1.5">
<div className="flex-1">
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue placeholder={t('models.selectModel')} />
</SelectTrigger>
<SelectContent>
{Object.entries(groupedModels).map(([providerName, models]) => (
<SelectGroup key={providerName}>
<SelectLabel>{providerName}</SelectLabel>
{models.map((model) => (
<SelectItem key={model.uuid} value={model.uuid}>
<span className="inline-flex items-center gap-1">
{model.name}
{model.abilities?.includes('vision') && (
<Eye className="h-3 w-3 text-muted-foreground" />
)}
{model.abilities?.includes('func_call') && (
<Wrench className="h-3 w-3 text-muted-foreground" />
)}
</span>
</SelectItem>
))}
</SelectGroup>
))}
{/* Space models section */}
{showSpaceLoginCTA ? (
<SelectGroup>
<SelectLabel>
<span className="inline-flex items-center gap-1.5">
<Sparkles className="h-3.5 w-3.5 text-purple-500" />
{t('models.langbotModels')}
<Tooltip>
<TooltipTrigger
asChild
onMouseDown={(e) => e.preventDefault()}
>
<Info className="h-3 w-3 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[240px]">
{t('models.spaceTrialTooltip')}
</TooltipContent>
</Tooltip>
</span>
</SelectItem>
))}
</SelectGroup>
))}
</SelectContent>
</Select>
</SelectLabel>
<div
className="relative"
onMouseDown={(e) => e.preventDefault()}
>
{/* Preview models (first 3 visible, rest blurred) */}
{(spaceModels.length > 0
? spaceModels.map((m) => m.name)
: previewModelNames
)
.slice(0, 3)
.map((name) => (
<div
key={name}
className="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm text-muted-foreground/60"
>
{name}
</div>
))}
{/* Blurred remaining models with login overlay */}
<div className="relative">
<div
className="select-none overflow-hidden"
style={{ maxHeight: '3rem' }}
>
{(spaceModels.length > 0
? spaceModels.map((m) => m.name)
: previewModelNames
)
.slice(3)
.map((name) => (
<div
key={name}
className="flex w-full items-center py-1.5 pl-8 pr-2 text-sm text-muted-foreground/40 blur-[2px]"
>
{name}
</div>
))}
</div>
{/* Login overlay */}
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-b from-transparent to-background/80">
<Button
type="button"
variant="outline"
size="sm"
className="h-7 text-xs px-3 gap-1.5 shadow-sm"
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
handleSpaceLogin();
}}
>
<Sparkles className="h-3 w-3" />
{t('models.unlockModels')}
</Button>
</div>
</div>
</div>
</SelectGroup>
) : !systemInfo.disable_models_service ? (
// User is logged into Space — show space models normally
Object.entries(groupedSpaceModels).map(
([providerName, models]) => (
<SelectGroup key={providerName}>
<SelectLabel>
<span className="inline-flex items-center gap-1.5">
<Sparkles className="h-3.5 w-3.5 text-purple-500" />
{providerName}
</span>
</SelectLabel>
{models.map((model) => (
<SelectItem key={model.uuid} value={model.uuid}>
<span className="inline-flex items-center gap-1">
{model.name}
{model.abilities?.includes('vision') && (
<Eye className="h-3 w-3 text-muted-foreground" />
)}
{model.abilities?.includes('func_call') && (
<Wrench className="h-3 w-3 text-muted-foreground" />
)}
</span>
</SelectItem>
))}
</SelectGroup>
),
)
) : null}
</SelectContent>
</Select>
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 shrink-0"
onClick={() => setModelsDialogOpen(true)}
>
<Settings className="h-4 w-4 text-muted-foreground" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">{t('models.title')}</TooltipContent>
</Tooltip>
<ModelsDialog
open={modelsDialogOpen}
onOpenChange={handleModelsDialogChange}
/>
</div>
);
@@ -338,8 +520,16 @@ export default function DynamicFormItemComponent({
);
case DynamicFormItemType.MODEL_FALLBACK_SELECTOR: {
// Group models by provider
const groupedModelsForFallback = llmModels.reduce(
// Separate space models from regular models
const fbSpaceModels = llmModels.filter(
(m) => m.provider?.requester === 'space-chat-completions',
);
const fbRegularModels = llmModels.filter(
(m) => m.provider?.requester !== 'space-chat-completions',
);
// Group regular models by provider
const groupedModelsForFallback = fbRegularModels.reduce(
(acc, model) => {
const providerName =
model.provider?.name || model.provider?.requester || 'Unknown';
@@ -350,6 +540,27 @@ export default function DynamicFormItemComponent({
{} as Record<string, LLMModel[]>,
);
// Group space models by provider (for logged-in users)
const fbGroupedSpaceModels = fbSpaceModels.reduce(
(acc, model) => {
const providerName =
model.provider?.name || model.provider?.requester || 'Unknown';
if (!acc[providerName]) acc[providerName] = [];
acc[providerName].push(model);
return acc;
},
{} as Record<string, LLMModel[]>,
);
// Hardcoded preview model names for CTA
const fbPreviewModelNames = [
'gpt-4o',
'claude-sonnet-4-20250514',
'deepseek-chat',
'gemini-2.5-flash',
'qwen-plus',
];
const rawModelValue = field.value;
const modelValue: { primary: string; fallbacks: string[] } =
rawModelValue != null &&
@@ -406,6 +617,112 @@ export default function DynamicFormItemComponent({
</SelectGroup>
),
)}
{/* Space models section */}
{showSpaceLoginCTA ? (
<SelectGroup>
<SelectLabel>
<span className="inline-flex items-center gap-1.5">
<Sparkles className="h-3.5 w-3.5 text-purple-500" />
{t('models.langbotModels')}
<Tooltip>
<TooltipTrigger
asChild
onMouseDown={(e) => e.preventDefault()}
>
<Info className="h-3 w-3 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[240px]">
{t('models.spaceTrialTooltip')}
</TooltipContent>
</Tooltip>
</span>
</SelectLabel>
<div
className="relative"
onMouseDown={(e) => e.preventDefault()}
>
{/* Preview models (first 3 visible, rest blurred) */}
{(fbSpaceModels.length > 0
? fbSpaceModels.map((m) => m.name)
: fbPreviewModelNames
)
.slice(0, 3)
.map((name) => (
<div
key={name}
className="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm text-muted-foreground/60"
>
{name}
</div>
))}
{/* Blurred remaining models with login overlay */}
<div className="relative">
<div
className="select-none overflow-hidden"
style={{ maxHeight: '3rem' }}
>
{(fbSpaceModels.length > 0
? fbSpaceModels.map((m) => m.name)
: fbPreviewModelNames
)
.slice(3)
.map((name) => (
<div
key={name}
className="flex w-full items-center py-1.5 pl-8 pr-2 text-sm text-muted-foreground/40 blur-[2px]"
>
{name}
</div>
))}
</div>
{/* Login overlay */}
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-b from-transparent to-background/80">
<Button
type="button"
variant="outline"
size="sm"
className="h-7 text-xs px-3 gap-1.5 shadow-sm"
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
handleSpaceLogin();
}}
>
<Sparkles className="h-3 w-3" />
{t('models.unlockModels')}
</Button>
</div>
</div>
</div>
</SelectGroup>
) : !systemInfo.disable_models_service ? (
// User is logged into Space — show space models normally
Object.entries(fbGroupedSpaceModels).map(
([providerName, models]) => (
<SelectGroup key={providerName}>
<SelectLabel>
<span className="inline-flex items-center gap-1.5">
<Sparkles className="h-3.5 w-3.5 text-purple-500" />
{providerName}
</span>
</SelectLabel>
{models.map((model) => (
<SelectItem key={model.uuid} value={model.uuid}>
<span className="inline-flex items-center gap-1">
{model.name}
{model.abilities?.includes('vision') && (
<Eye className="h-3 w-3 text-muted-foreground" />
)}
{model.abilities?.includes('func_call') && (
<Wrench className="h-3 w-3 text-muted-foreground" />
)}
</span>
</SelectItem>
))}
</SelectGroup>
),
)
) : null}
</SelectContent>
</Select>
);
@@ -448,11 +765,35 @@ export default function DynamicFormItemComponent({
<p className="text-xs text-muted-foreground mb-1">
{t('models.fallback.primary')}
</p>
{renderModelSelect(
modelValue.primary,
(val) => updateValue({ primary: val }),
t('models.selectModel'),
)}
<div className="flex items-center gap-1.5">
<div className="flex-1">
{renderModelSelect(
modelValue.primary,
(val) => updateValue({ primary: val }),
t('models.selectModel'),
)}
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 shrink-0"
onClick={() => setModelsDialogOpen(true)}
>
<Settings className="h-4 w-4 text-muted-foreground" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">
{t('models.title')}
</TooltipContent>
</Tooltip>
<ModelsDialog
open={modelsDialogOpen}
onOpenChange={handleModelsDialogChange}
/>
</div>
</div>
{/* Fallback models */}

View File

@@ -251,6 +251,14 @@ export interface SystemLimitation {
max_extensions: number;
}
export interface WizardProgress {
step: number;
selected_adapter: string | null;
created_bot_uuid: string | null;
bot_saved: boolean;
selected_runner: string | null;
}
export interface ApiRespSystemInfo {
debug: boolean;
version: string;
@@ -261,6 +269,7 @@ export interface ApiRespSystemInfo {
disable_models_service: boolean;
limitation: SystemLimitation;
wizard_status: string; // 'none' | 'skipped' | 'completed'
wizard_progress: WizardProgress | null;
}
export interface RagMigrationStatusResp {

View File

@@ -705,6 +705,16 @@ export class BackendClient extends BaseHttpClient {
return this.post('/api/v1/system/wizard/completed', { status });
}
public saveWizardProgress(progress: {
step: number;
selected_adapter: string | null;
created_bot_uuid: string | null;
bot_saved: boolean;
selected_runner: string | null;
}): Promise<void> {
return this.put('/api/v1/system/wizard/progress', progress);
}
public getAsyncTasks(): Promise<ApiRespAsyncTasks> {
return this.get('/api/v1/system/tasks');
}

View File

@@ -17,6 +17,7 @@ export const systemInfo: ApiRespSystemInfo = {
max_extensions: -1,
},
wizard_status: 'none',
wizard_progress: null,
};
// 用户信息

View File

@@ -22,7 +22,12 @@ import {
initializeUserInfo,
initializeSystemInfo,
} from '@/app/infra/http';
import { Adapter, Bot, Pipeline } from '@/app/infra/entities/api';
import {
Adapter,
Bot,
Pipeline,
WizardProgress,
} from '@/app/infra/entities/api';
import { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic';
import {
PipelineConfigTab,
@@ -97,7 +102,24 @@ export default function WizardPage() {
const [isSavingBot, setIsSavingBot] = useState(false);
const [botSaved, setBotSaved] = useState(false);
// ---- Fetch remote data ----
// ---- 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 () => {
@@ -113,6 +135,47 @@ export default function WizardPage() {
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'));
@@ -183,6 +246,15 @@ export default function WizardPage() {
);
}, [selectedRunnerConfigStage]);
// ---- Runner selection with progress saving ----
const handleSelectRunner = useCallback(
(runner: string) => {
setSelectedRunner(runner);
saveProgress({ step: 2, selected_runner: runner });
},
[saveProgress],
);
// ---- Navigation helpers ----
const canProceed = useCallback((): boolean => {
@@ -200,15 +272,19 @@ export default function WizardPage() {
const goNext = useCallback(() => {
if (currentStep < TOTAL_STEPS - 1 && canProceed()) {
setCurrentStep((s) => s + 1);
const nextStep = currentStep + 1;
setCurrentStep(nextStep);
saveProgress({ step: nextStep });
}
}, [currentStep, canProceed]);
}, [currentStep, canProceed, saveProgress]);
const goPrev = useCallback(() => {
if (currentStep > 0) {
setCurrentStep((s) => s - 1);
const prevStep = currentStep - 1;
setCurrentStep(prevStep);
saveProgress({ step: prevStep });
}
}, [currentStep]);
}, [currentStep, saveProgress]);
// ---- Create Bot (Step 0) ----
// Creates a disabled bot using the adapter label as name.
@@ -255,6 +331,15 @@ export default function WizardPage() {
// 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(
@@ -263,7 +348,7 @@ export default function WizardPage() {
} finally {
setIsCreatingBot(false);
}
}, [selectedAdapter, adapters, t]);
}, [selectedAdapter, adapters, t, saveProgress]);
// ---- Save Bot Config & Enable (Step 1) ----
// Updates the bot's adapter config and enables it.
@@ -295,6 +380,9 @@ export default function WizardPage() {
} catch {
// Non-critical
}
// Persist progress
saveProgress({ step: 1, bot_saved: true });
} catch (err) {
const apiErr = err as { msg?: string };
toast.error(
@@ -310,6 +398,7 @@ export default function WizardPage() {
botDescription,
adapterConfig,
t,
saveProgress,
]);
// ---- Create Pipeline & Link (Step 2 finish) ----
@@ -540,7 +629,7 @@ export default function WizardPage() {
<StepAIEngine
runnerOptions={runnerOptions}
selected={selectedRunner}
onSelect={setSelectedRunner}
onSelect={handleSelectRunner}
isLocalAccount={isLocalAccount}
onSpaceAuth={handleSpaceAuth}
runnerConfigItems={selectedRunnerConfigItems}