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