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
@@ -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);
}}