{stageLabel}
{isRunning && (
diff --git a/web/src/app/home/plugins/components/plugin-installed/ExtensionCardComponent.tsx b/web/src/app/home/plugins/components/plugin-installed/ExtensionCardComponent.tsx
new file mode 100644
index 00000000..871fe0fe
--- /dev/null
+++ b/web/src/app/home/plugins/components/plugin-installed/ExtensionCardComponent.tsx
@@ -0,0 +1,317 @@
+import { ExtensionCardVO, ExtensionType } from './ExtensionCardVO';
+import { useState } from 'react';
+import { Badge } from '@/components/ui/badge';
+import { useTranslation } from 'react-i18next';
+import { BugIcon, ExternalLink, Ellipsis, Trash, ArrowUp } from 'lucide-react';
+import { getCloudServiceClientSync, systemInfo } from '@/app/infra/http';
+import { httpClient } from '@/app/infra/http/HttpClient';
+import { Button } from '@/components/ui/button';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+import {
+ MCPSessionStatus,
+} from '@/app/infra/entities/api';
+
+type ExtensionCardComponentProps = {
+ cardVO: ExtensionCardVO;
+ onCardClick: () => void;
+ onDeleteClick: (cardVO: ExtensionCardVO) => void;
+ onUpgradeClick?: (cardVO: ExtensionCardVO) => void;
+};
+
+export default function ExtensionCardComponent({
+ cardVO,
+ onCardClick,
+ onDeleteClick,
+ onUpgradeClick,
+}: ExtensionCardComponentProps) {
+ const { t } = useTranslation();
+ const [dropdownOpen, setDropdownOpen] = useState(false);
+
+ const getTypeBadgeColor = (type: ExtensionType) => {
+ switch (type) {
+ case 'mcp':
+ return 'border-sky-500 text-sky-600 dark:border-sky-400 dark:text-sky-300';
+ case 'skill':
+ return 'border-emerald-500 text-emerald-600 dark:border-emerald-400 dark:text-emerald-300';
+ default:
+ return 'border-violet-500 text-violet-600 dark:border-violet-400 dark:text-violet-300';
+ }
+ };
+
+ const getTypeLabel = (type: ExtensionType) => {
+ switch (type) {
+ case 'mcp':
+ return 'MCP';
+ case 'skill':
+ return t('common.skill');
+ default:
+ return t('market.typePlugin');
+ }
+ };
+
+ const getStatusColor = (status?: string) => {
+ switch (status) {
+ case MCPSessionStatus.CONNECTED:
+ return 'text-green-600';
+ case MCPSessionStatus.CONNECTING:
+ return 'text-yellow-600';
+ case MCPSessionStatus.ERROR:
+ return 'text-red-600';
+ default:
+ return 'text-gray-600';
+ }
+ };
+
+ const renderPluginContent = () => (
+ <>
+
+ {cardVO.author} / {cardVO.name}
+
+
+
+ {cardVO.label}
+
+
+ v{cardVO.version}
+
+
+ {getTypeLabel(cardVO.type)}
+
+ {cardVO.debug && (
+
+
+ {t('plugins.debugging')}
+
+ )}
+ {!cardVO.debug && (
+ <>
+ {cardVO.install_source === 'github' && (
+
+ {t('plugins.fromGithub')}
+
+ )}
+ {cardVO.install_source === 'local' && (
+
+ {t('plugins.fromLocal')}
+
+ )}
+ {cardVO.install_source === 'marketplace' && (
+
+ {t('plugins.fromMarketplace')}
+
+ )}
+ >
+ )}
+
+
+ {cardVO.description}
+
+ >
+ );
+
+ const renderMCPContent = () => (
+ <>
+
+ MCP Server
+
+
+
+ {cardVO.label}
+
+
+ MCP
+
+ {cardVO.mode && (
+
+ {cardVO.mode.toUpperCase()}
+
+ )}
+
+ {cardVO.enabled ? t('mcp.statusConnected') : t('mcp.statusDisabled')}
+
+
+
+ {cardVO.description || t('mcp.noToolsFound')}
+ {cardVO.tools !== undefined && cardVO.tools > 0 && (
+ {t('mcp.toolCount', { count: cardVO.tools })}
+ )}
+
+ >
+ );
+
+ const renderSkillContent = () => (
+ <>
+
+ Skill
+
+
+
+ {cardVO.label}
+
+
+ {t('common.skill')}
+
+
+
+ {cardVO.description}
+
+ >
+ );
+
+ return (
+ <>
+
onCardClick()}
+ >
+
+

+
+
+
+ {cardVO.type === 'plugin' && renderPluginContent()}
+ {cardVO.type === 'mcp' && renderMCPContent()}
+ {cardVO.type === 'skill' && renderSkillContent()}
+
+
+
+
e.stopPropagation()}
+ >
+
+
+
+
{
+ setDropdownOpen(open);
+ }}
+ >
+
+
+
+ {cardVO.hasUpdate && (
+
+ )}
+
+
+
+ {cardVO.type === 'plugin' && cardVO.install_source === 'marketplace' && (
+ {
+ e.stopPropagation();
+ if (onUpgradeClick) {
+ onUpgradeClick(cardVO);
+ }
+ setDropdownOpen(false);
+ }}
+ >
+
+ {t('plugins.update')}
+ {cardVO.hasUpdate && (
+
+ {t('plugins.new')}
+
+ )}
+
+ )}
+ {cardVO.type === 'plugin' && (cardVO.install_source === 'github' || cardVO.install_source === 'marketplace') && (
+ {
+ e.stopPropagation();
+ if (cardVO.install_source === 'github') {
+ window.open(cardVO.install_info?.github_url as string, '_blank');
+ } else if (cardVO.install_source === 'marketplace') {
+ window.open(
+ getCloudServiceClientSync().getPluginMarketplaceURL(
+ systemInfo.cloud_service_url,
+ cardVO.author,
+ cardVO.name,
+ ),
+ '_blank',
+ );
+ }
+ setDropdownOpen(false);
+ }}
+ >
+
+ {t('plugins.viewSource')}
+
+ )}
+ {
+ e.stopPropagation();
+ onDeleteClick(cardVO);
+ setDropdownOpen(false);
+ }}
+ >
+
+
+ {cardVO.type === 'mcp'
+ ? t('mcp.deleteServer')
+ : cardVO.type === 'skill'
+ ? t('skills.delete')
+ : t('plugins.delete')}
+
+
+
+
+
+
+
+
+ >
+ );
+}
\ No newline at end of file
diff --git a/web/src/app/home/plugins/components/plugin-installed/ExtensionCardVO.ts b/web/src/app/home/plugins/components/plugin-installed/ExtensionCardVO.ts
new file mode 100644
index 00000000..19511982
--- /dev/null
+++ b/web/src/app/home/plugins/components/plugin-installed/ExtensionCardVO.ts
@@ -0,0 +1,61 @@
+export type ExtensionType = 'plugin' | 'mcp' | 'skill';
+
+export interface IExtensionCardVO {
+ id: string;
+ author: string;
+ label: string;
+ name: string;
+ description: string;
+ version: string;
+ enabled: boolean;
+ type: ExtensionType;
+ iconURL?: string;
+ install_source?: string;
+ install_info?: Record
;
+ status?: string;
+ debug?: boolean;
+ hasUpdate?: boolean;
+ runtimeStatus?: 'connecting' | 'connected' | 'error' | 'disabled';
+ tools?: number;
+ mode?: 'stdio' | 'sse' | 'http';
+}
+
+export class ExtensionCardVO implements IExtensionCardVO {
+ id: string;
+ author: string;
+ label: string;
+ name: string;
+ description: string;
+ version: string;
+ enabled: boolean;
+ type: ExtensionType;
+ iconURL?: string;
+ install_source?: string;
+ install_info?: Record;
+ status?: string;
+ debug?: boolean;
+ hasUpdate?: boolean;
+ runtimeStatus?: 'connecting' | 'connected' | 'error' | 'disabled';
+ tools?: number;
+ mode?: 'stdio' | 'sse' | 'http';
+
+ constructor(prop: IExtensionCardVO) {
+ this.id = prop.id;
+ this.author = prop.author;
+ this.label = prop.label;
+ this.name = prop.name;
+ this.description = prop.description;
+ this.version = prop.version;
+ this.enabled = prop.enabled;
+ this.type = prop.type;
+ this.iconURL = prop.iconURL;
+ this.install_source = prop.install_source;
+ this.install_info = prop.install_info;
+ this.status = prop.status;
+ this.debug = prop.debug;
+ this.hasUpdate = prop.hasUpdate;
+ this.runtimeStatus = prop.runtimeStatus;
+ this.tools = prop.tools;
+ this.mode = prop.mode;
+ }
+}
\ No newline at end of file
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 e82f921e..8cb21894 100644
--- a/web/src/app/home/plugins/components/plugin-installed/PluginInstalledComponent.tsx
+++ b/web/src/app/home/plugins/components/plugin-installed/PluginInstalledComponent.tsx
@@ -1,7 +1,7 @@
import { useState, useEffect, forwardRef, useImperativeHandle } from 'react';
import { useNavigate } from 'react-router-dom';
-import { PluginCardVO } from '@/app/home/plugins/components/plugin-installed/PluginCardVO';
-import PluginCardComponent from '@/app/home/plugins/components/plugin-installed/plugin-card/PluginCardComponent';
+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';
@@ -22,42 +22,59 @@ import { toast } from 'sonner';
import { useAsyncTask, AsyncTaskStatus } from '@/hooks/useAsyncTask';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import { Puzzle } from 'lucide-react';
+import {
+ ToggleGroup,
+ ToggleGroupItem,
+} from '@/components/ui/toggle-group';
+import { Wrench, AudioWaveform, Book } from 'lucide-react';
+import { MCPSessionStatus } from '@/app/infra/entities/api';
export interface PluginInstalledComponentRef {
refreshPluginList: () => void;
}
-enum PluginOperationType {
+enum ExtensionOperationType {
DELETE = 'DELETE',
UPDATE = 'UPDATE',
}
+type FilterType = 'all' | ExtensionType;
+
+const FilterOptions = [
+ { value: 'all' as FilterType, labelKey: 'market.filters.allFormats', icon: null },
+ { value: 'plugin' as FilterType, labelKey: 'market.typePlugin', icon: Wrench },
+ { value: 'mcp' as FilterType, labelKey: 'market.typeMCP', icon: AudioWaveform },
+ { value: 'skill' as FilterType, labelKey: 'market.typeSkill', icon: Book },
+];
+
const PluginInstalledComponent = forwardRef(
(props, ref) => {
const { t } = useTranslation();
const navigate = useNavigate();
- const { refreshPlugins } = useSidebarData();
- const [pluginList, setPluginList] = useState([]);
+ const { refreshPlugins, refreshMCPServers, refreshSkills } = useSidebarData();
+ const [extensionList, setExtensionList] = useState([]);
+ const [filterType, setFilterType] = useState('all');
const [showOperationModal, setShowOperationModal] = useState(false);
- const [operationType, setOperationType] = useState(
- PluginOperationType.DELETE,
+ const [operationType, setOperationType] = useState(
+ ExtensionOperationType.DELETE,
);
- const [targetPlugin, setTargetPlugin] = useState(null);
+ const [targetExtension, setTargetExtension] = useState(null);
const [deleteData, setDeleteData] = useState(false);
const asyncTask = useAsyncTask({
onSuccess: () => {
const successMessage =
- operationType === PluginOperationType.DELETE
+ operationType === ExtensionOperationType.DELETE
? t('plugins.deleteSuccess')
: t('plugins.updateSuccess');
toast.success(successMessage);
setShowOperationModal(false);
- getPluginList();
+ getExtensionList();
refreshPlugins();
+ refreshMCPServers();
+ refreshSkills();
},
onError: () => {
- // Error is already handled in the hook state
},
});
@@ -66,131 +83,171 @@ const PluginInstalledComponent = forwardRef(
}, []);
function initData() {
- getPluginList();
+ getExtensionList();
}
- async function getPluginList() {
+ async function getExtensionList() {
try {
- // 获取已安装插件列表
- const installedPluginsResp = await httpClient.getPlugins();
- const installedPlugins = installedPluginsResp.plugins;
-
- // 获取市场插件列表
const client = getCloudServiceClientSync();
- const marketplaceResp = await client.getMarketplacePlugins(1, 100);
- const marketplacePlugins = marketplaceResp.plugins;
- // 创建市场插件映射,便于快速查找
- const marketplacePluginMap = new Map();
- marketplacePlugins.forEach((plugin) => {
+ const [installedPluginsResp, marketplaceResp, mcpResp, skillsResp] = await Promise.all([
+ httpClient.getPlugins().catch(() => ({ plugins: [] })),
+ client.getMarketplacePlugins(1, 100).catch(() => ({ plugins: [] })),
+ httpClient.getMCPServers().catch(() => ({ servers: [] })),
+ httpClient.getSkills().catch(() => ({ skills: [] })),
+ ]);
+
+ const marketplacePluginMap = new Map();
+ marketplaceResp.plugins.forEach((plugin: any) => {
const key = `${plugin.author}/${plugin.name}`;
marketplacePluginMap.set(key, plugin);
});
- // 转换并比较版本号
- const pluginCards = installedPlugins.map((plugin) => {
- const marketplaceKey = `${plugin.manifest.manifest.metadata.author}/${plugin.manifest.manifest.metadata.name}`;
- const marketplacePlugin = marketplacePluginMap.get(marketplaceKey);
- const cardVO = new PluginCardVO({
- author: plugin.manifest.manifest.metadata.author ?? '',
- label: extractI18nObject(plugin.manifest.manifest.metadata.label),
- description: extractI18nObject(
- plugin.manifest.manifest.metadata.description ?? {
- en_US: '',
- zh_Hans: '',
- },
- ),
- debug: plugin.debug,
- enabled: plugin.enabled,
- name: plugin.manifest.manifest.metadata.name,
- version: plugin.manifest.manifest.metadata.version ?? '',
- status: plugin.status,
- components: plugin.components,
- priority: plugin.priority,
- install_source: plugin.install_source,
- install_info: plugin.install_info,
- type: marketplacePlugin?.type,
- });
+ const extensions: ExtensionCardVO[] = [];
- // 检查是否来自市场且有更新
- if (cardVO.install_source === 'marketplace' && marketplacePlugin) {
+ for (const plugin of installedPluginsResp.plugins) {
+ 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) {
- cardVO.hasUpdate = isNewerVersion(
+ hasUpdate = isNewerVersion(
marketplacePlugin.latest_version,
- cardVO.version,
+ meta.version ?? '',
);
}
}
- return cardVO;
- });
+ 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',
+ iconURL: httpClient.getPluginIconURL(author, name),
+ install_source: plugin.install_source,
+ install_info: plugin.install_info,
+ status: plugin.status,
+ debug: plugin.debug,
+ hasUpdate,
+ }));
+ }
- setPluginList(pluginCards);
+ for (const server of mcpResp.servers) {
+ extensions.push(new ExtensionCardVO({
+ id: `mcp-${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,
+ }));
+ }
+
+ for (const skill of skillsResp.skills) {
+ extensions.push(new ExtensionCardVO({
+ id: `skill-${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('获取插件列表失败:', error);
- // 失败时仍显示已安装插件,不影响用户体验
- const installedPluginsResp = await httpClient.getPlugins();
- setPluginList(
- installedPluginsResp.plugins.map((plugin) => {
- return new PluginCardVO({
- author: plugin.manifest.manifest.metadata.author ?? '',
- label: extractI18nObject(plugin.manifest.manifest.metadata.label),
- description: extractI18nObject(
- plugin.manifest.manifest.metadata.description ?? {
- en_US: '',
- zh_Hans: '',
- },
- ),
- debug: plugin.debug,
- enabled: plugin.enabled,
- name: plugin.manifest.manifest.metadata.name,
- version: plugin.manifest.manifest.metadata.version ?? '',
- status: plugin.status,
- components: plugin.components,
- priority: plugin.priority,
- install_source: plugin.install_source,
- install_info: plugin.install_info,
- });
- }),
- );
+ console.error('Failed to fetch extension list:', error);
+ setExtensionList([]);
}
}
useImperativeHandle(ref, () => ({
- refreshPluginList: getPluginList,
+ refreshPluginList: getExtensionList,
}));
- function handlePluginClick(plugin: PluginCardVO) {
- const pluginId = `${plugin.author}/${plugin.name}`;
- navigate(`/home/plugins?id=${encodeURIComponent(pluginId)}`);
+ function handleExtensionClick(extension: ExtensionCardVO) {
+ if (extension.type === 'mcp') {
+ navigate(`/home/mcp`);
+ } else if (extension.type === 'skill') {
+ navigate(`/home/skills`);
+ } else {
+ const extensionId = `${extension.author}/${extension.name}`;
+ navigate(`/home/plugins?id=${encodeURIComponent(extensionId)}`);
+ }
}
- function handlePluginDelete(plugin: PluginCardVO) {
- setTargetPlugin(plugin);
- setOperationType(PluginOperationType.DELETE);
+ function handleExtensionDelete(extension: ExtensionCardVO) {
+ setTargetExtension(extension);
+ setOperationType(ExtensionOperationType.DELETE);
setShowOperationModal(true);
setDeleteData(false);
asyncTask.reset();
}
- function handlePluginUpdate(plugin: PluginCardVO) {
- setTargetPlugin(plugin);
- setOperationType(PluginOperationType.UPDATE);
+ function handleExtensionUpdate(extension: ExtensionCardVO) {
+ setTargetExtension(extension);
+ setOperationType(ExtensionOperationType.UPDATE);
setShowOperationModal(true);
asyncTask.reset();
}
function executeOperation() {
- if (!targetPlugin) return;
+ 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;
+ }
const apiCall =
- operationType === PluginOperationType.DELETE
+ operationType === ExtensionOperationType.DELETE
? httpClient.removePlugin(
- targetPlugin.author,
- targetPlugin.name,
+ targetExtension.author,
+ targetExtension.name,
deleteData,
)
- : httpClient.upgradePlugin(targetPlugin.author, targetPlugin.name);
+ : httpClient.upgradePlugin(targetExtension.author, targetExtension.name);
apiCall
.then((res) => {
@@ -198,13 +255,32 @@ const PluginInstalledComponent = forwardRef(
})
.catch((error) => {
const errorMessage =
- operationType === PluginOperationType.DELETE
+ operationType === ExtensionOperationType.DELETE
? t('plugins.deleteError') + error.message
: t('plugins.updateError') + error.message;
toast.error(errorMessage);
});
}
+ const filteredExtensions = extensionList.filter((ext) => {
+ if (filterType === 'all') return true;
+ return ext.type === filterType;
+ });
+
+ 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 (
<>