feat(wizard): start bot setup from scenarios

This commit is contained in:
huanghuoguoguo
2026-07-11 11:45:39 +08:00
parent a2b30957b6
commit 24322836ce
10 changed files with 963 additions and 208 deletions
@@ -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));
+27 -1
View File
@@ -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": [
@@ -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
@@ -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)
+1
View File
@@ -445,6 +445,7 @@ export interface SystemLimitation {
export interface WizardProgress {
step: number;
selected_scenario?: string | null;
selected_adapter: string | null;
created_bot_uuid: string | null;
bot_saved: boolean;
+1
View File
@@ -1072,6 +1072,7 @@ export class BackendClient extends BaseHttpClient {
public saveWizardProgress(progress: {
step: number;
selected_scenario?: string | null;
selected_adapter: string | null;
created_bot_uuid: string | null;
bot_saved: boolean;
+442 -191
View File
@@ -12,6 +12,11 @@ import {
Sparkles,
PartyPopper,
Loader2,
MessageSquare,
ShieldCheck,
UserCheck,
UserMinus,
UserPlus,
X,
ExternalLink,
} from 'lucide-react';
@@ -75,6 +80,73 @@ import {
const TOTAL_STEPS = 4;
type WizardScenarioId =
| 'message_reply'
| 'welcome_members'
| 'handle_departures'
| 'review_friend_requests'
| 'handle_moderation';
const WIZARD_SCENARIOS = [
{
id: 'message_reply' as const,
eventType: 'message.received',
processorKind: 'pipeline' as const,
labelKey: 'wizard.scenario.messageReply',
descriptionKey: 'wizard.scenario.messageReplyDescription',
icon: MessageSquare,
emoji: '💬',
},
{
id: 'welcome_members' as const,
eventType: 'group.member_joined',
processorKind: 'agent' as const,
labelKey: 'wizard.scenario.welcomeMembers',
descriptionKey: 'wizard.scenario.welcomeMembersDescription',
icon: UserPlus,
emoji: '👋',
},
{
id: 'handle_departures' as const,
eventType: 'group.member_left',
processorKind: 'agent' as const,
labelKey: 'wizard.scenario.handleDepartures',
descriptionKey: 'wizard.scenario.handleDeparturesDescription',
icon: UserMinus,
emoji: '👤',
},
{
id: 'review_friend_requests' as const,
eventType: 'friend.request_received',
processorKind: 'agent' as const,
labelKey: 'wizard.scenario.reviewFriendRequests',
descriptionKey: 'wizard.scenario.reviewFriendRequestsDescription',
icon: UserCheck,
emoji: '✅',
},
{
id: 'handle_moderation' as const,
eventType: 'group.member_banned',
processorKind: 'agent' as const,
labelKey: 'wizard.scenario.handleModeration',
descriptionKey: 'wizard.scenario.handleModerationDescription',
icon: ShieldCheck,
emoji: '🛡️',
},
];
function adapterSupportsScenario(
adapter: Adapter,
scenarioId: WizardScenarioId,
) {
const scenario = WIZARD_SCENARIOS.find((item) => item.id === scenarioId);
if (!scenario) return false;
const supportedEvents = adapter.spec.supported_events?.length
? adapter.spec.supported_events
: ['message.received'];
return supportedEvents.includes(scenario.eventType);
}
// ---------------------------------------------------------------------------
// Main Wizard Page (full-screen, no sidebar)
// ---------------------------------------------------------------------------
@@ -85,6 +157,8 @@ export default function WizardPage() {
// ---- Wizard state ----
const [currentStep, setCurrentStep] = useState(0);
const [selectedScenario, setSelectedScenario] =
useState<WizardScenarioId | null>(null);
const [selectedAdapter, setSelectedAdapter] = useState<string | null>(null);
const [selectedRunner, setSelectedRunner] = useState<string | null>(null);
const [botName, setBotName] = useState('');
@@ -113,16 +187,36 @@ export default function WizardPage() {
(overrides: Partial<WizardProgress> = {}) => {
const progress: WizardProgress = {
step: overrides.step ?? currentStep,
selected_adapter: overrides.selected_adapter ?? selectedAdapter,
created_bot_uuid: overrides.created_bot_uuid ?? createdBotUuid,
selected_scenario:
overrides.selected_scenario !== undefined
? overrides.selected_scenario
: selectedScenario,
selected_adapter:
overrides.selected_adapter !== undefined
? overrides.selected_adapter
: selectedAdapter,
created_bot_uuid:
overrides.created_bot_uuid !== undefined
? overrides.created_bot_uuid
: createdBotUuid,
bot_saved: overrides.bot_saved ?? botSaved,
selected_runner: overrides.selected_runner ?? selectedRunner,
selected_runner:
overrides.selected_runner !== undefined
? overrides.selected_runner
: selectedRunner,
};
httpClient.saveWizardProgress(progress).catch((err) => {
console.error('Failed to save wizard progress', err);
});
},
[currentStep, selectedAdapter, createdBotUuid, botSaved, selectedRunner],
[
currentStep,
selectedScenario,
selectedAdapter,
createdBotUuid,
botSaved,
selectedRunner,
],
);
// ---- Fetch remote data & restore progress ----
@@ -151,6 +245,10 @@ export default function WizardPage() {
if (cancelled) return;
setSelectedAdapter(progress.selected_adapter);
setSelectedScenario(
(progress.selected_scenario as WizardScenarioId | null) ??
'message_reply',
);
setCreatedBotUuid(progress.created_bot_uuid);
setBotSaved(progress.bot_saved ?? false);
setSelectedRunner(progress.selected_runner);
@@ -167,13 +265,14 @@ export default function WizardPage() {
(runtimeValues?.extra_webhook_full_url as string) || '',
);
// Restore step (cap at step 2 — step 3 means done)
setCurrentStep(Math.min(progress.step, 2));
// Step 3 is resumable so a refresh cannot create a duplicate processor.
setCurrentStep(Math.min(progress.step, 3));
} catch {
// Bot no longer exists — clear stale progress and start fresh
httpClient
.saveWizardProgress({
step: 0,
selected_scenario: null,
selected_adapter: null,
created_bot_uuid: null,
bot_saved: false,
@@ -213,6 +312,11 @@ export default function WizardPage() {
return aiConfigTab.stages.find((s) => s.name === selectedRunner);
}, [selectedRunner, aiConfigTab]);
const selectedScenarioDefinition = useMemo(
() => WIZARD_SCENARIOS.find((item) => item.id === selectedScenario),
[selectedScenario],
);
// Adapter spec config for the selected adapter
const selectedAdapterConfig: IDynamicFormItemSchema[] = useMemo(() => {
const adapter = adapters.find((a) => a.name === selectedAdapter);
@@ -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<string, unknown>)
: {};
const existingRunner =
fullAiConfig.runner && typeof fullAiConfig.runner === 'object'
? (fullAiConfig.runner as Record<string, unknown>)
: {};
const existingRunnerConfigs =
fullAiConfig.runner_config &&
typeof fullAiConfig.runner_config === 'object'
? (fullAiConfig.runner_config as Record<string, unknown>)
: {};
if (selectedScenarioDefinition.processorKind === 'pipeline') {
const pipeline: Pipeline = {
name: `${botName} Pipeline`,
description: botDescription || '',
config: {},
};
const pipelineResp = await httpClient.createPipeline(pipeline);
processorUuid = pipelineResp.uuid;
const createdPipeline = await httpClient.getPipeline(pipelineResp.uuid);
const fullConfig = createdPipeline.pipeline.config as unknown as Record<
string,
unknown
>;
const fullAiConfig =
fullConfig.ai && typeof fullConfig.ai === 'object'
? (fullConfig.ai as Record<string, unknown>)
: {};
const existingRunner =
fullAiConfig.runner && typeof fullAiConfig.runner === 'object'
? (fullAiConfig.runner as Record<string, unknown>)
: {};
const existingRunnerConfigs =
fullAiConfig.runner_config &&
typeof fullAiConfig.runner_config === 'object'
? (fullAiConfig.runner_config as Record<string, unknown>)
: {};
const mergedConfig = {
...fullConfig,
ai: {
...fullAiConfig,
runner: {
...existingRunner,
id: selectedRunner,
await httpClient.updatePipeline(pipelineResp.uuid, {
name: `${botName} Pipeline`,
description: botDescription || '',
config: {
...fullConfig,
ai: {
...fullAiConfig,
runner: { ...existingRunner, id: selectedRunner },
runner_config: {
...existingRunnerConfigs,
[selectedRunner]: runnerConfig,
},
},
},
runner_config: {
...existingRunnerConfigs,
[selectedRunner]: runnerConfig,
});
targetType = 'pipeline';
} else {
const agentResp = await httpClient.createAgent({
kind: 'agent',
name: `${botName} - ${t(selectedScenarioDefinition.labelKey)}`,
description: botDescription || '',
emoji: selectedScenarioDefinition.emoji,
component_ref: selectedRunner,
config: {
runner: { id: selectedRunner, 'expire-time': 0 },
runner_config: { [selectedRunner]: runnerConfig },
},
},
};
enabled: true,
supported_event_patterns: [selectedScenarioDefinition.eventType],
});
processorUuid = agentResp.uuid;
targetType = 'agent';
}
await httpClient.updatePipeline(pipelineResp.uuid, {
name: `${botName} Pipeline`,
description: botDescription || '',
config: mergedConfig,
});
// 3. Link pipeline to the bot created in Step 1
const botData = await httpClient.getBot(createdBotUuid);
const existingBot = botData.bot;
await httpClient.updateBot(createdBotUuid, {
@@ -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 && (
<StepPlatform
adapters={adapters}
selectedScenario={selectedScenario}
onSelectScenario={handleSelectScenario}
selected={selectedAdapter}
onSelect={setSelectedAdapter}
/>
@@ -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 (
<div className="space-y-6 max-w-4xl mx-auto">
<div className="text-center">
<h2 className="text-xl font-semibold">{t('wizard.platform.title')}</h2>
<p className="text-sm text-muted-foreground mt-1">
{t('wizard.platform.description')}
</p>
</div>
{groupedAdapters.map((group) => (
<div key={group.categoryId ?? 'uncategorized'} className="space-y-3">
{group.categoryId && (
<h3 className="text-sm font-medium text-muted-foreground">
{getCategoryLabel(t, group.categoryId)}
</h3>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{group.items.map((adapter) => (
<Card
key={adapter.name}
<div className="mx-auto max-w-5xl space-y-8">
<section className="space-y-3">
<div>
<h2 className="text-xl font-semibold">
{t('wizard.scenario.title')}
</h2>
<p className="mt-1 text-sm text-muted-foreground">
{t('wizard.scenario.description')}
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{WIZARD_SCENARIOS.map((scenario) => {
const Icon = scenario.icon;
const isSelected = selectedScenario === scenario.id;
return (
<button
key={scenario.id}
type="button"
onClick={() => onSelectScenario(scenario.id)}
className={cn(
'cursor-pointer transition-all hover:shadow-md',
selected === adapter.name
? 'ring-2 ring-primary shadow-md'
: 'hover:border-primary/50',
'rounded-md border bg-card p-3 text-left transition-colors',
isSelected
? 'border-primary ring-2 ring-primary/20'
: 'hover:border-primary/60',
)}
onClick={() => onSelect(adapter.name)}
>
<CardHeader className="flex flex-row items-center gap-3 pb-2">
<img
src={httpClient.getAdapterIconURL(adapter.name)}
alt=""
className="w-10 h-10 rounded-lg shrink-0"
/>
<div className="min-w-0">
<CardTitle className="text-base truncate">
{extractI18nObject(adapter.label)}
</CardTitle>
</div>
{selected === adapter.name && (
<div className="ml-auto shrink-0">
<div className="w-5 h-5 rounded-full bg-primary flex items-center justify-center">
<Check className="w-3 h-3 text-primary-foreground" />
</div>
</div>
)}
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground line-clamp-2">
{extractI18nObject(adapter.description)}
</p>
{(() => {
const docUrl = getAdapterDocUrl(
adapter.spec.help_links,
i18n.language,
);
return docUrl ? (
<a
href={docUrl}
target="_blank"
rel="noopener noreferrer"
className="mt-2 inline-flex items-center text-xs text-primary hover:underline"
onClick={(e) => e.stopPropagation()}
>
<ExternalLink className="mr-1 h-3 w-3" />
{t('bots.viewAdapterDocs')}
</a>
) : null;
})()}
</CardContent>
</Card>
))}
</div>
</div>
))}
{legacyAdapters.length > 0 && (
<div className="border-t pt-4 space-y-3">
<button
type="button"
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground"
onClick={() => setShowLegacy((v) => !v)}
>
{showLegacy ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
{t('bots.legacyAdapters')}
<span className="rounded bg-muted px-1.5 py-0.5 text-xs">
{legacyAdapters.length}
</span>
</button>
{showLegacy && (
<>
<p className="text-xs text-muted-foreground">
{t('bots.legacyAdaptersHint')}
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 opacity-60">
{legacyAdapters.map((adapter) => (
<Card
key={adapter.name}
<div className="flex items-start gap-3">
<span
className={cn(
'cursor-pointer transition-all hover:shadow-md',
selected === adapter.name
? 'ring-2 ring-primary shadow-md'
: 'hover:border-primary/50',
'flex h-8 w-8 shrink-0 items-center justify-center rounded-md',
isSelected
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground',
)}
onClick={() => onSelect(adapter.name)}
>
<CardHeader className="flex flex-row items-center gap-3 pb-2">
<img
src={httpClient.getAdapterIconURL(adapter.name)}
alt=""
className="w-10 h-10 rounded-lg shrink-0 grayscale"
/>
<div className="min-w-0">
<CardTitle className="text-base truncate">
{extractI18nObject(adapter.label)}
</CardTitle>
</div>
{selected === adapter.name && (
<div className="ml-auto shrink-0">
<div className="w-5 h-5 rounded-full bg-primary flex items-center justify-center">
<Check className="w-3 h-3 text-primary-foreground" />
</div>
</div>
)}
</CardHeader>
</Card>
))}
</div>
</>
)}
<Icon className="h-4 w-4" />
</span>
<span className="min-w-0 flex-1">
<span className="flex items-center justify-between gap-2">
<span className="font-medium">
{t(scenario.labelKey)}
</span>
<span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
{t(
scenario.processorKind === 'pipeline'
? 'wizard.scenario.pipelineBadge'
: 'wizard.scenario.agentBadge',
)}
</span>
</span>
<span className="mt-1 block text-sm leading-snug text-muted-foreground">
{t(scenario.descriptionKey)}
</span>
</span>
</div>
</button>
);
})}
</div>
)}
</section>
<section className="space-y-5 border-t pt-6">
<div className="text-center">
<h2 className="text-xl font-semibold">
{t('wizard.platform.title')}
</h2>
<p className="text-sm text-muted-foreground mt-1">
{t('wizard.platform.description')}
</p>
</div>
{!selectedScenario && (
<div className="rounded-md border border-dashed p-5 text-center text-sm text-muted-foreground">
{t('wizard.platform.chooseScenarioFirst')}
</div>
)}
{selectedScenario &&
activeAdapters.length === 0 &&
legacyAdapters.length === 0 && (
<div className="rounded-md border border-dashed p-5 text-center text-sm text-muted-foreground">
{t('wizard.platform.noCompatiblePlatforms')}
</div>
)}
{groupedAdapters.map((group) => (
<div key={group.categoryId ?? 'uncategorized'} className="space-y-3">
{group.categoryId && (
<h3 className="text-sm font-medium text-muted-foreground">
{getCategoryLabel(t, group.categoryId)}
</h3>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{group.items.map((adapter) => (
<Card
key={adapter.name}
className={cn(
'cursor-pointer transition-all hover:shadow-md',
selected === adapter.name
? 'ring-2 ring-primary shadow-md'
: 'hover:border-primary/50',
)}
onClick={() => onSelect(adapter.name)}
>
<CardHeader className="flex flex-row items-center gap-3 pb-2">
<img
src={httpClient.getAdapterIconURL(adapter.name)}
alt=""
className="w-10 h-10 rounded-lg shrink-0"
/>
<div className="min-w-0">
<CardTitle className="text-base truncate">
{extractI18nObject(adapter.label)}
</CardTitle>
</div>
{selected === adapter.name && (
<div className="ml-auto shrink-0">
<div className="w-5 h-5 rounded-full bg-primary flex items-center justify-center">
<Check className="w-3 h-3 text-primary-foreground" />
</div>
</div>
)}
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground line-clamp-2">
{extractI18nObject(adapter.description)}
</p>
{(() => {
const docUrl = getAdapterDocUrl(
adapter.spec.help_links,
i18n.language,
);
return docUrl ? (
<a
href={docUrl}
target="_blank"
rel="noopener noreferrer"
className="mt-2 inline-flex items-center text-xs text-primary hover:underline"
onClick={(e) => e.stopPropagation()}
>
<ExternalLink className="mr-1 h-3 w-3" />
{t('bots.viewAdapterDocs')}
</a>
) : null;
})()}
</CardContent>
</Card>
))}
</div>
</div>
))}
{legacyAdapters.length > 0 && (
<div className="border-t pt-4 space-y-3">
<button
type="button"
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground"
onClick={() => setShowLegacy((v) => !v)}
>
{showLegacy ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
{t('bots.legacyAdapters')}
<span className="rounded bg-muted px-1.5 py-0.5 text-xs">
{legacyAdapters.length}
</span>
</button>
{showLegacy && (
<>
<p className="text-xs text-muted-foreground">
{t('bots.legacyAdaptersHint')}
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 opacity-60">
{legacyAdapters.map((adapter) => (
<Card
key={adapter.name}
className={cn(
'cursor-pointer transition-all hover:shadow-md',
selected === adapter.name
? 'ring-2 ring-primary shadow-md'
: 'hover:border-primary/50',
)}
onClick={() => onSelect(adapter.name)}
>
<CardHeader className="flex flex-row items-center gap-3 pb-2">
<img
src={httpClient.getAdapterIconURL(adapter.name)}
alt=""
className="w-10 h-10 rounded-lg shrink-0 grayscale"
/>
<div className="min-w-0">
<CardTitle className="text-base truncate">
{extractI18nObject(adapter.label)}
</CardTitle>
</div>
{selected === adapter.name && (
<div className="ml-auto shrink-0">
<div className="w-5 h-5 rounded-full bg-primary flex items-center justify-center">
<Check className="w-3 h-3 text-primary-foreground" />
</div>
</div>
)}
</CardHeader>
</Card>
))}
</div>
</>
)}
</div>
)}
</section>
</div>
);
}
@@ -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,
+30 -4
View File
@@ -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',
},
},
+31 -5
View File
@@ -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: 'ワークベンチに戻る',
},
},
+23 -5
View File
@@ -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: '返回工作台',
},
},