diff --git a/src/langbot/pkg/plugin/connector.py b/src/langbot/pkg/plugin/connector.py index 4b48ff4b5..92666801e 100644 --- a/src/langbot/pkg/plugin/connector.py +++ b/src/langbot/pkg/plugin/connector.py @@ -94,8 +94,10 @@ async def _read_httpx_response_limited( response: httpx.Response, *, max_bytes: int, + task_context: taskmgr.TaskContext | None = None, ) -> bytes: content_length = response.headers.get('content-length') + declared_size: int | None = None if content_length is not None: try: declared_size = int(content_length) @@ -104,11 +106,25 @@ async def _read_httpx_response_limited( if declared_size is not None and declared_size > max_bytes: raise ValueError(f'Remote response exceeds the {max_bytes}-byte limit') + if task_context is not None and declared_size is not None: + # Publish the advertised size up-front so the UI can render a + # determinate bar even before the first chunk arrives. + task_context.metadata['download_total'] = declared_size + + start_time = time.time() body = bytearray() async for chunk in response.aiter_bytes(chunk_size=64 * 1024): body.extend(chunk) if len(body) > max_bytes: raise ValueError(f'Remote response exceeds the {max_bytes}-byte limit') + if task_context is not None: + elapsed = time.time() - start_time + task_context.metadata.update( + { + 'download_current': len(body), + 'download_speed': len(body) / elapsed if elapsed > 0 else 0, + } + ) return bytes(body) @@ -118,6 +134,7 @@ async def _marketplace_get( *, max_bytes: int, allow_not_found: bool = False, + task_context: taskmgr.TaskContext | None = None, ) -> tuple[int, bytes]: async with client.stream('GET', url) as response: if allow_not_found and response.status_code == 404: @@ -126,6 +143,7 @@ async def _marketplace_get( return response.status_code, await _read_httpx_response_limited( response, max_bytes=max_bytes, + task_context=task_context, ) @@ -1711,6 +1729,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector): client, f'{space_url}/api/v1/marketplace/plugins/download/{plugin_author}/{plugin_name}/{version}', max_bytes=_MARKETPLACE_PLUGIN_DOWNLOAD_MAX_BYTES, + task_context=task_context, ) return plugin_package, version @@ -1765,7 +1784,21 @@ class PluginRuntimeConnector(ManagedRuntimeConnector): plugin_name = str(install_info.get('plugin_name') or '') file_bytes: bytes | None + if task_context is not None: + # Reset the per-install counters so re-installing the same plugin + # cannot inherit stale progress metadata from a previous task. + task_context.set_current_action('preparing plugin install') + task_context.metadata.update( + { + 'download_total': 0, + 'download_current': 0, + 'download_speed': 0, + } + ) + if install_source == PluginInstallSource.MARKETPLACE: + if task_context is not None: + task_context.set_current_action('downloading plugin package') file_bytes, version = await self._download_marketplace_package( execution_context, plugin_author, @@ -1791,6 +1824,8 @@ class PluginRuntimeConnector(ManagedRuntimeConnector): raise ValueError(f'Unsupported plugin install source: {install_source.value}') install_info, verified_certificate = self._admit_plugin_archive(file_bytes, install_info) + if task_context is not None: + task_context.set_current_action('inspecting plugin package') manifest_author, manifest_name = self._inspect_plugin_package(file_bytes, task_context) if not manifest_author or not manifest_name: raise ValueError('Plugin package manifest identity is missing') @@ -1802,8 +1837,12 @@ class PluginRuntimeConnector(ManagedRuntimeConnector): if task_context is not None: task_context.metadata['plugin_name'] = f'{plugin_author}/{plugin_name}' + if task_context is not None: + task_context.set_current_action('storing plugin package') artifact_digest = hashlib.sha256(file_bytes).hexdigest() await self._store_artifact_package(execution_context, artifact_digest, file_bytes) + if task_context is not None: + task_context.set_current_action('persisting the installation') try: binding, previous_digest, previous_was_durable = await self._persist_installation_package( execution_context, @@ -1824,6 +1863,13 @@ class PluginRuntimeConnector(ManagedRuntimeConnector): certification_facts = verified_certificate.for_installation(binding.installation_uuid) if certification_facts.artifact_digest != install_info['_certification']['normalized_digest']: raise RuntimeError('Plugin certification digest changed before Runtime apply') + if task_context is not None: + # The runtime installs the plugin's dependencies and starts it + # inside apply_plugin_installation. It does not stream + # per-dependency progress back to this task context, so this stage + # deliberately stays coarse instead of claiming a separate, + # unobservable "installing dependencies" step. + task_context.set_current_action('installing or starting plugin') await self._apply_desired_state( PluginInstallationDesiredState(binding=binding, enabled=True), artifact_package=file_bytes, @@ -1841,6 +1887,8 @@ class PluginRuntimeConnector(ManagedRuntimeConnector): pass except Exception as exc: self.ap.logger.debug(f'Legacy OSS plugin cleanup skipped: {exc}') + if task_context is not None: + task_context.set_current_action('waiting for plugin to become ready') await self._wait_for_installed_plugin_ready(plugin_author, plugin_name, task_context) async def upgrade_plugin( diff --git a/web/src/app/home/plugins/components/plugin-install-task/PluginInstallProgressDialog.tsx b/web/src/app/home/plugins/components/plugin-install-task/PluginInstallProgressDialog.tsx index 27c706ed2..def01d4bf 100644 --- a/web/src/app/home/plugins/components/plugin-install-task/PluginInstallProgressDialog.tsx +++ b/web/src/app/home/plugins/components/plugin-install-task/PluginInstallProgressDialog.tsx @@ -12,6 +12,7 @@ import { Package, Server, Sparkles, + Rocket, CheckCircle2, XCircle, Loader2, @@ -39,6 +40,11 @@ const STAGES: { icon: Package, i18nKey: 'plugins.installProgress.installingDeps', }, + { + key: InstallStage.LAUNCHING, + icon: Rocket, + i18nKey: 'plugins.installProgress.launching', + }, ]; function getStageIndex(stage: InstallStage): number { @@ -219,53 +225,9 @@ function TaskProgressContent({ task }: { task: PluginInstallTask }) { } if (stageKey === InstallStage.INSTALLING_DEPS) { - const total = task.depsTotal; - const installed = task.depsInstalled; - const remaining = task.depsRemaining; - const currentDep = task.currentDep; - const dlSize = task.depsDownloadedSize; - const speed = task.depsSpeed; - - if (isCompletedView && total != null) { - const parts: string[] = []; - parts.push(t('plugins.installProgress.depsInfo', { count: total })); - if (dlSize && dlSize > 0) { - parts.push(formatFileSize(dlSize)); - } - return parts.join(' · '); - } - - if (total != null && installed != null) { - const parts: string[] = []; - parts.push( - t('plugins.installProgress.depsProgress', { - installed, - total, - remaining: remaining ?? total - installed, - }), - ); - if (dlSize && dlSize > 0) { - parts.push(formatFileSize(dlSize)); - } - if (speed && speed > 0) { - parts.push(formatSpeed(speed)); - } - if (currentDep) { - return ( - <> - {parts.join(' · ')} -
- {currentDep} - - ); - } - return parts.join(' · '); - } - - if (total != null) { - return t('plugins.installProgress.depsInfo', { count: total }); - } - + // The runtime installs dependencies and starts the plugin in one step + // and does not report per-dependency progress, so this stage has no + // detail to show beyond its label. return undefined; } diff --git a/web/src/app/home/plugins/components/plugin-install-task/PluginInstallTaskContext.tsx b/web/src/app/home/plugins/components/plugin-install-task/PluginInstallTaskContext.tsx index 4120c9599..247b6d88a 100644 --- a/web/src/app/home/plugins/components/plugin-install-task/PluginInstallTaskContext.tsx +++ b/web/src/app/home/plugins/components/plugin-install-task/PluginInstallTaskContext.tsx @@ -8,18 +8,14 @@ import React, { } from 'react'; import { httpClient } from '@/app/infra/http/HttpClient'; import { AsyncTask } from '@/app/infra/entities/api'; +import { + InstallStage, + INSTALL_PROGRESS_CAP, + computeStageProgress, + mapActionToStage, +} from './install-progress'; -/** - * Installation stages mapped from backend current_action strings. - */ -export enum InstallStage { - DOWNLOADING = 'downloading', - INSTALLING_DEPS = 'installing_deps', - INITIALIZING = 'initializing', - LAUNCHING = 'launching', - DONE = 'done', - ERROR = 'error', -} +export { InstallStage } from './install-progress'; export interface PluginInstallTask { id: string; // unique key: `${source}-${taskId}` @@ -34,15 +30,10 @@ export interface PluginInstallTask { downloadCurrent?: number; // bytes downloaded so far downloadTotal?: number; // total bytes to download downloadSpeed?: number; // bytes per second - // Dependency progress - depsTotal?: number; // total dependency count - depsInstalled?: number; // deps installed so far - depsRemaining?: number; // remaining - currentDep?: string; // currently installing dep name - depsDownloadedSize?: number; // total bytes of downloaded deps - depsSpeed?: number; // deps download speed bytes/s error?: string; startedAt: number; // timestamp + /** When the current stage began, used to bound in-stage drift. */ + stageStartedAt: number; currentAction: string; // raw backend action string } @@ -83,45 +74,6 @@ export function usePluginInstallTasks() { return ctx; } -/** - * Map backend `current_action` to our InstallStage. - */ -function mapActionToStage(action: string): InstallStage { - if (!action) return InstallStage.DOWNLOADING; - const lower = action.toLowerCase(); - if (lower.includes('download')) return InstallStage.DOWNLOADING; - if (lower.includes('dependencies') || lower.includes('requirements')) - return InstallStage.INSTALLING_DEPS; - if (lower.includes('initializ') || lower.includes('setting')) - return InstallStage.INSTALLING_DEPS; - if (lower.includes('launch')) return InstallStage.INSTALLING_DEPS; - if (lower.includes('installed') || lower.includes('complete')) - return InstallStage.DONE; - return InstallStage.DOWNLOADING; -} - -/** - * Get overall progress percentage from a stage. - */ -function stageToProgress(stage: InstallStage): number { - switch (stage) { - case InstallStage.DOWNLOADING: - return 10; - case InstallStage.INSTALLING_DEPS: - return 70; - case InstallStage.INITIALIZING: - return 70; - case InstallStage.LAUNCHING: - return 85; - case InstallStage.DONE: - return 100; - case InstallStage.ERROR: - return 0; - default: - return 0; - } -} - /** * Extract install source from backend task name. */ @@ -172,7 +124,15 @@ function asyncTaskToPluginInstallTask(task: AsyncTask): PluginInstallTask { } } else { stage = mapActionToStage(action); - overallProgress = Math.min(95, stageToProgress(stage)); + overallProgress = Math.min( + INSTALL_PROGRESS_CAP, + computeStageProgress({ + stage, + downloadCurrent: num(md.download_current), + downloadTotal: num(md.download_total), + stageElapsedSeconds: 0, + }), + ); } const pluginName = str(md.plugin_name) || task.label || `${source} extension`; @@ -195,14 +155,9 @@ function asyncTaskToPluginInstallTask(task: AsyncTask): PluginInstallTask { downloadCurrent: num(md.download_current), downloadTotal: num(md.download_total), downloadSpeed: num(md.download_speed), - depsTotal: num(md.deps_total), - depsInstalled: num(md.deps_installed), - depsRemaining: num(md.deps_remaining), - currentDep: str(md.current_dep), - depsDownloadedSize: num(md.deps_downloaded_size), - depsSpeed: num(md.deps_speed), error, startedAt: Date.now(), + stageStartedAt: Date.now(), currentAction: action, }; } @@ -271,19 +226,13 @@ export function PluginInstallTaskProvider({ unknown >; - // Extract progress fields from metadata + // Download byte counts are the only measurable install progress + // the backend reports for this task. const num = (v: unknown) => (typeof v === 'number' ? v : undefined); - const str = (v: unknown) => (typeof v === 'string' ? v : undefined); const downloadCurrent = num(md.download_current); const downloadTotal = num(md.download_total); const downloadSpeed = num(md.download_speed); - const depsTotal = num(md.deps_total); - const depsInstalled = num(md.deps_installed); - const depsRemaining = num(md.deps_remaining); - const currentDep = str(md.current_dep); - const depsDownloadedSize = num(md.deps_downloaded_size); - const depsSpeed = num(md.deps_speed); setTasks((prev) => prev.map((t) => { @@ -293,13 +242,6 @@ export function PluginInstallTaskProvider({ downloadCurrent: downloadCurrent ?? t.downloadCurrent, downloadTotal: downloadTotal ?? t.downloadTotal, downloadSpeed: downloadSpeed ?? t.downloadSpeed, - depsTotal: depsTotal ?? t.depsTotal, - depsInstalled: depsInstalled ?? t.depsInstalled, - depsRemaining: depsRemaining ?? t.depsRemaining, - currentDep: currentDep ?? t.currentDep, - depsDownloadedSize: - depsDownloadedSize ?? t.depsDownloadedSize, - depsSpeed: depsSpeed ?? t.depsSpeed, }; if (done) { @@ -333,21 +275,28 @@ export function PluginInstallTaskProvider({ } const stage = mapActionToStage(action); - const baseProgress = stageToProgress(stage); - // Add small time-based increment within stage - const elapsed = (Date.now() - t.startedAt) / 1000; - const withinStageIncrement = Math.min( - 15, - Math.floor(elapsed / 2), - ); + // Reset the in-stage clock whenever the reported stage moves + // so drift reflects time spent in this stage, not the whole + // installation. + const stageChanged = stage !== t.stage; + const stageStartedAt = stageChanged + ? Date.now() + : t.stageStartedAt; + const stageProgress = computeStageProgress({ + stage, + downloadCurrent, + downloadTotal, + stageElapsedSeconds: (Date.now() - stageStartedAt) / 1000, + }); const progress = Math.min( - 95, - baseProgress + withinStageIncrement, + INSTALL_PROGRESS_CAP, + Math.max(t.overallProgress, stageProgress), ); return { ...t, stage, + stageStartedAt, overallProgress: progress, currentAction: action, ...progressFields, @@ -474,6 +423,7 @@ export function PluginInstallTaskProvider({ overallProgress: 5, fileSize: params.fileSize, startedAt: Date.now(), + stageStartedAt: Date.now(), currentAction: '', }; diff --git a/web/src/app/home/plugins/components/plugin-install-task/PluginInstallTaskQueue.tsx b/web/src/app/home/plugins/components/plugin-install-task/PluginInstallTaskQueue.tsx index d8c9e9869..e53436452 100644 --- a/web/src/app/home/plugins/components/plugin-install-task/PluginInstallTaskQueue.tsx +++ b/web/src/app/home/plugins/components/plugin-install-task/PluginInstallTaskQueue.tsx @@ -4,6 +4,7 @@ import { Progress } from '@/components/ui/progress'; import { Download, Package, + Rocket, CheckCircle2, XCircle, Loader2, @@ -30,6 +31,7 @@ import { cn } from '@/lib/utils'; const STAGE_ICONS: Record = { [InstallStage.DOWNLOADING]: Download, [InstallStage.INSTALLING_DEPS]: Package, + [InstallStage.LAUNCHING]: Rocket, [InstallStage.DONE]: CheckCircle2, [InstallStage.ERROR]: XCircle, }; @@ -95,6 +97,8 @@ function TaskQueueItem({ return t('plugins.installProgress.downloading'); case InstallStage.INSTALLING_DEPS: return t('plugins.installProgress.installingDeps'); + case InstallStage.LAUNCHING: + return t('plugins.installProgress.launching'); case InstallStage.DONE: return isDone ? getInstallCompleteMessage() diff --git a/web/src/app/home/plugins/components/plugin-install-task/install-progress.ts b/web/src/app/home/plugins/components/plugin-install-task/install-progress.ts new file mode 100644 index 000000000..42fdb95d9 --- /dev/null +++ b/web/src/app/home/plugins/components/plugin-install-task/install-progress.ts @@ -0,0 +1,135 @@ +/** + * Pure install-stage model shared by the install-task UI. + * + * Kept free of React imports so the mapping and progress maths can be + * exercised directly in unit tests. + */ + +/** + * Installation stages mapped from backend current_action strings. + */ +export enum InstallStage { + DOWNLOADING = 'downloading', + INSTALLING_DEPS = 'installing_deps', + INITIALIZING = 'initializing', + LAUNCHING = 'launching', + DONE = 'done', + ERROR = 'error', +} + +/** + * Map the backend `current_action` string to an InstallStage. + * + * The runtime connector emits human-readable stage strings; each branch here + * matches the wording produced by the connector so newly added stages show up + * in the UI without a protocol change. + */ +export function mapActionToStage(action: string): InstallStage { + const lower = (action || '').toLowerCase(); + + // Terminal wording first: "installed" would otherwise also match the + // in-progress "installing" branch below. + if (lower.includes('installed') || lower.includes('complete')) { + return InstallStage.DONE; + } + // "waiting for plugin to become ready" is the post-install readiness wait, + // checked before the stage branches so "ready" is not read as "done". + if (lower.includes('waiting') || lower.includes('ready')) { + return InstallStage.LAUNCHING; + } + // Pre-download wording, checked before the "install" branches because + // "preparing plugin install" also contains "install". + if (lower.includes('prepar') || lower.includes('checking')) { + return InstallStage.DOWNLOADING; + } + if (lower.includes('download')) return InstallStage.DOWNLOADING; + + // The runtime installs the plugin's dependencies and starts it in a single + // step ("installing or starting plugin"), and persisting the installation + // precedes it. None of these stream finer-grained progress, so they all map + // to one honest stage rather than pretending to be a separate dependency + // step. This is checked before the generic "launch"/"start" branch, which + // would otherwise catch the "...or starting..." wording. + if ( + lower.includes('installing') || + lower.includes('starting') || + lower.includes('persisting') || + lower.includes('storing') || + lower.includes('inspect') + ) { + return InstallStage.INSTALLING_DEPS; + } + if (lower.includes('launch')) return InstallStage.LAUNCHING; + if (lower.includes('initializ') || lower.includes('configur')) { + return InstallStage.INITIALIZING; + } + + return InstallStage.DOWNLOADING; +} + +/** + * Progress range (start → end) attributed to each stage, used to build a + * smooth determinate bar that never goes backwards. The ranges are contiguous + * and non-overlapping, so progress never has to move backwards when the stage + * advances. + */ +export const STAGE_PROGRESS_RANGE: Record = { + [InstallStage.DOWNLOADING]: [5, 45], + [InstallStage.INSTALLING_DEPS]: [45, 85], + [InstallStage.INITIALIZING]: [85, 88], + [InstallStage.LAUNCHING]: [88, 97], + [InstallStage.DONE]: [100, 100], + [InstallStage.ERROR]: [0, 0], +}; + +/** Progress never reaches 100 until the backend reports the task as done. */ +export const INSTALL_PROGRESS_CAP = 99; + +function clampToRange(value: number, start: number, end: number): number { + return Math.min(end, Math.max(start, value)); +} + +export interface StageProgressInput { + stage: InstallStage; + downloadCurrent?: number; + downloadTotal?: number; + /** Seconds spent in the current stage, used to bound fallback drift. */ + stageElapsedSeconds: number; +} + +/** + * Progress contributed by a single stage, always inside that stage's range. + * + * Real byte counts take priority: when the backend has reported a download + * size, the measured ratio is authoritative and no time-based drift is added + * on top of it. Drift is only a fallback for stages that report no measurable + * progress, and it is clamped to the current stage so it can never spill into + * a later stage's range. + */ +export function computeStageProgress(input: StageProgressInput): number { + const [start, end] = STAGE_PROGRESS_RANGE[input.stage] ?? [0, 0]; + + const hasMeasuredBytes = + input.stage === InstallStage.DOWNLOADING && + input.downloadTotal != null && + input.downloadTotal > 0 && + input.downloadCurrent != null; + + if (hasMeasuredBytes) { + const ratio = Math.min( + 1, + (input.downloadCurrent as number) / (input.downloadTotal as number), + ); + return clampToRange(Math.round(start + (end - start) * ratio), start, end); + } + + // Nothing measurable to show yet: drift slowly, but never past this stage's + // own ceiling (hence `end - start - 1`, leaving the final point to the real + // stage transition). + const maxDrift = Math.max(0, end - start - 1); + const drift = Math.min( + maxDrift, + Math.floor(Math.max(0, input.stageElapsedSeconds) / 2), + ); + return clampToRange(start + drift, start, end); +} diff --git a/web/src/app/home/plugins/components/plugin-installed/PluginInstalledComponent.tsx b/web/src/app/home/plugins/components/plugin-installed/PluginInstalledComponent.tsx index 4429f7958..95867c50b 100644 --- a/web/src/app/home/plugins/components/plugin-installed/PluginInstalledComponent.tsx +++ b/web/src/app/home/plugins/components/plugin-installed/PluginInstalledComponent.tsx @@ -21,7 +21,7 @@ import { extractI18nObject } from '@/i18n/I18nProvider'; import { toast } from 'sonner'; import { useAsyncTask, AsyncTaskStatus } from '@/hooks/useAsyncTask'; import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext'; -import { Loader2, Puzzle, Server, Sparkles } from 'lucide-react'; +import { Loader2, Puzzle, Search, Server, Sparkles } from 'lucide-react'; export interface PluginInstalledComponentRef { refreshPluginList: () => void; @@ -60,12 +60,16 @@ export const FilterOptions = [ interface PluginInstalledComponentProps { filterType: FilterType; groupByType: boolean; + /** Free-text filter over label / name / author / description. */ + searchQuery?: string; + /** Invoked when the user clears the search from the empty state. */ + onClearSearch?: () => void; } const PluginInstalledComponent = forwardRef< PluginInstalledComponentRef, PluginInstalledComponentProps ->(({ filterType, groupByType }, ref) => { +>(({ filterType, groupByType, searchQuery = '', onClearSearch }, ref) => { const { t } = useTranslation(); const navigate = useNavigate(); const { refreshPlugins, refreshMCPServers, refreshSkills } = useSidebarData(); @@ -307,11 +311,21 @@ const PluginInstalledComponent = forwardRef< }); } + // Match the query against the fields a user can actually see on the card + // (label / name / author) plus the description, case-insensitively. + const normalizedQuery = searchQuery.trim().toLowerCase(); const filteredExtensions = extensionList.filter((ext) => { - if (filterType === 'all') return true; - return ext.type === filterType; + if (filterType !== 'all' && ext.type !== filterType) return false; + if (!normalizedQuery) return true; + return [ext.label, ext.name, ext.author, ext.description].some((field) => + (field || '').toLowerCase().includes(normalizedQuery), + ); }); + const clearSearch = () => { + onClearSearch?.(); + }; + const showGrouped = groupByType && filterType === 'all'; const groupOrder: ExtensionType[] = ['plugin', 'mcp', 'skill']; const groupedExtensions = groupOrder @@ -461,10 +475,26 @@ const PluginInstalledComponent = forwardRef< ) : filteredExtensions.length === 0 ? (
- -
- {t('plugins.noExtensionInstalled')} -
+ {normalizedQuery ? ( + <> + +
+ {t('plugins.noMatchingExtensions', { + query: searchQuery.trim(), + })} +
+ + + ) : ( + <> + +
+ {t('plugins.noExtensionInstalled')} +
+ + )}
) : showGrouped ? (
diff --git a/web/src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx b/web/src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx index b13e13ff3..eca52de98 100644 --- a/web/src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx +++ b/web/src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx @@ -1,4 +1,11 @@ -import { useState, useEffect, useCallback, useRef, Suspense } from 'react'; +import { + useState, + useEffect, + useCallback, + useMemo, + useRef, + Suspense, +} from 'react'; import { useSearchParams } from 'react-router-dom'; import { Input } from '@/components/ui/input'; import { @@ -37,6 +44,8 @@ import { } from '@/components/ui/tooltip'; import PluginMarketCardComponent from './plugin-market-card/PluginMarketCardComponent'; import { PluginMarketCardVO } from './plugin-market-card/PluginMarketCardVO'; +import { resolveInstalledState } from './marketplace-installed'; +import { useMarketplaceInstalledIndex } from './useMarketplaceInstalledIndex'; import { RecommendationLists } from './RecommendationLists'; import type { RecommendationList } from './RecommendationLists'; import { @@ -122,6 +131,8 @@ function MarketPageContent({ const [recommendationLists, setRecommendationLists] = useState< RecommendationList[] >([]); + // Installed extensions from the sidebar; used to mark market cards. + const installedIndex = useMarketplaceInstalledIndex(); const [plugins, setPlugins] = useState([]); const [isLoading, setIsLoading] = useState(false); const [isLoadingMore, setIsLoadingMore] = useState(false); @@ -571,7 +582,27 @@ function MarketPageContent({ }; }, []); - const visiblePlugins = plugins; + // Annotate cards with installed state derived from the sidebar index. This is + // computed (rather than baked into `plugins`) so a finished install updates + // the badges as soon as the sidebar refreshes. + const visiblePlugins = useMemo( + () => + plugins.map((plugin) => { + const state = resolveInstalledState(installedIndex, plugin); + if ( + state.installed === plugin.installed && + state.hasUpdate === plugin.hasUpdate + ) { + return plugin; + } + return new PluginMarketCardVO({ + ...plugin, + installed: state.installed, + hasUpdate: state.hasUpdate, + }); + }), + [plugins, installedIndex], + ); // 加载更多 const loadMore = useCallback(() => { @@ -853,6 +884,7 @@ function MarketPageContent({ onInstall={handleInstallPlugin} installDisabled={installDisabled} installDisabledTooltip={installDisabledTooltip} + installedIndex={installedIndex} /> )} diff --git a/web/src/app/home/plugins/components/plugin-market/RecommendationLists.tsx b/web/src/app/home/plugins/components/plugin-market/RecommendationLists.tsx index 2eb5a22eb..6f8dbfe11 100644 --- a/web/src/app/home/plugins/components/plugin-market/RecommendationLists.tsx +++ b/web/src/app/home/plugins/components/plugin-market/RecommendationLists.tsx @@ -8,6 +8,10 @@ import { I18nObject } from '@/app/infra/entities/common'; import { extractI18nObject } from '@/i18n/I18nProvider'; import { getCloudServiceClientSync } from '@/app/infra/http'; import { useTranslation } from 'react-i18next'; +import { + resolveInstalledState, + type InstalledExtensionEntry, +} from './marketplace-installed'; export interface RecommendationList { uuid: string; @@ -21,6 +25,7 @@ export interface RecommendationList { function pluginToVO( plugin: PluginV4, t: (key: string) => string, + installedIndex?: Map, ): PluginMarketCardVO { const cloudClient = getCloudServiceClientSync(); // Recommendation lists are mixed-type; resolve the icon per extension type, @@ -32,6 +37,14 @@ function pluginToVO( plugin.icon, ); + const installedState = installedIndex + ? resolveInstalledState(installedIndex, { + type: plugin.type, + author: plugin.author, + pluginName: plugin.name, + }) + : undefined; + return new PluginMarketCardVO({ pluginId: plugin.author + ' / ' + plugin.name, author: plugin.author, @@ -47,6 +60,8 @@ function pluginToVO( components: plugin.components, tags: plugin.tags || [], type: plugin.type, + installed: installedState?.installed, + hasUpdate: installedState?.hasUpdate, }); } @@ -57,6 +72,7 @@ function RecommendationListRow({ installDisabled, installDisabledTooltip, isLast, + installedIndex, }: { list: RecommendationList; tagNames: Record; @@ -64,6 +80,7 @@ function RecommendationListRow({ installDisabled?: boolean; installDisabledTooltip?: string; isLast: boolean; + installedIndex?: Map; }) { const { t } = useTranslation(); const [page, setPage] = useState(0); @@ -264,7 +281,7 @@ function RecommendationListRow({ {visiblePlugins.map((plugin) => ( ; onInstall: (cardVO: PluginMarketCardVO) => void; installDisabled?: boolean; installDisabledTooltip?: string; + installedIndex?: Map; }) { if (!lists || lists.length === 0) return null; @@ -305,6 +324,7 @@ export function RecommendationLists({ installDisabled={installDisabled} installDisabledTooltip={installDisabledTooltip} isLast={index === lists.length - 1} + installedIndex={installedIndex} /> ))}
diff --git a/web/src/app/home/plugins/components/plugin-market/marketplace-installed.ts b/web/src/app/home/plugins/components/plugin-market/marketplace-installed.ts new file mode 100644 index 000000000..27d25da46 --- /dev/null +++ b/web/src/app/home/plugins/components/plugin-market/marketplace-installed.ts @@ -0,0 +1,107 @@ +/** + * Marketplace extensions are addressed as `author/name`. Installed extensions + * only carry a publisher-scoped identity for plugins and MCP servers: + * - plugins: `author/name` + * - MCP servers: `author__name` (double underscore) + * - skills: the bare skill name, with no publisher recorded + * + * The index below normalises those to a single `type:author/name` shape so a + * marketplace card can be matched with one lookup. + * + * Skills are deliberately *not* indexable: the backend derives a skill's name + * from the `name` field in its own SKILL.md (falling back to the package + * directory name), so a skill published by `alice/review` and one published by + * `bob/review` both install as the plain name `review`. Matching on that bare + * name would mark every publisher's `review` as installed once any single one + * of them is. Until the installed skill carries its publisher, a skill card + * cannot be resolved authoritatively, and so is reported as not installed. + * + * This module is intentionally free of React imports so it can be unit tested + * directly; the reactive hook lives in `useMarketplaceInstalledIndex.ts`. + */ + +export interface InstalledExtensionEntry { + /** An installed extension of the same identity has a newer remote version. */ + hasUpdate: boolean; +} + +export interface MarketplaceInstalledState { + installed: boolean; + hasUpdate: boolean; +} + +/** Identity for a marketplace extension card. */ +export function installedExtensionKey( + type: string | undefined, + author: string, + name: string, +): string { + return `${type || 'plugin'}:${author}/${name}`; +} + +/** Split an `author/name` identity, tolerating a missing author. */ +function splitIdentity(identity: string): [string, string] { + const slash = identity.indexOf('/'); + if (slash < 0) return ['', identity]; + return [identity.slice(0, slash), identity.slice(slash + 1)]; +} + +/** + * Build the installed-extension lookup from the sidebar entity lists. + * + * Skills are intentionally omitted — see the module header for why a bare + * skill name cannot be attributed to a publisher. + */ +export function buildInstalledIndex( + plugins: { id: string; hasUpdate?: boolean }[], + mcpServers: { id: string }[], + skills: { id: string }[], +): Map { + const index = new Map(); + + for (const plugin of plugins) { + index.set(installedExtensionKey('plugin', ...splitIdentity(plugin.id)), { + hasUpdate: plugin.hasUpdate ?? false, + }); + } + + for (const server of mcpServers) { + // MCP servers are keyed with `__`; normalise to `author/name`. + index.set( + installedExtensionKey( + 'mcp', + ...splitIdentity(server.id.replace(/__/g, '/')), + ), + { hasUpdate: false }, + ); + } + + // `skills` is accepted for call-site symmetry (and so the surrounding + // useMemo still re-runs when the list changes) but contributes nothing. + void skills; + + return index; +} + +/** + * Resolve whether a marketplace extension is already installed. + * + * Matching requires the full `type:author/name` identity, so a card is only + * marked installed when the installed extension carries the same publisher. + * Unknown types fall back to `plugin`, matching the marketplace defaults. + */ +export function resolveInstalledState( + index: Map, + extension: { type?: string; author: string; pluginName: string }, +): MarketplaceInstalledState { + const type = extension.type || 'plugin'; + const entry = index.get( + `${type}:${extension.author}/${extension.pluginName}`, + ); + + if (entry) { + return { installed: true, hasUpdate: entry.hasUpdate }; + } + + return { installed: false, hasUpdate: false }; +} diff --git a/web/src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardComponent.tsx b/web/src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardComponent.tsx index c4d741ed6..c8393ac1b 100644 --- a/web/src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardComponent.tsx +++ b/web/src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardComponent.tsx @@ -3,7 +3,14 @@ import { useRef, useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import PluginComponentList from '../PluginComponentList'; import { Badge } from '@/components/ui/badge'; -import { Info, Package, ExternalLink, Heart, Loader2 } from 'lucide-react'; +import { + Info, + Package, + ExternalLink, + Heart, + Loader2, + Check, +} from 'lucide-react'; import { Tooltip, TooltipContent, @@ -48,6 +55,9 @@ export default function PluginMarketCardComponent({ return keys.length > 0 && keys.every((k) => k === 'KnowledgeRetriever'); })(); + const isInstalled = !!cardVO.installed; + const hasUpdate = !!cardVO.hasUpdate; + const showTypeBadge = cardVO.type; const typeLabel = cardVO.type === 'mcp' @@ -158,12 +168,33 @@ export default function PluginMarketCardComponent({ } }; + // An already-installed extension turns its download affordance into a filled + // green circle-check, so the card reads as "installed" in place instead of + // offering another install. + const showInstalledMark = isInstalled && !hasUpdate; + + // Bottom-right slot: the component list. + const bottomTrailing = + cardVO.components && Object.keys(cardVO.components).length > 0 ? ( + + ) : null; const cardContent = (
- + + + ) : ( + + + + + + )} +
- - - - -
- {cardVO.installCount?.toLocaleString() ?? '0'} + {showInstalledMark + ? t('market.installed') + : (cardVO.installCount?.toLocaleString() ?? '0')}
@@ -376,18 +437,11 @@ export default function PluginMarketCardComponent({ )}
- {cardVO.components && Object.keys(cardVO.components).length > 0 && ( + {bottomTrailing ? (
- + {bottomTrailing}
- )} + ) : null}
diff --git a/web/src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardVO.ts b/web/src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardVO.ts index 11579fe4c..4816d5d3a 100644 --- a/web/src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardVO.ts +++ b/web/src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardVO.ts @@ -12,6 +12,10 @@ export interface IPluginMarketCardVO { components?: Record; tags?: string[]; type?: 'plugin' | 'mcp' | 'skill'; + /** Whether this extension is already installed in the current workspace. */ + installed?: boolean; + /** Whether the installed extension has a newer marketplace version. */ + hasUpdate?: boolean; } export class PluginMarketCardVO implements IPluginMarketCardVO { @@ -28,6 +32,8 @@ export class PluginMarketCardVO implements IPluginMarketCardVO { components?: Record; tags?: string[]; type?: 'plugin' | 'mcp' | 'skill'; + installed?: boolean; + hasUpdate?: boolean; constructor(prop: IPluginMarketCardVO) { this.description = prop.description; @@ -43,5 +49,7 @@ export class PluginMarketCardVO implements IPluginMarketCardVO { this.components = prop.components; this.tags = prop.tags; this.type = prop.type; + this.installed = prop.installed ?? false; + this.hasUpdate = prop.hasUpdate ?? false; } } diff --git a/web/src/app/home/plugins/components/plugin-market/useMarketplaceInstalledIndex.ts b/web/src/app/home/plugins/components/plugin-market/useMarketplaceInstalledIndex.ts new file mode 100644 index 000000000..8d675a264 --- /dev/null +++ b/web/src/app/home/plugins/components/plugin-market/useMarketplaceInstalledIndex.ts @@ -0,0 +1,24 @@ +import { useMemo } from 'react'; +import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext'; +import { + buildInstalledIndex, + type InstalledExtensionEntry, +} from './marketplace-installed'; + +/** + * Reactive installed-extension index derived from the sidebar data context. + * + * Because the index is memoised on the sidebar lists, a finished install (which + * triggers a sidebar refresh) automatically re-evaluates the marketplace cards. + */ +export function useMarketplaceInstalledIndex(): Map< + string, + InstalledExtensionEntry +> { + const { plugins, mcpServers, skills } = useSidebarData(); + + return useMemo( + () => buildInstalledIndex(plugins, mcpServers, skills), + [plugins, mcpServers, skills], + ); +} diff --git a/web/src/app/home/plugins/page.tsx b/web/src/app/home/plugins/page.tsx index 51bf48815..d2be12ed7 100644 --- a/web/src/app/home/plugins/page.tsx +++ b/web/src/app/home/plugins/page.tsx @@ -9,7 +9,7 @@ import { Label } from '@/components/ui/label'; import PluginDetailContent from './PluginDetailContent'; import styles from './plugins.module.css'; import { Button } from '@/components/ui/button'; -import { Power, Code, Copy, Check, Bug, Unlink } from 'lucide-react'; +import { Power, Code, Copy, Check, Bug, Unlink, Search, X } from 'lucide-react'; import { copyToClipboard } from '@/app/utils/clipboard'; import { Popover, @@ -63,6 +63,7 @@ function PluginListView() { const [copiedDebugUrl, setCopiedDebugUrl] = useState(false); const [copiedDebugKey, setCopiedDebugKey] = useState(false); const [filterType, setFilterType] = useState('all'); + const [installedSearchQuery, setInstalledSearchQuery] = useState(''); const pluginInstalledRef = useRef(null); useEffect(() => { @@ -179,6 +180,27 @@ function PluginListView() {
+ {/* Search installed extensions by label / name / author / description */} +
+ + setInstalledSearchQuery(e.target.value)} + placeholder={t('plugins.searchInstalled')} + aria-label={t('plugins.searchInstalled')} + className="pl-9 pr-8 text-sm" + /> + {installedSearchQuery && ( + + )} +
diff --git a/web/src/app/infra/entities/api/index.ts b/web/src/app/infra/entities/api/index.ts index 997ffc489..135c09a55 100644 --- a/web/src/app/infra/entities/api/index.ts +++ b/web/src/app/infra/entities/api/index.ts @@ -460,6 +460,8 @@ export interface AsyncTask { name: string; label: string; task_type: string; // system or user + /** Unix epoch seconds (float) when the task was created. */ + created_at?: number; runtime: AsyncTaskRuntimeInfo; task_context: AsyncTaskTaskContext; } diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 2f00dc058..2b79ac004 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -43,6 +43,7 @@ const enUS = { delete: 'Delete', add: 'Add', select: 'Select', + clear: 'Clear', skill: 'Skill', cancel: 'Cancel', submit: 'Submit', @@ -549,6 +550,8 @@ const enUS = { getPluginListError: 'Failed to get plugin list:', noPluginInstalled: 'No plugins installed', noExtensionInstalled: 'No extensions installed', + searchInstalled: 'Search installed extensions', + noMatchingExtensions: 'No extensions match "{{query}}"', loadingExtensions: 'Loading extensions...', groupByType: 'Group by format', pluginConfig: 'Plugin Configuration', @@ -744,6 +747,9 @@ const enUS = { allLoadedCount: 'All {{count}} extensions displayed', install: 'Install', installCard: 'Install {{name}}', + installedCard: 'Installed {{name}}', + installed: 'Installed', + updateAvailable: 'Update available', installConfirm: 'Are you sure you want to install plugin "{{name}}" ({{version}})?', downloadComplete: 'Plugin "{{name}}" download completed', diff --git a/web/src/i18n/locales/es-ES.ts b/web/src/i18n/locales/es-ES.ts index 5c8c2edb7..076d18986 100644 --- a/web/src/i18n/locales/es-ES.ts +++ b/web/src/i18n/locales/es-ES.ts @@ -46,6 +46,7 @@ const esES = { delete: 'Eliminar', add: 'Añadir', select: 'Seleccionar', + clear: 'Limpiar', skill: 'Habilidad', cancel: 'Cancelar', submit: 'Enviar', @@ -566,6 +567,8 @@ const esES = { getPluginListError: 'Error al obtener la lista de plugins:', noPluginInstalled: 'No hay plugins instalados', noExtensionInstalled: 'No hay extensiones instaladas', + searchInstalled: 'Buscar extensiones instaladas', + noMatchingExtensions: 'Ninguna extensión coincide con "{{query}}"', loadingExtensions: 'Cargando extensiones...', groupByType: 'Agrupar por formato', pluginConfig: 'Configuración del plugin', @@ -842,6 +845,9 @@ const esES = { noTags: 'No hay etiquetas disponibles', }, installCard: 'Instalar {{name}}', + installedCard: '{{name}} instalado', + installed: 'Instalado', + updateAvailable: 'Actualización disponible', }, mcp: { title: 'MCP', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index 4460f2640..efe4ecec5 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -44,6 +44,7 @@ const jaJP = { delete: '削除', add: '追加', select: '選択してください', + clear: 'クリア', skill: 'スキル', cancel: 'キャンセル', submit: '送信', @@ -557,6 +558,8 @@ const jaJP = { getPluginListError: 'プラグインリストの取得に失敗しました:', noPluginInstalled: 'プラグインがインストールされていません', noExtensionInstalled: '拡張機能がインストールされていません', + searchInstalled: 'インストール済み拡張機能を検索', + noMatchingExtensions: '「{{query}}」に一致する拡張機能はありません', loadingExtensions: '拡張機能を読み込み中...', groupByType: '形式でグループ化', pluginConfig: 'プラグイン設定', @@ -831,6 +834,9 @@ const jaJP = { deprecatedTooltip: '対応する「ナレッジエンジン」プラグインをインストールしてください。', installCard: '{{name}} をインストール', + installedCard: '{{name}} はインストール済み', + installed: 'インストール済み', + updateAvailable: '更新があります', }, mcp: { title: 'MCP', diff --git a/web/src/i18n/locales/ru-RU.ts b/web/src/i18n/locales/ru-RU.ts index 478af0b97..bcc4117a4 100644 --- a/web/src/i18n/locales/ru-RU.ts +++ b/web/src/i18n/locales/ru-RU.ts @@ -44,6 +44,7 @@ const ruRU = { delete: 'Удалить', add: 'Добавить', select: 'Выбрать', + clear: 'Очистить', skill: 'Навык', cancel: 'Отмена', submit: 'Отправить', @@ -563,6 +564,8 @@ const ruRU = { getPluginListError: 'Не удалось получить список плагинов:', noPluginInstalled: 'Плагины не установлены', noExtensionInstalled: 'Расширения не установлены', + searchInstalled: 'Поиск установленных расширений', + noMatchingExtensions: 'Нет расширений, соответствующих «{{query}}»', loadingExtensions: 'Загрузка расширений...', groupByType: 'Группировать по формату', pluginConfig: 'Настройка плагина', @@ -836,6 +839,9 @@ const ruRU = { noTags: 'Нет доступных тегов', }, installCard: 'Установить {{name}}', + installedCard: '{{name}} установлен', + installed: 'Установлено', + updateAvailable: 'Доступно обновление', }, mcp: { title: 'MCP', diff --git a/web/src/i18n/locales/th-TH.ts b/web/src/i18n/locales/th-TH.ts index 51e1e6c3e..7993ab7d4 100644 --- a/web/src/i18n/locales/th-TH.ts +++ b/web/src/i18n/locales/th-TH.ts @@ -43,6 +43,7 @@ const thTH = { delete: 'ลบ', add: 'เพิ่ม', select: 'เลือก', + clear: 'ล้าง', skill: 'สกิล', cancel: 'ยกเลิก', submit: 'ส่ง', @@ -547,6 +548,8 @@ const thTH = { getPluginListError: 'ไม่สามารถดึงรายการปลั๊กอินได้:', noPluginInstalled: 'ยังไม่มีปลั๊กอินที่ติดตั้ง', noExtensionInstalled: 'ยังไม่มีส่วนขยายที่ติดตั้ง', + searchInstalled: 'ค้นหาส่วนขยายที่ติดตั้งแล้ว', + noMatchingExtensions: 'ไม่มีส่วนขยายที่ตรงกับ "{{query}}"', loadingExtensions: 'กำลังโหลดส่วนขยาย...', groupByType: 'จัดกลุ่มตามรูปแบบ', pluginConfig: 'การกำหนดค่าปลั๊กอิน', @@ -813,6 +816,9 @@ const thTH = { noTags: 'ไม่มีแท็กที่พร้อมใช้งาน', }, installCard: 'ติดตั้ง {{name}}', + installedCard: 'ติดตั้ง {{name}} แล้ว', + installed: 'ติดตั้งแล้ว', + updateAvailable: 'มีอัปเดต', }, mcp: { title: 'MCP', diff --git a/web/src/i18n/locales/vi-VN.ts b/web/src/i18n/locales/vi-VN.ts index c30791515..ee00478d9 100644 --- a/web/src/i18n/locales/vi-VN.ts +++ b/web/src/i18n/locales/vi-VN.ts @@ -44,6 +44,7 @@ const viVN = { delete: 'Xóa', add: 'Thêm', select: 'Chọn', + clear: 'Xóa', skill: 'Kỹ năng', cancel: 'Hủy', submit: 'Gửi', @@ -557,6 +558,8 @@ const viVN = { getPluginListError: 'Lấy danh sách plugin thất bại:', noPluginInstalled: 'Chưa cài đặt plugin nào', noExtensionInstalled: 'Chưa cài đặt tiện ích mở rộng nào', + searchInstalled: 'Tìm tiện ích mở rộng đã cài đặt', + noMatchingExtensions: 'Không có tiện ích mở rộng nào khớp với "{{query}}"', loadingExtensions: 'Đang tải tiện ích mở rộng...', groupByType: 'Nhóm theo định dạng', pluginConfig: 'Cấu hình Plugin', @@ -828,6 +831,9 @@ const viVN = { noTags: 'Không có thẻ nào', }, installCard: 'Cài đặt {{name}}', + installedCard: 'Đã cài đặt {{name}}', + installed: 'Đã cài đặt', + updateAvailable: 'Có bản cập nhật', }, mcp: { title: 'MCP', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 1b9d40ec4..044a88e6e 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -42,6 +42,7 @@ const zhHans = { delete: '删除', add: '添加', select: '请选择', + clear: '清除', skill: '技能', cancel: '取消', submit: '提交', @@ -528,6 +529,8 @@ const zhHans = { pluginConfig: '插件配置', noPluginInstalled: '暂未安装任何插件', noExtensionInstalled: '暂未安装任何扩展', + searchInstalled: '搜索已安装扩展', + noMatchingExtensions: '没有匹配「{{query}}」的扩展', loadingExtensions: '正在加载扩展...', groupByType: '按格式分组', pluginSort: '插件排序', @@ -712,6 +715,9 @@ const zhHans = { allLoadedCount: '已显示全部 {{count}} 个扩展', install: '安装', installCard: '安装 {{name}}', + installedCard: '已安装 {{name}}', + installed: '已安装', + updateAvailable: '有可用更新', installConfirm: '确定要安装插件 "{{name}}" ({{version}}) 吗?', downloadComplete: '插件 "{{name}}" 下载完成', installFailed: '安装失败,请稍后重试', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 693950c5e..e9f250fe5 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -42,6 +42,7 @@ const zhHant = { delete: '刪除', add: '新增', select: '請選擇', + clear: '清除', skill: '技能', cancel: '取消', submit: '提交', @@ -532,6 +533,8 @@ const zhHant = { pluginConfig: '外掛設定', noPluginInstalled: '暫未安裝任何外掛', noExtensionInstalled: '暫未安裝任何擴充功能', + searchInstalled: '搜尋已安裝擴充功能', + noMatchingExtensions: '沒有符合「{{query}}」的擴充功能', loadingExtensions: '正在載入擴充功能...', groupByType: '依格式分組', pluginSort: '外掛排序', @@ -715,6 +718,9 @@ const zhHant = { allLoadedCount: '已顯示全部 {{count}} 個擴展', install: '安裝', installCard: '安裝 {{name}}', + installedCard: '已安裝 {{name}}', + installed: '已安裝', + updateAvailable: '有可用更新', installConfirm: '確定要安裝插件 "{{name}}" ({{version}}) 嗎?', downloadComplete: '插件 "{{name}}" 下載完成', installFailed: '安裝失敗,請稍後重試', diff --git a/web/tests/e2e/home-smoke.spec.ts b/web/tests/e2e/home-smoke.spec.ts index 32623864b..cc731e18a 100644 --- a/web/tests/e2e/home-smoke.spec.ts +++ b/web/tests/e2e/home-smoke.spec.ts @@ -84,10 +84,10 @@ test.describe('authenticated app shell', () => { await page.getByRole('button', { name: 'Debug Info' }).click(); await expect(page.getByText('Plugin Debug Information')).toBeVisible(); - await expect(page.getByRole('textbox').nth(0)).toHaveValue( + await expect(page.getByRole('textbox', { name: 'Debug URL' })).toHaveValue( 'ws://127.0.0.1:5300/plugin/debug', ); - await expect(page.getByRole('textbox').nth(1)).toHaveValue( + await expect(page.getByRole('textbox', { name: 'Debug Key' })).toHaveValue( 'test-debug-key', ); }); diff --git a/web/tests/unit/install-progress.test.mjs b/web/tests/unit/install-progress.test.mjs new file mode 100644 index 000000000..96fae40c3 --- /dev/null +++ b/web/tests/unit/install-progress.test.mjs @@ -0,0 +1,133 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; + +const currentDirectory = path.dirname(fileURLToPath(import.meta.url)); +const sourcePath = path.resolve( + currentDirectory, + '../../src/app/home/plugins/components/plugin-install-task/install-progress.ts', +); +const source = fs.readFileSync(sourcePath, 'utf8'); +const compiled = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.CommonJS }, +}).outputText; +const sourceRequire = createRequire(sourcePath); +const loadedModule = { exports: {} }; +new Function('require', 'module', 'exports', compiled)( + sourceRequire, + loadedModule, + loadedModule.exports, +); + +const { + InstallStage, + INSTALL_PROGRESS_CAP, + STAGE_PROGRESS_RANGE, + computeStageProgress, + mapActionToStage, +} = loadedModule.exports; + +test('maps the connector stage strings the runtime actually emits', () => { + assert.equal( + mapActionToStage('preparing plugin install'), + InstallStage.DOWNLOADING, + '"preparing plugin install" must not be read as the dependency stage', + ); + assert.equal( + mapActionToStage('downloading plugin package'), + InstallStage.DOWNLOADING, + ); + assert.equal( + mapActionToStage('inspecting plugin package'), + InstallStage.INSTALLING_DEPS, + ); + assert.equal( + mapActionToStage('storing plugin package'), + InstallStage.INSTALLING_DEPS, + ); + assert.equal( + mapActionToStage('persisting the installation'), + InstallStage.INSTALLING_DEPS, + ); + assert.equal(mapActionToStage('launching plugin'), InstallStage.LAUNCHING); + assert.equal( + mapActionToStage('waiting for plugin to become ready'), + InstallStage.LAUNCHING, + 'the readiness wait is still an active stage, not completion', + ); +}); + +test('the combined install-and-start stage is not reported as launching', () => { + // The runtime installs dependencies and starts the plugin in one step; the + // wording contains "starting" and must not be mapped to the launch stage. + assert.equal( + mapActionToStage('installing or starting plugin'), + InstallStage.INSTALLING_DEPS, + ); +}); + +test('measured byte counts stay inside the download stage range', () => { + const [start, end] = STAGE_PROGRESS_RANGE[InstallStage.DOWNLOADING]; + + // 90 of 100 bytes is 90% of the download range, even after 40s elapsed. + const progress = computeStageProgress({ + stage: InstallStage.DOWNLOADING, + downloadCurrent: 90, + downloadTotal: 100, + stageElapsedSeconds: 40, + }); + + assert.ok( + progress >= start && progress <= end, + `expected progress within [${start}, ${end}], received ${progress}`, + ); + assert.equal( + progress, + 41, + 'drift must not be layered on top of a measured byte ratio', + ); +}); + +test('fallback drift never spills into the next stage range', () => { + for (const stage of [ + InstallStage.DOWNLOADING, + InstallStage.INSTALLING_DEPS, + InstallStage.INITIALIZING, + InstallStage.LAUNCHING, + ]) { + const [start, end] = STAGE_PROGRESS_RANGE[stage]; + const progress = computeStageProgress({ + stage, + stageElapsedSeconds: 100000, + }); + + assert.ok( + progress >= start && progress <= end, + `stage ${stage} produced ${progress}, outside [${start}, ${end}]`, + ); + assert.ok( + progress < INSTALL_PROGRESS_CAP, + `stage ${stage} must not reach the completion cap on drift alone`, + ); + } +}); + +test('a missing or zero download total falls back to bounded drift', () => { + const [start, end] = STAGE_PROGRESS_RANGE[InstallStage.DOWNLOADING]; + + const progress = computeStageProgress({ + stage: InstallStage.DOWNLOADING, + downloadCurrent: 10, + downloadTotal: 0, + stageElapsedSeconds: 100000, + }); + + assert.ok( + progress >= start && progress <= end, + `expected progress within [${start}, ${end}], received ${progress}`, + ); +}); diff --git a/web/tests/unit/marketplace-installed-state.test.mjs b/web/tests/unit/marketplace-installed-state.test.mjs new file mode 100644 index 000000000..cb3695f39 --- /dev/null +++ b/web/tests/unit/marketplace-installed-state.test.mjs @@ -0,0 +1,121 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; + +const currentDirectory = path.dirname(fileURLToPath(import.meta.url)); +const sourcePath = path.resolve( + currentDirectory, + '../../src/app/home/plugins/components/plugin-market/marketplace-installed.ts', +); +const source = fs.readFileSync(sourcePath, 'utf8'); +const compiled = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.CommonJS }, +}).outputText; +const sourceRequire = createRequire(sourcePath); +const loadedModule = { exports: {} }; +new Function('require', 'module', 'exports', compiled)( + sourceRequire, + loadedModule, + loadedModule.exports, +); + +const { buildInstalledIndex, resolveInstalledState, installedExtensionKey } = + loadedModule.exports; + +test('matches installed plugins by author and name', () => { + const index = buildInstalledIndex( + [{ id: 'alice/review', hasUpdate: true }], + [], + [], + ); + + assert.deepEqual( + resolveInstalledState(index, { + type: 'plugin', + author: 'alice', + pluginName: 'review', + }), + { installed: true, hasUpdate: true }, + ); + assert.deepEqual( + resolveInstalledState(index, { + type: 'plugin', + author: 'bob', + pluginName: 'review', + }), + { installed: false, hasUpdate: false }, + 'a different publisher must not match', + ); +}); + +test('normalises MCP servers from `author__name` to `author/name`', () => { + const index = buildInstalledIndex([], [{ id: 'acme__search' }], []); + + assert.equal( + resolveInstalledState(index, { + type: 'mcp', + author: 'acme', + pluginName: 'search', + }).installed, + true, + ); + assert.equal( + resolveInstalledState(index, { + type: 'mcp', + author: 'other', + pluginName: 'search', + }).installed, + false, + ); +}); + +test('does not mark skills installed from a bare name', () => { + // Two publishers ship a skill that both install as the plain name + // `review`; the sidebar records no publisher for either. + const index = buildInstalledIndex([], [], [{ id: 'review' }]); + + const alice = resolveInstalledState(index, { + type: 'skill', + author: 'alice', + pluginName: 'review', + }); + const bob = resolveInstalledState(index, { + type: 'skill', + author: 'bob', + pluginName: 'review', + }); + + assert.equal( + alice.installed, + false, + 'alice/review must not be reported installed from a bare skill name', + ); + assert.equal( + bob.installed, + false, + 'bob/review must not be reported installed from a bare skill name', + ); +}); + +test('keeps extension kinds separate for identical identities', () => { + const index = buildInstalledIndex([{ id: 'alice/toolkit' }], [], []); + + assert.equal( + resolveInstalledState(index, { + type: 'mcp', + author: 'alice', + pluginName: 'toolkit', + }).installed, + false, + 'a plugin must not mark the same-named MCP server as installed', + ); + assert.equal( + installedExtensionKey(undefined, 'alice', 'toolkit'), + 'plugin:alice/toolkit', + 'a missing type must default to plugin', + ); +});