mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-26 11:26:55 +08:00
fix(wizard): restore message pipeline setup and page bot preview
This commit is contained in:
@@ -199,7 +199,9 @@ class EmbedRouterGroup(group.RouterGroup):
|
||||
|
||||
base_url = quart.request.host_url.rstrip('/')
|
||||
webhook_prefix = self.ap.instance_config.data.get('api', {}).get('webhook_prefix', '')
|
||||
if webhook_prefix:
|
||||
# The wizard previews the currently connected backend, which can
|
||||
# differ from the public address used by external website embeds.
|
||||
if webhook_prefix and quart.request.args.get('preview') != 'wizard':
|
||||
base_url = webhook_prefix.rstrip('/')
|
||||
|
||||
if not re.match(r'^https?://[a-zA-Z0-9._:/-]+$', base_url):
|
||||
|
||||
@@ -148,6 +148,21 @@ class TestEmbedWidgetEndpoint:
|
||||
assert 'javascript' in response.content_type
|
||||
fake_embed_app.platform_mgr.resolve_public_bot.assert_any_await('a1b2c3d4-5678-90ab-cdef-123456789abc')
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
('query', 'expected_base'),
|
||||
[('', 'https://public.example/bot'), ('?preview=wizard', 'http://localhost')],
|
||||
)
|
||||
async def test_widget_preview_uses_current_backend(
|
||||
self, quart_test_client, fake_embed_app, monkeypatch, query, expected_base
|
||||
):
|
||||
monkeypatch.setitem(fake_embed_app.instance_config.data['api'], 'webhook_prefix', 'https://public.example/bot')
|
||||
response = await quart_test_client.get('/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/widget.js' + query)
|
||||
assert response.status_code == 200
|
||||
body = await response.get_data(as_text=True)
|
||||
assert f'baseUrl: "{expected_base}"' in body
|
||||
assert f'logoUrl: "{expected_base}"' in body
|
||||
|
||||
def test_widget_template_cache_reloads_after_file_change(self, monkeypatch, tmp_path):
|
||||
"""Development edits to widget.js take effect without restarting the backend."""
|
||||
import langbot.pkg.api.http.controller.groups.pipelines.embed as embed
|
||||
|
||||
+94
-324
@@ -11,12 +11,9 @@ import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Sparkles,
|
||||
MessageSquare,
|
||||
PartyPopper,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
ShieldCheck,
|
||||
UserMinus,
|
||||
UserPlus,
|
||||
X,
|
||||
ExternalLink,
|
||||
Download,
|
||||
@@ -41,10 +38,7 @@ import {
|
||||
Pipeline,
|
||||
WizardProgress,
|
||||
} from '@/app/infra/entities/api';
|
||||
import {
|
||||
DynamicFormItemType,
|
||||
IDynamicFormItemSchema,
|
||||
} from '@/app/infra/entities/form/dynamic';
|
||||
import { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic';
|
||||
import {
|
||||
PipelineConfigTab,
|
||||
PipelineConfigStage,
|
||||
@@ -108,67 +102,9 @@ import {
|
||||
const TOTAL_STEPS = 4;
|
||||
const WIZARD_RUNNER_INSTALL_SCOPE = 'wizard';
|
||||
|
||||
type WizardScenarioId =
|
||||
| 'message_reply'
|
||||
| 'welcome_members'
|
||||
| 'handle_departures'
|
||||
| 'handle_moderation';
|
||||
|
||||
const WIZARD_SCENARIO_PROMPT_KEYS: Partial<Record<WizardScenarioId, string>> = {
|
||||
welcome_members: 'wizard.scenario.welcomeMembersPrompt',
|
||||
handle_departures: 'wizard.scenario.handleDeparturesPrompt',
|
||||
handle_moderation: 'wizard.scenario.handleModerationPrompt',
|
||||
};
|
||||
|
||||
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: '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);
|
||||
function adapterSupportsMessages(adapter: Adapter) {
|
||||
const events = adapter.spec.supported_events;
|
||||
return !events?.length || events.includes('message.received');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -181,8 +117,6 @@ 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('');
|
||||
@@ -245,10 +179,7 @@ export default function WizardPage() {
|
||||
(overrides: Partial<WizardProgress> = {}) => {
|
||||
const progress: WizardProgress = {
|
||||
step: overrides.step ?? currentStep,
|
||||
selected_scenario:
|
||||
overrides.selected_scenario !== undefined
|
||||
? overrides.selected_scenario
|
||||
: selectedScenario,
|
||||
selected_scenario: null,
|
||||
selected_adapter:
|
||||
overrides.selected_adapter !== undefined
|
||||
? overrides.selected_adapter
|
||||
@@ -274,7 +205,6 @@ export default function WizardPage() {
|
||||
},
|
||||
[
|
||||
currentStep,
|
||||
selectedScenario,
|
||||
selectedAdapter,
|
||||
createdBotUuid,
|
||||
createdPipelineUuid,
|
||||
@@ -314,6 +244,16 @@ export default function WizardPage() {
|
||||
if (progress && progress.created_bot_uuid) {
|
||||
// Verify the bot still exists before restoring
|
||||
try {
|
||||
// Scenario-based drafts may point at Agents. Leave those resources
|
||||
// untouched and start a new message-only setup instead.
|
||||
if (
|
||||
progress.selected_scenario &&
|
||||
progress.selected_scenario !== 'message_reply'
|
||||
) {
|
||||
throw new Error(
|
||||
'Scenario-based wizard draft is no longer supported',
|
||||
);
|
||||
}
|
||||
const botData = await httpClient.getBot(progress.created_bot_uuid);
|
||||
if (cancelled) return;
|
||||
|
||||
@@ -330,10 +270,6 @@ export default function WizardPage() {
|
||||
const configNeedsSave = configToRestore !== restoredConfig;
|
||||
|
||||
setSelectedAdapter(restoredAdapter);
|
||||
setSelectedScenario(
|
||||
(progress.selected_scenario as WizardScenarioId | null) ??
|
||||
'message_reply',
|
||||
);
|
||||
setCreatedBotUuid(progress.created_bot_uuid);
|
||||
setCreatedPipelineUuid(
|
||||
progress.created_pipeline_uuid ??
|
||||
@@ -366,7 +302,7 @@ export default function WizardPage() {
|
||||
// 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
|
||||
// Clear stale or unsupported progress without modifying its resources.
|
||||
httpClient
|
||||
.saveWizardProgress({
|
||||
step: 0,
|
||||
@@ -412,11 +348,6 @@ 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);
|
||||
@@ -478,19 +409,10 @@ export default function WizardPage() {
|
||||
setSelectedRunner(runner);
|
||||
const configStage = configTab?.stages.find((s) => s.name === runner);
|
||||
const defaults = configStage ? getDefaultValues(configStage.config) : {};
|
||||
const promptKey = selectedScenario
|
||||
? WIZARD_SCENARIO_PROMPT_KEYS[selectedScenario]
|
||||
: undefined;
|
||||
const supportsPromptEditor = configStage?.config.some(
|
||||
(item) => item.type === DynamicFormItemType.PROMPT_EDITOR,
|
||||
);
|
||||
if (promptKey && supportsPromptEditor) {
|
||||
defaults.prompt = [{ role: 'system', content: t(promptKey) }];
|
||||
}
|
||||
setRunnerConfig(defaults);
|
||||
saveProgress({ step: 2, selected_runner: runner });
|
||||
},
|
||||
[aiConfigTab, saveProgress, selectedScenario, t],
|
||||
[aiConfigTab, saveProgress],
|
||||
);
|
||||
|
||||
const handleInstallRunner = useCallback(
|
||||
@@ -579,13 +501,9 @@ export default function WizardPage() {
|
||||
const canProceed = useCallback((): boolean => {
|
||||
switch (currentStep) {
|
||||
case 0:
|
||||
return selectedScenario !== null && selectedAdapter !== null;
|
||||
return selectedAdapter !== null;
|
||||
case 1:
|
||||
return (
|
||||
createdBotUuid !== null &&
|
||||
botSaved &&
|
||||
(selectedScenario !== 'message_reply' || messageReceived)
|
||||
);
|
||||
return createdBotUuid !== null && botSaved && messageReceived;
|
||||
case 2:
|
||||
return selectedRunner !== null && isRunnerConfigComplete;
|
||||
default:
|
||||
@@ -593,7 +511,6 @@ export default function WizardPage() {
|
||||
}
|
||||
}, [
|
||||
currentStep,
|
||||
selectedScenario,
|
||||
selectedAdapter,
|
||||
createdBotUuid,
|
||||
botSaved,
|
||||
@@ -602,24 +519,6 @@ export default function WizardPage() {
|
||||
isRunnerConfigComplete,
|
||||
]);
|
||||
|
||||
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()) {
|
||||
const nextStep = currentStep + 1;
|
||||
@@ -691,7 +590,7 @@ export default function WizardPage() {
|
||||
// Persist progress
|
||||
saveProgress({
|
||||
step: 1,
|
||||
selected_scenario: selectedScenario,
|
||||
selected_scenario: null,
|
||||
selected_adapter: selectedAdapter,
|
||||
created_bot_uuid: resp.uuid,
|
||||
created_pipeline_uuid: null,
|
||||
@@ -707,7 +606,7 @@ export default function WizardPage() {
|
||||
} finally {
|
||||
setIsCreatingBot(false);
|
||||
}
|
||||
}, [selectedScenario, selectedAdapter, adapters, t, saveProgress]);
|
||||
}, [selectedAdapter, adapters, t, saveProgress]);
|
||||
|
||||
// ---- Save Bot Config & Enable (Step 1) ----
|
||||
// Updates the bot's adapter config and enables it.
|
||||
@@ -725,10 +624,7 @@ export default function WizardPage() {
|
||||
);
|
||||
setAdapterConfig(configToSave);
|
||||
|
||||
if (
|
||||
selectedScenarioDefinition?.processorKind === 'pipeline' &&
|
||||
!previewPipelineUuid
|
||||
) {
|
||||
if (!previewPipelineUuid) {
|
||||
const pipelineResp = await httpClient.createPipeline({
|
||||
name: `${botName} Pipeline`,
|
||||
description: botDescription || '',
|
||||
@@ -745,13 +641,10 @@ export default function WizardPage() {
|
||||
adapter_config: configToSave,
|
||||
enable: true,
|
||||
};
|
||||
if (
|
||||
selectedScenarioDefinition?.processorKind === 'pipeline' &&
|
||||
previewPipelineUuid
|
||||
) {
|
||||
if (previewPipelineUuid) {
|
||||
botUpdate.event_bindings = [
|
||||
{
|
||||
event_pattern: selectedScenarioDefinition.eventType,
|
||||
event_pattern: 'message.received',
|
||||
target_type: 'pipeline',
|
||||
target_uuid: previewPipelineUuid,
|
||||
filters: [],
|
||||
@@ -818,7 +711,6 @@ export default function WizardPage() {
|
||||
botDescription,
|
||||
adapterConfig,
|
||||
createdPipelineUuid,
|
||||
selectedScenarioDefinition,
|
||||
t,
|
||||
saveProgress,
|
||||
]);
|
||||
@@ -832,84 +724,57 @@ export default function WizardPage() {
|
||||
// ---- Create Pipeline & Link (Step 2 finish) ----
|
||||
|
||||
const handleFinish = useCallback(async () => {
|
||||
if (
|
||||
!selectedRunner ||
|
||||
!isRunnerConfigComplete ||
|
||||
!createdBotUuid ||
|
||||
!selectedScenarioDefinition
|
||||
)
|
||||
return;
|
||||
if (!selectedRunner || !isRunnerConfigComplete || !createdBotUuid) return;
|
||||
setIsSubmitting(true);
|
||||
let processorUuid = '';
|
||||
let processorCreatedThisAttempt = false;
|
||||
let targetType: 'agent' | 'pipeline' | null = null;
|
||||
|
||||
try {
|
||||
if (selectedScenarioDefinition.processorKind === 'pipeline') {
|
||||
targetType = 'pipeline';
|
||||
processorUuid = createdPipelineUuid ?? '';
|
||||
if (!processorUuid) {
|
||||
const pipeline: Pipeline = {
|
||||
name: `${botName} Pipeline`,
|
||||
description: botDescription || '',
|
||||
config: {},
|
||||
};
|
||||
const pipelineResp = await httpClient.createPipeline(pipeline);
|
||||
processorUuid = pipelineResp.uuid;
|
||||
processorCreatedThisAttempt = true;
|
||||
}
|
||||
const createdPipeline = await httpClient.getPipeline(processorUuid);
|
||||
const fullConfig = createdPipeline.pipeline.config as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const fullAiConfig =
|
||||
fullConfig.ai && typeof fullConfig.ai === 'object'
|
||||
? (fullConfig.ai as Record<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>)
|
||||
: {};
|
||||
|
||||
await httpClient.updatePipeline(processorUuid, {
|
||||
processorUuid = createdPipelineUuid ?? '';
|
||||
if (!processorUuid) {
|
||||
const pipeline: Pipeline = {
|
||||
name: `${botName} Pipeline`,
|
||||
description: botDescription || '',
|
||||
config: {
|
||||
...fullConfig,
|
||||
ai: {
|
||||
...fullAiConfig,
|
||||
runner: { ...existingRunner, id: selectedRunner },
|
||||
runner_config: {
|
||||
...existingRunnerConfigs,
|
||||
[selectedRunner]: runnerConfig,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
} else {
|
||||
targetType = 'agent';
|
||||
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 },
|
||||
},
|
||||
supported_event_patterns: [selectedScenarioDefinition.eventType],
|
||||
});
|
||||
processorUuid = agentResp.uuid;
|
||||
config: {},
|
||||
};
|
||||
const pipelineResp = await httpClient.createPipeline(pipeline);
|
||||
processorUuid = pipelineResp.uuid;
|
||||
processorCreatedThisAttempt = true;
|
||||
}
|
||||
const createdPipeline = await httpClient.getPipeline(processorUuid);
|
||||
const fullConfig = createdPipeline.pipeline.config as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const fullAiConfig =
|
||||
fullConfig.ai && typeof fullConfig.ai === 'object'
|
||||
? (fullConfig.ai as Record<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>)
|
||||
: {};
|
||||
|
||||
await httpClient.updatePipeline(processorUuid, {
|
||||
name: `${botName} Pipeline`,
|
||||
description: botDescription || '',
|
||||
config: {
|
||||
...fullConfig,
|
||||
ai: {
|
||||
...fullAiConfig,
|
||||
runner: { ...existingRunner, id: selectedRunner },
|
||||
runner_config: {
|
||||
...existingRunnerConfigs,
|
||||
[selectedRunner]: runnerConfig,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const botData = await httpClient.getBot(createdBotUuid);
|
||||
const existingBot = botData.bot;
|
||||
await httpClient.updateBot(createdBotUuid, {
|
||||
@@ -920,8 +785,8 @@ export default function WizardPage() {
|
||||
enable: existingBot.enable,
|
||||
event_bindings: [
|
||||
{
|
||||
event_pattern: selectedScenarioDefinition.eventType,
|
||||
target_type: targetType,
|
||||
event_pattern: 'message.received',
|
||||
target_type: 'pipeline',
|
||||
target_uuid: processorUuid,
|
||||
filters: [],
|
||||
priority: 0,
|
||||
@@ -932,21 +797,15 @@ export default function WizardPage() {
|
||||
});
|
||||
|
||||
setCurrentStep(3);
|
||||
if (targetType === 'pipeline') {
|
||||
setCreatedPipelineUuid(processorUuid);
|
||||
}
|
||||
setCreatedPipelineUuid(processorUuid);
|
||||
saveProgress({
|
||||
step: 3,
|
||||
created_pipeline_uuid: targetType === 'pipeline' ? processorUuid : null,
|
||||
created_pipeline_uuid: processorUuid,
|
||||
});
|
||||
} catch (err) {
|
||||
if (processorCreatedThisAttempt && processorUuid) {
|
||||
try {
|
||||
if (targetType === 'pipeline') {
|
||||
await httpClient.deletePipeline(processorUuid);
|
||||
} else {
|
||||
await httpClient.deleteAgent(processorUuid);
|
||||
}
|
||||
await httpClient.deletePipeline(processorUuid);
|
||||
} catch (rollbackError) {
|
||||
console.warn('Failed to roll back wizard processor', rollbackError);
|
||||
}
|
||||
@@ -963,7 +822,6 @@ export default function WizardPage() {
|
||||
isRunnerConfigComplete,
|
||||
createdBotUuid,
|
||||
createdPipelineUuid,
|
||||
selectedScenarioDefinition,
|
||||
botName,
|
||||
botDescription,
|
||||
runnerConfig,
|
||||
@@ -1014,7 +872,7 @@ export default function WizardPage() {
|
||||
}
|
||||
|
||||
const stepLabels = [
|
||||
t('wizard.step.scenarioChannel'),
|
||||
t('wizard.step.platform'),
|
||||
t('wizard.step.botConfig'),
|
||||
t('wizard.step.aiEngine'),
|
||||
t('wizard.step.done'),
|
||||
@@ -1103,8 +961,6 @@ export default function WizardPage() {
|
||||
{currentStep === 0 && (
|
||||
<StepPlatform
|
||||
adapters={adapters}
|
||||
selectedScenario={selectedScenario}
|
||||
onSelectScenario={handleSelectScenario}
|
||||
selected={selectedAdapter}
|
||||
onSelect={setSelectedAdapter}
|
||||
/>
|
||||
@@ -1121,7 +977,7 @@ export default function WizardPage() {
|
||||
botSaved={botSaved}
|
||||
pageBotPreviewRequest={pageBotPreviewRequest}
|
||||
messageReceived={messageReceived}
|
||||
requiresMessageVerification={selectedScenario === 'message_reply'}
|
||||
requiresMessageVerification
|
||||
onMessageReceived={handleMessageReceived}
|
||||
onSaveBot={handleSaveBot}
|
||||
webhookUrl={webhookUrl}
|
||||
@@ -1227,41 +1083,28 @@ 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;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [showLegacy, setShowLegacy] = useState(false);
|
||||
|
||||
const activeAdapters = useMemo(
|
||||
const messageAdapters = useMemo(
|
||||
() =>
|
||||
selectedScenario
|
||||
? adapters.filter(
|
||||
(adapter) =>
|
||||
!adapter.spec.legacy &&
|
||||
adapterSupportsScenario(adapter, selectedScenario),
|
||||
)
|
||||
: [],
|
||||
[adapters, selectedScenario],
|
||||
Array.from(
|
||||
new Map(adapters.map((adapter) => [adapter.name, adapter])).values(),
|
||||
).filter(adapterSupportsMessages),
|
||||
[adapters],
|
||||
);
|
||||
const legacyAdapters = useMemo(
|
||||
() =>
|
||||
selectedScenario
|
||||
? adapters.filter(
|
||||
(adapter) =>
|
||||
adapter.spec.legacy &&
|
||||
adapterSupportsScenario(adapter, selectedScenario),
|
||||
)
|
||||
: [],
|
||||
[adapters, selectedScenario],
|
||||
const activeAdapters = messageAdapters.filter(
|
||||
(adapter) => !adapter.spec.legacy,
|
||||
);
|
||||
const legacyAdapters = messageAdapters.filter(
|
||||
(adapter) => adapter.spec.legacy,
|
||||
);
|
||||
|
||||
const groupedAdapters = useMemo(() => {
|
||||
@@ -1273,68 +1116,8 @@ function StepPlatform({
|
||||
}, [activeAdapters]);
|
||||
|
||||
return (
|
||||
<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">
|
||||
{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(
|
||||
'rounded-md border bg-card p-3 text-left transition-colors',
|
||||
isSelected
|
||||
? 'border-primary ring-2 ring-primary/20'
|
||||
: 'hover:border-primary/60',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span
|
||||
className={cn(
|
||||
'flex h-8 w-8 shrink-0 items-center justify-center rounded-md',
|
||||
isSelected
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<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="mx-auto max-w-4xl space-y-6">
|
||||
<section className="space-y-5">
|
||||
<div className="text-center">
|
||||
<h2 className="text-xl font-semibold">
|
||||
{t('wizard.platform.title')}
|
||||
@@ -1343,18 +1126,6 @@ function StepPlatform({
|
||||
{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 && (
|
||||
@@ -1501,15 +1272,22 @@ function PageBotFloatingWidget({
|
||||
testNotice: string;
|
||||
openRequest: number;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
useEffect(() => {
|
||||
const script = document.createElement('script');
|
||||
script.src = `${window.location.origin}/api/v1/embed/${botUuid}/widget.js?preview=wizard&v=${Date.now()}`;
|
||||
const backendUrl = httpClient.getBaseUrl().replace(/\/$/, '');
|
||||
script.src = `${backendUrl}/api/v1/embed/${botUuid}/widget.js?preview=wizard&v=${Date.now()}`;
|
||||
script.dataset.title = title || 'LangBot';
|
||||
script.dataset.testNotice = testNotice;
|
||||
script.dataset.autoOpen = 'true';
|
||||
script.onerror = () =>
|
||||
toast.error(t('wizard.botConfig.pageBotPreviewFailed'), {
|
||||
id: `wizard-page-bot-${botUuid}`,
|
||||
});
|
||||
document.body.appendChild(script);
|
||||
|
||||
return () => {
|
||||
script.onerror = null;
|
||||
script.remove();
|
||||
const root = document.getElementById('langbot-widget-root') as
|
||||
| (HTMLElement & {
|
||||
@@ -1523,15 +1301,7 @@ function PageBotFloatingWidget({
|
||||
root?.remove();
|
||||
}
|
||||
};
|
||||
}, [botUuid, testNotice, title]);
|
||||
|
||||
useEffect(() => {
|
||||
if (openRequest <= 0) return;
|
||||
const root = document.getElementById('langbot-widget-root') as
|
||||
| (HTMLElement & { langbotOpen?: () => void })
|
||||
| null;
|
||||
root?.langbotOpen?.();
|
||||
}, [openRequest]);
|
||||
}, [botUuid, testNotice, title, openRequest, t]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -2529,7 +2529,7 @@ const enUS = {
|
||||
next: 'Next',
|
||||
finish: 'Create & Deploy',
|
||||
confirmCreateBot: 'Confirm, Create Bot',
|
||||
createSuccess: 'Processor created and linked to the bot successfully!',
|
||||
createSuccess: 'Pipeline created and linked to bot successfully!',
|
||||
botCreateSuccess: 'Bot created successfully!',
|
||||
botSaveSuccess: 'Bot configuration saved and enabled!',
|
||||
createError: 'Failed to create resources',
|
||||
@@ -2537,42 +2537,13 @@ 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.',
|
||||
welcomeMembersPrompt:
|
||||
'Welcome new group members with a short, friendly message. Use the available member and group context when present. Do not mention internal event names or system details.',
|
||||
handleDepartures: 'Handle member departures',
|
||||
handleDeparturesDescription:
|
||||
'Run an Agent when someone leaves or is removed.',
|
||||
handleDeparturesPrompt:
|
||||
'Respond to group member departures with a brief, respectful message when a public response is appropriate. Do not speculate about why the member left or mention internal event names.',
|
||||
handleModeration: 'Handle moderation events',
|
||||
handleModerationDescription:
|
||||
'Run an Agent when a group member is restricted.',
|
||||
handleModerationPrompt:
|
||||
'Write a concise, neutral group notice about the member restriction using only the available context. Do not invent details or mention internal event names.',
|
||||
pipelineBadge: 'Pipeline',
|
||||
agentBadge: 'Agent',
|
||||
},
|
||||
platform: {
|
||||
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.',
|
||||
title: 'Select a Platform',
|
||||
description: 'Choose the messaging platform your bot will connect to.',
|
||||
},
|
||||
botConfig: {
|
||||
title: 'Configure Your Bot',
|
||||
@@ -2587,6 +2558,8 @@ const enUS = {
|
||||
'The bot received an IM message. You can continue to the next step.',
|
||||
messageReceivedLocalAccountWarning:
|
||||
'The bot-side connection is configured correctly and received an IM message. Because you are not signed in with a LangBot Account, model calls may fail; continue to the next step to add your own model.',
|
||||
pageBotPreviewFailed:
|
||||
'Failed to load the test chat. Please save the configuration again to retry.',
|
||||
pageBotTestPrompt:
|
||||
'Page Bot is enabled. Click the chat bubble in the lower-right corner and send a message to verify the full conversation flow.',
|
||||
pageBotTestNotice:
|
||||
@@ -2686,7 +2659,7 @@ const enUS = {
|
||||
done: {
|
||||
title: 'All Set!',
|
||||
description:
|
||||
'Your bot has been created and connected to its processor. You can now manage it from the workbench.',
|
||||
'Your bot has been created and connected to its pipeline. You can now manage it from the workbench.',
|
||||
backToWorkbench: 'Back to Workbench',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1990,6 +1990,8 @@ const esES = {
|
||||
'El Bot recibió un mensaje. Puedes continuar al siguiente paso.',
|
||||
messageReceivedLocalAccountWarning:
|
||||
'La conexión del Bot está configurada correctamente y recibió un mensaje. Como no has iniciado sesión con una cuenta de LangBot, las llamadas al modelo pueden fallar; continúa al siguiente paso para añadir tu propio modelo.',
|
||||
pageBotPreviewFailed:
|
||||
'No se pudo cargar el chat de prueba. Guarda la configuración de nuevo para reintentar.',
|
||||
pageBotTestPrompt:
|
||||
'El Bot de página está activado. Haz clic en la burbuja de chat de la esquina inferior derecha y envía un mensaje para verificar el flujo completo de la conversación.',
|
||||
pageBotTestNotice:
|
||||
|
||||
@@ -2308,7 +2308,7 @@ const jaJP = {
|
||||
next: '次へ',
|
||||
finish: '作成&デプロイ',
|
||||
confirmCreateBot: '確定、ボットを作成',
|
||||
createSuccess: 'プロセッサーが作成され、ボットにリンクされました!',
|
||||
createSuccess: 'パイプラインが作成され、ボットにリンクされました!',
|
||||
botCreateSuccess: 'ボットが正常に作成されました!',
|
||||
botSaveSuccess: 'ボット設定が保存され、有効になりました!',
|
||||
createError: 'リソースの作成に失敗しました',
|
||||
@@ -2316,43 +2316,14 @@ const jaJP = {
|
||||
completeSaveError: '完了状態の保存に失敗しました。もう一度お試しください。',
|
||||
step: {
|
||||
platform: 'プラットフォーム',
|
||||
scenarioChannel: 'シナリオとチャンネル',
|
||||
botConfig: 'ボット設定',
|
||||
aiEngine: 'AIエンジン',
|
||||
done: '完了',
|
||||
},
|
||||
scenario: {
|
||||
title: 'このボットで何を実現しますか?',
|
||||
description:
|
||||
'まず主要な動作を1つ選びます。作成後に他の動作も追加できます。',
|
||||
messageReply: '受信メッセージに返信',
|
||||
messageReplyDescription:
|
||||
'AI Pipeline でプライベートまたはグループメッセージに返信します。',
|
||||
welcomeMembers: '新しいメンバーを歓迎',
|
||||
welcomeMembersDescription:
|
||||
'メンバーがグループに参加したときに Agent を実行します。',
|
||||
welcomeMembersPrompt:
|
||||
'新しいグループメンバーを短く親しみやすいメッセージで歓迎してください。利用可能なメンバーとグループの情報を活用し、内部イベント名やシステムの詳細には言及しないでください。',
|
||||
handleDepartures: 'メンバーの退出を処理',
|
||||
handleDeparturesDescription:
|
||||
'メンバーが退出または削除されたときに Agent を実行します。',
|
||||
handleDeparturesPrompt:
|
||||
'公開の応答が適切な場合に、メンバーの退出へ短く敬意のあるメッセージで対応してください。退出理由を推測したり、内部イベント名に言及したりしないでください。',
|
||||
handleModeration: 'モデレーションイベントを処理',
|
||||
handleModerationDescription:
|
||||
'グループメンバーが制限されたときに Agent を実行します。',
|
||||
handleModerationPrompt:
|
||||
'利用可能な情報だけを使い、メンバーへの制限について簡潔で中立的なグループ通知を書いてください。詳細を作り上げたり、内部イベント名に言及したりしないでください。',
|
||||
pipelineBadge: 'Pipeline',
|
||||
agentBadge: 'Agent',
|
||||
},
|
||||
platform: {
|
||||
title: 'チャンネルを選択',
|
||||
description: '選択したシナリオに対応するチャンネルのみ表示されます。',
|
||||
chooseScenarioFirst:
|
||||
'シナリオを選択すると、対応するチャンネルが表示されます。',
|
||||
noCompatiblePlatforms:
|
||||
'現在インストールされているチャンネルはこのシナリオに対応していません。',
|
||||
title: 'プラットフォームを選択',
|
||||
description:
|
||||
'ボットが接続するメッセージングプラットフォームを選択してください。',
|
||||
},
|
||||
botConfig: {
|
||||
title: 'ボットを設定',
|
||||
@@ -2368,6 +2339,8 @@ const jaJP = {
|
||||
'ボットが IM メッセージを受信しました。次のステップに進めます。',
|
||||
messageReceivedLocalAccountWarning:
|
||||
'ボット側の接続設定は正常で、IM メッセージを受信できています。LangBot Account でログインしていないためモデル呼び出しが失敗する場合がありますが、次のステップで独自のモデルを追加できます。',
|
||||
pageBotPreviewFailed:
|
||||
'テストチャットを読み込めませんでした。設定を再保存してお試しください。',
|
||||
pageBotTestPrompt:
|
||||
'ページボットが有効になりました。右下のチャットバブルをクリックしてメッセージを送信し、会話フロー全体を確認してください。',
|
||||
pageBotTestNotice:
|
||||
@@ -2466,7 +2439,7 @@ const jaJP = {
|
||||
done: {
|
||||
title: '完了しました!',
|
||||
description:
|
||||
'ボットが作成され、プロセッサーに接続されました。ワークベンチから管理できます。',
|
||||
'ボットが作成され、パイプラインに接続されました。ワークベンチから管理できます。',
|
||||
backToWorkbench: 'ワークベンチに戻る',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1954,6 +1954,8 @@ const ruRU = {
|
||||
'Бот получил сообщение. Можно перейти к следующему шагу.',
|
||||
messageReceivedLocalAccountWarning:
|
||||
'Подключение бота настроено правильно, и сообщение получено. Поскольку вход выполнен не через аккаунт LangBot, вызовы модели могут завершаться ошибкой; перейдите к следующему шагу, чтобы добавить собственную модель.',
|
||||
pageBotPreviewFailed:
|
||||
'Не удалось загрузить тестовый чат. Сохраните настройки ещё раз, чтобы повторить попытку.',
|
||||
pageBotTestPrompt:
|
||||
'Бот для веб-страницы включён. Нажмите на значок чата в правом нижнем углу и отправьте сообщение, чтобы проверить полный сценарий диалога.',
|
||||
pageBotTestNotice:
|
||||
|
||||
@@ -1917,6 +1917,8 @@ const thTH = {
|
||||
messageReceived: 'Bot ได้รับข้อความแล้ว คุณสามารถไปยังขั้นตอนถัดไปได้',
|
||||
messageReceivedLocalAccountWarning:
|
||||
'การเชื่อมต่อฝั่ง Bot ได้รับการกำหนดค่าอย่างถูกต้องและได้รับข้อความแล้ว เนื่องจากคุณไม่ได้เข้าสู่ระบบด้วยบัญชี LangBot การเรียกใช้โมเดลอาจล้มเหลว โปรดไปยังขั้นตอนถัดไปเพื่อเพิ่มโมเดลของคุณเอง',
|
||||
pageBotPreviewFailed:
|
||||
'โหลดแชททดสอบไม่สำเร็จ โปรดบันทึกการตั้งค่าอีกครั้งเพื่อลองใหม่',
|
||||
pageBotTestPrompt:
|
||||
'เปิดใช้งาน Page Bot แล้ว คลิกฟองแชตที่มุมขวาล่างและส่งข้อความเพื่อตรวจสอบขั้นตอนการสนทนาทั้งหมด',
|
||||
pageBotTestNotice:
|
||||
|
||||
@@ -1944,6 +1944,8 @@ const viVN = {
|
||||
'Bot đã nhận được tin nhắn. Bạn có thể tiếp tục sang bước tiếp theo.',
|
||||
messageReceivedLocalAccountWarning:
|
||||
'Kết nối phía Bot đã được cấu hình đúng và đã nhận được tin nhắn. Vì bạn không đăng nhập bằng tài khoản LangBot, lệnh gọi mô hình có thể thất bại; hãy tiếp tục sang bước tiếp theo để thêm mô hình của riêng bạn.',
|
||||
pageBotPreviewFailed:
|
||||
'Không thể tải cuộc trò chuyện thử nghiệm. Hãy lưu lại cấu hình để thử lại.',
|
||||
pageBotTestPrompt:
|
||||
'Page Bot đã được bật. Nhấp vào bong bóng trò chuyện ở góc dưới bên phải và gửi tin nhắn để xác minh toàn bộ luồng hội thoại.',
|
||||
pageBotTestNotice:
|
||||
|
||||
@@ -2404,7 +2404,7 @@ const zhHans = {
|
||||
next: '下一步',
|
||||
finish: '创建并部署',
|
||||
confirmCreateBot: '确定,创建机器人',
|
||||
createSuccess: '处理器已创建并关联到机器人!',
|
||||
createSuccess: '流水线已创建并关联到机器人!',
|
||||
botCreateSuccess: '机器人创建成功!',
|
||||
botSaveSuccess: '机器人配置已保存并启用!',
|
||||
createError: '创建资源失败',
|
||||
@@ -2412,36 +2412,13 @@ const zhHans = {
|
||||
completeSaveError: '保存完成状态失败,请重试。',
|
||||
step: {
|
||||
platform: '平台接入',
|
||||
scenarioChannel: '场景与频道',
|
||||
botConfig: '机器人配置',
|
||||
aiEngine: 'AI 引擎',
|
||||
done: '完成',
|
||||
},
|
||||
scenario: {
|
||||
title: '这个机器人要完成什么?',
|
||||
description: '先选择一个主要结果,机器人创建后还可以继续添加其他行为。',
|
||||
messageReply: '回复收到的消息',
|
||||
messageReplyDescription: '使用 AI Pipeline 回复私聊或群聊消息。',
|
||||
welcomeMembers: '欢迎新成员',
|
||||
welcomeMembersDescription: '有人加入群组时运行 Agent。',
|
||||
welcomeMembersPrompt:
|
||||
'用简短、友好的消息欢迎新群成员。有成员和群组上下文时请合理使用,不要提及内部事件名称或系统细节。',
|
||||
handleDepartures: '处理成员离群',
|
||||
handleDeparturesDescription: '有人离开或被移出群组时运行 Agent。',
|
||||
handleDeparturesPrompt:
|
||||
'当适合公开回应时,用简短、尊重的消息处理群成员离开。不要猜测成员离开的原因,也不要提及内部事件名称。',
|
||||
handleModeration: '处理群管理事件',
|
||||
handleModerationDescription: '群成员受到限制时运行 Agent。',
|
||||
handleModerationPrompt:
|
||||
'仅根据现有上下文,写一条简洁、中立的群管理通知,说明成员受到的限制。不要编造细节或提及内部事件名称。',
|
||||
pipelineBadge: 'Pipeline',
|
||||
agentBadge: 'Agent',
|
||||
},
|
||||
platform: {
|
||||
title: '选择频道',
|
||||
description: '这里只显示支持所选场景的频道。',
|
||||
chooseScenarioFirst: '请先选择场景,再查看可用频道。',
|
||||
noCompatiblePlatforms: '当前安装的频道都不支持这个场景。',
|
||||
title: '选择平台',
|
||||
description: '选择机器人要接入的消息平台。',
|
||||
},
|
||||
botConfig: {
|
||||
title: '配置机器人',
|
||||
@@ -2453,6 +2430,7 @@ const zhHans = {
|
||||
messageReceived: '机器人已成功收到 IM 消息,可以进入下一步。',
|
||||
messageReceivedLocalAccountWarning:
|
||||
'机器人侧已配置正常并成功收到 IM 消息。当前未通过 LangBot Account 登录,模型调用可能报错;可以进入下一步添加自己的模型。',
|
||||
pageBotPreviewFailed: '测试聊天加载失败,请重新保存配置后重试。',
|
||||
pageBotTestPrompt:
|
||||
'页面机器人已启用。点击右下角聊天气泡并发送一条消息,验证完整对话链路。',
|
||||
pageBotTestNotice: '仅供测试使用,请嵌入代码到真实外部网页。',
|
||||
@@ -2535,7 +2513,7 @@ const zhHans = {
|
||||
},
|
||||
done: {
|
||||
title: '一切就绪!',
|
||||
description: '机器人已创建并连接到处理器。你现在可以在工作台中管理它。',
|
||||
description: '机器人已创建并连接到流水线。你现在可以在工作台中管理它。',
|
||||
backToWorkbench: '返回工作台',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1858,6 +1858,7 @@ const zhHant = {
|
||||
messageReceived: '機器人已成功收到 IM 訊息,可以進入下一步。',
|
||||
messageReceivedLocalAccountWarning:
|
||||
'機器人側已配置正常並成功收到 IM 訊息。目前未透過 LangBot Account 登入,模型呼叫可能報錯;可以進入下一步新增自己的模型。',
|
||||
pageBotPreviewFailed: '測試聊天載入失敗,請重新儲存設定後重試。',
|
||||
pageBotTestPrompt:
|
||||
'頁面機器人已啟用。點擊右下角聊天氣泡並傳送一則訊息,驗證完整對話流程。',
|
||||
pageBotTestNotice: '僅供測試使用,請將程式碼嵌入真實的外部網頁。',
|
||||
|
||||
@@ -81,15 +81,123 @@ function adapterWithQrLogin(
|
||||
}
|
||||
|
||||
test.describe('wizard and QR platform regressions', () => {
|
||||
test('opens the Page Bot test panel after the first and every later save', async ({
|
||||
test('starts with message platforms directly and keeps legacy adapters available', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
const adapter = adapterWithQrLogin(
|
||||
'message-adapter',
|
||||
'Message Adapter',
|
||||
'feishu',
|
||||
);
|
||||
await page.route('**/api/v1/platform/adapters', async (route) => {
|
||||
await route.fulfill({
|
||||
contentType: 'application/json',
|
||||
body: ok({
|
||||
adapters: [
|
||||
adapter,
|
||||
adapter,
|
||||
{
|
||||
...adapter,
|
||||
name: 'events-only',
|
||||
label: { en_US: 'Events Only' },
|
||||
spec: {
|
||||
...adapter.spec,
|
||||
supported_events: ['group.member_joined'],
|
||||
},
|
||||
},
|
||||
{
|
||||
...adapter,
|
||||
name: 'legacy',
|
||||
label: { en_US: 'Legacy Message Adapter' },
|
||||
spec: {
|
||||
...adapter.spec,
|
||||
legacy: true,
|
||||
supported_events: undefined,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.goto('/wizard');
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Select a Platform' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: /Reply to messages|Welcome new members/,
|
||||
}),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByText('Message Adapter', { exact: true }),
|
||||
).toHaveCount(1);
|
||||
await expect(page.getByText('Events Only', { exact: true })).toHaveCount(0);
|
||||
await page.getByRole('button', { name: /Legacy Adapters/i }).click();
|
||||
await expect(
|
||||
page.getByText('Legacy Message Adapter', { exact: true }),
|
||||
).toBeVisible();
|
||||
await page.getByText('Message Adapter', { exact: true }).click();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Confirm, Create Bot' }),
|
||||
).toBeEnabled();
|
||||
});
|
||||
|
||||
test('old non-message drafts start fresh without modifying their Agent or bot', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
await page.route('**/api/v1/system/info', async (route) => {
|
||||
await route.fulfill({
|
||||
contentType: 'application/json',
|
||||
body: ok({
|
||||
version: 'frontend-smoke',
|
||||
edition: 'community',
|
||||
wizard_status: 'none',
|
||||
cloud_service_url: 'https://space.langbot.app',
|
||||
enable_marketplace: true,
|
||||
wizard_progress: {
|
||||
step: 2,
|
||||
selected_scenario: 'welcome_members',
|
||||
created_bot_uuid: 'old-bot',
|
||||
selected_adapter: 'test',
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
const resourceRequests: string[] = [];
|
||||
page.on('request', (request) => {
|
||||
if (
|
||||
/\/api\/v1\/(agents|pipelines|platform\/bots)(\/[^_]|$)/.test(
|
||||
new URL(request.url()).pathname,
|
||||
) &&
|
||||
request.method() !== 'GET'
|
||||
) {
|
||||
resourceRequests.push(request.url());
|
||||
}
|
||||
});
|
||||
await page.goto('/wizard');
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Select a Platform' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Confirm, Create Bot' }),
|
||||
).toBeDisabled();
|
||||
expect(resourceRequests).toEqual([]);
|
||||
});
|
||||
|
||||
test('loads the Page Bot from the API origin, retries failures, and reopens after every save', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
|
||||
let pipelineCreateCount = 0;
|
||||
let boundPipelineUuid: string | null = null;
|
||||
let backendOrigin = '';
|
||||
page.on('request', (request) => {
|
||||
const url = new URL(request.url());
|
||||
if (url.pathname === '/api/v1/platform/adapters')
|
||||
backendOrigin = url.origin;
|
||||
if (request.method() === 'POST' && url.pathname === '/api/v1/pipelines') {
|
||||
pipelineCreateCount += 1;
|
||||
}
|
||||
@@ -150,8 +258,10 @@ test.describe('wizard and QR platform regressions', () => {
|
||||
path.resolve(process.cwd(), '../src/langbot/templates/embed/widget.js'),
|
||||
'utf8',
|
||||
);
|
||||
let widgetAvailable = false;
|
||||
await page.route('**/api/v1/embed/*/widget.js?*', async (route) => {
|
||||
if (!boundPipelineUuid || pipelineCreateCount === 0) {
|
||||
expect(new URL(route.request().url()).origin).toBe(backendOrigin);
|
||||
if (!widgetAvailable || !boundPipelineUuid || pipelineCreateCount === 0) {
|
||||
await route.fulfill({
|
||||
status: 404,
|
||||
contentType: 'application/javascript',
|
||||
@@ -174,7 +284,6 @@ test.describe('wizard and QR platform regressions', () => {
|
||||
});
|
||||
|
||||
await page.goto('/wizard');
|
||||
await page.getByRole('button', { name: /Reply to messages/ }).click();
|
||||
await page.getByText('Page Bot', { exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Confirm, Create Bot' }).click();
|
||||
|
||||
@@ -186,6 +295,15 @@ test.describe('wizard and QR platform regressions', () => {
|
||||
await expect.poll(() => pipelineCreateCount).toBe(1);
|
||||
await expect.poll(() => boundPipelineUuid).toBe('pipeline-1');
|
||||
|
||||
await expect(
|
||||
page.getByText(
|
||||
'Failed to load the test chat. Please save the configuration again to retry.',
|
||||
{ exact: true },
|
||||
),
|
||||
).toBeVisible();
|
||||
widgetAvailable = true;
|
||||
await saveButton.click();
|
||||
|
||||
const widgetRoot = page.locator('#langbot-widget-root');
|
||||
await expect(widgetRoot).toBeAttached();
|
||||
await expect
|
||||
@@ -327,7 +445,6 @@ test.describe('wizard and QR platform regressions', () => {
|
||||
);
|
||||
|
||||
await page.goto('/wizard');
|
||||
await page.getByRole('button', { name: /Reply to messages/ }).click();
|
||||
await page.getByText('HTTP Bot', { exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Confirm, Create Bot' }).click();
|
||||
await page
|
||||
@@ -344,13 +461,44 @@ test.describe('wizard and QR platform regressions', () => {
|
||||
await expect.poll(() => inboundTestCount).toBe(1);
|
||||
});
|
||||
|
||||
test('blocks deployment until required Runner configuration is real', async ({
|
||||
test('creates only a message pipeline after message verification and valid Runner configuration', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, {
|
||||
authenticated: true,
|
||||
withAdapterEvents: true,
|
||||
});
|
||||
const processorRequests: string[] = [];
|
||||
page.on('request', (request) => {
|
||||
const path = new URL(request.url()).pathname;
|
||||
if (
|
||||
request.method() === 'POST' &&
|
||||
['/api/v1/pipelines', '/api/v1/agents'].includes(path)
|
||||
) {
|
||||
processorRequests.push(path);
|
||||
}
|
||||
});
|
||||
let messageReceived = false;
|
||||
await page.route('**/api/v1/platform/bots/*/logs', async (route) => {
|
||||
await route.fulfill({
|
||||
contentType: 'application/json',
|
||||
body: ok({
|
||||
logs: messageReceived
|
||||
? [
|
||||
{
|
||||
seq_id: 1,
|
||||
timestamp: Date.now() / 1000,
|
||||
level: 'info',
|
||||
text: 'Received message',
|
||||
images: [],
|
||||
message_session_id: 'person_123',
|
||||
},
|
||||
]
|
||||
: [],
|
||||
total_count: messageReceived ? 1 : 0,
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route('**/api/v1/pipelines/_/metadata', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
@@ -408,10 +556,11 @@ test.describe('wizard and QR platform regressions', () => {
|
||||
});
|
||||
|
||||
await page.goto('/wizard');
|
||||
await page.getByRole('button', { name: /Welcome new members/ }).click();
|
||||
await page.getByText('Playwright Adapter', { exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Confirm, Create Bot' }).click();
|
||||
await page.getByRole('button', { name: 'Save & Enable Bot' }).click();
|
||||
await expect(page.getByRole('button', { name: 'Next' })).toBeDisabled();
|
||||
messageReceived = true;
|
||||
await page.getByRole('button', { name: 'Next' }).click();
|
||||
await page.getByText('External Runner', { exact: true }).click();
|
||||
|
||||
@@ -419,6 +568,28 @@ test.describe('wizard and QR platform regressions', () => {
|
||||
await expect(deployButton).toBeDisabled();
|
||||
await page.getByRole('textbox').fill('app-real-api-key');
|
||||
await expect(deployButton).toBeEnabled();
|
||||
const bindingRequest = page.waitForRequest(
|
||||
(request) =>
|
||||
request.method() === 'PUT' &&
|
||||
new URL(request.url()).pathname === '/api/v1/platform/bots/bot-1',
|
||||
);
|
||||
await deployButton.click();
|
||||
const body = (await bindingRequest).postDataJSON();
|
||||
expect(body.event_bindings).toEqual([
|
||||
{
|
||||
event_pattern: 'message.received',
|
||||
target_type: 'pipeline',
|
||||
target_uuid: 'pipeline-1',
|
||||
filters: [],
|
||||
priority: 0,
|
||||
enabled: true,
|
||||
description: '',
|
||||
},
|
||||
]);
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Back to Workbench' }),
|
||||
).toBeVisible();
|
||||
expect(processorRequests).toEqual(['/api/v1/pipelines']);
|
||||
});
|
||||
|
||||
for (const qrPlatform of qrPlatforms) {
|
||||
|
||||
@@ -45,20 +45,20 @@ test('opens the Page Bot preview after every successful wizard save', () => {
|
||||
wizardSource,
|
||||
/setPageBotPreviewRequest\(\(request\) => request \+ 1\)/,
|
||||
);
|
||||
assert.match(wizardSource, /root\?\.langbotOpen\?\.\(\)/);
|
||||
|
||||
assert.match(widgetSource, /getAttribute\("data-auto-open"\) === "true"/);
|
||||
assert.match(widgetSource, /root\.langbotOpen = function \(\)/);
|
||||
assert.match(widgetSource, /if \(scriptAutoOpen\) root\.langbotOpen\(\)/);
|
||||
});
|
||||
|
||||
test('binds every message-reply bot to its provisional pipeline before verification', () => {
|
||||
test('binds every wizard bot to its provisional pipeline before verification', () => {
|
||||
assert.match(
|
||||
wizardSource,
|
||||
/selectedScenarioDefinition\?\.processorKind === 'pipeline'[\s\S]*?httpClient\.createPipeline\(/,
|
||||
/if \(!previewPipelineUuid\) \{[\s\S]*?httpClient\.createPipeline\(/,
|
||||
);
|
||||
assert.match(
|
||||
wizardSource,
|
||||
/event_pattern: selectedScenarioDefinition\.eventType,[\s\S]*?target_type: 'pipeline',[\s\S]*?target_uuid: previewPipelineUuid/,
|
||||
/event_pattern: 'message\.received',[\s\S]*?target_type: 'pipeline',[\s\S]*?target_uuid: previewPipelineUuid/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
wizardSource,
|
||||
@@ -99,15 +99,12 @@ test('requires the selected Runner mandatory configuration before finishing', ()
|
||||
);
|
||||
});
|
||||
|
||||
test('requires an observed message only for the message-reply scenario', () => {
|
||||
test('requires an observed message before configuring the runner', () => {
|
||||
assert.match(
|
||||
wizardSource,
|
||||
/selectedScenario !== 'message_reply' \|\| messageReceived/,
|
||||
);
|
||||
assert.match(
|
||||
wizardSource,
|
||||
/requiresMessageVerification=\{selectedScenario === 'message_reply'\}/,
|
||||
/createdBotUuid !== null && botSaved && messageReceived/,
|
||||
);
|
||||
assert.match(wizardSource, /requiresMessageVerification/);
|
||||
assert.match(wizardSource, /onMessageReceived=\{handleMessageReceived\}/);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user