feat(web): improve marketplace install workflows

This commit is contained in:
Hyu
2026-09-01 16:43:50 +08:00
parent 982d660236
commit a87c814dae
23 changed files with 1657 additions and 353 deletions
@@ -426,6 +426,15 @@ class PluginsRouterGroup(group.RouterGroup):
async def _(author: str, plugin_name: str, request_context: RequestContext) -> str:
execution_context = await self.ap.plugin_connector.require_workspace_context(request_context)
ctx = taskmgr.TaskContext.new()
ctx.metadata.update(
{
'plugin_name': f'{author}/{plugin_name}',
'install_source': 'marketplace',
'operation': 'upgrade',
'progress_percent': 3,
}
)
ctx.set_current_action('checking for latest version')
wrapper = self.ap.task_mgr.create_user_task(
self._run_fenced_plugin_operation(
execution_context,
+32 -1
View File
@@ -1718,6 +1718,9 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
file_bytes: bytes | None
if install_source == PluginInstallSource.MARKETPLACE:
if task_context is not None:
task_context.set_current_action('downloading plugin package')
task_context.metadata['progress_percent'] = 15
file_bytes, version = await self._download_marketplace_package(
execution_context,
plugin_author,
@@ -1741,6 +1744,9 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
else:
raise ValueError(f'Unsupported plugin install source: {install_source.value}')
if task_context is not None:
task_context.set_current_action('validating plugin package')
task_context.metadata['progress_percent'] = 32
manifest_author, manifest_name = self._inspect_plugin_package(file_bytes, task_context)
if not manifest_author or not manifest_name:
raise ValueError('Plugin package manifest identity is missing')
@@ -1751,6 +1757,8 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
plugin_author, plugin_name = manifest_author, manifest_name
if task_context is not None:
task_context.metadata['plugin_name'] = f'{plugin_author}/{plugin_name}'
task_context.set_current_action('preparing plugin installation')
task_context.metadata['progress_percent'] = 45
artifact_digest = hashlib.sha256(file_bytes).hexdigest()
await self._store_artifact_package(execution_context, artifact_digest, file_bytes)
@@ -1787,14 +1795,30 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
pass
except Exception as exc:
self.ap.logger.debug(f'Legacy OSS plugin cleanup skipped: {exc}')
if task_context is not None:
operation = task_context.metadata.get('operation')
task_context.set_current_action(
'applying plugin update' if operation == 'upgrade' else 'installing plugin dependencies'
)
task_context.metadata['progress_percent'] = 62
await self._apply_desired_state(
desired,
artifact_package=file_bytes,
)
if previous_digest is not None and previous_digest != artifact_digest:
await self._delete_artifact_if_unreferenced(execution_context, previous_digest)
if task_context is not None:
task_context.set_current_action('waiting for plugin initialization')
task_context.metadata['progress_percent'] = 84
await self._wait_for_installed_plugin_ready(plugin_author, plugin_name, task_context)
if task_context is not None:
task_context.set_current_action('refreshing plugin components')
task_context.metadata['progress_percent'] = 95
await self._refresh_agent_runner_registry()
if task_context is not None:
operation = task_context.metadata.get('operation')
task_context.set_current_action('plugin updated' if operation == 'upgrade' else 'plugin installed')
task_context.metadata['progress_percent'] = 100
async def upgrade_plugin(
self,
@@ -1806,13 +1830,20 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
if setting.install_source != PluginInstallSource.MARKETPLACE.value:
raise ValueError(f'Plugin {plugin_author}/{plugin_name} is not installed from marketplace')
if task_context is not None:
task_context.metadata.update(
{
'plugin_name': f'{plugin_author}/{plugin_name}',
'install_source': 'marketplace',
'operation': 'upgrade',
'progress_percent': 3,
}
)
task_context.set_current_action('checking for latest version')
await self.install_plugin(
PluginInstallSource.MARKETPLACE,
{'plugin_author': plugin_author, 'plugin_name': plugin_name},
task_context=task_context,
)
await self._refresh_agent_runner_registry()
return {}
async def delete_plugin(
@@ -290,6 +290,93 @@ async def test_local_install_persists_verified_package_before_runtime_apply():
)
@pytest.mark.asyncio
async def test_marketplace_upgrade_reports_multistep_progress():
package = b'marketplace-lbpkg-bytes'
digest = hashlib.sha256(package).hexdigest()
execution_context = ExecutionContext(
instance_uuid='instance-a',
workspace_uuid='workspace-a',
placement_generation=1,
)
binding = InstallationBinding(
instance_uuid='instance-a',
workspace_uuid='workspace-a',
placement_generation=1,
installation_uuid='00000000-0000-4000-8000-000000000001',
runtime_revision=2,
artifact_digest=digest,
)
app = SimpleNamespace(
instance_config=SimpleNamespace(data={'plugin': {'enable': True}}),
deployment=SimpleNamespace(mode='cloud'),
logger=Mock(),
)
connector = PluginRuntimeConnector(app, AsyncMock())
connector.handler = runtime_handler()
connector._current_execution_context = AsyncMock(return_value=execution_context)
connector._setting_for_plugin = AsyncMock(
return_value=(execution_context, SimpleNamespace(install_source=PluginInstallSource.MARKETPLACE.value))
)
observed_actions: list[str] = []
task_context = SimpleNamespace(current_action='default', metadata={})
def set_current_action(action: str):
task_context.current_action = action
task_context.set_current_action = set_current_action
async def download(*_args, **_kwargs):
observed_actions.append(task_context.current_action)
return package, '2.0.0'
def inspect(*_args, **_kwargs):
observed_actions.append(task_context.current_action)
return 'author', 'plugin'
async def store(*_args, **_kwargs):
observed_actions.append(task_context.current_action)
async def persist(*_args, **_kwargs):
return binding, None, False
async def apply(*_args, **_kwargs):
observed_actions.append(task_context.current_action)
return {'state': 'starting'}
async def wait_until_ready(*_args, **_kwargs):
observed_actions.append(task_context.current_action)
async def refresh_registry():
observed_actions.append(task_context.current_action)
connector._download_marketplace_package = AsyncMock(side_effect=download)
connector._inspect_plugin_package = Mock(side_effect=inspect)
connector._store_artifact_package = AsyncMock(side_effect=store)
connector._persist_installation_package = AsyncMock(side_effect=persist)
connector.handler.apply_plugin_installation = AsyncMock(side_effect=apply)
connector._wait_for_installed_plugin_ready = AsyncMock(side_effect=wait_until_ready)
connector._refresh_agent_runner_registry = AsyncMock(side_effect=refresh_registry)
await connector.upgrade_plugin('author', 'plugin', task_context=task_context)
assert observed_actions == [
'downloading plugin package',
'validating plugin package',
'preparing plugin installation',
'applying plugin update',
'waiting for plugin initialization',
'refreshing plugin components',
]
assert task_context.current_action == 'plugin updated'
assert task_context.metadata == {
'plugin_name': 'author/plugin',
'install_source': 'marketplace',
'operation': 'upgrade',
'progress_percent': 100,
}
@pytest.mark.asyncio
async def test_workspace_reads_do_not_wait_for_an_installation_apply():
package = b'local-lbpkg-bytes'
@@ -4,6 +4,7 @@ import { getActiveWorkspaceUuid } from '@/app/infra/http/workspaceContext';
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 { I18nObject } from '@/app/infra/entities/common';
export const RUNNER_COMPONENT_FILTER = 'AgentRunner';
@@ -28,6 +29,7 @@ export class AgentRunnerMarketplaceError extends Error {
export interface AgentRunnerCatalog {
marketplaceRunners: PluginV4[];
installedPluginIds: string[];
installedPluginDescriptions: Record<string, I18nObject>;
}
export interface InstalledAgentRunner {
@@ -199,12 +201,20 @@ export async function loadAgentRunnerCatalog(): Promise<AgentRunnerCatalog> {
return right.install_count - left.install_count;
});
const installedPluginDescriptions: Record<string, I18nObject> = {};
const installedPluginIds = installedResult.plugins.map((plugin) => {
const metadata = plugin.manifest.manifest.metadata;
const pluginId = `${metadata.author ?? ''}/${metadata.name}`;
if (metadata.description) {
installedPluginDescriptions[pluginId] = metadata.description;
}
return pluginId;
});
return {
marketplaceRunners,
installedPluginIds: installedResult.plugins.map((plugin) => {
const metadata = plugin.manifest.manifest.metadata;
return `${metadata.author ?? ''}/${metadata.name}`;
}),
installedPluginIds,
installedPluginDescriptions,
};
}
@@ -21,7 +21,6 @@ import {
PipelineConfigTab,
} from '@/app/infra/entities/pipeline';
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
import { getDefaultValues } from '@/app/home/components/dynamic-form/DynamicFormItemConfig';
import {
getErrorMessage,
readPendingAgentRunnerInstall,
@@ -161,29 +160,8 @@ function AgentFormComponent(
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('');
@@ -251,7 +229,7 @@ function AgentFormComponent(
if (cancelled || !installed) return;
applyInstalledRunner(installed);
toast.success(
t('wizard.aiEngine.installSuccess', {
t('agents.runnerInstallSuccess', {
runner: extractI18nObject(installed.runner.label),
}),
);
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Bot, Download, ExternalLink, Loader2, Store } from 'lucide-react';
import { Bot, ExternalLink, Loader2, Store } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
@@ -15,9 +15,14 @@ import {
runnerPluginPrefix,
readPendingAgentRunnerInstall,
subscribePendingAgentRunnerInstall,
type AgentRunnerCatalog,
type InstalledAgentRunner,
} from '@/app/home/agents/agent-runner-marketplace';
import { usePluginInstallTasks } from '@/app/home/plugins/components/plugin-install-task';
import {
InstallStage,
usePluginInstallTasks,
} from '@/app/home/plugins/components/plugin-install-task';
import MarketplaceInstallButton from '@/app/home/components/MarketplaceInstallButton';
import { extractI18nObject } from '@/i18n/I18nProvider';
import {
Select,
@@ -30,8 +35,6 @@ import {
SelectValue,
} from '@/components/ui/select';
const MARKETPLACE_VALUE_PREFIX = '__agent_runner_marketplace__:';
function installErrorMessage(
error: unknown,
t: ReturnType<typeof useTranslation>['t'],
@@ -48,17 +51,21 @@ function installErrorMessage(
return getErrorMessage(error) || t('wizard.aiEngine.installFailed');
}
function InstalledRunnerContent({
option,
}: {
option: IDynamicFormItemOption;
}) {
const iconURL = option.name.startsWith('plugin:')
function installedRunnerIconURL(option: IDynamicFormItemOption) {
return option.name.startsWith('plugin:')
? (() => {
const match = option.name.match(/^plugin:([^/]+)\/([^/]+)(?:\/|$)/);
return match ? httpClient.getPluginIconURL(match[1], match[2]) : null;
})()
: null;
}
function InstalledRunnerContent({
option,
}: {
option: IDynamicFormItemOption;
}) {
const iconURL = installedRunnerIconURL(option);
return (
<span className="flex min-w-0 items-center gap-2">
@@ -76,7 +83,79 @@ function InstalledRunnerContent({
);
}
function MarketplaceRunnerContent({ plugin }: { plugin: PluginV4 }) {
function InstalledRunnerOptionContent({
option,
description,
}: {
option: IDynamicFormItemOption;
description: string;
}) {
const iconURL = installedRunnerIconURL(option);
return (
<span className="grid w-full min-w-0 grid-cols-[1.75rem_minmax(0,1fr)] items-center gap-x-2 text-left">
{iconURL ? (
<img
src={iconURL}
alt=""
className="row-span-2 size-7 shrink-0 rounded-md object-cover"
/>
) : (
<Bot className="row-span-2 size-5 justify-self-center text-muted-foreground" />
)}
<span className="truncate font-medium leading-5">
{extractI18nObject(option.label)}
</span>
<span
className="truncate text-xs leading-4 text-muted-foreground"
title={description}
>
{description}
</span>
</span>
);
}
function runnerPluginId(optionName: string) {
return optionName.match(/^plugin:([^/]+\/[^/]+)(?:\/|$)/)?.[1] ?? null;
}
function installedRunnerDescription(
option: IDynamicFormItemOption,
marketplaceRunners: PluginV4[],
installedPluginDescriptions: AgentRunnerCatalog['installedPluginDescriptions'],
) {
const pluginId = runnerPluginId(option.name);
if (!pluginId) return option.name;
const marketplacePlugin = marketplaceRunners.find(
(plugin) => marketplacePluginId(plugin) === pluginId,
);
return (
(marketplacePlugin?.description
? extractI18nObject(marketplacePlugin.description)
: '') ||
(installedPluginDescriptions[pluginId]
? extractI18nObject(installedPluginDescriptions[pluginId])
: '') ||
option.name
);
}
function MarketplaceRunnerContent({
plugin,
installing,
progress,
installDisabled,
installLabel,
onInstall,
}: {
plugin: PluginV4;
installing: boolean;
progress: number;
installDisabled: boolean;
installLabel: string;
onInstall: () => void;
}) {
const iconURL = getCloudServiceClientSync().resolveMarketplaceIconURL(
plugin.type,
plugin.author,
@@ -87,7 +166,7 @@ function MarketplaceRunnerContent({ plugin }: { plugin: PluginV4 }) {
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">
<div className="grid w-full min-w-0 grid-cols-[1.75rem_minmax(0,1fr)_4rem] items-center gap-x-2 rounded-sm px-2 py-1.5 hover:bg-accent focus-within:bg-accent">
<img
src={iconURL}
alt=""
@@ -96,14 +175,20 @@ function MarketplaceRunnerContent({ plugin }: { plugin: PluginV4 }) {
<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" />
<MarketplaceInstallButton
installing={installing}
progress={progress}
disabled={installDisabled}
label={installLabel}
onInstall={onInstall}
/>
<span
className="truncate text-xs leading-4 text-muted-foreground"
title={description}
>
{description}
</span>
</span>
</div>
);
}
@@ -123,15 +208,20 @@ export default function AgentRunnerSelect({
onInstalled: (installed: InstalledAgentRunner) => void;
}) {
const { t } = useTranslation();
const { addTask } = usePluginInstallTasks();
const { addTask, tasks } = usePluginInstallTasks();
const [marketplaceRunners, setMarketplaceRunners] = useState<PluginV4[]>([]);
const [installedPluginIds, setInstalledPluginIds] = useState<string[]>([]);
const [installedPluginDescriptions, setInstalledPluginDescriptions] =
useState<AgentRunnerCatalog['installedPluginDescriptions']>({});
const [catalogLoading, setCatalogLoading] = useState(true);
const [catalogError, setCatalogError] = useState(false);
const [pendingInstall, setPendingInstall] = useState(() =>
readPendingAgentRunnerInstall(installScope),
);
const [installError, setInstallError] = useState<string | null>(null);
const [installingPluginId, setInstallingPluginId] = useState<string | null>(
null,
);
const loadCatalog = useCallback(async () => {
setCatalogLoading(true);
@@ -140,6 +230,7 @@ export default function AgentRunnerSelect({
const catalog = await loadAgentRunnerCatalog();
setMarketplaceRunners(catalog.marketplaceRunners);
setInstalledPluginIds(catalog.installedPluginIds);
setInstalledPluginDescriptions(catalog.installedPluginDescriptions);
} catch (error) {
console.error('Failed to load AgentRunner catalog', error);
setCatalogError(true);
@@ -172,26 +263,32 @@ export default function AgentRunnerSelect({
);
const selectedOption = options.find((option) => option.name === value);
const installingPlugin = pendingInstall
? (marketplaceRunners.find(
(plugin) => marketplacePluginId(plugin) === pendingInstall.pluginId,
) ?? null)
: null;
const activePluginId = pendingInstall?.pluginId ?? installingPluginId;
const activeTask = pendingInstall
? tasks.find((task) => task.taskId === pendingInstall.taskId)
: undefined;
const installProgress = activePluginId
? activeTask
? activeTask.stage === InstallStage.DONE
? 95
: Math.max(5, activeTask.overallProgress)
: 5
: 0;
const handleValueChange = useCallback(
async (nextValue: string) => {
if (!nextValue.startsWith(MARKETPLACE_VALUE_PREFIX)) {
setInstallError(null);
onValueChange(nextValue);
return;
}
(nextValue: string) => {
setInstallError(null);
onValueChange(nextValue);
},
[onValueChange],
);
const pluginId = nextValue.slice(MARKETPLACE_VALUE_PREFIX.length);
const plugin = marketplaceRunners.find(
(candidate) => marketplacePluginId(candidate) === pluginId,
);
if (!plugin || pendingInstall) return;
const handleInstall = useCallback(
async (plugin: PluginV4) => {
const pluginId = marketplacePluginId(plugin);
if (pendingInstall || installingPluginId) return;
setInstallingPluginId(pluginId);
setInstallError(null);
try {
const installed = await installMarketplaceAgentRunner(plugin, {
@@ -207,7 +304,7 @@ export default function AgentRunnerSelect({
onInstalled(installed);
await loadCatalog();
toast.success(
t('wizard.aiEngine.installSuccess', {
t('agents.runnerInstallSuccess', {
runner: extractI18nObject(plugin.label) || plugin.name,
}),
);
@@ -216,16 +313,17 @@ export default function AgentRunnerSelect({
setInstallError(message);
toast.error(message);
} finally {
setPendingInstall(readPendingAgentRunnerInstall(installScope));
const current = readPendingAgentRunnerInstall(installScope);
setPendingInstall(current);
if (!current) setInstallingPluginId(null);
}
},
[
addTask,
installScope,
installingPluginId,
loadCatalog,
marketplaceRunners,
onInstalled,
onValueChange,
pendingInstall,
t,
],
@@ -235,8 +333,7 @@ export default function AgentRunnerSelect({
<div className="w-full max-w-[22rem] space-y-2">
<Select
value={value}
disabled={pendingInstall !== null}
onValueChange={(nextValue) => void handleValueChange(nextValue)}
onValueChange={handleValueChange}
onOpenChange={(open) => {
if (open && catalogError && !catalogLoading) void loadCatalog();
}}
@@ -245,18 +342,7 @@ export default function AgentRunnerSelect({
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 ? (
{selectedOption ? (
<InstalledRunnerContent option={selectedOption} />
) : (
<SelectValue placeholder={t('common.select')} />
@@ -271,16 +357,25 @@ export default function AgentRunnerSelect({
</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>
))
options.map((option) => {
const description = installedRunnerDescription(
option,
marketplaceRunners,
installedPluginDescriptions,
);
return (
<SelectItem
key={option.name}
value={option.name}
className="py-1.5 [&>span:last-child]:min-w-0 [&>span:last-child]:flex-1"
>
<InstalledRunnerOptionContent
option={option}
description={description}
/>
</SelectItem>
);
})
) : (
<div className="px-2 py-1.5 text-xs text-muted-foreground">
{t('agents.noInstalledRunners')}
@@ -318,15 +413,24 @@ export default function AgentRunnerSelect({
{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>
))
marketplaceOptions.map((plugin) => {
const pluginId = marketplacePluginId(plugin);
const pluginLabel =
extractI18nObject(plugin.label) || plugin.name;
return (
<MarketplaceRunnerContent
key={pluginId}
plugin={plugin}
installing={activePluginId === pluginId}
progress={installProgress}
installDisabled={
activePluginId !== null && activePluginId !== pluginId
}
installLabel={`${t('plugins.install')} ${pluginLabel}`}
onInstall={() => void handleInstall(plugin)}
/>
);
})
) : (
<div className="px-2 py-1.5 text-xs text-muted-foreground">
{t('wizard.aiEngine.noMarketplaceRunners')}
@@ -0,0 +1,49 @@
import { Download } from 'lucide-react';
import { Progress } from '@/components/ui/progress';
export default function MarketplaceInstallButton({
installing,
progress,
disabled,
label,
onInstall,
}: {
installing: boolean;
progress: number;
disabled: boolean;
label: string;
onInstall: () => void;
}) {
const normalizedProgress = Math.max(0, Math.min(100, Math.round(progress)));
return (
<button
type="button"
aria-label={label}
title={label}
disabled={disabled}
className="row-span-2 flex h-8 w-16 shrink-0 items-center justify-center justify-self-end rounded-md text-muted-foreground transition-colors hover:bg-background hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-45"
onPointerDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onInstall();
}}
>
{installing ? (
<span className="flex w-14 flex-col gap-1" aria-live="polite">
<span className="text-center text-[10px] font-medium leading-none tabular-nums text-primary">
{normalizedProgress}%
</span>
<Progress value={normalizedProgress} className="h-1 bg-primary/15" />
</span>
) : (
<Download className="size-4" />
)}
</button>
);
}
@@ -113,6 +113,10 @@ import {
} from '@/components/ui/popover';
import { cn } from '@/lib/utils';
import { useSidebarData, SidebarEntityItem } from './SidebarDataContext';
import {
pluginTaskKey,
usePluginInstallTasks,
} from '@/app/home/plugins/components/plugin-install-task';
import { FeedbackPopoverContent } from './FeedbackPopover';
import {
type WorkspaceQuotaItem,
@@ -404,6 +408,7 @@ function NavItems({
const pathname = location.pathname;
const [searchParams] = useSearchParams();
const sidebarData = useSidebarData();
const refreshSidebarPlugins = sidebarData.refreshPlugins;
const quotaStatus = useWorkspaceQuotaStatus();
const { state: sidebarState, isMobile } = useSidebar();
const { t } = useTranslation();
@@ -452,19 +457,36 @@ function NavItems({
const [targetPluginItem, setTargetPluginItem] =
useState<SidebarEntityItem | null>(null);
const [deleteData, setDeleteData] = useState(false);
const {
addTask: addPluginTask,
setSelectedTaskId: setSelectedPluginTaskId,
registerOnTaskComplete,
unregisterOnTaskComplete,
} = usePluginInstallTasks();
const asyncTask = useAsyncTask({
onSuccess: () => {
const msg =
pluginOpType === PluginOperationType.DELETE
? t('plugins.deleteSuccess')
: t('plugins.updateSuccess');
toast.success(msg);
toast.success(t('plugins.deleteSuccess'));
setShowPluginOpModal(false);
sidebarData.refreshPlugins();
},
});
useEffect(() => {
const onPluginTaskComplete = (
_taskId: number,
success: boolean,
_error?: string,
operation?: 'install' | 'upgrade',
) => {
if (success && operation === 'upgrade') {
refreshSidebarPlugins();
}
};
registerOnTaskComplete(onPluginTaskComplete);
return () => unregisterOnTaskComplete(onPluginTaskComplete);
}, [refreshSidebarPlugins, registerOnTaskComplete, unregisterOnTaskComplete]);
function handlePluginDelete(item: SidebarEntityItem) {
setTargetPluginItem(item);
setPluginOpType(PluginOperationType.DELETE);
@@ -490,21 +512,37 @@ function NavItems({
? targetPluginItem.id.substring(slashIdx + 1)
: targetPluginItem.id;
const apiCall =
pluginOpType === PluginOperationType.DELETE
? httpClient.removePlugin(author, name, deleteData)
: httpClient.upgradePlugin(author, name);
if (pluginOpType === PluginOperationType.UPDATE) {
httpClient
.upgradePlugin(author, name)
.then((res) => {
addPluginTask({
taskId: res.task_id,
pluginName: `${author}/${name}`,
source: 'marketplace',
extensionType: 'plugin',
operation: 'upgrade',
});
setSelectedPluginTaskId(
pluginTaskKey(res.task_id, 'marketplace', 'upgrade'),
);
setShowPluginOpModal(false);
setTargetPluginItem(null);
asyncTask.reset();
})
.catch((error) => {
toast.error(t('plugins.updateError') + error.message);
});
return;
}
apiCall
httpClient
.removePlugin(author, name, deleteData)
.then((res) => {
asyncTask.startTask(res.task_id);
})
.catch((error) => {
const errorMessage =
pluginOpType === PluginOperationType.DELETE
? t('plugins.deleteError') + error.message
: t('plugins.updateError') + error.message;
toast.error(errorMessage);
toast.error(t('plugins.deleteError') + error.message);
});
}
@@ -1,10 +1,8 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useTranslation } from 'react-i18next';
import { AuthenticatedPluginIcon } from '@/components/AuthenticatedPluginIcon';
import { Input } from '@/components/ui/input';
import EmojiPicker from '@/components/ui/emoji-picker';
import {
@@ -24,13 +22,6 @@ import {
CardTitle,
} from '@/components/ui/card';
import { httpClient } from '@/app/infra/http/HttpClient';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Separator } from '@/components/ui/separator';
import { KnowledgeBase, KnowledgeEngine } from '@/app/infra/entities/api';
import { CustomApiError } from '@/app/infra/entities/common';
@@ -44,6 +35,7 @@ import {
parseDynamicFormItemType,
} from '@/app/home/components/dynamic-form/DynamicFormItemConfig';
import { UUID } from 'uuidjs';
import KnowledgeEngineSelect from './KnowledgeEngineSelect';
const getFormSchema = (t: (key: string) => string) =>
z.object({
@@ -107,6 +99,7 @@ export default function KBForm({
// Dirty tracking: snapshot of saved state for comparison
const savedSnapshotRef = useRef<string>('');
const isInitializing = useRef(true);
const suppressNextAutoSelectRef = useRef(false);
// Refs to store validation functions from dynamic forms
const configValidateRef = useRef<(() => Promise<boolean>) | null>(null);
@@ -161,6 +154,10 @@ export default function KBForm({
// Auto-select first engine when engines are loaded and no selection
useEffect(() => {
if (ragEngines.length > 0 && !selectedEngineId && !isEditing) {
if (suppressNextAutoSelectRef.current) {
suppressNextAutoSelectRef.current = false;
return;
}
const firstEngine = ragEngines[0];
setSelectedEngineId(firstEngine.plugin_id);
form.setValue('ragEngineId', firstEngine.plugin_id);
@@ -219,26 +216,41 @@ export default function KBForm({
}
};
const handleEngineChange = (engineId: string) => {
setSelectedEngineId(engineId);
form.setValue('ragEngineId', engineId);
const handleEngineChange = useCallback(
(engineId: string, installedEngine?: KnowledgeEngine) => {
setSelectedEngineId(engineId);
form.setValue('ragEngineId', engineId, {
shouldDirty: true,
shouldTouch: true,
shouldValidate: true,
});
form.clearErrors('ragEngineId');
void form.trigger('ragEngineId');
const engine =
installedEngine ??
ragEngines.find((candidate) => candidate.plugin_id === engineId);
if (!engine) return;
const engine = ragEngines.find((e) => e.plugin_id === engineId);
if (engine) {
const formItems = parseCreationSchema(engine.creation_schema);
if (formItems.length > 0) {
setConfigSettings(getDefaultValues(formItems));
} else {
setConfigSettings({});
}
setConfigSettings(
formItems.length > 0 ? getDefaultValues(formItems) : {},
);
const retrievalItems = parseCreationSchema(engine.retrieval_schema);
if (retrievalItems.length > 0) {
setRetrievalSettings(getDefaultValues(retrievalItems));
} else {
setRetrievalSettings({});
}
}
};
setRetrievalSettings(
retrievalItems.length > 0 ? getDefaultValues(retrievalItems) : {},
);
},
[form, ragEngines],
);
const handleEngineInstalled = useCallback((engine: KnowledgeEngine) => {
suppressNextAutoSelectRef.current = true;
setRagEngines((current) => [
...current.filter((item) => item.plugin_id !== engine.plugin_id),
engine,
]);
}, []);
const onSubmit = async (data: z.infer<typeof formSchema>) => {
// Validate dynamic forms before submission
@@ -309,32 +321,6 @@ export default function KBForm({
[selectedEngine?.retrieval_schema],
);
// Show loading state
if (loading) {
return (
<div className="flex items-center justify-center py-8">
<p className="text-muted-foreground">{t('common.loading')}</p>
</div>
);
}
// Show message if no engines available
if (ragEngines.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-8 space-y-4">
<p className="text-muted-foreground">
{t('knowledge.noEnginesAvailable')}
</p>
<Link
to="/home/add-extension"
className="text-sm text-primary hover:underline"
>
{t('knowledge.installEngineHint')}
</Link>
</div>
);
}
return (
<Form {...form}>
<form
@@ -425,66 +411,18 @@ export default function KBForm({
<span className="text-destructive">*</span>
</FormLabel>
<FormControl>
<Select
disabled={isEditing}
onValueChange={(value) => {
field.onChange(value);
handleEngineChange(value);
}}
<KnowledgeEngineSelect
engines={ragEngines}
value={field.value}
>
<SelectTrigger className="w-full bg-[#ffffff] dark:bg-[#2a2a2e]">
{field.value ? (
(() => {
const [author, name] = field.value.split('/');
const engine = ragEngines.find(
(e) => e.plugin_id === field.value,
);
return (
<div className="flex items-center gap-2">
<AuthenticatedPluginIcon
author={author}
name={name}
className="h-5 w-5 rounded"
/>
<span>
{engine
? extractI18nObject(engine.name)
: field.value}
</span>
</div>
);
})()
) : (
<SelectValue
placeholder={t('knowledge.selectKnowledgeEngine')}
/>
)}
</SelectTrigger>
<SelectContent className="fixed z-[1000]">
{ragEngines.map((engine) => {
const [author, name] = engine.plugin_id.split('/');
return (
<SelectItem
key={engine.plugin_id}
value={engine.plugin_id}
>
<div className="flex items-center gap-2">
<AuthenticatedPluginIcon
author={author}
name={name}
className="h-5 w-5 rounded"
/>
<span>{extractI18nObject(engine.name)}</span>
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
disabled={isEditing}
loading={loading}
installScope="knowledge-base-create"
onValueChange={handleEngineChange}
onInstalled={handleEngineInstalled}
/>
</FormControl>
{selectedEngine?.description && (
<FormDescription>
<FormDescription className="max-w-[28rem] leading-5">
{extractI18nObject(selectedEngine.description)}
</FormDescription>
)}
@@ -0,0 +1,468 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { BookOpen, ExternalLink, Loader2, Store } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { AuthenticatedPluginIcon } from '@/components/AuthenticatedPluginIcon';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { KnowledgeEngine } from '@/app/infra/entities/api';
import type { PluginV4 } from '@/app/infra/entities/plugin';
import { getCloudServiceClientSync } from '@/app/infra/http';
import { extractI18nObject } from '@/i18n/I18nProvider';
import {
InstallStage,
usePluginInstallTasks,
} from '@/app/home/plugins/components/plugin-install-task';
import MarketplaceInstallButton from '@/app/home/components/MarketplaceInstallButton';
import {
KnowledgeEngineMarketplaceError,
getKnowledgeEngineInstallError,
installMarketplaceKnowledgeEngine,
knowledgeEnginePluginId,
loadKnowledgeEngineCatalog,
readPendingKnowledgeEngineInstall,
resumePendingKnowledgeEngineInstall,
subscribePendingKnowledgeEngineInstall,
} from './knowledge-engine-marketplace';
const KNOWLEDGE_ENGINE_MARKETPLACE_URL =
'https://space.langbot.app/market?type=plugin&component=KnowledgeEngine';
function installErrorMessage(
error: unknown,
t: ReturnType<typeof useTranslation>['t'],
) {
if (error instanceof KnowledgeEngineMarketplaceError) {
if (error.code === 'version-unavailable') {
return t('knowledge.engineVersionUnavailable');
}
if (error.code === 'install-timeout') {
return t('knowledge.engineInstallTimeout');
}
return t('knowledge.engineRegistrationTimeout');
}
return (
getKnowledgeEngineInstallError(error) || t('knowledge.engineInstallFailed')
);
}
function InstalledEngineContent({ engine }: { engine: KnowledgeEngine }) {
const [author, name] = engine.plugin_id.split('/');
const description = engine.description
? extractI18nObject(engine.description)
: '';
return (
<span className="grid w-full min-w-0 grid-cols-[1.5rem_minmax(0,1fr)] items-center gap-x-2">
{author && name ? (
<AuthenticatedPluginIcon
author={author}
name={name}
className="row-span-2 size-5 rounded object-cover"
/>
) : (
<BookOpen className="row-span-2 size-4 text-muted-foreground" />
)}
<span className="truncate font-medium leading-5">
{extractI18nObject(engine.name) || engine.plugin_id}
</span>
<span
className="truncate text-xs leading-4 text-muted-foreground"
title={description || engine.plugin_id}
>
{description || engine.plugin_id}
</span>
</span>
);
}
function SelectedEngineContent({ engine }: { engine: KnowledgeEngine }) {
const [author, name] = engine.plugin_id.split('/');
return (
<span className="flex min-w-0 flex-1 items-center gap-2 text-left">
{author && name ? (
<AuthenticatedPluginIcon
author={author}
name={name}
className="size-5 shrink-0 rounded object-cover"
/>
) : (
<BookOpen className="size-4 shrink-0 text-muted-foreground" />
)}
<span className="truncate font-medium">
{extractI18nObject(engine.name) || engine.plugin_id}
</span>
</span>
);
}
function MarketplaceEngineContent({
plugin,
installing,
progress,
installDisabled,
installLabel,
onInstall,
}: {
plugin: PluginV4;
installing: boolean;
progress: number;
installDisabled: boolean;
installLabel: string;
onInstall: () => void;
}) {
const iconURL = getCloudServiceClientSync().resolveMarketplaceIconURL(
plugin.type,
plugin.author,
plugin.name,
plugin.icon,
);
const description =
extractI18nObject(plugin.description) || `${plugin.author}/${plugin.name}`;
return (
<div className="grid w-full min-w-0 grid-cols-[1.75rem_minmax(0,1fr)_4rem] items-center gap-x-2 rounded-sm px-2 py-1.5 hover:bg-accent focus-within:bg-accent">
<img
src={iconURL}
alt=""
className="row-span-2 size-7 rounded-md object-cover"
/>
<span className="truncate font-medium leading-5">
{extractI18nObject(plugin.label) || plugin.name}
</span>
<MarketplaceInstallButton
installing={installing}
progress={progress}
disabled={installDisabled}
label={installLabel}
onInstall={onInstall}
/>
<span
className="truncate text-xs leading-4 text-muted-foreground"
title={description}
>
{description}
</span>
</div>
);
}
export default function KnowledgeEngineSelect({
engines,
value,
disabled = false,
loading = false,
installScope,
onValueChange,
onInstalled,
}: {
engines: KnowledgeEngine[];
value: string;
disabled?: boolean;
loading?: boolean;
installScope: string;
onValueChange: (value: string) => void;
onInstalled: (engine: KnowledgeEngine) => void;
}) {
const { t } = useTranslation();
const { addTask, tasks } = usePluginInstallTasks();
const [marketplaceEngines, setMarketplaceEngines] = useState<PluginV4[]>([]);
const [installedPluginIds, setInstalledPluginIds] = useState<string[]>([]);
const [catalogLoading, setCatalogLoading] = useState(true);
const [catalogError, setCatalogError] = useState(false);
const [pendingInstall, setPendingInstall] = useState(() =>
readPendingKnowledgeEngineInstall(installScope),
);
const [installError, setInstallError] = useState<string | null>(null);
const [installingPluginId, setInstallingPluginId] = useState<string | null>(
null,
);
const activeInstallRef = useRef(false);
const resumedTaskRef = useRef<number | null>(null);
const mountedRef = useRef(true);
const loadCatalog = useCallback(async () => {
setCatalogLoading(true);
setCatalogError(false);
try {
const catalog = await loadKnowledgeEngineCatalog();
if (!mountedRef.current) return;
setMarketplaceEngines(catalog.marketplaceEngines);
setInstalledPluginIds(catalog.installedPluginIds);
} catch (error) {
console.error('Failed to load KnowledgeEngine catalog', error);
if (mountedRef.current) setCatalogError(true);
} finally {
if (mountedRef.current) setCatalogLoading(false);
}
}, []);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
useEffect(() => {
void loadCatalog();
}, [loadCatalog]);
useEffect(() => {
const syncPendingInstall = () =>
setPendingInstall(readPendingKnowledgeEngineInstall(installScope));
syncPendingInstall();
return subscribePendingKnowledgeEngineInstall(
installScope,
syncPendingInstall,
);
}, [installScope]);
useEffect(() => {
if (
!pendingInstall ||
activeInstallRef.current ||
resumedTaskRef.current === pendingInstall.taskId
) {
return;
}
resumedTaskRef.current = pendingInstall.taskId;
void resumePendingKnowledgeEngineInstall(installScope)
.then(async (engine) => {
if (!engine || !mountedRef.current) return;
onInstalled(engine);
await loadCatalog();
toast.success(
t('knowledge.engineInstallSuccess', {
engine: pendingInstall.pluginLabel,
}),
);
})
.catch((error) => {
if (!mountedRef.current) return;
const message = installErrorMessage(error, t);
setInstallError(message);
toast.error(message);
})
.finally(() => {
if (!mountedRef.current) return;
const current = readPendingKnowledgeEngineInstall(installScope);
if (!current) resumedTaskRef.current = null;
setPendingInstall(current);
});
}, [installScope, loadCatalog, onInstalled, pendingInstall, t]);
const marketplaceOptions = useMemo(
() =>
marketplaceEngines.filter((plugin) => {
const pluginId = knowledgeEnginePluginId(plugin);
if (installedPluginIds.includes(pluginId)) return false;
return !engines.some((engine) => engine.plugin_id === pluginId);
}),
[engines, installedPluginIds, marketplaceEngines],
);
const selectedEngine = engines.find((engine) => engine.plugin_id === value);
const activePluginId = pendingInstall?.pluginId ?? installingPluginId;
const activeTask = pendingInstall
? tasks.find((task) => task.taskId === pendingInstall.taskId)
: undefined;
const installProgress = activePluginId
? activeTask
? activeTask.stage === InstallStage.DONE
? 95
: Math.max(5, activeTask.overallProgress)
: 5
: 0;
const handleValueChange = useCallback(
(nextValue: string) => {
setInstallError(null);
onValueChange(nextValue);
},
[onValueChange],
);
const handleInstall = useCallback(
async (plugin: PluginV4) => {
const pluginId = knowledgeEnginePluginId(plugin);
if (pendingInstall || installingPluginId) return;
activeInstallRef.current = true;
setInstallingPluginId(pluginId);
setInstallError(null);
try {
const installed = await installMarketplaceKnowledgeEngine(plugin, {
scope: installScope,
onTaskCreated: (taskId) =>
addTask({
taskId,
pluginName: knowledgeEnginePluginId(plugin),
source: 'marketplace',
extensionType: 'plugin',
}),
});
onInstalled(installed);
await loadCatalog();
toast.success(
t('knowledge.engineInstallSuccess', {
engine: extractI18nObject(plugin.label) || plugin.name,
}),
);
} catch (error) {
const message = installErrorMessage(error, t);
setInstallError(message);
toast.error(message);
const current = readPendingKnowledgeEngineInstall(installScope);
if (current) resumedTaskRef.current = current.taskId;
} finally {
activeInstallRef.current = false;
const current = readPendingKnowledgeEngineInstall(installScope);
setPendingInstall(current);
if (!current) setInstallingPluginId(null);
}
},
[
addTask,
installScope,
installingPluginId,
loadCatalog,
onInstalled,
pendingInstall,
t,
],
);
return (
<div className="w-full max-w-[28rem] space-y-2">
<Select
value={value}
disabled={disabled}
onValueChange={handleValueChange}
onOpenChange={(open) => {
if (open && catalogError && !catalogLoading) void loadCatalog();
}}
>
<SelectTrigger
aria-label={t('knowledge.knowledgeEngine')}
className="w-full bg-[#ffffff] text-left dark:bg-[#2a2a2e]"
>
{selectedEngine ? (
<SelectedEngineContent engine={selectedEngine} />
) : value ? (
<span className="flex min-w-0 items-center gap-2">
<BookOpen className="size-4 shrink-0 text-muted-foreground" />
<span className="truncate">{value}</span>
</span>
) : loading ? (
<span className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
{t('common.loading')}
</span>
) : (
<SelectValue placeholder={t('knowledge.selectKnowledgeEngine')} />
)}
</SelectTrigger>
<SelectContent className="max-h-80 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">
<BookOpen className="size-3.5" />
{t('knowledge.installedEngines')}
</span>
</SelectLabel>
{engines.length > 0 ? (
engines.map((engine) => (
<SelectItem
key={engine.plugin_id}
value={engine.plugin_id}
className="py-1.5 [&>span:last-child]:min-w-0 [&>span:last-child]:flex-1"
>
<InstalledEngineContent engine={engine} />
</SelectItem>
))
) : (
<div className="px-2 py-1.5 text-xs text-muted-foreground">
{loading
? t('common.loading')
: t('knowledge.noInstalledEngines')}
</div>
)}
</SelectGroup>
<SelectSeparator />
<SelectGroup>
<SelectLabel className="flex items-center justify-between gap-2 px-2 py-1 text-[11px] font-medium">
<span className="inline-flex min-w-0 items-center gap-1.5">
<Store className="size-3.5" />
{t('knowledge.marketplaceEngines')}
</span>
<a
href={KNOWLEDGE_ENGINE_MARKETPLACE_URL}
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('knowledge.viewMarketplace')}
<ExternalLink className="size-3" />
</a>
</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('knowledge.loadingEngineCatalog')}
</div>
) : catalogError ? (
<div className="px-2 py-1.5 text-xs text-destructive">
{t('knowledge.engineCatalogUnavailable')}
</div>
) : marketplaceOptions.length > 0 ? (
marketplaceOptions.map((plugin) => {
const pluginId = knowledgeEnginePluginId(plugin);
const pluginLabel =
extractI18nObject(plugin.label) || plugin.name;
return (
<MarketplaceEngineContent
key={pluginId}
plugin={plugin}
installing={activePluginId === pluginId}
progress={installProgress}
installDisabled={
activePluginId !== null && activePluginId !== pluginId
}
installLabel={`${t('plugins.install')} ${pluginLabel}`}
onInstall={() => void handleInstall(plugin)}
/>
);
})
) : (
<div className="px-2 py-1.5 text-xs text-muted-foreground">
{t('knowledge.noMarketplaceEngines')}
</div>
)}
</SelectGroup>
</SelectContent>
</Select>
{installError && (
<p role="alert" className="text-sm text-destructive">
{installError}
</p>
)}
</div>
);
}
@@ -0,0 +1,266 @@
import type { KnowledgeEngine } from '@/app/infra/entities/api';
import type { PluginV4 } from '@/app/infra/entities/plugin';
import { getCloudServiceClient } from '@/app/infra/http';
import { httpClient } from '@/app/infra/http/HttpClient';
import { getActiveWorkspaceUuid } from '@/app/infra/http/workspaceContext';
export const KNOWLEDGE_ENGINE_COMPONENT_FILTER = 'KnowledgeEngine';
const CATALOG_PAGE_SIZE = 100;
const INSTALL_TIMEOUT_MS = 120_000;
const REGISTRATION_TIMEOUT_MS = 60_000;
const INSTALL_INTENT_KEY_PREFIX = 'langbot-knowledge-engine-install';
const INSTALL_INTENT_EVENT = 'langbot-knowledge-engine-install-change';
export type KnowledgeEngineMarketplaceErrorCode =
| 'version-unavailable'
| 'install-timeout'
| 'registration-timeout';
export class KnowledgeEngineMarketplaceError extends Error {
constructor(public readonly code: KnowledgeEngineMarketplaceErrorCode) {
super(code);
this.name = 'KnowledgeEngineMarketplaceError';
}
}
export interface KnowledgeEngineCatalog {
marketplaceEngines: PluginV4[];
installedPluginIds: string[];
}
export interface PendingKnowledgeEngineInstall {
taskId: number;
pluginId: string;
pluginAuthor: string;
pluginName: string;
pluginLabel: string;
scope: string;
startedAt: number;
}
interface InstallKnowledgeEngineOptions {
scope: string;
onTaskCreated?: (taskId: number) => void;
}
function wait(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function getKnowledgeEngineInstallError(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 knowledgeEnginePluginId(
plugin: Pick<PluginV4, 'author' | 'name'>,
) {
return `${plugin.author}/${plugin.name}`;
}
function installIntentStorageKey(scope: string) {
return `${INSTALL_INTENT_KEY_PREFIX}:${getActiveWorkspaceUuid() || 'default'}:${scope}`;
}
function emitInstallIntentChange(scope: string) {
if (typeof window === 'undefined') return;
window.dispatchEvent(
new CustomEvent(INSTALL_INTENT_EVENT, { detail: { scope } }),
);
}
export function readPendingKnowledgeEngineInstall(
scope: string,
): PendingKnowledgeEngineInstall | 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<PendingKnowledgeEngineInstall>;
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 PendingKnowledgeEngineInstall;
} catch {
return null;
}
}
function writePendingKnowledgeEngineInstall(
intent: PendingKnowledgeEngineInstall,
) {
if (typeof window === 'undefined') return;
sessionStorage.setItem(
installIntentStorageKey(intent.scope),
JSON.stringify(intent),
);
emitInstallIntentChange(intent.scope);
}
export function clearPendingKnowledgeEngineInstall(
scope: string,
taskId?: number,
) {
if (typeof window === 'undefined') return;
const current = readPendingKnowledgeEngineInstall(scope);
if (taskId !== undefined && current?.taskId !== taskId) return;
sessionStorage.removeItem(installIntentStorageKey(scope));
emitInstallIntentChange(scope);
}
export function subscribePendingKnowledgeEngineInstall(
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(INSTALL_INTENT_EVENT, handleChange);
return () => window.removeEventListener(INSTALL_INTENT_EVENT, handleChange);
}
export async function loadKnowledgeEngineCatalog(): Promise<KnowledgeEngineCatalog> {
const cloudClient = await getCloudServiceClient();
const [firstResult, installedResult] = await Promise.all([
cloudClient.searchMarketplaceExtensions({
query: '',
page: 1,
page_size: CATALOG_PAGE_SIZE,
type_filter: 'plugin',
component_filter: KNOWLEDGE_ENGINE_COMPONENT_FILTER,
}),
httpClient.getPlugins().catch(() => ({ plugins: [] })),
]);
const remainingPageCount = Math.max(
0,
Math.ceil((firstResult.total || 0) / CATALOG_PAGE_SIZE) - 1,
);
const remainingResults = await Promise.all(
Array.from({ length: remainingPageCount }, (_, index) =>
cloudClient.searchMarketplaceExtensions({
query: '',
page: index + 2,
page_size: CATALOG_PAGE_SIZE,
type_filter: 'plugin',
component_filter: KNOWLEDGE_ENGINE_COMPONENT_FILTER,
}),
),
);
const marketplaceEngines = [
...(firstResult.plugins || []),
...remainingResults.flatMap((result) => result.plugins || []),
]
.filter((plugin) => plugin.components?.[KNOWLEDGE_ENGINE_COMPONENT_FILTER])
.sort((left, right) => right.install_count - left.install_count);
return {
marketplaceEngines,
installedPluginIds: installedResult.plugins.map((plugin) => {
const metadata = plugin.manifest.manifest.metadata;
return `${metadata.author ?? ''}/${metadata.name}`;
}),
};
}
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;
}
export async function installMarketplaceKnowledgeEngine(
plugin: PluginV4,
options: InstallKnowledgeEngineOptions,
): Promise<KnowledgeEngine> {
if (!plugin.latest_version) {
throw new KnowledgeEngineMarketplaceError('version-unavailable');
}
const { task_id: taskId } = await httpClient.installPluginFromMarketplace(
plugin.author,
plugin.name,
plugin.latest_version,
);
const pending: PendingKnowledgeEngineInstall = {
taskId,
pluginId: knowledgeEnginePluginId(plugin),
pluginAuthor: plugin.author,
pluginName: plugin.name,
pluginLabel: extractPluginLabel(plugin),
scope: options.scope,
startedAt: Date.now(),
};
writePendingKnowledgeEngineInstall(pending);
options.onTaskCreated?.(taskId);
return finishKnowledgeEngineInstall(pending);
}
async function finishKnowledgeEngineInstall(
pending: PendingKnowledgeEngineInstall,
): Promise<KnowledgeEngine> {
const installDeadline = Date.now() + INSTALL_TIMEOUT_MS;
let installCompleted = false;
while (true) {
const task = await httpClient.getAsyncTask(pending.taskId);
if (task.runtime.done) {
if (task.runtime.exception) {
clearPendingKnowledgeEngineInstall(pending.scope, pending.taskId);
throw new Error(task.runtime.exception);
}
installCompleted = true;
break;
}
if (Date.now() >= installDeadline) break;
await wait(1000);
}
if (!installCompleted) {
throw new KnowledgeEngineMarketplaceError('install-timeout');
}
const registrationDeadline = Date.now() + REGISTRATION_TIMEOUT_MS;
while (Date.now() < registrationDeadline) {
const result = await httpClient.getKnowledgeEngines();
const engine = result.engines.find(
(candidate) => candidate.plugin_id === pending.pluginId,
);
if (engine) {
clearPendingKnowledgeEngineInstall(pending.scope, pending.taskId);
return engine;
}
await wait(1000);
}
clearPendingKnowledgeEngineInstall(pending.scope, pending.taskId);
throw new KnowledgeEngineMarketplaceError('registration-timeout');
}
export async function resumePendingKnowledgeEngineInstall(
scope: string,
): Promise<KnowledgeEngine | null> {
const pending = readPendingKnowledgeEngineInstall(scope);
if (!pending) return null;
return finishKnowledgeEngineInstall(pending);
}
@@ -232,31 +232,8 @@ const PipelineFormComponent = forwardRef<
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(
() => ({ pipeline_id: pipelineId }),
@@ -340,7 +317,7 @@ const PipelineFormComponent = forwardRef<
if (cancelled || !installed) return;
applyInstalledRunner(installed);
toast.success(
t('wizard.aiEngine.installSuccess', {
t('agents.runnerInstallSuccess', {
runner: extractI18nObject(installed.runner.label),
}),
);
@@ -15,6 +15,9 @@ import {
CheckCircle2,
XCircle,
Loader2,
RefreshCcw,
ShieldCheck,
Rocket,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import {
@@ -24,25 +27,51 @@ import {
} from './PluginInstallTaskContext';
import { cn } from '@/lib/utils';
const STAGES: {
type StageConfig = {
key: InstallStage;
icon: React.ElementType;
i18nKey: string;
}[] = [
{
key: InstallStage.DOWNLOADING,
icon: Download,
i18nKey: 'plugins.installProgress.downloading',
},
{
key: InstallStage.INSTALLING_DEPS,
icon: Package,
i18nKey: 'plugins.installProgress.installingDeps',
},
];
};
function getStageIndex(stage: InstallStage): number {
const idx = STAGES.findIndex((s) => s.key === stage);
function getStages(task: PluginInstallTask): StageConfig[] {
return [
...(task.operation === 'upgrade'
? [
{
key: InstallStage.CHECKING,
icon: RefreshCcw,
i18nKey: 'plugins.installProgress.checkingUpdate',
} as StageConfig,
]
: []),
{
key: InstallStage.DOWNLOADING,
icon: Download,
i18nKey: 'plugins.installProgress.downloading',
},
{
key: InstallStage.VALIDATING,
icon: ShieldCheck,
i18nKey: 'plugins.installProgress.validating',
},
{
key: InstallStage.INSTALLING_DEPS,
icon: Package,
i18nKey:
task.operation === 'upgrade'
? 'plugins.installProgress.applyingUpdate'
: 'plugins.installProgress.installingDeps',
},
{
key: InstallStage.ACTIVATING,
icon: Rocket,
i18nKey: 'plugins.installProgress.activating',
},
];
}
function getStageIndex(stages: StageConfig[], stage: InstallStage): number {
const idx = stages.findIndex((item) => item.key === stage);
return idx >= 0 ? idx : -1;
}
@@ -169,9 +198,13 @@ function formatSpeed(bytesPerSec: number): string {
function TaskProgressContent({ task }: { task: PluginInstallTask }) {
const { t } = useTranslation();
const currentStageIndex = getStageIndex(task.stage);
const stages = getStages(task);
const isDone = task.stage === InstallStage.DONE;
const isError = task.stage === InstallStage.ERROR;
const currentStageIndex = getStageIndex(
stages,
isError ? (task.failedStage ?? InstallStage.INSTALLING_DEPS) : task.stage,
);
// MCP / Skill don't have the plugin's download + dependency-install stages;
// show a single "installing → done/failed" row instead of plugin steps.
@@ -334,43 +367,22 @@ function TaskProgressContent({ task }: { task: PluginInstallTask }) {
isError={isError}
detail={isError ? task.error : undefined}
/>
) : isDone ? (
/* When done: show all stages with completed style */
STAGES.map((stageConfig) => (
) : (
stages.map((stageConfig, index) => (
<StageRow
key={stageConfig.key}
icon={stageConfig.icon}
label={t(stageConfig.i18nKey)}
isActive={false}
isCompleted={true}
isError={false}
detail={getStageDetail(stageConfig.key, true)}
isActive={!isDone && index === currentStageIndex}
isCompleted={isDone || index < currentStageIndex}
isError={isError && index === currentStageIndex}
detail={
isDone || index === currentStageIndex
? getStageDetail(stageConfig.key, isDone)
: undefined
}
/>
))
) : isError ? (
/* Error: show the failed stage */
currentStageIndex >= 0 && (
<StageRow
icon={STAGES[currentStageIndex].icon}
label={t(STAGES[currentStageIndex].i18nKey)}
isActive={true}
isCompleted={false}
isError={true}
detail={task.error}
/>
)
) : (
/* In progress: only show the current active stage */
currentStageIndex >= 0 && (
<StageRow
icon={STAGES[currentStageIndex].icon}
label={t(STAGES[currentStageIndex].i18nKey)}
isActive={true}
isCompleted={false}
isError={false}
detail={getStageDetail(STAGES[currentStageIndex].key, false)}
/>
)
)}
</div>
@@ -379,7 +391,9 @@ function TaskProgressContent({ task }: { task: PluginInstallTask }) {
<div className="flex items-center gap-2 px-3 py-2.5 rounded-lg bg-green-50 dark:bg-green-950/30 border border-green-200 dark:border-green-900">
<CheckCircle2 className="w-5 h-5 shrink-0 text-green-600 dark:text-green-400" />
<span className="text-sm text-green-700 dark:text-green-300 font-medium break-words">
{t('plugins.installProgress.installComplete')}
{task.operation === 'upgrade'
? t('plugins.installProgress.updateComplete')
: t('plugins.installProgress.installComplete')}
</span>
</div>
)}
@@ -402,6 +416,8 @@ export default function PluginInstallProgressDialog() {
usePluginInstallTasks();
const selectedTask = tasks.find((t) => t.id === selectedTaskId) || null;
const TitleIcon =
selectedTask?.operation === 'upgrade' ? RefreshCcw : Download;
const open = !!selectedTask;
const handleClose = () => {
@@ -428,12 +444,16 @@ export default function PluginInstallProgressDialog() {
>
<DialogHeader>
<DialogTitle className="flex items-start gap-3">
<Download className="size-5 shrink-0 mt-0.5" />
<TitleIcon className="size-5 shrink-0 mt-0.5" />
<span className="break-words">
{selectedTask
? t('plugins.installProgress.title', {
name: selectedTask.pluginName,
})
? selectedTask.operation === 'upgrade'
? t('plugins.installProgress.updateTitle', {
name: selectedTask.pluginName,
})
: t('plugins.installProgress.title', {
name: selectedTask.pluginName,
})
: t('plugins.installProgress.titleGeneric')}
</span>
</DialogTitle>
@@ -13,20 +13,25 @@ import { AsyncTask } from '@/app/infra/entities/api';
* Installation stages mapped from backend current_action strings.
*/
export enum InstallStage {
CHECKING = 'checking',
DOWNLOADING = 'downloading',
VALIDATING = 'validating',
INSTALLING_DEPS = 'installing_deps',
INITIALIZING = 'initializing',
LAUNCHING = 'launching',
ACTIVATING = 'activating',
DONE = 'done',
ERROR = 'error',
}
export type PluginTaskOperation = 'install' | 'upgrade';
export interface PluginInstallTask {
id: string; // unique key: `${source}-${taskId}`
taskId: number; // backend async task id
pluginName: string; // display name
source: 'github' | 'marketplace' | 'local';
operation: PluginTaskOperation;
stage: InstallStage;
failedStage?: InstallStage;
overallProgress: number; // 0-100
extensionType: 'plugin' | 'mcp' | 'skill'; // type of extension being installed
fileSize?: number; // bytes, if known
@@ -50,6 +55,7 @@ type OnTaskCompleteCallback = (
taskId: number,
success: boolean,
error?: string,
operation?: PluginTaskOperation,
) => void;
interface PluginInstallTaskContextValue {
@@ -60,6 +66,7 @@ interface PluginInstallTaskContextValue {
source: 'github' | 'marketplace' | 'local';
extensionType: 'plugin' | 'mcp' | 'skill';
fileSize?: number;
operation?: PluginTaskOperation;
}) => void;
removeTask: (id: string) => void;
clearCompletedTasks: () => void;
@@ -89,13 +96,30 @@ export function usePluginInstallTasks() {
function mapActionToStage(action: string): InstallStage {
if (!action) return InstallStage.DOWNLOADING;
const lower = action.toLowerCase();
if (lower.includes('check')) return InstallStage.CHECKING;
if (lower.includes('download')) return InstallStage.DOWNLOADING;
if (lower.includes('validat') || lower.includes('inspect'))
return InstallStage.VALIDATING;
if (lower.includes('dependencies') || lower.includes('requirements'))
return InstallStage.INSTALLING_DEPS;
if (lower.includes('initializ') || lower.includes('setting'))
if (
lower.includes('preparing') ||
lower.includes('applying') ||
lower.includes('installing')
)
return InstallStage.INSTALLING_DEPS;
if (lower.includes('launch')) return InstallStage.INSTALLING_DEPS;
if (lower.includes('installed') || lower.includes('complete'))
if (
lower.includes('initializ') ||
lower.includes('launch') ||
lower.includes('refresh') ||
lower.includes('waiting')
)
return InstallStage.ACTIVATING;
if (
lower.includes('installed') ||
lower.includes('updated') ||
lower.includes('complete')
)
return InstallStage.DONE;
return InstallStage.DOWNLOADING;
}
@@ -105,13 +129,15 @@ function mapActionToStage(action: string): InstallStage {
*/
function stageToProgress(stage: InstallStage): number {
switch (stage) {
case InstallStage.CHECKING:
return 5;
case InstallStage.DOWNLOADING:
return 10;
return 18;
case InstallStage.VALIDATING:
return 35;
case InstallStage.INSTALLING_DEPS:
return 70;
case InstallStage.INITIALIZING:
return 70;
case InstallStage.LAUNCHING:
return 60;
case InstallStage.ACTIVATING:
return 85;
case InstallStage.DONE:
return 100;
@@ -130,15 +156,27 @@ function extractSourceFromName(
): 'github' | 'marketplace' | 'local' {
if (name.includes('github')) return 'github';
if (name.includes('marketplace')) return 'marketplace';
if (name.startsWith('plugin-upgrade-')) return 'marketplace';
return 'local';
}
export function pluginTaskKey(
taskId: number,
source: 'github' | 'marketplace' | 'local',
operation: PluginTaskOperation = 'install',
) {
return operation === 'upgrade'
? `upgrade-${source}-${taskId}`
: `${source}-${taskId}`;
}
/**
* Check if a backend task name is a plugin install task.
*/
function isPluginInstallTask(name: string): boolean {
return (
name.startsWith('plugin-install-') ||
name.startsWith('plugin-upgrade-') ||
name.startsWith('mcp-install-') ||
name.startsWith('skill-install-')
);
@@ -151,6 +189,10 @@ function asyncTaskToPluginInstallTask(task: AsyncTask): PluginInstallTask {
const source = extractSourceFromName(task.name);
const md = (task.task_context?.metadata ?? {}) as Record<string, unknown>;
const action = task.task_context?.current_action || '';
const operation: PluginTaskOperation =
md.operation === 'upgrade' || task.name.startsWith('plugin-upgrade-')
? 'upgrade'
: 'install';
const done = task.runtime.done;
const exception = task.runtime.exception;
@@ -160,11 +202,13 @@ function asyncTaskToPluginInstallTask(task: AsyncTask): PluginInstallTask {
let stage: InstallStage;
let overallProgress: number;
let error: string | undefined;
let failedStage: InstallStage | undefined;
if (done) {
if (exception) {
stage = InstallStage.ERROR;
overallProgress = 0;
failedStage = mapActionToStage(action);
overallProgress = stageToProgress(failedStage);
error = exception;
} else {
stage = InstallStage.DONE;
@@ -172,7 +216,10 @@ function asyncTaskToPluginInstallTask(task: AsyncTask): PluginInstallTask {
}
} else {
stage = mapActionToStage(action);
overallProgress = Math.min(95, stageToProgress(stage));
overallProgress = Math.min(
95,
num(md.progress_percent) ?? stageToProgress(stage),
);
}
const pluginName = str(md.plugin_name) || task.label || `${source} extension`;
@@ -185,12 +232,14 @@ function asyncTaskToPluginInstallTask(task: AsyncTask): PluginInstallTask {
}
return {
id: `${source}-${task.id}`,
id: pluginTaskKey(task.id, source, operation),
taskId: task.id,
pluginName,
source,
operation,
extensionType,
stage,
failedStage,
overallProgress,
downloadCurrent: num(md.download_current),
downloadTotal: num(md.download_total),
@@ -202,7 +251,7 @@ function asyncTaskToPluginInstallTask(task: AsyncTask): PluginInstallTask {
depsDownloadedSize: num(md.deps_downloaded_size),
depsSpeed: num(md.deps_speed),
error,
startedAt: Date.now(),
startedAt: task.created_at ? task.created_at * 1000 : Date.now(),
currentAction: action,
};
}
@@ -244,11 +293,16 @@ export function PluginInstallTaskProvider({
}, []);
const notifyTaskComplete = useCallback(
(taskId: number, success: boolean, error?: string) => {
(
taskId: number,
success: boolean,
error?: string,
operation?: PluginTaskOperation,
) => {
if (notifiedTaskIds.current.has(taskId)) return;
notifiedTaskIds.current.add(taskId);
onTaskCompleteCallbacks.current.forEach((cb) => {
cb(taskId, success, error);
cb(taskId, success, error, operation);
});
},
[],
@@ -284,6 +338,7 @@ export function PluginInstallTaskProvider({
const currentDep = str(md.current_dep);
const depsDownloadedSize = num(md.deps_downloaded_size);
const depsSpeed = num(md.deps_speed);
const reportedProgress = num(md.progress_percent);
setTasks((prev) =>
prev.map((t) => {
@@ -311,18 +366,21 @@ export function PluginInstallTaskProvider({
}
if (exception) {
notifyTaskComplete(taskId, false, exception);
notifyTaskComplete(taskId, false, exception, t.operation);
return {
...t,
stage: InstallStage.ERROR,
failedStage: mapActionToStage(action),
error: exception,
overallProgress: 0,
overallProgress: stageToProgress(
mapActionToStage(action),
),
currentAction: action,
...progressFields,
};
}
notifyTaskComplete(taskId, true);
notifyTaskComplete(taskId, true, undefined, t.operation);
return {
...t,
stage: InstallStage.DONE,
@@ -342,7 +400,7 @@ export function PluginInstallTaskProvider({
);
const progress = Math.min(
95,
baseProgress + withinStageIncrement,
reportedProgress ?? baseProgress + withinStageIncrement,
);
return {
@@ -458,8 +516,10 @@ export function PluginInstallTaskProvider({
source: 'github' | 'marketplace' | 'local';
extensionType: 'plugin' | 'mcp' | 'skill';
fileSize?: number;
operation?: PluginTaskOperation;
}) => {
const taskKey = `${params.source}-${params.taskId}`;
const operation = params.operation ?? 'install';
const taskKey = pluginTaskKey(params.taskId, params.source, operation);
// Remove from dismissed set if re-added
dismissedTaskIds.current.delete(params.taskId);
@@ -469,9 +529,13 @@ export function PluginInstallTaskProvider({
taskId: params.taskId,
pluginName: params.pluginName,
source: params.source,
operation,
extensionType: params.extensionType,
stage: InstallStage.DOWNLOADING,
overallProgress: 5,
stage:
operation === 'upgrade'
? InstallStage.CHECKING
: InstallStage.DOWNLOADING,
overallProgress: operation === 'upgrade' ? 3 : 5,
fileSize: params.fileSize,
startedAt: Date.now(),
currentAction: '',
@@ -12,6 +12,9 @@ import {
Puzzle,
Server,
Sparkles,
RefreshCcw,
ShieldCheck,
Rocket,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
@@ -28,8 +31,11 @@ import {
import { cn } from '@/lib/utils';
const STAGE_ICONS: Record<string, React.ElementType> = {
[InstallStage.CHECKING]: RefreshCcw,
[InstallStage.DOWNLOADING]: Download,
[InstallStage.VALIDATING]: ShieldCheck,
[InstallStage.INSTALLING_DEPS]: Package,
[InstallStage.ACTIVATING]: Rocket,
[InstallStage.DONE]: CheckCircle2,
[InstallStage.ERROR]: XCircle,
};
@@ -79,6 +85,9 @@ function TaskQueueItem({
};
const getInstallCompleteMessage = () => {
if (task.operation === 'upgrade') {
return t('plugins.installProgress.updateComplete');
}
switch (task.extensionType) {
case 'mcp':
return t('plugins.installProgress.installCompleteMCP');
@@ -91,10 +100,18 @@ function TaskQueueItem({
const stageLabel = (() => {
switch (task.stage) {
case InstallStage.CHECKING:
return t('plugins.installProgress.checkingUpdate');
case InstallStage.DOWNLOADING:
return t('plugins.installProgress.downloading');
case InstallStage.VALIDATING:
return t('plugins.installProgress.validating');
case InstallStage.INSTALLING_DEPS:
return t('plugins.installProgress.installingDeps');
return task.operation === 'upgrade'
? t('plugins.installProgress.applyingUpdate')
: t('plugins.installProgress.installingDeps');
case InstallStage.ACTIVATING:
return t('plugins.installProgress.activating');
case InstallStage.DONE:
return isDone
? getInstallCompleteMessage()
@@ -2,7 +2,11 @@ export {
PluginInstallTaskProvider,
usePluginInstallTasks,
InstallStage,
pluginTaskKey,
} from './PluginInstallTaskContext';
export type {
PluginInstallTask,
PluginTaskOperation,
} from './PluginInstallTaskContext';
export type { PluginInstallTask } from './PluginInstallTaskContext';
export { default as PluginInstallProgressDialog } from './PluginInstallProgressDialog';
export { default as PluginInstallTaskQueue } from './PluginInstallTaskQueue';
@@ -22,6 +22,10 @@ import { toast } from 'sonner';
import { useAsyncTask, AsyncTaskStatus } from '@/hooks/useAsyncTask';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import { Loader2, Puzzle, Server, Sparkles } from 'lucide-react';
import {
pluginTaskKey,
usePluginInstallTasks,
} from '@/app/home/plugins/components/plugin-install-task';
export interface PluginInstalledComponentRef {
refreshPluginList: () => void;
@@ -68,6 +72,7 @@ const PluginInstalledComponent = forwardRef<
>(({ filterType, groupByType }, ref) => {
const { t } = useTranslation();
const navigate = useNavigate();
const { addTask, setSelectedTaskId } = usePluginInstallTasks();
const { refreshPlugins, refreshMCPServers, refreshSkills } = useSidebarData();
const [extensionList, setExtensionList] = useState<ExtensionCardVO[]>([]);
const [loading, setLoading] = useState<boolean>(true);
@@ -81,11 +86,7 @@ const PluginInstalledComponent = forwardRef<
const asyncTask = useAsyncTask({
onSuccess: () => {
const successMessage =
operationType === ExtensionOperationType.DELETE
? t('plugins.deleteSuccess')
: t('plugins.updateSuccess');
toast.success(successMessage);
toast.success(t('plugins.deleteSuccess'));
setShowOperationModal(false);
getExtensionList();
refreshPlugins();
@@ -282,28 +283,37 @@ const PluginInstalledComponent = forwardRef<
return;
}
const apiCall =
operationType === ExtensionOperationType.DELETE
? httpClient.removePlugin(
targetExtension.author,
targetExtension.name,
deleteData,
)
: httpClient.upgradePlugin(
targetExtension.author,
targetExtension.name,
if (operationType === ExtensionOperationType.UPDATE) {
httpClient
.upgradePlugin(targetExtension.author, targetExtension.name)
.then((res) => {
addTask({
taskId: res.task_id,
pluginName: `${targetExtension.author}/${targetExtension.name}`,
source: 'marketplace',
extensionType: 'plugin',
operation: 'upgrade',
});
setSelectedTaskId(
pluginTaskKey(res.task_id, 'marketplace', 'upgrade'),
);
setShowOperationModal(false);
setTargetExtension(null);
asyncTask.reset();
})
.catch((error) => {
toast.error(t('plugins.updateError') + error.message);
});
return;
}
apiCall
httpClient
.removePlugin(targetExtension.author, targetExtension.name, deleteData)
.then((res) => {
asyncTask.startTask(res.task_id);
})
.catch((error) => {
const errorMessage =
operationType === ExtensionOperationType.DELETE
? t('plugins.deleteError') + error.message
: t('plugins.updateError') + error.message;
toast.error(errorMessage);
toast.error(t('plugins.deleteError') + error.message);
});
}
+11 -2
View File
@@ -83,9 +83,18 @@ function PluginListView() {
}, [t]);
useEffect(() => {
const onComplete = (_taskId: number, success: boolean, error?: string) => {
const onComplete = (
_taskId: number,
success: boolean,
error?: string,
operation?: 'install' | 'upgrade',
) => {
if (success) {
toast.success(t('plugins.installSuccess'));
toast.success(
operation === 'upgrade'
? t('plugins.updateSuccess')
: t('plugins.installSuccess'),
);
pluginInstalledRef.current?.refreshPluginList();
refreshPlugins();
} else {
+1
View File
@@ -549,6 +549,7 @@ export interface AsyncTaskTaskContext {
export interface AsyncTask {
id: number;
created_at?: number;
kind: string;
name: string;
label: string;
+26 -2
View File
@@ -777,6 +777,7 @@ const enUS = {
'Restoring the AgentRunner plugin installation and waiting for the runner…',
noInstalledRunners: 'No AgentRunner extension is installed yet.',
installingRunner: 'Installing {{runner}}...',
runnerInstallSuccess: '{{runner}} is installed and ready to select',
selectedRunnerUnavailable: 'Selected runner is unavailable',
selectedRunnerUnavailableDescription:
'{{runner}} is not currently registered. Select another runner or restore its extension.',
@@ -1017,13 +1018,18 @@ const enUS = {
goToMarketplace: 'Go to Extension Market',
installProgress: {
title: 'Installing {{name}}',
updateTitle: 'Updating {{name}}',
titleGeneric: 'Extension Installation',
titlePlugin: 'Installing Plugin {{name}}',
titleMCP: 'Installing MCP Server {{name}}',
titleSkill: 'Installing Skill {{name}}',
overallProgress: 'Overall Progress',
checkingUpdate: 'Checking for updates',
downloading: 'Downloading',
validating: 'Validating package',
installingDeps: 'Installing Dependencies',
applyingUpdate: 'Applying update',
activating: 'Starting and refreshing components',
initializing: 'Initializing Settings',
launching: 'Launching',
completed: 'Completed',
@@ -1033,14 +1039,15 @@ const enUS = {
depsProgress:
'{{installed}}/{{total}} installed · {{remaining}} remaining',
installComplete: 'Installation successful',
updateComplete: 'Plugin updated successfully',
installCompletePlugin: 'Plugin installed successfully',
installCompleteMCP: 'MCP Server installed successfully',
installCompleteSkill: 'Skill installed successfully',
dismiss: 'Dismiss',
background: 'Run in Background',
taskQueue: 'Install Tasks',
taskQueue: 'Plugin Tasks',
clearCompleted: 'Clear Completed',
noTasks: 'No install tasks',
noTasks: 'No plugin tasks',
},
},
market: {
@@ -1512,6 +1519,23 @@ const enUS = {
knowledgeEngine: 'Knowledge Engine',
knowledgeEngineRequired: 'Knowledge engine is required',
selectKnowledgeEngine: 'Select Knowledge Engine',
installedEngines: 'Installed knowledge engines',
noInstalledEngines: 'No knowledge engine plugins are installed yet.',
marketplaceEngines: 'Knowledge engine plugins in Marketplace',
noMarketplaceEngines: 'No knowledge engine plugins are available.',
loadingEngineCatalog: 'Loading Marketplace plugins…',
engineCatalogUnavailable:
'Marketplace is temporarily unavailable. Reopen the selector to retry.',
viewMarketplace: 'View market',
installingEngine: 'Installing {{engine}}…',
engineInstallSuccess: '{{engine}} is installed and ready to select',
engineInstallFailed:
'Knowledge engine installation failed. Please try again.',
engineVersionUnavailable: 'This plugin has no installable version.',
engineInstallTimeout:
'Installation is still running. Refresh the page to check again.',
engineRegistrationTimeout:
'The plugin is installed, but its knowledge engine is not ready yet.',
builtInEngine: 'Built-in Engine',
cannotChangeKnowledgeEngine:
'Knowledge engine cannot be changed after creation',
+31 -2
View File
@@ -790,6 +790,8 @@ const jaJP = {
noInstalledRunners:
'AgentRunner 拡張機能はまだインストールされていません。',
installingRunner: '{{runner}} をインストールしています...',
runnerInstallSuccess:
'{{runner}} をインストールしました。インストール済み一覧から選択できます',
selectedRunnerUnavailable: '選択した Runner は利用できません',
selectedRunnerUnavailableDescription:
'{{runner}} は現在登録されていません。別の Runner を選択するか、対応する拡張機能を復元してください。',
@@ -982,10 +984,15 @@ const jaJP = {
goToMarketplace: 'マーケットプレイスへ',
installProgress: {
title: '{{name}} をインストール中',
updateTitle: '{{name}} を更新中',
titleGeneric: 'プラグインのインストール',
overallProgress: '全体の進捗',
checkingUpdate: '最新バージョンを確認中',
downloading: 'プラグインをダウンロード中',
validating: 'プラグインパッケージを検証中',
installingDeps: '依存関係をインストール中',
applyingUpdate: 'プラグインの更新を適用中',
activating: 'コンポーネントを起動・更新中',
initializing: '設定を初期化中',
launching: 'プラグインを起動中',
completed: '完了',
@@ -995,11 +1002,12 @@ const jaJP = {
depsProgress:
'{{installed}}/{{total}} インストール済み · 残り {{remaining}} 個',
installComplete: 'プラグインのインストール完了',
updateComplete: 'プラグインの更新が完了しました',
dismiss: '閉じる',
background: 'バックグラウンドで実行',
taskQueue: 'インストールタスク',
taskQueue: 'プラグインタスク',
clearCompleted: '完了を消去',
noTasks: 'インストールタスクはありません',
noTasks: 'プラグインタスクはありません',
titlePlugin: 'プラグイン {{name}} をインストール中',
titleMCP: 'MCP サーバー {{name}} をインストール中',
titleSkill: 'スキル {{name}} をインストール中',
@@ -1495,6 +1503,27 @@ const jaJP = {
knowledgeEngine: 'ナレッジエンジン',
knowledgeEngineRequired: 'ナレッジエンジンは必須です',
selectKnowledgeEngine: 'ナレッジエンジンを選択',
installedEngines: 'インストール済みのナレッジエンジン',
noInstalledEngines:
'ナレッジエンジンプラグインはまだインストールされていません。',
marketplaceEngines: 'マーケットプレイスのナレッジエンジンプラグイン',
noMarketplaceEngines:
'インストール可能なナレッジエンジンプラグインがありません。',
loadingEngineCatalog: 'マーケットプレイスを読み込み中…',
engineCatalogUnavailable:
'マーケットプレイスを利用できません。選択欄を開き直して再試行してください。',
viewMarketplace: '市場を見る',
installingEngine: '{{engine}} をインストール中…',
engineInstallSuccess:
'{{engine}} をインストールしました。インストール済み一覧から選択できます',
engineInstallFailed:
'ナレッジエンジンのインストールに失敗しました。再試行してください。',
engineVersionUnavailable:
'このプラグインにはインストール可能なバージョンがありません。',
engineInstallTimeout:
'インストールはまだ実行中です。ページを更新して確認してください。',
engineRegistrationTimeout:
'プラグインはインストール済みですが、ナレッジエンジンはまだ準備中です。',
builtInEngine: '組み込みエンジン',
cannotChangeKnowledgeEngine:
'作成後にナレッジエンジンを変更することはできません',
+22 -2
View File
@@ -744,6 +744,7 @@ const zhHans = {
restoringRunnerInstall: '正在恢复 AgentRunner 插件安装并等待运行器就绪…',
noInstalledRunners: '尚未安装任何 AgentRunner 扩展。',
installingRunner: '正在安装 {{runner}}...',
runnerInstallSuccess: '{{runner}} 已安装,可从已安装列表中选择',
selectedRunnerUnavailable: '所选运行器不可用',
selectedRunnerUnavailableDescription:
'{{runner}} 当前未注册。请选择其他运行器,或恢复对应扩展。',
@@ -969,13 +970,18 @@ const zhHans = {
goToMarketplace: '前往扩展市场',
installProgress: {
title: '正在安装 {{name}}',
updateTitle: '正在更新 {{name}}',
titleGeneric: '扩展安装',
titlePlugin: '正在安装插件 {{name}}',
titleMCP: '正在安装 MCP 服务器 {{name}}',
titleSkill: '正在安装技能 {{name}}',
overallProgress: '总体进度',
checkingUpdate: '检查最新版本',
downloading: '下载中',
validating: '校验插件包',
installingDeps: '安装依赖',
applyingUpdate: '应用插件更新',
activating: '启动并刷新组件',
initializing: '初始化配置',
launching: '启动中',
completed: '已完成',
@@ -984,14 +990,15 @@ const zhHans = {
depsInfo: '共 {{count}} 个依赖需要安装',
depsProgress: '已安装 {{installed}}/{{total}} · 剩余 {{remaining}} 个',
installComplete: '安装成功',
updateComplete: '插件更新成功',
installCompletePlugin: '插件安装成功',
installCompleteMCP: 'MCP 服务器安装成功',
installCompleteSkill: '技能安装成功',
dismiss: '关闭',
background: '后台运行',
taskQueue: '安装任务',
taskQueue: '插件任务',
clearCompleted: '清除已完成',
noTasks: '暂无安装任务',
noTasks: '暂无插件任务',
},
},
market: {
@@ -1445,6 +1452,19 @@ const zhHans = {
knowledgeEngine: '知识引擎',
knowledgeEngineRequired: '知识引擎不能为空',
selectKnowledgeEngine: '选择知识引擎',
installedEngines: '已安装的知识引擎',
noInstalledEngines: '尚未安装任何知识引擎插件。',
marketplaceEngines: '插件市场中的知识引擎插件',
noMarketplaceEngines: '暂无可安装的知识引擎插件。',
loadingEngineCatalog: '正在加载插件市场…',
engineCatalogUnavailable: '插件市场暂时不可用,重新打开选择器即可重试。',
viewMarketplace: '查看市场',
installingEngine: '正在安装 {{engine}}…',
engineInstallSuccess: '{{engine}} 已安装,可从已安装列表中选择',
engineInstallFailed: '知识引擎安装失败,请稍后重试。',
engineVersionUnavailable: '该插件没有可安装的版本。',
engineInstallTimeout: '安装仍未完成,请稍后刷新页面查看。',
engineRegistrationTimeout: '插件已安装,但知识引擎组件尚未就绪。',
builtInEngine: '内置引擎',
cannotChangeKnowledgeEngine: '知识库创建后不可修改知识引擎',
basicInfo: '基础信息',
@@ -0,0 +1,151 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
const currentDirectory = path.dirname(fileURLToPath(import.meta.url));
const webRoot = path.resolve(currentDirectory, '../..');
function readSource(relativePath) {
return fs.readFileSync(path.join(webRoot, relativePath), 'utf8');
}
const kbFormSource = readSource(
'src/app/home/knowledge/components/kb-form/KBForm.tsx',
);
const selectSource = readSource(
'src/app/home/knowledge/components/kb-form/KnowledgeEngineSelect.tsx',
);
const agentRunnerSelectSource = readSource(
'src/app/home/agents/components/AgentRunnerSelect.tsx',
);
const agentFormSource = readSource(
'src/app/home/agents/components/AgentFormComponent.tsx',
);
const pipelineFormSource = readSource(
'src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx',
);
const agentRunnerMarketplaceSource = readSource(
'src/app/home/agents/agent-runner-marketplace.ts',
);
const marketplaceInstallButtonSource = readSource(
'src/app/home/components/MarketplaceInstallButton.tsx',
);
const marketplaceSource = readSource(
'src/app/home/knowledge/components/kb-form/knowledge-engine-marketplace.ts',
);
const taskContextSource = readSource(
'src/app/home/plugins/components/plugin-install-task/PluginInstallTaskContext.tsx',
);
const progressDialogSource = readSource(
'src/app/home/plugins/components/plugin-install-task/PluginInstallProgressDialog.tsx',
);
const installedPluginsSource = readSource(
'src/app/home/plugins/components/plugin-installed/PluginInstalledComponent.tsx',
);
const homeSidebarSource = readSource(
'src/app/home/components/home-sidebar/HomeSidebar.tsx',
);
test('keeps the knowledge-base fields visible without an installed engine', () => {
assert.match(kbFormSource, /<KnowledgeEngineSelect/);
assert.match(kbFormSource, /name="name"/);
assert.match(kbFormSource, /name="description"/);
assert.doesNotMatch(kbFormSource, /if \(ragEngines\.length === 0\)/);
});
test('offers KnowledgeEngine marketplace plugins inside the selector', () => {
assert.match(
marketplaceSource,
/KNOWLEDGE_ENGINE_COMPONENT_FILTER = 'KnowledgeEngine'/,
);
assert.match(marketplaceSource, /installPluginFromMarketplace\(/);
assert.match(marketplaceSource, /getKnowledgeEngines\(\)/);
assert.match(marketplaceSource, /sessionStorage\.setItem\(/);
assert.match(selectSource, /resumePendingKnowledgeEngineInstall\(/);
assert.match(selectSource, /onInstalled\(installed\)/);
assert.match(selectSource, /component=KnowledgeEngine/);
});
test('tracks plugin upgrades as recoverable multistep async tasks', () => {
assert.match(taskContextSource, /name\.startsWith\('plugin-upgrade-'\)/);
assert.match(taskContextSource, /operation: PluginTaskOperation/);
assert.match(taskContextSource, /progress_percent/);
assert.match(progressDialogSource, /InstallStage\.CHECKING/);
assert.match(progressDialogSource, /InstallStage\.VALIDATING/);
assert.match(progressDialogSource, /InstallStage\.ACTIVATING/);
assert.match(progressDialogSource, /plugins\.installProgress\.updateTitle/);
for (const source of [installedPluginsSource, homeSidebarSource]) {
assert.match(
source,
/upgradePlugin\([\s\S]*?add(?:Plugin)?Task\([\s\S]*?operation: 'upgrade'/,
);
assert.match(
source,
/pluginTaskKey\(res\.task_id, 'marketplace', 'upgrade'\)/,
);
}
});
test('keeps marketplace install actions on one fixed vertical column', () => {
for (const source of [selectSource, agentRunnerSelectSource]) {
assert.match(source, /grid-cols-\[1\.75rem_minmax\(0,1fr\)_4rem\]/);
assert.match(source, /<MarketplaceInstallButton/);
}
assert.match(marketplaceInstallButtonSource, /justify-self-end/);
});
test('uses a compact selected engine layout and clears stale required errors', () => {
assert.match(selectSource, /function SelectedEngineContent/);
assert.match(
selectSource,
/selectedEngine \? \([\s\S]*?<SelectedEngineContent engine=\{selectedEngine\}/,
);
assert.match(
selectSource,
/flex min-w-0 flex-1 items-center gap-2 text-left/,
);
assert.match(kbFormSource, /form\.clearErrors\('ragEngineId'\)/);
assert.match(kbFormSource, /form\.trigger\('ragEngineId'\)/);
assert.doesNotMatch(kbFormSource, /field\.onChange\(value\)/);
});
test('installs marketplace components from inline progress buttons without selecting them', () => {
for (const source of [selectSource, agentRunnerSelectSource]) {
assert.match(source, /const handleInstall = useCallback/);
assert.match(source, /installing=\{activePluginId === pluginId\}/);
assert.match(source, /progress=\{installProgress\}/);
assert.doesNotMatch(source, /MARKETPLACE_VALUE_PREFIX/);
}
assert.match(marketplaceInstallButtonSource, /<Progress/);
assert.match(marketplaceInstallButtonSource, /onPointerDown=/);
assert.match(kbFormSource, /suppressNextAutoSelectRef\.current = true/);
assert.doesNotMatch(
kbFormSource,
/handleEngineInstalled[\s\S]*?handleEngineChange\(engine\.plugin_id/,
);
for (const source of [agentFormSource, pipelineFormSource]) {
const callback = source.match(
/const applyInstalledRunner = useCallback\(([\s\S]*?)\n \);/,
);
assert.ok(callback);
assert.doesNotMatch(callback[1], /form\.setValue/);
}
});
test('shows plugin descriptions for installed AgentRunner entries', () => {
assert.match(agentRunnerMarketplaceSource, /installedPluginDescriptions/);
assert.match(agentRunnerMarketplaceSource, /metadata\.description/);
assert.match(agentRunnerSelectSource, /installedRunnerDescription\(/);
assert.match(
agentRunnerSelectSource,
/function InstalledRunnerOptionContent/,
);
assert.match(agentRunnerSelectSource, /marketplacePlugin\?\.description/);
assert.match(
agentRunnerSelectSource,
/grid-cols-\[1\.75rem_minmax\(0,1fr\)\]/,
);
assert.doesNotMatch(agentRunnerSelectSource, /description=\{option\.name\}/);
});