diff --git a/skills/scripts/e2e/wizard-scenario-routing.mjs b/skills/scripts/e2e/wizard-scenario-routing.mjs new file mode 100644 index 000000000..a0e0fc2a5 --- /dev/null +++ b/skills/scripts/e2e/wizard-scenario-routing.mjs @@ -0,0 +1,349 @@ +#!/usr/bin/env node + +import { + apiJson, + createBrowser, + ensureAuthenticatedBrowser, + ensureEvidence, + evidencePaths, + exitCode, + loadEnvFiles, + localIsoWithOffset, + safeScreenshot, + scanBrowserDiagnostics, + writeResult, +} from "./lib/langbot-e2e.mjs"; + +const caseId = "wizard-scenario-routing"; +await loadEnvFiles(); +const paths = evidencePaths(caseId); +await ensureEvidence(paths); +const messageScreenshot = paths.screenshot.replace( + /\.png$/, + "-message-scenario.png", +); +const mobileScreenshot = paths.screenshot.replace(/\.png$/, "-mobile.png"); + +const startedAt = new Date(); +const frontendUrl = process.env.LANGBOT_FRONTEND_URL || ""; +const backendUrl = process.env.LANGBOT_BACKEND_URL || ""; + +let browser; +let token = ""; +let botId = ""; +let agentId = ""; +const result = { + source: "automation", + case_id: caseId, + run_id: paths.runId, + started_at: startedAt.toISOString(), + started_at_local: localIsoWithOffset(startedAt), + finished_at: "", + finished_at_local: "", + status: "fail", + reason: "", + url: "", + visible_signals: [], + api: {}, + diagnostics: null, + cleanup: null, + evidence: { + console_log: paths.consoleLog, + network_log: paths.networkLog, + screenshot: paths.screenshot, + message_scenario_screenshot: messageScreenshot, + mobile_screenshot: mobileScreenshot, + automation_result_json: paths.automationResultJson, + result_json: paths.resultJson, + }, + evidence_collected: ["ui", "screenshot", "console", "api_diagnostic"], +}; + +try { + if (!frontendUrl) throw new Error("LANGBOT_FRONTEND_URL is not configured."); + if (!backendUrl) throw new Error("LANGBOT_BACKEND_URL is not configured."); + + browser = await createBrowser(paths); + const { page } = browser; + await page.goto(frontendUrl, { waitUntil: "domcontentloaded" }); + const auth = await ensureAuthenticatedBrowser(page, { + frontendUrl, + backendUrl, + }); + if (auth.status !== "pass") { + result.status = auth.status; + throw new Error(auth.reason); + } + + token = await page.evaluate(() => localStorage.getItem("token") || ""); + if (!token) { + result.status = "blocked"; + throw new Error("Authenticated browser has no reusable local token."); + } + + const adapters = await apiJson(backendUrl, "/api/v1/platform/adapters", { + token, + }); + const catalog = adapters.json.data?.adapters || []; + const httpBot = catalog.find((adapter) => adapter.name === "http_bot"); + const welcomeAdapters = catalog.filter((adapter) => + (adapter.spec?.supported_events || []).includes("group.member_joined"), + ); + result.api.adapters = { + http_status: adapters.status, + code: adapters.json.code ?? null, + http_bot_supports_message: + !httpBot?.spec?.supported_events?.length || + httpBot.spec.supported_events.includes("message.received"), + http_bot_supports_member_joined: + httpBot?.spec?.supported_events?.includes("group.member_joined") || false, + welcome_adapter_count: welcomeAdapters.length, + }; + if (adapters.status >= 400 || adapters.json.code !== 0) { + throw new Error(adapters.json.msg || "Adapter discovery failed."); + } + if (!httpBot || welcomeAdapters.length === 0) { + throw new Error( + "The adapter catalog does not contain the fixtures required by this case.", + ); + } + if (result.api.adapters.http_bot_supports_member_joined) { + throw new Error( + "HTTP Bot unexpectedly declares group.member_joined support; update the case expectation.", + ); + } + + const progressUrl = `${backendUrl.replace(/\/$/, "")}/api/v1/system/wizard/progress`; + await page.route(progressUrl, async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ code: 0, msg: "ok", data: {} }), + }); + }); + + await page.goto(`${frontendUrl.replace(/\/$/, "")}/wizard`, { + waitUntil: "domcontentloaded", + }); + result.url = page.url(); + await page + .getByText( + /What should this bot do\?|这个机器人要完成什么?|このボットで何を実現しますか?/, + ) + .waitFor({ timeout: 15_000 }); + + await page + .getByRole("button", { + name: /Reply to messages|回复收到的消息|受信メッセージに返信/, + }) + .click(); + await page + .getByText(/HTTP Bot|HTTP 通用接入|HTTP ボット/, { exact: true }) + .waitFor(); + result.visible_signals.push("scenario-first", "message-http-compatible"); + await safeScreenshot(page, messageScreenshot); + + await page + .getByRole("button", { + name: /Welcome new members|欢迎新成员|新しいメンバーを歓迎/, + }) + .click(); + await page + .getByText(/Discord/, { exact: true }) + .first() + .waitFor(); + const httpBotCount = await page + .getByText(/HTTP Bot|HTTP 通用接入|HTTP ボット/, { exact: true }) + .count(); + if (httpBotCount !== 0) { + throw new Error("HTTP Bot remained visible for group.member_joined."); + } + const selectedScenario = page.getByRole("button", { + name: /Welcome new members|欢迎新成员|新しいメンバーを歓迎/, + }); + if ( + !(await selectedScenario.getByText("Agent", { exact: true }).isVisible()) + ) { + throw new Error( + "The non-message scenario is not visibly labeled as Agent.", + ); + } + result.visible_signals.push( + "welcome-compatible-channel", + "http-filtered", + "agent-behavior-label", + ); + + await safeScreenshot(page, paths.screenshot); + await page.setViewportSize({ width: 390, height: 844 }); + await page.waitForTimeout(250); + const horizontalOverflow = await page.evaluate( + () => document.documentElement.scrollWidth - window.innerWidth, + ); + if (horizontalOverflow > 1) { + throw new Error( + `The mobile wizard overflows horizontally by ${horizontalOverflow}px.`, + ); + } + await selectedScenario.scrollIntoViewIfNeeded(); + await page + .getByText(/Discord/, { exact: true }) + .first() + .waitFor(); + await safeScreenshot(page, mobileScreenshot); + result.visible_signals.push("mobile-layout"); + + const fixtureSuffix = paths.runId.slice(-48); + const agent = await apiJson(backendUrl, "/api/v1/agents", { + method: "POST", + token, + body: { + kind: "agent", + name: `Wizard Scenario Agent ${fixtureSuffix}`, + description: "Temporary wizard scenario routing fixture", + emoji: "W", + enabled: true, + supported_event_patterns: ["group.member_joined"], + }, + }); + agentId = agent.json.data?.uuid || ""; + result.api.create_agent = { + http_status: agent.status, + code: agent.json.code ?? null, + }; + if (agent.status >= 400 || agent.json.code !== 0 || !agentId) { + throw new Error(agent.json.msg || "Failed to create the temporary Agent."); + } + + const bot = await apiJson(backendUrl, "/api/v1/platform/bots", { + method: "POST", + token, + body: { + name: `Wizard Scenario Bot ${fixtureSuffix}`, + description: "Temporary disabled wizard scenario routing fixture", + adapter: "aiocqhttp", + adapter_config: { + host: "127.0.0.1", + port: 2280, + "access-token": "", + }, + enable: false, + event_bindings: [], + }, + }); + botId = bot.json.data?.uuid || ""; + result.api.create_bot = { + http_status: bot.status, + code: bot.json.code ?? null, + }; + if (bot.status >= 400 || bot.json.code !== 0 || !botId) { + throw new Error(bot.json.msg || "Failed to create the temporary Bot."); + } + + const update = await apiJson( + backendUrl, + `/api/v1/platform/bots/${encodeURIComponent(botId)}`, + { + method: "PUT", + token, + body: { + event_bindings: [ + { + event_pattern: "group.member_joined", + target_type: "agent", + target_uuid: agentId, + filters: [], + priority: 0, + enabled: true, + description: "Welcome new members", + }, + ], + }, + }, + ); + result.api.bind_route = { + http_status: update.status, + code: update.json.code ?? null, + }; + if (update.status >= 400 || update.json.code !== 0) { + throw new Error(update.json.msg || "Failed to bind the temporary route."); + } + + const savedBot = await apiJson( + backendUrl, + `/api/v1/platform/bots/${encodeURIComponent(botId)}`, + { token }, + ); + const savedRoute = savedBot.json.data?.bot?.event_bindings?.[0]; + result.api.read_route = { + http_status: savedBot.status, + code: savedBot.json.code ?? null, + event_pattern: savedRoute?.event_pattern || null, + target_type: savedRoute?.target_type || null, + target_matches: savedRoute?.target_uuid === agentId, + }; + if ( + savedBot.status >= 400 || + savedBot.json.code !== 0 || + savedRoute?.event_pattern !== "group.member_joined" || + savedRoute?.target_type !== "agent" || + savedRoute?.target_uuid !== agentId + ) { + throw new Error("The saved Bot did not return the expected Agent route."); + } + result.visible_signals.push("agent-route-persisted"); + + result.diagnostics = await scanBrowserDiagnostics(paths); + if (result.diagnostics.status !== "pass") { + throw new Error(result.diagnostics.reason); + } + result.status = "pass"; + result.reason = + "Quick Start visibly filtered channels by scenario at desktop and mobile widths."; +} catch (error) { + if (!["blocked", "env_issue"].includes(result.status)) result.status = "fail"; + result.reason = result.reason || error.message; + if (browser?.page) await safeScreenshot(browser.page, paths.screenshot); +} finally { + const cleanup = {}; + if (botId && token && backendUrl) { + const deletedBot = await apiJson( + backendUrl, + `/api/v1/platform/bots/${encodeURIComponent(botId)}`, + { method: "DELETE", token }, + ).catch((error) => ({ + status: 0, + json: { code: null, msg: error.message }, + })); + cleanup.bot_deleted = deletedBot.status < 400 && deletedBot.json.code === 0; + cleanup.bot_http_status = deletedBot.status; + } + if (agentId && token && backendUrl) { + const deletedAgent = await apiJson( + backendUrl, + `/api/v1/agents/${encodeURIComponent(agentId)}`, + { method: "DELETE", token }, + ).catch((error) => ({ + status: 0, + json: { code: null, msg: error.message }, + })); + cleanup.agent_deleted = + deletedAgent.status < 400 && deletedAgent.json.code === 0; + cleanup.agent_http_status = deletedAgent.status; + } + result.cleanup = cleanup; + const cleanupFailed = + (botId && !cleanup.bot_deleted) || (agentId && !cleanup.agent_deleted); + if (cleanupFailed && result.status === "pass") { + result.status = "fail"; + result.reason = "The temporary wizard routing fixtures were not deleted."; + } + if (browser) await browser.close().catch(() => {}); + const finishedAt = new Date(); + result.finished_at = finishedAt.toISOString(); + result.finished_at_local = localIsoWithOffset(finishedAt); + await writeResult(paths, result); + console.log(JSON.stringify(result, null, 2)); +} + +process.exit(exitCode(result.status)); diff --git a/skills/skills.index.json b/skills/skills.index.json index bfac05e33..d5bed5995 100644 --- a/skills/skills.index.json +++ b/skills/skills.index.json @@ -190,7 +190,8 @@ "sandbox-skill-authoring-e2e", "sandbox-skill-authoring-edit-existing-e2e", "skill-discovery-via-mcp-gateway", - "webui-login-state" + "webui-login-state", + "wizard-scenario-routing" ], "case_summaries": [ { @@ -1647,6 +1648,31 @@ "screenshot", "console" ] + }, + { + "id": "wizard-scenario-routing", + "title": "Quick Start filters channels by the selected bot scenario", + "mode": "agent-browser", + "area": "wizard", + "type": "feature", + "priority": "p0", + "risk": "medium", + "ci_eligible": false, + "tags": [ + "wizard", + "eba", + "event-routing", + "productization" + ], + "automation": "scripts/e2e/wizard-scenario-routing.mjs", + "setup_automation": [], + "setup_provides_env": [], + "evidence_required": [ + "ui", + "screenshot", + "console", + "api_diagnostic" + ] } ], "suites": [ diff --git a/skills/skills/langbot-testing/cases/wizard-scenario-routing.yaml b/skills/skills/langbot-testing/cases/wizard-scenario-routing.yaml new file mode 100644 index 000000000..64c9bd31c --- /dev/null +++ b/skills/skills/langbot-testing/cases/wizard-scenario-routing.yaml @@ -0,0 +1,57 @@ +id: wizard-scenario-routing +title: "Quick Start filters channels by the selected bot scenario" +mode: agent-browser +area: wizard +type: feature +priority: p0 +risk: medium +ci_eligible: false +tags: + - wizard + - eba + - event-routing + - productization +skills: + - langbot-env-setup + - langbot-testing +env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_BROWSER_PROFILE +automation: scripts/e2e/wizard-scenario-routing.mjs +automation_env: + - LANGBOT_FRONTEND_URL + - LANGBOT_BACKEND_URL + - LANGBOT_BROWSER_PROFILE + - LANGBOT_CHROMIUM_EXECUTABLE +preconditions: + - "The target is a local test instance where a disabled temporary Bot and Agent may be created and deleted." +steps: + - "Open Quick Start and select Reply to messages." + - "Confirm HTTP Bot is available for the message scenario." + - "Select Welcome new members." + - "Confirm a compatible channel remains visible and HTTP Bot is removed." + - "Create a disabled temporary Bot and Agent, persist a group.member_joined route, and read it back through the API." + - "Verify the scenario and channel layout at desktop and mobile widths." +checks: + - "UI: Scenario selection appears before channel selection." + - "UI: Reply to messages includes HTTP Bot." + - "UI: Welcome new members includes a compatible event-capable channel." + - "UI: Welcome new members excludes HTTP Bot because it does not declare group.member_joined support." + - "UI: The selected non-message scenario is labeled as an Agent behavior." + - "Console: No unexpected frontend errors appear during the flow." + - "Network: Adapter discovery returns without a 5xx response." + - "API: A non-message event route to an Agent is normalized and persisted." + - "Cleanup: The temporary Bot and Agent are deleted after evidence is collected." + - "Isolation: Wizard progress writes are stubbed in the browser and do not change backend state." +evidence_required: + - ui + - screenshot + - console + - api_diagnostic +diagnostics: + - "The temporary Bot remains disabled, so the case cannot connect to an external messaging platform." + - "A passing adapter API response without the visible filtered channel list is not a pass." +troubleshooting: + - backend-not-listening + - proxy-env-mismatch diff --git a/src/langbot/pkg/api/http/controller/groups/system.py b/src/langbot/pkg/api/http/controller/groups/system.py index 236a23582..7ca4340b9 100644 --- a/src/langbot/pkg/api/http/controller/groups/system.py +++ b/src/langbot/pkg/api/http/controller/groups/system.py @@ -105,8 +105,8 @@ class SystemRouterGroup(group.RouterGroup): """Save wizard progress to metadata table. Accepts JSON body with wizard state fields: - { "step": int, "selected_adapter": str|null, "created_bot_uuid": str|null, - "bot_saved": bool, "selected_runner": str|null } + { "step": int, "selected_scenario": str|null, "selected_adapter": str|null, + "created_bot_uuid": str|null, "bot_saved": bool, "selected_runner": str|null } """ data = await quart.request.get_json(silent=True) or {} progress_json = json.dumps(data, ensure_ascii=False) diff --git a/web/src/app/infra/entities/api/index.ts b/web/src/app/infra/entities/api/index.ts index 026b0dfb2..5012fd0dc 100644 --- a/web/src/app/infra/entities/api/index.ts +++ b/web/src/app/infra/entities/api/index.ts @@ -445,6 +445,7 @@ export interface SystemLimitation { export interface WizardProgress { step: number; + selected_scenario?: string | null; selected_adapter: string | null; created_bot_uuid: string | null; bot_saved: boolean; diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts index 76c34ab13..151865a1f 100644 --- a/web/src/app/infra/http/BackendClient.ts +++ b/web/src/app/infra/http/BackendClient.ts @@ -1072,6 +1072,7 @@ export class BackendClient extends BaseHttpClient { public saveWizardProgress(progress: { step: number; + selected_scenario?: string | null; selected_adapter: string | null; created_bot_uuid: string | null; bot_saved: boolean; diff --git a/web/src/app/wizard/page.tsx b/web/src/app/wizard/page.tsx index 69b6c9cf7..6bc9ceb27 100644 --- a/web/src/app/wizard/page.tsx +++ b/web/src/app/wizard/page.tsx @@ -12,6 +12,11 @@ import { Sparkles, PartyPopper, Loader2, + MessageSquare, + ShieldCheck, + UserCheck, + UserMinus, + UserPlus, X, ExternalLink, } from 'lucide-react'; @@ -75,6 +80,73 @@ import { const TOTAL_STEPS = 4; +type WizardScenarioId = + | 'message_reply' + | 'welcome_members' + | 'handle_departures' + | 'review_friend_requests' + | 'handle_moderation'; + +const WIZARD_SCENARIOS = [ + { + id: 'message_reply' as const, + eventType: 'message.received', + processorKind: 'pipeline' as const, + labelKey: 'wizard.scenario.messageReply', + descriptionKey: 'wizard.scenario.messageReplyDescription', + icon: MessageSquare, + emoji: '💬', + }, + { + id: 'welcome_members' as const, + eventType: 'group.member_joined', + processorKind: 'agent' as const, + labelKey: 'wizard.scenario.welcomeMembers', + descriptionKey: 'wizard.scenario.welcomeMembersDescription', + icon: UserPlus, + emoji: '👋', + }, + { + id: 'handle_departures' as const, + eventType: 'group.member_left', + processorKind: 'agent' as const, + labelKey: 'wizard.scenario.handleDepartures', + descriptionKey: 'wizard.scenario.handleDeparturesDescription', + icon: UserMinus, + emoji: '👤', + }, + { + id: 'review_friend_requests' as const, + eventType: 'friend.request_received', + processorKind: 'agent' as const, + labelKey: 'wizard.scenario.reviewFriendRequests', + descriptionKey: 'wizard.scenario.reviewFriendRequestsDescription', + icon: UserCheck, + emoji: '✅', + }, + { + id: 'handle_moderation' as const, + eventType: 'group.member_banned', + processorKind: 'agent' as const, + labelKey: 'wizard.scenario.handleModeration', + descriptionKey: 'wizard.scenario.handleModerationDescription', + icon: ShieldCheck, + emoji: '🛡️', + }, +]; + +function adapterSupportsScenario( + adapter: Adapter, + scenarioId: WizardScenarioId, +) { + const scenario = WIZARD_SCENARIOS.find((item) => item.id === scenarioId); + if (!scenario) return false; + const supportedEvents = adapter.spec.supported_events?.length + ? adapter.spec.supported_events + : ['message.received']; + return supportedEvents.includes(scenario.eventType); +} + // --------------------------------------------------------------------------- // Main Wizard Page (full-screen, no sidebar) // --------------------------------------------------------------------------- @@ -85,6 +157,8 @@ export default function WizardPage() { // ---- Wizard state ---- const [currentStep, setCurrentStep] = useState(0); + const [selectedScenario, setSelectedScenario] = + useState(null); const [selectedAdapter, setSelectedAdapter] = useState(null); const [selectedRunner, setSelectedRunner] = useState(null); const [botName, setBotName] = useState(''); @@ -113,16 +187,36 @@ export default function WizardPage() { (overrides: Partial = {}) => { const progress: WizardProgress = { step: overrides.step ?? currentStep, - selected_adapter: overrides.selected_adapter ?? selectedAdapter, - created_bot_uuid: overrides.created_bot_uuid ?? createdBotUuid, + selected_scenario: + overrides.selected_scenario !== undefined + ? overrides.selected_scenario + : selectedScenario, + selected_adapter: + overrides.selected_adapter !== undefined + ? overrides.selected_adapter + : selectedAdapter, + created_bot_uuid: + overrides.created_bot_uuid !== undefined + ? overrides.created_bot_uuid + : createdBotUuid, bot_saved: overrides.bot_saved ?? botSaved, - selected_runner: overrides.selected_runner ?? selectedRunner, + selected_runner: + overrides.selected_runner !== undefined + ? overrides.selected_runner + : selectedRunner, }; httpClient.saveWizardProgress(progress).catch((err) => { console.error('Failed to save wizard progress', err); }); }, - [currentStep, selectedAdapter, createdBotUuid, botSaved, selectedRunner], + [ + currentStep, + selectedScenario, + selectedAdapter, + createdBotUuid, + botSaved, + selectedRunner, + ], ); // ---- Fetch remote data & restore progress ---- @@ -151,6 +245,10 @@ export default function WizardPage() { if (cancelled) return; setSelectedAdapter(progress.selected_adapter); + setSelectedScenario( + (progress.selected_scenario as WizardScenarioId | null) ?? + 'message_reply', + ); setCreatedBotUuid(progress.created_bot_uuid); setBotSaved(progress.bot_saved ?? false); setSelectedRunner(progress.selected_runner); @@ -167,13 +265,14 @@ export default function WizardPage() { (runtimeValues?.extra_webhook_full_url as string) || '', ); - // Restore step (cap at step 2 — step 3 means done) - setCurrentStep(Math.min(progress.step, 2)); + // Step 3 is resumable so a refresh cannot create a duplicate processor. + setCurrentStep(Math.min(progress.step, 3)); } catch { // Bot no longer exists — clear stale progress and start fresh httpClient .saveWizardProgress({ step: 0, + selected_scenario: null, selected_adapter: null, created_bot_uuid: null, bot_saved: false, @@ -213,6 +312,11 @@ export default function WizardPage() { return aiConfigTab.stages.find((s) => s.name === selectedRunner); }, [selectedRunner, aiConfigTab]); + const selectedScenarioDefinition = useMemo( + () => WIZARD_SCENARIOS.find((item) => item.id === selectedScenario), + [selectedScenario], + ); + // Adapter spec config for the selected adapter const selectedAdapterConfig: IDynamicFormItemSchema[] = useMemo(() => { const adapter = adapters.find((a) => a.name === selectedAdapter); @@ -270,7 +374,7 @@ export default function WizardPage() { const canProceed = useCallback((): boolean => { switch (currentStep) { case 0: - return selectedAdapter !== null; + return selectedScenario !== null && selectedAdapter !== null; case 1: return createdBotUuid !== null && botSaved; case 2: @@ -278,7 +382,32 @@ export default function WizardPage() { default: return false; } - }, [currentStep, selectedAdapter, createdBotUuid, botSaved, selectedRunner]); + }, [ + currentStep, + selectedScenario, + selectedAdapter, + createdBotUuid, + botSaved, + selectedRunner, + ]); + + const handleSelectScenario = useCallback( + (scenarioId: WizardScenarioId) => { + const adapter = adapters.find((item) => item.name === selectedAdapter); + const nextAdapter = + adapter && adapterSupportsScenario(adapter, scenarioId) + ? selectedAdapter + : null; + setSelectedScenario(scenarioId); + setSelectedAdapter(nextAdapter); + saveProgress({ + step: 0, + selected_scenario: scenarioId, + selected_adapter: nextAdapter, + }); + }, + [adapters, selectedAdapter, saveProgress], + ); const goNext = useCallback(() => { if (currentStep < TOTAL_STEPS - 1 && canProceed()) { @@ -345,6 +474,7 @@ export default function WizardPage() { // Persist progress saveProgress({ step: 1, + selected_scenario: selectedScenario, selected_adapter: selectedAdapter, created_bot_uuid: resp.uuid, bot_saved: false, @@ -358,7 +488,7 @@ export default function WizardPage() { } finally { setIsCreatingBot(false); } - }, [selectedAdapter, adapters, t, saveProgress]); + }, [selectedScenario, selectedAdapter, adapters, t, saveProgress]); // ---- Save Bot Config & Enable (Step 1) ---- // Updates the bot's adapter config and enables it. @@ -414,62 +544,75 @@ export default function WizardPage() { // ---- Create Pipeline & Link (Step 2 finish) ---- const handleFinish = useCallback(async () => { - if (!selectedRunner || !createdBotUuid) return; + if (!selectedRunner || !createdBotUuid || !selectedScenarioDefinition) + return; setIsSubmitting(true); + let processorUuid = ''; try { - // 1. Create pipeline (backend fills config from default template) - const pipeline: Pipeline = { - name: `${botName} Pipeline`, - description: botDescription || '', - config: {}, - }; - const pipelineResp = await httpClient.createPipeline(pipeline); + let targetType: 'agent' | 'pipeline'; - // 2. Fetch the created pipeline to get the full default config - // (includes trigger, safety, ai, output sections). - // Then merge only the AI section with the wizard's runner config. - const createdPipeline = await httpClient.getPipeline(pipelineResp.uuid); - const fullConfig = createdPipeline.pipeline.config as unknown as Record< - string, - unknown - >; - const fullAiConfig = - fullConfig.ai && typeof fullConfig.ai === 'object' - ? (fullConfig.ai as Record) - : {}; - 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) - : {}; + if (selectedScenarioDefinition.processorKind === 'pipeline') { + const pipeline: Pipeline = { + name: `${botName} Pipeline`, + description: botDescription || '', + config: {}, + }; + const pipelineResp = await httpClient.createPipeline(pipeline); + processorUuid = pipelineResp.uuid; + const createdPipeline = await httpClient.getPipeline(pipelineResp.uuid); + const fullConfig = createdPipeline.pipeline.config as unknown as Record< + string, + unknown + >; + const fullAiConfig = + fullConfig.ai && typeof fullConfig.ai === 'object' + ? (fullConfig.ai as Record) + : {}; + 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) + : {}; - const mergedConfig = { - ...fullConfig, - ai: { - ...fullAiConfig, - runner: { - ...existingRunner, - id: selectedRunner, + await httpClient.updatePipeline(pipelineResp.uuid, { + name: `${botName} Pipeline`, + description: botDescription || '', + config: { + ...fullConfig, + ai: { + ...fullAiConfig, + runner: { ...existingRunner, id: selectedRunner }, + runner_config: { + ...existingRunnerConfigs, + [selectedRunner]: runnerConfig, + }, + }, }, - runner_config: { - ...existingRunnerConfigs, - [selectedRunner]: runnerConfig, + }); + targetType = 'pipeline'; + } else { + const agentResp = await httpClient.createAgent({ + kind: 'agent', + name: `${botName} - ${t(selectedScenarioDefinition.labelKey)}`, + description: botDescription || '', + emoji: selectedScenarioDefinition.emoji, + component_ref: selectedRunner, + config: { + runner: { id: selectedRunner, 'expire-time': 0 }, + runner_config: { [selectedRunner]: runnerConfig }, }, - }, - }; + enabled: true, + supported_event_patterns: [selectedScenarioDefinition.eventType], + }); + processorUuid = agentResp.uuid; + targetType = 'agent'; + } - await httpClient.updatePipeline(pipelineResp.uuid, { - name: `${botName} Pipeline`, - description: botDescription || '', - config: mergedConfig, - }); - - // 3. Link pipeline to the bot created in Step 1 const botData = await httpClient.getBot(createdBotUuid); const existingBot = botData.bot; await httpClient.updateBot(createdBotUuid, { @@ -480,9 +623,9 @@ export default function WizardPage() { enable: existingBot.enable, event_bindings: [ { - event_pattern: 'message.received', - target_type: 'pipeline', - target_uuid: pipelineResp.uuid, + event_pattern: selectedScenarioDefinition.eventType, + target_type: targetType, + target_uuid: processorUuid, filters: [], priority: 0, enabled: true, @@ -492,7 +635,15 @@ export default function WizardPage() { }); setCurrentStep(3); + saveProgress({ step: 3 }); } catch (err) { + if (processorUuid) { + try { + await httpClient.deleteAgent(processorUuid); + } catch (rollbackError) { + console.warn('Failed to roll back wizard processor', rollbackError); + } + } const apiErr = err as { msg?: string }; toast.error( t('wizard.createError') + (apiErr?.msg ? `: ${apiErr.msg}` : ''), @@ -503,10 +654,12 @@ export default function WizardPage() { }, [ selectedRunner, createdBotUuid, + selectedScenarioDefinition, botName, botDescription, runnerConfig, t, + saveProgress, ]); // ---- Space auth redirect ---- @@ -541,6 +694,7 @@ export default function WizardPage() { // Always clear persisted progress so re-entering starts fresh await httpClient.saveWizardProgress({ step: 0, + selected_scenario: null, selected_adapter: null, created_bot_uuid: null, bot_saved: false, @@ -568,7 +722,7 @@ export default function WizardPage() { } const stepLabels = [ - t('wizard.step.platform'), + t('wizard.step.scenarioChannel'), t('wizard.step.botConfig'), t('wizard.step.aiEngine'), t('wizard.step.done'), @@ -657,6 +811,8 @@ export default function WizardPage() { {currentStep === 0 && ( @@ -769,10 +925,14 @@ export default function WizardPage() { function StepPlatform({ adapters, + selectedScenario, + onSelectScenario, selected, onSelect, }: { adapters: Adapter[]; + selectedScenario: WizardScenarioId | null; + onSelectScenario: (scenarioId: WizardScenarioId) => void; selected: string | null; onSelect: (name: string) => void; }) { @@ -780,12 +940,26 @@ function StepPlatform({ const [showLegacy, setShowLegacy] = useState(false); const activeAdapters = useMemo( - () => adapters.filter((a) => !a.spec.legacy), - [adapters], + () => + selectedScenario + ? adapters.filter( + (adapter) => + !adapter.spec.legacy && + adapterSupportsScenario(adapter, selectedScenario), + ) + : [], + [adapters, selectedScenario], ); const legacyAdapters = useMemo( - () => adapters.filter((a) => a.spec.legacy), - [adapters], + () => + selectedScenario + ? adapters.filter( + (adapter) => + adapter.spec.legacy && + adapterSupportsScenario(adapter, selectedScenario), + ) + : [], + [adapters, selectedScenario], ); const groupedAdapters = useMemo(() => { @@ -797,139 +971,215 @@ function StepPlatform({ }, [activeAdapters]); return ( -
-
-

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

-

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

-
- {groupedAdapters.map((group) => ( -
- {group.categoryId && ( -

- {getCategoryLabel(t, group.categoryId)} -

- )} -
- {group.items.map((adapter) => ( - +
+
+

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

+

+ {t('wizard.scenario.description')} +

+
+
+ {WIZARD_SCENARIOS.map((scenario) => { + const Icon = scenario.icon; + const isSelected = selectedScenario === scenario.id; + return ( +
-
- ))} - {legacyAdapters.length > 0 && ( -
- - {showLegacy && ( - <> -

- {t('bots.legacyAdaptersHint')} -

-
- {legacyAdapters.map((adapter) => ( - + onSelect(adapter.name)} > - - -
- - {extractI18nObject(adapter.label)} - -
- {selected === adapter.name && ( -
-
- -
-
- )} -
-
- ))} -
- - )} + + + + + + {t(scenario.labelKey)} + + + {t( + scenario.processorKind === 'pipeline' + ? 'wizard.scenario.pipelineBadge' + : 'wizard.scenario.agentBadge', + )} + + + + {t(scenario.descriptionKey)} + + +
+ + ); + })}
- )} + + +
+
+

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

+

+ {t('wizard.platform.description')} +

+
+ {!selectedScenario && ( +
+ {t('wizard.platform.chooseScenarioFirst')} +
+ )} + {selectedScenario && + activeAdapters.length === 0 && + legacyAdapters.length === 0 && ( +
+ {t('wizard.platform.noCompatiblePlatforms')} +
+ )} + {groupedAdapters.map((group) => ( +
+ {group.categoryId && ( +

+ {getCategoryLabel(t, group.categoryId)} +

+ )} +
+ {group.items.map((adapter) => ( + onSelect(adapter.name)} + > + + +
+ + {extractI18nObject(adapter.label)} + +
+ {selected === adapter.name && ( +
+
+ +
+
+ )} +
+ +

+ {extractI18nObject(adapter.description)} +

+ {(() => { + const docUrl = getAdapterDocUrl( + adapter.spec.help_links, + i18n.language, + ); + return docUrl ? ( + e.stopPropagation()} + > + + {t('bots.viewAdapterDocs')} + + ) : null; + })()} +
+
+ ))} +
+
+ ))} + {legacyAdapters.length > 0 && ( +
+ + {showLegacy && ( + <> +

+ {t('bots.legacyAdaptersHint')} +

+
+ {legacyAdapters.map((adapter) => ( + onSelect(adapter.name)} + > + + +
+ + {extractI18nObject(adapter.label)} + +
+ {selected === adapter.name && ( +
+
+ +
+
+ )} +
+
+ ))} +
+ + )} +
+ )} +
); } @@ -1308,6 +1558,7 @@ function StepDone() { // Always clear persisted progress so re-entering starts fresh await httpClient.saveWizardProgress({ step: 0, + selected_scenario: null, selected_adapter: null, created_bot_uuid: null, bot_saved: false, diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index f609106fa..adb5be595 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -1913,7 +1913,7 @@ const enUS = { next: 'Next', finish: 'Create & Deploy', confirmCreateBot: 'Confirm, Create Bot', - createSuccess: 'Pipeline created and linked to bot successfully!', + createSuccess: 'Processor created and linked to the bot successfully!', botCreateSuccess: 'Bot created successfully!', botSaveSuccess: 'Bot configuration saved and enabled!', createError: 'Failed to create resources', @@ -1922,13 +1922,39 @@ const enUS = { completeSaveError: 'Failed to save completion status. Please try again.', step: { platform: 'Platform', + scenarioChannel: 'Scenario & Channel', botConfig: 'Bot Setup', aiEngine: 'AI Engine', done: 'Done', }, + scenario: { + title: 'What should this bot do?', + description: + 'Start with one outcome. You can add more behaviors after the bot is created.', + messageReply: 'Reply to messages', + messageReplyDescription: + 'Answer incoming private or group messages with an AI Pipeline.', + welcomeMembers: 'Welcome new members', + welcomeMembersDescription: 'Run an Agent when someone joins a group.', + handleDepartures: 'Handle member departures', + handleDeparturesDescription: + 'Run an Agent when someone leaves or is removed.', + reviewFriendRequests: 'Review friend requests', + reviewFriendRequestsDescription: + 'Let an Agent decide how to handle new friend requests.', + handleModeration: 'Handle moderation events', + handleModerationDescription: + 'Run an Agent when a group member is restricted.', + pipelineBadge: 'Pipeline', + agentBadge: 'Agent', + }, platform: { - title: 'Select a Platform', - description: 'Choose the messaging platform your bot will connect to.', + title: 'Select a Channel', + description: + 'Only channels that support the selected scenario are shown.', + chooseScenarioFirst: 'Choose a scenario to see compatible channels.', + noCompatiblePlatforms: + 'No installed channel currently supports this scenario.', }, botConfig: { title: 'Configure Your Bot', @@ -1961,7 +1987,7 @@ const enUS = { done: { title: 'All Set!', description: - 'Your bot has been created and connected to the AI pipeline. You can now manage it from the workbench.', + 'Your bot has been created and connected to its processor. You can now manage it from the workbench.', backToWorkbench: 'Back to Workbench', }, }, diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index 9b381964a..1d0721046 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -1834,7 +1834,7 @@ const jaJP = { next: '次へ', finish: '作成&デプロイ', confirmCreateBot: '確定、ボットを作成', - createSuccess: 'パイプラインが作成され、ボットにリンクされました!', + createSuccess: 'プロセッサーが作成され、ボットにリンクされました!', botCreateSuccess: 'ボットが正常に作成されました!', botSaveSuccess: 'ボット設定が保存され、有効になりました!', createError: 'リソースの作成に失敗しました', @@ -1843,14 +1843,40 @@ const jaJP = { completeSaveError: '完了状態の保存に失敗しました。もう一度お試しください。', step: { platform: 'プラットフォーム', + scenarioChannel: 'シナリオとチャンネル', botConfig: 'ボット設定', aiEngine: 'AIエンジン', done: '完了', }, - platform: { - title: 'プラットフォームを選択', + scenario: { + title: 'このボットで何を実現しますか?', description: - 'ボットが接続するメッセージングプラットフォームを選択してください。', + 'まず主要な動作を1つ選びます。作成後に他の動作も追加できます。', + messageReply: '受信メッセージに返信', + messageReplyDescription: + 'AI Pipeline でプライベートまたはグループメッセージに返信します。', + welcomeMembers: '新しいメンバーを歓迎', + welcomeMembersDescription: + 'メンバーがグループに参加したときに Agent を実行します。', + handleDepartures: 'メンバーの退出を処理', + handleDeparturesDescription: + 'メンバーが退出または削除されたときに Agent を実行します。', + reviewFriendRequests: '友だち申請を確認', + reviewFriendRequestsDescription: + '新しい申請の処理方法を Agent に判断させます。', + handleModeration: 'モデレーションイベントを処理', + handleModerationDescription: + 'グループメンバーが制限されたときに Agent を実行します。', + pipelineBadge: 'Pipeline', + agentBadge: 'Agent', + }, + platform: { + title: 'チャンネルを選択', + description: '選択したシナリオに対応するチャンネルのみ表示されます。', + chooseScenarioFirst: + 'シナリオを選択すると、対応するチャンネルが表示されます。', + noCompatiblePlatforms: + '現在インストールされているチャンネルはこのシナリオに対応していません。', }, botConfig: { title: 'ボットを設定', @@ -1884,7 +1910,7 @@ const jaJP = { done: { title: '完了しました!', description: - 'ボットが作成され、AIパイプラインに接続されました。ワークベンチから管理できます。', + 'ボットが作成され、プロセッサーに接続されました。ワークベンチから管理できます。', backToWorkbench: 'ワークベンチに戻る', }, }, diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 0517aef3b..960963817 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -1834,7 +1834,7 @@ const zhHans = { next: '下一步', finish: '创建并部署', confirmCreateBot: '确定,创建机器人', - createSuccess: '流水线已创建并关联到机器人!', + createSuccess: '处理器已创建并关联到机器人!', botCreateSuccess: '机器人创建成功!', botSaveSuccess: '机器人配置已保存并启用!', createError: '创建资源失败', @@ -1843,13 +1843,32 @@ const zhHans = { completeSaveError: '保存完成状态失败,请重试。', step: { platform: '平台接入', + scenarioChannel: '场景与频道', botConfig: '机器人配置', aiEngine: 'AI 引擎', done: '完成', }, + scenario: { + title: '这个机器人要完成什么?', + description: '先选择一个主要结果,机器人创建后还可以继续添加其他行为。', + messageReply: '回复收到的消息', + messageReplyDescription: '使用 AI Pipeline 回复私聊或群聊消息。', + welcomeMembers: '欢迎新成员', + welcomeMembersDescription: '有人加入群组时运行 Agent。', + handleDepartures: '处理成员离群', + handleDeparturesDescription: '有人离开或被移出群组时运行 Agent。', + reviewFriendRequests: '审核好友请求', + reviewFriendRequestsDescription: '让 Agent 决定如何处理新的好友请求。', + handleModeration: '处理群管理事件', + handleModerationDescription: '群成员受到限制时运行 Agent。', + pipelineBadge: 'Pipeline', + agentBadge: 'Agent', + }, platform: { - title: '选择平台', - description: '选择机器人要接入的消息平台。', + title: '选择频道', + description: '这里只显示支持所选场景的频道。', + chooseScenarioFirst: '请先选择场景,再查看可用频道。', + noCompatiblePlatforms: '当前安装的频道都不支持这个场景。', }, botConfig: { title: '配置机器人', @@ -1877,8 +1896,7 @@ const zhHans = { }, done: { title: '一切就绪!', - description: - '机器人已创建并连接到 AI 流水线。你现在可以在工作台中管理它。', + description: '机器人已创建并连接到处理器。你现在可以在工作台中管理它。', backToWorkbench: '返回工作台', }, },