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: async def _(author: str, plugin_name: str, request_context: RequestContext) -> str:
execution_context = await self.ap.plugin_connector.require_workspace_context(request_context) execution_context = await self.ap.plugin_connector.require_workspace_context(request_context)
ctx = taskmgr.TaskContext.new() 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( wrapper = self.ap.task_mgr.create_user_task(
self._run_fenced_plugin_operation( self._run_fenced_plugin_operation(
execution_context, execution_context,
+32 -1
View File
@@ -1718,6 +1718,9 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
file_bytes: bytes | None file_bytes: bytes | None
if install_source == PluginInstallSource.MARKETPLACE: 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( file_bytes, version = await self._download_marketplace_package(
execution_context, execution_context,
plugin_author, plugin_author,
@@ -1741,6 +1744,9 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
else: else:
raise ValueError(f'Unsupported plugin install source: {install_source.value}') 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) manifest_author, manifest_name = self._inspect_plugin_package(file_bytes, task_context)
if not manifest_author or not manifest_name: if not manifest_author or not manifest_name:
raise ValueError('Plugin package manifest identity is missing') raise ValueError('Plugin package manifest identity is missing')
@@ -1751,6 +1757,8 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
plugin_author, plugin_name = manifest_author, manifest_name plugin_author, plugin_name = manifest_author, manifest_name
if task_context is not None: if task_context is not None:
task_context.metadata['plugin_name'] = f'{plugin_author}/{plugin_name}' 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() artifact_digest = hashlib.sha256(file_bytes).hexdigest()
await self._store_artifact_package(execution_context, artifact_digest, file_bytes) await self._store_artifact_package(execution_context, artifact_digest, file_bytes)
@@ -1787,14 +1795,30 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
pass pass
except Exception as exc: except Exception as exc:
self.ap.logger.debug(f'Legacy OSS plugin cleanup skipped: {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( await self._apply_desired_state(
desired, desired,
artifact_package=file_bytes, artifact_package=file_bytes,
) )
if previous_digest is not None and previous_digest != artifact_digest: if previous_digest is not None and previous_digest != artifact_digest:
await self._delete_artifact_if_unreferenced(execution_context, previous_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) 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() 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( async def upgrade_plugin(
self, self,
@@ -1806,13 +1830,20 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
if setting.install_source != PluginInstallSource.MARKETPLACE.value: if setting.install_source != PluginInstallSource.MARKETPLACE.value:
raise ValueError(f'Plugin {plugin_author}/{plugin_name} is not installed from marketplace') raise ValueError(f'Plugin {plugin_author}/{plugin_name} is not installed from marketplace')
if task_context is not None: 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') task_context.set_current_action('checking for latest version')
await self.install_plugin( await self.install_plugin(
PluginInstallSource.MARKETPLACE, PluginInstallSource.MARKETPLACE,
{'plugin_author': plugin_author, 'plugin_name': plugin_name}, {'plugin_author': plugin_author, 'plugin_name': plugin_name},
task_context=task_context, task_context=task_context,
) )
await self._refresh_agent_runner_registry()
return {} return {}
async def delete_plugin( 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 @pytest.mark.asyncio
async def test_workspace_reads_do_not_wait_for_an_installation_apply(): async def test_workspace_reads_do_not_wait_for_an_installation_apply():
package = b'local-lbpkg-bytes' 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 { IDynamicFormItemOption } from '@/app/infra/entities/form/dynamic';
import type { PipelineConfigTab } from '@/app/infra/entities/pipeline'; import type { PipelineConfigTab } from '@/app/infra/entities/pipeline';
import type { PluginV4 } from '@/app/infra/entities/plugin'; import type { PluginV4 } from '@/app/infra/entities/plugin';
import type { I18nObject } from '@/app/infra/entities/common';
export const RUNNER_COMPONENT_FILTER = 'AgentRunner'; export const RUNNER_COMPONENT_FILTER = 'AgentRunner';
@@ -28,6 +29,7 @@ export class AgentRunnerMarketplaceError extends Error {
export interface AgentRunnerCatalog { export interface AgentRunnerCatalog {
marketplaceRunners: PluginV4[]; marketplaceRunners: PluginV4[];
installedPluginIds: string[]; installedPluginIds: string[];
installedPluginDescriptions: Record<string, I18nObject>;
} }
export interface InstalledAgentRunner { export interface InstalledAgentRunner {
@@ -199,12 +201,20 @@ export async function loadAgentRunnerCatalog(): Promise<AgentRunnerCatalog> {
return right.install_count - left.install_count; 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 { return {
marketplaceRunners, marketplaceRunners,
installedPluginIds: installedResult.plugins.map((plugin) => { installedPluginIds,
const metadata = plugin.manifest.manifest.metadata; installedPluginDescriptions,
return `${metadata.author ?? ''}/${metadata.name}`;
}),
}; };
} }
@@ -21,7 +21,6 @@ import {
PipelineConfigTab, PipelineConfigTab,
} from '@/app/infra/entities/pipeline'; } from '@/app/infra/entities/pipeline';
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent'; import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
import { getDefaultValues } from '@/app/home/components/dynamic-form/DynamicFormItemConfig';
import { import {
getErrorMessage, getErrorMessage,
readPendingAgentRunnerInstall, readPendingAgentRunnerInstall,
@@ -161,29 +160,8 @@ function AgentFormComponent(
const applyInstalledRunner = useCallback( const applyInstalledRunner = useCallback(
(installed: InstalledAgentRunner) => { (installed: InstalledAgentRunner) => {
setRunnerConfigSchema(installed.configTab); setRunnerConfigSchema(installed.configTab);
const currentRunner = form.getValues('runner') || {};
const currentConfigs = form.getValues('runner_config') || {};
const runnerName = installed.runner.name;
const runnerStage = installed.configTab.stages.find(
(stage) => stage.name === runnerName,
);
form.setValue(
'runner',
{ ...currentRunner, id: runnerName },
{ shouldDirty: true },
);
if (!(runnerName in currentConfigs) && runnerStage) {
form.setValue(
'runner_config',
{
...currentConfigs,
[runnerName]: getDefaultValues(runnerStage.config),
},
{ shouldDirty: true },
);
}
}, },
[form], [],
); );
const savedSnapshotRef = useRef(''); const savedSnapshotRef = useRef('');
@@ -251,7 +229,7 @@ function AgentFormComponent(
if (cancelled || !installed) return; if (cancelled || !installed) return;
applyInstalledRunner(installed); applyInstalledRunner(installed);
toast.success( toast.success(
t('wizard.aiEngine.installSuccess', { t('agents.runnerInstallSuccess', {
runner: extractI18nObject(installed.runner.label), runner: extractI18nObject(installed.runner.label),
}), }),
); );
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; 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 { useTranslation } from 'react-i18next';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -15,9 +15,14 @@ import {
runnerPluginPrefix, runnerPluginPrefix,
readPendingAgentRunnerInstall, readPendingAgentRunnerInstall,
subscribePendingAgentRunnerInstall, subscribePendingAgentRunnerInstall,
type AgentRunnerCatalog,
type InstalledAgentRunner, type InstalledAgentRunner,
} from '@/app/home/agents/agent-runner-marketplace'; } from '@/app/home/agents/agent-runner-marketplace';
import { usePluginInstallTasks } from '@/app/home/plugins/components/plugin-install-task'; import {
InstallStage,
usePluginInstallTasks,
} from '@/app/home/plugins/components/plugin-install-task';
import MarketplaceInstallButton from '@/app/home/components/MarketplaceInstallButton';
import { extractI18nObject } from '@/i18n/I18nProvider'; import { extractI18nObject } from '@/i18n/I18nProvider';
import { import {
Select, Select,
@@ -30,8 +35,6 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
const MARKETPLACE_VALUE_PREFIX = '__agent_runner_marketplace__:';
function installErrorMessage( function installErrorMessage(
error: unknown, error: unknown,
t: ReturnType<typeof useTranslation>['t'], t: ReturnType<typeof useTranslation>['t'],
@@ -48,17 +51,21 @@ function installErrorMessage(
return getErrorMessage(error) || t('wizard.aiEngine.installFailed'); return getErrorMessage(error) || t('wizard.aiEngine.installFailed');
} }
function InstalledRunnerContent({ function installedRunnerIconURL(option: IDynamicFormItemOption) {
option, return option.name.startsWith('plugin:')
}: {
option: IDynamicFormItemOption;
}) {
const iconURL = option.name.startsWith('plugin:')
? (() => { ? (() => {
const match = option.name.match(/^plugin:([^/]+)\/([^/]+)(?:\/|$)/); const match = option.name.match(/^plugin:([^/]+)\/([^/]+)(?:\/|$)/);
return match ? httpClient.getPluginIconURL(match[1], match[2]) : null; return match ? httpClient.getPluginIconURL(match[1], match[2]) : null;
})() })()
: null; : null;
}
function InstalledRunnerContent({
option,
}: {
option: IDynamicFormItemOption;
}) {
const iconURL = installedRunnerIconURL(option);
return ( return (
<span className="flex min-w-0 items-center gap-2"> <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( const iconURL = getCloudServiceClientSync().resolveMarketplaceIconURL(
plugin.type, plugin.type,
plugin.author, plugin.author,
@@ -87,7 +166,7 @@ function MarketplaceRunnerContent({ plugin }: { plugin: PluginV4 }) {
extractI18nObject(plugin.description) || `${plugin.author}/${plugin.name}`; extractI18nObject(plugin.description) || `${plugin.author}/${plugin.name}`;
return ( 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 <img
src={iconURL} src={iconURL}
alt="" alt=""
@@ -96,14 +175,20 @@ function MarketplaceRunnerContent({ plugin }: { plugin: PluginV4 }) {
<span className="truncate font-medium leading-5"> <span className="truncate font-medium leading-5">
{extractI18nObject(plugin.label) || plugin.name} {extractI18nObject(plugin.label) || plugin.name}
</span> </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 <span
className="truncate text-xs leading-4 text-muted-foreground" className="truncate text-xs leading-4 text-muted-foreground"
title={description} title={description}
> >
{description} {description}
</span> </span>
</span> </div>
); );
} }
@@ -123,15 +208,20 @@ export default function AgentRunnerSelect({
onInstalled: (installed: InstalledAgentRunner) => void; onInstalled: (installed: InstalledAgentRunner) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { addTask } = usePluginInstallTasks(); const { addTask, tasks } = usePluginInstallTasks();
const [marketplaceRunners, setMarketplaceRunners] = useState<PluginV4[]>([]); const [marketplaceRunners, setMarketplaceRunners] = useState<PluginV4[]>([]);
const [installedPluginIds, setInstalledPluginIds] = useState<string[]>([]); const [installedPluginIds, setInstalledPluginIds] = useState<string[]>([]);
const [installedPluginDescriptions, setInstalledPluginDescriptions] =
useState<AgentRunnerCatalog['installedPluginDescriptions']>({});
const [catalogLoading, setCatalogLoading] = useState(true); const [catalogLoading, setCatalogLoading] = useState(true);
const [catalogError, setCatalogError] = useState(false); const [catalogError, setCatalogError] = useState(false);
const [pendingInstall, setPendingInstall] = useState(() => const [pendingInstall, setPendingInstall] = useState(() =>
readPendingAgentRunnerInstall(installScope), readPendingAgentRunnerInstall(installScope),
); );
const [installError, setInstallError] = useState<string | null>(null); const [installError, setInstallError] = useState<string | null>(null);
const [installingPluginId, setInstallingPluginId] = useState<string | null>(
null,
);
const loadCatalog = useCallback(async () => { const loadCatalog = useCallback(async () => {
setCatalogLoading(true); setCatalogLoading(true);
@@ -140,6 +230,7 @@ export default function AgentRunnerSelect({
const catalog = await loadAgentRunnerCatalog(); const catalog = await loadAgentRunnerCatalog();
setMarketplaceRunners(catalog.marketplaceRunners); setMarketplaceRunners(catalog.marketplaceRunners);
setInstalledPluginIds(catalog.installedPluginIds); setInstalledPluginIds(catalog.installedPluginIds);
setInstalledPluginDescriptions(catalog.installedPluginDescriptions);
} catch (error) { } catch (error) {
console.error('Failed to load AgentRunner catalog', error); console.error('Failed to load AgentRunner catalog', error);
setCatalogError(true); setCatalogError(true);
@@ -172,26 +263,32 @@ export default function AgentRunnerSelect({
); );
const selectedOption = options.find((option) => option.name === value); const selectedOption = options.find((option) => option.name === value);
const installingPlugin = pendingInstall const activePluginId = pendingInstall?.pluginId ?? installingPluginId;
? (marketplaceRunners.find( const activeTask = pendingInstall
(plugin) => marketplacePluginId(plugin) === pendingInstall.pluginId, ? tasks.find((task) => task.taskId === pendingInstall.taskId)
) ?? null) : undefined;
: null; const installProgress = activePluginId
? activeTask
? activeTask.stage === InstallStage.DONE
? 95
: Math.max(5, activeTask.overallProgress)
: 5
: 0;
const handleValueChange = useCallback( const handleValueChange = useCallback(
async (nextValue: string) => { (nextValue: string) => {
if (!nextValue.startsWith(MARKETPLACE_VALUE_PREFIX)) { setInstallError(null);
setInstallError(null); onValueChange(nextValue);
onValueChange(nextValue); },
return; [onValueChange],
} );
const pluginId = nextValue.slice(MARKETPLACE_VALUE_PREFIX.length); const handleInstall = useCallback(
const plugin = marketplaceRunners.find( async (plugin: PluginV4) => {
(candidate) => marketplacePluginId(candidate) === pluginId, const pluginId = marketplacePluginId(plugin);
); if (pendingInstall || installingPluginId) return;
if (!plugin || pendingInstall) return;
setInstallingPluginId(pluginId);
setInstallError(null); setInstallError(null);
try { try {
const installed = await installMarketplaceAgentRunner(plugin, { const installed = await installMarketplaceAgentRunner(plugin, {
@@ -207,7 +304,7 @@ export default function AgentRunnerSelect({
onInstalled(installed); onInstalled(installed);
await loadCatalog(); await loadCatalog();
toast.success( toast.success(
t('wizard.aiEngine.installSuccess', { t('agents.runnerInstallSuccess', {
runner: extractI18nObject(plugin.label) || plugin.name, runner: extractI18nObject(plugin.label) || plugin.name,
}), }),
); );
@@ -216,16 +313,17 @@ export default function AgentRunnerSelect({
setInstallError(message); setInstallError(message);
toast.error(message); toast.error(message);
} finally { } finally {
setPendingInstall(readPendingAgentRunnerInstall(installScope)); const current = readPendingAgentRunnerInstall(installScope);
setPendingInstall(current);
if (!current) setInstallingPluginId(null);
} }
}, },
[ [
addTask, addTask,
installScope, installScope,
installingPluginId,
loadCatalog, loadCatalog,
marketplaceRunners,
onInstalled, onInstalled,
onValueChange,
pendingInstall, pendingInstall,
t, t,
], ],
@@ -235,8 +333,7 @@ export default function AgentRunnerSelect({
<div className="w-full max-w-[22rem] space-y-2"> <div className="w-full max-w-[22rem] space-y-2">
<Select <Select
value={value} value={value}
disabled={pendingInstall !== null} onValueChange={handleValueChange}
onValueChange={(nextValue) => void handleValueChange(nextValue)}
onOpenChange={(open) => { onOpenChange={(open) => {
if (open && catalogError && !catalogLoading) void loadCatalog(); if (open && catalogError && !catalogLoading) void loadCatalog();
}} }}
@@ -245,18 +342,7 @@ export default function AgentRunnerSelect({
aria-label={label} aria-label={label}
className="w-full bg-[#ffffff] dark:bg-[#2a2a2e]" className="w-full bg-[#ffffff] dark:bg-[#2a2a2e]"
> >
{installingPlugin ? ( {selectedOption ? (
<div className="flex min-w-0 items-center gap-2">
<Loader2 className="size-4 shrink-0 animate-spin" />
<span className="truncate">
{t('agents.installingRunner', {
runner:
extractI18nObject(installingPlugin.label) ||
installingPlugin.name,
})}
</span>
</div>
) : selectedOption ? (
<InstalledRunnerContent option={selectedOption} /> <InstalledRunnerContent option={selectedOption} />
) : ( ) : (
<SelectValue placeholder={t('common.select')} /> <SelectValue placeholder={t('common.select')} />
@@ -271,16 +357,25 @@ export default function AgentRunnerSelect({
</span> </span>
</SelectLabel> </SelectLabel>
{options.length > 0 ? ( {options.length > 0 ? (
options.map((option) => ( options.map((option) => {
<SelectItem const description = installedRunnerDescription(
key={option.name} option,
value={option.name} marketplaceRunners,
description={option.name} installedPluginDescriptions,
className="py-1.5" );
> return (
<InstalledRunnerContent option={option} /> <SelectItem
</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"> <div className="px-2 py-1.5 text-xs text-muted-foreground">
{t('agents.noInstalledRunners')} {t('agents.noInstalledRunners')}
@@ -318,15 +413,24 @@ export default function AgentRunnerSelect({
{t('wizard.aiEngine.catalogUnavailable')} {t('wizard.aiEngine.catalogUnavailable')}
</div> </div>
) : marketplaceOptions.length > 0 ? ( ) : marketplaceOptions.length > 0 ? (
marketplaceOptions.map((plugin) => ( marketplaceOptions.map((plugin) => {
<SelectItem const pluginId = marketplacePluginId(plugin);
key={marketplacePluginId(plugin)} const pluginLabel =
value={`${MARKETPLACE_VALUE_PREFIX}${marketplacePluginId(plugin)}`} extractI18nObject(plugin.label) || plugin.name;
className="py-1.5 pr-8" return (
> <MarketplaceRunnerContent
<MarketplaceRunnerContent plugin={plugin} /> key={pluginId}
</SelectItem> 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"> <div className="px-2 py-1.5 text-xs text-muted-foreground">
{t('wizard.aiEngine.noMarketplaceRunners')} {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'; } from '@/components/ui/popover';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useSidebarData, SidebarEntityItem } from './SidebarDataContext'; import { useSidebarData, SidebarEntityItem } from './SidebarDataContext';
import {
pluginTaskKey,
usePluginInstallTasks,
} from '@/app/home/plugins/components/plugin-install-task';
import { FeedbackPopoverContent } from './FeedbackPopover'; import { FeedbackPopoverContent } from './FeedbackPopover';
import { import {
type WorkspaceQuotaItem, type WorkspaceQuotaItem,
@@ -404,6 +408,7 @@ function NavItems({
const pathname = location.pathname; const pathname = location.pathname;
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const sidebarData = useSidebarData(); const sidebarData = useSidebarData();
const refreshSidebarPlugins = sidebarData.refreshPlugins;
const quotaStatus = useWorkspaceQuotaStatus(); const quotaStatus = useWorkspaceQuotaStatus();
const { state: sidebarState, isMobile } = useSidebar(); const { state: sidebarState, isMobile } = useSidebar();
const { t } = useTranslation(); const { t } = useTranslation();
@@ -452,19 +457,36 @@ function NavItems({
const [targetPluginItem, setTargetPluginItem] = const [targetPluginItem, setTargetPluginItem] =
useState<SidebarEntityItem | null>(null); useState<SidebarEntityItem | null>(null);
const [deleteData, setDeleteData] = useState(false); const [deleteData, setDeleteData] = useState(false);
const {
addTask: addPluginTask,
setSelectedTaskId: setSelectedPluginTaskId,
registerOnTaskComplete,
unregisterOnTaskComplete,
} = usePluginInstallTasks();
const asyncTask = useAsyncTask({ const asyncTask = useAsyncTask({
onSuccess: () => { onSuccess: () => {
const msg = toast.success(t('plugins.deleteSuccess'));
pluginOpType === PluginOperationType.DELETE
? t('plugins.deleteSuccess')
: t('plugins.updateSuccess');
toast.success(msg);
setShowPluginOpModal(false); setShowPluginOpModal(false);
sidebarData.refreshPlugins(); 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) { function handlePluginDelete(item: SidebarEntityItem) {
setTargetPluginItem(item); setTargetPluginItem(item);
setPluginOpType(PluginOperationType.DELETE); setPluginOpType(PluginOperationType.DELETE);
@@ -490,21 +512,37 @@ function NavItems({
? targetPluginItem.id.substring(slashIdx + 1) ? targetPluginItem.id.substring(slashIdx + 1)
: targetPluginItem.id; : targetPluginItem.id;
const apiCall = if (pluginOpType === PluginOperationType.UPDATE) {
pluginOpType === PluginOperationType.DELETE httpClient
? httpClient.removePlugin(author, name, deleteData) .upgradePlugin(author, name)
: 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) => { .then((res) => {
asyncTask.startTask(res.task_id); asyncTask.startTask(res.task_id);
}) })
.catch((error) => { .catch((error) => {
const errorMessage = toast.error(t('plugins.deleteError') + error.message);
pluginOpType === PluginOperationType.DELETE
? t('plugins.deleteError') + error.message
: t('plugins.updateError') + error.message;
toast.error(errorMessage);
}); });
} }
@@ -1,10 +1,8 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod'; import { z } from 'zod';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { AuthenticatedPluginIcon } from '@/components/AuthenticatedPluginIcon';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import EmojiPicker from '@/components/ui/emoji-picker'; import EmojiPicker from '@/components/ui/emoji-picker';
import { import {
@@ -24,13 +22,6 @@ import {
CardTitle, CardTitle,
} from '@/components/ui/card'; } from '@/components/ui/card';
import { httpClient } from '@/app/infra/http/HttpClient'; import { httpClient } from '@/app/infra/http/HttpClient';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { KnowledgeBase, KnowledgeEngine } from '@/app/infra/entities/api'; import { KnowledgeBase, KnowledgeEngine } from '@/app/infra/entities/api';
import { CustomApiError } from '@/app/infra/entities/common'; import { CustomApiError } from '@/app/infra/entities/common';
@@ -44,6 +35,7 @@ import {
parseDynamicFormItemType, parseDynamicFormItemType,
} from '@/app/home/components/dynamic-form/DynamicFormItemConfig'; } from '@/app/home/components/dynamic-form/DynamicFormItemConfig';
import { UUID } from 'uuidjs'; import { UUID } from 'uuidjs';
import KnowledgeEngineSelect from './KnowledgeEngineSelect';
const getFormSchema = (t: (key: string) => string) => const getFormSchema = (t: (key: string) => string) =>
z.object({ z.object({
@@ -107,6 +99,7 @@ export default function KBForm({
// Dirty tracking: snapshot of saved state for comparison // Dirty tracking: snapshot of saved state for comparison
const savedSnapshotRef = useRef<string>(''); const savedSnapshotRef = useRef<string>('');
const isInitializing = useRef(true); const isInitializing = useRef(true);
const suppressNextAutoSelectRef = useRef(false);
// Refs to store validation functions from dynamic forms // Refs to store validation functions from dynamic forms
const configValidateRef = useRef<(() => Promise<boolean>) | null>(null); 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 // Auto-select first engine when engines are loaded and no selection
useEffect(() => { useEffect(() => {
if (ragEngines.length > 0 && !selectedEngineId && !isEditing) { if (ragEngines.length > 0 && !selectedEngineId && !isEditing) {
if (suppressNextAutoSelectRef.current) {
suppressNextAutoSelectRef.current = false;
return;
}
const firstEngine = ragEngines[0]; const firstEngine = ragEngines[0];
setSelectedEngineId(firstEngine.plugin_id); setSelectedEngineId(firstEngine.plugin_id);
form.setValue('ragEngineId', firstEngine.plugin_id); form.setValue('ragEngineId', firstEngine.plugin_id);
@@ -219,26 +216,41 @@ export default function KBForm({
} }
}; };
const handleEngineChange = (engineId: string) => { const handleEngineChange = useCallback(
setSelectedEngineId(engineId); (engineId: string, installedEngine?: KnowledgeEngine) => {
form.setValue('ragEngineId', engineId); 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); const formItems = parseCreationSchema(engine.creation_schema);
if (formItems.length > 0) { setConfigSettings(
setConfigSettings(getDefaultValues(formItems)); formItems.length > 0 ? getDefaultValues(formItems) : {},
} else { );
setConfigSettings({});
}
const retrievalItems = parseCreationSchema(engine.retrieval_schema); const retrievalItems = parseCreationSchema(engine.retrieval_schema);
if (retrievalItems.length > 0) { setRetrievalSettings(
setRetrievalSettings(getDefaultValues(retrievalItems)); retrievalItems.length > 0 ? getDefaultValues(retrievalItems) : {},
} else { );
setRetrievalSettings({}); },
} [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>) => { const onSubmit = async (data: z.infer<typeof formSchema>) => {
// Validate dynamic forms before submission // Validate dynamic forms before submission
@@ -309,32 +321,6 @@ export default function KBForm({
[selectedEngine?.retrieval_schema], [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 ( return (
<Form {...form}> <Form {...form}>
<form <form
@@ -425,66 +411,18 @@ export default function KBForm({
<span className="text-destructive">*</span> <span className="text-destructive">*</span>
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Select <KnowledgeEngineSelect
disabled={isEditing} engines={ragEngines}
onValueChange={(value) => {
field.onChange(value);
handleEngineChange(value);
}}
value={field.value} value={field.value}
> disabled={isEditing}
<SelectTrigger className="w-full bg-[#ffffff] dark:bg-[#2a2a2e]"> loading={loading}
{field.value ? ( installScope="knowledge-base-create"
(() => { onValueChange={handleEngineChange}
const [author, name] = field.value.split('/'); onInstalled={handleEngineInstalled}
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>
</FormControl> </FormControl>
{selectedEngine?.description && ( {selectedEngine?.description && (
<FormDescription> <FormDescription className="max-w-[28rem] leading-5">
{extractI18nObject(selectedEngine.description)} {extractI18nObject(selectedEngine.description)}
</FormDescription> </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( const applyInstalledRunner = useCallback(
(installed: InstalledAgentRunner) => { (installed: InstalledAgentRunner) => {
setAIConfigTabSchema(installed.configTab); setAIConfigTabSchema(installed.configTab);
const currentAI = (form.getValues('ai') || {}) as Record<string, any>;
const currentRunner =
currentAI.runner && typeof currentAI.runner === 'object'
? currentAI.runner
: {};
const currentConfigs =
currentAI.runner_config && typeof currentAI.runner_config === 'object'
? currentAI.runner_config
: {};
const runnerName = installed.runner.name;
const runnerStage = installed.configTab.stages.find(
(stage) => stage.name === runnerName,
);
form.setValue('ai', {
...currentAI,
runner: { ...currentRunner, id: runnerName },
runner_config: {
...currentConfigs,
...(runnerStage && !(runnerName in currentConfigs)
? { [runnerName]: getDefaultValues(runnerStage.config) }
: {}),
},
});
}, },
[form], [],
); );
const dynamicFormSystemContext = useMemo( const dynamicFormSystemContext = useMemo(
() => ({ pipeline_id: pipelineId }), () => ({ pipeline_id: pipelineId }),
@@ -340,7 +317,7 @@ const PipelineFormComponent = forwardRef<
if (cancelled || !installed) return; if (cancelled || !installed) return;
applyInstalledRunner(installed); applyInstalledRunner(installed);
toast.success( toast.success(
t('wizard.aiEngine.installSuccess', { t('agents.runnerInstallSuccess', {
runner: extractI18nObject(installed.runner.label), runner: extractI18nObject(installed.runner.label),
}), }),
); );
@@ -15,6 +15,9 @@ import {
CheckCircle2, CheckCircle2,
XCircle, XCircle,
Loader2, Loader2,
RefreshCcw,
ShieldCheck,
Rocket,
} from 'lucide-react'; } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
@@ -24,25 +27,51 @@ import {
} from './PluginInstallTaskContext'; } from './PluginInstallTaskContext';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
const STAGES: { type StageConfig = {
key: InstallStage; key: InstallStage;
icon: React.ElementType; icon: React.ElementType;
i18nKey: string; 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 { function getStages(task: PluginInstallTask): StageConfig[] {
const idx = STAGES.findIndex((s) => s.key === stage); 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; return idx >= 0 ? idx : -1;
} }
@@ -169,9 +198,13 @@ function formatSpeed(bytesPerSec: number): string {
function TaskProgressContent({ task }: { task: PluginInstallTask }) { function TaskProgressContent({ task }: { task: PluginInstallTask }) {
const { t } = useTranslation(); const { t } = useTranslation();
const currentStageIndex = getStageIndex(task.stage); const stages = getStages(task);
const isDone = task.stage === InstallStage.DONE; const isDone = task.stage === InstallStage.DONE;
const isError = task.stage === InstallStage.ERROR; 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; // MCP / Skill don't have the plugin's download + dependency-install stages;
// show a single "installing → done/failed" row instead of plugin steps. // show a single "installing → done/failed" row instead of plugin steps.
@@ -334,43 +367,22 @@ function TaskProgressContent({ task }: { task: PluginInstallTask }) {
isError={isError} isError={isError}
detail={isError ? task.error : undefined} detail={isError ? task.error : undefined}
/> />
) : isDone ? ( ) : (
/* When done: show all stages with completed style */ stages.map((stageConfig, index) => (
STAGES.map((stageConfig) => (
<StageRow <StageRow
key={stageConfig.key} key={stageConfig.key}
icon={stageConfig.icon} icon={stageConfig.icon}
label={t(stageConfig.i18nKey)} label={t(stageConfig.i18nKey)}
isActive={false} isActive={!isDone && index === currentStageIndex}
isCompleted={true} isCompleted={isDone || index < currentStageIndex}
isError={false} isError={isError && index === currentStageIndex}
detail={getStageDetail(stageConfig.key, true)} 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> </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"> <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" /> <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"> <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> </span>
</div> </div>
)} )}
@@ -402,6 +416,8 @@ export default function PluginInstallProgressDialog() {
usePluginInstallTasks(); usePluginInstallTasks();
const selectedTask = tasks.find((t) => t.id === selectedTaskId) || null; const selectedTask = tasks.find((t) => t.id === selectedTaskId) || null;
const TitleIcon =
selectedTask?.operation === 'upgrade' ? RefreshCcw : Download;
const open = !!selectedTask; const open = !!selectedTask;
const handleClose = () => { const handleClose = () => {
@@ -428,12 +444,16 @@ export default function PluginInstallProgressDialog() {
> >
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-start gap-3"> <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"> <span className="break-words">
{selectedTask {selectedTask
? t('plugins.installProgress.title', { ? selectedTask.operation === 'upgrade'
name: selectedTask.pluginName, ? t('plugins.installProgress.updateTitle', {
}) name: selectedTask.pluginName,
})
: t('plugins.installProgress.title', {
name: selectedTask.pluginName,
})
: t('plugins.installProgress.titleGeneric')} : t('plugins.installProgress.titleGeneric')}
</span> </span>
</DialogTitle> </DialogTitle>
@@ -13,20 +13,25 @@ import { AsyncTask } from '@/app/infra/entities/api';
* Installation stages mapped from backend current_action strings. * Installation stages mapped from backend current_action strings.
*/ */
export enum InstallStage { export enum InstallStage {
CHECKING = 'checking',
DOWNLOADING = 'downloading', DOWNLOADING = 'downloading',
VALIDATING = 'validating',
INSTALLING_DEPS = 'installing_deps', INSTALLING_DEPS = 'installing_deps',
INITIALIZING = 'initializing', ACTIVATING = 'activating',
LAUNCHING = 'launching',
DONE = 'done', DONE = 'done',
ERROR = 'error', ERROR = 'error',
} }
export type PluginTaskOperation = 'install' | 'upgrade';
export interface PluginInstallTask { export interface PluginInstallTask {
id: string; // unique key: `${source}-${taskId}` id: string; // unique key: `${source}-${taskId}`
taskId: number; // backend async task id taskId: number; // backend async task id
pluginName: string; // display name pluginName: string; // display name
source: 'github' | 'marketplace' | 'local'; source: 'github' | 'marketplace' | 'local';
operation: PluginTaskOperation;
stage: InstallStage; stage: InstallStage;
failedStage?: InstallStage;
overallProgress: number; // 0-100 overallProgress: number; // 0-100
extensionType: 'plugin' | 'mcp' | 'skill'; // type of extension being installed extensionType: 'plugin' | 'mcp' | 'skill'; // type of extension being installed
fileSize?: number; // bytes, if known fileSize?: number; // bytes, if known
@@ -50,6 +55,7 @@ type OnTaskCompleteCallback = (
taskId: number, taskId: number,
success: boolean, success: boolean,
error?: string, error?: string,
operation?: PluginTaskOperation,
) => void; ) => void;
interface PluginInstallTaskContextValue { interface PluginInstallTaskContextValue {
@@ -60,6 +66,7 @@ interface PluginInstallTaskContextValue {
source: 'github' | 'marketplace' | 'local'; source: 'github' | 'marketplace' | 'local';
extensionType: 'plugin' | 'mcp' | 'skill'; extensionType: 'plugin' | 'mcp' | 'skill';
fileSize?: number; fileSize?: number;
operation?: PluginTaskOperation;
}) => void; }) => void;
removeTask: (id: string) => void; removeTask: (id: string) => void;
clearCompletedTasks: () => void; clearCompletedTasks: () => void;
@@ -89,13 +96,30 @@ export function usePluginInstallTasks() {
function mapActionToStage(action: string): InstallStage { function mapActionToStage(action: string): InstallStage {
if (!action) return InstallStage.DOWNLOADING; if (!action) return InstallStage.DOWNLOADING;
const lower = action.toLowerCase(); const lower = action.toLowerCase();
if (lower.includes('check')) return InstallStage.CHECKING;
if (lower.includes('download')) return InstallStage.DOWNLOADING; if (lower.includes('download')) return InstallStage.DOWNLOADING;
if (lower.includes('validat') || lower.includes('inspect'))
return InstallStage.VALIDATING;
if (lower.includes('dependencies') || lower.includes('requirements')) if (lower.includes('dependencies') || lower.includes('requirements'))
return InstallStage.INSTALLING_DEPS; 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; return InstallStage.INSTALLING_DEPS;
if (lower.includes('launch')) return InstallStage.INSTALLING_DEPS; if (
if (lower.includes('installed') || lower.includes('complete')) 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.DONE;
return InstallStage.DOWNLOADING; return InstallStage.DOWNLOADING;
} }
@@ -105,13 +129,15 @@ function mapActionToStage(action: string): InstallStage {
*/ */
function stageToProgress(stage: InstallStage): number { function stageToProgress(stage: InstallStage): number {
switch (stage) { switch (stage) {
case InstallStage.CHECKING:
return 5;
case InstallStage.DOWNLOADING: case InstallStage.DOWNLOADING:
return 10; return 18;
case InstallStage.VALIDATING:
return 35;
case InstallStage.INSTALLING_DEPS: case InstallStage.INSTALLING_DEPS:
return 70; return 60;
case InstallStage.INITIALIZING: case InstallStage.ACTIVATING:
return 70;
case InstallStage.LAUNCHING:
return 85; return 85;
case InstallStage.DONE: case InstallStage.DONE:
return 100; return 100;
@@ -130,15 +156,27 @@ function extractSourceFromName(
): 'github' | 'marketplace' | 'local' { ): 'github' | 'marketplace' | 'local' {
if (name.includes('github')) return 'github'; if (name.includes('github')) return 'github';
if (name.includes('marketplace')) return 'marketplace'; if (name.includes('marketplace')) return 'marketplace';
if (name.startsWith('plugin-upgrade-')) return 'marketplace';
return 'local'; 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. * Check if a backend task name is a plugin install task.
*/ */
function isPluginInstallTask(name: string): boolean { function isPluginInstallTask(name: string): boolean {
return ( return (
name.startsWith('plugin-install-') || name.startsWith('plugin-install-') ||
name.startsWith('plugin-upgrade-') ||
name.startsWith('mcp-install-') || name.startsWith('mcp-install-') ||
name.startsWith('skill-install-') name.startsWith('skill-install-')
); );
@@ -151,6 +189,10 @@ function asyncTaskToPluginInstallTask(task: AsyncTask): PluginInstallTask {
const source = extractSourceFromName(task.name); const source = extractSourceFromName(task.name);
const md = (task.task_context?.metadata ?? {}) as Record<string, unknown>; const md = (task.task_context?.metadata ?? {}) as Record<string, unknown>;
const action = task.task_context?.current_action || ''; 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 done = task.runtime.done;
const exception = task.runtime.exception; const exception = task.runtime.exception;
@@ -160,11 +202,13 @@ function asyncTaskToPluginInstallTask(task: AsyncTask): PluginInstallTask {
let stage: InstallStage; let stage: InstallStage;
let overallProgress: number; let overallProgress: number;
let error: string | undefined; let error: string | undefined;
let failedStage: InstallStage | undefined;
if (done) { if (done) {
if (exception) { if (exception) {
stage = InstallStage.ERROR; stage = InstallStage.ERROR;
overallProgress = 0; failedStage = mapActionToStage(action);
overallProgress = stageToProgress(failedStage);
error = exception; error = exception;
} else { } else {
stage = InstallStage.DONE; stage = InstallStage.DONE;
@@ -172,7 +216,10 @@ function asyncTaskToPluginInstallTask(task: AsyncTask): PluginInstallTask {
} }
} else { } else {
stage = mapActionToStage(action); 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`; const pluginName = str(md.plugin_name) || task.label || `${source} extension`;
@@ -185,12 +232,14 @@ function asyncTaskToPluginInstallTask(task: AsyncTask): PluginInstallTask {
} }
return { return {
id: `${source}-${task.id}`, id: pluginTaskKey(task.id, source, operation),
taskId: task.id, taskId: task.id,
pluginName, pluginName,
source, source,
operation,
extensionType, extensionType,
stage, stage,
failedStage,
overallProgress, overallProgress,
downloadCurrent: num(md.download_current), downloadCurrent: num(md.download_current),
downloadTotal: num(md.download_total), downloadTotal: num(md.download_total),
@@ -202,7 +251,7 @@ function asyncTaskToPluginInstallTask(task: AsyncTask): PluginInstallTask {
depsDownloadedSize: num(md.deps_downloaded_size), depsDownloadedSize: num(md.deps_downloaded_size),
depsSpeed: num(md.deps_speed), depsSpeed: num(md.deps_speed),
error, error,
startedAt: Date.now(), startedAt: task.created_at ? task.created_at * 1000 : Date.now(),
currentAction: action, currentAction: action,
}; };
} }
@@ -244,11 +293,16 @@ export function PluginInstallTaskProvider({
}, []); }, []);
const notifyTaskComplete = useCallback( const notifyTaskComplete = useCallback(
(taskId: number, success: boolean, error?: string) => { (
taskId: number,
success: boolean,
error?: string,
operation?: PluginTaskOperation,
) => {
if (notifiedTaskIds.current.has(taskId)) return; if (notifiedTaskIds.current.has(taskId)) return;
notifiedTaskIds.current.add(taskId); notifiedTaskIds.current.add(taskId);
onTaskCompleteCallbacks.current.forEach((cb) => { 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 currentDep = str(md.current_dep);
const depsDownloadedSize = num(md.deps_downloaded_size); const depsDownloadedSize = num(md.deps_downloaded_size);
const depsSpeed = num(md.deps_speed); const depsSpeed = num(md.deps_speed);
const reportedProgress = num(md.progress_percent);
setTasks((prev) => setTasks((prev) =>
prev.map((t) => { prev.map((t) => {
@@ -311,18 +366,21 @@ export function PluginInstallTaskProvider({
} }
if (exception) { if (exception) {
notifyTaskComplete(taskId, false, exception); notifyTaskComplete(taskId, false, exception, t.operation);
return { return {
...t, ...t,
stage: InstallStage.ERROR, stage: InstallStage.ERROR,
failedStage: mapActionToStage(action),
error: exception, error: exception,
overallProgress: 0, overallProgress: stageToProgress(
mapActionToStage(action),
),
currentAction: action, currentAction: action,
...progressFields, ...progressFields,
}; };
} }
notifyTaskComplete(taskId, true); notifyTaskComplete(taskId, true, undefined, t.operation);
return { return {
...t, ...t,
stage: InstallStage.DONE, stage: InstallStage.DONE,
@@ -342,7 +400,7 @@ export function PluginInstallTaskProvider({
); );
const progress = Math.min( const progress = Math.min(
95, 95,
baseProgress + withinStageIncrement, reportedProgress ?? baseProgress + withinStageIncrement,
); );
return { return {
@@ -458,8 +516,10 @@ export function PluginInstallTaskProvider({
source: 'github' | 'marketplace' | 'local'; source: 'github' | 'marketplace' | 'local';
extensionType: 'plugin' | 'mcp' | 'skill'; extensionType: 'plugin' | 'mcp' | 'skill';
fileSize?: number; 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 // Remove from dismissed set if re-added
dismissedTaskIds.current.delete(params.taskId); dismissedTaskIds.current.delete(params.taskId);
@@ -469,9 +529,13 @@ export function PluginInstallTaskProvider({
taskId: params.taskId, taskId: params.taskId,
pluginName: params.pluginName, pluginName: params.pluginName,
source: params.source, source: params.source,
operation,
extensionType: params.extensionType, extensionType: params.extensionType,
stage: InstallStage.DOWNLOADING, stage:
overallProgress: 5, operation === 'upgrade'
? InstallStage.CHECKING
: InstallStage.DOWNLOADING,
overallProgress: operation === 'upgrade' ? 3 : 5,
fileSize: params.fileSize, fileSize: params.fileSize,
startedAt: Date.now(), startedAt: Date.now(),
currentAction: '', currentAction: '',
@@ -12,6 +12,9 @@ import {
Puzzle, Puzzle,
Server, Server,
Sparkles, Sparkles,
RefreshCcw,
ShieldCheck,
Rocket,
} from 'lucide-react'; } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
@@ -28,8 +31,11 @@ import {
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
const STAGE_ICONS: Record<string, React.ElementType> = { const STAGE_ICONS: Record<string, React.ElementType> = {
[InstallStage.CHECKING]: RefreshCcw,
[InstallStage.DOWNLOADING]: Download, [InstallStage.DOWNLOADING]: Download,
[InstallStage.VALIDATING]: ShieldCheck,
[InstallStage.INSTALLING_DEPS]: Package, [InstallStage.INSTALLING_DEPS]: Package,
[InstallStage.ACTIVATING]: Rocket,
[InstallStage.DONE]: CheckCircle2, [InstallStage.DONE]: CheckCircle2,
[InstallStage.ERROR]: XCircle, [InstallStage.ERROR]: XCircle,
}; };
@@ -79,6 +85,9 @@ function TaskQueueItem({
}; };
const getInstallCompleteMessage = () => { const getInstallCompleteMessage = () => {
if (task.operation === 'upgrade') {
return t('plugins.installProgress.updateComplete');
}
switch (task.extensionType) { switch (task.extensionType) {
case 'mcp': case 'mcp':
return t('plugins.installProgress.installCompleteMCP'); return t('plugins.installProgress.installCompleteMCP');
@@ -91,10 +100,18 @@ function TaskQueueItem({
const stageLabel = (() => { const stageLabel = (() => {
switch (task.stage) { switch (task.stage) {
case InstallStage.CHECKING:
return t('plugins.installProgress.checkingUpdate');
case InstallStage.DOWNLOADING: case InstallStage.DOWNLOADING:
return t('plugins.installProgress.downloading'); return t('plugins.installProgress.downloading');
case InstallStage.VALIDATING:
return t('plugins.installProgress.validating');
case InstallStage.INSTALLING_DEPS: 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: case InstallStage.DONE:
return isDone return isDone
? getInstallCompleteMessage() ? getInstallCompleteMessage()
@@ -2,7 +2,11 @@ export {
PluginInstallTaskProvider, PluginInstallTaskProvider,
usePluginInstallTasks, usePluginInstallTasks,
InstallStage, InstallStage,
pluginTaskKey,
} from './PluginInstallTaskContext';
export type {
PluginInstallTask,
PluginTaskOperation,
} from './PluginInstallTaskContext'; } from './PluginInstallTaskContext';
export type { PluginInstallTask } from './PluginInstallTaskContext';
export { default as PluginInstallProgressDialog } from './PluginInstallProgressDialog'; export { default as PluginInstallProgressDialog } from './PluginInstallProgressDialog';
export { default as PluginInstallTaskQueue } from './PluginInstallTaskQueue'; export { default as PluginInstallTaskQueue } from './PluginInstallTaskQueue';
@@ -22,6 +22,10 @@ import { toast } from 'sonner';
import { useAsyncTask, AsyncTaskStatus } from '@/hooks/useAsyncTask'; import { useAsyncTask, AsyncTaskStatus } from '@/hooks/useAsyncTask';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext'; import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import { Loader2, Puzzle, Server, Sparkles } from 'lucide-react'; import { Loader2, Puzzle, Server, Sparkles } from 'lucide-react';
import {
pluginTaskKey,
usePluginInstallTasks,
} from '@/app/home/plugins/components/plugin-install-task';
export interface PluginInstalledComponentRef { export interface PluginInstalledComponentRef {
refreshPluginList: () => void; refreshPluginList: () => void;
@@ -68,6 +72,7 @@ const PluginInstalledComponent = forwardRef<
>(({ filterType, groupByType }, ref) => { >(({ filterType, groupByType }, ref) => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const { addTask, setSelectedTaskId } = usePluginInstallTasks();
const { refreshPlugins, refreshMCPServers, refreshSkills } = useSidebarData(); const { refreshPlugins, refreshMCPServers, refreshSkills } = useSidebarData();
const [extensionList, setExtensionList] = useState<ExtensionCardVO[]>([]); const [extensionList, setExtensionList] = useState<ExtensionCardVO[]>([]);
const [loading, setLoading] = useState<boolean>(true); const [loading, setLoading] = useState<boolean>(true);
@@ -81,11 +86,7 @@ const PluginInstalledComponent = forwardRef<
const asyncTask = useAsyncTask({ const asyncTask = useAsyncTask({
onSuccess: () => { onSuccess: () => {
const successMessage = toast.success(t('plugins.deleteSuccess'));
operationType === ExtensionOperationType.DELETE
? t('plugins.deleteSuccess')
: t('plugins.updateSuccess');
toast.success(successMessage);
setShowOperationModal(false); setShowOperationModal(false);
getExtensionList(); getExtensionList();
refreshPlugins(); refreshPlugins();
@@ -282,28 +283,37 @@ const PluginInstalledComponent = forwardRef<
return; return;
} }
const apiCall = if (operationType === ExtensionOperationType.UPDATE) {
operationType === ExtensionOperationType.DELETE httpClient
? httpClient.removePlugin( .upgradePlugin(targetExtension.author, targetExtension.name)
targetExtension.author, .then((res) => {
targetExtension.name, addTask({
deleteData, taskId: res.task_id,
) pluginName: `${targetExtension.author}/${targetExtension.name}`,
: httpClient.upgradePlugin( source: 'marketplace',
targetExtension.author, extensionType: 'plugin',
targetExtension.name, 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) => { .then((res) => {
asyncTask.startTask(res.task_id); asyncTask.startTask(res.task_id);
}) })
.catch((error) => { .catch((error) => {
const errorMessage = toast.error(t('plugins.deleteError') + error.message);
operationType === ExtensionOperationType.DELETE
? t('plugins.deleteError') + error.message
: t('plugins.updateError') + error.message;
toast.error(errorMessage);
}); });
} }
+11 -2
View File
@@ -83,9 +83,18 @@ function PluginListView() {
}, [t]); }, [t]);
useEffect(() => { useEffect(() => {
const onComplete = (_taskId: number, success: boolean, error?: string) => { const onComplete = (
_taskId: number,
success: boolean,
error?: string,
operation?: 'install' | 'upgrade',
) => {
if (success) { if (success) {
toast.success(t('plugins.installSuccess')); toast.success(
operation === 'upgrade'
? t('plugins.updateSuccess')
: t('plugins.installSuccess'),
);
pluginInstalledRef.current?.refreshPluginList(); pluginInstalledRef.current?.refreshPluginList();
refreshPlugins(); refreshPlugins();
} else { } else {
+1
View File
@@ -549,6 +549,7 @@ export interface AsyncTaskTaskContext {
export interface AsyncTask { export interface AsyncTask {
id: number; id: number;
created_at?: number;
kind: string; kind: string;
name: string; name: string;
label: string; label: string;
+26 -2
View File
@@ -777,6 +777,7 @@ const enUS = {
'Restoring the AgentRunner plugin installation and waiting for the runner…', 'Restoring the AgentRunner plugin installation and waiting for the runner…',
noInstalledRunners: 'No AgentRunner extension is installed yet.', noInstalledRunners: 'No AgentRunner extension is installed yet.',
installingRunner: 'Installing {{runner}}...', installingRunner: 'Installing {{runner}}...',
runnerInstallSuccess: '{{runner}} is installed and ready to select',
selectedRunnerUnavailable: 'Selected runner is unavailable', selectedRunnerUnavailable: 'Selected runner is unavailable',
selectedRunnerUnavailableDescription: selectedRunnerUnavailableDescription:
'{{runner}} is not currently registered. Select another runner or restore its extension.', '{{runner}} is not currently registered. Select another runner or restore its extension.',
@@ -1017,13 +1018,18 @@ const enUS = {
goToMarketplace: 'Go to Extension Market', goToMarketplace: 'Go to Extension Market',
installProgress: { installProgress: {
title: 'Installing {{name}}', title: 'Installing {{name}}',
updateTitle: 'Updating {{name}}',
titleGeneric: 'Extension Installation', titleGeneric: 'Extension Installation',
titlePlugin: 'Installing Plugin {{name}}', titlePlugin: 'Installing Plugin {{name}}',
titleMCP: 'Installing MCP Server {{name}}', titleMCP: 'Installing MCP Server {{name}}',
titleSkill: 'Installing Skill {{name}}', titleSkill: 'Installing Skill {{name}}',
overallProgress: 'Overall Progress', overallProgress: 'Overall Progress',
checkingUpdate: 'Checking for updates',
downloading: 'Downloading', downloading: 'Downloading',
validating: 'Validating package',
installingDeps: 'Installing Dependencies', installingDeps: 'Installing Dependencies',
applyingUpdate: 'Applying update',
activating: 'Starting and refreshing components',
initializing: 'Initializing Settings', initializing: 'Initializing Settings',
launching: 'Launching', launching: 'Launching',
completed: 'Completed', completed: 'Completed',
@@ -1033,14 +1039,15 @@ const enUS = {
depsProgress: depsProgress:
'{{installed}}/{{total}} installed · {{remaining}} remaining', '{{installed}}/{{total}} installed · {{remaining}} remaining',
installComplete: 'Installation successful', installComplete: 'Installation successful',
updateComplete: 'Plugin updated successfully',
installCompletePlugin: 'Plugin installed successfully', installCompletePlugin: 'Plugin installed successfully',
installCompleteMCP: 'MCP Server installed successfully', installCompleteMCP: 'MCP Server installed successfully',
installCompleteSkill: 'Skill installed successfully', installCompleteSkill: 'Skill installed successfully',
dismiss: 'Dismiss', dismiss: 'Dismiss',
background: 'Run in Background', background: 'Run in Background',
taskQueue: 'Install Tasks', taskQueue: 'Plugin Tasks',
clearCompleted: 'Clear Completed', clearCompleted: 'Clear Completed',
noTasks: 'No install tasks', noTasks: 'No plugin tasks',
}, },
}, },
market: { market: {
@@ -1512,6 +1519,23 @@ const enUS = {
knowledgeEngine: 'Knowledge Engine', knowledgeEngine: 'Knowledge Engine',
knowledgeEngineRequired: 'Knowledge engine is required', knowledgeEngineRequired: 'Knowledge engine is required',
selectKnowledgeEngine: 'Select Knowledge Engine', 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', builtInEngine: 'Built-in Engine',
cannotChangeKnowledgeEngine: cannotChangeKnowledgeEngine:
'Knowledge engine cannot be changed after creation', 'Knowledge engine cannot be changed after creation',
+31 -2
View File
@@ -790,6 +790,8 @@ const jaJP = {
noInstalledRunners: noInstalledRunners:
'AgentRunner 拡張機能はまだインストールされていません。', 'AgentRunner 拡張機能はまだインストールされていません。',
installingRunner: '{{runner}} をインストールしています...', installingRunner: '{{runner}} をインストールしています...',
runnerInstallSuccess:
'{{runner}} をインストールしました。インストール済み一覧から選択できます',
selectedRunnerUnavailable: '選択した Runner は利用できません', selectedRunnerUnavailable: '選択した Runner は利用できません',
selectedRunnerUnavailableDescription: selectedRunnerUnavailableDescription:
'{{runner}} は現在登録されていません。別の Runner を選択するか、対応する拡張機能を復元してください。', '{{runner}} は現在登録されていません。別の Runner を選択するか、対応する拡張機能を復元してください。',
@@ -982,10 +984,15 @@ const jaJP = {
goToMarketplace: 'マーケットプレイスへ', goToMarketplace: 'マーケットプレイスへ',
installProgress: { installProgress: {
title: '{{name}} をインストール中', title: '{{name}} をインストール中',
updateTitle: '{{name}} を更新中',
titleGeneric: 'プラグインのインストール', titleGeneric: 'プラグインのインストール',
overallProgress: '全体の進捗', overallProgress: '全体の進捗',
checkingUpdate: '最新バージョンを確認中',
downloading: 'プラグインをダウンロード中', downloading: 'プラグインをダウンロード中',
validating: 'プラグインパッケージを検証中',
installingDeps: '依存関係をインストール中', installingDeps: '依存関係をインストール中',
applyingUpdate: 'プラグインの更新を適用中',
activating: 'コンポーネントを起動・更新中',
initializing: '設定を初期化中', initializing: '設定を初期化中',
launching: 'プラグインを起動中', launching: 'プラグインを起動中',
completed: '完了', completed: '完了',
@@ -995,11 +1002,12 @@ const jaJP = {
depsProgress: depsProgress:
'{{installed}}/{{total}} インストール済み · 残り {{remaining}} 個', '{{installed}}/{{total}} インストール済み · 残り {{remaining}} 個',
installComplete: 'プラグインのインストール完了', installComplete: 'プラグインのインストール完了',
updateComplete: 'プラグインの更新が完了しました',
dismiss: '閉じる', dismiss: '閉じる',
background: 'バックグラウンドで実行', background: 'バックグラウンドで実行',
taskQueue: 'インストールタスク', taskQueue: 'プラグインタスク',
clearCompleted: '完了を消去', clearCompleted: '完了を消去',
noTasks: 'インストールタスクはありません', noTasks: 'プラグインタスクはありません',
titlePlugin: 'プラグイン {{name}} をインストール中', titlePlugin: 'プラグイン {{name}} をインストール中',
titleMCP: 'MCP サーバー {{name}} をインストール中', titleMCP: 'MCP サーバー {{name}} をインストール中',
titleSkill: 'スキル {{name}} をインストール中', titleSkill: 'スキル {{name}} をインストール中',
@@ -1495,6 +1503,27 @@ const jaJP = {
knowledgeEngine: 'ナレッジエンジン', knowledgeEngine: 'ナレッジエンジン',
knowledgeEngineRequired: 'ナレッジエンジンは必須です', knowledgeEngineRequired: 'ナレッジエンジンは必須です',
selectKnowledgeEngine: 'ナレッジエンジンを選択', selectKnowledgeEngine: 'ナレッジエンジンを選択',
installedEngines: 'インストール済みのナレッジエンジン',
noInstalledEngines:
'ナレッジエンジンプラグインはまだインストールされていません。',
marketplaceEngines: 'マーケットプレイスのナレッジエンジンプラグイン',
noMarketplaceEngines:
'インストール可能なナレッジエンジンプラグインがありません。',
loadingEngineCatalog: 'マーケットプレイスを読み込み中…',
engineCatalogUnavailable:
'マーケットプレイスを利用できません。選択欄を開き直して再試行してください。',
viewMarketplace: '市場を見る',
installingEngine: '{{engine}} をインストール中…',
engineInstallSuccess:
'{{engine}} をインストールしました。インストール済み一覧から選択できます',
engineInstallFailed:
'ナレッジエンジンのインストールに失敗しました。再試行してください。',
engineVersionUnavailable:
'このプラグインにはインストール可能なバージョンがありません。',
engineInstallTimeout:
'インストールはまだ実行中です。ページを更新して確認してください。',
engineRegistrationTimeout:
'プラグインはインストール済みですが、ナレッジエンジンはまだ準備中です。',
builtInEngine: '組み込みエンジン', builtInEngine: '組み込みエンジン',
cannotChangeKnowledgeEngine: cannotChangeKnowledgeEngine:
'作成後にナレッジエンジンを変更することはできません', '作成後にナレッジエンジンを変更することはできません',
+22 -2
View File
@@ -744,6 +744,7 @@ const zhHans = {
restoringRunnerInstall: '正在恢复 AgentRunner 插件安装并等待运行器就绪…', restoringRunnerInstall: '正在恢复 AgentRunner 插件安装并等待运行器就绪…',
noInstalledRunners: '尚未安装任何 AgentRunner 扩展。', noInstalledRunners: '尚未安装任何 AgentRunner 扩展。',
installingRunner: '正在安装 {{runner}}...', installingRunner: '正在安装 {{runner}}...',
runnerInstallSuccess: '{{runner}} 已安装,可从已安装列表中选择',
selectedRunnerUnavailable: '所选运行器不可用', selectedRunnerUnavailable: '所选运行器不可用',
selectedRunnerUnavailableDescription: selectedRunnerUnavailableDescription:
'{{runner}} 当前未注册。请选择其他运行器,或恢复对应扩展。', '{{runner}} 当前未注册。请选择其他运行器,或恢复对应扩展。',
@@ -969,13 +970,18 @@ const zhHans = {
goToMarketplace: '前往扩展市场', goToMarketplace: '前往扩展市场',
installProgress: { installProgress: {
title: '正在安装 {{name}}', title: '正在安装 {{name}}',
updateTitle: '正在更新 {{name}}',
titleGeneric: '扩展安装', titleGeneric: '扩展安装',
titlePlugin: '正在安装插件 {{name}}', titlePlugin: '正在安装插件 {{name}}',
titleMCP: '正在安装 MCP 服务器 {{name}}', titleMCP: '正在安装 MCP 服务器 {{name}}',
titleSkill: '正在安装技能 {{name}}', titleSkill: '正在安装技能 {{name}}',
overallProgress: '总体进度', overallProgress: '总体进度',
checkingUpdate: '检查最新版本',
downloading: '下载中', downloading: '下载中',
validating: '校验插件包',
installingDeps: '安装依赖', installingDeps: '安装依赖',
applyingUpdate: '应用插件更新',
activating: '启动并刷新组件',
initializing: '初始化配置', initializing: '初始化配置',
launching: '启动中', launching: '启动中',
completed: '已完成', completed: '已完成',
@@ -984,14 +990,15 @@ const zhHans = {
depsInfo: '共 {{count}} 个依赖需要安装', depsInfo: '共 {{count}} 个依赖需要安装',
depsProgress: '已安装 {{installed}}/{{total}} · 剩余 {{remaining}} 个', depsProgress: '已安装 {{installed}}/{{total}} · 剩余 {{remaining}} 个',
installComplete: '安装成功', installComplete: '安装成功',
updateComplete: '插件更新成功',
installCompletePlugin: '插件安装成功', installCompletePlugin: '插件安装成功',
installCompleteMCP: 'MCP 服务器安装成功', installCompleteMCP: 'MCP 服务器安装成功',
installCompleteSkill: '技能安装成功', installCompleteSkill: '技能安装成功',
dismiss: '关闭', dismiss: '关闭',
background: '后台运行', background: '后台运行',
taskQueue: '安装任务', taskQueue: '插件任务',
clearCompleted: '清除已完成', clearCompleted: '清除已完成',
noTasks: '暂无安装任务', noTasks: '暂无插件任务',
}, },
}, },
market: { market: {
@@ -1445,6 +1452,19 @@ const zhHans = {
knowledgeEngine: '知识引擎', knowledgeEngine: '知识引擎',
knowledgeEngineRequired: '知识引擎不能为空', knowledgeEngineRequired: '知识引擎不能为空',
selectKnowledgeEngine: '选择知识引擎', selectKnowledgeEngine: '选择知识引擎',
installedEngines: '已安装的知识引擎',
noInstalledEngines: '尚未安装任何知识引擎插件。',
marketplaceEngines: '插件市场中的知识引擎插件',
noMarketplaceEngines: '暂无可安装的知识引擎插件。',
loadingEngineCatalog: '正在加载插件市场…',
engineCatalogUnavailable: '插件市场暂时不可用,重新打开选择器即可重试。',
viewMarketplace: '查看市场',
installingEngine: '正在安装 {{engine}}…',
engineInstallSuccess: '{{engine}} 已安装,可从已安装列表中选择',
engineInstallFailed: '知识引擎安装失败,请稍后重试。',
engineVersionUnavailable: '该插件没有可安装的版本。',
engineInstallTimeout: '安装仍未完成,请稍后刷新页面查看。',
engineRegistrationTimeout: '插件已安装,但知识引擎组件尚未就绪。',
builtInEngine: '内置引擎', builtInEngine: '内置引擎',
cannotChangeKnowledgeEngine: '知识库创建后不可修改知识引擎', cannotChangeKnowledgeEngine: '知识库创建后不可修改知识引擎',
basicInfo: '基础信息', 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\}/);
});