fix(web): harden agent runner marketplace flows

This commit is contained in:
Hyu
2026-09-01 12:46:16 +08:00
parent e5e62c8fe9
commit 982d660236
9 changed files with 426 additions and 30 deletions
@@ -1,5 +1,6 @@
import { httpClient } from '@/app/infra/http/HttpClient'; import { httpClient } from '@/app/infra/http/HttpClient';
import { getCloudServiceClient } from '@/app/infra/http'; import { getCloudServiceClient } from '@/app/infra/http';
import { getActiveWorkspaceUuid } from '@/app/infra/http/workspaceContext';
import type { IDynamicFormItemOption } from '@/app/infra/entities/form/dynamic'; import type { IDynamicFormItemOption } from '@/app/infra/entities/form/dynamic';
import type { PipelineConfigTab } from '@/app/infra/entities/pipeline'; import type { PipelineConfigTab } from '@/app/infra/entities/pipeline';
import type { PluginV4 } from '@/app/infra/entities/plugin'; import type { PluginV4 } from '@/app/infra/entities/plugin';
@@ -9,6 +10,8 @@ export const RUNNER_COMPONENT_FILTER = 'AgentRunner';
const RUNNER_CATALOG_PAGE_SIZE = 100; const RUNNER_CATALOG_PAGE_SIZE = 100;
const RUNNER_INSTALL_TIMEOUT_MS = 120_000; const RUNNER_INSTALL_TIMEOUT_MS = 120_000;
const RUNNER_REGISTRATION_TIMEOUT_MS = 60_000; const RUNNER_REGISTRATION_TIMEOUT_MS = 60_000;
const RUNNER_INSTALL_INTENT_KEY_PREFIX = 'langbot-agent-runner-install';
const RUNNER_INSTALL_INTENT_EVENT = 'langbot-agent-runner-install-change';
export type AgentRunnerMarketplaceErrorCode = export type AgentRunnerMarketplaceErrorCode =
| 'version-unavailable' | 'version-unavailable'
@@ -32,6 +35,21 @@ export interface InstalledAgentRunner {
runner: IDynamicFormItemOption; runner: IDynamicFormItemOption;
} }
export interface PendingAgentRunnerInstall {
taskId: number;
pluginId: string;
pluginAuthor: string;
pluginName: string;
pluginLabel: string;
scope: string;
startedAt: number;
}
interface InstallAgentRunnerOptions {
scope: string;
onTaskCreated?: (taskId: number) => void;
}
function wait(ms: number) { function wait(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
} }
@@ -52,6 +70,74 @@ export function runnerPluginPrefix(plugin: Pick<PluginV4, 'author' | 'name'>) {
return `plugin:${plugin.author}/${plugin.name}/`; return `plugin:${plugin.author}/${plugin.name}/`;
} }
function installIntentStorageKey(scope: string) {
return `${RUNNER_INSTALL_INTENT_KEY_PREFIX}:${getActiveWorkspaceUuid() || 'default'}:${scope}`;
}
function emitInstallIntentChange(scope: string) {
if (typeof window === 'undefined') return;
window.dispatchEvent(
new CustomEvent(RUNNER_INSTALL_INTENT_EVENT, { detail: { scope } }),
);
}
export function readPendingAgentRunnerInstall(
scope: string,
): PendingAgentRunnerInstall | null {
if (typeof window === 'undefined') return null;
try {
const raw = sessionStorage.getItem(installIntentStorageKey(scope));
if (!raw) return null;
const value = JSON.parse(raw) as Partial<PendingAgentRunnerInstall>;
if (
value.scope !== scope ||
typeof value.taskId !== 'number' ||
typeof value.pluginId !== 'string' ||
typeof value.pluginAuthor !== 'string' ||
typeof value.pluginName !== 'string' ||
typeof value.pluginLabel !== 'string' ||
typeof value.startedAt !== 'number'
) {
sessionStorage.removeItem(installIntentStorageKey(scope));
return null;
}
return value as PendingAgentRunnerInstall;
} catch {
return null;
}
}
function writePendingAgentRunnerInstall(intent: PendingAgentRunnerInstall) {
if (typeof window === 'undefined') return;
sessionStorage.setItem(
installIntentStorageKey(intent.scope),
JSON.stringify(intent),
);
emitInstallIntentChange(intent.scope);
}
export function clearPendingAgentRunnerInstall(scope: string, taskId?: number) {
if (typeof window === 'undefined') return;
const current = readPendingAgentRunnerInstall(scope);
if (taskId !== undefined && current?.taskId !== taskId) return;
sessionStorage.removeItem(installIntentStorageKey(scope));
emitInstallIntentChange(scope);
}
export function subscribePendingAgentRunnerInstall(
scope: string,
listener: () => void,
) {
if (typeof window === 'undefined') return () => undefined;
const handleChange = (event: Event) => {
const detail = (event as CustomEvent<{ scope?: string }>).detail;
if (detail?.scope === scope) listener();
};
window.addEventListener(RUNNER_INSTALL_INTENT_EVENT, handleChange);
return () =>
window.removeEventListener(RUNNER_INSTALL_INTENT_EVENT, handleChange);
}
export async function loadAgentRunnerCatalog(): Promise<AgentRunnerCatalog> { export async function loadAgentRunnerCatalog(): Promise<AgentRunnerCatalog> {
const cloudClient = await getCloudServiceClient(); const cloudClient = await getCloudServiceClient();
const [firstSearchResult, recommendationResult, installedResult] = const [firstSearchResult, recommendationResult, installedResult] =
@@ -124,6 +210,7 @@ export async function loadAgentRunnerCatalog(): Promise<AgentRunnerCatalog> {
export async function installMarketplaceAgentRunner( export async function installMarketplaceAgentRunner(
plugin: PluginV4, plugin: PluginV4,
options: InstallAgentRunnerOptions,
): Promise<InstalledAgentRunner> { ): Promise<InstalledAgentRunner> {
if (!plugin.latest_version) { if (!plugin.latest_version) {
throw new AgentRunnerMarketplaceError('version-unavailable'); throw new AgentRunnerMarketplaceError('version-unavailable');
@@ -134,17 +221,51 @@ export async function installMarketplaceAgentRunner(
plugin.name, plugin.name,
plugin.latest_version, plugin.latest_version,
); );
const pending: PendingAgentRunnerInstall = {
taskId,
pluginId: marketplacePluginId(plugin),
pluginAuthor: plugin.author,
pluginName: plugin.name,
pluginLabel: extractPluginLabel(plugin),
scope: options.scope,
startedAt: Date.now(),
};
writePendingAgentRunnerInstall(pending);
options.onTaskCreated?.(taskId);
return finishAgentRunnerInstall(pending);
}
function extractPluginLabel(plugin: PluginV4) {
const label = plugin.label;
if (typeof label === 'string') return label || plugin.name;
if (label && typeof label === 'object') {
const localized = Object.values(label).find(
(value): value is string => typeof value === 'string' && value.length > 0,
);
if (localized) return localized;
}
return plugin.name;
}
async function finishAgentRunnerInstall(
pending: PendingAgentRunnerInstall,
): Promise<InstalledAgentRunner> {
// A refreshed page receives a fresh observation window. The backend task is
// authoritative; `startedAt` is display metadata, not a reason to abandon a
// still-running installation immediately after recovery.
const installDeadline = Date.now() + RUNNER_INSTALL_TIMEOUT_MS; const installDeadline = Date.now() + RUNNER_INSTALL_TIMEOUT_MS;
let installCompleted = false; let installCompleted = false;
while (Date.now() < installDeadline) { while (true) {
const task = await httpClient.getAsyncTask(taskId); const task = await httpClient.getAsyncTask(pending.taskId);
if (task.runtime.done) { if (task.runtime.done) {
if (task.runtime.exception) { if (task.runtime.exception) {
clearPendingAgentRunnerInstall(pending.scope, pending.taskId);
throw new Error(task.runtime.exception); throw new Error(task.runtime.exception);
} }
installCompleted = true; installCompleted = true;
break; break;
} }
if (Date.now() >= installDeadline) break;
await wait(1000); await wait(1000);
} }
if (!installCompleted) { if (!installCompleted) {
@@ -152,7 +273,10 @@ export async function installMarketplaceAgentRunner(
} }
const registrationDeadline = Date.now() + RUNNER_REGISTRATION_TIMEOUT_MS; const registrationDeadline = Date.now() + RUNNER_REGISTRATION_TIMEOUT_MS;
const prefix = runnerPluginPrefix(plugin); const prefix = runnerPluginPrefix({
author: pending.pluginAuthor,
name: pending.pluginName,
});
while (Date.now() < registrationDeadline) { while (Date.now() < registrationDeadline) {
const metadata = await httpClient.getGeneralPipelineMetadata(); const metadata = await httpClient.getGeneralPipelineMetadata();
const configTab = metadata.configs.find((config) => config.name === 'ai'); const configTab = metadata.configs.find((config) => config.name === 'ai');
@@ -169,10 +293,20 @@ export async function installMarketplaceAgentRunner(
pluginRunnerOptions[0]; pluginRunnerOptions[0];
if (configTab && runner) { if (configTab && runner) {
clearPendingAgentRunnerInstall(pending.scope, pending.taskId);
return { configTab, runner }; return { configTab, runner };
} }
await wait(1000); await wait(1000);
} }
clearPendingAgentRunnerInstall(pending.scope, pending.taskId);
throw new AgentRunnerMarketplaceError('registration-timeout'); throw new AgentRunnerMarketplaceError('registration-timeout');
} }
export async function resumePendingAgentRunnerInstall(
scope: string,
): Promise<InstalledAgentRunner | null> {
const pending = readPendingAgentRunnerInstall(scope);
if (!pending) return null;
return finishAgentRunnerInstall(pending);
}
@@ -13,7 +13,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod'; import { z } from 'zod';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Bot, SlidersHorizontal, Zap } from 'lucide-react'; import { Bot, Loader2, SlidersHorizontal, Zap } from 'lucide-react';
import { httpClient } from '@/app/infra/http/HttpClient'; import { httpClient } from '@/app/infra/http/HttpClient';
import { Agent, ApiRespPluginSystemStatus } from '@/app/infra/entities/api'; import { Agent, ApiRespPluginSystemStatus } from '@/app/infra/entities/api';
import { import {
@@ -21,6 +21,13 @@ import {
PipelineConfigTab, PipelineConfigTab,
} from '@/app/infra/entities/pipeline'; } from '@/app/infra/entities/pipeline';
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent'; import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
import { getDefaultValues } from '@/app/home/components/dynamic-form/DynamicFormItemConfig';
import {
getErrorMessage,
readPendingAgentRunnerInstall,
resumePendingAgentRunnerInstall,
type InstalledAgentRunner,
} from '@/app/home/agents/agent-runner-marketplace';
import { extractI18nObject } from '@/i18n/I18nProvider'; import { extractI18nObject } from '@/i18n/I18nProvider';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { import {
@@ -117,6 +124,8 @@ function AgentFormComponent(
useState<ApiRespPluginSystemStatus | null>(null); useState<ApiRespPluginSystemStatus | null>(null);
const [pluginStatusLoading, setPluginStatusLoading] = useState(true); const [pluginStatusLoading, setPluginStatusLoading] = useState(true);
const [pluginStatusError, setPluginStatusError] = useState(false); const [pluginStatusError, setPluginStatusError] = useState(false);
const [initialDataLoaded, setInitialDataLoaded] = useState(false);
const [runnerInstallRecovering, setRunnerInstallRecovering] = useState(false);
const [activeSection, setActiveSection] = const [activeSection, setActiveSection] =
useState<AgentConfigSection>('runner'); useState<AgentConfigSection>('runner');
const isSavingRef = useRef(false); const isSavingRef = useRef(false);
@@ -147,6 +156,35 @@ function AgentFormComponent(
supported_event_patterns: ['*'], supported_event_patterns: ['*'],
}, },
}); });
const runnerInstallScope = `agent:${agentId}`;
const applyInstalledRunner = useCallback(
(installed: InstalledAgentRunner) => {
setRunnerConfigSchema(installed.configTab);
const currentRunner = form.getValues('runner') || {};
const currentConfigs = form.getValues('runner_config') || {};
const runnerName = installed.runner.name;
const runnerStage = installed.configTab.stages.find(
(stage) => stage.name === runnerName,
);
form.setValue(
'runner',
{ ...currentRunner, id: runnerName },
{ shouldDirty: true },
);
if (!(runnerName in currentConfigs) && runnerStage) {
form.setValue(
'runner_config',
{
...currentConfigs,
[runnerName]: getDefaultValues(runnerStage.config),
},
{ shouldDirty: true },
);
}
},
[form],
);
const savedSnapshotRef = useRef(''); const savedSnapshotRef = useRef('');
const initializedStagesRef = useRef<Set<string>>(new Set()); const initializedStagesRef = useRef<Set<string>>(new Set());
@@ -189,6 +227,7 @@ function AgentFormComponent(
form.reset(loadedValues); form.reset(loadedValues);
savedSnapshotRef.current = JSON.stringify(loadedValues); savedSnapshotRef.current = JSON.stringify(loadedValues);
initializedStagesRef.current.clear(); initializedStagesRef.current.clear();
setInitialDataLoaded(true);
}) })
.catch((err) => { .catch((err) => {
toast.error(t('agents.loadError') + err.msg); toast.error(t('agents.loadError') + err.msg);
@@ -198,6 +237,40 @@ function AgentFormComponent(
}; };
}, [agentId, form, t]); }, [agentId, form, t]);
useEffect(() => {
if (
!initialDataLoaded ||
!readPendingAgentRunnerInstall(runnerInstallScope)
) {
return;
}
let cancelled = false;
setRunnerInstallRecovering(true);
void resumePendingAgentRunnerInstall(runnerInstallScope)
.then((installed) => {
if (cancelled || !installed) return;
applyInstalledRunner(installed);
toast.success(
t('wizard.aiEngine.installSuccess', {
runner: extractI18nObject(installed.runner.label),
}),
);
})
.catch((error) => {
if (!cancelled) {
toast.error(
getErrorMessage(error) || t('wizard.aiEngine.installFailed'),
);
}
})
.finally(() => {
if (!cancelled) setRunnerInstallRecovering(false);
});
return () => {
cancelled = true;
};
}, [applyInstalledRunner, initialDataLoaded, runnerInstallScope, t]);
const loadPluginSystemStatus = useCallback(async () => { const loadPluginSystemStatus = useCallback(async () => {
setPluginStatusLoading(true); setPluginStatusLoading(true);
setPluginStatusError(false); setPluginStatusError(false);
@@ -425,7 +498,8 @@ function AgentFormComponent(
label={extractI18nObject(config.label)} label={extractI18nObject(config.label)}
value={String(field.value ?? '')} value={String(field.value ?? '')}
onValueChange={field.onChange} onValueChange={field.onChange}
onMetadataRefresh={setRunnerConfigSchema} installScope={runnerInstallScope}
onInstalled={applyInstalledRunner}
/> />
) : undefined ) : undefined
: undefined : undefined
@@ -576,7 +650,17 @@ function AgentFormComponent(
{activeSection === 'runner_config' && ( {activeSection === 'runner_config' && (
<div className="space-y-6"> <div className="space-y-6">
{activeRunnerStage ? ( {runnerInstallRecovering ? (
<Card>
<CardHeader>
<CardTitle>{t('agents.runnerSettings')}</CardTitle>
<CardDescription className="flex items-center gap-2">
<Loader2 className="size-4 animate-spin" />
{t('agents.restoringRunnerInstall')}
</CardDescription>
</CardHeader>
</Card>
) : activeRunnerStage ? (
renderDynamicStage(activeRunnerStage) renderDynamicStage(activeRunnerStage)
) : ( ) : (
<Card> <Card>
@@ -1,11 +1,10 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { Bot, Download, Loader2, Store } from 'lucide-react'; import { Bot, Download, ExternalLink, Loader2, Store } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { getCloudServiceClientSync, httpClient } from '@/app/infra/http'; import { getCloudServiceClientSync, httpClient } from '@/app/infra/http';
import type { IDynamicFormItemOption } from '@/app/infra/entities/form/dynamic'; 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 type { PluginV4 } from '@/app/infra/entities/plugin';
import { import {
AgentRunnerMarketplaceError, AgentRunnerMarketplaceError,
@@ -14,7 +13,11 @@ import {
loadAgentRunnerCatalog, loadAgentRunnerCatalog,
marketplacePluginId, marketplacePluginId,
runnerPluginPrefix, runnerPluginPrefix,
readPendingAgentRunnerInstall,
subscribePendingAgentRunnerInstall,
type InstalledAgentRunner,
} from '@/app/home/agents/agent-runner-marketplace'; } from '@/app/home/agents/agent-runner-marketplace';
import { usePluginInstallTasks } from '@/app/home/plugins/components/plugin-install-task';
import { extractI18nObject } from '@/i18n/I18nProvider'; import { extractI18nObject } from '@/i18n/I18nProvider';
import { import {
Select, Select,
@@ -109,21 +112,24 @@ export default function AgentRunnerSelect({
label, label,
value, value,
onValueChange, onValueChange,
onMetadataRefresh, installScope,
onInstalled,
}: { }: {
options: IDynamicFormItemOption[]; options: IDynamicFormItemOption[];
label: string; label: string;
value: string; value: string;
onValueChange: (value: string) => void; onValueChange: (value: string) => void;
onMetadataRefresh: (configTab: PipelineConfigTab) => void; installScope: string;
onInstalled: (installed: InstalledAgentRunner) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { addTask } = usePluginInstallTasks();
const [marketplaceRunners, setMarketplaceRunners] = useState<PluginV4[]>([]); const [marketplaceRunners, setMarketplaceRunners] = useState<PluginV4[]>([]);
const [installedPluginIds, setInstalledPluginIds] = useState<string[]>([]); const [installedPluginIds, setInstalledPluginIds] = useState<string[]>([]);
const [catalogLoading, setCatalogLoading] = useState(true); const [catalogLoading, setCatalogLoading] = useState(true);
const [catalogError, setCatalogError] = useState(false); const [catalogError, setCatalogError] = useState(false);
const [installingPlugin, setInstallingPlugin] = useState<PluginV4 | null>( const [pendingInstall, setPendingInstall] = useState(() =>
null, readPendingAgentRunnerInstall(installScope),
); );
const [installError, setInstallError] = useState<string | null>(null); const [installError, setInstallError] = useState<string | null>(null);
@@ -146,6 +152,13 @@ export default function AgentRunnerSelect({
void loadCatalog(); void loadCatalog();
}, [loadCatalog]); }, [loadCatalog]);
useEffect(() => {
const syncPendingInstall = () =>
setPendingInstall(readPendingAgentRunnerInstall(installScope));
syncPendingInstall();
return subscribePendingAgentRunnerInstall(installScope, syncPendingInstall);
}, [installScope]);
const marketplaceOptions = useMemo( const marketplaceOptions = useMemo(
() => () =>
marketplaceRunners.filter((plugin) => { marketplaceRunners.filter((plugin) => {
@@ -159,6 +172,11 @@ export default function AgentRunnerSelect({
); );
const selectedOption = options.find((option) => option.name === value); const selectedOption = options.find((option) => option.name === value);
const installingPlugin = pendingInstall
? (marketplaceRunners.find(
(plugin) => marketplacePluginId(plugin) === pendingInstall.pluginId,
) ?? null)
: null;
const handleValueChange = useCallback( const handleValueChange = useCallback(
async (nextValue: string) => { async (nextValue: string) => {
@@ -172,14 +190,21 @@ export default function AgentRunnerSelect({
const plugin = marketplaceRunners.find( const plugin = marketplaceRunners.find(
(candidate) => marketplacePluginId(candidate) === pluginId, (candidate) => marketplacePluginId(candidate) === pluginId,
); );
if (!plugin || installingPlugin) return; if (!plugin || pendingInstall) return;
setInstallingPlugin(plugin);
setInstallError(null); setInstallError(null);
try { try {
const installed = await installMarketplaceAgentRunner(plugin); const installed = await installMarketplaceAgentRunner(plugin, {
onMetadataRefresh(installed.configTab); scope: installScope,
onValueChange(installed.runner.name); onTaskCreated: (taskId) =>
addTask({
taskId,
pluginName: marketplacePluginId(plugin),
source: 'marketplace',
extensionType: 'plugin',
}),
});
onInstalled(installed);
await loadCatalog(); await loadCatalog();
toast.success( toast.success(
t('wizard.aiEngine.installSuccess', { t('wizard.aiEngine.installSuccess', {
@@ -191,15 +216,17 @@ export default function AgentRunnerSelect({
setInstallError(message); setInstallError(message);
toast.error(message); toast.error(message);
} finally { } finally {
setInstallingPlugin(null); setPendingInstall(readPendingAgentRunnerInstall(installScope));
} }
}, },
[ [
installingPlugin, addTask,
installScope,
loadCatalog, loadCatalog,
marketplaceRunners, marketplaceRunners,
onMetadataRefresh, onInstalled,
onValueChange, onValueChange,
pendingInstall,
t, t,
], ],
); );
@@ -208,7 +235,7 @@ export default function AgentRunnerSelect({
<div className="w-full max-w-[22rem] space-y-2"> <div className="w-full max-w-[22rem] space-y-2">
<Select <Select
value={value} value={value}
disabled={installingPlugin !== null} disabled={pendingInstall !== null}
onValueChange={(nextValue) => void handleValueChange(nextValue)} onValueChange={(nextValue) => void handleValueChange(nextValue)}
onOpenChange={(open) => { onOpenChange={(open) => {
if (open && catalogError && !catalogLoading) void loadCatalog(); if (open && catalogError && !catalogLoading) void loadCatalog();
@@ -264,11 +291,22 @@ export default function AgentRunnerSelect({
<SelectSeparator /> <SelectSeparator />
<SelectGroup> <SelectGroup>
<SelectLabel className="px-2 py-1 text-[11px] font-medium"> <SelectLabel className="flex items-center justify-between gap-2 px-2 py-1 text-[11px] font-medium">
<span className="inline-flex items-center gap-1.5"> <span className="inline-flex min-w-0 items-center gap-1.5">
<Store className="size-3.5" /> <Store className="size-3.5" />
{t('agents.marketplaceRunners')} {t('agents.marketplaceRunners')}
</span> </span>
<a
href="https://space.langbot.app/market?type=plugin&component=AgentRunner"
target="_blank"
rel="noreferrer"
className="inline-flex shrink-0 items-center gap-1 rounded border px-1.5 py-0.5 text-[10px] font-medium text-foreground hover:bg-accent"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
{t('agents.viewMarketplace')}
<ExternalLink className="size-3" />
</a>
</SelectLabel> </SelectLabel>
{catalogLoading && marketplaceOptions.length === 0 ? ( {catalogLoading && marketplaceOptions.length === 0 ? (
<div className="flex items-center gap-2 px-2 py-1.5 text-xs text-muted-foreground"> <div className="flex items-center gap-2 px-2 py-1.5 text-xs text-muted-foreground">
@@ -1,5 +1,6 @@
import { import {
forwardRef, forwardRef,
useCallback,
useEffect, useEffect,
useImperativeHandle, useImperativeHandle,
useMemo, useMemo,
@@ -58,6 +59,12 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import PipelineExtension from '@/app/home/pipelines/components/pipeline-extensions/PipelineExtension'; import PipelineExtension from '@/app/home/pipelines/components/pipeline-extensions/PipelineExtension';
import AgentRunnerSelect from '@/app/home/agents/components/AgentRunnerSelect'; import AgentRunnerSelect from '@/app/home/agents/components/AgentRunnerSelect';
import {
getErrorMessage,
readPendingAgentRunnerInstall,
resumePendingAgentRunnerInstall,
type InstalledAgentRunner,
} from '@/app/home/agents/agent-runner-marketplace';
interface PipelineFormComponentProps { interface PipelineFormComponentProps {
pipelineId?: string; pipelineId?: string;
@@ -204,6 +211,8 @@ const PipelineFormComponent = forwardRef<
useState<PipelineConfigTab>(); useState<PipelineConfigTab>();
const [outputConfigTabSchema, setOutputConfigTabSchema] = const [outputConfigTabSchema, setOutputConfigTabSchema] =
useState<PipelineConfigTab>(); useState<PipelineConfigTab>();
const [metadataLoaded, setMetadataLoaded] = useState(false);
const [pipelineLoaded, setPipelineLoaded] = useState(!isEditMode);
const form = useForm<FormValues>({ const form = useForm<FormValues>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
@@ -219,6 +228,36 @@ const PipelineFormComponent = forwardRef<
output: {}, output: {},
}, },
}); });
const runnerInstallScope = `pipeline:${pipelineId || 'new'}`;
const applyInstalledRunner = useCallback(
(installed: InstalledAgentRunner) => {
setAIConfigTabSchema(installed.configTab);
const currentAI = (form.getValues('ai') || {}) as Record<string, any>;
const currentRunner =
currentAI.runner && typeof currentAI.runner === 'object'
? currentAI.runner
: {};
const currentConfigs =
currentAI.runner_config && typeof currentAI.runner_config === 'object'
? currentAI.runner_config
: {};
const runnerName = installed.runner.name;
const runnerStage = installed.configTab.stages.find(
(stage) => stage.name === runnerName,
);
form.setValue('ai', {
...currentAI,
runner: { ...currentRunner, id: runnerName },
runner_config: {
...currentConfigs,
...(runnerStage && !(runnerName in currentConfigs)
? { [runnerName]: getDefaultValues(runnerStage.config) }
: {}),
},
});
},
[form],
);
const dynamicFormSystemContext = useMemo( const dynamicFormSystemContext = useMemo(
() => ({ pipeline_id: pipelineId }), () => ({ pipeline_id: pipelineId }),
[pipelineId], [pipelineId],
@@ -244,6 +283,8 @@ const PipelineFormComponent = forwardRef<
}, [hasUnsavedChanges, onDirtyChange]); }, [hasUnsavedChanges, onDirtyChange]);
useEffect(() => { useEffect(() => {
setMetadataLoaded(false);
setPipelineLoaded(!isEditMode);
// get config schema from metadata // get config schema from metadata
httpClient.getGeneralPipelineMetadata().then((resp) => { httpClient.getGeneralPipelineMetadata().then((resp) => {
for (const config of resp.configs) { for (const config of resp.configs) {
@@ -257,6 +298,7 @@ const PipelineFormComponent = forwardRef<
setOutputConfigTabSchema(config); setOutputConfigTabSchema(config);
} }
} }
setMetadataLoaded(true);
}); });
if (isEditMode) { if (isEditMode) {
@@ -279,10 +321,48 @@ const PipelineFormComponent = forwardRef<
form.reset(loadedValues); form.reset(loadedValues);
savedSnapshotRef.current = JSON.stringify(loadedValues); savedSnapshotRef.current = JSON.stringify(loadedValues);
initializedStagesRef.current.clear(); initializedStagesRef.current.clear();
setPipelineLoaded(true);
}); });
} }
}, [form, isEditMode, pipelineId]); }, [form, isEditMode, pipelineId]);
useEffect(() => {
if (
!metadataLoaded ||
!pipelineLoaded ||
!readPendingAgentRunnerInstall(runnerInstallScope)
) {
return;
}
let cancelled = false;
void resumePendingAgentRunnerInstall(runnerInstallScope)
.then((installed) => {
if (cancelled || !installed) return;
applyInstalledRunner(installed);
toast.success(
t('wizard.aiEngine.installSuccess', {
runner: extractI18nObject(installed.runner.label),
}),
);
})
.catch((error) => {
if (!cancelled) {
toast.error(
getErrorMessage(error) || t('wizard.aiEngine.installFailed'),
);
}
});
return () => {
cancelled = true;
};
}, [
applyInstalledRunner,
metadataLoaded,
pipelineLoaded,
runnerInstallScope,
t,
]);
useEffect(() => { useEffect(() => {
if (!isEditMode) { if (!isEditMode) {
form.reset({ form.reset({
@@ -523,7 +603,8 @@ const PipelineFormComponent = forwardRef<
label={extractI18nObject(config.label)} label={extractI18nObject(config.label)}
value={String(field.value ?? '')} value={String(field.value ?? '')}
onValueChange={field.onChange} onValueChange={field.onChange}
onMetadataRefresh={setAIConfigTabSchema} installScope={runnerInstallScope}
onInstalled={applyInstalledRunner}
/> />
) : undefined ) : undefined
} }
+46 -1
View File
@@ -70,6 +70,8 @@ import {
installMarketplaceAgentRunner, installMarketplaceAgentRunner,
loadAgentRunnerCatalog, loadAgentRunnerCatalog,
marketplacePluginId, marketplacePluginId,
readPendingAgentRunnerInstall,
resumePendingAgentRunnerInstall,
runnerPluginPrefix, runnerPluginPrefix,
} from '@/app/home/agents/agent-runner-marketplace'; } from '@/app/home/agents/agent-runner-marketplace';
import { import {
@@ -104,6 +106,7 @@ import {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const TOTAL_STEPS = 4; const TOTAL_STEPS = 4;
const WIZARD_RUNNER_INSTALL_SCOPE = 'wizard';
type WizardScenarioId = type WizardScenarioId =
| 'message_reply' | 'message_reply'
@@ -497,7 +500,9 @@ export default function WizardPage() {
setRunnerInstallError(null); setRunnerInstallError(null);
try { try {
const installed = await installMarketplaceAgentRunner(plugin); const installed = await installMarketplaceAgentRunner(plugin, {
scope: WIZARD_RUNNER_INSTALL_SCOPE,
});
setAiConfigTab(installed.configTab); setAiConfigTab(installed.configTab);
setInstalledPluginIds((current) => setInstalledPluginIds((current) =>
current.includes(pluginId) ? current : [...current, pluginId], current.includes(pluginId) ? current : [...current, pluginId],
@@ -529,6 +534,46 @@ export default function WizardPage() {
[handleSelectRunner, t], [handleSelectRunner, t],
); );
useEffect(() => {
if (isLoading) return;
const pending = readPendingAgentRunnerInstall(WIZARD_RUNNER_INSTALL_SCOPE);
if (!pending) return;
let cancelled = false;
setInstallingRunnerPluginId(pending.pluginId);
setRunnerInstallError(null);
void resumePendingAgentRunnerInstall(WIZARD_RUNNER_INSTALL_SCOPE)
.then((installed) => {
if (cancelled || !installed) return;
setAiConfigTab(installed.configTab);
setInstalledPluginIds((current) =>
current.includes(pending.pluginId)
? current
: [...current, pending.pluginId],
);
handleSelectRunner(installed.runner.name, installed.configTab);
toast.success(
t('wizard.aiEngine.installSuccess', {
runner: pending.pluginLabel,
}),
);
})
.catch((error) => {
if (cancelled) return;
const message =
getErrorMessage(error) || t('wizard.aiEngine.installFailed');
setRunnerInstallError(message);
toast.error(message);
})
.finally(() => {
if (!cancelled) setInstallingRunnerPluginId(null);
});
return () => {
cancelled = true;
};
}, [handleSelectRunner, isLoading, t]);
// ---- Navigation helpers ---- // ---- Navigation helpers ----
const canProceed = useCallback((): boolean => { const canProceed = useCallback((): boolean => {
+4 -1
View File
@@ -771,7 +771,10 @@ const enUS = {
noRunnersAvailableDescription: noRunnersAvailableDescription:
'Install and enable an AgentRunner extension before configuring this Agent.', 'Install and enable an AgentRunner extension before configuring this Agent.',
installedRunners: 'Installed AgentRunners', installedRunners: 'Installed AgentRunners',
marketplaceRunners: 'AgentRunner Marketplace', marketplaceRunners: 'AgentRunner plugins in Marketplace',
viewMarketplace: 'View market',
restoringRunnerInstall:
'Restoring the AgentRunner plugin installation and waiting for the runner…',
noInstalledRunners: 'No AgentRunner extension is installed yet.', noInstalledRunners: 'No AgentRunner extension is installed yet.',
installingRunner: 'Installing {{runner}}...', installingRunner: 'Installing {{runner}}...',
selectedRunnerUnavailable: 'Selected runner is unavailable', selectedRunnerUnavailable: 'Selected runner is unavailable',
+4 -1
View File
@@ -783,7 +783,10 @@ const jaJP = {
noRunnersAvailableDescription: noRunnersAvailableDescription:
'この Agent を設定する前に AgentRunner 拡張機能をインストールして有効にしてください。', 'この Agent を設定する前に AgentRunner 拡張機能をインストールして有効にしてください。',
installedRunners: 'インストール済み AgentRunner', installedRunners: 'インストール済み AgentRunner',
marketplaceRunners: 'AgentRunner マーケットプレイス', marketplaceRunners: 'マーケットプレイスの AgentRunner プラグイン',
viewMarketplace: '市場を見る',
restoringRunnerInstall:
'AgentRunner プラグインのインストールを復元し、ランナーを待機しています…',
noInstalledRunners: noInstalledRunners:
'AgentRunner 拡張機能はまだインストールされていません。', 'AgentRunner 拡張機能はまだインストールされていません。',
installingRunner: '{{runner}} をインストールしています...', installingRunner: '{{runner}} をインストールしています...',
+3 -1
View File
@@ -739,7 +739,9 @@ const zhHans = {
noRunnersAvailableDescription: noRunnersAvailableDescription:
'请先安装并启用 AgentRunner 扩展,再配置此 Agent。', '请先安装并启用 AgentRunner 扩展,再配置此 Agent。',
installedRunners: '已安装的 AgentRunner', installedRunners: '已安装的 AgentRunner',
marketplaceRunners: 'AgentRunner 插件市场', marketplaceRunners: '插件市场中的 AgentRunner 插件',
viewMarketplace: '查看市场',
restoringRunnerInstall: '正在恢复 AgentRunner 插件安装并等待运行器就绪…',
noInstalledRunners: '尚未安装任何 AgentRunner 扩展。', noInstalledRunners: '尚未安装任何 AgentRunner 扩展。',
installingRunner: '正在安装 {{runner}}...', installingRunner: '正在安装 {{runner}}...',
selectedRunnerUnavailable: '所选运行器不可用', selectedRunnerUnavailable: '所选运行器不可用',
+8 -2
View File
@@ -68,14 +68,20 @@ test('binds every message-reply bot to its provisional pipeline before verificat
test('keeps the 4.11 AgentRunner marketplace installation flow', () => { test('keeps the 4.11 AgentRunner marketplace installation flow', () => {
assert.match(wizardSource, /loadAgentRunnerCatalog\(\)/); assert.match(wizardSource, /loadAgentRunnerCatalog\(\)/);
assert.match(wizardSource, /installMarketplaceAgentRunner\(plugin\)/); assert.match(
wizardSource,
/installMarketplaceAgentRunner\(plugin, \{[\s\S]*?scope: WIZARD_RUNNER_INSTALL_SCOPE/,
);
assert.match(wizardSource, /resumePendingAgentRunnerInstall\(/);
assert.match( assert.match(
runnerMarketplaceSource, runnerMarketplaceSource,
/RUNNER_COMPONENT_FILTER = 'AgentRunner'/, /RUNNER_COMPONENT_FILTER = 'AgentRunner'/,
); );
assert.match(runnerMarketplaceSource, /installPluginFromMarketplace\(/); assert.match(runnerMarketplaceSource, /installPluginFromMarketplace\(/);
assert.match(runnerMarketplaceSource, /runnerPluginPrefix\(plugin\)/); assert.match(runnerMarketplaceSource, /const prefix = runnerPluginPrefix\(\{/);
assert.match(runnerMarketplaceSource, /option\.name\.startsWith\(prefix\)/);
assert.match(runnerMarketplaceSource, /registrationDeadline/); assert.match(runnerMarketplaceSource, /registrationDeadline/);
assert.match(runnerMarketplaceSource, /sessionStorage\.setItem\(/);
}); });
test('requires the selected AgentRunner mandatory configuration before finishing', () => { test('requires the selected AgentRunner mandatory configuration before finishing', () => {