diff --git a/src/langbot/pkg/plugin/connector.py b/src/langbot/pkg/plugin/connector.py index 99c072dc8..47b54c20f 100644 --- a/src/langbot/pkg/plugin/connector.py +++ b/src/langbot/pkg/plugin/connector.py @@ -104,7 +104,7 @@ async def _read_httpx_response_limited( task_context: taskmgr.TaskContext | None = None, ) -> bytes: content_length = response.headers.get('content-length') - declared_size = None + declared_size: int | None = None if content_length is not None: try: declared_size = int(content_length) @@ -1759,6 +1759,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 @@ -1814,6 +1815,18 @@ 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') @@ -1860,8 +1873,12 @@ class PluginRuntimeConnector(ManagedRuntimeConnector): task_context.set_current_action('preparing plugin installation') task_context.metadata['progress_percent'] = 45 + 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: # Persist and publish the new desired generation under the same # gate used by request-time reconciliation. This closes the small @@ -1901,7 +1918,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector): if task_context is not None: operation = task_context.metadata.get('operation') task_context.set_current_action( - 'applying plugin update' if operation == 'upgrade' else 'installing plugin dependencies' + 'applying plugin update' if operation == 'upgrade' else 'installing or starting plugin' ) task_context.metadata['progress_percent'] = 62 await self._apply_desired_state( diff --git a/tests/unit_tests/plugin/test_connector_reconcile.py b/tests/unit_tests/plugin/test_connector_reconcile.py index d5292f8f3..2a7ea1e1e 100644 --- a/tests/unit_tests/plugin/test_connector_reconcile.py +++ b/tests/unit_tests/plugin/test_connector_reconcile.py @@ -381,7 +381,7 @@ async def test_marketplace_upgrade_reports_multistep_progress(): assert observed_actions == [ 'downloading plugin package', 'validating plugin package', - 'preparing plugin installation', + 'storing plugin package', 'applying plugin update', 'waiting for plugin initialization', 'refreshing plugin components', @@ -392,6 +392,9 @@ async def test_marketplace_upgrade_reports_multistep_progress(): 'install_source': 'marketplace', 'operation': 'upgrade', 'progress_percent': 100, + 'download_total': 0, + 'download_current': 0, + 'download_speed': 0, } 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 d69c8a22a..6714c2753 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,12 +12,12 @@ import { Package, Server, Sparkles, + Rocket, CheckCircle2, XCircle, Loader2, RefreshCcw, ShieldCheck, - Rocket, } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { @@ -63,7 +63,7 @@ function getStages(task: PluginInstallTask): StageConfig[] { : 'plugins.installProgress.installingDeps', }, { - key: InstallStage.ACTIVATING, + key: InstallStage.LAUNCHING, icon: Rocket, i18nKey: 'plugins.installProgress.activating', }, @@ -71,7 +71,9 @@ function getStages(task: PluginInstallTask): StageConfig[] { } function getStageIndex(stages: StageConfig[], stage: InstallStage): number { - const idx = stages.findIndex((item) => item.key === stage); + const displayStage = + stage === InstallStage.INITIALIZING ? InstallStage.LAUNCHING : stage; + const idx = stages.findIndex((item) => item.key === displayStage); return idx >= 0 ? idx : -1; } @@ -252,53 +254,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 0ed8d5cdd..8fecd0eee 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,19 +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 { - CHECKING = 'checking', - DOWNLOADING = 'downloading', - VALIDATING = 'validating', - INSTALLING_DEPS = 'installing_deps', - ACTIVATING = 'activating', - DONE = 'done', - ERROR = 'error', -} +export { InstallStage } from './install-progress'; export type PluginTaskOperation = 'install' | 'upgrade'; @@ -39,15 +34,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 } @@ -90,64 +80,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('check')) return InstallStage.CHECKING; - if (lower.includes('download')) return InstallStage.DOWNLOADING; - if (lower.includes('validat') || lower.includes('inspect')) - return InstallStage.VALIDATING; - if (lower.includes('dependencies') || lower.includes('requirements')) - return InstallStage.INSTALLING_DEPS; - if ( - lower.includes('preparing') || - lower.includes('applying') || - lower.includes('installing') - ) - return InstallStage.INSTALLING_DEPS; - if ( - lower.includes('initializ') || - lower.includes('launch') || - lower.includes('refresh') || - lower.includes('waiting') - ) - return InstallStage.ACTIVATING; - if ( - lower.includes('installed') || - lower.includes('updated') || - lower.includes('complete') - ) - return InstallStage.DONE; - return InstallStage.DOWNLOADING; -} - -/** - * Get overall progress percentage from a stage. - */ -function stageToProgress(stage: InstallStage): number { - switch (stage) { - case InstallStage.CHECKING: - return 5; - case InstallStage.DOWNLOADING: - return 18; - case InstallStage.VALIDATING: - return 35; - case InstallStage.INSTALLING_DEPS: - return 60; - case InstallStage.ACTIVATING: - return 85; - case InstallStage.DONE: - return 100; - case InstallStage.ERROR: - return 0; - default: - return 0; - } -} - /** * Extract install source from backend task name. */ @@ -210,7 +142,10 @@ export function asyncTaskToPluginInstallTask( if (exception) { stage = InstallStage.ERROR; failedStage = mapActionToStage(action); - overallProgress = stageToProgress(failedStage); + overallProgress = computeStageProgress({ + stage: failedStage, + stageElapsedSeconds: 0, + }); error = exception; } else { stage = InstallStage.DONE; @@ -219,8 +154,13 @@ export function asyncTaskToPluginInstallTask( } else { stage = mapActionToStage(action); overallProgress = Math.min( - 95, - num(md.progress_percent) ?? stageToProgress(stage), + INSTALL_PROGRESS_CAP, + computeStageProgress({ + stage, + downloadCurrent: num(md.download_current), + downloadTotal: num(md.download_total), + stageElapsedSeconds: 0, + }), ); } @@ -246,14 +186,9 @@ export function asyncTaskToPluginInstallTask( 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: task.created_at ? task.created_at * 1000 : Date.now(), + stageStartedAt: Date.now(), currentAction: action, }; } @@ -327,20 +262,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); - const reportedProgress = num(md.progress_percent); setTasks((prev) => prev.map((t) => { @@ -350,13 +278,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) { @@ -374,9 +295,10 @@ export function PluginInstallTaskProvider({ stage: InstallStage.ERROR, failedStage: mapActionToStage(action), error: exception, - overallProgress: stageToProgress( - mapActionToStage(action), - ), + overallProgress: computeStageProgress({ + stage: mapActionToStage(action), + stageElapsedSeconds: 0, + }), currentAction: action, ...progressFields, }; @@ -393,21 +315,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, - reportedProgress ?? baseProgress + withinStageIncrement, + INSTALL_PROGRESS_CAP, + Math.max(t.overallProgress, stageProgress), ); return { ...t, stage, + stageStartedAt, overallProgress: progress, currentAction: action, ...progressFields, @@ -540,6 +469,7 @@ export function PluginInstallTaskProvider({ overallProgress: operation === 'upgrade' ? 3 : 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 748ee2dd3..2a5ebbab5 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, @@ -14,7 +15,6 @@ import { Sparkles, RefreshCcw, ShieldCheck, - Rocket, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { @@ -35,7 +35,8 @@ const STAGE_ICONS: Record = { [InstallStage.DOWNLOADING]: Download, [InstallStage.VALIDATING]: ShieldCheck, [InstallStage.INSTALLING_DEPS]: Package, - [InstallStage.ACTIVATING]: Rocket, + [InstallStage.INITIALIZING]: Rocket, + [InstallStage.LAUNCHING]: Rocket, [InstallStage.DONE]: CheckCircle2, [InstallStage.ERROR]: XCircle, }; @@ -110,7 +111,8 @@ function TaskQueueItem({ return task.operation === 'upgrade' ? t('plugins.installProgress.applyingUpdate') : t('plugins.installProgress.installingDeps'); - case InstallStage.ACTIVATING: + case InstallStage.INITIALIZING: + case InstallStage.LAUNCHING: return t('plugins.installProgress.activating'); case InstallStage.DONE: return isDone 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..d84751c5e --- /dev/null +++ b/web/src/app/home/plugins/components/plugin-install-task/install-progress.ts @@ -0,0 +1,148 @@ +/** + * 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 { + CHECKING = 'checking', + VALIDATING = 'validating', + 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('updated') || + 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; + } + if (lower.includes('checking')) return InstallStage.CHECKING; + if (lower.includes('validat')) return InstallStage.VALIDATING; + if (lower.includes('refresh')) return InstallStage.LAUNCHING; + if (lower.includes('applying')) return InstallStage.INSTALLING_DEPS; + + // 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.CHECKING]: [3, 5], + [InstallStage.VALIDATING]: [45, 45], + [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 8719b36f5..76e7ad956 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'; import { pluginTaskKey, usePluginInstallTasks, @@ -64,12 +64,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 { addTask, setSelectedTaskId } = usePluginInstallTasks(); @@ -317,11 +321,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 @@ -471,10 +485,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 ea39dcb5f..1dd640e0e 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 { @@ -38,6 +45,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 { @@ -157,6 +166,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); @@ -622,7 +633,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(() => { @@ -936,6 +967,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 323de8ba1..0cf603042 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(() => { @@ -188,6 +189,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 c55810e6c..301538bbb 100644 --- a/web/src/app/infra/entities/api/index.ts +++ b/web/src/app/infra/entities/api/index.ts @@ -649,11 +649,12 @@ export interface AsyncTaskTaskContext { export interface AsyncTask { id: number; - created_at?: number; kind: string; 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/app/wizard/page.tsx b/web/src/app/wizard/page.tsx index be411bed2..8e9f51dee 100644 --- a/web/src/app/wizard/page.tsx +++ b/web/src/app/wizard/page.tsx @@ -523,7 +523,7 @@ export default function WizardPage() { setRunnerInstallProgress((current) => ({ ...current, [pluginId]: { - stage: registering ? InstallStage.ACTIVATING : progress.stage, + stage: registering ? InstallStage.LAUNCHING : progress.stage, percent: registering ? 95 : progress.overallProgress, }, })); @@ -1870,7 +1870,8 @@ const RUNNER_INSTALL_STAGE_LABELS: Record = { [InstallStage.DOWNLOADING]: 'plugins.installProgress.downloading', [InstallStage.VALIDATING]: 'plugins.installProgress.validating', [InstallStage.INSTALLING_DEPS]: 'plugins.installProgress.installingDeps', - [InstallStage.ACTIVATING]: 'plugins.installProgress.activating', + [InstallStage.INITIALIZING]: 'plugins.installProgress.activating', + [InstallStage.LAUNCHING]: 'plugins.installProgress.activating', [InstallStage.DONE]: 'plugins.installProgress.completed', [InstallStage.ERROR]: 'plugins.installProgress.failed', }; diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index ba0168a73..1b66621c0 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -290,6 +290,7 @@ const enUS = { delete: 'Delete', add: 'Add', select: 'Select', + clear: 'Clear', skill: 'Skill', cancel: 'Cancel', submit: 'Submit', @@ -1364,6 +1365,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', groupByTypeShort: 'Group', @@ -1572,6 +1575,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 784de31cb..dbfe88ab2 100644 --- a/web/src/i18n/locales/es-ES.ts +++ b/web/src/i18n/locales/es-ES.ts @@ -295,6 +295,7 @@ const esES = { delete: 'Eliminar', add: 'Añadir', select: 'Seleccionar', + clear: 'Limpiar', skill: 'Habilidad', cancel: 'Cancelar', submit: 'Enviar', @@ -1397,6 +1398,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', groupByTypeShort: 'Agrupar', @@ -1688,6 +1691,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 bf388ff38..3b5d6c1fd 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -292,6 +292,7 @@ const jaJP = { delete: '削除', add: '追加', select: '選択してください', + clear: 'クリア', skill: 'スキル', cancel: 'キャンセル', submit: '送信', @@ -1384,6 +1385,8 @@ const jaJP = { getPluginListError: 'プラグインリストの取得に失敗しました:', noPluginInstalled: 'プラグインがインストールされていません', noExtensionInstalled: '拡張機能がインストールされていません', + searchInstalled: 'インストール済み拡張機能を検索', + noMatchingExtensions: '「{{query}}」に一致する拡張機能はありません', loadingExtensions: '拡張機能を読み込み中...', groupByType: '形式でグループ化', groupByTypeShort: 'グループ', @@ -1672,6 +1675,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 438dae4d9..fd9f8831f 100644 --- a/web/src/i18n/locales/ru-RU.ts +++ b/web/src/i18n/locales/ru-RU.ts @@ -294,6 +294,7 @@ const ruRU = { delete: 'Удалить', add: 'Добавить', select: 'Выбрать', + clear: 'Очистить', skill: 'Навык', cancel: 'Отмена', submit: 'Отправить', @@ -1397,6 +1398,8 @@ const ruRU = { getPluginListError: 'Не удалось получить список плагинов:', noPluginInstalled: 'Плагины не установлены', noExtensionInstalled: 'Расширения не установлены', + searchInstalled: 'Поиск установленных расширений', + noMatchingExtensions: 'Нет расширений, соответствующих «{{query}}»', loadingExtensions: 'Загрузка расширений...', groupByType: 'Группировать по формату', groupByTypeShort: 'Группа', @@ -1685,6 +1688,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 8cbf6e716..8f6f1faf6 100644 --- a/web/src/i18n/locales/th-TH.ts +++ b/web/src/i18n/locales/th-TH.ts @@ -289,6 +289,7 @@ const thTH = { delete: 'ลบ', add: 'เพิ่ม', select: 'เลือก', + clear: 'ล้าง', skill: 'สกิล', cancel: 'ยกเลิก', submit: 'ส่ง', @@ -1347,6 +1348,8 @@ const thTH = { getPluginListError: 'ไม่สามารถดึงรายการปลั๊กอินได้:', noPluginInstalled: 'ยังไม่มีปลั๊กอินที่ติดตั้ง', noExtensionInstalled: 'ยังไม่มีส่วนขยายที่ติดตั้ง', + searchInstalled: 'ค้นหาส่วนขยายที่ติดตั้งแล้ว', + noMatchingExtensions: 'ไม่มีส่วนขยายที่ตรงกับ "{{query}}"', loadingExtensions: 'กำลังโหลดส่วนขยาย...', groupByType: 'จัดกลุ่มตามรูปแบบ', groupByTypeShort: 'จัดกลุ่ม', @@ -1628,6 +1631,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 9a542d8d2..b23f30580 100644 --- a/web/src/i18n/locales/vi-VN.ts +++ b/web/src/i18n/locales/vi-VN.ts @@ -291,6 +291,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', @@ -1367,6 +1368,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', groupByTypeShort: 'Nhóm', @@ -1653,6 +1656,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 c32caef36..5382b3f03 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -273,6 +273,7 @@ const zhHans = { delete: '删除', add: '添加', select: '请选择', + clear: '清除', skill: '技能', cancel: '取消', submit: '提交', @@ -1285,6 +1286,8 @@ const zhHans = { pluginConfig: '插件配置', noPluginInstalled: '暂未安装任何插件', noExtensionInstalled: '暂未安装任何扩展', + searchInstalled: '搜索已安装扩展', + noMatchingExtensions: '没有匹配「{{query}}」的扩展', loadingExtensions: '正在加载扩展...', groupByType: '按格式分组', groupByTypeShort: '分组', @@ -1482,6 +1485,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 fe63dcd97..2b8b15e3a 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -273,6 +273,7 @@ const zhHant = { delete: '刪除', add: '新增', select: '請選擇', + clear: '清除', skill: '技能', cancel: '取消', submit: '提交', @@ -1292,6 +1293,8 @@ const zhHant = { pluginConfig: '外掛設定', noPluginInstalled: '暫未安裝任何外掛', noExtensionInstalled: '暫未安裝任何擴充功能', + searchInstalled: '搜尋已安裝擴充功能', + noMatchingExtensions: '沒有符合「{{query}}」的擴充功能', loadingExtensions: '正在載入擴充功能...', groupByType: '依格式分組', groupByTypeShort: '分組', @@ -1489,6 +1492,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 fa15dcaba..34a69670d 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', + ); +});