fix(web): complete pluginized agent onboarding flows

This commit is contained in:
Hyu
2026-09-01 10:41:29 +08:00
parent 7e51044a87
commit 91e09af76a
18 changed files with 1784 additions and 280 deletions
@@ -13,6 +13,7 @@ import logging
import uuid
import hmac
import hashlib
import os
import time
import re
import httpx
@@ -29,6 +30,7 @@ _AUTH_TIMEOUT_SECONDS = 10.0
# Cache the widget template content
_widget_template_cache: str | None = None
_widget_template_cache_mtime_ns: int | None = None
_logo_bytes_cache: bytes | None = None
@@ -37,12 +39,14 @@ def _is_valid_uuid(s: str) -> bool:
def _get_widget_template() -> str:
"""Load and cache the widget JS template."""
global _widget_template_cache
if _widget_template_cache is None:
template_path = paths.get_resource_path('templates/embed/widget.js')
"""Load the widget template and refresh the cache when the file changes."""
global _widget_template_cache, _widget_template_cache_mtime_ns
template_path = paths.get_resource_path('templates/embed/widget.js')
template_mtime_ns = os.stat(template_path).st_mtime_ns
if _widget_template_cache is None or _widget_template_cache_mtime_ns != template_mtime_ns:
with open(template_path, 'r', encoding='utf-8') as f:
_widget_template_cache = f.read()
_widget_template_cache_mtime_ns = template_mtime_ns
return _widget_template_cache
+8
View File
@@ -10,6 +10,9 @@
var scriptTestNotice = scriptEl
? scriptEl.getAttribute("data-test-notice")
: null;
var scriptAutoOpen = scriptEl
? scriptEl.getAttribute("data-auto-open") === "true"
: false;
// ========== i18n ==========
var I18N = {
@@ -1252,6 +1255,9 @@
}
root.remove();
};
root.langbotOpen = function () {
if (!state.isOpen) togglePanel();
};
document.body.appendChild(root);
var shadow = root.attachShadow({ mode: "open" });
@@ -1406,6 +1412,8 @@
panel.appendChild(inputArea);
shadow.appendChild(panel);
if (scriptAutoOpen) root.langbotOpen();
}
// ========== Initialize ==========
+19
View File
@@ -9,6 +9,7 @@ Run: uv run pytest tests/integration/api/test_embed.py -q
from __future__ import annotations
import json
import os
import pytest
from unittest.mock import MagicMock, AsyncMock, Mock
@@ -147,6 +148,24 @@ class TestEmbedWidgetEndpoint:
assert 'javascript' in response.content_type
fake_embed_app.platform_mgr.resolve_public_bot.assert_any_await('a1b2c3d4-5678-90ab-cdef-123456789abc')
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
template_path = tmp_path / 'widget.js'
template_path.write_text('first version', encoding='utf-8')
monkeypatch.setattr(embed.paths, 'get_resource_path', lambda _: str(template_path))
embed._widget_template_cache = None
embed._widget_template_cache_mtime_ns = None
assert embed._get_widget_template() == 'first version'
previous_mtime_ns = template_path.stat().st_mtime_ns
template_path.write_text('second version', encoding='utf-8')
os.utime(template_path, ns=(previous_mtime_ns + 1_000_000, previous_mtime_ns + 1_000_000))
assert embed._get_widget_template() == 'second version'
@pytest.mark.asyncio
async def test_get_widget_js_invalid_uuid(self, quart_test_client):
"""GET widget.js with invalid UUID returns 400."""
@@ -0,0 +1,178 @@
import { httpClient } from '@/app/infra/http/HttpClient';
import { getCloudServiceClient } from '@/app/infra/http';
import type { IDynamicFormItemOption } from '@/app/infra/entities/form/dynamic';
import type { PipelineConfigTab } from '@/app/infra/entities/pipeline';
import type { PluginV4 } from '@/app/infra/entities/plugin';
export const RUNNER_COMPONENT_FILTER = 'AgentRunner';
const RUNNER_CATALOG_PAGE_SIZE = 100;
const RUNNER_INSTALL_TIMEOUT_MS = 120_000;
const RUNNER_REGISTRATION_TIMEOUT_MS = 60_000;
export type AgentRunnerMarketplaceErrorCode =
| 'version-unavailable'
| 'install-timeout'
| 'registration-timeout';
export class AgentRunnerMarketplaceError extends Error {
constructor(public readonly code: AgentRunnerMarketplaceErrorCode) {
super(code);
this.name = 'AgentRunnerMarketplaceError';
}
}
export interface AgentRunnerCatalog {
marketplaceRunners: PluginV4[];
installedPluginIds: string[];
}
export interface InstalledAgentRunner {
configTab: PipelineConfigTab;
runner: IDynamicFormItemOption;
}
function wait(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function getErrorMessage(error: unknown): string {
if (error && typeof error === 'object') {
const value = error as { msg?: string; message?: string };
return value.msg || value.message || '';
}
return typeof error === 'string' ? error : '';
}
export function marketplacePluginId(plugin: Pick<PluginV4, 'author' | 'name'>) {
return `${plugin.author}/${plugin.name}`;
}
export function runnerPluginPrefix(plugin: Pick<PluginV4, 'author' | 'name'>) {
return `plugin:${plugin.author}/${plugin.name}/`;
}
export async function loadAgentRunnerCatalog(): Promise<AgentRunnerCatalog> {
const cloudClient = await getCloudServiceClient();
const [firstSearchResult, recommendationResult, installedResult] =
await Promise.all([
cloudClient.searchMarketplaceExtensions({
query: '',
page: 1,
page_size: RUNNER_CATALOG_PAGE_SIZE,
type_filter: 'plugin',
component_filter: RUNNER_COMPONENT_FILTER,
}),
cloudClient.getRecommendationLists().catch(() => ({ lists: [] })),
httpClient.getPlugins().catch(() => ({ plugins: [] })),
]);
const remainingPageCount = Math.max(
0,
Math.ceil((firstSearchResult.total || 0) / RUNNER_CATALOG_PAGE_SIZE) - 1,
);
const remainingResults = await Promise.all(
Array.from({ length: remainingPageCount }, (_, index) =>
cloudClient.searchMarketplaceExtensions({
query: '',
page: index + 2,
page_size: RUNNER_CATALOG_PAGE_SIZE,
type_filter: 'plugin',
component_filter: RUNNER_COMPONENT_FILTER,
}),
),
);
const catalogPlugins = [
...(firstSearchResult.plugins || []),
...remainingResults.flatMap((result) => result.plugins || []),
];
const recommendationOrder = new Map<string, number>();
let nextOrder = 0;
for (const list of recommendationResult.lists || []) {
for (const plugin of list.plugins || []) {
if (!plugin.components?.[RUNNER_COMPONENT_FILTER]) continue;
const id = marketplacePluginId(plugin);
if (!recommendationOrder.has(id)) {
recommendationOrder.set(id, nextOrder);
nextOrder += 1;
}
}
}
const marketplaceRunners = catalogPlugins
.filter((plugin) => plugin.components?.[RUNNER_COMPONENT_FILTER])
.sort((left, right) => {
const leftOrder = recommendationOrder.get(marketplacePluginId(left));
const rightOrder = recommendationOrder.get(marketplacePluginId(right));
if (leftOrder !== undefined && rightOrder !== undefined) {
return leftOrder - rightOrder;
}
if (leftOrder !== undefined) return -1;
if (rightOrder !== undefined) return 1;
return right.install_count - left.install_count;
});
return {
marketplaceRunners,
installedPluginIds: installedResult.plugins.map((plugin) => {
const metadata = plugin.manifest.manifest.metadata;
return `${metadata.author ?? ''}/${metadata.name}`;
}),
};
}
export async function installMarketplaceAgentRunner(
plugin: PluginV4,
): Promise<InstalledAgentRunner> {
if (!plugin.latest_version) {
throw new AgentRunnerMarketplaceError('version-unavailable');
}
const { task_id: taskId } = await httpClient.installPluginFromMarketplace(
plugin.author,
plugin.name,
plugin.latest_version,
);
const installDeadline = Date.now() + RUNNER_INSTALL_TIMEOUT_MS;
let installCompleted = false;
while (Date.now() < installDeadline) {
const task = await httpClient.getAsyncTask(taskId);
if (task.runtime.done) {
if (task.runtime.exception) {
throw new Error(task.runtime.exception);
}
installCompleted = true;
break;
}
await wait(1000);
}
if (!installCompleted) {
throw new AgentRunnerMarketplaceError('install-timeout');
}
const registrationDeadline = Date.now() + RUNNER_REGISTRATION_TIMEOUT_MS;
const prefix = runnerPluginPrefix(plugin);
while (Date.now() < registrationDeadline) {
const metadata = await httpClient.getGeneralPipelineMetadata();
const configTab = metadata.configs.find((config) => config.name === 'ai');
const runnerStage = configTab?.stages.find(
(stage) => stage.name === 'runner',
);
const runnerOptions =
runnerStage?.config.find((item) => item.name === 'id')?.options ?? [];
const pluginRunnerOptions = runnerOptions.filter((option) =>
option.name.startsWith(prefix),
);
const runner =
pluginRunnerOptions.find((option) => option.name.endsWith('/default')) ??
pluginRunnerOptions[0];
if (configTab && runner) {
return { configTab, runner };
}
await wait(1000);
}
throw new AgentRunnerMarketplaceError('registration-timeout');
}
@@ -38,6 +38,7 @@ import {
FormMessage,
} from '@/components/ui/form';
import AgentEventPatternPicker from './AgentEventPatternPicker';
import AgentRunnerSelect from './AgentRunnerSelect';
export interface AgentRunnerStatus {
label: string;
@@ -415,6 +416,20 @@ function AgentFormComponent(
<DynamicFormComponent
itemConfigList={stage.config}
initialValues={initialValues}
renderItem={
isRunnerSelector
? ({ config, field }) =>
config.name === 'id' ? (
<AgentRunnerSelect
options={config.options ?? []}
label={extractI18nObject(config.label)}
value={String(field.value ?? '')}
onValueChange={field.onChange}
onMetadataRefresh={setRunnerConfigSchema}
/>
) : undefined
: undefined
}
onSubmit={(values) =>
handleDynamicFormEmit(
isRunnerSelector ? 'runner' : 'runner_config',
@@ -0,0 +1,308 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Bot, Download, Loader2, Store } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { getCloudServiceClientSync, httpClient } from '@/app/infra/http';
import type { IDynamicFormItemOption } from '@/app/infra/entities/form/dynamic';
import type { PipelineConfigTab } from '@/app/infra/entities/pipeline';
import type { PluginV4 } from '@/app/infra/entities/plugin';
import {
AgentRunnerMarketplaceError,
getErrorMessage,
installMarketplaceAgentRunner,
loadAgentRunnerCatalog,
marketplacePluginId,
runnerPluginPrefix,
} from '@/app/home/agents/agent-runner-marketplace';
import { extractI18nObject } from '@/i18n/I18nProvider';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
const MARKETPLACE_VALUE_PREFIX = '__agent_runner_marketplace__:';
function installErrorMessage(
error: unknown,
t: ReturnType<typeof useTranslation>['t'],
) {
if (error instanceof AgentRunnerMarketplaceError) {
if (error.code === 'version-unavailable') {
return t('wizard.aiEngine.versionUnavailable');
}
if (error.code === 'install-timeout') {
return t('wizard.aiEngine.installTimeout');
}
return t('wizard.aiEngine.registrationTimeout');
}
return getErrorMessage(error) || t('wizard.aiEngine.installFailed');
}
function InstalledRunnerContent({
option,
}: {
option: IDynamicFormItemOption;
}) {
const iconURL = option.name.startsWith('plugin:')
? (() => {
const match = option.name.match(/^plugin:([^/]+)\/([^/]+)(?:\/|$)/);
return match ? httpClient.getPluginIconURL(match[1], match[2]) : null;
})()
: null;
return (
<span className="flex min-w-0 items-center gap-2">
{iconURL ? (
<img
src={iconURL}
alt=""
className="size-5 shrink-0 rounded object-cover"
/>
) : (
<Bot className="size-4 shrink-0 text-muted-foreground" />
)}
<span className="truncate">{extractI18nObject(option.label)}</span>
</span>
);
}
function MarketplaceRunnerContent({ plugin }: { plugin: PluginV4 }) {
const iconURL = getCloudServiceClientSync().resolveMarketplaceIconURL(
plugin.type,
plugin.author,
plugin.name,
plugin.icon,
);
const description =
extractI18nObject(plugin.description) || `${plugin.author}/${plugin.name}`;
return (
<span className="grid min-w-0 flex-1 grid-cols-[1.75rem_minmax(0,1fr)_auto] items-center gap-x-2">
<img
src={iconURL}
alt=""
className="row-span-2 size-7 shrink-0 rounded-md object-cover"
/>
<span className="truncate font-medium leading-5">
{extractI18nObject(plugin.label) || plugin.name}
</span>
<Download className="row-span-2 size-3.5 shrink-0 text-muted-foreground" />
<span
className="truncate text-xs leading-4 text-muted-foreground"
title={description}
>
{description}
</span>
</span>
);
}
export default function AgentRunnerSelect({
options,
label,
value,
onValueChange,
onMetadataRefresh,
}: {
options: IDynamicFormItemOption[];
label: string;
value: string;
onValueChange: (value: string) => void;
onMetadataRefresh: (configTab: PipelineConfigTab) => void;
}) {
const { t } = useTranslation();
const [marketplaceRunners, setMarketplaceRunners] = useState<PluginV4[]>([]);
const [installedPluginIds, setInstalledPluginIds] = useState<string[]>([]);
const [catalogLoading, setCatalogLoading] = useState(true);
const [catalogError, setCatalogError] = useState(false);
const [installingPlugin, setInstallingPlugin] = useState<PluginV4 | null>(
null,
);
const [installError, setInstallError] = useState<string | null>(null);
const loadCatalog = useCallback(async () => {
setCatalogLoading(true);
setCatalogError(false);
try {
const catalog = await loadAgentRunnerCatalog();
setMarketplaceRunners(catalog.marketplaceRunners);
setInstalledPluginIds(catalog.installedPluginIds);
} catch (error) {
console.error('Failed to load AgentRunner catalog', error);
setCatalogError(true);
} finally {
setCatalogLoading(false);
}
}, []);
useEffect(() => {
void loadCatalog();
}, [loadCatalog]);
const marketplaceOptions = useMemo(
() =>
marketplaceRunners.filter((plugin) => {
const pluginId = marketplacePluginId(plugin);
if (installedPluginIds.includes(pluginId)) return false;
return !options.some((option) =>
option.name.startsWith(runnerPluginPrefix(plugin)),
);
}),
[installedPluginIds, marketplaceRunners, options],
);
const selectedOption = options.find((option) => option.name === value);
const handleValueChange = useCallback(
async (nextValue: string) => {
if (!nextValue.startsWith(MARKETPLACE_VALUE_PREFIX)) {
setInstallError(null);
onValueChange(nextValue);
return;
}
const pluginId = nextValue.slice(MARKETPLACE_VALUE_PREFIX.length);
const plugin = marketplaceRunners.find(
(candidate) => marketplacePluginId(candidate) === pluginId,
);
if (!plugin || installingPlugin) return;
setInstallingPlugin(plugin);
setInstallError(null);
try {
const installed = await installMarketplaceAgentRunner(plugin);
onMetadataRefresh(installed.configTab);
onValueChange(installed.runner.name);
await loadCatalog();
toast.success(
t('wizard.aiEngine.installSuccess', {
runner: extractI18nObject(plugin.label) || plugin.name,
}),
);
} catch (error) {
const message = installErrorMessage(error, t);
setInstallError(message);
toast.error(message);
} finally {
setInstallingPlugin(null);
}
},
[
installingPlugin,
loadCatalog,
marketplaceRunners,
onMetadataRefresh,
onValueChange,
t,
],
);
return (
<div className="w-full max-w-[22rem] space-y-2">
<Select
value={value}
disabled={installingPlugin !== null}
onValueChange={(nextValue) => void handleValueChange(nextValue)}
onOpenChange={(open) => {
if (open && catalogError && !catalogLoading) void loadCatalog();
}}
>
<SelectTrigger
aria-label={label}
className="w-full bg-[#ffffff] dark:bg-[#2a2a2e]"
>
{installingPlugin ? (
<div className="flex min-w-0 items-center gap-2">
<Loader2 className="size-4 shrink-0 animate-spin" />
<span className="truncate">
{t('agents.installingRunner', {
runner:
extractI18nObject(installingPlugin.label) ||
installingPlugin.name,
})}
</span>
</div>
) : selectedOption ? (
<InstalledRunnerContent option={selectedOption} />
) : (
<SelectValue placeholder={t('common.select')} />
)}
</SelectTrigger>
<SelectContent className="max-h-72 w-[var(--radix-select-trigger-width)] max-w-[calc(100vw-2rem)]">
<SelectGroup>
<SelectLabel className="px-2 py-1 text-[11px] font-medium">
<span className="inline-flex items-center gap-1.5">
<Bot className="size-3.5" />
{t('agents.installedRunners')}
</span>
</SelectLabel>
{options.length > 0 ? (
options.map((option) => (
<SelectItem
key={option.name}
value={option.name}
description={option.name}
className="py-1.5"
>
<InstalledRunnerContent option={option} />
</SelectItem>
))
) : (
<div className="px-2 py-1.5 text-xs text-muted-foreground">
{t('agents.noInstalledRunners')}
</div>
)}
</SelectGroup>
<SelectSeparator />
<SelectGroup>
<SelectLabel className="px-2 py-1 text-[11px] font-medium">
<span className="inline-flex items-center gap-1.5">
<Store className="size-3.5" />
{t('agents.marketplaceRunners')}
</span>
</SelectLabel>
{catalogLoading && marketplaceOptions.length === 0 ? (
<div className="flex items-center gap-2 px-2 py-1.5 text-xs text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" />
{t('wizard.aiEngine.loadingCatalog')}
</div>
) : catalogError ? (
<div className="px-2 py-1.5 text-xs text-destructive">
{t('wizard.aiEngine.catalogUnavailable')}
</div>
) : marketplaceOptions.length > 0 ? (
marketplaceOptions.map((plugin) => (
<SelectItem
key={marketplacePluginId(plugin)}
value={`${MARKETPLACE_VALUE_PREFIX}${marketplacePluginId(plugin)}`}
className="py-1.5 pr-8"
>
<MarketplaceRunnerContent plugin={plugin} />
</SelectItem>
))
) : (
<div className="px-2 py-1.5 text-xs text-muted-foreground">
{t('wizard.aiEngine.noMarketplaceRunners')}
</div>
)}
</SelectGroup>
</SelectContent>
</Select>
{installError && (
<p role="alert" className="text-sm text-destructive">
{installError}
</p>
)}
</div>
);
}
@@ -4,6 +4,7 @@ import {
DynamicFormItemType,
} from '@/app/infra/entities/form/dynamic';
import { useForm } from 'react-hook-form';
import type { ControllerRenderProps } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import {
@@ -22,7 +23,8 @@ import {
import QrCodeLoginDialog, {
QrLoginPlatform,
} from '@/app/home/components/qrcode-login/QrCodeLoginDialog';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { ReactNode } from 'react';
import { extractI18nObject } from '@/i18n/I18nProvider';
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
@@ -460,6 +462,7 @@ export default function DynamicFormComponent({
externalDependentValues,
systemContext,
onValidate,
renderItem,
}: {
itemConfigList: IDynamicFormItemSchema[];
onSubmit?: (val: object) => unknown;
@@ -473,6 +476,15 @@ export default function DynamicFormComponent({
/** Callback to expose validation function to parent component.
* Parent can call this function to trigger validation and get validity state. */
onValidate?: (validateFn: () => Promise<boolean>) => void;
/** Override a field control while retaining the DynamicForm label,
* description, validation, and value emission behavior. Return undefined
* to use the standard control for that item. */
renderItem?: (args: {
config: IDynamicFormItemSchema;
field: ControllerRenderProps<any, any>;
formValues: Record<string, unknown>;
setFormValue: (name: string, value: unknown) => void;
}) => ReactNode | undefined;
}) {
const isInitialMount = useRef(true);
const previousInitialValues = useRef(initialValues);
@@ -539,15 +551,15 @@ export default function DynamicFormComponent({
});
// Expose validation function to parent component
const validate = async (): Promise<boolean> => {
const validate = useCallback(async (): Promise<boolean> => {
// Trigger validation for all fields
const result = await form.trigger();
return result;
};
}, [form]);
useEffect(() => {
onValidate?.(validate);
}, [onValidate]);
}, [onValidate, validate]);
// 当 initialValues 变化时更新表单值
// 但要避免因为内部表单更新触发的 onSubmit 导致的 initialValues 变化而重新设置表单
@@ -978,42 +990,56 @@ export default function DynamicFormComponent({
key={fieldKey}
control={form.control}
name={config.name as keyof FormValues}
render={({ field }) => (
<FormItem className="min-w-0">
<FormLabel className="flex min-w-0 items-center gap-1.5">
<span className="min-w-0 break-words">
{extractI18nObject(config.label)}{' '}
{config.required && (
<span className="text-red-500">*</span>
)}
</span>
{renderDisabledTooltipIcon()}
</FormLabel>
<FormControl>
<div
className={cn(
'min-w-0 max-w-full overflow-x-hidden',
isFieldDisabled && 'pointer-events-none opacity-60',
)}
>
<DynamicFormItemComponent
config={normalizedConfig}
field={field}
formValues={watchedValues as Record<string, unknown>}
onFileUploaded={onFileUploaded}
setFormValue={setFormValue}
systemContext={systemContext}
/>
</div>
</FormControl>
{config.description && (
<p className="text-sm break-words text-muted-foreground">
{extractI18nObject(config.description)}
</p>
)}
<FormMessage />
</FormItem>
)}
render={({ field }) => {
const customItem = renderItem?.({
config: normalizedConfig,
field,
formValues: watchedValues as Record<string, unknown>,
setFormValue,
});
return (
<FormItem className="min-w-0">
<FormLabel className="flex min-w-0 items-center gap-1.5">
<span className="min-w-0 break-words">
{extractI18nObject(config.label)}{' '}
{config.required && (
<span className="text-red-500">*</span>
)}
</span>
{renderDisabledTooltipIcon()}
</FormLabel>
<FormControl>
<div
className={cn(
'min-w-0 max-w-full overflow-x-hidden',
isFieldDisabled && 'pointer-events-none opacity-60',
)}
>
{customItem !== undefined ? (
customItem
) : (
<DynamicFormItemComponent
config={normalizedConfig}
field={field}
formValues={
watchedValues as Record<string, unknown>
}
onFileUploaded={onFileUploaded}
setFormValue={setFormValue}
systemContext={systemContext}
/>
)}
</div>
</FormControl>
{config.description && (
<p className="text-sm break-words text-muted-foreground">
{extractI18nObject(config.description)}
</p>
)}
<FormMessage />
</FormItem>
);
}}
/>
);
})}
@@ -16,6 +16,7 @@ import {
} from 'lucide-react';
import QRCode from 'qrcode';
import { getActiveWorkspaceUuid } from '@/app/infra/http/workspaceContext';
import { getBackendBaseUrl } from '@/app/infra/http/backendUrl';
export type QrLoginPlatform =
| 'feishu'
@@ -213,7 +214,7 @@ export default function QrCodeLoginDialog({
const token = localStorage.getItem('token');
const workspaceUuid = getActiveWorkspaceUuid();
const baseUrl = import.meta.env.VITE_API_BASE_URL || window.location.origin;
const baseUrl = getBackendBaseUrl();
baseUrlRef.current = baseUrl;
const cfg = platformConfigRef.current;
@@ -57,6 +57,7 @@ import {
Copy,
} from 'lucide-react';
import PipelineExtension from '@/app/home/pipelines/components/pipeline-extensions/PipelineExtension';
import AgentRunnerSelect from '@/app/home/agents/components/AgentRunnerSelect';
interface PipelineFormComponentProps {
pipelineId?: string;
@@ -515,6 +516,17 @@ const PipelineFormComponent = forwardRef<
] || {}
}
systemContext={dynamicFormSystemContext}
renderItem={({ config, field }) =>
config.name === 'id' ? (
<AgentRunnerSelect
options={config.options ?? []}
label={extractI18nObject(config.label)}
value={String(field.value ?? '')}
onValueChange={field.onChange}
onMetadataRefresh={setAIConfigTabSchema}
/>
) : undefined
}
onSubmit={(values) => {
handleDynamicFormEmit(formName, stage.name, values);
}}
+24
View File
@@ -0,0 +1,24 @@
export function resolveBackendBaseUrl(
configuredBaseUrl: string | undefined,
origin: string,
): string {
const normalizedOrigin = origin.replace(/\/+$/, '');
const configured = configuredBaseUrl?.trim() ?? '';
if (!configured || configured === '/') {
return normalizedOrigin;
}
if (/^https?:\/\//i.test(configured)) {
return configured.replace(/\/+$/, '');
}
return `${normalizedOrigin}/${configured.replace(/^\/+|\/+$/g, '')}`;
}
export function getBackendBaseUrl(): string {
return resolveBackendBaseUrl(
import.meta.env.VITE_API_BASE_URL,
window.location.origin,
);
}
+229 -231
View File
@@ -32,7 +32,6 @@ import {
systemInfo,
bootstrapWorkspaceSession,
initializeSystemInfo,
getCloudServiceClient,
getCloudServiceClientSync,
userInfo,
} from '@/app/infra/http';
@@ -65,8 +64,17 @@ import {
import { getAdapterDocUrl } from '@/app/infra/entities/adapter-docs';
import i18n from 'i18next';
import { PluginV4 } from '@/app/infra/entities/plugin';
import {
AgentRunnerMarketplaceError,
getErrorMessage,
installMarketplaceAgentRunner,
loadAgentRunnerCatalog,
marketplacePluginId,
runnerPluginPrefix,
} from '@/app/home/agents/agent-runner-marketplace';
import {
ensureHttpBotSigningSecret,
isRequiredRunnerConfigComplete,
isWebhookModeEnabled,
} from '@/app/wizard/utils';
@@ -96,30 +104,6 @@ import {
// ---------------------------------------------------------------------------
const TOTAL_STEPS = 4;
const RUNNER_COMPONENT_FILTER = 'AgentRunner';
const RUNNER_CATALOG_PAGE_SIZE = 100;
const RUNNER_INSTALL_TIMEOUT_MS = 120_000;
const RUNNER_REGISTRATION_TIMEOUT_MS = 60_000;
function wait(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function getErrorMessage(error: unknown): string {
if (error && typeof error === 'object') {
const value = error as { msg?: string; message?: string };
return value.msg || value.message || '';
}
return typeof error === 'string' ? error : '';
}
function marketplacePluginId(plugin: Pick<PluginV4, 'author' | 'name'>) {
return `${plugin.author}/${plugin.name}`;
}
function runnerPluginPrefix(plugin: Pick<PluginV4, 'author' | 'name'>) {
return `plugin:${plugin.author}/${plugin.name}/`;
}
type WizardScenarioId =
| 'message_reply'
@@ -205,6 +189,9 @@ export default function WizardPage() {
);
const [runnerConfig, setRunnerConfig] = useState<Record<string, unknown>>({});
const [createdBotUuid, setCreatedBotUuid] = useState<string | null>(null);
const [createdPipelineUuid, setCreatedPipelineUuid] = useState<string | null>(
null,
);
const [webhookUrl, setWebhookUrl] = useState<string>('');
const [extraWebhookUrl, setExtraWebhookUrl] = useState<string>('');
@@ -228,82 +215,16 @@ export default function WizardPage() {
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSavingBot, setIsSavingBot] = useState(false);
const [botSaved, setBotSaved] = useState(false);
const [pageBotPreviewRequest, setPageBotPreviewRequest] = useState(0);
const [messageReceived, setMessageReceived] = useState(false);
const loadRunnerCatalog = useCallback(async () => {
setIsRunnerCatalogLoading(true);
setRunnerCatalogError(false);
try {
const cloudClient = await getCloudServiceClient();
const [firstSearchResult, recommendationResult, installedResult] =
await Promise.all([
cloudClient.searchMarketplaceExtensions({
query: '',
page: 1,
page_size: RUNNER_CATALOG_PAGE_SIZE,
type_filter: 'plugin',
component_filter: RUNNER_COMPONENT_FILTER,
}),
cloudClient.getRecommendationLists().catch(() => ({ lists: [] })),
httpClient.getPlugins().catch(() => ({ plugins: [] })),
]);
const remainingPageCount = Math.max(
0,
Math.ceil((firstSearchResult.total || 0) / RUNNER_CATALOG_PAGE_SIZE) -
1,
);
const remainingResults = await Promise.all(
Array.from({ length: remainingPageCount }, (_, index) =>
cloudClient.searchMarketplaceExtensions({
query: '',
page: index + 2,
page_size: RUNNER_CATALOG_PAGE_SIZE,
type_filter: 'plugin',
component_filter: RUNNER_COMPONENT_FILTER,
}),
),
);
const catalogPlugins = [
...(firstSearchResult.plugins || []),
...remainingResults.flatMap((result) => result.plugins || []),
];
const recommendationOrder = new Map<string, number>();
let nextOrder = 0;
for (const list of recommendationResult.lists || []) {
for (const plugin of list.plugins || []) {
if (!plugin.components?.[RUNNER_COMPONENT_FILTER]) continue;
const id = marketplacePluginId(plugin);
if (!recommendationOrder.has(id)) {
recommendationOrder.set(id, nextOrder);
nextOrder += 1;
}
}
}
const runners = catalogPlugins
.filter((plugin) => plugin.components?.[RUNNER_COMPONENT_FILTER])
.sort((left, right) => {
const leftOrder = recommendationOrder.get(marketplacePluginId(left));
const rightOrder = recommendationOrder.get(
marketplacePluginId(right),
);
if (leftOrder !== undefined && rightOrder !== undefined) {
return leftOrder - rightOrder;
}
if (leftOrder !== undefined) return -1;
if (rightOrder !== undefined) return 1;
return right.install_count - left.install_count;
});
setMarketplaceRunners(runners);
setInstalledPluginIds(
installedResult.plugins.map((plugin) => {
const metadata = plugin.manifest.manifest.metadata;
return `${metadata.author ?? ''}/${metadata.name}`;
}),
);
const catalog = await loadAgentRunnerCatalog();
setMarketplaceRunners(catalog.marketplaceRunners);
setInstalledPluginIds(catalog.installedPluginIds);
} catch (error) {
console.error('Failed to load AgentRunner catalog', error);
setRunnerCatalogError(true);
@@ -333,6 +254,10 @@ export default function WizardPage() {
overrides.created_bot_uuid !== undefined
? overrides.created_bot_uuid
: createdBotUuid,
created_pipeline_uuid:
overrides.created_pipeline_uuid !== undefined
? overrides.created_pipeline_uuid
: createdPipelineUuid,
bot_saved: overrides.bot_saved ?? botSaved,
message_received: overrides.message_received ?? messageReceived,
selected_runner:
@@ -349,6 +274,7 @@ export default function WizardPage() {
selectedScenario,
selectedAdapter,
createdBotUuid,
createdPipelineUuid,
botSaved,
messageReceived,
selectedRunner,
@@ -406,6 +332,15 @@ export default function WizardPage() {
'message_reply',
);
setCreatedBotUuid(progress.created_bot_uuid);
setCreatedPipelineUuid(
progress.created_pipeline_uuid ??
botData.bot.event_bindings?.find(
(binding) =>
binding.event_pattern === 'message.received' &&
binding.target_type === 'pipeline',
)?.target_uuid ??
null,
);
setBotSaved(
configNeedsSave ? false : (progress.bot_saved ?? false),
);
@@ -435,6 +370,7 @@ export default function WizardPage() {
selected_scenario: null,
selected_adapter: null,
created_bot_uuid: null,
created_pipeline_uuid: null,
bot_saved: false,
message_received: false,
selected_runner: null,
@@ -527,6 +463,12 @@ export default function WizardPage() {
);
}, [selectedRunnerConfigStage]);
const isRunnerConfigComplete = useMemo(
() =>
isRequiredRunnerConfigComplete(selectedRunnerConfigItems, runnerConfig),
[selectedRunnerConfigItems, runnerConfig],
);
// ---- Runner selection with progress saving ----
const handleSelectRunner = useCallback(
(runner: string, configTab: PipelineConfigTab | null = aiConfigTab) => {
@@ -555,74 +497,29 @@ export default function WizardPage() {
setRunnerInstallError(null);
try {
if (!plugin.latest_version) {
throw new Error(t('wizard.aiEngine.versionUnavailable'));
}
const { task_id: taskId } =
await httpClient.installPluginFromMarketplace(
plugin.author,
plugin.name,
plugin.latest_version,
);
const installDeadline = Date.now() + RUNNER_INSTALL_TIMEOUT_MS;
let installCompleted = false;
while (Date.now() < installDeadline) {
const task = await httpClient.getAsyncTask(taskId);
if (task.runtime.done) {
if (task.runtime.exception) {
throw new Error(task.runtime.exception);
}
installCompleted = true;
break;
}
await wait(1000);
}
if (!installCompleted) {
throw new Error(t('wizard.aiEngine.installTimeout'));
}
const registrationDeadline =
Date.now() + RUNNER_REGISTRATION_TIMEOUT_MS;
const prefix = runnerPluginPrefix(plugin);
while (Date.now() < registrationDeadline) {
const metadata = await httpClient.getGeneralPipelineMetadata();
const nextAiTab =
metadata.configs.find((config) => config.name === 'ai') ?? null;
const nextRunnerStage = nextAiTab?.stages.find(
(stage) => stage.name === 'runner',
);
const nextRunnerOptions =
nextRunnerStage?.config.find((item) => item.name === 'id')
?.options ?? [];
const pluginRunnerOptions = nextRunnerOptions.filter((option) =>
option.name.startsWith(prefix),
);
const preferredRunner =
pluginRunnerOptions.find((option) =>
option.name.endsWith('/default'),
) ?? pluginRunnerOptions[0];
if (nextAiTab && preferredRunner) {
setAiConfigTab(nextAiTab);
setInstalledPluginIds((current) =>
current.includes(pluginId) ? current : [...current, pluginId],
);
handleSelectRunner(preferredRunner.name, nextAiTab);
toast.success(
t('wizard.aiEngine.installSuccess', {
runner: extractI18nObject(plugin.label),
}),
);
return;
}
await wait(1000);
}
throw new Error(t('wizard.aiEngine.registrationTimeout'));
const installed = await installMarketplaceAgentRunner(plugin);
setAiConfigTab(installed.configTab);
setInstalledPluginIds((current) =>
current.includes(pluginId) ? current : [...current, pluginId],
);
handleSelectRunner(installed.runner.name, installed.configTab);
toast.success(
t('wizard.aiEngine.installSuccess', {
runner: extractI18nObject(plugin.label),
}),
);
} catch (error) {
const message =
getErrorMessage(error) || t('wizard.aiEngine.installFailed');
let message = getErrorMessage(error);
if (error instanceof AgentRunnerMarketplaceError) {
const key =
error.code === 'version-unavailable'
? 'wizard.aiEngine.versionUnavailable'
: error.code === 'install-timeout'
? 'wizard.aiEngine.installTimeout'
: 'wizard.aiEngine.registrationTimeout';
message = t(key);
}
message ||= t('wizard.aiEngine.installFailed');
setRunnerInstallError(message);
toast.error(message);
} finally {
@@ -645,7 +542,7 @@ export default function WizardPage() {
(selectedScenario !== 'message_reply' || messageReceived)
);
case 2:
return selectedRunner !== null;
return selectedRunner !== null && isRunnerConfigComplete;
default:
return false;
}
@@ -657,6 +554,7 @@ export default function WizardPage() {
botSaved,
messageReceived,
selectedRunner,
isRunnerConfigComplete,
]);
const handleSelectScenario = useCallback(
@@ -726,6 +624,7 @@ export default function WizardPage() {
};
const resp = await httpClient.createBot(bot);
setCreatedBotUuid(resp.uuid);
setCreatedPipelineUuid(null);
// Fetch runtime info to get webhook URL(s)
try {
@@ -750,6 +649,7 @@ export default function WizardPage() {
selected_scenario: selectedScenario,
selected_adapter: selectedAdapter,
created_bot_uuid: resp.uuid,
created_pipeline_uuid: null,
bot_saved: false,
message_received: false,
selected_runner: null,
@@ -770,6 +670,8 @@ export default function WizardPage() {
const handleSaveBot = useCallback(async () => {
if (!createdBotUuid || !selectedAdapter) return;
setIsSavingBot(true);
let previewPipelineUuid = createdPipelineUuid;
let createdPreviewPipelineUuid: string | null = null;
try {
const configToSave = ensureHttpBotSigningSecret(
@@ -778,14 +680,51 @@ export default function WizardPage() {
);
setAdapterConfig(configToSave);
await httpClient.updateBot(createdBotUuid, {
if (
selectedScenarioDefinition?.processorKind === 'pipeline' &&
!previewPipelineUuid
) {
const pipelineResp = await httpClient.createPipeline({
name: `${botName} Pipeline`,
description: botDescription || '',
config: {},
});
previewPipelineUuid = pipelineResp.uuid;
createdPreviewPipelineUuid = pipelineResp.uuid;
}
const botUpdate: Partial<Bot> = {
name: botName,
description: botDescription || '',
adapter: selectedAdapter,
adapter_config: configToSave,
enable: true,
});
};
if (
selectedScenarioDefinition?.processorKind === 'pipeline' &&
previewPipelineUuid
) {
botUpdate.event_bindings = [
{
event_pattern: selectedScenarioDefinition.eventType,
target_type: 'pipeline',
target_uuid: previewPipelineUuid,
filters: [],
priority: 0,
enabled: true,
description: '',
},
];
}
await httpClient.updateBot(createdBotUuid, botUpdate);
if (previewPipelineUuid !== createdPipelineUuid) {
setCreatedPipelineUuid(previewPipelineUuid);
}
setBotSaved(true);
if (selectedAdapter === 'web_page_bot') {
setPageBotPreviewRequest((request) => request + 1);
}
setMessageReceived(false);
// Re-fetch runtime info to get updated webhook URL(s)
@@ -803,8 +742,23 @@ export default function WizardPage() {
}
// Persist progress
saveProgress({ step: 1, bot_saved: true, message_received: false });
saveProgress({
step: 1,
created_pipeline_uuid: previewPipelineUuid,
bot_saved: true,
message_received: false,
});
} catch (err) {
if (createdPreviewPipelineUuid) {
try {
await httpClient.deletePipeline(createdPreviewPipelineUuid);
} catch (rollbackError) {
console.warn(
'Failed to roll back wizard preview pipeline',
rollbackError,
);
}
}
const apiErr = err as { msg?: string };
toast.error(
t('wizard.createError') + (apiErr?.msg ? `: ${apiErr.msg}` : ''),
@@ -818,6 +772,8 @@ export default function WizardPage() {
botName,
botDescription,
adapterConfig,
createdPipelineUuid,
selectedScenarioDefinition,
t,
saveProgress,
]);
@@ -831,23 +787,33 @@ export default function WizardPage() {
// ---- Create Pipeline & Link (Step 2 finish) ----
const handleFinish = useCallback(async () => {
if (!selectedRunner || !createdBotUuid || !selectedScenarioDefinition)
if (
!selectedRunner ||
!isRunnerConfigComplete ||
!createdBotUuid ||
!selectedScenarioDefinition
)
return;
setIsSubmitting(true);
let processorUuid = '';
let processorCreatedThisAttempt = false;
let targetType: 'agent' | 'pipeline' | null = null;
try {
let targetType: 'agent' | 'pipeline';
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);
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
@@ -866,7 +832,7 @@ export default function WizardPage() {
? (fullAiConfig.runner_config as Record<string, unknown>)
: {};
await httpClient.updatePipeline(pipelineResp.uuid, {
await httpClient.updatePipeline(processorUuid, {
name: `${botName} Pipeline`,
description: botDescription || '',
config: {
@@ -881,8 +847,8 @@ export default function WizardPage() {
},
},
});
targetType = 'pipeline';
} else {
targetType = 'agent';
const agentResp = await httpClient.createAgent({
kind: 'agent',
name: `${botName} - ${t(selectedScenarioDefinition.labelKey)}`,
@@ -896,7 +862,7 @@ export default function WizardPage() {
supported_event_patterns: [selectedScenarioDefinition.eventType],
});
processorUuid = agentResp.uuid;
targetType = 'agent';
processorCreatedThisAttempt = true;
}
const botData = await httpClient.getBot(createdBotUuid);
@@ -921,11 +887,21 @@ export default function WizardPage() {
});
setCurrentStep(3);
saveProgress({ step: 3 });
if (targetType === 'pipeline') {
setCreatedPipelineUuid(processorUuid);
}
saveProgress({
step: 3,
created_pipeline_uuid: targetType === 'pipeline' ? processorUuid : null,
});
} catch (err) {
if (processorUuid) {
if (processorCreatedThisAttempt && processorUuid) {
try {
await httpClient.deleteAgent(processorUuid);
if (targetType === 'pipeline') {
await httpClient.deletePipeline(processorUuid);
} else {
await httpClient.deleteAgent(processorUuid);
}
} catch (rollbackError) {
console.warn('Failed to roll back wizard processor', rollbackError);
}
@@ -939,7 +915,9 @@ export default function WizardPage() {
}
}, [
selectedRunner,
isRunnerConfigComplete,
createdBotUuid,
createdPipelineUuid,
selectedScenarioDefinition,
botName,
botDescription,
@@ -965,6 +943,7 @@ export default function WizardPage() {
selected_scenario: null,
selected_adapter: null,
created_bot_uuid: null,
created_pipeline_uuid: null,
bot_saved: false,
selected_runner: null,
});
@@ -1095,6 +1074,7 @@ export default function WizardPage() {
createdBotUuid={createdBotUuid}
isSavingBot={isSavingBot}
botSaved={botSaved}
pageBotPreviewRequest={pageBotPreviewRequest}
messageReceived={messageReceived}
requiresMessageVerification={selectedScenario === 'message_reply'}
onMessageReceived={handleMessageReceived}
@@ -1469,22 +1449,28 @@ function PageBotFloatingWidget({
botUuid,
title,
testNotice,
openRequest,
}: {
botUuid: string;
title?: string;
testNotice: string;
openRequest: number;
}) {
useEffect(() => {
const script = document.createElement('script');
script.src = `${window.location.origin}/api/v1/embed/${botUuid}/widget.js?preview=wizard&v=${Date.now()}`;
script.dataset.title = title || 'LangBot';
script.dataset.testNotice = testNotice;
script.dataset.autoOpen = 'true';
document.body.appendChild(script);
return () => {
script.remove();
const root = document.getElementById('langbot-widget-root') as
| (HTMLElement & { langbotDestroy?: () => void })
| (HTMLElement & {
langbotDestroy?: () => void;
langbotOpen?: () => void;
})
| null;
if (root?.langbotDestroy) {
root.langbotDestroy();
@@ -1494,6 +1480,14 @@ function PageBotFloatingWidget({
};
}, [botUuid, testNotice, title]);
useEffect(() => {
if (openRequest <= 0) return;
const root = document.getElementById('langbot-widget-root') as
| (HTMLElement & { langbotOpen?: () => void })
| null;
root?.langbotOpen?.();
}, [openRequest]);
return null;
}
@@ -1506,6 +1500,7 @@ function StepBotConfig({
createdBotUuid,
isSavingBot,
botSaved,
pageBotPreviewRequest,
messageReceived,
requiresMessageVerification,
onMessageReceived,
@@ -1521,6 +1516,7 @@ function StepBotConfig({
createdBotUuid: string | null;
isSavingBot: boolean;
botSaved: boolean;
pageBotPreviewRequest: number;
messageReceived: boolean;
requiresMessageVerification: boolean;
onMessageReceived: () => void;
@@ -1592,6 +1588,7 @@ function StepBotConfig({
: undefined
}
testNotice={t('wizard.botConfig.pageBotTestNotice')}
openRequest={pageBotPreviewRequest}
/>
)}
@@ -1709,51 +1706,51 @@ function StepBotConfig({
<div className="grid gap-6 grid-cols-1 lg:grid-cols-2">
{/* Left column: Adapter config form */}
<div className="space-y-4">
{adapterConfigItems.length > 0 && (
<Card>
<CardHeader className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2">
<div className="flex items-center gap-2">
<CardTitle className="text-base">
{t('wizard.config.platformConfig', {
platform: adapterLabel,
})}
</CardTitle>
{selectedAdapterName &&
(() => {
const selectedAdapter = adapters.find(
(a) => a.name === selectedAdapterName,
);
const docUrl = getAdapterDocUrl(
selectedAdapter?.spec.help_links,
i18n.language,
);
return docUrl ? (
<a
href={docUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center text-xs text-primary hover:underline"
>
<ExternalLink className="mr-1 h-3 w-3" />
{t('bots.viewAdapterDocs')}
</a>
) : null;
})()}
</div>
<Button
size="sm"
onClick={onSaveBot}
disabled={isSavingBot}
className="w-full sm:w-auto shrink-0"
>
{isSavingBot && (
<Loader2 className="w-4 h-4 mr-1.5 animate-spin" />
)}
{botSaved
? t('wizard.botConfig.resaveBot')
: t('wizard.botConfig.saveBot')}
</Button>
</CardHeader>
<Card>
<CardHeader className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2">
<div className="flex items-center gap-2">
<CardTitle className="text-base">
{t('wizard.config.platformConfig', {
platform: adapterLabel,
})}
</CardTitle>
{selectedAdapterName &&
(() => {
const selectedAdapter = adapters.find(
(a) => a.name === selectedAdapterName,
);
const docUrl = getAdapterDocUrl(
selectedAdapter?.spec.help_links,
i18n.language,
);
return docUrl ? (
<a
href={docUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center text-xs text-primary hover:underline"
>
<ExternalLink className="mr-1 h-3 w-3" />
{t('bots.viewAdapterDocs')}
</a>
) : null;
})()}
</div>
<Button
size="sm"
onClick={onSaveBot}
disabled={isSavingBot}
className="w-full sm:w-auto shrink-0"
>
{isSavingBot && (
<Loader2 className="w-4 h-4 mr-1.5 animate-spin" />
)}
{botSaved
? t('wizard.botConfig.resaveBot')
: t('wizard.botConfig.saveBot')}
</Button>
</CardHeader>
{adapterConfigItems.length > 0 && (
<CardContent>
<DynamicFormComponent
itemConfigList={adapterConfigItems}
@@ -1767,8 +1764,8 @@ function StepBotConfig({
}}
/>
</CardContent>
</Card>
)}
)}
</Card>
{/* Bot saved indicator */}
{botSaved && !requiresMessageVerification && (
@@ -2182,6 +2179,7 @@ function StepDone() {
selected_scenario: null,
selected_adapter: null,
created_bot_uuid: null,
created_pipeline_uuid: null,
bot_saved: false,
selected_runner: null,
});
+4
View File
@@ -770,6 +770,10 @@ const enUS = {
noRunnersAvailable: 'No runners are available',
noRunnersAvailableDescription:
'Install and enable an AgentRunner extension before configuring this Agent.',
installedRunners: 'Installed AgentRunners',
marketplaceRunners: 'AgentRunner Marketplace',
noInstalledRunners: 'No AgentRunner extension is installed yet.',
installingRunner: 'Installing {{runner}}...',
selectedRunnerUnavailable: 'Selected runner is unavailable',
selectedRunnerUnavailableDescription:
'{{runner}} is not currently registered. Select another runner or restore its extension.',
+5
View File
@@ -782,6 +782,11 @@ const jaJP = {
noRunnersAvailable: '利用可能な Runner がありません',
noRunnersAvailableDescription:
'この Agent を設定する前に AgentRunner 拡張機能をインストールして有効にしてください。',
installedRunners: 'インストール済み AgentRunner',
marketplaceRunners: 'AgentRunner マーケットプレイス',
noInstalledRunners:
'AgentRunner 拡張機能はまだインストールされていません。',
installingRunner: '{{runner}} をインストールしています...',
selectedRunnerUnavailable: '選択した Runner は利用できません',
selectedRunnerUnavailableDescription:
'{{runner}} は現在登録されていません。別の Runner を選択するか、対応する拡張機能を復元してください。',
+4
View File
@@ -738,6 +738,10 @@ const zhHans = {
noRunnersAvailable: '没有可用的运行器',
noRunnersAvailableDescription:
'请先安装并启用 AgentRunner 扩展,再配置此 Agent。',
installedRunners: '已安装的 AgentRunner',
marketplaceRunners: 'AgentRunner 插件市场',
noInstalledRunners: '尚未安装任何 AgentRunner 扩展。',
installingRunner: '正在安装 {{runner}}...',
selectedRunnerUnavailable: '所选运行器不可用',
selectedRunnerUnavailableDescription:
'{{runner}} 当前未注册。请选择其他运行器,或恢复对应扩展。',
+303
View File
@@ -836,6 +836,309 @@ test.describe('pipeline advanced flows', () => {
});
test.describe('agent runner resource selectors', () => {
test('installs an AgentRunner from the grouped empty selector and refreshes it', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
const runnerId = 'plugin:qa/MarketplaceRunner/default';
let installed = false;
let installRequests = 0;
let taskPolls = 0;
let marketplaceSearchBody: Record<string, unknown> | undefined;
const apiResponse = (data: unknown) =>
JSON.stringify({
code: 0,
message: 'ok',
data,
timestamp: Date.now(),
});
const runnerConfig = () => ({
name: 'ai',
label: { en_US: 'AI Feature', zh_Hans: 'AI 能力' },
stages: [
{
name: 'runner',
label: { en_US: 'Runtime', zh_Hans: '运行方式' },
config: [
{
name: 'id',
label: { en_US: 'Runner', zh_Hans: '运行器' },
type: 'select',
required: true,
default: '',
options: installed
? [
{
name: runnerId,
label: {
en_US: 'Marketplace Runner',
zh_Hans: '市场运行器',
},
},
]
: [],
},
],
},
...(installed
? [
{
name: runnerId,
label: {
en_US: 'Marketplace Runner',
zh_Hans: '市场运行器',
},
config: [],
},
]
: []),
],
});
await page.route('**/api/v1/agents/_/metadata', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: apiResponse({
runner_config: runnerConfig(),
kinds: [],
}),
}),
);
await page.route('**/api/v1/agents/agent-empty', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: apiResponse({
agent: {
uuid: 'agent-empty',
name: 'Empty Runner Agent',
description: '',
emoji: 'A',
kind: 'agent',
config: {
runner: { id: '', 'expire-time': 0 },
runner_config: {},
},
supported_event_patterns: ['*'],
},
}),
}),
);
await page.route('**/api/v1/pipelines/_/metadata', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: apiResponse({ configs: [runnerConfig()] }),
}),
);
await page.route('**/api/v1/plugins', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: apiResponse({
plugins: installed
? [
{
manifest: {
manifest: {
metadata: { author: 'qa', name: 'MarketplaceRunner' },
},
},
},
]
: [],
}),
}),
);
await page.route('**/api/v1/plugins/install/marketplace', (route) => {
installRequests += 1;
installed = true;
return route.fulfill({
status: 200,
contentType: 'application/json',
body: apiResponse({ task_id: 77 }),
});
});
await page.route('**/api/v1/system/tasks/77', (route) => {
taskPolls += 1;
return route.fulfill({
status: 200,
contentType: 'application/json',
body: apiResponse({
id: 77,
name: 'plugin-install-marketplace',
label: 'Marketplace Runner',
runtime: { done: true, exception: null },
task_context: { current_action: 'complete', metadata: {} },
}),
});
});
await page.route(
'https://space.langbot.app/api/v1/marketplace/extensions/search',
async (route) => {
marketplaceSearchBody = JSON.parse(
route.request().postData() || '{}',
) as Record<string, unknown>;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: apiResponse({
extensions: [
{
id: 1,
plugin_id: 'qa/MarketplaceRunner',
author: 'qa',
name: 'MarketplaceRunner',
label: {
en_US: 'Marketplace Runner',
zh_Hans: '市场运行器',
},
description: {
en_US: 'Runner used by the grouped selector test.',
zh_Hans: '用于分组选择器测试的运行器。',
},
icon: '',
repository: 'https://example.test/runner',
tags: [],
install_count: 12,
latest_version: '1.0.0',
components: { AgentRunner: 1 },
status: 'live',
type: 'plugin',
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
},
],
total: 1,
}),
});
},
);
await page.goto('/home/agents?id=agent-empty');
await page.getByRole('tab', { name: /^Runner$/ }).click();
const runnerSelect = page.getByRole('combobox', { name: 'Runner' });
const triggerBox = await runnerSelect.boundingBox();
await runnerSelect.click();
await expect(page.getByText('Installed AgentRunners')).toBeVisible();
await expect(
page.getByText('No AgentRunner extension is installed yet.'),
).toBeVisible();
await expect(page.getByText('AgentRunner Marketplace')).toBeVisible();
const selectorPopup = page.locator('[data-slot="select-content"]');
await expect(
selectorPopup.getByText('Runner used by the grouped selector test.', {
exact: true,
}),
).toBeVisible();
const popupBox = await selectorPopup.boundingBox();
expect(triggerBox?.width).toBeLessThanOrEqual(353);
expect(popupBox?.width ?? 0).toBeLessThanOrEqual(
(triggerBox?.width ?? 0) + 1,
);
expect(popupBox?.width ?? 0).toBeGreaterThan((triggerBox?.width ?? 0) - 20);
await expect
.poll(() => marketplaceSearchBody)
.toMatchObject({
component_filter: 'AgentRunner',
type_filter: 'plugin',
});
await page
.getByRole('option')
.filter({ hasText: 'Marketplace Runner' })
.click();
await expect.poll(() => installRequests).toBe(1);
await expect.poll(() => taskPolls).toBeGreaterThan(0);
await expect(runnerSelect).toContainText('Marketplace Runner');
await runnerSelect.click();
await expect(
page.getByRole('option').filter({ hasText: runnerId }),
).toBeVisible();
await expect(
page.getByText('Runner used by the grouped selector test.', {
exact: true,
}),
).toHaveCount(0);
});
test('uses the compact AgentRunner marketplace selector in pipeline AI settings', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
const apiResponse = (data: unknown) =>
JSON.stringify({
code: 0,
message: 'ok',
data,
timestamp: Date.now(),
});
await page.route(
'https://space.langbot.app/api/v1/marketplace/extensions/search',
(route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: apiResponse({
extensions: [
{
id: 2,
plugin_id: 'qa/PipelineMarketplaceRunner',
author: 'qa',
name: 'PipelineMarketplaceRunner',
label: {
en_US: 'Pipeline Marketplace Runner',
zh_Hans: '流水线市场运行器',
},
description: {
en_US: 'A marketplace runner shown inside pipeline settings.',
zh_Hans: '展示在流水线配置中的市场运行器。',
},
icon: '',
repository: 'https://example.test/pipeline-runner',
tags: [],
install_count: 9,
latest_version: '1.0.0',
components: { AgentRunner: 1 },
status: 'live',
type: 'plugin',
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
},
],
total: 1,
}),
}),
);
await page.goto('/home/agents?id=pipeline-runner-selector');
await page.getByRole('tab', { name: /^AI$/ }).click();
const runnerSelect = page.getByRole('combobox', { name: 'Runner' });
await runnerSelect.click();
await expect(page.getByText('Installed AgentRunners')).toBeVisible();
await expect(page.getByText('AgentRunner Marketplace')).toBeVisible();
await expect(
page
.locator('[data-slot="select-content"]')
.getByText('A marketplace runner shown inside pipeline settings.', {
exact: true,
}),
).toBeVisible();
await expect(
page
.getByRole('option')
.filter({ hasText: 'Pipeline Marketplace Runner' }),
).toBeVisible();
});
test('uses the global catalog and preserves temporarily unavailable tools', async ({
page,
}) => {
@@ -0,0 +1,529 @@
import { expect, test } from '@playwright/test';
import fs from 'node:fs';
import path from 'node:path';
import { installLangBotApiMocks } from './fixtures/langbot-api';
const qrPlatforms = [
{
platform: 'feishu',
adapterName: 'qr-feishu',
label: 'Feishu QR Adapter',
apiBase: '/api/v1/platform/adapters/lark/create-app',
},
{
platform: 'weixin',
adapterName: 'qr-weixin',
label: 'Weixin QR Adapter',
apiBase: '/api/v1/platform/adapters/weixin/login',
},
{
platform: 'dingtalk',
adapterName: 'qr-dingtalk',
label: 'DingTalk QR Adapter',
apiBase: '/api/v1/platform/adapters/dingtalk/create-app',
},
{
platform: 'wecombot',
adapterName: 'qr-wecombot',
label: 'WeCom QR Adapter',
apiBase: '/api/v1/platform/adapters/wecombot/create-bot',
},
{
platform: 'qqofficial',
adapterName: 'qr-qqofficial',
label: 'QQ Official QR Adapter',
apiBase: '/api/v1/platform/adapters/qqofficial/bind',
},
] as const;
function ok(data: unknown) {
return JSON.stringify({
code: 0,
message: 'ok',
data,
timestamp: Date.now(),
});
}
function adapterWithQrLogin(
adapterName: string,
label: string,
platform: string,
) {
return {
name: adapterName,
label: { en_US: label, zh_Hans: label },
description: {
en_US: 'Exercises the QR-assisted setup flow.',
zh_Hans: '验证扫码辅助创建流程。',
},
spec: {
categories: ['testing'],
supported_events: ['message.received'],
config: [
{
id: 'qr-login',
name: 'qr-login',
type: 'qr-code-login',
label: { en_US: 'Create with QR code', zh_Hans: '扫码创建' },
description: {
en_US: 'Scan to finish creating this adapter.',
zh_Hans: '扫码完成适配器创建。',
},
required: false,
default: '',
login_platform: platform,
},
],
},
};
}
test.describe('wizard and QR platform regressions', () => {
test('opens the Page Bot test panel after the first and every later save', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
let pipelineCreateCount = 0;
let boundPipelineUuid: string | null = null;
page.on('request', (request) => {
const url = new URL(request.url());
if (request.method() === 'POST' && url.pathname === '/api/v1/pipelines') {
pipelineCreateCount += 1;
}
if (
request.method() === 'PUT' &&
url.pathname === '/api/v1/platform/bots/bot-1'
) {
const body = request.postDataJSON() as {
event_bindings?: Array<{
event_pattern?: string;
target_type?: string;
target_uuid?: string;
}>;
};
const binding = body.event_bindings?.find(
(item) =>
item.event_pattern === 'message.received' &&
item.target_type === 'pipeline',
);
boundPipelineUuid = binding?.target_uuid ?? null;
}
});
await page.route('**/api/v1/platform/adapters', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: ok({
adapters: [
{
name: 'web_page_bot',
label: { en_US: 'Page Bot', zh_Hans: '页面机器人' },
description: {
en_US: 'An embeddable page bot.',
zh_Hans: '可嵌入网页的机器人。',
},
spec: {
categories: ['web'],
supported_events: ['message.received'],
config: [
{
id: 'title',
name: 'title',
type: 'string',
label: { en_US: 'Title', zh_Hans: '标题' },
required: false,
default: 'Wizard Page Bot',
},
],
},
},
],
}),
});
});
const widgetTemplate = fs.readFileSync(
path.resolve(process.cwd(), '../src/langbot/templates/embed/widget.js'),
'utf8',
);
await page.route('**/api/v1/embed/*/widget.js?*', async (route) => {
if (!boundPipelineUuid || pipelineCreateCount === 0) {
await route.fulfill({
status: 404,
contentType: 'application/javascript',
body: '// Bot not found or not available',
});
return;
}
const origin = new URL(route.request().url()).origin;
const source = widgetTemplate
.replaceAll('__LANGBOT_LOCALE__', 'en_US')
.replaceAll('__LANGBOT_BOT_UUID__', 'bot-1')
.replaceAll('__LANGBOT_BASE_URL__', origin)
.replaceAll('__LANGBOT_TURNSTILE_SITE_KEY__', '')
.replaceAll('__LANGBOT_BUBBLE_ICON__', 'chat');
await route.fulfill({
status: 200,
contentType: 'application/javascript',
body: source,
});
});
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();
const saveButton = page.getByRole('button', {
name: /^(Save & Enable Bot|Re-save Configuration)$/,
});
await saveButton.click();
await expect.poll(() => pipelineCreateCount).toBe(1);
await expect.poll(() => boundPipelineUuid).toBe('pipeline-1');
const widgetRoot = page.locator('#langbot-widget-root');
await expect(widgetRoot).toBeAttached();
await expect
.poll(() =>
widgetRoot.evaluate((root) =>
root.shadowRoot
?.querySelector('.lb-panel')
?.classList.contains('lb-visible'),
),
)
.toBe(true);
await expect
.poll(() =>
widgetRoot.evaluate(
(root) =>
root.shadowRoot?.querySelector('.lb-test-notice')?.textContent,
),
)
.toContain('For testing only');
await widgetRoot.evaluate((root) => {
const minimize = root.shadowRoot?.querySelector(
'.lb-header-btn[aria-label="Minimize"]',
) as HTMLButtonElement | null;
minimize?.click();
});
await expect
.poll(() =>
widgetRoot.evaluate((root) =>
root.shadowRoot
?.querySelector('.lb-panel')
?.classList.contains('lb-visible'),
),
)
.toBe(false);
await saveButton.click();
await expect.poll(() => pipelineCreateCount).toBe(1);
await expect.poll(() => boundPipelineUuid).toBe('pipeline-1');
await expect
.poll(() =>
widgetRoot.evaluate((root) =>
root.shadowRoot
?.querySelector('.lb-panel')
?.classList.contains('lb-visible'),
),
)
.toBe(true);
});
test('binds HTTP Bot before its signed inbound verification request', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
let pipelineCreateCount = 0;
let inboundTestCount = 0;
let boundPipelineUuid: string | null = null;
let savedInboundSecret = '';
page.on('request', (request) => {
const url = new URL(request.url());
if (request.method() === 'POST' && url.pathname === '/api/v1/pipelines') {
pipelineCreateCount += 1;
}
if (
request.method() === 'PUT' &&
url.pathname === '/api/v1/platform/bots/bot-1'
) {
const body = request.postDataJSON() as {
adapter_config?: { inbound_secret?: string };
event_bindings?: Array<{
event_pattern?: string;
target_type?: string;
target_uuid?: string;
}>;
};
savedInboundSecret = body.adapter_config?.inbound_secret ?? '';
const binding = body.event_bindings?.find(
(item) =>
item.event_pattern === 'message.received' &&
item.target_type === 'pipeline',
);
boundPipelineUuid = binding?.target_uuid ?? null;
}
});
await page.route('**/api/v1/platform/adapters', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: ok({
adapters: [
{
name: 'http_bot',
label: { en_US: 'HTTP Bot', zh_Hans: 'HTTP 机器人' },
description: {
en_US: 'Receives signed HTTP messages.',
zh_Hans: '接收签名的 HTTP 消息。',
},
spec: {
categories: ['web'],
supported_events: ['message.received'],
config: [
{
id: 'signature-required',
name: 'signature_required',
type: 'boolean',
label: { en_US: 'Require signature', zh_Hans: '要求签名' },
required: false,
default: true,
},
{
id: 'webhook-url',
name: 'webhook_url',
type: 'webhook-url',
label: { en_US: 'Webhook URL', zh_Hans: 'Webhook 地址' },
required: false,
default: '',
},
],
},
},
],
}),
});
});
await page.route(
'**/api/v1/platform/bots/bot-1/test-inbound',
async (route) => {
inboundTestCount += 1;
expect(route.request().method()).toBe('POST');
await route.fulfill({
status: 200,
contentType: 'application/json',
body: ok({ accepted: true }),
});
},
);
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
.getByRole('button', {
name: /^(Save & Enable Bot|Re-save Configuration)$/,
})
.click();
await expect.poll(() => pipelineCreateCount).toBe(1);
await expect.poll(() => boundPipelineUuid).toBe('pipeline-1');
await expect.poll(() => savedInboundSecret).toMatch(/^[a-f0-9]{64}$/);
await page.getByRole('button', { name: 'Send Test' }).click();
await expect.poll(() => inboundTestCount).toBe(1);
});
test('blocks deployment until required AgentRunner configuration is real', async ({
page,
}) => {
await installLangBotApiMocks(page, {
authenticated: true,
withAdapterEvents: true,
});
await page.route('**/api/v1/pipelines/_/metadata', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: ok({
configs: [
{
name: 'ai',
label: { en_US: 'AI Feature', zh_Hans: 'AI 能力' },
stages: [
{
name: 'runner',
label: { en_US: 'Runtime', zh_Hans: '运行方式' },
config: [
{
name: 'id',
label: { en_US: 'Runner', zh_Hans: '运行器' },
type: 'select',
required: true,
default: 'external-runner',
options: [
{
name: 'external-runner',
label: {
en_US: 'External Runner',
zh_Hans: '外部运行器',
},
},
],
},
],
},
{
name: 'external-runner',
label: {
en_US: 'External Runner',
zh_Hans: '外部运行器',
},
config: [
{
id: 'api-key',
name: 'api-key',
label: { en_US: 'API Key', zh_Hans: 'API 密钥' },
type: 'secret',
required: true,
default: 'your-api-key',
},
],
},
],
},
],
}),
});
});
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 page.getByRole('button', { name: 'Next' }).click();
await page.getByText('External Runner', { exact: true }).click();
const deployButton = page.getByRole('button', { name: 'Create & Deploy' });
await expect(deployButton).toBeDisabled();
await page.getByRole('textbox').fill('app-real-api-key');
await expect(deployButton).toBeEnabled();
});
for (const qrPlatform of qrPlatforms) {
test(`${qrPlatform.label} requests and displays its QR code`, async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.route('**/api/v1/platform/adapters', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: ok({
adapters: [
adapterWithQrLogin(
qrPlatform.adapterName,
qrPlatform.label,
qrPlatform.platform,
),
],
}),
});
});
let cleanupRequestSeen = false;
await page.route(`**${qrPlatform.apiBase}`, async (route) => {
expect(route.request().method()).toBe('POST');
expect(new URL(route.request().url()).origin).toBe(
'http://127.0.0.1:4173',
);
expect(route.request().headers()['authorization']).toBe(
'Bearer playwright-token',
);
expect(route.request().headers()['x-workspace-id']).toBe(
'workspace-playwright',
);
await route.fulfill({
status: 200,
contentType: 'application/json',
body: ok({
session_id: `session-${qrPlatform.platform}`,
qr_url: `https://example.test/qr/${qrPlatform.platform}`,
expire_at: Math.floor(Date.now() / 1000) + 120,
}),
});
});
await page.route(`**${qrPlatform.apiBase}/**`, async (route) => {
if (route.request().method() === 'DELETE') {
cleanupRequestSeen = true;
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: ok({ status: 'pending' }),
});
});
await page.goto('/home/bots?id=new');
await page.getByRole('combobox').click();
await page.getByRole('option', { name: qrPlatform.label }).click();
await page.getByRole('button', { name: /^Start$/ }).click();
const qrImage = page.getByRole('img', { name: 'QR Code' });
await expect(qrImage).toBeVisible();
await expect(qrImage).toHaveAttribute('src', /^data:image\/png;base64,/);
await page.keyboard.press('Escape');
await expect(qrImage).toHaveCount(0);
await expect.poll(() => cleanupRequestSeen).toBe(true);
});
}
test('a failed QR request remains cancellable instead of trapping the form', async ({
page,
}) => {
const qrPlatform = qrPlatforms[0];
await installLangBotApiMocks(page, { authenticated: true });
await page.route('**/api/v1/platform/adapters', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: ok({
adapters: [
adapterWithQrLogin(
qrPlatform.adapterName,
qrPlatform.label,
qrPlatform.platform,
),
],
}),
});
});
await page.route(`**${qrPlatform.apiBase}`, async (route) => {
await route.fulfill({ status: 503, body: 'unavailable' });
});
await page.goto('/home/bots?id=new');
await page.getByRole('combobox').click();
await page.getByRole('option', { name: qrPlatform.label }).click();
await page.getByRole('button', { name: /^Start$/ }).click();
const dialog = page.getByRole('dialog');
await expect(dialog.getByText('HTTP 503')).toBeVisible();
await expect(dialog.getByRole('button', { name: 'Retry' })).toBeEnabled();
await dialog.getByRole('button', { name: 'Cancel' }).click();
await expect(dialog).toHaveCount(0);
await expect(page.getByRole('button', { name: /^Submit$/ })).toBeVisible();
});
});
@@ -9,8 +9,20 @@ const dialogPath = path.join(
'src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx',
);
const localeDir = path.join(root, 'src/i18n/locales');
const backendUrlPath = path.join(root, 'src/app/infra/http/backendUrl.ts');
const dialogSource = fs.readFileSync(dialogPath, 'utf8');
const backendUrlSource = fs.readFileSync(backendUrlPath, 'utf8');
test('QR login resolves a proxy-root API base against the current origin', () => {
assert.match(dialogSource, /const baseUrl = getBackendBaseUrl\(\)/);
assert.match(backendUrlSource, /!configured \|\| configured === '\/'/);
assert.match(backendUrlSource, /return normalizedOrigin/);
assert.doesNotMatch(
dialogSource,
/import\.meta\.env\.VITE_API_BASE_URL \|\| window\.location\.origin/,
);
});
test('QR credential exchanges preserve the active Workspace scope', () => {
assert.match(dialogSource, /getActiveWorkspaceUuid/);
+58 -4
View File
@@ -9,6 +9,13 @@ const wizardSource = fs.readFileSync(
path.resolve(currentDirectory, '../../src/app/wizard/page.tsx'),
'utf8',
);
const runnerMarketplaceSource = fs.readFileSync(
path.resolve(
currentDirectory,
'../../src/app/home/agents/agent-runner-marketplace.ts',
),
'utf8',
);
const widgetSource = fs.readFileSync(
path.resolve(
currentDirectory,
@@ -32,11 +39,58 @@ test('shows the test-only notice only when the wizard opts in', () => {
assert.match(widgetSource, /testNotice\.textContent = scriptTestNotice/);
});
test('opens the Page Bot preview after every successful wizard save', () => {
assert.match(wizardSource, /script\.dataset\.autoOpen = 'true'/);
assert.match(
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', () => {
assert.match(
wizardSource,
/selectedScenarioDefinition\?\.processorKind === 'pipeline'[\s\S]*?httpClient\.createPipeline\(/,
);
assert.match(
wizardSource,
/event_pattern: selectedScenarioDefinition\.eventType,[\s\S]*?target_type: 'pipeline',[\s\S]*?target_uuid: previewPipelineUuid/,
);
assert.doesNotMatch(
wizardSource,
/selectedAdapter === 'web_page_bot' && !previewPipelineUuid/,
);
});
test('keeps the 4.11 AgentRunner marketplace installation flow', () => {
assert.match(wizardSource, /RUNNER_COMPONENT_FILTER = 'AgentRunner'/);
assert.match(wizardSource, /installPluginFromMarketplace\(/);
assert.match(wizardSource, /runnerPluginPrefix\(plugin\)/);
assert.match(wizardSource, /registrationDeadline/);
assert.match(wizardSource, /loadAgentRunnerCatalog\(\)/);
assert.match(wizardSource, /installMarketplaceAgentRunner\(plugin\)/);
assert.match(
runnerMarketplaceSource,
/RUNNER_COMPONENT_FILTER = 'AgentRunner'/,
);
assert.match(runnerMarketplaceSource, /installPluginFromMarketplace\(/);
assert.match(runnerMarketplaceSource, /runnerPluginPrefix\(plugin\)/);
assert.match(runnerMarketplaceSource, /registrationDeadline/);
});
test('requires the selected AgentRunner mandatory configuration before finishing', () => {
assert.match(
wizardSource,
/isRequiredRunnerConfigComplete\(selectedRunnerConfigItems, runnerConfig\)/,
);
assert.match(
wizardSource,
/return selectedRunner !== null && isRunnerConfigComplete/,
);
assert.match(
wizardSource,
/!selectedRunner \|\|[\s\S]*?!isRunnerConfigComplete \|\|[\s\S]*?!createdBotUuid/,
);
});
test('requires an observed message only for the message-reply scenario', () => {