From 8ff60c5b98798d3ba4d5a37e84ccfb92cb9ea124 Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Sat, 9 May 2026 23:50:17 +0800 Subject: [PATCH] feat(extensions): unify extensions endpoint and refresh extensions page UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename /home/plugins route to /home/extensions and update all sidebar links. - Add unified GET /api/v1/extensions returning plugins, MCP servers and skills, sorted by name; replace the three separate frontend fetches with this single call. - Migrate the extensions page to shadcn primitives (Tabs/Card/Alert/Badge/Skeleton/ Switch/Label) and clean up hardcoded color tokens on the extension card. - Add a localStorage-persisted "Group by type" switch that, when enabled in the All Types tab, renders extensions grouped by type with a compact section header. - Show a spinner while loading and rename the empty-state copy from "No plugins installed" to "No extensions installed". - Rename the "格式 / Formats" filter label to "类型 / Types" across all 8 locales. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../api/http/controller/groups/extensions.py | 52 ++ .../components/home-sidebar/HomeSidebar.tsx | 10 +- .../home-sidebar/sidbarConfigList.tsx | 4 +- web/src/app/home/layout.tsx | 4 +- .../ExtensionCardComponent.tsx | 150 ++-- .../PluginInstalledComponent.tsx | 766 ++++++++++-------- web/src/app/home/plugins/page.tsx | 254 +++--- web/src/app/infra/entities/api/index.ts | 9 + web/src/app/infra/http/BackendClient.ts | 16 +- web/src/i18n/locales/en-US.ts | 5 +- web/src/i18n/locales/es-ES.ts | 5 +- web/src/i18n/locales/ja-JP.ts | 5 +- web/src/i18n/locales/ru-RU.ts | 5 +- web/src/i18n/locales/th-TH.ts | 5 +- web/src/i18n/locales/vi-VN.ts | 5 +- web/src/i18n/locales/zh-Hans.ts | 5 +- web/src/i18n/locales/zh-Hant.ts | 5 +- web/src/router.tsx | 2 +- 18 files changed, 756 insertions(+), 551 deletions(-) create mode 100644 src/langbot/pkg/api/http/controller/groups/extensions.py diff --git a/src/langbot/pkg/api/http/controller/groups/extensions.py b/src/langbot/pkg/api/http/controller/groups/extensions.py new file mode 100644 index 00000000..ac8463c9 --- /dev/null +++ b/src/langbot/pkg/api/http/controller/groups/extensions.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import asyncio +import quart + +from .. import group + + +@group.group_class('extensions', '/api/v1/extensions') +class ExtensionsRouterGroup(group.RouterGroup): + """Unified API for installed extensions (plugins, MCP servers, skills).""" + + async def initialize(self) -> None: + @self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def _() -> quart.Response: + plugins, mcp_servers, skills = await asyncio.gather( + self.ap.plugin_connector.list_plugins(), + self.ap.mcp_service.get_mcp_servers(contain_runtime_info=True), + self.ap.skill_service.list_skills(), + return_exceptions=True, + ) + + def _sort_key(item: dict) -> str: + if item['type'] == 'plugin': + return ( + item['plugin'] + .get('manifest', {}) + .get('manifest', {}) + .get('metadata', {}) + .get('name', '') + .lower() + ) + if item['type'] == 'mcp': + return (item['server'].get('name') or '').lower() + if item['type'] == 'skill': + return (item['skill'].get('display_name') or item['skill'].get('name') or '').lower() + return '' + + extensions: list[dict] = [] + if isinstance(plugins, list): + for plugin in plugins: + extensions.append({'type': 'plugin', 'plugin': plugin}) + if isinstance(mcp_servers, list): + for server in mcp_servers: + extensions.append({'type': 'mcp', 'server': server}) + if isinstance(skills, list): + for skill in skills: + extensions.append({'type': 'skill', 'skill': skill}) + + extensions.sort(key=_sort_key) + + return self.success(data={'extensions': extensions}) diff --git a/web/src/app/home/components/home-sidebar/HomeSidebar.tsx b/web/src/app/home/components/home-sidebar/HomeSidebar.tsx index e54a2b3e..7ea5b9ab 100644 --- a/web/src/app/home/components/home-sidebar/HomeSidebar.tsx +++ b/web/src/app/home/components/home-sidebar/HomeSidebar.tsx @@ -177,7 +177,7 @@ const ENTITY_ROUTE_MAP: Record = { bots: '/home/bots', pipelines: '/home/pipelines', knowledge: '/home/knowledge', - plugins: '/home/plugins', + plugins: '/home/extensions', mcp: '/home/mcp', skills: '/home/skills', }; @@ -664,7 +664,7 @@ function NavItems({ onClick={(e) => { e.stopPropagation(); setPendingPluginInstallAction('local'); - navigate('/home/plugins'); + navigate('/home/extensions'); setPopoverOpen((prev) => ({ ...prev, [config.id]: false, @@ -678,7 +678,7 @@ function NavItems({ onClick={(e) => { e.stopPropagation(); setPendingPluginInstallAction('github'); - navigate('/home/plugins'); + navigate('/home/extensions'); setPopoverOpen((prev) => ({ ...prev, [config.id]: false, @@ -838,7 +838,7 @@ function NavItems({ onClick={(e) => { e.stopPropagation(); setPendingPluginInstallAction('local'); - navigate('/home/plugins'); + navigate('/home/extensions'); }} > @@ -848,7 +848,7 @@ function NavItems({ onClick={(e) => { e.stopPropagation(); setPendingPluginInstallAction('github'); - navigate('/home/plugins'); + navigate('/home/extensions'); }} > diff --git a/web/src/app/home/components/home-sidebar/sidbarConfigList.tsx b/web/src/app/home/components/home-sidebar/sidbarConfigList.tsx index a6a717a6..8f12c5d0 100644 --- a/web/src/app/home/components/home-sidebar/sidbarConfigList.tsx +++ b/web/src/app/home/components/home-sidebar/sidbarConfigList.tsx @@ -86,7 +86,7 @@ export const sidebarConfigList = [ id: 'plugins', name: t('sidebar.installedPlugins'), icon: , - route: '/home/plugins', + route: '/home/extensions', description: t('plugins.description'), helpLink: { en_US: 'https://link.langbot.app/en/docs/plugins', @@ -108,4 +108,4 @@ export const sidebarConfigList = [ }, section: 'extensions', }), -]; \ No newline at end of file +]; diff --git a/web/src/app/home/layout.tsx b/web/src/app/home/layout.tsx index f678b6e7..8cce527e 100644 --- a/web/src/app/home/layout.tsx +++ b/web/src/app/home/layout.tsx @@ -45,7 +45,7 @@ import { // Routes that belong to the "Extensions" section const EXTENSIONS_ROUTES = [ - '/home/plugins', + '/home/extensions', '/home/market', '/home/mcp', '/home/skills', @@ -124,7 +124,7 @@ function HomeLayoutInner({ children }: { children: React.ReactNode }) { const sectionLabel = isExtensions ? t('sidebar.extensions') : t('sidebar.home'); - const sectionLink = isExtensions ? '/home/plugins' : '/home/monitoring'; + const sectionLink = isExtensions ? '/home/extensions' : '/home/monitoring'; return ( diff --git a/web/src/app/home/plugins/components/plugin-installed/ExtensionCardComponent.tsx b/web/src/app/home/plugins/components/plugin-installed/ExtensionCardComponent.tsx index 871fe0fe..61c19ff7 100644 --- a/web/src/app/home/plugins/components/plugin-installed/ExtensionCardComponent.tsx +++ b/web/src/app/home/plugins/components/plugin-installed/ExtensionCardComponent.tsx @@ -6,15 +6,14 @@ 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 { Card } from '@/components/ui/card'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; -import { - MCPSessionStatus, -} from '@/app/infra/entities/api'; +import { MCPSessionStatus } from '@/app/infra/entities/api'; type ExtensionCardComponentProps = { cardVO: ExtensionCardVO; @@ -69,17 +68,14 @@ export default function ExtensionCardComponent({ const renderPluginContent = () => ( <> -
+
{cardVO.author} / {cardVO.name}
-
+
{cardVO.label}
- + v{cardVO.version} )}
-
+
{cardVO.description}
@@ -134,11 +130,11 @@ export default function ExtensionCardComponent({ const renderMCPContent = () => ( <> -
+
MCP Server
-
+
{cardVO.label}
-
+
{cardVO.description || t('mcp.noToolsFound')} {cardVO.tools !== undefined && cardVO.tools > 0 && ( - {t('mcp.toolCount', { count: cardVO.tools })} + + {t('mcp.toolCount', { count: cardVO.tools })} + )}
@@ -177,11 +175,11 @@ export default function ExtensionCardComponent({ const renderSkillContent = () => ( <> -
+
Skill
-
+
{cardVO.label}
-
+
{cardVO.description}
@@ -199,13 +197,16 @@ export default function ExtensionCardComponent({ return ( <> -
onCardClick()} >
extension icon @@ -233,62 +234,65 @@ export default function ExtensionCardComponent({ >
- {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')} - - )} + {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')} + + )} { @@ -302,8 +306,8 @@ export default function ExtensionCardComponent({ {cardVO.type === 'mcp' ? t('mcp.deleteServer') : cardVO.type === 'skill' - ? t('skills.delete') - : t('plugins.delete')} + ? t('skills.delete') + : t('plugins.delete')} @@ -311,7 +315,7 @@ export default function ExtensionCardComponent({
-
+ ); -} \ 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 926ce072..b530e7d4 100644 --- a/web/src/app/home/plugins/components/plugin-installed/PluginInstalledComponent.tsx +++ b/web/src/app/home/plugins/components/plugin-installed/PluginInstalledComponent.tsx @@ -21,13 +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 { Puzzle } from 'lucide-react'; -import { - ToggleGroup, - ToggleGroupItem, -} from '@/components/ui/toggle-group'; +import { Loader2, Puzzle } from 'lucide-react'; import { Wrench, AudioWaveform, Book } from 'lucide-react'; -import { MCPSessionStatus } from '@/app/infra/entities/api'; export interface PluginInstalledComponentRef { refreshPluginList: () => void; @@ -38,74 +33,94 @@ enum ExtensionOperationType { UPDATE = 'UPDATE', } -type FilterType = 'all' | ExtensionType; +export 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 }, +export 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, refreshMCPServers, refreshSkills } = useSidebarData(); - const [extensionList, setExtensionList] = useState([]); - const [filterType, setFilterType] = useState('all'); - const [showOperationModal, setShowOperationModal] = useState(false); - const [operationType, setOperationType] = useState( - ExtensionOperationType.DELETE, - ); - const [targetExtension, setTargetExtension] = useState(null); - const [deleteData, setDeleteData] = useState(false); +interface PluginInstalledComponentProps { + filterType: FilterType; + groupByType: boolean; +} - const asyncTask = useAsyncTask({ - onSuccess: () => { - const successMessage = - operationType === ExtensionOperationType.DELETE - ? t('plugins.deleteSuccess') - : t('plugins.updateSuccess'); - toast.success(successMessage); - setShowOperationModal(false); - getExtensionList(); - refreshPlugins(); - refreshMCPServers(); - refreshSkills(); - }, - onError: () => { - }, - }); +const PluginInstalledComponent = forwardRef< + PluginInstalledComponentRef, + PluginInstalledComponentProps +>(({ filterType, groupByType }, ref) => { + const { t } = useTranslation(); + const navigate = useNavigate(); + 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); - useEffect(() => { - initData(); - }, []); - - function initData() { + const asyncTask = useAsyncTask({ + onSuccess: () => { + const successMessage = + operationType === ExtensionOperationType.DELETE + ? t('plugins.deleteSuccess') + : t('plugins.updateSuccess'); + toast.success(successMessage); + setShowOperationModal(false); getExtensionList(); - } + refreshPlugins(); + refreshMCPServers(); + refreshSkills(); + }, + onError: () => {}, + }); - async function getExtensionList() { - try { - const client = getCloudServiceClientSync(); + useEffect(() => { + initData(); + }, []); - 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: [] })), - ]); + function initData() { + getExtensionList(); + } - const marketplacePluginMap = new Map(); - marketplaceResp.plugins.forEach((plugin: any) => { - const key = `${plugin.author}/${plugin.name}`; - marketplacePluginMap.set(key, plugin); - }); + async function getExtensionList() { + setLoading(true); + try { + const client = getCloudServiceClientSync(); - const extensions: ExtensionCardVO[] = []; + const [extensionsResp, marketplaceResp] = await Promise.all([ + httpClient.getExtensions().catch(() => ({ extensions: [] })), + client.getMarketplacePlugins(1, 100).catch(() => ({ plugins: [] })), + ]); - for (const plugin of installedPluginsResp.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; @@ -122,190 +137,215 @@ const PluginInstalledComponent = forwardRef( } } - 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, - })); + 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, + }), + ); + } 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), + }), + ); } - - for (const server of mcpResp.servers) { - 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, - })); - } - - for (const skill of skillsResp.skills) { - 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); - setExtensionList([]); - } - } - - 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/plugins?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; - } + setExtensionList(extensions); + } catch (error) { + console.error('Failed to fetch extension list:', error); + setExtensionList([]); + } finally { + setLoading(false); + } + } - const apiCall = - operationType === ExtensionOperationType.DELETE - ? httpClient.removePlugin( - targetExtension.author, - targetExtension.name, - deleteData, - ) - : httpClient.upgradePlugin(targetExtension.author, targetExtension.name); + useImperativeHandle(ref, () => ({ + refreshPluginList: getExtensionList, + })); - apiCall - .then((res) => { - asyncTask.startTask(res.task_id); + 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) => { - const errorMessage = - operationType === ExtensionOperationType.DELETE - ? t('plugins.deleteError') + error.message - : t('plugins.updateError') + error.message; - toast.error(errorMessage); + toast.error(t('mcp.deleteError') + error.message); }); + return; } - const filteredExtensions = extensionList.filter((ext) => { - if (filterType === 'all') return true; - return ext.type === filterType; - }); + 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 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, + const apiCall = + operationType === ExtensionOperationType.DELETE + ? httpClient.removePlugin( + targetExtension.author, + targetExtension.name, + deleteData, + ) + : httpClient.upgradePlugin( + targetExtension.author, + targetExtension.name, + ); + + apiCall + .then((res) => { + asyncTask.startTask(res.task_id); + }) + .catch((error) => { + const errorMessage = + operationType === ExtensionOperationType.DELETE + ? t('plugins.deleteError') + error.message + : t('plugins.updateError') + error.message; + toast.error(errorMessage); }); - }; + } - 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' && ( + const filteredExtensions = extensionList.filter((ext) => { + if (filterType === 'all') return true; + return ext.type === filterType; + }); + + 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' && (
(
)} -
- )} - {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 && ( - - )} - -
-
- -
- { - if (value) setFilterType(value as FilterType); - }} - className="justify-start" - > - {FilterOptions.map((option) => ( - + )} + {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 ? ( -
- -
{t('plugins.noPluginInstalled')}
+ ) : filteredExtensions.length === 0 ? ( +
+ +
+ {t('plugins.noExtensionInstalled')}
- ) : ( -
- {filteredExtensions.map((vo, index) => { - return ( -
- handleExtensionClick(vo)} - onDeleteClick={() => handleExtensionDelete(vo)} - onUpgradeClick={vo.type === 'plugin' ? () => handleExtensionUpdate(vo) : undefined} - /> -
- ); - })} -
- )} - - ); - }, -); +
+ ) : 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; \ No newline at end of file +export default PluginInstalledComponent; diff --git a/web/src/app/home/plugins/page.tsx b/web/src/app/home/plugins/page.tsx index 2c06f907..76e31cff 100644 --- a/web/src/app/home/plugins/page.tsx +++ b/web/src/app/home/plugins/page.tsx @@ -1,6 +1,11 @@ import PluginInstalledComponent, { PluginInstalledComponentRef, + FilterOptions, + FilterType, } from '@/app/home/plugins/components/plugin-installed/PluginInstalledComponent'; +import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Switch } from '@/components/ui/switch'; +import { Label } from '@/components/ui/label'; import PluginDetailContent from './PluginDetailContent'; import styles from './plugins.module.css'; import { Button } from '@/components/ui/button'; @@ -28,6 +33,9 @@ import { CardDescription, CardContent, } from '@/components/ui/card'; +import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert'; +import { Badge } from '@/components/ui/badge'; +import { Skeleton } from '@/components/ui/skeleton'; import { Input } from '@/components/ui/input'; import React, { useState, useRef, useCallback, useEffect } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; @@ -98,7 +106,7 @@ function PluginListView() { } = usePluginInstallTasks(); const [showGithubInstall, setShowGithubInstall] = useState(false); const [installSource, setInstallSource] = useState('local'); - const [installInfo] = useState>({}); // eslint-disable-line @typescript-eslint/no-explicit-any + const [installInfo] = useState>({}); const [pluginInstallStatus, setPluginInstallStatus] = useState(PluginInstallStatus.WAIT_INPUT); const [installError, setInstallError] = useState(null); @@ -125,6 +133,15 @@ function PluginListView() { const [debugPopoverOpen, setDebugPopoverOpen] = useState(false); const [copiedDebugUrl, setCopiedDebugUrl] = useState(false); const [copiedDebugKey, setCopiedDebugKey] = useState(false); + const [filterType, setFilterType] = useState('all'); + const [groupByType, setGroupByType] = useState(() => { + if (typeof window === 'undefined') return false; + return localStorage.getItem('extensions_group_by_type') === 'true'; + }); + + useEffect(() => { + localStorage.setItem('extensions_group_by_type', String(groupByType)); + }, [groupByType]); useEffect(() => { const fetchPluginSystemStatus = async () => { @@ -280,13 +297,13 @@ function PluginListView() { release_tag: selectedRelease.tag_name, }); } else { - installPlugin(installSource, installInfo as Record); // eslint-disable-line @typescript-eslint/no-explicit-any + installPlugin(installSource, installInfo as Record); } } function installPlugin( installSource: string, - installInfo: Record, // eslint-disable-line @typescript-eslint/no-explicit-any + installInfo: Record, ) { setPluginInstallStatus(PluginInstallStatus.INSTALLING); if (installSource === 'github') { @@ -469,35 +486,30 @@ function PluginListView() { }; const renderPluginDisabledState = () => ( -
- -

- {t('plugins.systemDisabled')} -

-

- {t('plugins.systemDisabledDesc')} -

+
+ + + {t('plugins.systemDisabled')} + {t('plugins.systemDisabledDesc')} +
); const renderPluginConnectionErrorState = () => ( -
- - -

- {t('plugins.connectionError')} -

-

- {t('plugins.connectionErrorDesc')} -

+
+ + + {t('plugins.connectionError')} + {t('plugins.connectionErrorDesc')} +
); const renderLoadingState = () => ( -
-

- {t('plugins.loadingStatus')} -

+
+ + +
); @@ -530,67 +542,66 @@ function PluginListView() { style={{ display: 'none' }} /> - {/* Header bar with debug info, task queue, and install button */} -
- - - - - - - -
- {/* Header with icon and title */} -
- -

- {t('plugins.debugInfoTitle')} -

-
+ {t('plugins.groupByType')} + +
+ - {/* Debug URL row */} -
- - - -
+ + + + + +
+ {/* Header with icon and title */} +
+ +

+ {t('plugins.debugInfoTitle')} +

+
- {/* Debug Key row */} -
+ {/* Debug URL row */}
@@ -599,29 +610,59 @@ function PluginListView() { size="icon" className="h-8 w-8 shrink-0" onClick={() => - handleCopyDebugInfo( - debugInfo?.plugin_debug_key || '', - 'key', - ) + handleCopyDebugInfo(debugInfo?.debug_url || '', 'url') } - disabled={!debugInfo?.plugin_debug_key} > - {copiedDebugKey ? ( + {copiedDebugUrl ? ( ) : ( )}
- {!debugInfo?.plugin_debug_key && ( -

- {t('plugins.debugKeyDisabled')} -

- )} + + {/* Debug Key row */} +
+
+ + + +
+ {!debugInfo?.plugin_debug_key && ( +

+ {t('plugins.debugKeyDisabled')} +

+ )} +
-
-
-
+
+
+
{/* Inline GitHub install flow */} @@ -712,9 +753,12 @@ function PluginListView() {
{release.prerelease && ( - + {t('plugins.prerelease')} - + )} @@ -867,19 +911,21 @@ function PluginListView() { {/* Installed plugins grid */}
- +
{isDragOver && ( -
-
-
- -

- {t('plugins.dragToUpload')} -

-
-
+
+ + + +

{t('plugins.dragToUpload')}

+
+
)}
diff --git a/web/src/app/infra/entities/api/index.ts b/web/src/app/infra/entities/api/index.ts index f259d1bc..8bd95b2e 100644 --- a/web/src/app/infra/entities/api/index.ts +++ b/web/src/app/infra/entities/api/index.ts @@ -280,6 +280,15 @@ export interface ApiRespPlugins { plugins: Plugin[]; } +export type ExtensionItem = + | { type: 'plugin'; plugin: Plugin } + | { type: 'mcp'; server: MCPServer } + | { type: 'skill'; skill: Skill }; + +export interface ApiRespExtensions { + extensions: ExtensionItem[]; +} + export interface ApiRespPlugin { plugin: Plugin; } diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts index 2dd3f959..ab39be07 100644 --- a/web/src/app/infra/http/BackendClient.ts +++ b/web/src/app/infra/http/BackendClient.ts @@ -15,6 +15,7 @@ import { ApiRespPlugins, ApiRespPlugin, ApiRespPluginConfig, + ApiRespExtensions, AsyncTaskCreatedResp, ApiRespSystemInfo, ApiRespAsyncTasks, @@ -543,6 +544,11 @@ export class BackendClient extends BaseHttpClient { return this.get(`/api/v1/knowledge/parsers${params}`); } + // ============ Extensions API ============ + public getExtensions(): Promise { + return this.get('/api/v1/extensions'); + } + // ============ Plugins API ============ public getPlugins(): Promise { return this.get('/api/v1/plugins'); @@ -815,7 +821,10 @@ export class BackendClient extends BaseHttpClient { serverName: string, server: Partial, ): Promise { - return this.put(`/api/v1/mcp/servers/${encodeURIComponent(serverName)}`, server); + return this.put( + `/api/v1/mcp/servers/${encodeURIComponent(serverName)}`, + server, + ); } public deleteMCPServer(serverName: string): Promise { @@ -835,7 +844,10 @@ export class BackendClient extends BaseHttpClient { serverName: string, serverData: object, ): Promise { - return this.post(`/api/v1/mcp/servers/${encodeURIComponent(serverName)}/test`, serverData); + return this.post( + `/api/v1/mcp/servers/${encodeURIComponent(serverName)}/test`, + serverData, + ); } public installMCPServerFromGithub( diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 75e90d28..bf35f6a4 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -449,6 +449,9 @@ const enUS = { loading: 'Loading...', getPluginListError: 'Failed to get plugin list:', noPluginInstalled: 'No plugins installed', + noExtensionInstalled: 'No extensions installed', + loadingExtensions: 'Loading extensions...', + groupByType: 'Group by type', pluginConfig: 'Plugin Configuration', pluginSort: 'Plugin Sort', pluginSortDescription: @@ -658,7 +661,7 @@ const enUS = { deprecatedTooltip: 'Please install the corresponding Knowledge Engine plugin.', filters: { - allFormats: 'All Formats', + allFormats: 'All Types', more: 'More', advancedTitle: 'Advanced Filters', advancedDescription: 'Filter by extension type', diff --git a/web/src/i18n/locales/es-ES.ts b/web/src/i18n/locales/es-ES.ts index dbf657bd..1281cfb0 100644 --- a/web/src/i18n/locales/es-ES.ts +++ b/web/src/i18n/locales/es-ES.ts @@ -462,6 +462,9 @@ const esES = { loading: 'Cargando...', getPluginListError: 'Error al obtener la lista de plugins:', noPluginInstalled: 'No hay plugins instalados', + noExtensionInstalled: 'No hay extensiones instaladas', + loadingExtensions: 'Cargando extensiones...', + groupByType: 'Agrupar por tipo', pluginConfig: 'Configuración del plugin', pluginSort: 'Orden de plugins', pluginSortDescription: @@ -665,7 +668,7 @@ const esES = { deprecatedTooltip: 'Por favor, instala el plugin de motor de conocimiento correspondiente.', filters: { - allFormats: 'Todos los formatos', + allFormats: 'Todos los tipos', more: 'Más', advancedTitle: 'Filtros avanzados', advancedDescription: 'Filtrar por tipo de extensión', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index 92e7b17f..c6be05df 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -454,6 +454,9 @@ const jaJP = { loading: '読み込み中...', getPluginListError: 'プラグインリストの取得に失敗しました:', noPluginInstalled: 'プラグインがインストールされていません', + noExtensionInstalled: '拡張機能がインストールされていません', + loadingExtensions: '拡張機能を読み込み中...', + groupByType: '種類でグループ化', pluginConfig: 'プラグイン設定', pluginSort: 'プラグインの並び替え', pluginSortDescription: @@ -659,7 +662,7 @@ const jaJP = { noTags: 'タグがありません', }, filters: { - allFormats: 'すべての形式', + allFormats: 'すべての種類', more: 'もっと', advancedTitle: '高度なフィルター', advancedDescription: '拡張子タイプでフィルター', diff --git a/web/src/i18n/locales/ru-RU.ts b/web/src/i18n/locales/ru-RU.ts index 045d0c9e..99c8398f 100644 --- a/web/src/i18n/locales/ru-RU.ts +++ b/web/src/i18n/locales/ru-RU.ts @@ -458,6 +458,9 @@ const ruRU = { loading: 'Загрузка...', getPluginListError: 'Не удалось получить список плагинов:', noPluginInstalled: 'Плагины не установлены', + noExtensionInstalled: 'Расширения не установлены', + loadingExtensions: 'Загрузка расширений...', + groupByType: 'Группировать по типу', pluginConfig: 'Настройка плагина', pluginSort: 'Порядок плагинов', pluginSortDescription: @@ -661,7 +664,7 @@ const ruRU = { deprecatedTooltip: 'Пожалуйста, установите соответствующий плагин движка знаний.', filters: { - allFormats: 'Все форматы', + allFormats: 'Все типы', more: 'Ещё', advancedTitle: 'Расширенные фильтры', advancedDescription: 'Фильтр по типу расширения', diff --git a/web/src/i18n/locales/th-TH.ts b/web/src/i18n/locales/th-TH.ts index 6d305350..ad676f56 100644 --- a/web/src/i18n/locales/th-TH.ts +++ b/web/src/i18n/locales/th-TH.ts @@ -445,6 +445,9 @@ const thTH = { loading: 'กำลังโหลด...', getPluginListError: 'ไม่สามารถดึงรายการปลั๊กอินได้:', noPluginInstalled: 'ยังไม่มีปลั๊กอินที่ติดตั้ง', + noExtensionInstalled: 'ยังไม่มีส่วนขยายที่ติดตั้ง', + loadingExtensions: 'กำลังโหลดส่วนขยาย...', + groupByType: 'จัดกลุ่มตามประเภท', pluginConfig: 'การกำหนดค่าปลั๊กอิน', pluginSort: 'เรียงลำดับปลั๊กอิน', pluginSortDescription: @@ -642,7 +645,7 @@ const thTH = { deprecated: 'เลิกใช้แล้ว', deprecatedTooltip: 'กรุณาติดตั้งปลั๊กอินเครื่องมือความรู้ที่เกี่ยวข้อง', filters: { - allFormats: 'ทุกรูปแบบ', + allFormats: 'ทุกประเภท', more: 'เพิ่มเติม', advancedTitle: 'ตัวกรองขั้นสูง', advancedDescription: 'กรองตามประเภทส่วนขยาย', diff --git a/web/src/i18n/locales/vi-VN.ts b/web/src/i18n/locales/vi-VN.ts index 15dd8d72..88af1379 100644 --- a/web/src/i18n/locales/vi-VN.ts +++ b/web/src/i18n/locales/vi-VN.ts @@ -455,6 +455,9 @@ const viVN = { loading: 'Đang tải...', 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', + loadingExtensions: 'Đang tải tiện ích mở rộng...', + groupByType: 'Nhóm theo loại', pluginConfig: 'Cấu hình Plugin', pluginSort: 'Sắp xếp Plugin', pluginSortDescription: @@ -655,7 +658,7 @@ const viVN = { 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', + allFormats: 'Tất cả loại', more: 'Thêm', advancedTitle: 'Bộ lọc nâng cao', advancedDescription: 'Lọc theo loại phần mở rộng', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 93d39547..156fbc82 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -433,6 +433,9 @@ const zhHans = { getPluginListError: '获取插件列表失败:', pluginConfig: '插件配置', noPluginInstalled: '暂未安装任何插件', + noExtensionInstalled: '暂未安装任何扩展', + loadingExtensions: '正在加载扩展...', + groupByType: '按类型分组', pluginSort: '插件排序', pluginSortDescription: '插件顺序会影响同一事件内的处理顺序,请拖动插件卡片排序', @@ -634,7 +637,7 @@ const zhHans = { noTags: '暂无标签', }, filters: { - allFormats: '全部格式', + allFormats: '全部类型', more: '更多', advancedTitle: '高级筛选', advancedDescription: '按扩展类型筛选', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 51d4b653..98c4bb76 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -434,6 +434,9 @@ const zhHant = { getPluginListError: '取得外掛清單失敗:', pluginConfig: '外掛設定', noPluginInstalled: '暫未安裝任何外掛', + noExtensionInstalled: '暫未安裝任何擴充功能', + loadingExtensions: '正在載入擴充功能...', + groupByType: '依類型分組', pluginSort: '外掛排序', pluginSortDescription: '外掛順序會影響同一事件內的處理順序,請拖曳外掛卡片排序', @@ -627,7 +630,7 @@ const zhHant = { noTags: '暫無標籤', }, filters: { - allFormats: '全部格式', + allFormats: '全部類型', more: '更多', advancedTitle: '高級篩選', advancedDescription: '按擴展類型篩選', diff --git a/web/src/router.tsx b/web/src/router.tsx index 41a92061..d80c5c90 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -110,7 +110,7 @@ export const router = createBrowserRouter([ ), }, { - path: '/home/plugins', + path: '/home/extensions', element: ( }>