mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-16 14:57:15 +00:00
fix(web): complete pluginized agent onboarding flows
This commit is contained in:
@@ -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);
|
||||
}}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -782,6 +782,11 @@ const jaJP = {
|
||||
noRunnersAvailable: '利用可能な Runner がありません',
|
||||
noRunnersAvailableDescription:
|
||||
'この Agent を設定する前に AgentRunner 拡張機能をインストールして有効にしてください。',
|
||||
installedRunners: 'インストール済み AgentRunner',
|
||||
marketplaceRunners: 'AgentRunner マーケットプレイス',
|
||||
noInstalledRunners:
|
||||
'AgentRunner 拡張機能はまだインストールされていません。',
|
||||
installingRunner: '{{runner}} をインストールしています...',
|
||||
selectedRunnerUnavailable: '選択した Runner は利用できません',
|
||||
selectedRunnerUnavailableDescription:
|
||||
'{{runner}} は現在登録されていません。別の Runner を選択するか、対応する拡張機能を復元してください。',
|
||||
|
||||
@@ -738,6 +738,10 @@ const zhHans = {
|
||||
noRunnersAvailable: '没有可用的运行器',
|
||||
noRunnersAvailableDescription:
|
||||
'请先安装并启用 AgentRunner 扩展,再配置此 Agent。',
|
||||
installedRunners: '已安装的 AgentRunner',
|
||||
marketplaceRunners: 'AgentRunner 插件市场',
|
||||
noInstalledRunners: '尚未安装任何 AgentRunner 扩展。',
|
||||
installingRunner: '正在安装 {{runner}}...',
|
||||
selectedRunnerUnavailable: '所选运行器不可用',
|
||||
selectedRunnerUnavailableDescription:
|
||||
'{{runner}} 当前未注册。请选择其他运行器,或恢复对应扩展。',
|
||||
|
||||
Reference in New Issue
Block a user