diff --git a/src/langbot/templates/embed/widget.js b/src/langbot/templates/embed/widget.js index c988b41b7..72e3f5355 100644 --- a/src/langbot/templates/embed/widget.js +++ b/src/langbot/templates/embed/widget.js @@ -1266,6 +1266,11 @@ // Styles var style = document.createElement("style"); style.textContent = STYLES; + // Keep the wizard navigation accessible while the chat preview is open. + if (scriptTestNotice) { + style.textContent += + ".lb-bubble { bottom: 84px; } .lb-panel { bottom: 152px; max-height: calc(100vh - 172px); } @media (max-width: 480px) { .lb-panel { bottom: 64px; height: calc(100vh - 64px); max-height: calc(100vh - 64px); } }"; + } shadow.appendChild(style); // Chat bubble button diff --git a/web/src/app/home/agents/runner-marketplace.ts b/web/src/app/home/agents/runner-marketplace.ts index 378981d77..a2cb498f4 100644 --- a/web/src/app/home/agents/runner-marketplace.ts +++ b/web/src/app/home/agents/runner-marketplace.ts @@ -8,6 +8,7 @@ import { type PluginV4, type RunnerUsage, } from '@/app/infra/entities/plugin'; +import type { AsyncTask } from '@/app/infra/entities/api'; import type { I18nObject } from '@/app/infra/entities/common'; export const RUNNER_COMPONENT_FILTER = 'Runner'; @@ -54,6 +55,7 @@ export interface PendingRunnerInstall { interface InstallRunnerOptions { scope: string; onTaskCreated?: (taskId: number) => void; + onProgress?: (task: AsyncTask) => void; } function wait(ms: number) { @@ -250,7 +252,7 @@ export async function installMarketplaceRunner( }; writePendingRunnerInstall(pending); options.onTaskCreated?.(taskId); - return finishRunnerInstall(pending); + return finishRunnerInstall(pending, options.onProgress); } function extractPluginLabel(plugin: PluginV4) { @@ -267,6 +269,7 @@ function extractPluginLabel(plugin: PluginV4) { async function finishRunnerInstall( pending: PendingRunnerInstall, + onProgress?: (task: AsyncTask) => void, ): Promise { // A refreshed page receives a fresh observation window. The backend task is // authoritative; `startedAt` is display metadata, not a reason to abandon a @@ -275,6 +278,7 @@ async function finishRunnerInstall( let installCompleted = false; while (true) { const task = await httpClient.getAsyncTask(pending.taskId); + onProgress?.(task); if (task.runtime.done) { if (task.runtime.exception) { clearPendingRunnerInstall(pending.scope, pending.taskId); @@ -323,8 +327,9 @@ async function finishRunnerInstall( export async function resumePendingRunnerInstall( scope: string, + onProgress?: (task: AsyncTask) => void, ): Promise { const pending = readPendingRunnerInstall(scope); if (!pending) return null; - return finishRunnerInstall(pending); + return finishRunnerInstall(pending, onProgress); } diff --git a/web/src/app/home/plugins/components/plugin-install-task/PluginInstallTaskContext.tsx b/web/src/app/home/plugins/components/plugin-install-task/PluginInstallTaskContext.tsx index 042aeb897..0ed8d5cdd 100644 --- a/web/src/app/home/plugins/components/plugin-install-task/PluginInstallTaskContext.tsx +++ b/web/src/app/home/plugins/components/plugin-install-task/PluginInstallTaskContext.tsx @@ -185,7 +185,9 @@ function isPluginInstallTask(name: string): boolean { /** * Convert a backend AsyncTask to our PluginInstallTask. */ -function asyncTaskToPluginInstallTask(task: AsyncTask): PluginInstallTask { +export function asyncTaskToPluginInstallTask( + task: AsyncTask, +): PluginInstallTask { const source = extractSourceFromName(task.name); const md = (task.task_context?.metadata ?? {}) as Record; const action = task.task_context?.current_action || ''; diff --git a/web/src/app/wizard/page.tsx b/web/src/app/wizard/page.tsx index dd636c4d1..83d007adb 100644 --- a/web/src/app/wizard/page.tsx +++ b/web/src/app/wizard/page.tsx @@ -1,9 +1,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { Link, useNavigate } from 'react-router-dom'; +import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { UUID } from 'uuidjs'; import { toast } from 'sonner'; import { + Bug, + Blocks, + Cable, + Settings2, ArrowLeft, ArrowRight, AlertTriangle, @@ -30,14 +34,10 @@ import { bootstrapWorkspaceSession, initializeSystemInfo, getCloudServiceClientSync, + getCloudServiceClient, userInfo, } from '@/app/infra/http'; -import { - Adapter, - Bot, - Pipeline, - WizardProgress, -} from '@/app/infra/entities/api'; +import { Adapter, Bot, WizardProgress } from '@/app/infra/entities/api'; import { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic'; import { PipelineConfigTab, @@ -58,6 +58,7 @@ import { import { getAdapterDocUrl } from '@/app/infra/entities/adapter-docs'; import i18n from 'i18next'; import { PluginV4 } from '@/app/infra/entities/plugin'; +import type { I18nObject } from '@/app/infra/entities/common'; import { RunnerMarketplaceError, getErrorMessage, @@ -69,12 +70,31 @@ import { runnerPluginPrefix, } from '@/app/home/agents/runner-marketplace'; import { + LOCAL_AGENT_RUNNER_ID, + configureLocalAgentPrimaryModel, ensureHttpBotSigningSecret, isRequiredRunnerConfigComplete, isWebhookModeEnabled, } from '@/app/wizard/utils'; +import OwnModelSetup, { + type OwnModelSelection, +} from '@/app/wizard/components/OwnModelSetup'; + +type RunnerInstallProgress = { stage: InstallStage; percent: number }; + +type RunnerPluginStatus = 'installed' | 'debug'; + +type AIChoice = 'more-features' | 'external' | 'own-model'; + import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import type { AsyncTask } from '@/app/infra/entities/api'; +import { + InstallStage, + asyncTaskToPluginInstallTask, +} from '@/app/home/plugins/components/plugin-install-task/PluginInstallTaskContext'; +import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; import { Input } from '@/components/ui/input'; import { Card, @@ -117,6 +137,10 @@ export default function WizardPage() { // ---- Wizard state ---- const [currentStep, setCurrentStep] = useState(0); + const [aiChoice, setAiChoice] = useState('more-features'); + const [ownModelSelection, setOwnModelSelection] = + useState(null); + const [preparingDefault, setPreparingDefault] = useState(false); const [selectedAdapter, setSelectedAdapter] = useState(null); const [selectedRunner, setSelectedRunner] = useState(null); const [botName, setBotName] = useState(''); @@ -139,11 +163,20 @@ export default function WizardPage() { ); const [marketplaceRunners, setMarketplaceRunners] = useState([]); const [installedPluginIds, setInstalledPluginIds] = useState([]); + const [runnerPluginStatuses, setRunnerPluginStatuses] = useState< + Record + >({}); + const [runnerPluginDescriptions, setRunnerPluginDescriptions] = useState< + Record + >({}); const [isRunnerCatalogLoading, setIsRunnerCatalogLoading] = useState(true); const [runnerCatalogError, setRunnerCatalogError] = useState(false); const [installingRunnerPluginId, setInstallingRunnerPluginId] = useState< string | null >(null); + const [runnerInstallProgress, setRunnerInstallProgress] = useState< + Record + >({}); const [runnerInstallError, setRunnerInstallError] = useState( null, ); @@ -171,8 +204,44 @@ export default function WizardPage() { }, []); useEffect(() => { + if (currentStep !== 2 || aiChoice !== 'external') return; void loadRunnerCatalog(); - }, [loadRunnerCatalog]); + let cancelled = false; + // Local status remains available even when the marketplace cannot be reached. + void httpClient + .getPlugins() + .then(({ plugins }) => { + if (cancelled) return; + setRunnerPluginDescriptions( + Object.fromEntries( + plugins.flatMap((plugin) => { + const { author, name, description } = + plugin.manifest.manifest.metadata; + return description + ? [[`${author ?? ''}/${name}`, description]] + : []; + }), + ), + ); + setRunnerPluginStatuses( + Object.fromEntries( + plugins.map((plugin) => { + const { author, name } = plugin.manifest.manifest.metadata; + return [ + `${author ?? ''}/${name}`, + plugin.debug ? 'debug' : 'installed', + ]; + }), + ), + ); + }) + .catch((error) => + console.error('Failed to load local Runner status', error), + ); + return () => { + cancelled = true; + }; + }, [loadRunnerCatalog, currentStep, aiChoice]); // ---- Helper: persist wizard progress to backend (fire-and-forget) ---- const saveProgress = useCallback( @@ -271,17 +340,44 @@ export default function WizardPage() { setSelectedAdapter(restoredAdapter); setCreatedBotUuid(progress.created_bot_uuid); - setCreatedPipelineUuid( + const restoredPipelineUuid = progress.created_pipeline_uuid ?? - botData.bot.event_bindings?.find( - (binding) => - binding.event_pattern === 'message.received' && - binding.target_type === 'pipeline', - )?.target_uuid ?? - null, - ); + botData.bot.event_bindings?.find( + (binding) => + binding.event_pattern === 'message.received' && + binding.target_type === 'pipeline', + )?.target_uuid ?? + null; + setCreatedPipelineUuid(restoredPipelineUuid); + let pipelineReady = false; + if (restoredPipelineUuid) { + const { pipeline } = + await httpClient.getPipeline(restoredPipelineUuid); + const config = pipeline.config as unknown as Record< + string, + unknown + >; + const ai = config.ai as + | { + runner?: { id?: string }; + runner_config?: Record< + string, + { model?: { primary?: string } } + >; + } + | undefined; + const id = ai?.runner?.id; + pipelineReady = + !!id && + !!aiTab?.stages.some((stage) => stage.name === id) && + (id !== LOCAL_AGENT_RUNNER_ID || + !!ai?.runner_config?.[id]?.model?.primary); + } + if (cancelled) return; setBotSaved( - configNeedsSave ? false : (progress.bot_saved ?? false), + !configNeedsSave && + pipelineReady && + (progress.bot_saved ?? false), ); setMessageReceived(progress.message_received ?? false); setSelectedRunner(progress.selected_runner); @@ -300,7 +396,7 @@ export default function WizardPage() { ); // Step 3 is resumable so a refresh cannot create a duplicate processor. - setCurrentStep(Math.min(progress.step, 3)); + setCurrentStep(pipelineReady ? Math.min(progress.step, 3) : 1); } catch { // Clear stale or unsupported progress without modifying its resources. httpClient @@ -405,7 +501,10 @@ export default function WizardPage() { // ---- Runner selection with progress saving ---- const handleSelectRunner = useCallback( - (runner: string, configTab: PipelineConfigTab | null = aiConfigTab) => { + ( + runner: string | null, + configTab: PipelineConfigTab | null = aiConfigTab, + ) => { setSelectedRunner(runner); const configStage = configTab?.stages.find((s) => s.name === runner); const defaults = configStage ? getDefaultValues(configStage.config) : {}; @@ -415,17 +514,55 @@ export default function WizardPage() { [aiConfigTab, saveProgress], ); + const updateRunnerInstallProgress = useCallback( + (pluginId: string, task: AsyncTask) => { + const progress = asyncTaskToPluginInstallTask(task); + const registering = task.runtime.done && !task.runtime.exception; + setRunnerInstallProgress((current) => ({ + ...current, + [pluginId]: { + stage: registering ? InstallStage.ACTIVATING : progress.stage, + percent: registering ? 95 : progress.overallProgress, + }, + })); + }, + [], + ); + + const finishRunnerProgress = useCallback( + (pluginId: string, failed = false) => { + setRunnerInstallProgress((current) => ({ + ...current, + [pluginId]: { + stage: failed ? InstallStage.ERROR : InstallStage.DONE, + percent: failed ? (current[pluginId]?.percent ?? 0) : 100, + }, + })); + }, + [], + ); + const handleInstallRunner = useCallback( async (plugin: PluginV4) => { const pluginId = marketplacePluginId(plugin); setInstallingRunnerPluginId(pluginId); setRunnerInstallError(null); + setRunnerInstallProgress((current) => ({ + ...current, + [pluginId]: { stage: InstallStage.CHECKING, percent: 0 }, + })); try { const installed = await installMarketplaceRunner(plugin, { scope: WIZARD_RUNNER_INSTALL_SCOPE, + onProgress: (task) => updateRunnerInstallProgress(pluginId, task), }); + finishRunnerProgress(pluginId); setAiConfigTab(installed.configTab); + setRunnerPluginStatuses((current) => ({ + ...current, + [pluginId]: 'installed', + })); setInstalledPluginIds((current) => current.includes(pluginId) ? current : [...current, pluginId], ); @@ -436,6 +573,7 @@ export default function WizardPage() { }), ); } catch (error) { + finishRunnerProgress(pluginId, true); let message = getErrorMessage(error); if (error instanceof RunnerMarketplaceError) { const key = @@ -453,7 +591,7 @@ export default function WizardPage() { setInstallingRunnerPluginId(null); } }, - [handleSelectRunner, t], + [handleSelectRunner, updateRunnerInstallProgress, finishRunnerProgress, t], ); useEffect(() => { @@ -464,10 +602,17 @@ export default function WizardPage() { let cancelled = false; setInstallingRunnerPluginId(pending.pluginId); setRunnerInstallError(null); - void resumePendingRunnerInstall(WIZARD_RUNNER_INSTALL_SCOPE) + void resumePendingRunnerInstall(WIZARD_RUNNER_INSTALL_SCOPE, (task) => { + if (!cancelled) updateRunnerInstallProgress(pending.pluginId, task); + }) .then((installed) => { if (cancelled || !installed) return; + finishRunnerProgress(pending.pluginId); setAiConfigTab(installed.configTab); + setRunnerPluginStatuses((current) => ({ + ...current, + [pending.pluginId]: 'installed', + })); setInstalledPluginIds((current) => current.includes(pending.pluginId) ? current @@ -482,6 +627,7 @@ export default function WizardPage() { }) .catch((error) => { if (cancelled) return; + finishRunnerProgress(pending.pluginId, true); const message = getErrorMessage(error) || t('wizard.aiEngine.installFailed'); setRunnerInstallError(message); @@ -494,7 +640,13 @@ export default function WizardPage() { return () => { cancelled = true; }; - }, [handleSelectRunner, isLoading, t]); + }, [ + handleSelectRunner, + isLoading, + updateRunnerInstallProgress, + finishRunnerProgress, + t, + ]); // ---- Navigation helpers ---- @@ -505,12 +657,18 @@ export default function WizardPage() { case 1: return createdBotUuid !== null && botSaved && messageReceived; case 2: - return selectedRunner !== null && isRunnerConfigComplete; + if (aiChoice === 'own-model') return ownModelSelection !== null; + if (aiChoice === 'external') + return selectedRunner !== null && isRunnerConfigComplete; + return createdPipelineUuid !== null; default: return false; } }, [ currentStep, + aiChoice, + ownModelSelection, + createdPipelineUuid, selectedAdapter, createdBotUuid, botSaved, @@ -614,6 +772,7 @@ export default function WizardPage() { const handleSaveBot = useCallback(async () => { if (!createdBotUuid || !selectedAdapter) return; setIsSavingBot(true); + setBotSaved(false); let previewPipelineUuid = createdPipelineUuid; let createdPreviewPipelineUuid: string | null = null; @@ -624,6 +783,33 @@ export default function WizardPage() { ); setAdapterConfig(configToSave); + setPreparingDefault(true); + const metadata = await httpClient.getGeneralPipelineMetadata(); + let configTab = metadata.configs.find((tab) => tab.name === 'ai'); + if ( + !configTab?.stages.some((stage) => stage.name === LOCAL_AGENT_RUNNER_ID) + ) { + const scope = 'wizard-default'; + const pending = readPendingRunnerInstall(scope); + const installed = pending + ? await resumePendingRunnerInstall(scope) + : await installMarketplaceRunner( + ( + await ( + await getCloudServiceClient() + ).getPluginDetail('langbot-team', 'LocalAgent') + ).plugin, + { scope }, + ); + configTab = installed?.configTab; + } + const localStage = configTab?.stages.find( + (stage) => stage.name === LOCAL_AGENT_RUNNER_ID, + ); + if (!localStage) + throw new Error(t('wizard.aiEngine.defaultRunnerUnavailable')); + setAiConfigTab(configTab ?? null); + if (!previewPipelineUuid) { const pipelineResp = await httpClient.createPipeline({ name: `${botName} Pipeline`, @@ -634,6 +820,39 @@ export default function WizardPage() { createdPreviewPipelineUuid = pipelineResp.uuid; } + const { pipeline } = await httpClient.getPipeline(previewPipelineUuid); + const config = pipeline.config as unknown as Record; + const ai = (config.ai ?? {}) as Record; + const configs = (ai.runner_config ?? {}) as Record< + string, + Record + >; + const localConfig = { + ...getDefaultValues(localStage.config), + ...configs[LOCAL_AGENT_RUNNER_ID], + }; + const model = localConfig.model as { primary?: string } | undefined; + const modelUuid = + model?.primary || (await httpClient.getWizardRecommendedModel()).uuid; + if (!modelUuid) + throw new Error(t('wizard.aiEngine.defaultModelUnavailable')); + await httpClient.updatePipeline(previewPipelineUuid, { + config: configureLocalAgentPrimaryModel( + { + ...config, + ai: { + ...ai, + runner_config: { + ...configs, + [LOCAL_AGENT_RUNNER_ID]: localConfig, + }, + }, + }, + modelUuid, + ), + }); + setPreparingDefault(false); + const botUpdate: Partial = { name: botName, description: botDescription || '', @@ -697,11 +916,9 @@ export default function WizardPage() { ); } } - const apiErr = err as { msg?: string }; - toast.error( - t('wizard.createError') + (apiErr?.msg ? `: ${apiErr.msg}` : ''), - ); + toast.error(`${t('wizard.createError')}: ${getErrorMessage(err)}`); } finally { + setPreparingDefault(false); setIsSavingBot(false); } }, [ @@ -724,109 +941,75 @@ export default function WizardPage() { // ---- Create Pipeline & Link (Step 2 finish) ---- const handleFinish = useCallback(async () => { - if (!selectedRunner || !isRunnerConfigComplete || !createdBotUuid) return; + if (!createdBotUuid || !createdPipelineUuid || !canProceed()) return; setIsSubmitting(true); - let processorUuid = ''; - let processorCreatedThisAttempt = false; - + let createdModelUuid: string | null = null; + let modelBound = false; try { - processorUuid = createdPipelineUuid ?? ''; - if (!processorUuid) { - const pipeline: Pipeline = { - name: `${botName} Pipeline`, - description: botDescription || '', - config: {}, - }; - const pipelineResp = await httpClient.createPipeline(pipeline); - processorUuid = pipelineResp.uuid; - processorCreatedThisAttempt = true; - } - const createdPipeline = await httpClient.getPipeline(processorUuid); - const fullConfig = createdPipeline.pipeline.config as unknown as Record< - string, - unknown - >; - const fullAiConfig = - fullConfig.ai && typeof fullConfig.ai === 'object' - ? (fullConfig.ai as Record) - : {}; - const existingRunner = - fullAiConfig.runner && typeof fullAiConfig.runner === 'object' - ? (fullAiConfig.runner as Record) - : {}; - const existingRunnerConfigs = - fullAiConfig.runner_config && - typeof fullAiConfig.runner_config === 'object' - ? (fullAiConfig.runner_config as Record) - : {}; - - await httpClient.updatePipeline(processorUuid, { - name: `${botName} Pipeline`, - description: botDescription || '', - config: { - ...fullConfig, - ai: { - ...fullAiConfig, - runner: { ...existingRunner, id: selectedRunner }, - runner_config: { - ...existingRunnerConfigs, - [selectedRunner]: runnerConfig, + // The preview pipeline is already connected. Keeping the default requires no changes. + if (aiChoice !== 'more-features') { + const { pipeline } = await httpClient.getPipeline(createdPipelineUuid); + const config = pipeline.config as unknown as Record; + let updatedConfig: Record; + if (aiChoice === 'own-model' && ownModelSelection) { + const model = await httpClient.createProviderLLMModel({ + name: ownModelSelection.model.name, + provider_uuid: ownModelSelection.providerUuid, + abilities: ownModelSelection.model.abilities ?? [], + reasoning_config: { level: 'provider_default' }, + context_length: ownModelSelection.model.context_length ?? null, + extra_args: {}, + }); + createdModelUuid = model.uuid; + updatedConfig = configureLocalAgentPrimaryModel(config, model.uuid); + } else if (aiChoice === 'external' && selectedRunner) { + const ai = (config.ai ?? {}) as Record; + updatedConfig = { + ...config, + ai: { + ...ai, + runner: { + ...(ai.runner as Record), + id: selectedRunner, + }, + runner_config: { + ...(ai.runner_config as Record), + [selectedRunner]: runnerConfig, + }, }, - }, - }, - }); - const botData = await httpClient.getBot(createdBotUuid); - const existingBot = botData.bot; - await httpClient.updateBot(createdBotUuid, { - name: existingBot.name, - description: existingBot.description, - adapter: existingBot.adapter, - adapter_config: existingBot.adapter_config, - enable: existingBot.enable, - event_bindings: [ - { - event_pattern: 'message.received', - target_type: 'pipeline', - target_uuid: processorUuid, - filters: [], - priority: 0, - enabled: true, - description: '', - }, - ], - }); - + }; + } else { + return; + } + await httpClient.updatePipeline(createdPipelineUuid, { + config: updatedConfig, + }); + modelBound = true; + } setCurrentStep(3); - setCreatedPipelineUuid(processorUuid); - saveProgress({ - step: 3, - created_pipeline_uuid: processorUuid, - }); + saveProgress({ step: 3, created_pipeline_uuid: createdPipelineUuid }); } catch (err) { - if (processorCreatedThisAttempt && processorUuid) { + if (createdModelUuid && !modelBound) { try { - await httpClient.deletePipeline(processorUuid); + await httpClient.deleteProviderLLMModel(createdModelUuid); } catch (rollbackError) { - console.warn('Failed to roll back wizard processor', rollbackError); + console.warn('Failed to roll back wizard model', rollbackError); } } - const apiErr = err as { msg?: string }; - toast.error( - t('wizard.createError') + (apiErr?.msg ? `: ${apiErr.msg}` : ''), - ); + toast.error(`${t('wizard.createError')}: ${getErrorMessage(err)}`); } finally { setIsSubmitting(false); } }, [ - selectedRunner, - isRunnerConfigComplete, createdBotUuid, createdPipelineUuid, - botName, - botDescription, + canProceed, + aiChoice, + ownModelSelection, + selectedRunner, runnerConfig, - t, saveProgress, + t, ]); // ---- Skip handler ---- @@ -953,7 +1136,7 @@ export default function WizardPage() {
runner.name !== LOCAL_AGENT_RUNNER_ID, + )} + marketplaceRunners={marketplaceRunners.filter( + (plugin) => + marketplacePluginId(plugin) !== 'langbot-team/LocalAgent', + )} installedPluginIds={installedPluginIds} + runnerPluginStatuses={runnerPluginStatuses} + runnerPluginDescriptions={runnerPluginDescriptions} isRunnerCatalogLoading={isRunnerCatalogLoading} runnerCatalogError={runnerCatalogError} installingRunnerPluginId={installingRunnerPluginId} runnerInstallError={runnerInstallError} + runnerInstallProgress={runnerInstallProgress} selected={selectedRunner} onSelect={handleSelectRunner} onInstall={handleInstallRunner} @@ -1011,7 +1206,9 @@ export default function WizardPage() { )}
@@ -1314,6 +1515,7 @@ function StepBotConfig({ adapters, createdBotUuid, isSavingBot, + preparingDefault, botSaved, pageBotPreviewRequest, messageReceived, @@ -1330,6 +1532,7 @@ function StepBotConfig({ adapters: Adapter[]; createdBotUuid: string | null; isSavingBot: boolean; + preparingDefault: boolean; botSaved: boolean; pageBotPreviewRequest: number; messageReceived: boolean; @@ -1560,9 +1763,11 @@ function StepBotConfig({ {isSavingBot && ( )} - {botSaved - ? t('wizard.botConfig.resaveBot') - : t('wizard.botConfig.saveBot')} + {preparingDefault + ? t('wizard.aiEngine.preparingDefault') + : botSaved + ? t('wizard.botConfig.resaveBot') + : t('wizard.botConfig.saveBot')} {adapterConfigItems.length > 0 && ( @@ -1623,14 +1828,52 @@ function StepBotConfig({ // Step 2: Select & Configure AI Engine // --------------------------------------------------------------------------- +const RUNNER_INSTALL_STAGE_LABELS: Record = { + [InstallStage.CHECKING]: 'plugins.installProgress.checkingUpdate', + [InstallStage.DOWNLOADING]: 'plugins.installProgress.downloading', + [InstallStage.VALIDATING]: 'plugins.installProgress.validating', + [InstallStage.INSTALLING_DEPS]: 'plugins.installProgress.installingDeps', + [InstallStage.ACTIVATING]: 'plugins.installProgress.activating', + [InstallStage.DONE]: 'plugins.installProgress.completed', + [InstallStage.ERROR]: 'plugins.installProgress.failed', +}; + +function RunnerStatusBadge({ status }: { status?: RunnerPluginStatus }) { + const { t } = useTranslation(); + if (status !== 'debug') return null; + return ( + + + {t('plugins.debugging')} + + ); +} + +function runnerPluginId(runner: string) { + return runner + .replace(/^plugin:/, '') + .split('/') + .slice(0, 2) + .join('/'); +} + function StepAIEngine({ + choice, + onChoiceChange, + onOwnModelSelectionChange, runnerOptions, marketplaceRunners, installedPluginIds, + runnerPluginStatuses, + runnerPluginDescriptions, isRunnerCatalogLoading, runnerCatalogError, installingRunnerPluginId, runnerInstallError, + runnerInstallProgress, selected, onSelect, onInstall, @@ -1639,15 +1882,21 @@ function StepAIEngine({ runnerConfigValues, onRunnerConfigChange, }: { + choice: AIChoice; + onChoiceChange: (choice: AIChoice) => void; + onOwnModelSelectionChange: (selection: OwnModelSelection | null) => void; runnerOptions: { name: string; label: { en_US: string; zh_Hans: string } }[]; marketplaceRunners: PluginV4[]; installedPluginIds: string[]; + runnerPluginStatuses: Record; + runnerPluginDescriptions: Record; isRunnerCatalogLoading: boolean; runnerCatalogError: boolean; installingRunnerPluginId: string | null; runnerInstallError: string | null; + runnerInstallProgress: Record; selected: string | null; - onSelect: (name: string) => void; + onSelect: (name: string | null) => void; onInstall: (plugin: PluginV4) => void; onRetryCatalog: () => void; runnerConfigItems: IDynamicFormItemSchema[]; @@ -1683,61 +1932,181 @@ function StepAIEngine({ [marketplaceRunnerIds, runnerOptions], ); - // Before any runner is selected: centered grid layout - if (!selected) { + const orderedRunnerEntries = useMemo(() => { + const installedIds = new Set([ + ...installedPluginIds, + ...Object.keys(runnerPluginStatuses), + ...runnerOptions.map((option) => runnerPluginId(option.name)), + ]); + const entries: ( + | { kind: 'marketplace'; plugin: PluginV4; installed: boolean } + | { + kind: 'local'; + option: (typeof runnerOptions)[number]; + installed: boolean; + } + )[] = [ + ...marketplaceRunners.map((plugin) => ({ + kind: 'marketplace' as const, + plugin, + installed: installedIds.has(marketplacePluginId(plugin)), + })), + ...standaloneRunnerOptions.map((option) => ({ + kind: 'local' as const, + option, + installed: true, + })), + ]; + return entries.sort((a, b) => Number(b.installed) - Number(a.installed)); + }, [ + marketplaceRunners, + standaloneRunnerOptions, + installedPluginIds, + runnerPluginStatuses, + runnerOptions, + ]); + + const choices = [ + { + id: 'more-features' as const, + icon: Blocks, + title: t('wizard.aiEngine.moreFeaturesTitle'), + description: t('wizard.aiEngine.moreFeaturesDescription'), + }, + { + id: 'external' as const, + icon: Cable, + title: t('wizard.aiEngine.externalTitle'), + description: t('wizard.aiEngine.externalDescription'), + }, + { + id: 'own-model' as const, + icon: Settings2, + title: t('wizard.aiEngine.ownModelTitle'), + description: t('wizard.aiEngine.ownModelDescription'), + }, + ]; + + if (choice === 'own-model') { return ( -
+
+ onChoiceChange('more-features')} + onSelectionChange={onOwnModelSelectionChange} + /> +
+ ); + } + + if (choice !== 'external') { + return ( +

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

- {t('wizard.aiEngine.description')} + {t('wizard.aiEngine.optionalDescription')}

- {runnerCatalogError && ( -
-
- -
-

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

-

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

- -
+ { + if (value) onChoiceChange(value as AIChoice); + }} + aria-label={t('wizard.aiEngine.title')} + variant="outline" + spacing={3} + className="grid w-full grid-cols-1 items-stretch gap-3 md:grid-cols-3" + > + {choices.map((item) => { + const Icon = item.icon; + return ( + + + + + + + {item.title} + + + {item.description} + + + + ); + })} + +
+ ); + } + + // The catalog is shared by the picker and the configuration sidebar. + const runnerCatalog = ( +
+ {runnerCatalogError && ( +
+
+ +
+

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

+

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

+
- )} +
+ )} - {runnerInstallError && ( -
- {runnerInstallError} + {runnerInstallError && ( +
+ {runnerInstallError} +
+ )} + + {isRunnerCatalogLoading && marketplaceRunners.length === 0 && ( +
+
+ + {t('wizard.aiEngine.loadingCatalog')}
- )} +
+ )} - {isRunnerCatalogLoading && marketplaceRunners.length === 0 && ( -
-
- - {t('wizard.aiEngine.loadingCatalog')} -
-
+
- {marketplaceRunners.map((plugin) => { + > + {orderedRunnerEntries.map((entry) => { + if (entry.kind === 'marketplace') { + const plugin = entry.plugin; const pluginId = marketplacePluginId(plugin); const prefix = runnerPluginPrefix(plugin); const registeredOptions = runnerOptions.filter((option) => @@ -1747,8 +2116,15 @@ function StepAIEngine({ registeredOptions.find((option) => option.name.endsWith('/default'), ) ?? registeredOptions[0]; + const isSelected = registeredOptions.some( + (option) => option.name === selected, + ); const isInstalled = installedPluginIds.includes(pluginId); const isInstalling = installingRunnerPluginId === pluginId; + const progress = runnerInstallProgress[pluginId]; + const progressLabel = progress + ? t(RUNNER_INSTALL_STAGE_LABELS[progress.stage]) + : t('wizard.aiEngine.installing'); const iconUrl = getCloudServiceClientSync().resolveMarketplaceIconURL( plugin.type, @@ -1758,23 +2134,36 @@ function StepAIEngine({ ); return ( - - + +
- - {extractI18nObject(plugin.label) || plugin.name} - - +
+ + {extractI18nObject(plugin.label) || plugin.name} + + +
+ {plugin.author}/{plugin.name}
- +

{extractI18nObject(plugin.description)}

@@ -1783,7 +2172,11 @@ function StepAIEngine({ )}
); - })} - - {standaloneRunnerOptions.map((opt) => ( + } + const opt = entry.option; + const pluginId = runnerPluginId(opt.name); + const [author, name] = pluginId.split('/'); + const description = runnerPluginDescriptions[pluginId]; + const isSelected = selected === opt.name; + return ( onSelect(opt.name)} + className={cn( + 'flex flex-col gap-3 py-4 shadow-none', + !selected && 'min-h-52', + isSelected && + 'border-[#2288ee]/50 bg-blue-50/60 dark:border-blue-500/50 dark:bg-blue-500/10', + )} > - + + {author && name ? ( + + ) : ( + + )}
- - {extractI18nObject(opt.label)} - - - {opt.name} +
+ + {extractI18nObject(opt.label)} + + +
+ + {pluginId}
+ + {description && ( +

+ {extractI18nObject(description)} +

+ )} +
+ +
+
- ))} -
- - {!isRunnerCatalogLoading && - marketplaceRunners.length === 0 && - standaloneRunnerOptions.length === 0 && - !runnerCatalogError && ( -
-

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

-

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

-
- )} - -
- -
+ ); + })}
- ); - } - // After a runner is selected: left-right split layout - // On mobile (< lg): single column, normal scroll from parent - // On desktop (>= lg): side-by-side with independent scroll per column + {!isRunnerCatalogLoading && + marketplaceRunners.length === 0 && + standaloneRunnerOptions.length === 0 && + !runnerCatalogError && ( +
+

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

+

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

+
+ )} + + +
+ ); + return ( -
-
-

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

-

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

+
+

+ {t('wizard.aiEngine.externalTitle')} +

+

+ {t( + selected + ? 'wizard.aiEngine.description' + : 'wizard.aiEngine.runnerDescription', + )}

- -
- {/* Left: runner list */} -
- {/* p-1 provides space for ring-2 (4px) to render without clipping */} -
- {runnerOptions.map((opt) => { - const isSelected = selected === opt.name; - return ( - onSelect(opt.name)} - > - -
- - {extractI18nObject(opt.label)} - - - {opt.name} - -
- {isSelected && ( -
-
- -
-
- )} -
-
- ); - })} + + {selected ? ( +
+
+ {runnerCatalog}
-
- - {/* Right: runner configuration — fixed width on desktop */} -
-
+
{runnerConfigItems.length > 0 && ( - + {t('wizard.config.aiConfig', { engine: runnerLabel })} @@ -1937,7 +2382,6 @@ function StepAIEngine({ } onSubmit={stableRunnerConfigCb} @@ -1948,7 +2392,9 @@ function StepAIEngine({ )}
-
+ ) : ( + runnerCatalog + )}
); } diff --git a/web/src/app/wizard/utils.ts b/web/src/app/wizard/utils.ts index 064851d06..07ce0bebc 100644 --- a/web/src/app/wizard/utils.ts +++ b/web/src/app/wizard/utils.ts @@ -105,13 +105,16 @@ export function isRequiredRunnerConfigComplete( }); } +export const LOCAL_AGENT_RUNNER_ID = 'plugin:langbot-team/LocalAgent/default'; + export function configureLocalAgentPrimaryModel( config: Record, modelUuid: string, ): Record { const aiConfig = (config.ai ?? {}) as Record; const runnerConfig = (aiConfig.runner ?? {}) as Record; - const localAgentConfig = (aiConfig['local-agent'] ?? {}) as Record< + const configs = (aiConfig.runner_config ?? {}) as Record; + const localAgentConfig = (configs[LOCAL_AGENT_RUNNER_ID] ?? {}) as Record< string, unknown >; @@ -121,15 +124,18 @@ export function configureLocalAgentPrimaryModel( ...config, ai: { ...aiConfig, - runner: { ...runnerConfig, runner: 'local-agent' }, - 'local-agent': { - ...localAgentConfig, - model: { - ...modelConfig, - primary: modelUuid, - fallbacks: Array.isArray(modelConfig.fallbacks) - ? modelConfig.fallbacks - : [], + runner: { ...runnerConfig, id: LOCAL_AGENT_RUNNER_ID }, + runner_config: { + ...configs, + [LOCAL_AGENT_RUNNER_ID]: { + ...localAgentConfig, + model: { + ...modelConfig, + primary: modelUuid, + fallbacks: Array.isArray(modelConfig.fallbacks) + ? modelConfig.fallbacks + : [], + }, }, }, }, diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 64e8ccd29..d7645a125 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -2580,14 +2580,18 @@ const enUS = { 'Monitor bot activity to verify the platform connection is working.', }, aiEngine: { + defaultModelUnavailable: + 'No default chat model is available. Please retry.', + defaultRunnerUnavailable: 'Local Agent is not ready. Please retry.', + preparingDefault: 'Preparing default AI…', title: 'Select an AI Engine', description: "Choose the AI engine that will power your bot's intelligence.", optionalDescription: - 'This step is optional. Choose how you want to continue with the current agent.', + 'The default AI is ready. Keep it or choose another way to connect.', externalTitle: 'Connect an External Agent', externalDescription: - 'Connect Dify, n8n, Coze, or another platform and replace the bot pipeline.', + 'Connect Dify, n8n, Coze, or another external Agent through a Runner plugin.', ownModelTitle: 'Use My Own Model', ownModelDescription: 'Add a provider, then scan or manually enter a model to finish setup.', @@ -2600,7 +2604,7 @@ const enUS = { selectModelTitle: 'Choose a Model', selectScannedModelTitle: 'Choose a Model', selectScannedModelDescription: - 'The selected model will be the primary model of a new pipeline, and the bot will switch to it.', + 'The selected model will replace the primary model in this bot’s pipeline.', scanModelMode: 'Scan Models', manualModelMode: 'Add Manually', scanningModels: 'Scanning available models…', @@ -2619,12 +2623,13 @@ const enUS = { manualModelOptions: 'Optional Model Capabilities', editProvider: 'Edit provider', rescanModels: 'Scan models again', - moreFeaturesTitle: 'Add More Agent Features', + moreFeaturesTitle: 'Use the default setup', moreFeaturesDescription: - 'Open the workbench to add tools, knowledge bases, and other capabilities to the Agent that was just generated automatically.', + 'Keep Local Agent and the recommended model. You can add tools and knowledge bases later.', runnerDescription: 'Select a runner for the external agent and configure its connection.', backToChoices: 'Back to options', + backToList: 'Back to list', createExternal: 'Create and Bind', finishWithModel: 'Use Selected Model & Finish', openWorkbench: 'Open Workbench', diff --git a/web/src/i18n/locales/es-ES.ts b/web/src/i18n/locales/es-ES.ts index de4323909..fae6ed0a6 100644 --- a/web/src/i18n/locales/es-ES.ts +++ b/web/src/i18n/locales/es-ES.ts @@ -2012,14 +2012,19 @@ const esES = { 'Monitorea la actividad del Bot para verificar que la conexión con la plataforma funcione.', }, aiEngine: { + defaultModelUnavailable: + 'No hay un modelo predeterminado disponible. Inténtalo de nuevo.', + defaultRunnerUnavailable: + 'Local Agent aún no está listo. Inténtalo de nuevo.', + preparingDefault: 'Preparando la IA predeterminada…', title: 'Configura el motor de IA', description: 'Elige el motor de IA que impulsará la inteligencia de tu Bot.', optionalDescription: - 'Este paso es opcional. Elige cómo quieres continuar con el Agent actual.', + 'La IA ya está configurada. Puedes usarla o cambiar la conexión.', externalTitle: 'Conectar un Agent de una plataforma externa', externalDescription: - 'Conecta Dify, n8n, Coze u otra plataforma y sustituye el Pipeline del Bot.', + 'Conecta Dify, n8n, Coze u otro Agent externo mediante un plugin Runner.', ownModelTitle: 'Usar mi propio modelo', ownModelDescription: 'Añade un proveedor y luego escanea o introduce manualmente un modelo para completar la configuración.', @@ -2032,7 +2037,7 @@ const esES = { selectModelTitle: 'Elige un modelo', selectScannedModelTitle: 'Elige un modelo', selectScannedModelDescription: - 'El modelo seleccionado será el modelo principal de un nuevo Pipeline y el Bot cambiará a él.', + 'El modelo seleccionado sustituirá al modelo principal del pipeline de este bot.', scanModelMode: 'Detectar modelos', manualModelMode: 'Añadir manualmente', scanningModels: 'Detectando modelos disponibles…', @@ -2051,12 +2056,13 @@ const esES = { manualModelOptions: 'Capacidades opcionales del modelo', editProvider: 'Editar proveedor', rescanModels: 'Volver a detectar modelos', - moreFeaturesTitle: 'Añadir más funciones al Agent', + moreFeaturesTitle: 'Usar la configuración predeterminada', moreFeaturesDescription: - 'Abre el área de trabajo para añadir herramientas, bases de conocimiento y otras capacidades al Agent que se acaba de generar automáticamente.', + 'Usa Local Agent y el modelo recomendado. Podrás añadir herramientas y bases de conocimiento después.', runnerDescription: 'Selecciona un Runner para el Agent externo y configura su conexión.', backToChoices: 'Volver a las opciones', + backToList: 'Volver a la lista', createExternal: 'Crear y vincular', finishWithModel: 'Usar el modelo seleccionado y finalizar', openWorkbench: 'Abrir área de trabajo', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index ef6610567..02e729fc2 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -2361,14 +2361,19 @@ const jaJP = { 'ボットの活動を監視して、プラットフォーム接続が正常に動作していることを確認します。', }, aiEngine: { + defaultModelUnavailable: + '既定の会話モデルがありません。再試行してください。', + defaultRunnerUnavailable: + 'ローカル Agent が準備できていません。再試行してください。', + preparingDefault: '既定の AI を準備中…', title: 'AIエンジンを選択', description: 'ボットのインテリジェンスを駆動するAIエンジンを選択してください。', optionalDescription: - 'このステップは任意です。現在の Agent をどのように設定するか選択してください。', + '既定の AI は設定済みです。そのまま使うか、接続方法を変更できます。', externalTitle: '外部プラットフォームの Agent を接続', externalDescription: - 'Dify、n8n、Coze などを接続し、ボットのパイプラインを置き換えます。', + 'ランナープラグインで Dify、n8n、Coze などの外部 Agent に接続します。', ownModelTitle: '自分のモデルを使用', ownModelDescription: 'プロバイダーを追加し、モデルをスキャンまたは手動入力して設定を完了します。', @@ -2381,7 +2386,7 @@ const jaJP = { selectModelTitle: 'モデルを選択', selectScannedModelTitle: 'モデルを選択', selectScannedModelDescription: - '選択したモデルを新しいパイプラインのメインモデルに設定し、ボットをそのパイプラインへ切り替えます。', + '選択したモデルを現在のパイプラインのメインモデルに設定します。', scanModelMode: 'モデルをスキャン', manualModelMode: '手動で追加', scanningModels: '利用可能なモデルをスキャン中…', @@ -2400,11 +2405,12 @@ const jaJP = { manualModelOptions: '任意のモデル機能', editProvider: 'プロバイダーを編集', rescanModels: 'モデルを再スキャン', - moreFeaturesTitle: 'Agent に機能を追加', + moreFeaturesTitle: '既定の設定を使う', moreFeaturesDescription: - 'ワークベンチを開き、自動生成されたばかりの Agent にツール、ナレッジベースなどの機能を追加します。', + 'ローカル Agent と推奨モデルを使います。ツールやナレッジベースは後から追加できます。', runnerDescription: '外部 Agent の Runner を選択し、接続を設定します。', backToChoices: '選択肢に戻る', + backToList: '一覧に戻る', createExternal: '作成して関連付ける', finishWithModel: '選択したモデルを使用して完了', openWorkbench: 'ワークベンチを開く', diff --git a/web/src/i18n/locales/ru-RU.ts b/web/src/i18n/locales/ru-RU.ts index 763fca73c..5c0ae0394 100644 --- a/web/src/i18n/locales/ru-RU.ts +++ b/web/src/i18n/locales/ru-RU.ts @@ -1977,14 +1977,18 @@ const ruRU = { 'Отслеживайте активность бота для проверки подключения к платформе.', }, aiEngine: { + defaultModelUnavailable: + 'Модель по умолчанию недоступна. Повторите попытку.', + defaultRunnerUnavailable: 'Local Agent ещё не готов. Повторите попытку.', + preparingDefault: 'Подготовка ИИ по умолчанию…', title: 'Настройте ИИ-движок', description: 'Выберите ИИ-движок, который будет управлять интеллектом вашего бота.', optionalDescription: - 'Этот шаг необязателен. Выберите, как продолжить настройку текущего Agent.', + 'ИИ уже настроен. Используйте его или измените способ подключения.', externalTitle: 'Подключить Agent внешней платформы', externalDescription: - 'Подключите Dify, n8n, Coze или другую платформу и замените Pipeline бота.', + 'Подключите Dify, n8n, Coze или другой внешний Agent через плагин Runner.', ownModelTitle: 'Использовать собственную модель', ownModelDescription: 'Добавьте провайдера, затем найдите модель автоматически или укажите её вручную, чтобы завершить настройку.', @@ -1997,7 +2001,7 @@ const ruRU = { selectModelTitle: 'Выберите модель', selectScannedModelTitle: 'Выберите модель', selectScannedModelDescription: - 'Выбранная модель станет основной моделью нового Pipeline, и бот переключится на неё.', + 'Выбранная модель заменит основную модель в пайплайне этого бота.', scanModelMode: 'Найти модели', manualModelMode: 'Добавить вручную', scanningModels: 'Поиск доступных моделей…', @@ -2016,12 +2020,13 @@ const ruRU = { manualModelOptions: 'Дополнительные возможности модели', editProvider: 'Изменить провайдера', rescanModels: 'Повторить поиск моделей', - moreFeaturesTitle: 'Добавить возможности Agent', + moreFeaturesTitle: 'Использовать настройки по умолчанию', moreFeaturesDescription: - 'Откройте рабочую панель, чтобы добавить инструменты, базы знаний и другие возможности только что автоматически созданному Agent.', + 'Продолжить с Local Agent и рекомендованной моделью. Инструменты и базы знаний можно добавить позже.', runnerDescription: 'Выберите Runner для внешнего Agent и настройте подключение.', backToChoices: 'Вернуться к вариантам', + backToList: 'Вернуться к списку', createExternal: 'Создать и привязать', finishWithModel: 'Использовать выбранную модель и завершить', openWorkbench: 'Открыть рабочую панель', diff --git a/web/src/i18n/locales/th-TH.ts b/web/src/i18n/locales/th-TH.ts index d574904ef..9d58e6053 100644 --- a/web/src/i18n/locales/th-TH.ts +++ b/web/src/i18n/locales/th-TH.ts @@ -1938,13 +1938,17 @@ const thTH = { 'ตรวจสอบกิจกรรม Bot เพื่อยืนยันว่าการเชื่อมต่อแพลตฟอร์มทำงานอยู่', }, aiEngine: { + defaultModelUnavailable: + 'ไม่มีโมเดลเริ่มต้นที่พร้อมใช้งาน โปรดลองอีกครั้ง', + defaultRunnerUnavailable: 'Local Agent ยังไม่พร้อม โปรดลองอีกครั้ง', + preparingDefault: 'กำลังเตรียม AI เริ่มต้น…', title: 'กำหนดค่าเครื่องมือ AI', description: 'เลือกเครื่องมือ AI ที่จะขับเคลื่อนความฉลาดของ Bot', optionalDescription: - 'ขั้นตอนนี้ไม่บังคับ เลือกวิธีที่คุณต้องการดำเนินการต่อกับ Agent ปัจจุบัน', + 'AI เริ่มต้นพร้อมแล้ว ใช้งานได้ทันทีหรือเลือกวิธีเชื่อมต่ออื่น', externalTitle: 'เชื่อมต่อ Agent จากแพลตฟอร์มภายนอก', externalDescription: - 'เชื่อมต่อ Dify, n8n, Coze หรือแพลตฟอร์มอื่น และแทนที่ Pipeline ของ Bot', + 'เชื่อมต่อ Dify, n8n, Coze หรือ Agent ภายนอกผ่านปลั๊กอิน Runner', ownModelTitle: 'ใช้โมเดลของฉันเอง', ownModelDescription: 'เพิ่มผู้ให้บริการ แล้วสแกนหรือกรอกโมเดลด้วยตนเองเพื่อเสร็จสิ้นการตั้งค่า', @@ -1957,7 +1961,7 @@ const thTH = { selectModelTitle: 'เลือกโมเดล', selectScannedModelTitle: 'เลือกโมเดล', selectScannedModelDescription: - 'โมเดลที่เลือกจะเป็นโมเดลหลักของ Pipeline ใหม่ และ Bot จะเปลี่ยนไปใช้โมเดลนี้', + 'โมเดลที่เลือกจะแทนที่โมเดลหลักในไปป์ไลน์ของบอทนี้', scanModelMode: 'สแกนโมเดล', manualModelMode: 'เพิ่มด้วยตนเอง', scanningModels: 'กำลังสแกนโมเดลที่พร้อมใช้งาน…', @@ -1974,12 +1978,13 @@ const thTH = { manualModelOptions: 'ความสามารถเพิ่มเติมของโมเดล', editProvider: 'แก้ไขผู้ให้บริการ', rescanModels: 'สแกนโมเดลอีกครั้ง', - moreFeaturesTitle: 'เพิ่มความสามารถให้ Agent', + moreFeaturesTitle: 'ใช้การตั้งค่าเริ่มต้น', moreFeaturesDescription: - 'เปิดหน้าทำงานเพื่อเพิ่มเครื่องมือ ฐานความรู้ และความสามารถอื่น ๆ ให้ Agent ที่เพิ่งสร้างขึ้นโดยอัตโนมัติ', + 'ใช้ Local Agent และโมเดลที่แนะนำต่อไป เพิ่มเครื่องมือและฐานความรู้ได้ภายหลัง', runnerDescription: 'เลือก Runner สำหรับ Agent ภายนอกและกำหนดค่าการเชื่อมต่อ', backToChoices: 'กลับไปยังตัวเลือก', + backToList: 'กลับไปยังรายการ', createExternal: 'สร้างและผูก', finishWithModel: 'ใช้โมเดลที่เลือกและเสร็จสิ้น', openWorkbench: 'เปิดหน้าทำงาน', diff --git a/web/src/i18n/locales/vi-VN.ts b/web/src/i18n/locales/vi-VN.ts index 70267284b..21f277c9b 100644 --- a/web/src/i18n/locales/vi-VN.ts +++ b/web/src/i18n/locales/vi-VN.ts @@ -1966,13 +1966,16 @@ const viVN = { 'Giám sát hoạt động Bot để xác minh kết nối nền tảng đang hoạt động.', }, aiEngine: { + defaultModelUnavailable: 'Chưa có mô hình mặc định. Vui lòng thử lại.', + defaultRunnerUnavailable: 'Local Agent chưa sẵn sàng. Vui lòng thử lại.', + preparingDefault: 'Đang chuẩn bị AI mặc định…', title: 'Cấu hình công cụ AI', description: 'Chọn công cụ AI sẽ cung cấp trí tuệ cho Bot của bạn.', optionalDescription: - 'Bước này không bắt buộc. Hãy chọn cách bạn muốn tiếp tục với Agent hiện tại.', + 'AI mặc định đã sẵn sàng. Bạn có thể dùng ngay hoặc đổi cách kết nối.', externalTitle: 'Kết nối Agent từ nền tảng bên ngoài', externalDescription: - 'Kết nối Dify, n8n, Coze hoặc nền tảng khác và thay thế Pipeline của Bot.', + 'Kết nối Dify, n8n, Coze hoặc Agent bên ngoài qua plugin Runner.', ownModelTitle: 'Sử dụng mô hình của riêng tôi', ownModelDescription: 'Thêm nhà cung cấp, sau đó quét hoặc nhập mô hình thủ công để hoàn tất thiết lập.', @@ -1985,7 +1988,7 @@ const viVN = { selectModelTitle: 'Chọn mô hình', selectScannedModelTitle: 'Chọn mô hình', selectScannedModelDescription: - 'Mô hình đã chọn sẽ là mô hình chính của Pipeline mới và Bot sẽ chuyển sang sử dụng mô hình đó.', + 'Mô hình đã chọn sẽ thay thế mô hình chính trong pipeline của bot này.', scanModelMode: 'Quét mô hình', manualModelMode: 'Thêm thủ công', scanningModels: 'Đang quét các mô hình khả dụng…', @@ -2004,11 +2007,12 @@ const viVN = { manualModelOptions: 'Khả năng mô hình tùy chọn', editProvider: 'Chỉnh sửa nhà cung cấp', rescanModels: 'Quét lại mô hình', - moreFeaturesTitle: 'Thêm khả năng cho Agent', + moreFeaturesTitle: 'Dùng cấu hình mặc định', moreFeaturesDescription: - 'Mở bàn làm việc để thêm công cụ, cơ sở tri thức và các khả năng khác cho Agent vừa được tạo tự động.', + 'Tiếp tục dùng Local Agent và mô hình đề xuất. Có thể thêm công cụ và cơ sở tri thức sau.', runnerDescription: 'Chọn Runner cho Agent bên ngoài và cấu hình kết nối.', backToChoices: 'Quay lại các tùy chọn', + backToList: 'Quay lại danh sách', createExternal: 'Tạo và liên kết', finishWithModel: 'Sử dụng mô hình đã chọn và hoàn tất', openWorkbench: 'Mở bàn làm việc', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index b6bf31be8..1ca3b448e 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -2446,12 +2446,16 @@ const zhHans = { logsDescription: '监控机器人活动,确认平台连接是否正常工作。', }, aiEngine: { + defaultModelUnavailable: '暂无可用的默认对话模型,请重试。', + defaultRunnerUnavailable: '本地 Agent 尚未就绪,请重试。', + preparingDefault: '正在准备默认 AI…', title: '选择 AI 引擎', description: '选择驱动机器人智能的 AI 引擎。', - optionalDescription: '这一步可选。选择接下来要如何完善当前 Agent。', + optionalDescription: + '默认 AI 已配置好,你可以直接使用,也可以选择其他接入方式。', externalTitle: '接入外部平台 Agent', externalDescription: - '接入 Dify、n8n、Coze 等平台,并替换当前机器人的流水线。', + '通过运行器插件接入 Dify、n8n、Coze 等外部 Agent 平台。', ownModelTitle: '改成使用自己的模型', ownModelDescription: '添加模型供应商,自动扫描或手动填写模型以快速完成引导。', @@ -2463,7 +2467,7 @@ const zhHans = { selectModelTitle: '选择模型', selectScannedModelTitle: '选择一个模型', selectScannedModelDescription: - '选中的模型将作为新流水线的主模型,机器人会切换到这条流水线。', + '选中的模型将替换当前机器人流水线的主模型。', scanModelMode: '扫描模型', manualModelMode: '手动添加', scanningModels: '正在扫描可用模型…', @@ -2478,11 +2482,12 @@ const zhHans = { manualModelOptions: '可选模型能力', editProvider: '修改供应商', rescanModels: '重新扫描模型', - moreFeaturesTitle: '给现在的 Agent 配置更多功能', + moreFeaturesTitle: '使用默认配置', moreFeaturesDescription: - '进入工作台,为刚刚自动生成的 Agent 添加工具、知识库等能力', + '继续使用本地 Agent 和推荐模型,之后可在流水线中添加工具、知识库等功能。', runnerDescription: '选择外部 Agent 的运行器并完成连接配置。', backToChoices: '返回选项', + backToList: '返回列表', createExternal: '创建并绑定', finishWithModel: '使用所选模型并完成', openWorkbench: '进入工作台', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index adf948532..4203387d6 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -1875,12 +1875,16 @@ const zhHant = { logsDescription: '監控機器人活動,確認平台連接是否正常運作。', }, aiEngine: { + defaultModelUnavailable: '暫無可用的預設對話模型,請重試。', + defaultRunnerUnavailable: '本地 Agent 尚未就緒,請重試。', + preparingDefault: '正在準備預設 AI…', title: '配置 AI 引擎', description: '選擇驅動機器人智慧的 AI 引擎。', - optionalDescription: '這一步可選。選擇接下來要如何完善目前的 Agent。', + optionalDescription: + '預設 AI 已設定好,你可以直接使用,也可以選擇其他接入方式。', externalTitle: '接入外部平台 Agent', externalDescription: - '接入 Dify、n8n、Coze 等平台,並替換目前機器人的流水線。', + '透過執行器外掛接入 Dify、n8n、Coze 等外部 Agent 平台。', ownModelTitle: '改成使用自己的模型', ownModelDescription: '新增模型供應商,自動掃描或手動填寫模型以快速完成引導。', @@ -1892,7 +1896,7 @@ const zhHant = { selectModelTitle: '選擇模型', selectScannedModelTitle: '選擇模型', selectScannedModelDescription: - '所選模型將成為新流水線的主要模型,機器人也會切換到該流水線。', + '選取的模型將取代目前機器人流水線的主模型。', scanModelMode: '自動掃描', manualModelMode: '手動新增', scanningModels: '正在掃描可用模型…', @@ -1906,11 +1910,12 @@ const zhHant = { manualModelOptions: '模型能力(可選)', editProvider: '編輯供應商', rescanModels: '重新掃描模型', - moreFeaturesTitle: '給目前的 Agent 配置更多功能', + moreFeaturesTitle: '使用預設設定', moreFeaturesDescription: - '進入工作台,為剛剛自動產生的 Agent 新增工具、知識庫等能力', + '繼續使用本地 Agent 和推薦模型,之後可在流水線中新增工具、知識庫等功能。', runnerDescription: '選擇外部 Agent 的運行器並完成連接配置。', backToChoices: '返回選項', + backToList: '返回列表', createExternal: '建立並關聯', finishWithModel: '使用所選模型並完成', openWorkbench: '進入工作台', diff --git a/web/tests/e2e/fixtures/langbot-api.ts b/web/tests/e2e/fixtures/langbot-api.ts index 97a26088b..b1fb69ad0 100644 --- a/web/tests/e2e/fixtures/langbot-api.ts +++ b/web/tests/e2e/fixtures/langbot-api.ts @@ -301,7 +301,7 @@ function makePipeline( }; } -function pipelineMetadata(withRunnerToolSelector = false) { +export function pipelineMetadata(withRunnerToolSelector = false) { const runnerId = 'plugin:langbot-team/LocalAgent/default'; return { configs: [ diff --git a/web/tests/e2e/wizard-default-agent.spec.ts b/web/tests/e2e/wizard-default-agent.spec.ts new file mode 100644 index 000000000..396dac0e2 --- /dev/null +++ b/web/tests/e2e/wizard-default-agent.spec.ts @@ -0,0 +1,542 @@ +import { expect, test } from '@playwright/test'; +import { + installLangBotApiMocks, + pipelineMetadata, +} from './fixtures/langbot-api'; + +const runnerId = 'plugin:langbot-team/LocalAgent/default'; +const ok = (data: unknown) => JSON.stringify({ code: 0, message: 'ok', data }); + +for (const failsFirst of [false, true]) { + test(`prepares Local Agent before enabling the bot${failsFirst ? ' after an installation retry' : ''}`, async ({ + page, + }) => { + await installLangBotApiMocks(page, { + authenticated: true, + withAdapterEvents: true, + }); + let installed = false; + let installCount = 0; + let modelCount = 0; + let messageReceived = false; + const order: string[] = []; + const configs: Record[] = []; + await page.route('**/api/v1/pipelines/_/metadata', async (route) => { + const metadata = pipelineMetadata(); + if (!installed) metadata.configs[0].stages = []; + else { + const model = metadata.configs[0].stages.find( + (stage) => stage.name === runnerId, + )!.config[0]; + model.default = { primary: '', fallbacks: [] } as never; + } + await route.fulfill({ + contentType: 'application/json', + body: ok(metadata), + }); + }); + await page.route( + '**/api/v1/marketplace/plugins/langbot-team/LocalAgent', + async (route) => { + await route.fulfill({ + contentType: 'application/json', + body: ok({ + plugin: { + author: 'langbot-team', + name: 'LocalAgent', + label: { en_US: 'Local Agent' }, + latest_version: '0.1.7', + }, + }), + }); + }, + ); + await page.route('**/api/v1/plugins/install/marketplace', async (route) => { + installCount += 1; + expect(route.request().postDataJSON()).toEqual({ + plugin_author: 'langbot-team', + plugin_name: 'LocalAgent', + plugin_version: '0.1.7', + }); + order.push('install'); + await route.fulfill({ + contentType: 'application/json', + body: ok({ task_id: installCount }), + }); + }); + await page.route('**/api/v1/system/tasks/*', async (route) => { + const failed = failsFirst && installCount === 1; + installed = !failed; + await route.fulfill({ + contentType: 'application/json', + body: ok({ + runtime: { + done: true, + exception: failed ? 'Download failed: connection reset' : null, + }, + }), + }); + }); + await page.route( + '**/api/v1/system/wizard/recommended-model', + async (route) => { + modelCount += 1; + order.push('model'); + await route.fulfill({ + contentType: 'application/json', + body: ok({ uuid: 'recommended-model', name: 'Recommended' }), + }); + }, + ); + await page.route('**/api/v1/platform/bots/*/logs', async (route) => { + await route.fulfill({ + contentType: 'application/json', + body: ok({ + logs: messageReceived + ? [ + { + seq_id: 1, + timestamp: Date.now() / 1000, + level: 'info', + text: 'Received message', + images: [], + message_session_id: 'person_123', + }, + ] + : [], + total_count: messageReceived ? 1 : 0, + }), + }); + }); + page.on('request', (request) => { + const path = new URL(request.url()).pathname; + if ( + request.method() === 'PUT' && + path === '/api/v1/pipelines/pipeline-1' + ) { + configs.push(request.postDataJSON().config); + order.push('configure'); + } + if ( + request.method() === 'PUT' && + path === '/api/v1/platform/bots/bot-1' && + request.postDataJSON().enable + ) + order.push('enable'); + }); + await page.goto('/wizard'); + await page.getByText('Playwright Adapter', { exact: true }).click(); + await page.getByRole('button', { name: 'Confirm, Create Bot' }).click(); + await page.getByRole('button', { name: 'Save & Enable Bot' }).click(); + if (failsFirst) { + await expect( + page.getByText(/Download failed: connection reset/), + ).toBeVisible(); + expect(order).toEqual(['install']); + await expect(page.getByRole('button', { name: 'Next' })).toBeDisabled(); + await page.getByRole('button', { name: 'Save & Enable Bot' }).click(); + } + await expect( + page.getByRole('button', { name: 'Re-save Configuration' }), + ).toBeVisible(); + expect(order.slice(-4)).toEqual([ + 'install', + 'model', + 'configure', + 'enable', + ]); + expect(configs[0].ai.runner.id).toBe(runnerId); + expect(configs[0].ai.runner_config[runnerId].model.primary).toBe( + 'recommended-model', + ); + await page.getByRole('button', { name: 'Re-save Configuration' }).click(); + await expect.poll(() => configs.length).toBe(2); + expect(installCount).toBe(failsFirst ? 2 : 1); + expect(modelCount).toBe(1); + messageReceived = true; + await page.getByRole('button', { name: 'Next' }).click(); + await expect( + page.getByText('Use the default setup', { exact: true }), + ).toBeVisible(); + await expect(page.getByText('Local Agent', { exact: true })).toHaveCount(0); + await page.getByRole('button', { name: 'Done', exact: true }).click(); + await expect( + page.getByRole('button', { name: 'Back to Workbench' }), + ).toBeVisible(); + expect(configs.length).toBe(2); + }); +} + +test('uses a custom model in the existing Local Agent pipeline', async ({ + page, +}) => { + await installLangBotApiMocks(page, { + authenticated: true, + withAdapterEvents: true, + }); + await page.route('**/api/v1/platform/bots/*/logs', async (route) => { + await route.fulfill({ + json: { + code: 0, + data: { + logs: [ + { + seq_id: 1, + timestamp: Date.now() / 1000, + level: 'info', + text: 'Received message', + images: [], + message_session_id: 'person_123', + }, + ], + total_count: 1, + }, + }, + }); + }); + await page.route('**/api/v1/provider/**', async (route) => { + const path = new URL(route.request().url()).pathname; + if (path.endsWith('/requesters')) + return route.fulfill({ + json: { + code: 0, + data: { + requesters: [ + { + name: 'openai', + label: { en_US: 'OpenAI API' }, + description: { en_US: '' }, + spec: { + provider_category: 'manufacturer', + support_type: ['llm'], + config: [ + { name: 'base_url', default: 'https://api.openai.com/v1' }, + ], + }, + }, + ], + }, + }, + }); + if (path.endsWith('/providers') && route.request().method() === 'POST') + return route.fulfill({ + json: { code: 0, data: { uuid: 'own-provider' } }, + }); + if (path.endsWith('/scan-models')) + return route.fulfill({ json: { code: 0, data: { models: [] } } }); + if (path.endsWith('/models/llm') && route.request().method() === 'POST') + return route.fulfill({ json: { code: 0, data: { uuid: 'own-model' } } }); + await route.fallback(); + }); + await page.goto('/wizard'); + await page.getByText('Playwright Adapter', { exact: true }).click(); + await page.getByRole('button', { name: 'Confirm, Create Bot' }).click(); + await page.getByRole('button', { name: 'Save & Enable Bot' }).click(); + await page.getByRole('button', { name: 'Next' }).click(); + await page.getByText('Use My Own Model', { exact: true }).click(); + await expect( + page.getByRole('button', { name: 'Use Selected Model & Finish' }), + ).toBeDisabled(); + await page.locator('input[name="name"]').fill('Wizard custom provider'); + await page + .getByRole('button', { name: 'Select Provider Type', exact: true }) + .click(); + await page.getByRole('button', { name: /OpenAI API/ }).click(); + await page.locator('input[name="api_key"]').fill('fixture-key'); + await page.getByRole('button', { name: 'Save', exact: true }).click(); + await page.getByLabel('Model ID', { exact: false }).fill('test-model'); + const update = page.waitForRequest( + (request) => + request.method() === 'PUT' && + new URL(request.url()).pathname === '/api/v1/pipelines/pipeline-1', + ); + await page + .getByRole('button', { name: 'Use Selected Model & Finish' }) + .click(); + const config = (await update).postDataJSON().config; + expect(config.ai.runner.id).toBe(runnerId); + expect(config.ai.runner_config[runnerId].model.primary).toBe('own-model'); + await expect( + page.getByRole('button', { name: 'Back to Workbench' }), + ).toBeVisible(); +}); + +test('only labels debugging Runners even when the marketplace is offline', async ({ + page, +}) => { + await installLangBotApiMocks(page, { authenticated: true }); + await page.route('**/api/v1/system/info', async (route) => { + await route.fulfill({ + json: { + code: 0, + data: { + version: 'fixture', + edition: 'community', + cloud_service_url: 'https://space.langbot.app', + enable_marketplace: true, + wizard_status: 'none', + wizard_progress: { + step: 2, + created_bot_uuid: 'bot-1', + created_pipeline_uuid: 'pipeline-1', + selected_adapter: 'test', + bot_saved: true, + message_received: true, + }, + }, + }, + }); + }); + const plugins = [ + { name: 'local', label: 'Local Runner', debug: false }, + { name: 'debug', label: 'Debug Runner', debug: true }, + ]; + await page.route('**/api/v1/plugins', async (route) => { + await route.fulfill({ + json: { + code: 0, + data: { + plugins: plugins.map((plugin) => ({ + manifest: { + manifest: { + metadata: { + author: 'qa', + name: plugin.name, + description: { en_US: `${plugin.label} description` }, + }, + }, + }, + debug: plugin.debug, + })), + }, + }, + }); + }); + await page.route('**/api/v1/pipelines/_/metadata', async (route) => { + const metadata = pipelineMetadata(); + const selector = metadata.configs[0].stages[0].config[0]; + if (!('options' in selector)) + throw new Error('Missing Runner options in fixture'); + const options = selector.options; + for (const plugin of plugins) { + options.push({ + name: `plugin:qa/${plugin.name}/default`, + label: { en_US: plugin.label, zh_Hans: plugin.label }, + }); + } + await route.fulfill({ json: { code: 0, data: metadata } }); + }); + await page.route('**/api/v1/marketplace/**', async (route) => { + await route.fulfill({ + status: 503, + json: { code: 503, msg: 'Marketplace offline' }, + }); + }); + await page.goto('/wizard'); + await page + .getByRole('radio', { name: 'Connect an External Agent', exact: true }) + .click(); + const local = page + .locator('[data-slot="card"]') + .filter({ hasText: 'Local Runner' }); + const debug = page + .locator('[data-slot="card"]') + .filter({ hasText: 'Debug Runner' }); + await expect(local.getByText('Installed', { exact: true })).toHaveCount(0); + await expect(debug.getByText('Debugging', { exact: true })).toBeVisible(); + await expect(debug.getByText('Installed', { exact: true })).toHaveCount(0); + await debug.getByRole('button', { name: 'Use This Runner' }).click(); + await expect( + page + .locator('[data-slot="card"]') + .filter({ hasText: 'Debug Runner' }) + .getByText('Debugging', { exact: true }), + ).toBeVisible(); +}); + +test('shows installation progress inside the matching card button and recovers after failure', async ({ + page, +}) => { + await page.emulateMedia({ reducedMotion: 'reduce' }); + await installLangBotApiMocks(page, { authenticated: true }); + await page.route('**/api/v1/system/info', async (route) => { + await route.fulfill({ + json: { + code: 0, + data: { + version: 'fixture', + edition: 'community', + cloud_service_url: 'https://space.langbot.app', + enable_marketplace: true, + wizard_status: 'none', + wizard_progress: { + step: 2, + created_bot_uuid: 'bot-1', + created_pipeline_uuid: 'pipeline-1', + selected_adapter: 'test', + bot_saved: true, + message_received: true, + }, + }, + }, + }); + }); + const plugin = (name: string) => ({ + name, + author: 'qa', + label: { en_US: name }, + description: { en_US: 'Test Runner' }, + components: { Runner: 1 }, + runner_usages: ['agent'], + latest_version: '1.0.0', + install_count: 0, + type: 'plugin', + }); + await page.route( + '**/api/v1/marketplace/extensions/search**', + async (route) => { + await route.fulfill({ + json: { + code: 0, + data: { + extensions: [plugin('ProgressRunner'), plugin('OtherRunner')], + total: 2, + }, + }, + }); + }, + ); + let attempt = 0; + let phase: 'download' | 'deps' | 'failed' | 'done' = 'download'; + let registered = false; + await page.route('**/api/v1/plugins/install/marketplace', async (route) => { + attempt += 1; + phase = 'download'; + await route.fulfill({ json: { code: 0, data: { task_id: attempt } } }); + }); + await page.route('**/api/v1/system/tasks/*', async (route) => { + await route.fulfill({ + json: { + code: 0, + data: { + id: attempt, + name: 'plugin-install-marketplace', + label: 'ProgressRunner', + runtime: { + done: phase === 'done' || phase === 'failed', + exception: + phase === 'failed' ? 'Dependency installation failed' : null, + }, + task_context: { + current_action: + phase === 'download' + ? 'Downloading package' + : 'Installing dependencies', + metadata: { progress_percent: phase === 'download' ? 24 : 64 }, + }, + }, + }, + }); + }); + await page.route('**/api/v1/pipelines/_/metadata', async (route) => { + const metadata = pipelineMetadata(); + const selector = metadata.configs[0].stages[0].config[0]; + if ('options' in selector) + selector.options.push({ + name: 'plugin:qa/ExistingRunner/default', + label: { en_US: 'ExistingRunner', zh_Hans: 'ExistingRunner' }, + }); + metadata.configs[0].stages.push({ + name: 'plugin:qa/ExistingRunner/default', + label: { en_US: 'ExistingRunner', zh_Hans: 'ExistingRunner' }, + config: [ + { + name: 'api-key', + label: { en_US: 'API Key', zh_Hans: 'API Key' }, + type: 'string', + required: true, + default: '', + }, + ], + } as (typeof metadata.configs)[0]['stages'][number]); + if (registered && 'options' in selector) + selector.options.push({ + name: 'plugin:qa/ProgressRunner/default', + label: { en_US: 'ProgressRunner', zh_Hans: 'ProgressRunner' }, + }); + await route.fulfill({ json: { code: 0, data: metadata } }); + }); + await page.goto('/wizard'); + await page + .getByRole('radio', { name: 'Connect an External Agent', exact: true }) + .click(); + const card = page + .locator('[data-slot="card"]') + .filter({ has: page.getByText('ProgressRunner', { exact: true }) }); + const other = page + .locator('[data-slot="card"]') + .filter({ has: page.getByText('OtherRunner', { exact: true }) }); + const back = page.getByRole('button', { + name: /Back to (options|list)/, + exact: true, + }); + await expect(back).toBeVisible(); + const pickerBounds = await back.boundingBox(); + await page + .locator('[data-slot="card"]') + .filter({ has: page.getByText('ExistingRunner', { exact: true }) }) + .getByRole('button', { name: 'Use This Runner' }) + .click(); + await expect(page.getByText('ExistingRunner Configuration')).toBeVisible(); + await expect + .poll(async () => (await back.boundingBox())?.x) + .toBeCloseTo(pickerBounds!.x, 0); + await expect + .poll(async () => (await back.boundingBox())?.y) + .toBeCloseTo(pickerBounds!.y, 0); + await page.getByRole('textbox').fill('unsaved-test-key'); + await page + .locator('[data-slot="card"]') + .filter({ has: page.getByText('ExistingRunner', { exact: true }) }) + .getByRole('button', { name: 'Use This Runner' }) + .click(); + await expect(page.getByRole('textbox')).toHaveValue('unsaved-test-key'); + await card + .getByRole('button', { name: 'Install & Continue', exact: true }) + .click(); + await expect( + card.getByRole('button', { name: /Downloading.*24%/ }), + ).toBeDisabled(); + await expect(page.getByRole('progressbar')).toHaveCount(0); + await expect( + other.getByRole('button', { name: 'Install & Continue', exact: true }), + ).toBeDisabled(); + phase = 'deps'; + await expect( + card.getByRole('button', { name: /Installing Dependencies.*64%/ }), + ).toBeDisabled(); + phase = 'failed'; + await card + .getByRole('button', { name: 'Failed · Retry', exact: true }) + .click(); + await expect( + card.getByRole('button', { name: /Downloading.*24%/ }), + ).toBeDisabled(); + phase = 'done'; + await expect( + card.getByRole('button', { + name: /Starting and refreshing components.*95%/, + }), + ).toBeDisabled(); + registered = true; + await expect( + card.getByRole('button', { name: 'Use This Runner' }), + ).toBeEnabled(); + await expect( + card.getByRole('button', { name: 'Use This Runner' }), + ).toHaveAttribute('aria-pressed', 'true'); + await expect( + other.getByRole('button', { name: 'Install & Continue' }), + ).toBeEnabled(); + expect(attempt).toBe(2); +}); diff --git a/web/tests/e2e/wizard-platform-regressions.spec.ts b/web/tests/e2e/wizard-platform-regressions.spec.ts index 8997fc9ef..9299d690e 100644 --- a/web/tests/e2e/wizard-platform-regressions.spec.ts +++ b/web/tests/e2e/wizard-platform-regressions.spec.ts @@ -509,6 +509,17 @@ test.describe('wizard and QR platform regressions', () => { name: 'ai', label: { en_US: 'AI Feature', zh_Hans: 'AI 能力' }, stages: [ + { + name: 'plugin:langbot-team/LocalAgent/default', + config: [ + { + name: 'model', + type: 'model-fallback-selector', + required: true, + default: { primary: 'llm-valid', fallbacks: [] }, + }, + ], + }, { name: 'runner', label: { en_US: 'Runtime', zh_Hans: '运行方式' }, @@ -562,7 +573,15 @@ test.describe('wizard and QR platform regressions', () => { await expect(page.getByRole('button', { name: 'Next' })).toBeDisabled(); messageReceived = true; await page.getByRole('button', { name: 'Next' }).click(); - await page.getByText('External Runner', { exact: true }).click(); + await expect( + page.getByText('Use the default setup', { exact: true }), + ).toBeVisible(); + await page.getByText('Connect an External Agent', { exact: true }).click(); + await page + .locator('[data-slot="card"]') + .filter({ has: page.getByText('External Runner', { exact: true }) }) + .getByRole('button', { name: 'Use This Runner' }) + .click(); const deployButton = page.getByRole('button', { name: 'Create & Deploy' }); await expect(deployButton).toBeDisabled(); @@ -571,21 +590,14 @@ test.describe('wizard and QR platform regressions', () => { const bindingRequest = page.waitForRequest( (request) => request.method() === 'PUT' && - new URL(request.url()).pathname === '/api/v1/platform/bots/bot-1', + new URL(request.url()).pathname === '/api/v1/pipelines/pipeline-1', ); await deployButton.click(); const body = (await bindingRequest).postDataJSON(); - expect(body.event_bindings).toEqual([ - { - event_pattern: 'message.received', - target_type: 'pipeline', - target_uuid: 'pipeline-1', - filters: [], - priority: 0, - enabled: true, - description: '', - }, - ]); + expect(body.config.ai.runner.id).toBe('external-runner'); + expect(body.config.ai.runner_config['external-runner']['api-key']).toBe( + 'app-real-api-key', + ); await expect( page.getByRole('button', { name: 'Back to Workbench' }), ).toBeVisible(); diff --git a/web/tests/unit/wizard-http-bot.test.mjs b/web/tests/unit/wizard-http-bot.test.mjs index 656e2f8a8..43dfb1ce2 100644 --- a/web/tests/unit/wizard-http-bot.test.mjs +++ b/web/tests/unit/wizard-http-bot.test.mjs @@ -28,6 +28,7 @@ function loadWizardUtils() { } const { + LOCAL_AGENT_RUNNER_ID, configureLocalAgentPrimaryModel, ensureHttpBotSigningSecret, findDefaultPipeline, @@ -81,23 +82,31 @@ test('configures the selected model as the Local Agent primary model', () => { const config = { trigger: { prefix: '!' }, ai: { - runner: { runner: 'plugin:external', timeout: 30 }, - 'local-agent': { - model: { primary: 'old-model', fallbacks: ['fallback-model'] }, - tools: { enabled: true }, + runner: { id: 'plugin:external', timeout: 30 }, + runner_config: { + [LOCAL_AGENT_RUNNER_ID]: { + model: { primary: 'old-model', fallbacks: ['fallback-model'] }, + tools: { enabled: true }, + }, }, }, }; const updated = configureLocalAgentPrimaryModel(config, 'selected-model'); - assert.equal(updated.ai.runner.runner, 'local-agent'); + assert.equal(updated.ai.runner.id, LOCAL_AGENT_RUNNER_ID); assert.equal(updated.ai.runner.timeout, 30); - assert.equal(updated.ai['local-agent'].model.primary, 'selected-model'); - assert.deepEqual(updated.ai['local-agent'].model.fallbacks, [ - 'fallback-model', - ]); - assert.deepEqual(updated.ai['local-agent'].tools, { enabled: true }); + assert.equal( + updated.ai.runner_config[LOCAL_AGENT_RUNNER_ID].model.primary, + 'selected-model', + ); + assert.deepEqual( + updated.ai.runner_config[LOCAL_AGENT_RUNNER_ID].model.fallbacks, + ['fallback-model'], + ); + assert.deepEqual(updated.ai.runner_config[LOCAL_AGENT_RUNNER_ID].tools, { + enabled: true, + }); assert.deepEqual(updated.trigger, { prefix: '!' }); }); diff --git a/web/tests/unit/wizard-page-bot.test.mjs b/web/tests/unit/wizard-page-bot.test.mjs index 49ede74fc..aea775a7f 100644 --- a/web/tests/unit/wizard-page-bot.test.mjs +++ b/web/tests/unit/wizard-page-bot.test.mjs @@ -89,13 +89,10 @@ test('requires the selected Runner mandatory configuration before finishing', () wizardSource, /isRequiredRunnerConfigComplete\(selectedRunnerConfigItems, runnerConfig\)/, ); + assert.match(wizardSource, /aiChoice === 'external'/); assert.match( wizardSource, - /return selectedRunner !== null && isRunnerConfigComplete/, - ); - assert.match( - wizardSource, - /!selectedRunner \|\|[\s\S]*?!isRunnerConfigComplete \|\|[\s\S]*?!createdBotUuid/, + /!createdBotUuid \|\| !createdPipelineUuid \|\| !canProceed\(\)/, ); });