diff --git a/web/src/app/home/plugins/components/plugin-market/PluginComponentList.tsx b/web/src/app/home/plugins/components/plugin-market/PluginComponentList.tsx new file mode 100644 index 00000000..db480b9f --- /dev/null +++ b/web/src/app/home/plugins/components/plugin-market/PluginComponentList.tsx @@ -0,0 +1,77 @@ +import { Fragment } from 'react'; +import { TFunction } from 'i18next'; +import { Wrench, AudioWaveform, Hash, Book, FileText } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; + +export default function PluginComponentList({ + components, + showComponentName, + showTitle, + useBadge, + t, + responsive = false, +}: { + components: Record; + showComponentName: boolean; + showTitle: boolean; + useBadge: boolean; + t: TFunction; + responsive?: boolean; +}) { + const kindIconMap: Record = { + Tool: , + EventListener: , + Command: , + KnowledgeEngine: , + Parser: , + }; + + const componentKindList = Object.keys(components || {}); + + return ( + <> + {showTitle &&
{t('market.componentsList')}
} + {componentKindList.length > 0 && ( + <> + {componentKindList.map((kind) => { + return ( + + {useBadge && ( + + {kindIconMap[kind]} + {responsive ? ( + + {t('market.componentName.' + kind)} + + ) : ( + showComponentName && t('market.componentName.' + kind) + )} + {components[kind]} + + )} + + {!useBadge && ( +
+ {kindIconMap[kind]} + {responsive ? ( + + {t('market.componentName.' + kind)} + + ) : ( + showComponentName && t('market.componentName.' + kind) + )} + {components[kind]} +
+ )} +
+ ); + })} + + )} + + {componentKindList.length === 0 &&
{t('market.noComponents')}
} + + ); +} \ No newline at end of file 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 1eedf137..e1950b44 100644 --- a/web/src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx +++ b/web/src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx @@ -8,14 +8,23 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; +import { Separator } from '@/components/ui/separator'; +import { + ToggleGroup, + ToggleGroupItem, +} from '@/components/ui/toggle-group'; import { Search, Wrench, AudioWaveform, - Hash, Book, - FileText, + SlidersHorizontal, + X, } from 'lucide-react'; import PluginMarketCardComponent from './plugin-market-card/PluginMarketCardComponent'; import { PluginMarketCardVO } from './plugin-market-card/PluginMarketCardVO'; @@ -26,6 +35,7 @@ import { extractI18nObject } from '@/i18n/I18nProvider'; import { toast } from 'sonner'; import { ApiRespMarketplacePlugins } from '@/app/infra/entities/api'; import { LoadingSpinner } from '@/components/ui/loading-spinner'; +import { Button } from '@/components/ui/button'; import { TagsFilter } from './TagsFilter'; import { PluginTag } from '@/app/infra/http/CloudServiceClient'; @@ -57,6 +67,13 @@ function MarketPageContent({ const validTypes = ['plugin', 'mcp', 'skill']; + const extensionTypeOptions = [ + { value: 'all', label: t('market.filters.allFormats'), icon: null }, + { value: 'plugin', label: t('market.typePlugin'), icon: Wrench }, + { value: 'mcp', label: t('market.typeMCP'), icon: AudioWaveform }, + { value: 'skill', label: t('market.typeSkill'), icon: Book }, + ]; + const [searchQuery, setSearchQuery] = useState(''); const [componentFilter, setComponentFilter] = useState(() => { const category = searchParams.get('category'); @@ -72,6 +89,7 @@ function MarketPageContent({ } return 'all'; }); + const activeAdvancedFilters = typeFilter === 'all' ? 0 : 1; const [selectedTags, setSelectedTags] = useState([]); const [availableTags, setAvailableTags] = useState([]); const [tagNames, setTagNames] = useState>({}); @@ -149,6 +167,40 @@ function MarketPageContent({ }); }, []); + const transformMCPToVO = useCallback((mcp: any): PluginMarketCardVO => { + return new PluginMarketCardVO({ + pluginId: mcp.author + ' / ' + mcp.name, + author: mcp.author, + pluginName: mcp.name, + label: extractI18nObject(mcp.label), + description: extractI18nObject(mcp.description) || t('market.noDescription'), + installCount: mcp.install_count || 0, + iconURL: mcp.icon || getCloudServiceClientSync().getPluginIconURL(mcp.author, mcp.name), + githubURL: mcp.repository, + version: mcp.latest_version, + components: mcp.components || {}, + tags: mcp.tags || [], + type: 'mcp', + }); + }, [t]); + + const transformSkillToVO = useCallback((skill: any): PluginMarketCardVO => { + return new PluginMarketCardVO({ + pluginId: skill.author + ' / ' + skill.name, + author: skill.author, + pluginName: skill.name, + label: extractI18nObject(skill.label), + description: extractI18nObject(skill.description) || t('market.noDescription'), + installCount: skill.install_count || 0, + iconURL: skill.icon || getCloudServiceClientSync().getPluginIconURL(skill.author, skill.name), + githubURL: skill.repository, + version: skill.latest_version, + components: skill.components || {}, + tags: skill.tags || [], + type: 'skill', + }); + }, [t]); + // 获取插件列表 const fetchPlugins = useCallback( async (page: number, isSearch: boolean = false, reset: boolean = false) => { @@ -162,32 +214,98 @@ function MarketPageContent({ const { sortBy, sortOrder } = getCurrentSort(); const filterValue = componentFilter === 'all' ? undefined : componentFilter; - const typeFilterValue = typeFilter === 'all' ? undefined : typeFilter; + const query = isSearch && searchQuery.trim() ? searchQuery.trim() : ''; - // Always use searchMarketplacePlugins to support component filtering and tags filtering - const response = - await getCloudServiceClientSync().searchMarketplacePlugins( - isSearch && searchQuery.trim() ? searchQuery.trim() : '', + let newPlugins: PluginMarketCardVO[] = []; + let total = 0; + + if (typeFilter === 'all') { + let pluginsResult: PluginMarketCardVO[] = []; + let mcpsResult: PluginMarketCardVO[] = []; + let skillsResult: PluginMarketCardVO[] = []; + let pluginsTotal = 0; + let mcpsTotal = 0; + let skillsTotal = 0; + + try { + const pluginsResponse = await getCloudServiceClientSync().searchMarketplacePlugins( + query, + page, + pageSize, + sortBy, + sortOrder, + filterValue, + selectedTags.length > 0 ? selectedTags : undefined, + 'plugin', + ); + pluginsResult = pluginsResponse.plugins + .filter((plugin) => { + const keys = Object.keys(plugin.components || {}); + return !(keys.length > 0 && keys.every((k) => k === 'KnowledgeRetriever')); + }) + .map(transformToVO); + pluginsTotal = pluginsResponse.total || 0; + } catch (e) { + console.warn('Failed to fetch plugins:', e); + } + + try { + const mcpsResponse = await getCloudServiceClientSync().searchMarketplacePlugins( + query, + page, + pageSize, + sortBy, + sortOrder, + filterValue, + selectedTags.length > 0 ? selectedTags : undefined, + 'mcp', + ); + mcpsResult = (mcpsResponse.plugins || []).map(transformMCPToVO); + mcpsTotal = mcpsResponse.total || 0; + } catch (e) { + console.warn('Failed to fetch mcps:', e); + } + + try { + const skillsResponse = await getCloudServiceClientSync().searchMarketplacePlugins( + query, + page, + pageSize, + sortBy, + sortOrder, + filterValue, + selectedTags.length > 0 ? selectedTags : undefined, + 'skill', + ); + skillsResult = (skillsResponse.plugins || []).map(transformSkillToVO); + skillsTotal = skillsResponse.total || 0; + } catch (e) { + console.warn('Failed to fetch skills:', e); + } + + newPlugins = [...pluginsResult, ...mcpsResult, ...skillsResult]; + total = pluginsTotal + mcpsTotal + skillsTotal; + } else { + const response = await getCloudServiceClientSync().searchMarketplacePlugins( + query, page, pageSize, sortBy, sortOrder, filterValue, selectedTags.length > 0 ? selectedTags : undefined, - typeFilterValue, + typeFilter === 'all' ? undefined : typeFilter, ); - const data: ApiRespMarketplacePlugins = response; - const newPlugins = data.plugins - .filter((plugin) => { - // Hide plugins that only contain deprecated KnowledgeRetriever components - const keys = Object.keys(plugin.components || {}); - return !( - keys.length > 0 && keys.every((k) => k === 'KnowledgeRetriever') - ); - }) - .map(transformToVO); - const total = data.total; + const data: ApiRespMarketplacePlugins = response; + newPlugins = data.plugins + .filter((plugin) => { + const keys = Object.keys(plugin.components || {}); + return !(keys.length > 0 && keys.every((k) => k === 'KnowledgeRetriever')); + }) + .map(transformToVO); + total = data.total; + } if (reset || page === 1) { setPlugins(newPlugins); @@ -197,8 +315,8 @@ function MarketPageContent({ setTotal(total); setHasMore( - data.plugins.length === pageSize && - plugins.length + newPlugins.length < total, + newPlugins.length > 0 && + (reset || page === 1 ? newPlugins.length : plugins.length + newPlugins.length) < total, ); } catch (error) { console.error('Failed to fetch plugins:', error); @@ -214,8 +332,11 @@ function MarketPageContent({ selectedTags, pageSize, transformToVO, + transformMCPToVO, + transformSkillToVO, plugins.length, getCurrentSort, + typeFilter, ], ); @@ -460,9 +581,9 @@ function MarketPageContent({
{/* Fixed header with search and sort controls */}
- {/* Search box and Tags filter */} -
-
+ {/* Search box */} +
+
{ if (e.key === 'Enter') { - // Immediately search, clear debounce timer if (searchTimeoutRef.current) { clearTimeout(searchTimeoutRef.current); } @@ -488,138 +608,9 @@ function MarketPageContent({ />
- {/* Tags filter */} - -
- - {/* Component filter and sort */} -
- {/* Component filter */} -
- - {t('market.filterByComponent')}: - -
- { - if (value) handleComponentFilterChange(value); - }} - className="justify-start flex-nowrap" - > - - {t('market.allComponents')} - - - - {t('plugins.componentName.Tool')} - - - - {t('plugins.componentName.Command')} - - - - {t('plugins.componentName.EventListener')} - - - - {t('plugins.componentName.KnowledgeEngine')} - - - - {t('plugins.componentName.Parser')} - - -
-
- - {/* Type filter */} -
- - {t('market.filterByType')}: - -
- { - if (value) handleTypeFilterChange(value); - }} - className="justify-start flex-nowrap" - > - - {t('market.allTypes')} - - - {t('market.typePlugin')} - - - {t('market.typeMCP')} - - - {t('market.typeSkill')} - - -
-
- - {/* Sort dropdown */} -
- - {t('market.sortBy')}: - +
+ + + + + + +
+
{t('market.filters.advancedTitle')}
+
+ {t('market.filters.advancedDescription')} +
+
+ +
+
+ {t('market.filters.technicalType')} +
+ { + if (value) handleTypeFilterChange(value); + }} + className="flex flex-wrap justify-start gap-2" + > + {extensionTypeOptions.map((option) => { + const Icon = option.icon; + return ( + + {Icon && } + {option.label} + + ); + })} + +
+
+
+ {/* Quick tag filter buttons */} +
+ + {availableTags.map((tag) => { + const selected = selectedTags.includes(tag.tag); + return ( + + ); + })} +
+ {/* Search results stats */} {total > 0 && (
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 0f7ddabf..0183dd1f 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 @@ -1,17 +1,15 @@ import { PluginMarketCardVO } from './PluginMarketCardVO'; +import { useRef, useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; +import PluginComponentList from '../PluginComponentList'; import { Badge } from '@/components/ui/badge'; +import { Info, Package } from 'lucide-react'; import { - Wrench, - AudioWaveform, - Hash, - Download, - ExternalLink, - Book, - FileText, -} from 'lucide-react'; -import { useState, useRef, useEffect } from 'react'; -import { Button } from '@/components/ui/button'; + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; export default function PluginMarketCardComponent({ cardVO, @@ -23,11 +21,24 @@ export default function PluginMarketCardComponent({ tagNames?: Record; }) { const { t } = useTranslation(); - const [isHovered, setIsHovered] = useState(false); const bottomRef = useRef(null); const [visibleTags, setVisibleTags] = useState(2); + const [iconFailed, setIconFailed] = useState(!cardVO.iconURL); + + const pluginDetailUrl = `https://space.langbot.app/market/${cardVO.author}/${cardVO.pluginName}`; + + const isDeprecated = (() => { + if (!cardVO.components) return false; + const keys = Object.keys(cardVO.components); + return keys.length > 0 && keys.every((k) => k === 'KnowledgeRetriever'); + })(); + + const showTypeBadge = cardVO.type; + + useEffect(() => { + setIconFailed(!cardVO.iconURL); + }, [cardVO.iconURL]); - // Measure how many tags fit in the bottom row useEffect(() => { const tags = cardVO.tags; if (!bottomRef.current || !tags || tags.length === 0) return; @@ -43,10 +54,7 @@ export default function PluginMarketCardComponent({ } const tagWidth = 80; const plusBadgeWidth = 40; - const maxTags = Math.max( - 0, - Math.floor((availableForTags - plusBadgeWidth) / tagWidth), - ); + const maxTags = Math.max(0, Math.floor((availableForTags - plusBadgeWidth) / tagWidth)); if (maxTags >= tags.length) { setVisibleTags(tags.length); } else { @@ -62,52 +70,55 @@ export default function PluginMarketCardComponent({ const remainingTags = cardVO.tags ? cardVO.tags.length - visibleTags : 0; - function handleInstallClick(e: React.MouseEvent) { - e.stopPropagation(); - if (onInstall) { - onInstall(cardVO.author, cardVO.pluginName); - } - } - - function handleViewDetailsClick(e: React.MouseEvent) { - e.stopPropagation(); - const detailUrl = `https://space.langbot.app/market/${cardVO.author}/${cardVO.pluginName}`; - window.open(detailUrl, '_blank'); - } - - const kindIconMap: Record = { - Tool: , - EventListener: , - Command: , - KnowledgeEngine: , - Parser: , - }; - return ( -
setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} + -
- {/* 上部分:插件信息 */} -
- plugin icon +
+
+ {iconFailed ? ( +
+ +
+ ) : ( + plugin icon setIconFailed(true)} + /> + )}
-
- {cardVO.pluginId} -
+
{cardVO.pluginId}
-
- {cardVO.label} -
- {cardVO.type && ( +
{cardVO.label}
+ {isDeprecated && ( + + + e.preventDefault()}> + + {t('market.deprecated')} + + + + + {t('market.deprecatedTooltip')} + + + + )} + {showTypeBadge && ( {cardVO.githubURL && ( { + e.preventDefault(); e.stopPropagation(); window.open(cardVO.githubURL, '_blank'); }} @@ -151,13 +163,8 @@ export default function PluginMarketCardComponent({
- {/* 下部分:下载量、标签和组件列表 */} -
+
- {/* 下载数量 */}
- {/* Tags - adaptive */} {cardVO.tags && cardVO.tags.length > 0 && visibleTags > 0 && (
{cardVO.tags.slice(0, visibleTags).map((tag) => ( @@ -198,9 +204,7 @@ export default function PluginMarketCardComponent({ - - {tagNames[tag] || tag} - + {tagNames[tag] || tag} ))} {remainingTags > 0 && ( @@ -215,52 +219,20 @@ export default function PluginMarketCardComponent({ )}
- {/* 组件列表 */} {cardVO.components && Object.keys(cardVO.components).length > 0 && ( -
- {Object.entries(cardVO.components).map(([kind, count]) => ( - - {kindIconMap[kind]} - {count} - - ))} +
+
)}
- - {/* Hover overlay with action buttons */} -
- - -
-
+
); -} +} \ No newline at end of file diff --git a/web/src/app/infra/http/CloudServiceClient.ts b/web/src/app/infra/http/CloudServiceClient.ts index c5b835e4..093ec417 100644 --- a/web/src/app/infra/http/CloudServiceClient.ts +++ b/web/src/app/infra/http/CloudServiceClient.ts @@ -53,12 +53,12 @@ export class CloudServiceClient extends BaseHttpClient { tags_filter, }, ).then((resp) => ({ - plugins: (resp.mcps || []).map((mcp) => ({ + plugins: (resp?.mcps || []).map((mcp) => ({ ...mcp, plugin_id: mcp.mcp_id || mcp.plugin_id, type: 'mcp' as const, })), - total: resp.total || 0, + total: resp?.total || 0, })); } else if (type_filter === 'skill') { return this.post<{ skills: PluginV4[]; total: number }>( @@ -72,12 +72,12 @@ export class CloudServiceClient extends BaseHttpClient { tags_filter, }, ).then((resp) => ({ - plugins: (resp.skills || []).map((skill) => ({ + plugins: (resp?.skills || []).map((skill) => ({ ...skill, plugin_id: skill.skill_id || skill.plugin_id, type: 'skill' as const, })), - total: resp.total || 0, + total: resp?.total || 0, })); } diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index fa2d8f99..272cab73 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -628,6 +628,14 @@ const enUS = { deprecated: 'Deprecated', deprecatedTooltip: 'Please install the corresponding Knowledge Engine plugin.', + filters: { + allFormats: 'All Formats', + more: 'More', + advancedTitle: 'Advanced Filters', + advancedDescription: 'Filter by extension type', + technicalType: 'Technical Type', + }, + allExtensions: 'All Extensions', tags: { filterByTags: 'Filter by Tags', selected: 'selected', diff --git a/web/src/i18n/locales/es-ES.ts b/web/src/i18n/locales/es-ES.ts index 0ae96ecb..7c187f6c 100644 --- a/web/src/i18n/locales/es-ES.ts +++ b/web/src/i18n/locales/es-ES.ts @@ -641,6 +641,14 @@ const esES = { deprecated: 'Obsoleto', deprecatedTooltip: 'Por favor, instala el plugin de motor de conocimiento correspondiente.', + filters: { + allFormats: 'Todos los formatos', + more: 'Más', + advancedTitle: 'Filtros avanzados', + advancedDescription: 'Filtrar por tipo de extensión', + technicalType: 'Tipo técnico', + }, + allExtensions: 'Todas las extensiones', tags: { filterByTags: 'Filtrar por etiquetas', selected: 'seleccionadas', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index 15f55a68..a7127b00 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -636,6 +636,14 @@ const jaJP = { clearAll: 'クリア', noTags: 'タグがありません', }, + filters: { + allFormats: 'すべての形式', + more: 'もっと', + advancedTitle: '高度なフィルター', + advancedDescription: '拡張子タイプでフィルター', + technicalType: '技術タイプ', + }, + allExtensions: 'すべての拡張機能', viewDetails: '詳細を表示', deprecated: '非推奨', deprecatedTooltip: diff --git a/web/src/i18n/locales/ru-RU.ts b/web/src/i18n/locales/ru-RU.ts index 6512c9fe..ed02bc86 100644 --- a/web/src/i18n/locales/ru-RU.ts +++ b/web/src/i18n/locales/ru-RU.ts @@ -638,6 +638,14 @@ const ruRU = { deprecated: 'Устаревший', deprecatedTooltip: 'Пожалуйста, установите соответствующий плагин движка знаний.', + filters: { + allFormats: 'Все форматы', + more: 'Ещё', + advancedTitle: 'Расширенные фильтры', + advancedDescription: 'Фильтр по типу расширения', + technicalType: 'Технический тип', + }, + allExtensions: 'Все расширения', tags: { filterByTags: 'Фильтр по тегам', selected: 'выбрано', diff --git a/web/src/i18n/locales/th-TH.ts b/web/src/i18n/locales/th-TH.ts index 772aa4a1..dfeae962 100644 --- a/web/src/i18n/locales/th-TH.ts +++ b/web/src/i18n/locales/th-TH.ts @@ -619,6 +619,14 @@ const thTH = { viewDetails: 'ดูรายละเอียด', deprecated: 'เลิกใช้แล้ว', deprecatedTooltip: 'กรุณาติดตั้งปลั๊กอินเครื่องมือความรู้ที่เกี่ยวข้อง', + filters: { + allFormats: 'ทุกรูปแบบ', + more: 'เพิ่มเติม', + advancedTitle: 'ตัวกรองขั้นสูง', + advancedDescription: 'กรองตามประเภทส่วนขยาย', + technicalType: 'ประเภทเทคนิค', + }, + allExtensions: 'ส่วนขยายทั้งหมด', tags: { filterByTags: 'กรองตามแท็ก', selected: 'เลือกแล้ว', diff --git a/web/src/i18n/locales/vi-VN.ts b/web/src/i18n/locales/vi-VN.ts index 9dae9356..62d64e29 100644 --- a/web/src/i18n/locales/vi-VN.ts +++ b/web/src/i18n/locales/vi-VN.ts @@ -631,6 +631,14 @@ const viVN = { viewDetails: 'Xem chi tiết', deprecated: 'Không còn hỗ trợ', deprecatedTooltip: 'Vui lòng cài đặt plugin Công cụ tri thức tương ứng.', + filters: { + allFormats: 'Tất cả định dạng', + more: 'Thêm', + advancedTitle: 'Bộ lọc nâng cao', + advancedDescription: 'Lọc theo loại phần mở rộng', + technicalType: 'Loại kỹ thuật', + }, + allExtensions: 'Tất cả phần mở rộng', tags: { filterByTags: 'Lọc theo thẻ', selected: 'đã chọn', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 4e173e64..2d7b535b 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -604,6 +604,14 @@ const zhHans = { clearAll: '清空', noTags: '暂无标签', }, + filters: { + allFormats: '全部格式', + more: '更多', + advancedTitle: '高级筛选', + advancedDescription: '按扩展类型筛选', + technicalType: '技术类型', + }, + allExtensions: '全部扩展', viewDetails: '查看详情', deprecated: '已弃用', deprecatedTooltip: '请安装对应「知识引擎」插件', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 9824a706..eb71837b 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -604,6 +604,14 @@ const zhHant = { clearAll: '清空', noTags: '暫無標籤', }, + filters: { + allFormats: '全部格式', + more: '更多', + advancedTitle: '高級篩選', + advancedDescription: '按擴展類型篩選', + technicalType: '技術類型', + }, + allExtensions: '全部擴展', viewDetails: '查看詳情', deprecated: '已棄用', deprecatedTooltip: '請安裝對應「知識引擎」插件',