import { useState, useEffect, forwardRef, useImperativeHandle } from 'react'; import { useNavigate } from 'react-router-dom'; import { ExtensionCardVO, ExtensionType } from './ExtensionCardVO'; import ExtensionCardComponent from './ExtensionCardComponent'; import styles from '@/app/home/plugins/plugins.module.css'; import { httpClient } from '@/app/infra/http/HttpClient'; import { getCloudServiceClientSync } from '@/app/infra/http'; import { isNewerVersion } from '@/app/utils/versionCompare'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { useTranslation } from 'react-i18next'; 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, Search, Server, Sparkles } from 'lucide-react'; import { pluginTaskKey, usePluginInstallTasks, } from '@/app/home/plugins/components/plugin-install-task'; export interface PluginInstalledComponentRef { refreshPluginList: () => void; } enum ExtensionOperationType { DELETE = 'DELETE', UPDATE = 'UPDATE', } export type FilterType = 'all' | ExtensionType; export const FilterOptions = [ { value: 'all' as FilterType, labelKey: 'market.filters.allFormats', icon: null, }, { value: 'plugin' as FilterType, labelKey: 'market.typePlugin', icon: Puzzle, }, { value: 'mcp' as FilterType, labelKey: 'market.typeMCP', icon: Server, }, { value: 'skill' as FilterType, labelKey: 'market.typeSkill', icon: Sparkles, }, ]; 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, searchQuery = '', onClearSearch }, ref) => { const { t } = useTranslation(); const navigate = useNavigate(); const { addTask, setSelectedTaskId } = usePluginInstallTasks(); const { refreshPlugins, refreshMCPServers, refreshSkills } = useSidebarData(); const [extensionList, setExtensionList] = useState([]); const [loading, setLoading] = useState(true); const [showOperationModal, setShowOperationModal] = useState(false); const [operationType, setOperationType] = useState( ExtensionOperationType.DELETE, ); const [targetExtension, setTargetExtension] = useState(null); const [deleteData, setDeleteData] = useState(false); const asyncTask = useAsyncTask({ onSuccess: () => { toast.success(t('plugins.deleteSuccess')); setShowOperationModal(false); getExtensionList(); refreshPlugins(); refreshMCPServers(); refreshSkills(); }, onError: () => {}, }); useEffect(() => { initData(); }, []); function initData() { getExtensionList(); } async function getExtensionList(silent = false) { if (!silent) setLoading(true); try { const client = getCloudServiceClientSync(); const [extensionsResp, marketplaceResp] = await Promise.all([ httpClient.getExtensions().catch(() => ({ extensions: [] })), client.getMarketplacePlugins(1, 100).catch(() => ({ plugins: [] })), ]); const marketplacePluginMap = new Map(); marketplaceResp.plugins.forEach((plugin: any) => { const key = `${plugin.author}/${plugin.name}`; marketplacePluginMap.set(key, plugin); }); const extensions: ExtensionCardVO[] = []; for (const item of extensionsResp.extensions) { if (item.type === 'plugin') { const plugin = item.plugin; const meta = plugin.manifest.manifest.metadata; const author = meta.author ?? ''; const name = meta.name; const marketplaceKey = `${author}/${name}`; const marketplacePlugin = marketplacePluginMap.get(marketplaceKey); let hasUpdate = false; if (plugin.install_source === 'marketplace' && marketplacePlugin) { if (marketplacePlugin.latest_version) { hasUpdate = isNewerVersion( marketplacePlugin.latest_version, meta.version ?? '', ); } } extensions.push( new ExtensionCardVO({ id: marketplaceKey, author, label: extractI18nObject(meta.label) || name, name, description: extractI18nObject( meta.description ?? { en_US: '', zh_Hans: '' }, ), version: meta.version ?? '', enabled: plugin.enabled, type: marketplacePlugin?.type || 'plugin', // Icon is resolved by ExtensionCardComponent through the shared // installed-plugin hook (authenticated fetch in multi-Workspace // deployments); the public /icon route 404s there. install_source: plugin.install_source, install_info: plugin.install_info, status: plugin.status, debug: plugin.debug, hasUpdate, }), ); } else if (item.type === 'mcp') { const server = item.server; extensions.push( new ExtensionCardVO({ id: server.name, author: '', label: server.name.replace(/__/g, '/'), name: server.name, description: '', version: '', enabled: server.enable, type: 'mcp', iconURL: httpClient.getPluginIconURL('mcp', server.name), status: server.runtime_info?.status, runtimeStatus: server.runtime_info?.status, tools: server.runtime_info?.tool_count || 0, mode: server.mode, }), ); } else if (item.type === 'skill') { const skill = item.skill; extensions.push( new ExtensionCardVO({ id: skill.name, author: '', label: skill.display_name || skill.name, name: skill.name, description: skill.description || '', version: '', enabled: true, type: 'skill', iconURL: httpClient.getPluginIconURL('skill', skill.name), }), ); } } setExtensionList(extensions); } catch (error) { console.error('Failed to fetch extension list:', error); if (!silent) setExtensionList([]); } finally { if (!silent) setLoading(false); } } // While any MCP server is still connecting, poll quietly so the status badge // transitions (connecting -> connected/error) without a manual refresh. useEffect(() => { const hasConnecting = extensionList.some( (e) => e.type === 'mcp' && e.enabled && e.runtimeStatus === 'connecting', ); if (!hasConnecting) return; const timer = setInterval(() => { getExtensionList(true); }, 3000); return () => clearInterval(timer); }, [extensionList]); useImperativeHandle(ref, () => ({ refreshPluginList: getExtensionList, })); function handleExtensionClick(extension: ExtensionCardVO) { if (extension.type === 'mcp') { navigate(`/home/mcp?id=${encodeURIComponent(extension.id)}`); } else if (extension.type === 'skill') { navigate(`/home/skills?id=${encodeURIComponent(extension.id)}`); } else { const extensionId = `${extension.author}/${extension.name}`; navigate(`/home/extensions?id=${encodeURIComponent(extensionId)}`); } } function handleExtensionDelete(extension: ExtensionCardVO) { setTargetExtension(extension); setOperationType(ExtensionOperationType.DELETE); setShowOperationModal(true); setDeleteData(false); asyncTask.reset(); } function handleExtensionUpdate(extension: ExtensionCardVO) { setTargetExtension(extension); setOperationType(ExtensionOperationType.UPDATE); setShowOperationModal(true); asyncTask.reset(); } function executeOperation() { if (!targetExtension) return; if (targetExtension.type === 'mcp') { httpClient .deleteMCPServer(targetExtension.name) .then(() => { toast.success(t('mcp.deleteSuccess')); setShowOperationModal(false); getExtensionList(); refreshMCPServers(); }) .catch((error) => { toast.error(t('mcp.deleteError') + error.message); }); return; } if (targetExtension.type === 'skill') { httpClient .deleteSkill(targetExtension.name) .then(() => { toast.success(t('skills.deleteSuccess')); setShowOperationModal(false); getExtensionList(); refreshSkills(); }) .catch((error) => { toast.error(t('skills.deleteError') + error.message); }); return; } if (operationType === ExtensionOperationType.UPDATE) { httpClient .upgradePlugin(targetExtension.author, targetExtension.name) .then((res) => { addTask({ taskId: res.task_id, pluginName: `${targetExtension.author}/${targetExtension.name}`, source: 'marketplace', extensionType: 'plugin', operation: 'upgrade', }); setSelectedTaskId( pluginTaskKey(res.task_id, 'marketplace', 'upgrade'), ); setShowOperationModal(false); setTargetExtension(null); asyncTask.reset(); }) .catch((error) => { toast.error(t('plugins.updateError') + error.message); }); return; } httpClient .removePlugin(targetExtension.author, targetExtension.name, deleteData) .then((res) => { asyncTask.startTask(res.task_id); }) .catch((error) => { toast.error(t('plugins.deleteError') + error.message); }); } // 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' && 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 .map((type) => ({ type, labelKey: FilterOptions.find((o) => o.value === type)!.labelKey, items: filteredExtensions.filter((ext) => ext.type === type), })) .filter((g) => g.items.length > 0); const getDeleteConfirmMessage = () => { if (!targetExtension) return ''; if (targetExtension.type === 'mcp') { return t('mcp.confirmDeleteServer'); } if (targetExtension.type === 'skill') { return t('skills.deleteConfirmation'); } return t('plugins.confirmDeletePlugin', { author: targetExtension.author, name: targetExtension.name, }); }; return ( <> { if (!open) { setShowOperationModal(false); setTargetExtension(null); asyncTask.reset(); } }} > {operationType === ExtensionOperationType.DELETE ? t('plugins.deleteConfirm') : t('plugins.updateConfirm')} {asyncTask.status === AsyncTaskStatus.WAIT_INPUT && (
{getDeleteConfirmMessage()}
{operationType === ExtensionOperationType.DELETE && targetExtension?.type === 'plugin' && (
setDeleteData(checked === true) } />
)}
)} {asyncTask.status === AsyncTaskStatus.RUNNING && (
{operationType === ExtensionOperationType.DELETE ? t('plugins.deleting') : t('plugins.updating')}
)} {asyncTask.status === AsyncTaskStatus.ERROR && (
{operationType === ExtensionOperationType.DELETE ? t('plugins.deleteError') : t('plugins.updateError')}
{asyncTask.error}
)}
{asyncTask.status === AsyncTaskStatus.WAIT_INPUT && ( )} {asyncTask.status === AsyncTaskStatus.WAIT_INPUT && ( )} {asyncTask.status === AsyncTaskStatus.RUNNING && ( )} {asyncTask.status === AsyncTaskStatus.ERROR && ( )}
{loading ? (
{t('plugins.loadingExtensions')}
) : filteredExtensions.length === 0 ? (
{normalizedQuery ? ( <>
{t('plugins.noMatchingExtensions', { query: searchQuery.trim(), })}
) : ( <>
{t('plugins.noExtensionInstalled')}
)}
) : showGrouped ? (
{groupedExtensions.map((group) => (

{t(group.labelKey)}

({group.items.length})
{group.items.map((vo, index) => (
handleExtensionClick(vo)} onDeleteClick={() => handleExtensionDelete(vo)} onUpgradeClick={ vo.type === 'plugin' ? () => handleExtensionUpdate(vo) : undefined } />
))}
))}
) : (
{filteredExtensions.map((vo, index) => { return (
handleExtensionClick(vo)} onDeleteClick={() => handleExtensionDelete(vo)} onUpgradeClick={ vo.type === 'plugin' ? () => handleExtensionUpdate(vo) : undefined } />
); })}
)} ); }); export default PluginInstalledComponent;