From b0566f4c9d81f22e11e2ef949fde61f83910e94e Mon Sep 17 00:00:00 2001 From: langbot-dev Date: Fri, 14 Aug 2026 01:10:00 +0800 Subject: [PATCH] feat(wizard): rework agent onboarding flow --- .../pkg/api/http/controller/groups/system.py | 14 + src/langbot/pkg/api/http/service/space.py | 72 +++ src/langbot/pkg/entity/dto/space_model.py | 7 + .../api/service/test_space_service.py | 85 ++++ .../bot-log/view/BotLogListComponent.tsx | 11 + web/src/app/infra/entities/api/index.ts | 2 + web/src/app/infra/http/BackendClient.ts | 9 + web/src/app/wizard/page.tsx | 430 +++++++++++------- web/src/i18n/locales/en-US.ts | 21 + web/src/i18n/locales/ja-JP.ts | 20 + web/src/i18n/locales/zh-Hans.ts | 15 + 11 files changed, 523 insertions(+), 163 deletions(-) diff --git a/src/langbot/pkg/api/http/controller/groups/system.py b/src/langbot/pkg/api/http/controller/groups/system.py index 330799335..9ae0e0bf2 100644 --- a/src/langbot/pkg/api/http/controller/groups/system.py +++ b/src/langbot/pkg/api/http/controller/groups/system.py @@ -206,6 +206,20 @@ class SystemRouterGroup(group.RouterGroup): return self.success(data={}) + @self.route( + '/wizard/recommended-model', + methods=['GET'], + auth_type=group.AuthType.USER_TOKEN, + permission=Permission.RESOURCE_MANAGE, + ) + async def _(request_context: RequestContext) -> str: + """Resolve Space's best available chat model to this Workspace.""" + try: + model = await self.ap.space_service.get_recommended_chat_model(request_context) + except ValueError as exc: + return self.http_status(503, -1, str(exc)) + return self.success(data=model) + @self.route( '/tasks', methods=['GET'], diff --git a/src/langbot/pkg/api/http/service/space.py b/src/langbot/pkg/api/http/service/space.py index a0fc7dfb8..8105fb41e 100644 --- a/src/langbot/pkg/api/http/service/space.py +++ b/src/langbot/pkg/api/http/service/space.py @@ -11,6 +11,9 @@ import sqlalchemy from ....core import app from ....entity.persistence import user from ....entity.dto.space_model import SpaceModel +from ....entity.dto.space_model import SpaceModelSelection +from ....entity.persistence import model as persistence_model +from ....cloud.model_catalog import LANGBOT_MODELS_PROVIDER_REQUESTER _CREDITS_CACHE_TTL_SECONDS = 60 @@ -238,3 +241,72 @@ class SpaceService: raise ValueError(f'Failed to get models: {data.get("msg")}') models_data = data.get('data', {}).get('models', []) return [SpaceModel.model_validate(model_dict) for model_dict in models_data] + + async def get_model_selection(self, category: str) -> typing.List[SpaceModelSelection]: + """Return Space models in the availability-ranked selection order.""" + space_url = self._get_space_config()['url'] + session = httpclient.get_session() + async with session.get( + f'{space_url}/api/v1/models/selection', + params={'category': category}, + ) as response: + if response.status != 200: + error = await httpclient.read_text_limited(response) + raise ValueError(f'Failed to get model selection: {error}') + payload = await httpclient.read_json_limited(response) + if payload.get('code') != 0: + raise ValueError(f'Failed to get model selection: {payload.get("msg")}') + + data = payload.get('data', []) + if isinstance(data, dict): + data = data.get('models', data.get('items', [])) + if not isinstance(data, list): + raise ValueError('Failed to get model selection: invalid response') + return [SpaceModelSelection.model_validate(model) for model in data] + + async def get_recommended_chat_model(self, context: typing.Any) -> dict: + """Resolve Space's first ranked chat model to a local Workspace model.""" + selection = await self.get_model_selection('chat') + if not selection: + raise ValueError('No recommended chat model is available') + recommended = selection[0] + + async def find_local_model(): + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_model.LLMModel) + .join( + persistence_model.ModelProvider, + sqlalchemy.and_( + persistence_model.ModelProvider.workspace_uuid + == persistence_model.LLMModel.workspace_uuid, + persistence_model.ModelProvider.uuid == persistence_model.LLMModel.provider_uuid, + ), + ) + .where( + persistence_model.LLMModel.workspace_uuid == context.workspace_uuid, + persistence_model.ModelProvider.requester == LANGBOT_MODELS_PROVIDER_REQUESTER, + sqlalchemy.or_( + persistence_model.LLMModel.uuid == recommended.uuid, + persistence_model.LLMModel.name == recommended.model_id, + ), + ) + ) + return result.first() + + local_model = await find_local_model() + if local_model is None: + # OSS synchronizes the public catalog locally. Refresh once in case + # the recommendation was published after this process started. + from ..context import ExecutionContext + + try: + await self.ap.model_mgr.sync_new_models_from_space( + ExecutionContext.from_request(context) + ) + except Exception: + pass + local_model = await find_local_model() + + if local_model is None: + raise ValueError('Recommended chat model is not available in this Workspace') + return {'uuid': local_model.uuid, 'name': local_model.name} diff --git a/src/langbot/pkg/entity/dto/space_model.py b/src/langbot/pkg/entity/dto/space_model.py index 62b9f2b09..4ecd598d7 100644 --- a/src/langbot/pkg/entity/dto/space_model.py +++ b/src/langbot/pkg/entity/dto/space_model.py @@ -47,3 +47,10 @@ class SpaceModel(pydantic.BaseModel): status: str created_at: str | None = None updated_at: str | None = None + + +class SpaceModelSelection(pydantic.BaseModel): + """Minimal model identity returned by the ranked selection endpoint.""" + + uuid: str + model_id: str diff --git a/tests/unit_tests/api/service/test_space_service.py b/tests/unit_tests/api/service/test_space_service.py index f16b609a9..ef21bd1de 100644 --- a/tests/unit_tests/api/service/test_space_service.py +++ b/tests/unit_tests/api/service/test_space_service.py @@ -820,6 +820,91 @@ class TestSpaceServiceGetModels: await service.get_models() +class TestSpaceServiceGetModelSelection: + """Tests for availability-ranked model selection.""" + + @pytest.mark.parametrize('use_envelope', [False, True]) + async def test_preserves_selection_order_and_category_query(self, use_envelope): + ap = SimpleNamespace(instance_config=SimpleNamespace(data={})) + service = SpaceService(ap) + models = [ + { + 'uuid': 'best-model', + 'model_id': 'best-chat-model', + 'provider': 'provider-1', + 'category': 'chat', + 'status': 'active', + }, + { + 'uuid': 'fallback-model', + 'model_id': 'fallback-chat-model', + 'provider': 'provider-2', + 'category': 'chat', + 'status': 'active', + }, + ] + payload = {'code': 0, 'data': {'models': models} if use_envelope else models} + mock_response = MagicMock(status=200) + + with ( + patch('langbot.pkg.api.http.service.space.httpclient.get_session') as get_session, + patch( + 'langbot.pkg.api.http.service.space.httpclient.read_json_limited', + new=AsyncMock(return_value=payload), + ), + ): + session = MagicMock() + session.get.return_value.__aenter__ = AsyncMock(return_value=mock_response) + session.get.return_value.__aexit__ = AsyncMock(return_value=None) + get_session.return_value = session + + result = await service.get_model_selection('chat') + + assert [model.uuid for model in result] == ['best-model', 'fallback-model'] + session.get.assert_called_once_with( + 'https://space.langbot.app/api/v1/models/selection', + params={'category': 'chat'}, + ) + + async def test_recommended_model_uses_first_selection_and_refreshes_once(self): + local_model = SimpleNamespace(uuid='local-model-uuid', name='best-chat-model') + persistence = SimpleNamespace( + execute_async=AsyncMock( + side_effect=[ + _create_mock_result(first_item=None), + _create_mock_result(first_item=local_model), + ] + ) + ) + model_mgr = SimpleNamespace(sync_new_models_from_space=AsyncMock()) + ap = SimpleNamespace( + instance_config=SimpleNamespace(data={}), + persistence_mgr=persistence, + model_mgr=model_mgr, + ) + service = SpaceService(ap) + service.get_model_selection = AsyncMock( + return_value=[ + SimpleNamespace(uuid='best-upstream-uuid', model_id='best-chat-model'), + SimpleNamespace(uuid='fallback-upstream-uuid', model_id='fallback-chat-model'), + ] + ) + context = SimpleNamespace( + instance_uuid='instance', + workspace_uuid='workspace', + placement_generation=1, + principal=SimpleNamespace(), + entitlement_revision=0, + ) + + result = await service.get_recommended_chat_model(context) + + assert result == {'uuid': 'local-model-uuid', 'name': 'best-chat-model'} + service.get_model_selection.assert_awaited_once_with('chat') + model_mgr.sync_new_models_from_space.assert_awaited_once() + assert persistence.execute_async.await_count == 2 + + class TestSpaceServiceCreditsCache: """Tests for credits cache behavior.""" diff --git a/web/src/app/home/bots/components/bot-log/view/BotLogListComponent.tsx b/web/src/app/home/bots/components/bot-log/view/BotLogListComponent.tsx index aacc9982b..00de91865 100644 --- a/web/src/app/home/bots/components/bot-log/view/BotLogListComponent.tsx +++ b/web/src/app/home/bots/components/bot-log/view/BotLogListComponent.tsx @@ -20,6 +20,7 @@ export function BotLogListComponent({ autoExpandImages = false, hideDetailedLogsLink = false, hideToolbar = false, + onMessageReceived, }: { botId: string; /** When true, log entries with images are rendered expanded by default */ @@ -28,6 +29,8 @@ export function BotLogListComponent({ hideDetailedLogsLink?: boolean; /** When true, hides the entire toolbar (auto-refresh, level filter, detailed logs link) */ hideToolbar?: boolean; + /** Called after an inbound person/group message appears in the bot log. */ + onMessageReceived?: () => void; }) { const { t } = useTranslation(); const navigate = useNavigate(); @@ -41,6 +44,8 @@ export function BotLogListComponent({ ]); const listContainerRef = useRef(null); const botLogListRef = useRef(botLogList); + const onMessageReceivedRef = useRef(onMessageReceived); + onMessageReceivedRef.current = onMessageReceived; const logLevels = [ { value: 'error', label: 'ERROR' }, @@ -108,6 +113,9 @@ export function BotLogListComponent({ manager.subscribeLogPush(handleBotLogPush); manager.loadFirstPage().then((response) => { setBotLogList(response.reverse()); + if (response.some((log) => Boolean(log.message_session_id))) { + onMessageReceivedRef.current?.(); + } }); listenScroll(); } @@ -138,6 +146,9 @@ export function BotLogListComponent({ function handleBotLogPush(response: BotLog[]) { setBotLogList(response.reverse()); + if (response.some((log) => Boolean(log.message_session_id))) { + onMessageReceivedRef.current?.(); + } } const handleScroll = useCallback( diff --git a/web/src/app/infra/entities/api/index.ts b/web/src/app/infra/entities/api/index.ts index 0eef6543c..7c66f6be1 100644 --- a/web/src/app/infra/entities/api/index.ts +++ b/web/src/app/infra/entities/api/index.ts @@ -363,7 +363,9 @@ export interface WizardProgress { step: number; selected_adapter: string | null; created_bot_uuid: string | null; + created_pipeline_uuid?: string | null; bot_saved: boolean; + message_received?: boolean; selected_runner: string | null; } diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts index 1bfe1fcf2..42825987c 100644 --- a/web/src/app/infra/http/BackendClient.ts +++ b/web/src/app/infra/http/BackendClient.ts @@ -1046,12 +1046,21 @@ export class BackendClient extends BaseHttpClient { step: number; selected_adapter: string | null; created_bot_uuid: string | null; + created_pipeline_uuid?: string | null; bot_saved: boolean; + message_received?: boolean; selected_runner: string | null; }): Promise { return this.put('/api/v1/system/wizard/progress', progress); } + public getWizardRecommendedModel(): Promise<{ + uuid: string; + name: string; + }> { + return this.get('/api/v1/system/wizard/recommended-model'); + } + public getAsyncTasks(params?: { type?: string; kind?: string; diff --git a/web/src/app/wizard/page.tsx b/web/src/app/wizard/page.tsx index eb533f118..36a7bc950 100644 --- a/web/src/app/wizard/page.tsx +++ b/web/src/app/wizard/page.tsx @@ -8,10 +8,12 @@ import { ArrowRight, Check, Sparkles, - PartyPopper, Loader2, X, ExternalLink, + Cable, + Settings2, + Blocks, } from 'lucide-react'; import { httpClient } from '@/app/infra/http/HttpClient'; @@ -21,12 +23,7 @@ import { bootstrapWorkspaceSession, initializeSystemInfo, } 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, @@ -71,7 +68,7 @@ import { // Types // --------------------------------------------------------------------------- -const TOTAL_STEPS = 4; +const TOTAL_STEPS = 3; // --------------------------------------------------------------------------- // Main Wizard Page (full-screen, no sidebar) @@ -93,6 +90,9 @@ export default function WizardPage() { ); const [runnerConfig, setRunnerConfig] = useState>({}); const [createdBotUuid, setCreatedBotUuid] = useState(null); + const [createdPipelineUuid, setCreatedPipelineUuid] = useState( + null, + ); const [webhookUrl, setWebhookUrl] = useState(''); const [extraWebhookUrl, setExtraWebhookUrl] = useState(''); @@ -106,6 +106,10 @@ export default function WizardPage() { const [isSubmitting, setIsSubmitting] = useState(false); const [isSavingBot, setIsSavingBot] = useState(false); const [botSaved, setBotSaved] = useState(false); + const [messageReceived, setMessageReceived] = useState(false); + const [aiChoice, setAiChoice] = useState< + 'external' | 'own-model' | 'more-features' | null + >(null); // ---- Helper: persist wizard progress to backend (fire-and-forget) ---- const saveProgress = useCallback( @@ -114,14 +118,25 @@ export default function WizardPage() { step: overrides.step ?? currentStep, selected_adapter: overrides.selected_adapter ?? selectedAdapter, created_bot_uuid: overrides.created_bot_uuid ?? createdBotUuid, + created_pipeline_uuid: + overrides.created_pipeline_uuid ?? createdPipelineUuid, bot_saved: overrides.bot_saved ?? botSaved, + message_received: overrides.message_received ?? messageReceived, selected_runner: overrides.selected_runner ?? selectedRunner, }; httpClient.saveWizardProgress(progress).catch((err) => { console.error('Failed to save wizard progress', err); }); }, - [currentStep, selectedAdapter, createdBotUuid, botSaved, selectedRunner], + [ + currentStep, + selectedAdapter, + createdBotUuid, + createdPipelineUuid, + botSaved, + messageReceived, + selectedRunner, + ], ); // ---- Fetch remote data & restore progress ---- @@ -159,7 +174,9 @@ export default function WizardPage() { setSelectedAdapter(progress.selected_adapter); setCreatedBotUuid(progress.created_bot_uuid); + setCreatedPipelineUuid(progress.created_pipeline_uuid ?? null); setBotSaved(progress.bot_saved ?? false); + setMessageReceived(progress.message_received ?? false); setSelectedRunner(progress.selected_runner); // Restore bot name from fetched bot data @@ -183,7 +200,9 @@ export default function WizardPage() { step: 0, selected_adapter: null, created_bot_uuid: null, + created_pipeline_uuid: null, bot_saved: false, + message_received: false, selected_runner: null, }) .catch(() => {}); @@ -211,7 +230,9 @@ export default function WizardPage() { const runnerOptions = useMemo(() => { if (!runnerStage) return []; const runnerField = runnerStage.config.find((c) => c.name === 'runner'); - return runnerField?.options ?? []; + return (runnerField?.options ?? []).filter( + (option) => option.name !== 'local-agent', + ); }, [runnerStage]); const selectedRunnerConfigStage: PipelineConfigStage | undefined = @@ -285,13 +306,20 @@ export default function WizardPage() { case 0: return selectedAdapter !== null; case 1: - return createdBotUuid !== null && botSaved; + return createdBotUuid !== null && botSaved && messageReceived; case 2: - return selectedRunner !== null; + return aiChoice !== null; default: return false; } - }, [currentStep, selectedAdapter, createdBotUuid, botSaved, selectedRunner]); + }, [ + currentStep, + selectedAdapter, + createdBotUuid, + botSaved, + messageReceived, + aiChoice, + ]); const goNext = useCallback(() => { if (currentStep < TOTAL_STEPS - 1 && canProceed()) { @@ -360,7 +388,9 @@ export default function WizardPage() { step: 1, selected_adapter: selectedAdapter, created_bot_uuid: resp.uuid, + created_pipeline_uuid: null, bot_saved: false, + message_received: false, selected_runner: null, }); } catch (err) { @@ -374,21 +404,61 @@ export default function WizardPage() { }, [selectedAdapter, adapters, t, saveProgress]); // ---- Save Bot Config & Enable (Step 1) ---- - // Updates the bot's adapter config and enables it. + // Creates a recommended Local Agent pipeline, binds it, and enables the bot. const handleSaveBot = useCallback(async () => { if (!createdBotUuid || !selectedAdapter) return; setIsSavingBot(true); + let createdPipelineThisAttempt: string | null = null; try { + let pipelineUuid = createdPipelineUuid; + if (!pipelineUuid) { + const recommendedModel = await httpClient.getWizardRecommendedModel(); + const pipelineResp = await httpClient.createPipeline({ + name: `${botName} Agent`, + description: botDescription || '', + config: {}, + }); + pipelineUuid = pipelineResp.uuid; + createdPipelineThisAttempt = pipelineUuid; + const createdPipeline = await httpClient.getPipeline(pipelineUuid); + const aiConfig = createdPipeline.pipeline.config.ai as Record< + string, + unknown + >; + const localAgentConfig = (aiConfig['local-agent'] ?? {}) as Record< + string, + unknown + >; + await httpClient.updatePipeline(pipelineUuid, { + name: `${botName} Agent`, + description: botDescription || '', + config: { + ...createdPipeline.pipeline.config, + ai: { + ...aiConfig, + runner: { runner: 'local-agent' }, + 'local-agent': { + ...localAgentConfig, + model: { primary: recommendedModel.uuid, fallbacks: [] }, + }, + }, + }, + }); + setCreatedPipelineUuid(pipelineUuid); + } + await httpClient.updateBot(createdBotUuid, { name: botName, description: botDescription || '', adapter: selectedAdapter, adapter_config: adapterConfig, enable: true, + use_pipeline_uuid: pipelineUuid, }); setBotSaved(true); + setMessageReceived(false); // Re-fetch runtime info to get updated webhook URL(s) try { @@ -405,8 +475,19 @@ export default function WizardPage() { } // Persist progress - saveProgress({ step: 1, bot_saved: true }); + saveProgress({ + step: 1, + bot_saved: true, + message_received: false, + created_pipeline_uuid: pipelineUuid, + }); } catch (err) { + if (createdPipelineThisAttempt) { + await httpClient + .deletePipeline(createdPipelineThisAttempt) + .catch(() => {}); + setCreatedPipelineUuid(null); + } const apiErr = err as { msg?: string }; toast.error( t('wizard.createError') + (apiErr?.msg ? `: ${apiErr.msg}` : ''), @@ -420,60 +501,80 @@ export default function WizardPage() { botName, botDescription, adapterConfig, + createdPipelineUuid, t, saveProgress, ]); - // ---- Create Pipeline & Link (Step 2 finish) ---- + const handleMessageReceived = useCallback(() => { + if (messageReceived) return; + setMessageReceived(true); + saveProgress({ step: 1, message_received: true }); + }, [messageReceived, saveProgress]); + + const completeWizard = useCallback(async () => { + await httpClient.updateWizardStatus('completed'); + systemInfo.wizard_status = 'completed'; + systemInfo.wizard_progress = null; + }, []); + + // ---- Complete the optional AI Engine step ---- const handleFinish = useCallback(async () => { - if (!selectedRunner || !createdBotUuid) return; + if (!aiChoice || !createdBotUuid || !createdPipelineUuid) return; + if (aiChoice === 'external' && !selectedRunner) return; setIsSubmitting(true); + let externalPipelineUuid: string | null = null; + let externalPipelineBound = false; try { - // 1. Create pipeline (backend fills config from default template) - const pipeline: Pipeline = { - name: `${botName} Pipeline`, - description: botDescription || '', - config: {}, - }; - const pipelineResp = await httpClient.createPipeline(pipeline); + if (aiChoice === 'external' && selectedRunner) { + const pipelineResp = await httpClient.createPipeline({ + name: `${botName} External Agent`, + description: botDescription || '', + config: {}, + }); + externalPipelineUuid = pipelineResp.uuid; + const createdPipeline = await httpClient.getPipeline(pipelineResp.uuid); + const fullConfig = createdPipeline.pipeline.config; + await httpClient.updatePipeline(pipelineResp.uuid, { + name: `${botName} External Agent`, + description: botDescription || '', + config: { + ...fullConfig, + ai: { + ...fullConfig.ai, + runner: { runner: selectedRunner }, + [selectedRunner]: runnerConfig, + }, + }, + }); - // 2. Fetch the created pipeline to get the full default config - // (includes trigger, safety, ai, output sections). - // Then merge only the AI section with the wizard's runner config. - const createdPipeline = await httpClient.getPipeline(pipelineResp.uuid); - const fullConfig = createdPipeline.pipeline.config; + const botData = await httpClient.getBot(createdBotUuid); + const existingBot = botData.bot; + await httpClient.updateBot(createdBotUuid, { + name: existingBot.name, + description: existingBot.description, + adapter: existingBot.adapter, + adapter_config: existingBot.adapter_config, + enable: existingBot.enable, + use_pipeline_uuid: pipelineResp.uuid, + }); + externalPipelineBound = true; + } - const mergedConfig = { - ...fullConfig, - ai: { - ...fullConfig.ai, - runner: { runner: selectedRunner }, - [selectedRunner]: runnerConfig, - }, - }; - - await httpClient.updatePipeline(pipelineResp.uuid, { - name: `${botName} Pipeline`, - description: botDescription || '', - config: mergedConfig, - }); - - // 3. Link pipeline to the bot created in Step 1 - const botData = await httpClient.getBot(createdBotUuid); - const existingBot = botData.bot; - await httpClient.updateBot(createdBotUuid, { - name: existingBot.name, - description: existingBot.description, - adapter: existingBot.adapter, - adapter_config: existingBot.adapter_config, - enable: existingBot.enable, - use_pipeline_uuid: pipelineResp.uuid, - }); - - setCurrentStep(3); + await completeWizard(); + if (aiChoice === 'own-model') { + navigate(`/home/pipelines?id=${createdPipelineUuid}`, { + replace: true, + }); + } else { + navigate('/home', { replace: true }); + } } catch (err) { + if (externalPipelineUuid && !externalPipelineBound) { + await httpClient.deletePipeline(externalPipelineUuid).catch(() => {}); + } const apiErr = err as { msg?: string }; toast.error( t('wizard.createError') + (apiErr?.msg ? `: ${apiErr.msg}` : ''), @@ -484,9 +585,13 @@ export default function WizardPage() { }, [ selectedRunner, createdBotUuid, + createdPipelineUuid, + aiChoice, botName, botDescription, runnerConfig, + completeWizard, + navigate, t, ]); @@ -524,7 +629,9 @@ export default function WizardPage() { step: 0, selected_adapter: null, created_bot_uuid: null, + created_pipeline_uuid: null, bot_saved: false, + message_received: false, selected_runner: null, }); systemInfo.wizard_progress = null; @@ -552,7 +659,6 @@ export default function WizardPage() { t('wizard.step.platform'), t('wizard.step.botConfig'), t('wizard.step.aiEngine'), - t('wizard.step.done'), ]; return ( @@ -567,7 +673,7 @@ export default function WizardPage() {
- {currentStep < 3 && ( + {currentStep < TOTAL_STEPS && (
{/* Footer navigation */} - {currentStep < 3 && ( + {currentStep < TOTAL_STEPS && (
)}
@@ -858,6 +975,8 @@ function StepBotConfig({ createdBotUuid, isSavingBot, botSaved, + messageReceived, + onMessageReceived, onSaveBot, webhookUrl, extraWebhookUrl, @@ -870,6 +989,8 @@ function StepBotConfig({ createdBotUuid: string | null; isSavingBot: boolean; botSaved: boolean; + messageReceived: boolean; + onMessageReceived: () => void; onSaveBot: () => void; webhookUrl: string; extraWebhookUrl: string; @@ -962,14 +1083,16 @@ function StepBotConfig({ )} - {/* Bot saved indicator */} + {/* Bot and inbound-message verification status */} {botSaved && (
- {t('wizard.botConfig.botSaved')} + {messageReceived + ? t('wizard.botConfig.messageReceived') + : t('wizard.botConfig.waitingForMessage')}
)} @@ -989,6 +1112,7 @@ function StepBotConfig({ botId={createdBotUuid} autoExpandImages hideToolbar + onMessageReceived={onMessageReceived} /> @@ -1004,6 +1128,8 @@ function StepBotConfig({ function StepAIEngine({ runnerOptions, + choice, + onChoiceChange, selected, onSelect, isLocalAccount, @@ -1013,6 +1139,10 @@ function StepAIEngine({ onRunnerConfigChange, }: { runnerOptions: { name: string; label: { en_US: string; zh_Hans: string } }[]; + choice: 'external' | 'own-model' | 'more-features' | null; + onChoiceChange: ( + choice: 'external' | 'own-model' | 'more-features' | null, + ) => void; selected: string | null; onSelect: (name: string) => void; isLocalAccount: boolean; @@ -1036,6 +1166,63 @@ function StepAIEngine({ return r ? extractI18nObject(r.label) : (selected ?? ''); }, [runnerOptions, selected]); + const choices = [ + { + 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'), + }, + { + id: 'more-features' as const, + icon: Blocks, + title: t('wizard.aiEngine.moreFeaturesTitle'), + description: t('wizard.aiEngine.moreFeaturesDescription'), + }, + ]; + + if (choice !== 'external') { + return ( +
+
+

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

+

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

+
+
+ {choices.map((item) => { + const Icon = item.icon; + return ( + onChoiceChange(item.id)} + > + + + {item.title} + {item.description} + + + ); + })} +
+
+ ); + } + // Before any runner is selected: centered grid layout if (!selected) { return ( @@ -1045,9 +1232,13 @@ function StepAIEngine({ {t('wizard.aiEngine.title')}

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

+
{runnerOptions.map((opt) => (
+ +
{/* Left: runner list */}
@@ -1179,100 +1380,3 @@ function StepAIEngine({
); } - -// --------------------------------------------------------------------------- -// Step 3: Done -// --------------------------------------------------------------------------- - -function StepDone() { - const { t } = useTranslation(); - const navigate = useNavigate(); - - const [particles] = useState(() => - Array.from({ length: 30 }, (_, i) => ({ - id: i, - left: Math.random() * 100, - delay: Math.random() * 2, - duration: 2 + Math.random() * 2, - size: 4 + Math.random() * 6, - color: [ - 'bg-purple-400', - 'bg-pink-400', - 'bg-orange-400', - 'bg-blue-400', - 'bg-green-400', - 'bg-yellow-400', - ][Math.floor(Math.random() * 6)], - })), - ); - - const [isCompleting, setIsCompleting] = useState(false); - - const handleBack = useCallback(async () => { - setIsCompleting(true); - try { - if (systemInfo.wizard_status === 'none') { - await httpClient.updateWizardStatus('completed'); - systemInfo.wizard_status = 'completed'; - } - // Always clear persisted progress so re-entering starts fresh - await httpClient.saveWizardProgress({ - step: 0, - selected_adapter: null, - created_bot_uuid: null, - bot_saved: false, - selected_runner: null, - }); - systemInfo.wizard_progress = null; - } catch { - toast.error(t('wizard.completeSaveError')); - setIsCompleting(false); - return; - } - setIsCompleting(false); - navigate('/home/bots'); - }, [navigate, t]); - - return ( -
- {/* Confetti particles */} -
- {particles.map((p) => ( -
- ))} -
- - -

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

-

- {t('wizard.done.description')} -

- - - -
- ); -} diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 6603cf560..279c3dd47 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -1827,6 +1827,10 @@ const enUS = { resaveBot: 'Re-save Configuration', botSaved: 'Bot configuration saved and enabled. Check the logs to verify the connection.', + waitingForMessage: + 'The bot is enabled. Send it a message from your IM platform to continue.', + messageReceived: + 'The bot received an IM message. You can continue to the next step.', logsTitle: 'Bot Logs', logsDescription: 'Monitor bot activity to verify the platform connection is working.', @@ -1835,6 +1839,23 @@ const enUS = { 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.', + externalTitle: 'Connect an External Agent', + externalDescription: + 'Connect Dify, n8n, Coze, or another platform and replace the bot pipeline.', + ownModelTitle: 'Use My Own Model', + ownModelDescription: + 'Open the current Local Agent pipeline and configure your own model.', + moreFeaturesTitle: 'Add More Agent Features', + moreFeaturesDescription: + 'Open the workbench to add tools, knowledge, and other capabilities.', + runnerDescription: + 'Select a runner for the external agent and configure its connection.', + backToChoices: 'Back to options', + createExternal: 'Create and Bind', + configurePipeline: 'Configure Pipeline', + openWorkbench: 'Open Workbench', }, spaceBanner: { message: diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index 7285086c4..7cab749ff 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -1744,6 +1744,10 @@ const jaJP = { resaveBot: '設定を再保存', botSaved: 'ボット設定が保存され、有効になりました。ログを確認して接続を検証してください。', + waitingForMessage: + 'ボットが有効になりました。続行するには IM からメッセージを送信してください。', + messageReceived: + 'ボットが IM メッセージを受信しました。次のステップに進めます。', logsTitle: 'ボットログ', logsDescription: 'ボットの活動を監視して、プラットフォーム接続が正常に動作していることを確認します。', @@ -1752,6 +1756,22 @@ const jaJP = { title: 'AIエンジンを選択', description: 'ボットのインテリジェンスを駆動するAIエンジンを選択してください。', + optionalDescription: + 'このステップは任意です。現在の Agent をどのように設定するか選択してください。', + externalTitle: '外部プラットフォームの Agent を接続', + externalDescription: + 'Dify、n8n、Coze などを接続し、ボットのパイプラインを置き換えます。', + ownModelTitle: '自分のモデルを使用', + ownModelDescription: + '現在の Local Agent パイプラインを開き、自分のモデルを設定します。', + moreFeaturesTitle: 'Agent に機能を追加', + moreFeaturesDescription: + 'ワークベンチを開き、ツールやナレッジなどの機能を追加します。', + runnerDescription: '外部 Agent の Runner を選択し、接続を設定します。', + backToChoices: '選択肢に戻る', + createExternal: '作成して関連付ける', + configurePipeline: 'パイプラインを設定', + openWorkbench: 'ワークベンチを開く', }, spaceBanner: { message: diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index bbece7f35..6794b036d 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -1749,12 +1749,27 @@ const zhHans = { saveBot: '保存并启用', resaveBot: '重新保存配置', botSaved: '机器人配置已保存并启用,请查看日志确认连接正常。', + waitingForMessage: '机器人已启用。请在 IM 中向机器人发送一条消息以继续。', + messageReceived: '机器人已成功收到 IM 消息,可以进入下一步。', logsTitle: '机器人日志', logsDescription: '监控机器人活动,确认平台连接是否正常工作。', }, aiEngine: { title: '选择 AI 引擎', description: '选择驱动机器人智能的 AI 引擎。', + optionalDescription: '这一步可选。选择接下来要如何完善当前 Agent。', + externalTitle: '接入外部平台 Agent', + externalDescription: + '接入 Dify、n8n、Coze 等平台,并替换当前机器人的流水线。', + ownModelTitle: '改成使用自己的模型', + ownModelDescription: '进入当前 Local Agent 流水线,配置你自己的模型。', + moreFeaturesTitle: '给现在的 Agent 配置更多功能', + moreFeaturesDescription: '进入工作台,为 Agent 添加工具、知识库等能力。', + runnerDescription: '选择外部 Agent 的 Runner 并完成连接配置。', + backToChoices: '返回选项', + createExternal: '创建并绑定', + configurePipeline: '配置流水线', + openWorkbench: '进入工作台', }, spaceBanner: { message: '接入 LangBot Space,获取免费试用模型额度,零配置极速开箱!',