feat(plugins): show installed state in marketplace and search installed extensions

Marketplace cards now reflect whether an extension is already installed in
the current workspace, and the installed-extension list gains a search box.

Backend (stream install progress):
- _read_httpx_response_limited gains an optional task_context: it publishes
  download_total from Content-Length before the first chunk and updates
  download_current / download_speed per chunk. The marketplace download path
  previously had no progress reporting; it now matches the GitHub path.
- _marketplace_get forwards task_context to that helper.
- install_plugin resets the per-install counters so re-installing the same
  plugin cannot inherit stale metadata, and reports human-readable stages:
  preparing -> downloading -> inspecting -> storing -> installing
  dependencies -> launching -> waiting for plugin to become ready.

Frontend (installed state):
- New marketplace-installed helper normalises the sidebar identities
  (plugin: author/name, mcp: author__name, skill: bare name) into one
  type:author/name index and resolves a card's installed state from it.
  useMarketplaceInstalledIndex memoises on the sidebar lists, so a finished
  install (which refreshes the sidebar) re-evaluates the cards automatically.
- PluginMarketCardVO carries installed / hasUpdate. An installed extension
  turns its download affordance into a hollow green ring with a green check
  in place, instead of adding a separate badge; the count slot switches to
  the installed label. Cards with an available update use amber.
- PluginMarketComponent derives the annotated list and shares the index with
  RecommendationLists.

Frontend (install task UI):
- mapActionToStage matches the new connector stage strings. The pre-download
  stages are checked before the generic "install" match, because
  "preparing plugin install" also contains "install".
- Stage progress ranges are non-overlapping; overall progress interpolates on
  real byte counts while downloading and drifts monotonically elsewhere,
  capped at 99%.
- The progress dialog and task queue expose the launching stage.

Frontend (installed list search):
- The installed list had no search at all. A query box in the page header
  filters by label / name / author / description, case-insensitively, applied
  before grouping so grouped and flat views both honour it.
- Search misses and an empty list now show distinct empty states, with a
  clear action on a search miss.
- AsyncTask entity gains the optional created_at field.

i18n: new marketplace / install / search strings across all 8 locales.

Verified: ruff format + check, tsc --noEmit, prettier --check, eslint
(0 errors), and 89/89 frontend unit tests.
This commit is contained in:
TyperBody
2026-09-16 02:03:57 +08:00
parent 9b7ba0d647
commit 4535a21cb5
20 changed files with 528 additions and 71 deletions
+43
View File
@@ -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(
@@ -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 {
@@ -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, [number, number]> = {
[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 {
@@ -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<string, React.ElementType> = {
[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()
@@ -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<
</div>
) : filteredExtensions.length === 0 ? (
<div className="flex flex-col items-center justify-center text-muted-foreground min-h-[60vh] w-full gap-2">
<Puzzle className="h-[3rem] w-[3rem]" />
<div className="text-lg mb-2">
{t('plugins.noExtensionInstalled')}
</div>
{normalizedQuery ? (
<>
<Search className="h-[3rem] w-[3rem]" />
<div className="text-lg mb-2">
{t('plugins.noMatchingExtensions', {
query: searchQuery.trim(),
})}
</div>
<Button variant="outline" size="sm" onClick={clearSearch}>
{t('common.clear')}
</Button>
</>
) : (
<>
<Puzzle className="h-[3rem] w-[3rem]" />
<div className="text-lg mb-2">
{t('plugins.noExtensionInstalled')}
</div>
</>
)}
</div>
) : showGrouped ? (
<div className="flex flex-col gap-4 pb-4">
@@ -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<PluginMarketCardVO[]>([]);
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}
/>
)}
@@ -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<string, InstalledExtensionEntry>,
): 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<string, string>;
@@ -64,6 +80,7 @@ function RecommendationListRow({
installDisabled?: boolean;
installDisabledTooltip?: string;
isLast: boolean;
installedIndex?: Map<string, InstalledExtensionEntry>;
}) {
const { t } = useTranslation();
const [page, setPage] = useState(0);
@@ -264,7 +281,7 @@ function RecommendationListRow({
{visiblePlugins.map((plugin) => (
<PluginMarketCardComponent
key={plugin.author + ' / ' + plugin.name}
cardVO={pluginToVO(plugin, t)}
cardVO={pluginToVO(plugin, t, installedIndex)}
tagNames={tagNames}
onInstall={onInstall}
installDisabled={installDisabled}
@@ -285,12 +302,14 @@ export function RecommendationLists({
onInstall,
installDisabled,
installDisabledTooltip,
installedIndex,
}: {
lists: RecommendationList[];
tagNames: Record<string, string>;
onInstall: (cardVO: PluginMarketCardVO) => void;
installDisabled?: boolean;
installDisabledTooltip?: string;
installedIndex?: Map<string, InstalledExtensionEntry>;
}) {
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}
/>
))}
<div className="border-b border-border mb-6" />
@@ -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<string, InstalledExtensionEntry> {
const index = new Map<string, InstalledExtensionEntry>();
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<string, InstalledExtensionEntry>,
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],
);
}
@@ -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 ? (
<PluginComponentList
components={cardVO.components}
showComponentName={false}
showTitle={false}
useBadge={true}
t={t}
responsive={false}
/>
) : null;
const cardContent = (
<div
role={installDisabled ? 'group' : 'button'}
tabIndex={0}
aria-disabled={installDisabled}
aria-label={t('market.installCard', { name: cardVO.label })}
aria-label={
isInstalled
? t('market.installedCard', { name: cardVO.label })
: t('market.installCard', { name: cardVO.label })
}
className={`w-[100%] h-[10rem] bg-white rounded-[10px] border border-border shadow-[0px_1px_2px_0_rgba(0,0,0,0.06)] p-3 sm:p-[1rem] transition-shadow duration-200 outline-none dark:bg-[#1f1f22] dark:shadow-[0px_1px_2px_0_rgba(255,255,255,0.04)] relative ${
installDisabled
? 'cursor-not-allowed opacity-60'
@@ -321,20 +352,50 @@ export default function PluginMarketCardComponent({
>
<div className="flex flex-row items-center justify-start gap-2 min-w-0 overflow-hidden">
<div className="flex flex-row items-center gap-[0.3rem] sm:gap-[0.4rem] flex-shrink-0">
<svg
className="w-4 h-4 sm:w-[1.2rem] sm:h-[1.2rem] text-[#2563eb] dark:text-[#5b8def] flex-shrink-0"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
{showInstalledMark ? (
// Hollow green ring enclosing a green check: the download
// affordance becomes an "installed" mark in place.
<span
title={t('market.installed')}
aria-label={t('market.installed')}
className="flex h-4 w-4 sm:h-[1.2rem] sm:w-[1.2rem] flex-shrink-0 items-center justify-center rounded-full border-2 border-green-500 dark:border-green-400"
>
<Check
className="h-2 w-2 text-green-500 dark:text-green-400 sm:h-2.5 sm:w-2.5"
strokeWidth={3.5}
/>
</span>
) : (
<svg
className={`w-4 h-4 sm:w-[1.2rem] sm:h-[1.2rem] flex-shrink-0 ${
hasUpdate
? 'text-amber-500 dark:text-amber-400'
: 'text-[#2563eb] dark:text-[#5b8def]'
}`}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7,10 12,15 17,10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
)}
<div
title={hasUpdate ? t('market.updateAvailable') : undefined}
className={`text-xs sm:text-sm font-medium whitespace-nowrap ${
showInstalledMark
? 'text-green-600 dark:text-green-400'
: hasUpdate
? 'text-amber-600 dark:text-amber-400'
: 'text-[#2563eb] dark:text-[#5b8def]'
}`}
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7,10 12,15 17,10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
<div className="text-xs sm:text-sm text-[#2563eb] dark:text-[#5b8def] font-medium whitespace-nowrap">
{cardVO.installCount?.toLocaleString() ?? '0'}
{showInstalledMark
? t('market.installed')
: (cardVO.installCount?.toLocaleString() ?? '0')}
</div>
</div>
@@ -376,18 +437,11 @@ export default function PluginMarketCardComponent({
)}
</div>
{cardVO.components && Object.keys(cardVO.components).length > 0 && (
{bottomTrailing ? (
<div className="flex flex-row items-center gap-1 flex-shrink-0">
<PluginComponentList
components={cardVO.components}
showComponentName={false}
showTitle={false}
useBadge={true}
t={t}
responsive={false}
/>
{bottomTrailing}
</div>
)}
) : null}
</div>
</div>
</div>
@@ -12,6 +12,10 @@ export interface IPluginMarketCardVO {
components?: Record<string, number>;
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<string, number>;
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;
}
}
+25 -1
View File
@@ -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<FilterType>('all');
const [installedSearchQuery, setInstalledSearchQuery] = useState('');
const pluginInstalledRef = useRef<PluginInstalledComponentRef>(null);
useEffect(() => {
@@ -179,6 +180,27 @@ function PluginListView() {
</Tabs>
</div>
<div className="flex flex-row items-center gap-2 flex-wrap">
{/* Search installed extensions by label / name / author / description */}
<div className="relative w-full sm:w-56">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={installedSearchQuery}
onChange={(e) => setInstalledSearchQuery(e.target.value)}
placeholder={t('plugins.searchInstalled')}
aria-label={t('plugins.searchInstalled')}
className="pl-9 pr-8 text-sm"
/>
{installedSearchQuery && (
<button
type="button"
aria-label={t('common.clear')}
onClick={() => setInstalledSearchQuery('')}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
<div className="flex items-center gap-2 px-1 sm:px-2">
<Switch
id="group-by-type"
@@ -300,6 +322,8 @@ function PluginListView() {
ref={pluginInstalledRef}
filterType={filterType}
groupByType={groupByType}
searchQuery={installedSearchQuery}
onClearSearch={() => setInstalledSearchQuery('')}
/>
</div>
</div>
+2
View File
@@ -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;
}
+6
View File
@@ -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',
+6
View File
@@ -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',
+6
View File
@@ -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',
+6
View File
@@ -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',
+6
View File
@@ -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',
+6
View File
@@ -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',
+6
View File
@@ -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: '安装失败,请稍后重试',
+6
View File
@@ -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: '安裝失敗,請稍後重試',