-
-
- {t('plugins.noExtensionInstalled')}
-
+ {normalizedQuery ? (
+ <>
+
+
+ {t('plugins.noMatchingExtensions', {
+ query: searchQuery.trim(),
+ })}
+
+
+ >
+ ) : (
+ <>
+
+
+ {t('plugins.noExtensionInstalled')}
+
+ >
+ )}
) : showGrouped ? (
diff --git a/web/src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx b/web/src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx
index b13e13ff3..eca52de98 100644
--- a/web/src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx
+++ b/web/src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx
@@ -1,4 +1,11 @@
-import { useState, useEffect, useCallback, useRef, Suspense } from 'react';
+import {
+ useState,
+ useEffect,
+ useCallback,
+ useMemo,
+ useRef,
+ Suspense,
+} from 'react';
import { useSearchParams } from 'react-router-dom';
import { Input } from '@/components/ui/input';
import {
@@ -37,6 +44,8 @@ import {
} from '@/components/ui/tooltip';
import PluginMarketCardComponent from './plugin-market-card/PluginMarketCardComponent';
import { PluginMarketCardVO } from './plugin-market-card/PluginMarketCardVO';
+import { resolveInstalledState } from './marketplace-installed';
+import { useMarketplaceInstalledIndex } from './useMarketplaceInstalledIndex';
import { RecommendationLists } from './RecommendationLists';
import type { RecommendationList } from './RecommendationLists';
import {
@@ -122,6 +131,8 @@ function MarketPageContent({
const [recommendationLists, setRecommendationLists] = useState<
RecommendationList[]
>([]);
+ // Installed extensions from the sidebar; used to mark market cards.
+ const installedIndex = useMarketplaceInstalledIndex();
const [plugins, setPlugins] = useState
([]);
const [isLoading, setIsLoading] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
@@ -571,7 +582,27 @@ function MarketPageContent({
};
}, []);
- const visiblePlugins = plugins;
+ // Annotate cards with installed state derived from the sidebar index. This is
+ // computed (rather than baked into `plugins`) so a finished install updates
+ // the badges as soon as the sidebar refreshes.
+ const visiblePlugins = useMemo(
+ () =>
+ plugins.map((plugin) => {
+ const state = resolveInstalledState(installedIndex, plugin);
+ if (
+ state.installed === plugin.installed &&
+ state.hasUpdate === plugin.hasUpdate
+ ) {
+ return plugin;
+ }
+ return new PluginMarketCardVO({
+ ...plugin,
+ installed: state.installed,
+ hasUpdate: state.hasUpdate,
+ });
+ }),
+ [plugins, installedIndex],
+ );
// 加载更多
const loadMore = useCallback(() => {
@@ -853,6 +884,7 @@ function MarketPageContent({
onInstall={handleInstallPlugin}
installDisabled={installDisabled}
installDisabledTooltip={installDisabledTooltip}
+ installedIndex={installedIndex}
/>
)}
diff --git a/web/src/app/home/plugins/components/plugin-market/RecommendationLists.tsx b/web/src/app/home/plugins/components/plugin-market/RecommendationLists.tsx
index 2eb5a22eb..6f8dbfe11 100644
--- a/web/src/app/home/plugins/components/plugin-market/RecommendationLists.tsx
+++ b/web/src/app/home/plugins/components/plugin-market/RecommendationLists.tsx
@@ -8,6 +8,10 @@ import { I18nObject } from '@/app/infra/entities/common';
import { extractI18nObject } from '@/i18n/I18nProvider';
import { getCloudServiceClientSync } from '@/app/infra/http';
import { useTranslation } from 'react-i18next';
+import {
+ resolveInstalledState,
+ type InstalledExtensionEntry,
+} from './marketplace-installed';
export interface RecommendationList {
uuid: string;
@@ -21,6 +25,7 @@ export interface RecommendationList {
function pluginToVO(
plugin: PluginV4,
t: (key: string) => string,
+ installedIndex?: Map,
): PluginMarketCardVO {
const cloudClient = getCloudServiceClientSync();
// Recommendation lists are mixed-type; resolve the icon per extension type,
@@ -32,6 +37,14 @@ function pluginToVO(
plugin.icon,
);
+ const installedState = installedIndex
+ ? resolveInstalledState(installedIndex, {
+ type: plugin.type,
+ author: plugin.author,
+ pluginName: plugin.name,
+ })
+ : undefined;
+
return new PluginMarketCardVO({
pluginId: plugin.author + ' / ' + plugin.name,
author: plugin.author,
@@ -47,6 +60,8 @@ function pluginToVO(
components: plugin.components,
tags: plugin.tags || [],
type: plugin.type,
+ installed: installedState?.installed,
+ hasUpdate: installedState?.hasUpdate,
});
}
@@ -57,6 +72,7 @@ function RecommendationListRow({
installDisabled,
installDisabledTooltip,
isLast,
+ installedIndex,
}: {
list: RecommendationList;
tagNames: Record;
@@ -64,6 +80,7 @@ function RecommendationListRow({
installDisabled?: boolean;
installDisabledTooltip?: string;
isLast: boolean;
+ installedIndex?: Map;
}) {
const { t } = useTranslation();
const [page, setPage] = useState(0);
@@ -264,7 +281,7 @@ function RecommendationListRow({
{visiblePlugins.map((plugin) => (
;
onInstall: (cardVO: PluginMarketCardVO) => void;
installDisabled?: boolean;
installDisabledTooltip?: string;
+ installedIndex?: Map;
}) {
if (!lists || lists.length === 0) return null;
@@ -305,6 +324,7 @@ export function RecommendationLists({
installDisabled={installDisabled}
installDisabledTooltip={installDisabledTooltip}
isLast={index === lists.length - 1}
+ installedIndex={installedIndex}
/>
))}
diff --git a/web/src/app/home/plugins/components/plugin-market/marketplace-installed.ts b/web/src/app/home/plugins/components/plugin-market/marketplace-installed.ts
new file mode 100644
index 000000000..27d25da46
--- /dev/null
+++ b/web/src/app/home/plugins/components/plugin-market/marketplace-installed.ts
@@ -0,0 +1,107 @@
+/**
+ * Marketplace extensions are addressed as `author/name`. Installed extensions
+ * only carry a publisher-scoped identity for plugins and MCP servers:
+ * - plugins: `author/name`
+ * - MCP servers: `author__name` (double underscore)
+ * - skills: the bare skill name, with no publisher recorded
+ *
+ * The index below normalises those to a single `type:author/name` shape so a
+ * marketplace card can be matched with one lookup.
+ *
+ * Skills are deliberately *not* indexable: the backend derives a skill's name
+ * from the `name` field in its own SKILL.md (falling back to the package
+ * directory name), so a skill published by `alice/review` and one published by
+ * `bob/review` both install as the plain name `review`. Matching on that bare
+ * name would mark every publisher's `review` as installed once any single one
+ * of them is. Until the installed skill carries its publisher, a skill card
+ * cannot be resolved authoritatively, and so is reported as not installed.
+ *
+ * This module is intentionally free of React imports so it can be unit tested
+ * directly; the reactive hook lives in `useMarketplaceInstalledIndex.ts`.
+ */
+
+export interface InstalledExtensionEntry {
+ /** An installed extension of the same identity has a newer remote version. */
+ hasUpdate: boolean;
+}
+
+export interface MarketplaceInstalledState {
+ installed: boolean;
+ hasUpdate: boolean;
+}
+
+/** Identity for a marketplace extension card. */
+export function installedExtensionKey(
+ type: string | undefined,
+ author: string,
+ name: string,
+): string {
+ return `${type || 'plugin'}:${author}/${name}`;
+}
+
+/** Split an `author/name` identity, tolerating a missing author. */
+function splitIdentity(identity: string): [string, string] {
+ const slash = identity.indexOf('/');
+ if (slash < 0) return ['', identity];
+ return [identity.slice(0, slash), identity.slice(slash + 1)];
+}
+
+/**
+ * Build the installed-extension lookup from the sidebar entity lists.
+ *
+ * Skills are intentionally omitted — see the module header for why a bare
+ * skill name cannot be attributed to a publisher.
+ */
+export function buildInstalledIndex(
+ plugins: { id: string; hasUpdate?: boolean }[],
+ mcpServers: { id: string }[],
+ skills: { id: string }[],
+): Map {
+ const index = new Map();
+
+ for (const plugin of plugins) {
+ index.set(installedExtensionKey('plugin', ...splitIdentity(plugin.id)), {
+ hasUpdate: plugin.hasUpdate ?? false,
+ });
+ }
+
+ for (const server of mcpServers) {
+ // MCP servers are keyed with `__`; normalise to `author/name`.
+ index.set(
+ installedExtensionKey(
+ 'mcp',
+ ...splitIdentity(server.id.replace(/__/g, '/')),
+ ),
+ { hasUpdate: false },
+ );
+ }
+
+ // `skills` is accepted for call-site symmetry (and so the surrounding
+ // useMemo still re-runs when the list changes) but contributes nothing.
+ void skills;
+
+ return index;
+}
+
+/**
+ * Resolve whether a marketplace extension is already installed.
+ *
+ * Matching requires the full `type:author/name` identity, so a card is only
+ * marked installed when the installed extension carries the same publisher.
+ * Unknown types fall back to `plugin`, matching the marketplace defaults.
+ */
+export function resolveInstalledState(
+ index: Map,
+ extension: { type?: string; author: string; pluginName: string },
+): MarketplaceInstalledState {
+ const type = extension.type || 'plugin';
+ const entry = index.get(
+ `${type}:${extension.author}/${extension.pluginName}`,
+ );
+
+ if (entry) {
+ return { installed: true, hasUpdate: entry.hasUpdate };
+ }
+
+ return { installed: false, hasUpdate: false };
+}
diff --git a/web/src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardComponent.tsx b/web/src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardComponent.tsx
index c4d741ed6..c8393ac1b 100644
--- a/web/src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardComponent.tsx
+++ b/web/src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardComponent.tsx
@@ -3,7 +3,14 @@ import { useRef, useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import PluginComponentList from '../PluginComponentList';
import { Badge } from '@/components/ui/badge';
-import { Info, Package, ExternalLink, Heart, Loader2 } from 'lucide-react';
+import {
+ Info,
+ Package,
+ ExternalLink,
+ Heart,
+ Loader2,
+ Check,
+} from 'lucide-react';
import {
Tooltip,
TooltipContent,
@@ -48,6 +55,9 @@ export default function PluginMarketCardComponent({
return keys.length > 0 && keys.every((k) => k === 'KnowledgeRetriever');
})();
+ const isInstalled = !!cardVO.installed;
+ const hasUpdate = !!cardVO.hasUpdate;
+
const showTypeBadge = cardVO.type;
const typeLabel =
cardVO.type === 'mcp'
@@ -158,12 +168,33 @@ export default function PluginMarketCardComponent({
}
};
+ // An already-installed extension turns its download affordance into a filled
+ // green circle-check, so the card reads as "installed" in place instead of
+ // offering another install.
+ const showInstalledMark = isInstalled && !hasUpdate;
+
+ // Bottom-right slot: the component list.
+ const bottomTrailing =
+ cardVO.components && Object.keys(cardVO.components).length > 0 ? (
+
+ ) : null;
const cardContent = (
-
- {cardVO.components && Object.keys(cardVO.components).length > 0 && (
+ {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