From 6c05f3dfcb61d827e4e029504d13b6e98c5f3525 Mon Sep 17 00:00:00 2001 From: TyperBody Date: Sun, 13 Sep 2026 00:02:08 +0800 Subject: [PATCH] feat(plugins): show installed state in marketplace and stream install tasks - report download progress (bytes, speed) and human-readable install stages from the plugin runtime connector via the install task context - add a marketplace installed-state helper so market cards reflect whether a plugin is already installed - surface install progress / queue state in the plugin install-task UI - extend BackendClient and API entities with install-task status - i18n for the new marketplace / install strings --- src/langbot/pkg/plugin/connector.py | 41 ++ .../PluginInstallProgressDialog.tsx | 24 +- .../PluginInstallTaskContext.tsx | 400 +++++++++++++----- .../PluginInstallTaskQueue.tsx | 4 + .../plugin-market/PluginMarketComponent.tsx | 34 +- .../plugin-market/RecommendationLists.tsx | 31 +- .../plugin-market/marketplace-installed.ts | 85 ++++ .../PluginMarketCardComponent.tsx | 56 ++- .../plugin-market-card/PluginMarketCardVO.ts | 8 + web/src/app/infra/entities/api/index.ts | 2 + web/src/i18n/locales/en-US.ts | 3 + web/src/i18n/locales/es-ES.ts | 3 + web/src/i18n/locales/ja-JP.ts | 3 + web/src/i18n/locales/ru-RU.ts | 3 + web/src/i18n/locales/th-TH.ts | 3 + web/src/i18n/locales/vi-VN.ts | 3 + web/src/i18n/locales/zh-Hans.ts | 3 + web/src/i18n/locales/zh-Hant.ts | 3 + 18 files changed, 580 insertions(+), 129 deletions(-) create mode 100644 web/src/app/home/plugins/components/plugin-market/marketplace-installed.ts diff --git a/src/langbot/pkg/plugin/connector.py b/src/langbot/pkg/plugin/connector.py index 7df06aefe..331b059d7 100644 --- a/src/langbot/pkg/plugin/connector.py +++ b/src/langbot/pkg/plugin/connector.py @@ -87,8 +87,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) @@ -97,11 +99,23 @@ 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: + 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) @@ -111,6 +125,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: @@ -119,6 +134,7 @@ async def _marketplace_get( return response.status_code, await _read_httpx_response_limited( response, max_bytes=max_bytes, + task_context=task_context, ) @@ -1680,6 +1696,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector): client, f'{space_url}/api/v1/marketplace/plugins/download/{plugin_author}/{plugin_name}/{latest_version}', max_bytes=_MARKETPLACE_PLUGIN_DOWNLOAD_MAX_BYTES, + task_context=task_context, ) return plugin_package, latest_version @@ -1695,7 +1712,21 @@ class PluginRuntimeConnector(ManagedRuntimeConnector): plugin_name = str(install_info.get('plugin_name') or '') file_bytes: bytes | None + if task_context is not None: + # Reset per-install progress counters so a re-install of the same + # plugin does not inherit stale 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, @@ -1719,6 +1750,8 @@ class PluginRuntimeConnector(ManagedRuntimeConnector): else: raise ValueError(f'Unsupported plugin install source: {install_source.value}') + if task_context is not None: + task_context.set_current_action('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') @@ -1730,8 +1763,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('installing plugin dependencies') try: binding, previous_digest, previous_was_durable = await self._persist_installation_package( execution_context, @@ -1749,6 +1786,8 @@ class PluginRuntimeConnector(ManagedRuntimeConnector): plugin_author=plugin_author, plugin_name=plugin_name, ) + if task_context is not None: + task_context.set_current_action('launching plugin') await self._apply_desired_state( PluginInstallationDesiredState(binding=binding, enabled=True), artifact_package=file_bytes, @@ -1766,6 +1805,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..14f27efc2 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 @@ -10,6 +10,7 @@ import { Button } from '@/components/ui/button'; import { Download, Package, + Rocket, Server, Sparkles, CheckCircle2, @@ -39,11 +40,27 @@ const STAGES: { icon: Package, i18nKey: 'plugins.installProgress.installingDeps', }, + { + key: InstallStage.LAUNCHING, + icon: Rocket, + i18nKey: 'plugins.installProgress.launching', + }, ]; +/** + * Find the row that should be highlighted for a given stage. + * LAUNCHING/INITIALIZING/DONE collapse onto the launching row. + */ function getStageIndex(stage: InstallStage): number { + if ( + stage === InstallStage.LAUNCHING || + stage === InstallStage.INITIALIZING || + stage === InstallStage.DONE + ) { + return STAGES.length - 1; + } const idx = STAGES.findIndex((s) => s.key === stage); - return idx >= 0 ? idx : -1; + return idx >= 0 ? idx : 0; } function formatFileSize(bytes: number): string { @@ -169,9 +186,12 @@ function formatSpeed(bytesPerSec: number): string { function TaskProgressContent({ task }: { task: PluginInstallTask }) { const { t } = useTranslation(); - const currentStageIndex = getStageIndex(task.stage); const isDone = task.stage === InstallStage.DONE; const isError = task.stage === InstallStage.ERROR; + // When a task fails, `stage` becomes ERROR — fall back to the furthest + // stage it actually reached so the failed phase is still displayed. + const displayStage = isError && task.lastStage ? task.lastStage : task.stage; + const currentStageIndex = getStageIndex(displayStage); // MCP / Skill don't have the plugin's download + dependency-install stages; // show a single "installing → done/failed" row instead of plugin steps. 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..050970ccf 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 @@ -27,6 +27,9 @@ export interface PluginInstallTask { pluginName: string; // display name source: 'github' | 'marketplace' | 'local'; stage: InstallStage; + /** Furthest non-terminal stage reached — kept when the task fails so the + * UI can still show which phase failed. */ + lastStage?: InstallStage; overallProgress: number; // 0-100 extensionType: 'plugin' | 'mcp' | 'skill'; // type of extension being installed fileSize?: number; // bytes, if known @@ -43,6 +46,8 @@ export interface PluginInstallTask { depsSpeed?: number; // deps download speed bytes/s error?: string; startedAt: number; // timestamp + /** Timestamp when the current stage began; used for smooth creeping. */ + stageStartedAt?: number; currentAction: string; // raw backend action string } @@ -84,42 +89,158 @@ export function usePluginInstallTasks() { } /** - * Map backend `current_action` to our InstallStage. + * Ordered lifecycle stages. Used to enforce forward-only transitions so the + * progress bar never moves backwards while a task is running. */ -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; +const STAGE_ORDER: InstallStage[] = [ + InstallStage.DOWNLOADING, + InstallStage.INSTALLING_DEPS, + InstallStage.INITIALIZING, + InstallStage.LAUNCHING, + InstallStage.DONE, +]; + +/** + * Lower bound (%) for each stage. A task's progress is never allowed to drop + * below the floor of the furthest stage it has already reached. + */ +const STAGE_FLOOR: Record = { + [InstallStage.DOWNLOADING]: 2, + [InstallStage.INSTALLING_DEPS]: 55, + [InstallStage.INITIALIZING]: 85, + [InstallStage.LAUNCHING]: 94, + [InstallStage.DONE]: 100, + [InstallStage.ERROR]: 0, +}; + +/** Get the lower-bound percentage for a stage. */ +function stageFloor(stage: InstallStage): number { + return STAGE_FLOOR[stage] ?? 0; +} + +/** Get the lower bound of the stage that follows the given one. */ +function nextStageFloor(stage: InstallStage): number { + const idx = STAGE_ORDER.indexOf(stage); + const next = idx >= 0 ? STAGE_ORDER[idx + 1] : undefined; + return next ? stageFloor(next) : 100; +} + +/** Return whichever stage is further along in the lifecycle. */ +function maxStage(current: InstallStage, incoming: InstallStage): InstallStage { + const currentIdx = STAGE_ORDER.indexOf(current); + const incomingIdx = STAGE_ORDER.indexOf(incoming); + if (currentIdx === -1) return incoming; + if (incomingIdx === -1) return current; + return incomingIdx >= currentIdx ? incoming : current; } /** - * Get overall progress percentage from a stage. + * Map backend `current_action` to our InstallStage. + * + * Unknown / transitional actions must NOT map back to an earlier stage, + * otherwise the bar would jump backwards mid-install. */ -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; +function mapActionToStage(action: string): InstallStage { + const lower = (action || '').toLowerCase(); + if (!lower) return InstallStage.DOWNLOADING; + + // "preparing"/"resolving" happen before any bytes land on disk. + if (lower.includes('prepar') || lower.includes('resolv')) + return InstallStage.DOWNLOADING; + + if (lower.includes('download') && !lower.includes('dependenc')) + return InstallStage.DOWNLOADING; + + // Activation / readiness tail phase — its own slice of the bar. + if ( + lower.includes('launch') || + lower.includes('start') || + lower.includes('wait') || + lower.includes('ready') || + lower.includes('initializ') + ) { + return InstallStage.LAUNCHING; } + + // Dependency installation and package finalization. + if ( + lower.includes('dependenc') || + lower.includes('requirements') || + lower.includes('parsing') || + lower.includes('extract') || + lower.includes('inspect') || + lower.includes('persist') || + lower.includes('stor') || + lower.includes('install') || + lower.includes('setting') + ) { + return InstallStage.INSTALLING_DEPS; + } + + // Unknown transitional actions belong to the busy middle of the install. + return InstallStage.INSTALLING_DEPS; +} + +/** + * Time-based creep so the bar keeps moving when no counters exist. + * + * Uses an asymptote so the increment decelerates as it approaches the stage + * ceiling — the bar always feels alive but never overshoots into the next + * stage's range. + */ +function creep(stageStartedAt: number, span: number): number { + if (span <= 0) return 0; + const elapsed = (Date.now() - stageStartedAt) / 1000; + // Approaching `span` asymptotically: after ~60s we are ~86% of the span. + const ratio = 1 - Math.exp(-elapsed / 30); + return span * ratio; +} + +/** + * Compute a progress value for the current stage. + * + * Real byte / dependency counters drive the value when available; otherwise + * the value creeps forward slowly based on elapsed time. Callers are expected + * to combine the result with the previous value via `Math.max` so it is + * monotonic. + */ +function computeStageProgress( + task: PluginInstallTask, + stage: InstallStage, +): number { + const floor = stageFloor(stage); + const ceiling = Math.max(floor, nextStageFloor(stage) - 1); + // Creep from when this stage began so a stage change restarts the ramp + // instead of inheriting the previous stage's elapsed time. + const stageStartedAt = task.stageStartedAt ?? task.startedAt; + const creepValue = Math.min( + ceiling, + floor + creep(stageStartedAt, ceiling - floor), + ); + + if (stage === InstallStage.DOWNLOADING) { + const total = task.downloadTotal ?? task.fileSize; + const current = task.downloadCurrent; + if (total && total > 0 && current != null && current > 0) { + const ratio = Math.min(1, current / total); + // Never let a stale counter pull the value below the creep baseline. + return Math.max(creepValue, floor + (ceiling - floor) * ratio); + } + return creepValue; + } + + if (stage === InstallStage.INSTALLING_DEPS) { + const total = task.depsTotal; + const installed = task.depsInstalled; + if (total && total > 0 && installed != null && installed > 0) { + const ratio = Math.min(1, installed / total); + // Leave headroom for the finalize/launch phase that has no counters. + return Math.max(creepValue, floor + (ceiling - floor) * ratio * 0.9); + } + return creepValue; + } + + return creepValue; } /** @@ -146,8 +267,14 @@ function isPluginInstallTask(name: string): boolean { /** * Convert a backend AsyncTask to our PluginInstallTask. + * + * `previous` (when provided) carries monotonic state forward so re-syncing + * after a refresh or a poll cannot make the progress bar move backwards. */ -function asyncTaskToPluginInstallTask(task: AsyncTask): PluginInstallTask { +function asyncTaskToPluginInstallTask( + task: AsyncTask, + previous?: PluginInstallTask, +): PluginInstallTask { const source = extractSourceFromName(task.name); const md = (task.task_context?.metadata ?? {}) as Record; const action = task.task_context?.current_action || ''; @@ -157,24 +284,6 @@ function asyncTaskToPluginInstallTask(task: AsyncTask): PluginInstallTask { const num = (v: unknown) => (typeof v === 'number' ? v : undefined); const str = (v: unknown) => (typeof v === 'string' ? v : undefined); - let stage: InstallStage; - let overallProgress: number; - let error: string | undefined; - - if (done) { - if (exception) { - stage = InstallStage.ERROR; - overallProgress = 0; - error = exception; - } else { - stage = InstallStage.DONE; - overallProgress = 100; - } - } else { - stage = mapActionToStage(action); - overallProgress = Math.min(95, stageToProgress(stage)); - } - const pluginName = str(md.plugin_name) || task.label || `${source} extension`; let extensionType: 'plugin' | 'mcp' | 'skill' = 'plugin'; @@ -184,6 +293,75 @@ function asyncTaskToPluginInstallTask(task: AsyncTask): PluginInstallTask { extensionType = 'skill'; } + // Prefer the task's real creation time so a refresh (or first sync) restores + // the correct elapsed baseline instead of restarting the ramp from zero. + const backendStartedAt = + typeof task.created_at === 'number' && task.created_at > 0 + ? task.created_at * 1000 + : undefined; + const startedAt = previous?.startedAt ?? backendStartedAt ?? Date.now(); + let stageStartedAt = + previous?.stageStartedAt ?? + previous?.startedAt ?? + backendStartedAt ?? + startedAt; + + let stage: InstallStage; + let overallProgress: number; + let error: string | undefined; + + // Furthest non-terminal stage reached, kept across failures. + let lastStage = previous?.lastStage ?? previous?.stage; + + if (done) { + if (exception) { + // Preserve how far the task got before failing, so the bar shows the + // failure point instead of jumping back to zero. + stage = InstallStage.ERROR; + overallProgress = previous?.overallProgress ?? 0; + error = exception; + } else { + stage = InstallStage.DONE; + overallProgress = 100; + } + } else { + const incoming = mapActionToStage(action); + // Forward-only: never move back to an earlier stage than we already reached. + stage = previous ? maxStage(previous.stage, incoming) : incoming; + if (!previous || previous.stage !== stage) { + stageStartedAt = Date.now(); + } + lastStage = stage; + + const counters: PluginInstallTask = { + id: `${source}-${task.id}`, + taskId: task.id, + pluginName, + source, + extensionType, + stage, + overallProgress: 0, + downloadCurrent: num(md.download_current) ?? previous?.downloadCurrent, + downloadTotal: num(md.download_total) ?? previous?.downloadTotal, + downloadSpeed: num(md.download_speed) ?? previous?.downloadSpeed, + depsTotal: num(md.deps_total) ?? previous?.depsTotal, + depsInstalled: num(md.deps_installed) ?? previous?.depsInstalled, + depsRemaining: num(md.deps_remaining) ?? previous?.depsRemaining, + currentDep: str(md.current_dep) ?? previous?.currentDep, + depsDownloadedSize: + num(md.deps_downloaded_size) ?? previous?.depsDownloadedSize, + depsSpeed: num(md.deps_speed) ?? previous?.depsSpeed, + startedAt, + stageStartedAt, + currentAction: action, + }; + + const computed = computeStageProgress(counters, stage); + overallProgress = Math.max(previous?.overallProgress ?? 0, computed); + // Keep the bar strictly below 100 until the backend confirms completion. + overallProgress = Math.round(Math.min(99, overallProgress)); + } + return { id: `${source}-${task.id}`, taskId: task.id, @@ -191,18 +369,21 @@ function asyncTaskToPluginInstallTask(task: AsyncTask): PluginInstallTask { source, extensionType, stage, + lastStage, overallProgress, - 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), + downloadCurrent: num(md.download_current) ?? previous?.downloadCurrent, + downloadTotal: num(md.download_total) ?? previous?.downloadTotal, + downloadSpeed: num(md.download_speed) ?? previous?.downloadSpeed, + depsTotal: num(md.deps_total) ?? previous?.depsTotal, + depsInstalled: num(md.deps_installed) ?? previous?.depsInstalled, + depsRemaining: num(md.deps_remaining) ?? previous?.depsRemaining, + currentDep: str(md.current_dep) ?? previous?.currentDep, + depsDownloadedSize: + num(md.deps_downloaded_size) ?? previous?.depsDownloadedSize, + depsSpeed: num(md.deps_speed) ?? previous?.depsSpeed, error, - startedAt: Date.now(), + startedAt, + stageStartedAt, currentAction: action, }; } @@ -315,8 +496,11 @@ export function PluginInstallTaskProvider({ return { ...t, stage: InstallStage.ERROR, + // Keep the phase that failed for the UI to display. + lastStage: t.lastStage ?? t.stage, error: exception, - overallProgress: 0, + // Show where it failed instead of resetting to 0. + overallProgress: t.overallProgress, currentAction: action, ...progressFields, }; @@ -332,26 +516,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), - ); - const progress = Math.min( - 95, - baseProgress + withinStageIncrement, - ); + // Forward-only stage transition. + const incoming = mapActionToStage(action); + const stage = maxStage(t.stage, incoming); + // Reset the per-stage ramp whenever we enter a new stage. + const stageAdvanced = stage !== t.stage; - return { + const next: PluginInstallTask = { ...t, stage, - overallProgress: progress, + lastStage: stage, + stageStartedAt: stageAdvanced + ? Date.now() + : (t.stageStartedAt ?? t.startedAt), currentAction: action, ...progressFields, }; + const computed = computeStageProgress(next, stage); + // Progress must never move backwards while the task runs. + const overallProgress = Math.round( + Math.min(99, Math.max(t.overallProgress, computed)), + ); + return { ...next, overallProgress }; }), ); }) @@ -377,46 +563,61 @@ export function PluginInstallTaskProvider({ ); setTasks((prevTasks) => { - const existingTaskIds = new Set(prevTasks.map((t) => t.taskId)); const updatedTasks = [...prevTasks]; + // Collect tasks that need polling started after state is committed. + const toPoll: Array<{ key: string; taskId: number }> = []; for (const bt of backendTasks) { // Skip tasks that the user has dismissed if (dismissedTaskIds.current.has(bt.id)) continue; - if (!existingTaskIds.has(bt.id)) { + const idx = updatedTasks.findIndex((t) => t.taskId === bt.id); + + if (idx === -1) { // New task from backend (e.g. after page refresh) — add it const newTask = asyncTaskToPluginInstallTask(bt); updatedTasks.push(newTask); - // If not done, start polling for progress if (!bt.runtime.done) { - pollTask(newTask.id, bt.id); + toPoll.push({ key: newTask.id, taskId: bt.id }); } else { // Mark as already notified so we don't re-trigger toasts for old completed tasks notifiedTaskIds.current.add(bt.id); } - } else { - // Already tracking — if it's done in backend but still active locally, update it - const idx = updatedTasks.findIndex((t) => t.taskId === bt.id); - if (idx !== -1) { - const existing = updatedTasks[idx]; - if ( - bt.runtime.done && - existing.stage !== InstallStage.DONE && - existing.stage !== InstallStage.ERROR - ) { - const converted = asyncTaskToPluginInstallTask(bt); - converted.startedAt = existing.startedAt; - converted.pluginName = existing.pluginName; - converted.fileSize = existing.fileSize; - converted.extensionType = existing.extensionType; - updatedTasks[idx] = converted; - } - } + continue; + } + + // Already tracking — merge the backend snapshot into the existing + // task. Passing `existing` keeps `startedAt`, `pluginName` and + // progress monotonic so re-syncing never rewinds the bar. + const existing = updatedTasks[idx]; + const converted = asyncTaskToPluginInstallTask(bt, existing); + converted.pluginName = existing.pluginName; + converted.fileSize = existing.fileSize; + converted.extensionType = existing.extensionType; + + // Never downgrade a terminal task that is already done/failed locally, + // unless the backend reports it finished as well. + if ( + (existing.stage === InstallStage.DONE || + existing.stage === InstallStage.ERROR) && + !bt.runtime.done + ) { + continue; + } + + updatedTasks[idx] = converted; + + if (!bt.runtime.done) { + toPoll.push({ key: converted.id, taskId: bt.id }); } } + // Schedule polling outside the state updater. + queueMicrotask(() => { + toPoll.forEach(({ key, taskId }) => pollTask(key, taskId)); + }); + return updatedTasks; }); } catch { @@ -464,6 +665,7 @@ export function PluginInstallTaskProvider({ // Remove from dismissed set if re-added dismissedTaskIds.current.delete(params.taskId); + const startedAt = Date.now(); const newTask: PluginInstallTask = { id: taskKey, taskId: params.taskId, @@ -471,9 +673,11 @@ export function PluginInstallTaskProvider({ source: params.source, extensionType: params.extensionType, stage: InstallStage.DOWNLOADING, - overallProgress: 5, + // Start at the downloading floor and creep up from real counters. + overallProgress: stageFloor(InstallStage.DOWNLOADING), fileSize: params.fileSize, - startedAt: Date.now(), + downloadTotal: params.fileSize, + startedAt, 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..cf39e0389 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 @@ -7,6 +7,7 @@ import { CheckCircle2, XCircle, Loader2, + Rocket, X, ListTodo, Puzzle, @@ -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-market/PluginMarketComponent.tsx b/web/src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx index b13e13ff3..5927ca64a 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 { @@ -51,6 +58,10 @@ import { ApiRespMarketplacePlugins } from '@/app/infra/entities/api'; import { LoadingSpinner } from '@/components/ui/loading-spinner'; import { Button } from '@/components/ui/button'; import { PluginTag } from '@/app/infra/http/CloudServiceClient'; +import { + resolveInstalledState, + useMarketplaceInstalledIndex, +} from './marketplace-installed'; interface SortOption { value: string; @@ -91,6 +102,20 @@ function MarketPageContent({ const { t } = useTranslation(); const [searchParams] = useSearchParams(); + // Installed-extension lookup, recomputed whenever the sidebar lists change + // (e.g. right after an install completes). + const installedIndex = useMarketplaceInstalledIndex(); + + const decorateInstalled = useCallback( + (vo: PluginMarketCardVO): PluginMarketCardVO => { + const state = resolveInstalledState(installedIndex, vo); + vo.installed = state.installed; + vo.hasUpdate = state.hasUpdate; + return vo; + }, + [installedIndex], + ); + const validTypes = ['plugin', 'mcp', 'skill']; const extensionTypeOptions = [ @@ -571,7 +596,12 @@ function MarketPageContent({ }; }, []); - const visiblePlugins = plugins; + // Decorate with installed state at render time so the badge updates the + // moment the sidebar lists refresh (e.g. after an install completes). + const visiblePlugins = useMemo( + () => plugins.map((plugin) => decorateInstalled(plugin)), + [plugins, decorateInstalled], + ); // 加载更多 const loadMore = useCallback(() => { 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..1a26be79e 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, + useMarketplaceInstalledIndex, +} from './marketplace-installed'; export interface RecommendationList { uuid: string; @@ -66,6 +70,7 @@ function RecommendationListRow({ isLast: boolean; }) { const { t } = useTranslation(); + const installedIndex = useMarketplaceInstalledIndex(); const [page, setPage] = useState(0); const [perPage, setPerPage] = useState(4); // Countdown progress to the next auto-advance, 0 → 1 over AUTO_ADVANCE_MS. @@ -261,16 +266,22 @@ function RecommendationListRow({ ref={gridRef} className="grid gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,24rem),1fr))]" > - {visiblePlugins.map((plugin) => ( - - ))} + {visiblePlugins.map((plugin) => { + const cardVO = pluginToVO(plugin, t); + const state = resolveInstalledState(installedIndex, cardVO); + cardVO.installed = state.installed; + cardVO.hasUpdate = state.hasUpdate; + return ( + + ); + })} {totalPages > 1 && !isLast && (
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..1b24dee6b --- /dev/null +++ b/web/src/app/home/plugins/components/plugin-market/marketplace-installed.ts @@ -0,0 +1,85 @@ +import { useMemo } from 'react'; +import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext'; + +export interface MarketplaceInstalledState { + installed: boolean; + hasUpdate: boolean; +} + +export interface InstalledIndexEntry { + hasUpdate: boolean; +} + +/** Composite key used to look up installed extensions: `type:author/name`. */ +export function installedExtensionKey( + type: string | undefined, + author: string, + name: string, +): string { + return `${type || 'plugin'}:${author}/${name}`; +} + +/** + * Build a lookup of already-installed extensions. + * + * The sidebar identifies each kind differently: + * - plugins: `author/name` + * - MCP servers: `author__name` (double underscore) + * - skills: the bare skill name + */ +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(`plugin:${plugin.id}`, { hasUpdate: plugin.hasUpdate ?? false }); + } + for (const server of mcpServers) { + index.set(`mcp:${server.id.replace(/__/g, '/')}`, { hasUpdate: false }); + } + for (const skill of skills) { + index.set(`skill:${skill.id}`, { hasUpdate: false }); + } + return index; +} + +/** + * Resolve whether a marketplace extension is installed. + * + * Marketplace entries always use `author/name`; skills may be stored under + * their bare name, so both keys are checked for that case. + */ +export function resolveInstalledState( + index: Map, + extension: { type?: string; author: string; pluginName: string }, +): MarketplaceInstalledState { + const type = extension.type || 'plugin'; + const keys = [ + `${type}:${extension.author}/${extension.pluginName}`, + `${type}:${extension.pluginName}`, + ]; + for (const key of keys) { + const entry = index.get(key); + if (entry) { + return { installed: true, hasUpdate: entry.hasUpdate }; + } + } + return { installed: false, hasUpdate: false }; +} + +/** + * Reactive installed-extension index derived from the sidebar data context. + * Recomputes automatically after an install finishes and the sidebar refreshes. + */ +export function useMarketplaceInstalledIndex(): Map< + string, + InstalledIndexEntry +> { + const { plugins, mcpServers, skills } = useSidebarData(); + return useMemo( + () => buildInstalledIndex(plugins, mcpServers, skills), + [plugins, mcpServers, skills], + ); +} 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..0b399693c 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 { + CheckCircle2, + Info, + Package, + ExternalLink, + Heart, + Loader2, +} from 'lucide-react'; import { Tooltip, TooltipContent, @@ -48,6 +55,10 @@ export default function PluginMarketCardComponent({ return keys.length > 0 && keys.every((k) => k === 'KnowledgeRetriever'); })(); + // Already installed → swap the download count for an "installed" marker. + // Click behaviour stays identical to a normal card. + const isInstalled = cardVO.installed === true; + const showTypeBadge = cardVO.type; const typeLabel = cardVO.type === 'mcp' @@ -320,23 +331,34 @@ export default function PluginMarketCardComponent({ className="w-full flex flex-row items-center justify-between gap-2 px-0 sm:px-[0.4rem] flex-shrink-0 overflow-hidden" >
-
- - - - - -
- {cardVO.installCount?.toLocaleString() ?? '0'} + {/* Installed extensions replace the download count with an + "installed" marker so the card reflects local state. */} + {isInstalled ? ( +
+ +
+ {t('market.installed')} +
-
+ ) : ( +
+ + + + + +
+ {cardVO.installCount?.toLocaleString() ?? '0'} +
+
+ )} {cardVO.tags && cardVO.tags.length > 0 && visibleTags > 0 && (
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..df8a28085 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 an 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/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..6ba6e7a8b 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -748,6 +748,9 @@ const enUS = { 'Are you sure you want to install plugin "{{name}}" ({{version}})?', downloadComplete: 'Plugin "{{name}}" download completed', installFailed: 'Installation failed, please try again later', + installed: 'Installed', + updateAvailable: 'Update available', + alreadyInstalled: '{{name}} is already installed', loadFailed: 'Failed to get plugin list, please try again later', noDescription: 'No description available', recommendation: { diff --git a/web/src/i18n/locales/es-ES.ts b/web/src/i18n/locales/es-ES.ts index 5c8c2edb7..e30387d18 100644 --- a/web/src/i18n/locales/es-ES.ts +++ b/web/src/i18n/locales/es-ES.ts @@ -769,6 +769,9 @@ const esES = { installFailed: 'Error en la instalación, por favor inténtalo más tarde', loadFailed: 'Error al obtener la lista de plugins, por favor inténtalo más tarde', + installed: 'Instalado', + updateAvailable: 'Actualización disponible', + alreadyInstalled: '{{name}} ya está instalado', noDescription: 'No hay descripción disponible', recommendation: { pause: 'Pausar rotación automática', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index 4460f2640..9d9e615e2 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -758,6 +758,9 @@ const jaJP = { installFailed: 'インストールに失敗しました。後でもう一度お試しください', loadFailed: 'プラグインリストの取得に失敗しました。後でもう一度お試しください', + installed: 'インストール済み', + updateAvailable: '更新あり', + alreadyInstalled: '{{name}} はインストール済みです', noDescription: '説明がありません', recommendation: { pause: '自動ローテーションを一時停止', diff --git a/web/src/i18n/locales/ru-RU.ts b/web/src/i18n/locales/ru-RU.ts index 478af0b97..104d4ccad 100644 --- a/web/src/i18n/locales/ru-RU.ts +++ b/web/src/i18n/locales/ru-RU.ts @@ -763,6 +763,9 @@ const ruRU = { downloadComplete: 'Плагин "{{name}}" загружен', installFailed: 'Ошибка установки, попробуйте позже', loadFailed: 'Не удалось получить список плагинов, попробуйте позже', + installed: 'Установлено', + updateAvailable: 'Доступно обновление', + alreadyInstalled: '{{name}} уже установлен', noDescription: 'Описание отсутствует', recommendation: { pause: 'Приостановить авто-прокрутку', diff --git a/web/src/i18n/locales/th-TH.ts b/web/src/i18n/locales/th-TH.ts index 51e1e6c3e..26594098a 100644 --- a/web/src/i18n/locales/th-TH.ts +++ b/web/src/i18n/locales/th-TH.ts @@ -741,6 +741,9 @@ const thTH = { downloadComplete: 'ดาวน์โหลดปลั๊กอิน "{{name}}" เสร็จสมบูรณ์', installFailed: 'ติดตั้งล้มเหลว กรุณาลองใหม่ภายหลัง', loadFailed: 'ไม่สามารถดึงรายการปลั๊กอินได้ กรุณาลองใหม่ภายหลัง', + installed: 'ติดตั้งแล้ว', + updateAvailable: 'มีอัปเดต', + alreadyInstalled: '{{name}} ติดตั้งแล้ว', noDescription: 'ไม่มีคำอธิบาย', recommendation: { pause: 'หยุดการหมุนอัตโนมัติชั่วคราว', diff --git a/web/src/i18n/locales/vi-VN.ts b/web/src/i18n/locales/vi-VN.ts index c30791515..a41085b2e 100644 --- a/web/src/i18n/locales/vi-VN.ts +++ b/web/src/i18n/locales/vi-VN.ts @@ -756,6 +756,9 @@ const viVN = { downloadComplete: 'Tải plugin "{{name}}" hoàn tất', installFailed: 'Cài đặt thất bại, vui lòng thử lại sau', loadFailed: 'Lấy danh sách plugin thất bại, vui lòng thử lại sau', + installed: 'Đã cài đặt', + updateAvailable: 'Có bản cập nhật', + alreadyInstalled: '{{name}} đã được cài đặt', noDescription: 'Không có mô tả', recommendation: { pause: 'Tạm dừng tự động xoay', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 1b9d40ec4..fcdb09aae 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -715,6 +715,9 @@ const zhHans = { installConfirm: '确定要安装插件 "{{name}}" ({{version}}) 吗?', downloadComplete: '插件 "{{name}}" 下载完成', installFailed: '安装失败,请稍后重试', + installed: '已安装', + updateAvailable: '有更新', + alreadyInstalled: '{{name}} 已安装', loadFailed: '获取插件列表失败,请稍后重试', noDescription: '暂无描述', recommendation: { diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 693950c5e..9a3fc12fe 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -719,6 +719,9 @@ const zhHant = { downloadComplete: '插件 "{{name}}" 下載完成', installFailed: '安裝失敗,請稍後重試', loadFailed: '取得插件列表失敗,請稍後重試', + installed: '已安裝', + updateAvailable: '有更新', + alreadyInstalled: '{{name}} 已安裝', noDescription: '暫無描述', recommendation: { pause: '暫停自動輪播',