From a87c814dae39df64e4c9f6047a1d5249010a196a Mon Sep 17 00:00:00 2001 From: Hyu Date: Tue, 1 Sep 2026 16:43:50 +0800 Subject: [PATCH] feat(web): improve marketplace install workflows --- .../pkg/api/http/controller/groups/plugins.py | 9 + src/langbot/pkg/plugin/connector.py | 33 +- .../plugin/test_connector_reconcile.py | 87 ++++ .../home/agents/agent-runner-marketplace.ts | 18 +- .../agents/components/AgentFormComponent.tsx | 26 +- .../agents/components/AgentRunnerSelect.tsx | 240 ++++++--- .../components/MarketplaceInstallButton.tsx | 49 ++ .../components/home-sidebar/HomeSidebar.tsx | 68 ++- .../knowledge/components/kb-form/KBForm.tsx | 158 ++---- .../kb-form/KnowledgeEngineSelect.tsx | 468 ++++++++++++++++++ .../kb-form/knowledge-engine-marketplace.ts | 266 ++++++++++ .../pipeline-form/PipelineFormComponent.tsx | 27 +- .../PluginInstallProgressDialog.tsx | 124 +++-- .../PluginInstallTaskContext.tsx | 110 +++- .../PluginInstallTaskQueue.tsx | 19 +- .../components/plugin-install-task/index.ts | 6 +- .../PluginInstalledComponent.tsx | 52 +- web/src/app/home/plugins/page.tsx | 13 +- web/src/app/infra/entities/api/index.ts | 1 + web/src/i18n/locales/en-US.ts | 28 +- web/src/i18n/locales/ja-JP.ts | 33 +- web/src/i18n/locales/zh-Hans.ts | 24 +- .../knowledge-engine-marketplace.test.mjs | 151 ++++++ 23 files changed, 1657 insertions(+), 353 deletions(-) create mode 100644 web/src/app/home/components/MarketplaceInstallButton.tsx create mode 100644 web/src/app/home/knowledge/components/kb-form/KnowledgeEngineSelect.tsx create mode 100644 web/src/app/home/knowledge/components/kb-form/knowledge-engine-marketplace.ts create mode 100644 web/tests/unit/knowledge-engine-marketplace.test.mjs diff --git a/src/langbot/pkg/api/http/controller/groups/plugins.py b/src/langbot/pkg/api/http/controller/groups/plugins.py index 77d710693..a338a3575 100644 --- a/src/langbot/pkg/api/http/controller/groups/plugins.py +++ b/src/langbot/pkg/api/http/controller/groups/plugins.py @@ -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, diff --git a/src/langbot/pkg/plugin/connector.py b/src/langbot/pkg/plugin/connector.py index 3bc33935f..3feb37ac6 100644 --- a/src/langbot/pkg/plugin/connector.py +++ b/src/langbot/pkg/plugin/connector.py @@ -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( diff --git a/tests/unit_tests/plugin/test_connector_reconcile.py b/tests/unit_tests/plugin/test_connector_reconcile.py index 35d06a72e..a55a79b3d 100644 --- a/tests/unit_tests/plugin/test_connector_reconcile.py +++ b/tests/unit_tests/plugin/test_connector_reconcile.py @@ -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' diff --git a/web/src/app/home/agents/agent-runner-marketplace.ts b/web/src/app/home/agents/agent-runner-marketplace.ts index 477b36b9f..3206e2231 100644 --- a/web/src/app/home/agents/agent-runner-marketplace.ts +++ b/web/src/app/home/agents/agent-runner-marketplace.ts @@ -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; } export interface InstalledAgentRunner { @@ -199,12 +201,20 @@ export async function loadAgentRunnerCatalog(): Promise { return right.install_count - left.install_count; }); + const installedPluginDescriptions: Record = {}; + 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, }; } diff --git a/web/src/app/home/agents/components/AgentFormComponent.tsx b/web/src/app/home/agents/components/AgentFormComponent.tsx index ec9c4aac7..ee9fcc104 100644 --- a/web/src/app/home/agents/components/AgentFormComponent.tsx +++ b/web/src/app/home/agents/components/AgentFormComponent.tsx @@ -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), }), ); diff --git a/web/src/app/home/agents/components/AgentRunnerSelect.tsx b/web/src/app/home/agents/components/AgentRunnerSelect.tsx index d28a00215..4adbac1ce 100644 --- a/web/src/app/home/agents/components/AgentRunnerSelect.tsx +++ b/web/src/app/home/agents/components/AgentRunnerSelect.tsx @@ -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['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 ( @@ -76,7 +83,79 @@ function InstalledRunnerContent({ ); } -function MarketplaceRunnerContent({ plugin }: { plugin: PluginV4 }) { +function InstalledRunnerOptionContent({ + option, + description, +}: { + option: IDynamicFormItemOption; + description: string; +}) { + const iconURL = installedRunnerIconURL(option); + + return ( + + {iconURL ? ( + + ) : ( + + )} + + {extractI18nObject(option.label)} + + + {description} + + + ); +} + +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 ( - +
{extractI18nObject(plugin.label) || plugin.name} - + {description} - +
); } @@ -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([]); const [installedPluginIds, setInstalledPluginIds] = useState([]); + const [installedPluginDescriptions, setInstalledPluginDescriptions] = + useState({}); const [catalogLoading, setCatalogLoading] = useState(true); const [catalogError, setCatalogError] = useState(false); const [pendingInstall, setPendingInstall] = useState(() => readPendingAgentRunnerInstall(installScope), ); const [installError, setInstallError] = useState(null); + const [installingPluginId, setInstallingPluginId] = useState( + 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({
{ - field.onChange(value); - handleEngineChange(value); - }} + - - {field.value ? ( - (() => { - const [author, name] = field.value.split('/'); - const engine = ragEngines.find( - (e) => e.plugin_id === field.value, - ); - return ( -
- - - {engine - ? extractI18nObject(engine.name) - : field.value} - -
- ); - })() - ) : ( - - )} -
- - {ragEngines.map((engine) => { - const [author, name] = engine.plugin_id.split('/'); - return ( - -
- - {extractI18nObject(engine.name)} -
-
- ); - })} -
- + disabled={isEditing} + loading={loading} + installScope="knowledge-base-create" + onValueChange={handleEngineChange} + onInstalled={handleEngineInstalled} + /> {selectedEngine?.description && ( - + {extractI18nObject(selectedEngine.description)} )} diff --git a/web/src/app/home/knowledge/components/kb-form/KnowledgeEngineSelect.tsx b/web/src/app/home/knowledge/components/kb-form/KnowledgeEngineSelect.tsx new file mode 100644 index 000000000..f0131b09a --- /dev/null +++ b/web/src/app/home/knowledge/components/kb-form/KnowledgeEngineSelect.tsx @@ -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['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 ( + + {author && name ? ( + + ) : ( + + )} + + {extractI18nObject(engine.name) || engine.plugin_id} + + + {description || engine.plugin_id} + + + ); +} + +function SelectedEngineContent({ engine }: { engine: KnowledgeEngine }) { + const [author, name] = engine.plugin_id.split('/'); + + return ( + + {author && name ? ( + + ) : ( + + )} + + {extractI18nObject(engine.name) || engine.plugin_id} + + + ); +} + +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 ( +
+ + + {extractI18nObject(plugin.label) || plugin.name} + + + + {description} + +
+ ); +} + +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([]); + const [installedPluginIds, setInstalledPluginIds] = useState([]); + const [catalogLoading, setCatalogLoading] = useState(true); + const [catalogError, setCatalogError] = useState(false); + const [pendingInstall, setPendingInstall] = useState(() => + readPendingKnowledgeEngineInstall(installScope), + ); + const [installError, setInstallError] = useState(null); + const [installingPluginId, setInstallingPluginId] = useState( + null, + ); + const activeInstallRef = useRef(false); + const resumedTaskRef = useRef(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 ( +
+ + + {installError && ( +

+ {installError} +

+ )} +
+ ); +} diff --git a/web/src/app/home/knowledge/components/kb-form/knowledge-engine-marketplace.ts b/web/src/app/home/knowledge/components/kb-form/knowledge-engine-marketplace.ts new file mode 100644 index 000000000..2abd6ee3f --- /dev/null +++ b/web/src/app/home/knowledge/components/kb-form/knowledge-engine-marketplace.ts @@ -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, +) { + 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; + 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 { + 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 { + 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 { + 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 { + const pending = readPendingKnowledgeEngineInstall(scope); + if (!pending) return null; + return finishKnowledgeEngineInstall(pending); +} diff --git a/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx b/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx index 4a4e80ae0..db3df3ce1 100644 --- a/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx +++ b/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx @@ -232,31 +232,8 @@ const PipelineFormComponent = forwardRef< const applyInstalledRunner = useCallback( (installed: InstalledAgentRunner) => { setAIConfigTabSchema(installed.configTab); - const currentAI = (form.getValues('ai') || {}) as Record; - 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), }), ); diff --git a/web/src/app/home/plugins/components/plugin-install-task/PluginInstallProgressDialog.tsx b/web/src/app/home/plugins/components/plugin-install-task/PluginInstallProgressDialog.tsx index 27c706ed2..d69c8a22a 100644 --- a/web/src/app/home/plugins/components/plugin-install-task/PluginInstallProgressDialog.tsx +++ b/web/src/app/home/plugins/components/plugin-install-task/PluginInstallProgressDialog.tsx @@ -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) => ( )) - ) : isError ? ( - /* Error: show the failed stage */ - currentStageIndex >= 0 && ( - - ) - ) : ( - /* In progress: only show the current active stage */ - currentStageIndex >= 0 && ( - - ) )}
@@ -379,7 +391,9 @@ function TaskProgressContent({ task }: { task: PluginInstallTask }) {
- {t('plugins.installProgress.installComplete')} + {task.operation === 'upgrade' + ? t('plugins.installProgress.updateComplete') + : t('plugins.installProgress.installComplete')}
)} @@ -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() { > - + {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')} diff --git a/web/src/app/home/plugins/components/plugin-install-task/PluginInstallTaskContext.tsx b/web/src/app/home/plugins/components/plugin-install-task/PluginInstallTaskContext.tsx index 4120c9599..042aeb897 100644 --- a/web/src/app/home/plugins/components/plugin-install-task/PluginInstallTaskContext.tsx +++ b/web/src/app/home/plugins/components/plugin-install-task/PluginInstallTaskContext.tsx @@ -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; 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: '', diff --git a/web/src/app/home/plugins/components/plugin-install-task/PluginInstallTaskQueue.tsx b/web/src/app/home/plugins/components/plugin-install-task/PluginInstallTaskQueue.tsx index d8c9e9869..748ee2dd3 100644 --- a/web/src/app/home/plugins/components/plugin-install-task/PluginInstallTaskQueue.tsx +++ b/web/src/app/home/plugins/components/plugin-install-task/PluginInstallTaskQueue.tsx @@ -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 = { + [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() diff --git a/web/src/app/home/plugins/components/plugin-install-task/index.ts b/web/src/app/home/plugins/components/plugin-install-task/index.ts index c6101edb8..3be6d5ceb 100644 --- a/web/src/app/home/plugins/components/plugin-install-task/index.ts +++ b/web/src/app/home/plugins/components/plugin-install-task/index.ts @@ -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'; diff --git a/web/src/app/home/plugins/components/plugin-installed/PluginInstalledComponent.tsx b/web/src/app/home/plugins/components/plugin-installed/PluginInstalledComponent.tsx index 4429f7958..8719b36f5 100644 --- a/web/src/app/home/plugins/components/plugin-installed/PluginInstalledComponent.tsx +++ b/web/src/app/home/plugins/components/plugin-installed/PluginInstalledComponent.tsx @@ -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([]); const [loading, setLoading] = useState(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); }); } diff --git a/web/src/app/home/plugins/page.tsx b/web/src/app/home/plugins/page.tsx index 51bf48815..f91b86eaf 100644 --- a/web/src/app/home/plugins/page.tsx +++ b/web/src/app/home/plugins/page.tsx @@ -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 { diff --git a/web/src/app/infra/entities/api/index.ts b/web/src/app/infra/entities/api/index.ts index 7b1217225..1d6bf4035 100644 --- a/web/src/app/infra/entities/api/index.ts +++ b/web/src/app/infra/entities/api/index.ts @@ -549,6 +549,7 @@ export interface AsyncTaskTaskContext { export interface AsyncTask { id: number; + created_at?: number; kind: string; name: string; label: string; diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index ac3135334..f3dbe0dd7 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -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', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index 5166ade68..4fdc53c5e 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -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: '作成後にナレッジエンジンを変更することはできません', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index ba1dc1849..28d1a5931 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -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: '基础信息', diff --git a/web/tests/unit/knowledge-engine-marketplace.test.mjs b/web/tests/unit/knowledge-engine-marketplace.test.mjs new file mode 100644 index 000000000..9911db044 --- /dev/null +++ b/web/tests/unit/knowledge-engine-marketplace.test.mjs @@ -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, / { + 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, / { + assert.match(selectSource, /function SelectedEngineContent/); + assert.match( + selectSource, + /selectedEngine \? \([\s\S]*? { + 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, / { + 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\}/); +});