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