diff --git a/src/langbot/pkg/plugin/connector.py b/src/langbot/pkg/plugin/connector.py index 7df06aefe..cf3f4b4aa 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,25 @@ async def _read_httpx_response_limited( if declared_size is not None and declared_size > max_bytes: raise ValueError(f'Remote response exceeds the {max_bytes}-byte limit') + if task_context is not None and declared_size is not None: + # Publish the advertised size up-front so the UI can render a + # determinate bar even before the first chunk arrives. + task_context.metadata['download_total'] = declared_size + + start_time = time.time() body = bytearray() async for chunk in response.aiter_bytes(chunk_size=64 * 1024): body.extend(chunk) if len(body) > max_bytes: raise ValueError(f'Remote response exceeds the {max_bytes}-byte limit') + if task_context is not None: + elapsed = time.time() - start_time + task_context.metadata.update( + { + 'download_current': len(body), + 'download_speed': len(body) / elapsed if elapsed > 0 else 0, + } + ) return bytes(body) @@ -111,6 +127,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 +136,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 +1698,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 +1714,21 @@ class PluginRuntimeConnector(ManagedRuntimeConnector): plugin_name = str(install_info.get('plugin_name') or '') file_bytes: bytes | None + if task_context is not None: + # Reset the per-install counters so re-installing the same plugin + # cannot inherit stale progress metadata from a previous task. + task_context.set_current_action('preparing plugin install') + task_context.metadata.update( + { + 'download_total': 0, + 'download_current': 0, + 'download_speed': 0, + } + ) + if install_source == PluginInstallSource.MARKETPLACE: + if task_context is not None: + task_context.set_current_action('downloading plugin package') file_bytes, version = await self._download_marketplace_package( execution_context, plugin_author, @@ -1719,6 +1752,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 +1765,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 +1788,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 +1807,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..f4f7ede31 100644 --- a/web/src/app/home/plugins/components/plugin-install-task/PluginInstallProgressDialog.tsx +++ b/web/src/app/home/plugins/components/plugin-install-task/PluginInstallProgressDialog.tsx @@ -12,6 +12,7 @@ import { Package, Server, Sparkles, + Rocket, CheckCircle2, XCircle, Loader2, @@ -39,6 +40,11 @@ const STAGES: { icon: Package, i18nKey: 'plugins.installProgress.installingDeps', }, + { + key: InstallStage.LAUNCHING, + icon: Rocket, + i18nKey: 'plugins.installProgress.launching', + }, ]; function getStageIndex(stage: InstallStage): number { 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..92878dbb3 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 @@ -84,42 +84,84 @@ export function usePluginInstallTasks() { } /** - * Map backend `current_action` to our InstallStage. + * 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. */ 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')) + const lower = (action || '').toLowerCase(); + + if ( + lower.includes('installed') || + lower.includes('complete') || + lower.includes('ready') + ) { + // "waiting for plugin to become ready" is still an active stage. + if (lower.includes('waiting')) return InstallStage.LAUNCHING; return InstallStage.DONE; + } + if (lower.includes('launch') || lower.includes('start')) { + return InstallStage.LAUNCHING; + } + // Check the pre-download stages before the generic "install" match below, + // because "preparing plugin install" also contains "install". + if (lower.includes('prepar')) return InstallStage.DOWNLOADING; + if (lower.includes('download')) return InstallStage.DOWNLOADING; + + if ( + lower.includes('dependenc') || + lower.includes('requirements') || + lower.includes('install') + ) { + return InstallStage.INSTALLING_DEPS; + } + if (lower.includes('initializ') || lower.includes('configur')) { + return InstallStage.INITIALIZING; + } + if (lower.includes('inspect') || lower.includes('storing')) { + return InstallStage.INSTALLING_DEPS; + } + return InstallStage.DOWNLOADING; } /** - * Get overall progress percentage from a stage. + * Progress range (start → end) attributed to each stage, used to build a + * smooth determinate bar that never goes backwards. */ -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; +const STAGE_PROGRESS_RANGE: Record = { + [InstallStage.DOWNLOADING]: [5, 45], + [InstallStage.INSTALLING_DEPS]: [45, 85], + [InstallStage.INITIALIZING]: [85, 88], + [InstallStage.LAUNCHING]: [88, 97], + [InstallStage.DONE]: [100, 100], + [InstallStage.ERROR]: [0, 0], +}; + +/** + * Compute overall progress, preferring real byte counts over the stage range + * when the backend has reported a download size. + */ +function computeOverallProgress(task: { + stage: InstallStage; + downloadCurrent?: number; + downloadTotal?: number; +}): number { + const [start, end] = STAGE_PROGRESS_RANGE[task.stage] ?? [0, 0]; + + if ( + task.stage === InstallStage.DOWNLOADING && + task.downloadTotal && + task.downloadTotal > 0 && + task.downloadCurrent != null + ) { + const ratio = Math.min(1, task.downloadCurrent / task.downloadTotal); + return Math.round(start + (end - start) * ratio); } + + return start; } /** @@ -172,7 +214,14 @@ function asyncTaskToPluginInstallTask(task: AsyncTask): PluginInstallTask { } } else { stage = mapActionToStage(action); - overallProgress = Math.min(95, stageToProgress(stage)); + overallProgress = Math.min( + 99, + computeOverallProgress({ + stage, + downloadCurrent: num(md.download_current), + downloadTotal: num(md.download_total), + }), + ); } const pluginName = str(md.plugin_name) || task.label || `${source} extension`; @@ -333,16 +382,26 @@ export function PluginInstallTaskProvider({ } const stage = mapActionToStage(action); - const baseProgress = stageToProgress(stage); - // Add small time-based increment within stage + const [rangeStart, rangeEnd] = STAGE_PROGRESS_RANGE[stage] ?? [ + 0, 0, + ]; + // Prefer real byte counts where available; otherwise drift + // slowly inside the current stage so the bar still moves. const elapsed = (Date.now() - t.startedAt) / 1000; - const withinStageIncrement = Math.min( - 15, + const drift = Math.min( + Math.max(0, rangeEnd - rangeStart - 1), Math.floor(elapsed / 2), ); const progress = Math.min( - 95, - baseProgress + withinStageIncrement, + 99, + Math.max( + t.overallProgress, + computeOverallProgress({ + stage, + downloadCurrent, + downloadTotal, + }) + drift, + ), ); return { diff --git a/web/src/app/home/plugins/components/plugin-install-task/PluginInstallTaskQueue.tsx b/web/src/app/home/plugins/components/plugin-install-task/PluginInstallTaskQueue.tsx index d8c9e9869..e53436452 100644 --- a/web/src/app/home/plugins/components/plugin-install-task/PluginInstallTaskQueue.tsx +++ b/web/src/app/home/plugins/components/plugin-install-task/PluginInstallTaskQueue.tsx @@ -4,6 +4,7 @@ import { Progress } from '@/components/ui/progress'; import { Download, Package, + Rocket, CheckCircle2, XCircle, Loader2, @@ -30,6 +31,7 @@ import { cn } from '@/lib/utils'; const STAGE_ICONS: Record = { [InstallStage.DOWNLOADING]: Download, [InstallStage.INSTALLING_DEPS]: Package, + [InstallStage.LAUNCHING]: Rocket, [InstallStage.DONE]: CheckCircle2, [InstallStage.ERROR]: XCircle, }; @@ -95,6 +97,8 @@ function TaskQueueItem({ return t('plugins.installProgress.downloading'); case InstallStage.INSTALLING_DEPS: return t('plugins.installProgress.installingDeps'); + case InstallStage.LAUNCHING: + return t('plugins.installProgress.launching'); case InstallStage.DONE: return isDone ? getInstallCompleteMessage() diff --git a/web/src/app/home/plugins/components/plugin-installed/PluginInstalledComponent.tsx b/web/src/app/home/plugins/components/plugin-installed/PluginInstalledComponent.tsx index 4429f7958..fb429b0a4 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,8 @@ 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 { Input } from '@/components/ui/input'; +import { Loader2, Puzzle, Search, Server, Sparkles, X } from 'lucide-react'; export interface PluginInstalledComponentRef { refreshPluginList: () => void; @@ -60,12 +61,16 @@ export const FilterOptions = [ interface PluginInstalledComponentProps { filterType: FilterType; groupByType: boolean; + /** Free-text filter over label / name / author / description. */ + searchQuery?: string; + /** Invoked when the user clears the search from the empty state. */ + onClearSearch?: () => void; } const PluginInstalledComponent = forwardRef< PluginInstalledComponentRef, PluginInstalledComponentProps ->(({ filterType, groupByType }, ref) => { +>(({ filterType, groupByType, searchQuery = '', onClearSearch }, ref) => { const { t } = useTranslation(); const navigate = useNavigate(); const { refreshPlugins, refreshMCPServers, refreshSkills } = useSidebarData(); @@ -307,11 +312,21 @@ const PluginInstalledComponent = forwardRef< }); } + // Match the query against the fields a user can actually see on the card + // (label / name / author) plus the description, case-insensitively. + const normalizedQuery = searchQuery.trim().toLowerCase(); const filteredExtensions = extensionList.filter((ext) => { - if (filterType === 'all') return true; - return ext.type === filterType; + if (filterType !== 'all' && ext.type !== filterType) return false; + if (!normalizedQuery) return true; + return [ext.label, ext.name, ext.author, ext.description].some((field) => + (field || '').toLowerCase().includes(normalizedQuery), + ); }); + const clearSearch = () => { + onClearSearch?.(); + }; + const showGrouped = groupByType && filterType === 'all'; const groupOrder: ExtensionType[] = ['plugin', 'mcp', 'skill']; const groupedExtensions = groupOrder @@ -461,10 +476,26 @@ const PluginInstalledComponent = forwardRef< ) : filteredExtensions.length === 0 ? (
- -
- {t('plugins.noExtensionInstalled')} -
+ {normalizedQuery ? ( + <> + +
+ {t('plugins.noMatchingExtensions', { + query: searchQuery.trim(), + })} +
+ + + ) : ( + <> + +
+ {t('plugins.noExtensionInstalled')} +
+ + )}
) : showGrouped ? (
diff --git a/web/src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx b/web/src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx index b13e13ff3..bcfcbb455 100644 --- a/web/src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx +++ b/web/src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx @@ -1,4 +1,11 @@ -import { useState, useEffect, useCallback, useRef, Suspense } from 'react'; +import { + useState, + useEffect, + useCallback, + useMemo, + useRef, + Suspense, +} from 'react'; import { useSearchParams } from 'react-router-dom'; import { Input } from '@/components/ui/input'; import { @@ -37,6 +44,10 @@ import { } from '@/components/ui/tooltip'; import PluginMarketCardComponent from './plugin-market-card/PluginMarketCardComponent'; import { PluginMarketCardVO } from './plugin-market-card/PluginMarketCardVO'; +import { + resolveInstalledState, + useMarketplaceInstalledIndex, +} from './marketplace-installed'; import { RecommendationLists } from './RecommendationLists'; import type { RecommendationList } from './RecommendationLists'; import { @@ -122,6 +133,8 @@ function MarketPageContent({ const [recommendationLists, setRecommendationLists] = useState< RecommendationList[] >([]); + // Installed extensions from the sidebar; used to mark market cards. + const installedIndex = useMarketplaceInstalledIndex(); const [plugins, setPlugins] = useState([]); const [isLoading, setIsLoading] = useState(false); const [isLoadingMore, setIsLoadingMore] = useState(false); @@ -571,7 +584,27 @@ function MarketPageContent({ }; }, []); - const visiblePlugins = plugins; + // Annotate cards with installed state derived from the sidebar index. This is + // computed (rather than baked into `plugins`) so a finished install updates + // the badges as soon as the sidebar refreshes. + const visiblePlugins = useMemo( + () => + plugins.map((plugin) => { + const state = resolveInstalledState(installedIndex, plugin); + if ( + state.installed === plugin.installed && + state.hasUpdate === plugin.hasUpdate + ) { + return plugin; + } + return new PluginMarketCardVO({ + ...plugin, + installed: state.installed, + hasUpdate: state.hasUpdate, + }); + }), + [plugins, installedIndex], + ); // 加载更多 const loadMore = useCallback(() => { @@ -853,6 +886,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..cbc9804b5 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 { + InstalledExtensionEntry, + resolveInstalledState, +} 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..59c01ef29 --- /dev/null +++ b/web/src/app/home/plugins/components/plugin-market/marketplace-installed.ts @@ -0,0 +1,124 @@ +import { useMemo } from 'react'; +import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext'; + +/** + * Marketplace extensions are addressed as `author/name`, while installed + * extensions in the sidebar use slightly different identities per kind: + * - plugins: `author/name` + * - MCP servers: `author__name` (double underscore) + * - skills: the bare skill name + * + * The index below normalises all of them to a single `type:author/name` shape + * so a marketplace card can be matched with one lookup. + */ +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}`; +} + +/** + * Build the installed-extension lookup from the sidebar entity lists. + * + * Skills are indexed under both the bare name and the `author/name` form so a + * marketplace skill card resolves regardless of how it was published. + */ +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 }, + ); + } + + for (const skill of skills) { + const entry: InstalledExtensionEntry = { hasUpdate: false }; + const identity = splitIdentity(skill.id); + index.set(installedExtensionKey('skill', ...identity), entry); + // Skills are stored under their bare name but marketplace cards always + // carry `author/name`, so also index the name-only form. + index.set(`skill:${skill.id}`, entry); + } + + return index; +} + +/** 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)]; +} + +/** + * Resolve whether a marketplace extension is already installed. + * + * 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 candidates = [ + `${type}:${extension.author}/${extension.pluginName}`, + // Skills may be indexed under their bare name. + `${type}:${extension.pluginName}`, + ]; + + for (const key of candidates) { + 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. + * + * 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/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/page.tsx b/web/src/app/home/plugins/page.tsx index 51bf48815..cbb28522d 100644 --- a/web/src/app/home/plugins/page.tsx +++ b/web/src/app/home/plugins/page.tsx @@ -9,7 +9,7 @@ import { Label } from '@/components/ui/label'; import PluginDetailContent from './PluginDetailContent'; import styles from './plugins.module.css'; import { Button } from '@/components/ui/button'; -import { Power, Code, Copy, Check, Bug, Unlink } from 'lucide-react'; +import { Power, Code, Copy, Check, Bug, Unlink, Search, X } from 'lucide-react'; import { copyToClipboard } from '@/app/utils/clipboard'; import { Popover, @@ -63,6 +63,7 @@ function PluginListView() { const [copiedDebugUrl, setCopiedDebugUrl] = useState(false); const [copiedDebugKey, setCopiedDebugKey] = useState(false); const [filterType, setFilterType] = useState('all'); + const [installedSearchQuery, setInstalledSearchQuery] = useState(''); const pluginInstalledRef = useRef(null); useEffect(() => { @@ -179,6 +180,27 @@ function PluginListView() {
+ {/* Search installed extensions by label / name / author / description */} +
+ + setInstalledSearchQuery(e.target.value)} + placeholder={t('plugins.searchInstalled')} + aria-label={t('plugins.searchInstalled')} + className="pl-9 pr-8 text-sm" + /> + {installedSearchQuery && ( + + )} +
setInstalledSearchQuery('')} />
diff --git a/web/src/app/infra/entities/api/index.ts b/web/src/app/infra/entities/api/index.ts index 997ffc489..135c09a55 100644 --- a/web/src/app/infra/entities/api/index.ts +++ b/web/src/app/infra/entities/api/index.ts @@ -460,6 +460,8 @@ export interface AsyncTask { name: string; label: string; task_type: string; // system or user + /** Unix epoch seconds (float) when the task was created. */ + created_at?: number; runtime: AsyncTaskRuntimeInfo; task_context: AsyncTaskTaskContext; } diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 2f00dc058..2b79ac004 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -43,6 +43,7 @@ const enUS = { delete: 'Delete', add: 'Add', select: 'Select', + clear: 'Clear', skill: 'Skill', cancel: 'Cancel', submit: 'Submit', @@ -549,6 +550,8 @@ const enUS = { getPluginListError: 'Failed to get plugin list:', noPluginInstalled: 'No plugins installed', noExtensionInstalled: 'No extensions installed', + searchInstalled: 'Search installed extensions', + noMatchingExtensions: 'No extensions match "{{query}}"', loadingExtensions: 'Loading extensions...', groupByType: 'Group by format', pluginConfig: 'Plugin Configuration', @@ -744,6 +747,9 @@ const enUS = { allLoadedCount: 'All {{count}} extensions displayed', install: 'Install', installCard: 'Install {{name}}', + installedCard: 'Installed {{name}}', + installed: 'Installed', + updateAvailable: 'Update available', installConfirm: 'Are you sure you want to install plugin "{{name}}" ({{version}})?', downloadComplete: 'Plugin "{{name}}" download completed', diff --git a/web/src/i18n/locales/es-ES.ts b/web/src/i18n/locales/es-ES.ts index 5c8c2edb7..076d18986 100644 --- a/web/src/i18n/locales/es-ES.ts +++ b/web/src/i18n/locales/es-ES.ts @@ -46,6 +46,7 @@ const esES = { delete: 'Eliminar', add: 'Añadir', select: 'Seleccionar', + clear: 'Limpiar', skill: 'Habilidad', cancel: 'Cancelar', submit: 'Enviar', @@ -566,6 +567,8 @@ const esES = { getPluginListError: 'Error al obtener la lista de plugins:', noPluginInstalled: 'No hay plugins instalados', noExtensionInstalled: 'No hay extensiones instaladas', + searchInstalled: 'Buscar extensiones instaladas', + noMatchingExtensions: 'Ninguna extensión coincide con "{{query}}"', loadingExtensions: 'Cargando extensiones...', groupByType: 'Agrupar por formato', pluginConfig: 'Configuración del plugin', @@ -842,6 +845,9 @@ const esES = { noTags: 'No hay etiquetas disponibles', }, installCard: 'Instalar {{name}}', + installedCard: '{{name}} instalado', + installed: 'Instalado', + updateAvailable: 'Actualización disponible', }, mcp: { title: 'MCP', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index 4460f2640..efe4ecec5 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -44,6 +44,7 @@ const jaJP = { delete: '削除', add: '追加', select: '選択してください', + clear: 'クリア', skill: 'スキル', cancel: 'キャンセル', submit: '送信', @@ -557,6 +558,8 @@ const jaJP = { getPluginListError: 'プラグインリストの取得に失敗しました:', noPluginInstalled: 'プラグインがインストールされていません', noExtensionInstalled: '拡張機能がインストールされていません', + searchInstalled: 'インストール済み拡張機能を検索', + noMatchingExtensions: '「{{query}}」に一致する拡張機能はありません', loadingExtensions: '拡張機能を読み込み中...', groupByType: '形式でグループ化', pluginConfig: 'プラグイン設定', @@ -831,6 +834,9 @@ const jaJP = { deprecatedTooltip: '対応する「ナレッジエンジン」プラグインをインストールしてください。', installCard: '{{name}} をインストール', + installedCard: '{{name}} はインストール済み', + installed: 'インストール済み', + updateAvailable: '更新があります', }, mcp: { title: 'MCP', diff --git a/web/src/i18n/locales/ru-RU.ts b/web/src/i18n/locales/ru-RU.ts index 478af0b97..bcc4117a4 100644 --- a/web/src/i18n/locales/ru-RU.ts +++ b/web/src/i18n/locales/ru-RU.ts @@ -44,6 +44,7 @@ const ruRU = { delete: 'Удалить', add: 'Добавить', select: 'Выбрать', + clear: 'Очистить', skill: 'Навык', cancel: 'Отмена', submit: 'Отправить', @@ -563,6 +564,8 @@ const ruRU = { getPluginListError: 'Не удалось получить список плагинов:', noPluginInstalled: 'Плагины не установлены', noExtensionInstalled: 'Расширения не установлены', + searchInstalled: 'Поиск установленных расширений', + noMatchingExtensions: 'Нет расширений, соответствующих «{{query}}»', loadingExtensions: 'Загрузка расширений...', groupByType: 'Группировать по формату', pluginConfig: 'Настройка плагина', @@ -836,6 +839,9 @@ const ruRU = { noTags: 'Нет доступных тегов', }, installCard: 'Установить {{name}}', + installedCard: '{{name}} установлен', + installed: 'Установлено', + updateAvailable: 'Доступно обновление', }, mcp: { title: 'MCP', diff --git a/web/src/i18n/locales/th-TH.ts b/web/src/i18n/locales/th-TH.ts index 51e1e6c3e..7993ab7d4 100644 --- a/web/src/i18n/locales/th-TH.ts +++ b/web/src/i18n/locales/th-TH.ts @@ -43,6 +43,7 @@ const thTH = { delete: 'ลบ', add: 'เพิ่ม', select: 'เลือก', + clear: 'ล้าง', skill: 'สกิล', cancel: 'ยกเลิก', submit: 'ส่ง', @@ -547,6 +548,8 @@ const thTH = { getPluginListError: 'ไม่สามารถดึงรายการปลั๊กอินได้:', noPluginInstalled: 'ยังไม่มีปลั๊กอินที่ติดตั้ง', noExtensionInstalled: 'ยังไม่มีส่วนขยายที่ติดตั้ง', + searchInstalled: 'ค้นหาส่วนขยายที่ติดตั้งแล้ว', + noMatchingExtensions: 'ไม่มีส่วนขยายที่ตรงกับ "{{query}}"', loadingExtensions: 'กำลังโหลดส่วนขยาย...', groupByType: 'จัดกลุ่มตามรูปแบบ', pluginConfig: 'การกำหนดค่าปลั๊กอิน', @@ -813,6 +816,9 @@ const thTH = { noTags: 'ไม่มีแท็กที่พร้อมใช้งาน', }, installCard: 'ติดตั้ง {{name}}', + installedCard: 'ติดตั้ง {{name}} แล้ว', + installed: 'ติดตั้งแล้ว', + updateAvailable: 'มีอัปเดต', }, mcp: { title: 'MCP', diff --git a/web/src/i18n/locales/vi-VN.ts b/web/src/i18n/locales/vi-VN.ts index c30791515..ee00478d9 100644 --- a/web/src/i18n/locales/vi-VN.ts +++ b/web/src/i18n/locales/vi-VN.ts @@ -44,6 +44,7 @@ const viVN = { delete: 'Xóa', add: 'Thêm', select: 'Chọn', + clear: 'Xóa', skill: 'Kỹ năng', cancel: 'Hủy', submit: 'Gửi', @@ -557,6 +558,8 @@ const viVN = { getPluginListError: 'Lấy danh sách plugin thất bại:', noPluginInstalled: 'Chưa cài đặt plugin nào', noExtensionInstalled: 'Chưa cài đặt tiện ích mở rộng nào', + searchInstalled: 'Tìm tiện ích mở rộng đã cài đặt', + noMatchingExtensions: 'Không có tiện ích mở rộng nào khớp với "{{query}}"', loadingExtensions: 'Đang tải tiện ích mở rộng...', groupByType: 'Nhóm theo định dạng', pluginConfig: 'Cấu hình Plugin', @@ -828,6 +831,9 @@ const viVN = { noTags: 'Không có thẻ nào', }, installCard: 'Cài đặt {{name}}', + installedCard: 'Đã cài đặt {{name}}', + installed: 'Đã cài đặt', + updateAvailable: 'Có bản cập nhật', }, mcp: { title: 'MCP', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 1b9d40ec4..044a88e6e 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -42,6 +42,7 @@ const zhHans = { delete: '删除', add: '添加', select: '请选择', + clear: '清除', skill: '技能', cancel: '取消', submit: '提交', @@ -528,6 +529,8 @@ const zhHans = { pluginConfig: '插件配置', noPluginInstalled: '暂未安装任何插件', noExtensionInstalled: '暂未安装任何扩展', + searchInstalled: '搜索已安装扩展', + noMatchingExtensions: '没有匹配「{{query}}」的扩展', loadingExtensions: '正在加载扩展...', groupByType: '按格式分组', pluginSort: '插件排序', @@ -712,6 +715,9 @@ const zhHans = { allLoadedCount: '已显示全部 {{count}} 个扩展', install: '安装', installCard: '安装 {{name}}', + installedCard: '已安装 {{name}}', + installed: '已安装', + updateAvailable: '有可用更新', installConfirm: '确定要安装插件 "{{name}}" ({{version}}) 吗?', downloadComplete: '插件 "{{name}}" 下载完成', installFailed: '安装失败,请稍后重试', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 693950c5e..e9f250fe5 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -42,6 +42,7 @@ const zhHant = { delete: '刪除', add: '新增', select: '請選擇', + clear: '清除', skill: '技能', cancel: '取消', submit: '提交', @@ -532,6 +533,8 @@ const zhHant = { pluginConfig: '外掛設定', noPluginInstalled: '暫未安裝任何外掛', noExtensionInstalled: '暫未安裝任何擴充功能', + searchInstalled: '搜尋已安裝擴充功能', + noMatchingExtensions: '沒有符合「{{query}}」的擴充功能', loadingExtensions: '正在載入擴充功能...', groupByType: '依格式分組', pluginSort: '外掛排序', @@ -715,6 +718,9 @@ const zhHant = { allLoadedCount: '已顯示全部 {{count}} 個擴展', install: '安裝', installCard: '安裝 {{name}}', + installedCard: '已安裝 {{name}}', + installed: '已安裝', + updateAvailable: '有可用更新', installConfirm: '確定要安裝插件 "{{name}}" ({{version}}) 嗎?', downloadComplete: '插件 "{{name}}" 下載完成', installFailed: '安裝失敗,請稍後重試',