mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 03:55:55 +00:00
feat(extensions): unify extensions endpoint and refresh extensions page UX
- 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) <noreply@anthropic.com>
This commit is contained in:
52
src/langbot/pkg/api/http/controller/groups/extensions.py
Normal file
52
src/langbot/pkg/api/http/controller/groups/extensions.py
Normal file
@@ -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})
|
||||||
@@ -177,7 +177,7 @@ const ENTITY_ROUTE_MAP: Record<EntityCategoryId, string> = {
|
|||||||
bots: '/home/bots',
|
bots: '/home/bots',
|
||||||
pipelines: '/home/pipelines',
|
pipelines: '/home/pipelines',
|
||||||
knowledge: '/home/knowledge',
|
knowledge: '/home/knowledge',
|
||||||
plugins: '/home/plugins',
|
plugins: '/home/extensions',
|
||||||
mcp: '/home/mcp',
|
mcp: '/home/mcp',
|
||||||
skills: '/home/skills',
|
skills: '/home/skills',
|
||||||
};
|
};
|
||||||
@@ -664,7 +664,7 @@ function NavItems({
|
|||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setPendingPluginInstallAction('local');
|
setPendingPluginInstallAction('local');
|
||||||
navigate('/home/plugins');
|
navigate('/home/extensions');
|
||||||
setPopoverOpen((prev) => ({
|
setPopoverOpen((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[config.id]: false,
|
[config.id]: false,
|
||||||
@@ -678,7 +678,7 @@ function NavItems({
|
|||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setPendingPluginInstallAction('github');
|
setPendingPluginInstallAction('github');
|
||||||
navigate('/home/plugins');
|
navigate('/home/extensions');
|
||||||
setPopoverOpen((prev) => ({
|
setPopoverOpen((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[config.id]: false,
|
[config.id]: false,
|
||||||
@@ -838,7 +838,7 @@ function NavItems({
|
|||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setPendingPluginInstallAction('local');
|
setPendingPluginInstallAction('local');
|
||||||
navigate('/home/plugins');
|
navigate('/home/extensions');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Upload className="size-4" />
|
<Upload className="size-4" />
|
||||||
@@ -848,7 +848,7 @@ function NavItems({
|
|||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setPendingPluginInstallAction('github');
|
setPendingPluginInstallAction('github');
|
||||||
navigate('/home/plugins');
|
navigate('/home/extensions');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Github className="size-4" />
|
<Github className="size-4" />
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ export const sidebarConfigList = [
|
|||||||
id: 'plugins',
|
id: 'plugins',
|
||||||
name: t('sidebar.installedPlugins'),
|
name: t('sidebar.installedPlugins'),
|
||||||
icon: <Puzzle className="text-blue-500" />,
|
icon: <Puzzle className="text-blue-500" />,
|
||||||
route: '/home/plugins',
|
route: '/home/extensions',
|
||||||
description: t('plugins.description'),
|
description: t('plugins.description'),
|
||||||
helpLink: {
|
helpLink: {
|
||||||
en_US: 'https://link.langbot.app/en/docs/plugins',
|
en_US: 'https://link.langbot.app/en/docs/plugins',
|
||||||
@@ -108,4 +108,4 @@ export const sidebarConfigList = [
|
|||||||
},
|
},
|
||||||
section: 'extensions',
|
section: 'extensions',
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ import {
|
|||||||
|
|
||||||
// Routes that belong to the "Extensions" section
|
// Routes that belong to the "Extensions" section
|
||||||
const EXTENSIONS_ROUTES = [
|
const EXTENSIONS_ROUTES = [
|
||||||
'/home/plugins',
|
'/home/extensions',
|
||||||
'/home/market',
|
'/home/market',
|
||||||
'/home/mcp',
|
'/home/mcp',
|
||||||
'/home/skills',
|
'/home/skills',
|
||||||
@@ -124,7 +124,7 @@ function HomeLayoutInner({ children }: { children: React.ReactNode }) {
|
|||||||
const sectionLabel = isExtensions
|
const sectionLabel = isExtensions
|
||||||
? t('sidebar.extensions')
|
? t('sidebar.extensions')
|
||||||
: t('sidebar.home');
|
: t('sidebar.home');
|
||||||
const sectionLink = isExtensions ? '/home/plugins' : '/home/monitoring';
|
const sectionLink = isExtensions ? '/home/extensions' : '/home/monitoring';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
|
|||||||
@@ -6,15 +6,14 @@ import { BugIcon, ExternalLink, Ellipsis, Trash, ArrowUp } from 'lucide-react';
|
|||||||
import { getCloudServiceClientSync, systemInfo } from '@/app/infra/http';
|
import { getCloudServiceClientSync, systemInfo } from '@/app/infra/http';
|
||||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card } from '@/components/ui/card';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import {
|
import { MCPSessionStatus } from '@/app/infra/entities/api';
|
||||||
MCPSessionStatus,
|
|
||||||
} from '@/app/infra/entities/api';
|
|
||||||
|
|
||||||
type ExtensionCardComponentProps = {
|
type ExtensionCardComponentProps = {
|
||||||
cardVO: ExtensionCardVO;
|
cardVO: ExtensionCardVO;
|
||||||
@@ -69,17 +68,14 @@ export default function ExtensionCardComponent({
|
|||||||
|
|
||||||
const renderPluginContent = () => (
|
const renderPluginContent = () => (
|
||||||
<>
|
<>
|
||||||
<div className="text-[0.7rem] text-[#666] dark:text-[#999] truncate w-full">
|
<div className="text-[0.7rem] text-muted-foreground truncate w-full">
|
||||||
{cardVO.author} / {cardVO.name}
|
{cardVO.author} / {cardVO.name}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row items-center justify-start gap-[0.4rem] flex-wrap max-w-full">
|
<div className="flex flex-row items-center justify-start gap-[0.4rem] flex-wrap max-w-full">
|
||||||
<div className="text-[1.2rem] text-black dark:text-[#f0f0f0] truncate max-w-[10rem]">
|
<div className="text-[1.2rem] text-foreground truncate max-w-[10rem]">
|
||||||
{cardVO.label}
|
{cardVO.label}
|
||||||
</div>
|
</div>
|
||||||
<Badge
|
<Badge variant="outline" className="text-[0.7rem] flex-shrink-0">
|
||||||
variant="outline"
|
|
||||||
className="text-[0.7rem] flex-shrink-0"
|
|
||||||
>
|
|
||||||
v{cardVO.version}
|
v{cardVO.version}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge
|
<Badge
|
||||||
@@ -126,7 +122,7 @@ export default function ExtensionCardComponent({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[0.8rem] text-[#666] line-clamp-2 dark:text-[#999] w-full">
|
<div className="text-[0.8rem] text-muted-foreground line-clamp-2 w-full">
|
||||||
{cardVO.description}
|
{cardVO.description}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -134,11 +130,11 @@ export default function ExtensionCardComponent({
|
|||||||
|
|
||||||
const renderMCPContent = () => (
|
const renderMCPContent = () => (
|
||||||
<>
|
<>
|
||||||
<div className="text-[0.7rem] text-[#666] dark:text-[#999] truncate w-full">
|
<div className="text-[0.7rem] text-muted-foreground truncate w-full">
|
||||||
MCP Server
|
MCP Server
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row items-center justify-start gap-[0.4rem] flex-wrap max-w-full">
|
<div className="flex flex-row items-center justify-start gap-[0.4rem] flex-wrap max-w-full">
|
||||||
<div className="text-[1.2rem] text-black dark:text-[#f0f0f0] truncate max-w-[10rem]">
|
<div className="text-[1.2rem] text-foreground truncate max-w-[10rem]">
|
||||||
{cardVO.label}
|
{cardVO.label}
|
||||||
</div>
|
</div>
|
||||||
<Badge
|
<Badge
|
||||||
@@ -166,10 +162,12 @@ export default function ExtensionCardComponent({
|
|||||||
{cardVO.enabled ? t('mcp.statusConnected') : t('mcp.statusDisabled')}
|
{cardVO.enabled ? t('mcp.statusConnected') : t('mcp.statusDisabled')}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[0.8rem] text-[#666] line-clamp-2 dark:text-[#999] w-full">
|
<div className="text-[0.8rem] text-muted-foreground line-clamp-2 w-full">
|
||||||
{cardVO.description || t('mcp.noToolsFound')}
|
{cardVO.description || t('mcp.noToolsFound')}
|
||||||
{cardVO.tools !== undefined && cardVO.tools > 0 && (
|
{cardVO.tools !== undefined && cardVO.tools > 0 && (
|
||||||
<span className="ml-1">{t('mcp.toolCount', { count: cardVO.tools })}</span>
|
<span className="ml-1">
|
||||||
|
{t('mcp.toolCount', { count: cardVO.tools })}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -177,11 +175,11 @@ export default function ExtensionCardComponent({
|
|||||||
|
|
||||||
const renderSkillContent = () => (
|
const renderSkillContent = () => (
|
||||||
<>
|
<>
|
||||||
<div className="text-[0.7rem] text-[#666] dark:text-[#999] truncate w-full">
|
<div className="text-[0.7rem] text-muted-foreground truncate w-full">
|
||||||
Skill
|
Skill
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row items-center justify-start gap-[0.4rem] flex-wrap max-w-full">
|
<div className="flex flex-row items-center justify-start gap-[0.4rem] flex-wrap max-w-full">
|
||||||
<div className="text-[1.2rem] text-black dark:text-[#f0f0f0] truncate max-w-[10rem]">
|
<div className="text-[1.2rem] text-foreground truncate max-w-[10rem]">
|
||||||
{cardVO.label}
|
{cardVO.label}
|
||||||
</div>
|
</div>
|
||||||
<Badge
|
<Badge
|
||||||
@@ -191,7 +189,7 @@ export default function ExtensionCardComponent({
|
|||||||
{t('common.skill')}
|
{t('common.skill')}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[0.8rem] text-[#666] line-clamp-2 dark:text-[#999] w-full">
|
<div className="text-[0.8rem] text-muted-foreground line-clamp-2 w-full">
|
||||||
{cardVO.description}
|
{cardVO.description}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -199,13 +197,16 @@ export default function ExtensionCardComponent({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<Card
|
||||||
className="w-[100%] h-[10rem] bg-white rounded-[10px] border border-[#e4e4e7] dark:border-[#27272a] p-[1.2rem] cursor-pointer dark:bg-[#1f1f22] relative transition-all duration-200 hover:border-[#a1a1aa] dark:hover:border-[#3f3f46]"
|
className="w-full h-[10rem] py-5 px-5 cursor-pointer relative gap-0 shadow-xs transition-shadow duration-200 hover:shadow-md"
|
||||||
onClick={() => onCardClick()}
|
onClick={() => onCardClick()}
|
||||||
>
|
>
|
||||||
<div className="w-full h-full flex flex-row items-start justify-start gap-[1.2rem]">
|
<div className="w-full h-full flex flex-row items-start justify-start gap-[1.2rem]">
|
||||||
<img
|
<img
|
||||||
src={cardVO.iconURL || httpClient.getPluginIconURL(cardVO.author, cardVO.name)}
|
src={
|
||||||
|
cardVO.iconURL ||
|
||||||
|
httpClient.getPluginIconURL(cardVO.author, cardVO.name)
|
||||||
|
}
|
||||||
alt="extension icon"
|
alt="extension icon"
|
||||||
className="w-16 h-16 rounded-[8%] flex-shrink-0"
|
className="w-16 h-16 rounded-[8%] flex-shrink-0"
|
||||||
/>
|
/>
|
||||||
@@ -233,62 +234,65 @@ export default function ExtensionCardComponent({
|
|||||||
>
|
>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Button
|
<Button variant="ghost" size="icon">
|
||||||
variant="ghost"
|
|
||||||
className="bg-white dark:bg-[#1f1f22] hover:bg-gray-100 dark:hover:bg-[#2a2a2d]"
|
|
||||||
>
|
|
||||||
<Ellipsis className="w-4 h-4" />
|
<Ellipsis className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
{cardVO.hasUpdate && (
|
{cardVO.hasUpdate && (
|
||||||
<div className="absolute -top-0.5 -right-0.5 w-2.5 h-2.5 bg-red-500 rounded-full border-2 border-white dark:border-[#1f1f22]"></div>
|
<div className="absolute -top-0.5 -right-0.5 w-2.5 h-2.5 bg-destructive rounded-full border-2 border-card"></div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent>
|
<DropdownMenuContent>
|
||||||
{cardVO.type === 'plugin' && cardVO.install_source === 'marketplace' && (
|
{cardVO.type === 'plugin' &&
|
||||||
<DropdownMenuItem
|
cardVO.install_source === 'marketplace' && (
|
||||||
className="flex flex-row items-center justify-start gap-[0.4rem] cursor-pointer"
|
<DropdownMenuItem
|
||||||
onClick={(e) => {
|
className="flex flex-row items-center justify-start gap-[0.4rem] cursor-pointer"
|
||||||
e.stopPropagation();
|
onClick={(e) => {
|
||||||
if (onUpgradeClick) {
|
e.stopPropagation();
|
||||||
onUpgradeClick(cardVO);
|
if (onUpgradeClick) {
|
||||||
}
|
onUpgradeClick(cardVO);
|
||||||
setDropdownOpen(false);
|
}
|
||||||
}}
|
setDropdownOpen(false);
|
||||||
>
|
}}
|
||||||
<ArrowUp className="w-4 h-4" />
|
>
|
||||||
<span>{t('plugins.update')}</span>
|
<ArrowUp className="w-4 h-4" />
|
||||||
{cardVO.hasUpdate && (
|
<span>{t('plugins.update')}</span>
|
||||||
<Badge className="ml-auto bg-red-500 hover:bg-red-500 text-white text-[0.6rem] px-1.5 py-0 h-4">
|
{cardVO.hasUpdate && (
|
||||||
{t('plugins.new')}
|
<Badge className="ml-auto bg-red-500 hover:bg-red-500 text-white text-[0.6rem] px-1.5 py-0 h-4">
|
||||||
</Badge>
|
{t('plugins.new')}
|
||||||
)}
|
</Badge>
|
||||||
</DropdownMenuItem>
|
)}
|
||||||
)}
|
</DropdownMenuItem>
|
||||||
{cardVO.type === 'plugin' && (cardVO.install_source === 'github' || cardVO.install_source === 'marketplace') && (
|
)}
|
||||||
<DropdownMenuItem
|
{cardVO.type === 'plugin' &&
|
||||||
className="flex flex-row items-center justify-start gap-[0.4rem] cursor-pointer"
|
(cardVO.install_source === 'github' ||
|
||||||
onClick={(e) => {
|
cardVO.install_source === 'marketplace') && (
|
||||||
e.stopPropagation();
|
<DropdownMenuItem
|
||||||
if (cardVO.install_source === 'github') {
|
className="flex flex-row items-center justify-start gap-[0.4rem] cursor-pointer"
|
||||||
window.open(cardVO.install_info?.github_url as string, '_blank');
|
onClick={(e) => {
|
||||||
} else if (cardVO.install_source === 'marketplace') {
|
e.stopPropagation();
|
||||||
window.open(
|
if (cardVO.install_source === 'github') {
|
||||||
getCloudServiceClientSync().getPluginMarketplaceURL(
|
window.open(
|
||||||
systemInfo.cloud_service_url,
|
cardVO.install_info?.github_url as string,
|
||||||
cardVO.author,
|
'_blank',
|
||||||
cardVO.name,
|
);
|
||||||
),
|
} else if (cardVO.install_source === 'marketplace') {
|
||||||
'_blank',
|
window.open(
|
||||||
);
|
getCloudServiceClientSync().getPluginMarketplaceURL(
|
||||||
}
|
systemInfo.cloud_service_url,
|
||||||
setDropdownOpen(false);
|
cardVO.author,
|
||||||
}}
|
cardVO.name,
|
||||||
>
|
),
|
||||||
<ExternalLink className="w-4 h-4" />
|
'_blank',
|
||||||
<span>{t('plugins.viewSource')}</span>
|
);
|
||||||
</DropdownMenuItem>
|
}
|
||||||
)}
|
setDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-4 h-4" />
|
||||||
|
<span>{t('plugins.viewSource')}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="flex flex-row items-center justify-start gap-[0.4rem] cursor-pointer text-red-600 focus:text-red-600"
|
className="flex flex-row items-center justify-start gap-[0.4rem] cursor-pointer text-red-600 focus:text-red-600"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -302,8 +306,8 @@ export default function ExtensionCardComponent({
|
|||||||
{cardVO.type === 'mcp'
|
{cardVO.type === 'mcp'
|
||||||
? t('mcp.deleteServer')
|
? t('mcp.deleteServer')
|
||||||
: cardVO.type === 'skill'
|
: cardVO.type === 'skill'
|
||||||
? t('skills.delete')
|
? t('skills.delete')
|
||||||
: t('plugins.delete')}
|
: t('plugins.delete')}
|
||||||
</span>
|
</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
@@ -311,7 +315,7 @@ export default function ExtensionCardComponent({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,13 +21,8 @@ import { extractI18nObject } from '@/i18n/I18nProvider';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useAsyncTask, AsyncTaskStatus } from '@/hooks/useAsyncTask';
|
import { useAsyncTask, AsyncTaskStatus } from '@/hooks/useAsyncTask';
|
||||||
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||||
import { Puzzle } from 'lucide-react';
|
import { Loader2, Puzzle } from 'lucide-react';
|
||||||
import {
|
|
||||||
ToggleGroup,
|
|
||||||
ToggleGroupItem,
|
|
||||||
} from '@/components/ui/toggle-group';
|
|
||||||
import { Wrench, AudioWaveform, Book } from 'lucide-react';
|
import { Wrench, AudioWaveform, Book } from 'lucide-react';
|
||||||
import { MCPSessionStatus } from '@/app/infra/entities/api';
|
|
||||||
|
|
||||||
export interface PluginInstalledComponentRef {
|
export interface PluginInstalledComponentRef {
|
||||||
refreshPluginList: () => void;
|
refreshPluginList: () => void;
|
||||||
@@ -38,74 +33,94 @@ enum ExtensionOperationType {
|
|||||||
UPDATE = 'UPDATE',
|
UPDATE = 'UPDATE',
|
||||||
}
|
}
|
||||||
|
|
||||||
type FilterType = 'all' | ExtensionType;
|
export type FilterType = 'all' | ExtensionType;
|
||||||
|
|
||||||
const FilterOptions = [
|
export const FilterOptions = [
|
||||||
{ value: 'all' as FilterType, labelKey: 'market.filters.allFormats', icon: null },
|
{
|
||||||
{ value: 'plugin' as FilterType, labelKey: 'market.typePlugin', icon: Wrench },
|
value: 'all' as FilterType,
|
||||||
{ value: 'mcp' as FilterType, labelKey: 'market.typeMCP', icon: AudioWaveform },
|
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 },
|
{ value: 'skill' as FilterType, labelKey: 'market.typeSkill', icon: Book },
|
||||||
];
|
];
|
||||||
|
|
||||||
const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
|
interface PluginInstalledComponentProps {
|
||||||
(props, ref) => {
|
filterType: FilterType;
|
||||||
const { t } = useTranslation();
|
groupByType: boolean;
|
||||||
const navigate = useNavigate();
|
}
|
||||||
const { refreshPlugins, refreshMCPServers, refreshSkills } = useSidebarData();
|
|
||||||
const [extensionList, setExtensionList] = useState<ExtensionCardVO[]>([]);
|
|
||||||
const [filterType, setFilterType] = useState<FilterType>('all');
|
|
||||||
const [showOperationModal, setShowOperationModal] = useState(false);
|
|
||||||
const [operationType, setOperationType] = useState<ExtensionOperationType>(
|
|
||||||
ExtensionOperationType.DELETE,
|
|
||||||
);
|
|
||||||
const [targetExtension, setTargetExtension] = useState<ExtensionCardVO | null>(null);
|
|
||||||
const [deleteData, setDeleteData] = useState<boolean>(false);
|
|
||||||
|
|
||||||
const asyncTask = useAsyncTask({
|
const PluginInstalledComponent = forwardRef<
|
||||||
onSuccess: () => {
|
PluginInstalledComponentRef,
|
||||||
const successMessage =
|
PluginInstalledComponentProps
|
||||||
operationType === ExtensionOperationType.DELETE
|
>(({ filterType, groupByType }, ref) => {
|
||||||
? t('plugins.deleteSuccess')
|
const { t } = useTranslation();
|
||||||
: t('plugins.updateSuccess');
|
const navigate = useNavigate();
|
||||||
toast.success(successMessage);
|
const { refreshPlugins, refreshMCPServers, refreshSkills } = useSidebarData();
|
||||||
setShowOperationModal(false);
|
const [extensionList, setExtensionList] = useState<ExtensionCardVO[]>([]);
|
||||||
getExtensionList();
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
refreshPlugins();
|
const [showOperationModal, setShowOperationModal] = useState(false);
|
||||||
refreshMCPServers();
|
const [operationType, setOperationType] = useState<ExtensionOperationType>(
|
||||||
refreshSkills();
|
ExtensionOperationType.DELETE,
|
||||||
},
|
);
|
||||||
onError: () => {
|
const [targetExtension, setTargetExtension] =
|
||||||
},
|
useState<ExtensionCardVO | null>(null);
|
||||||
});
|
const [deleteData, setDeleteData] = useState<boolean>(false);
|
||||||
|
|
||||||
useEffect(() => {
|
const asyncTask = useAsyncTask({
|
||||||
initData();
|
onSuccess: () => {
|
||||||
}, []);
|
const successMessage =
|
||||||
|
operationType === ExtensionOperationType.DELETE
|
||||||
function initData() {
|
? t('plugins.deleteSuccess')
|
||||||
|
: t('plugins.updateSuccess');
|
||||||
|
toast.success(successMessage);
|
||||||
|
setShowOperationModal(false);
|
||||||
getExtensionList();
|
getExtensionList();
|
||||||
}
|
refreshPlugins();
|
||||||
|
refreshMCPServers();
|
||||||
|
refreshSkills();
|
||||||
|
},
|
||||||
|
onError: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
async function getExtensionList() {
|
useEffect(() => {
|
||||||
try {
|
initData();
|
||||||
const client = getCloudServiceClientSync();
|
}, []);
|
||||||
|
|
||||||
const [installedPluginsResp, marketplaceResp, mcpResp, skillsResp] = await Promise.all([
|
function initData() {
|
||||||
httpClient.getPlugins().catch(() => ({ plugins: [] })),
|
getExtensionList();
|
||||||
client.getMarketplacePlugins(1, 100).catch(() => ({ plugins: [] })),
|
}
|
||||||
httpClient.getMCPServers().catch(() => ({ servers: [] })),
|
|
||||||
httpClient.getSkills().catch(() => ({ skills: [] })),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const marketplacePluginMap = new Map<string, any>();
|
async function getExtensionList() {
|
||||||
marketplaceResp.plugins.forEach((plugin: any) => {
|
setLoading(true);
|
||||||
const key = `${plugin.author}/${plugin.name}`;
|
try {
|
||||||
marketplacePluginMap.set(key, plugin);
|
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<string, any>();
|
||||||
|
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 meta = plugin.manifest.manifest.metadata;
|
||||||
const author = meta.author ?? '';
|
const author = meta.author ?? '';
|
||||||
const name = meta.name;
|
const name = meta.name;
|
||||||
@@ -122,190 +137,215 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extensions.push(new ExtensionCardVO({
|
extensions.push(
|
||||||
id: marketplaceKey,
|
new ExtensionCardVO({
|
||||||
author,
|
id: marketplaceKey,
|
||||||
label: extractI18nObject(meta.label) || name,
|
author,
|
||||||
name,
|
label: extractI18nObject(meta.label) || name,
|
||||||
description: extractI18nObject(meta.description ?? { en_US: '', zh_Hans: '' }),
|
name,
|
||||||
version: meta.version ?? '',
|
description: extractI18nObject(
|
||||||
enabled: plugin.enabled,
|
meta.description ?? { en_US: '', zh_Hans: '' },
|
||||||
type: marketplacePlugin?.type || 'plugin',
|
),
|
||||||
iconURL: httpClient.getPluginIconURL(author, name),
|
version: meta.version ?? '',
|
||||||
install_source: plugin.install_source,
|
enabled: plugin.enabled,
|
||||||
install_info: plugin.install_info,
|
type: marketplacePlugin?.type || 'plugin',
|
||||||
status: plugin.status,
|
iconURL: httpClient.getPluginIconURL(author, name),
|
||||||
debug: plugin.debug,
|
install_source: plugin.install_source,
|
||||||
hasUpdate,
|
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') {
|
setExtensionList(extensions);
|
||||||
httpClient.deleteSkill(targetExtension.name)
|
} catch (error) {
|
||||||
.then(() => {
|
console.error('Failed to fetch extension list:', error);
|
||||||
toast.success(t('skills.deleteSuccess'));
|
setExtensionList([]);
|
||||||
setShowOperationModal(false);
|
} finally {
|
||||||
getExtensionList();
|
setLoading(false);
|
||||||
refreshSkills();
|
}
|
||||||
})
|
}
|
||||||
.catch((error) => {
|
|
||||||
toast.error(t('skills.deleteError') + error.message);
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const apiCall =
|
useImperativeHandle(ref, () => ({
|
||||||
operationType === ExtensionOperationType.DELETE
|
refreshPluginList: getExtensionList,
|
||||||
? httpClient.removePlugin(
|
}));
|
||||||
targetExtension.author,
|
|
||||||
targetExtension.name,
|
|
||||||
deleteData,
|
|
||||||
)
|
|
||||||
: httpClient.upgradePlugin(targetExtension.author, targetExtension.name);
|
|
||||||
|
|
||||||
apiCall
|
function handleExtensionClick(extension: ExtensionCardVO) {
|
||||||
.then((res) => {
|
if (extension.type === 'mcp') {
|
||||||
asyncTask.startTask(res.task_id);
|
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) => {
|
.catch((error) => {
|
||||||
const errorMessage =
|
toast.error(t('mcp.deleteError') + error.message);
|
||||||
operationType === ExtensionOperationType.DELETE
|
|
||||||
? t('plugins.deleteError') + error.message
|
|
||||||
: t('plugins.updateError') + error.message;
|
|
||||||
toast.error(errorMessage);
|
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredExtensions = extensionList.filter((ext) => {
|
if (targetExtension.type === 'skill') {
|
||||||
if (filterType === 'all') return true;
|
httpClient
|
||||||
return ext.type === filterType;
|
.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 = () => {
|
const apiCall =
|
||||||
if (!targetExtension) return '';
|
operationType === ExtensionOperationType.DELETE
|
||||||
if (targetExtension.type === 'mcp') {
|
? httpClient.removePlugin(
|
||||||
return t('mcp.confirmDeleteServer');
|
targetExtension.author,
|
||||||
}
|
targetExtension.name,
|
||||||
if (targetExtension.type === 'skill') {
|
deleteData,
|
||||||
return t('skills.deleteConfirmation');
|
)
|
||||||
}
|
: httpClient.upgradePlugin(
|
||||||
return t('plugins.confirmDeletePlugin', {
|
targetExtension.author,
|
||||||
author: targetExtension.author,
|
targetExtension.name,
|
||||||
name: 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 (
|
const filteredExtensions = extensionList.filter((ext) => {
|
||||||
<>
|
if (filterType === 'all') return true;
|
||||||
<Dialog
|
return ext.type === filterType;
|
||||||
open={showOperationModal}
|
});
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) {
|
const showGrouped = groupByType && filterType === 'all';
|
||||||
setShowOperationModal(false);
|
const groupOrder: ExtensionType[] = ['plugin', 'mcp', 'skill'];
|
||||||
setTargetExtension(null);
|
const groupedExtensions = groupOrder
|
||||||
asyncTask.reset();
|
.map((type) => ({
|
||||||
}
|
type,
|
||||||
}}
|
labelKey: FilterOptions.find((o) => o.value === type)!.labelKey,
|
||||||
>
|
items: filteredExtensions.filter((ext) => ext.type === type),
|
||||||
<DialogContent>
|
}))
|
||||||
<DialogHeader>
|
.filter((g) => g.items.length > 0);
|
||||||
<DialogTitle>
|
|
||||||
{operationType === ExtensionOperationType.DELETE
|
const getDeleteConfirmMessage = () => {
|
||||||
? t('plugins.deleteConfirm')
|
if (!targetExtension) return '';
|
||||||
: t('plugins.updateConfirm')}
|
if (targetExtension.type === 'mcp') {
|
||||||
</DialogTitle>
|
return t('mcp.confirmDeleteServer');
|
||||||
</DialogHeader>
|
}
|
||||||
<DialogDescription>
|
if (targetExtension.type === 'skill') {
|
||||||
{asyncTask.status === AsyncTaskStatus.WAIT_INPUT && (
|
return t('skills.deleteConfirmation');
|
||||||
<div className="flex flex-col gap-4">
|
}
|
||||||
<div>{getDeleteConfirmMessage()}</div>
|
return t('plugins.confirmDeletePlugin', {
|
||||||
{operationType === ExtensionOperationType.DELETE && targetExtension?.type === 'plugin' && (
|
author: targetExtension.author,
|
||||||
|
name: targetExtension.name,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog
|
||||||
|
open={showOperationModal}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setShowOperationModal(false);
|
||||||
|
setTargetExtension(null);
|
||||||
|
asyncTask.reset();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{operationType === ExtensionOperationType.DELETE
|
||||||
|
? t('plugins.deleteConfirm')
|
||||||
|
: t('plugins.updateConfirm')}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogDescription>
|
||||||
|
{asyncTask.status === AsyncTaskStatus.WAIT_INPUT && (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div>{getDeleteConfirmMessage()}</div>
|
||||||
|
{operationType === ExtensionOperationType.DELETE &&
|
||||||
|
targetExtension?.type === 'plugin' && (
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="delete-data"
|
id="delete-data"
|
||||||
@@ -322,129 +362,147 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{asyncTask.status === AsyncTaskStatus.RUNNING && (
|
{asyncTask.status === AsyncTaskStatus.RUNNING && (
|
||||||
<div>
|
<div>
|
||||||
{operationType === ExtensionOperationType.DELETE
|
{operationType === ExtensionOperationType.DELETE
|
||||||
? t('plugins.deleting')
|
? t('plugins.deleting')
|
||||||
: t('plugins.updating')}
|
: t('plugins.updating')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{asyncTask.status === AsyncTaskStatus.ERROR && (
|
{asyncTask.status === AsyncTaskStatus.ERROR && (
|
||||||
<div>
|
<div>
|
||||||
{operationType === ExtensionOperationType.DELETE
|
{operationType === ExtensionOperationType.DELETE
|
||||||
? t('plugins.deleteError')
|
? t('plugins.deleteError')
|
||||||
: t('plugins.updateError')}
|
: t('plugins.updateError')}
|
||||||
<div className="text-red-500">{asyncTask.error}</div>
|
<div className="text-red-500">{asyncTask.error}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
{asyncTask.status === AsyncTaskStatus.WAIT_INPUT && (
|
{asyncTask.status === AsyncTaskStatus.WAIT_INPUT && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
setShowOperationModal(false);
|
|
||||||
setTargetExtension(null);
|
|
||||||
asyncTask.reset();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('plugins.cancel')}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{asyncTask.status === AsyncTaskStatus.WAIT_INPUT && (
|
|
||||||
<Button
|
|
||||||
variant={
|
|
||||||
operationType === ExtensionOperationType.DELETE
|
|
||||||
? 'destructive'
|
|
||||||
: 'default'
|
|
||||||
}
|
|
||||||
onClick={() => {
|
|
||||||
executeOperation();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{operationType === ExtensionOperationType.DELETE
|
|
||||||
? t('plugins.confirmDelete')
|
|
||||||
: t('plugins.confirmUpdate')}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{asyncTask.status === AsyncTaskStatus.RUNNING && (
|
|
||||||
<Button
|
|
||||||
variant={
|
|
||||||
operationType === ExtensionOperationType.DELETE
|
|
||||||
? 'destructive'
|
|
||||||
: 'default'
|
|
||||||
}
|
|
||||||
disabled
|
|
||||||
>
|
|
||||||
{operationType === ExtensionOperationType.DELETE
|
|
||||||
? t('plugins.deleting')
|
|
||||||
: t('plugins.updating')}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{asyncTask.status === AsyncTaskStatus.ERROR && (
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
onClick={() => {
|
|
||||||
setShowOperationModal(false);
|
|
||||||
asyncTask.reset();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('plugins.close')}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<div className="px-[0.8rem] pb-4">
|
|
||||||
<ToggleGroup
|
|
||||||
type="single"
|
|
||||||
value={filterType}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
if (value) setFilterType(value as FilterType);
|
|
||||||
}}
|
|
||||||
className="justify-start"
|
|
||||||
>
|
|
||||||
{FilterOptions.map((option) => (
|
|
||||||
<ToggleGroupItem
|
|
||||||
key={option.value}
|
|
||||||
value={option.value}
|
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="px-4 py-2"
|
onClick={() => {
|
||||||
|
setShowOperationModal(false);
|
||||||
|
setTargetExtension(null);
|
||||||
|
asyncTask.reset();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{option.icon && <option.icon className="w-4 h-4 mr-2" />}
|
{t('plugins.cancel')}
|
||||||
{t(option.labelKey)}
|
</Button>
|
||||||
</ToggleGroupItem>
|
)}
|
||||||
))}
|
{asyncTask.status === AsyncTaskStatus.WAIT_INPUT && (
|
||||||
</ToggleGroup>
|
<Button
|
||||||
|
variant={
|
||||||
|
operationType === ExtensionOperationType.DELETE
|
||||||
|
? 'destructive'
|
||||||
|
: 'default'
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
executeOperation();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{operationType === ExtensionOperationType.DELETE
|
||||||
|
? t('plugins.confirmDelete')
|
||||||
|
: t('plugins.confirmUpdate')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{asyncTask.status === AsyncTaskStatus.RUNNING && (
|
||||||
|
<Button
|
||||||
|
variant={
|
||||||
|
operationType === ExtensionOperationType.DELETE
|
||||||
|
? 'destructive'
|
||||||
|
: 'default'
|
||||||
|
}
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
{operationType === ExtensionOperationType.DELETE
|
||||||
|
? t('plugins.deleting')
|
||||||
|
: t('plugins.updating')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{asyncTask.status === AsyncTaskStatus.ERROR && (
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
onClick={() => {
|
||||||
|
setShowOperationModal(false);
|
||||||
|
asyncTask.reset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('plugins.close')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex flex-col items-center justify-center text-muted-foreground min-h-[60vh] w-full gap-2">
|
||||||
|
<Loader2 className="h-[3rem] w-[3rem] animate-spin" />
|
||||||
|
<div className="text-lg mb-2">{t('plugins.loadingExtensions')}</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : filteredExtensions.length === 0 ? (
|
||||||
{filteredExtensions.length === 0 ? (
|
<div className="flex flex-col items-center justify-center text-muted-foreground min-h-[60vh] w-full gap-2">
|
||||||
<div className="flex flex-col items-center justify-center text-gray-500 min-h-[60vh] w-full gap-2">
|
<Puzzle className="h-[3rem] w-[3rem]" />
|
||||||
<Puzzle className="h-[3rem] w-[3rem]" />
|
<div className="text-lg mb-2">
|
||||||
<div className="text-lg mb-2">{t('plugins.noPluginInstalled')}</div>
|
{t('plugins.noExtensionInstalled')}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</div>
|
||||||
<div className={`${styles.pluginListContainer}`}>
|
) : showGrouped ? (
|
||||||
{filteredExtensions.map((vo, index) => {
|
<div className="flex flex-col gap-4 pb-4">
|
||||||
return (
|
{groupedExtensions.map((group) => (
|
||||||
<div key={vo.id || index}>
|
<div key={group.type} className="flex flex-col">
|
||||||
<ExtensionCardComponent
|
<div className="px-[0.8rem] flex items-center gap-2 mb-2">
|
||||||
cardVO={vo}
|
<h3 className="text-sm font-semibold text-foreground">
|
||||||
onCardClick={() => handleExtensionClick(vo)}
|
{t(group.labelKey)}
|
||||||
onDeleteClick={() => handleExtensionDelete(vo)}
|
</h3>
|
||||||
onUpgradeClick={vo.type === 'plugin' ? () => handleExtensionUpdate(vo) : undefined}
|
<span className="text-xs text-muted-foreground">
|
||||||
/>
|
({group.items.length})
|
||||||
</div>
|
</span>
|
||||||
);
|
</div>
|
||||||
})}
|
<div className="px-[0.8rem] grid gap-8 [grid-template-columns:repeat(auto-fill,minmax(30rem,1fr))] items-start">
|
||||||
</div>
|
{group.items.map((vo, index) => (
|
||||||
)}
|
<div key={vo.id || index}>
|
||||||
</>
|
<ExtensionCardComponent
|
||||||
);
|
cardVO={vo}
|
||||||
},
|
onCardClick={() => handleExtensionClick(vo)}
|
||||||
);
|
onDeleteClick={() => handleExtensionDelete(vo)}
|
||||||
|
onUpgradeClick={
|
||||||
|
vo.type === 'plugin'
|
||||||
|
? () => handleExtensionUpdate(vo)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={`${styles.pluginListContainer}`}>
|
||||||
|
{filteredExtensions.map((vo, index) => {
|
||||||
|
return (
|
||||||
|
<div key={vo.id || index}>
|
||||||
|
<ExtensionCardComponent
|
||||||
|
cardVO={vo}
|
||||||
|
onCardClick={() => handleExtensionClick(vo)}
|
||||||
|
onDeleteClick={() => handleExtensionDelete(vo)}
|
||||||
|
onUpgradeClick={
|
||||||
|
vo.type === 'plugin'
|
||||||
|
? () => handleExtensionUpdate(vo)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
export default PluginInstalledComponent;
|
export default PluginInstalledComponent;
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import PluginInstalledComponent, {
|
import PluginInstalledComponent, {
|
||||||
PluginInstalledComponentRef,
|
PluginInstalledComponentRef,
|
||||||
|
FilterOptions,
|
||||||
|
FilterType,
|
||||||
} from '@/app/home/plugins/components/plugin-installed/PluginInstalledComponent';
|
} 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 PluginDetailContent from './PluginDetailContent';
|
||||||
import styles from './plugins.module.css';
|
import styles from './plugins.module.css';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -28,6 +33,9 @@ import {
|
|||||||
CardDescription,
|
CardDescription,
|
||||||
CardContent,
|
CardContent,
|
||||||
} from '@/components/ui/card';
|
} 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 { Input } from '@/components/ui/input';
|
||||||
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
@@ -98,7 +106,7 @@ function PluginListView() {
|
|||||||
} = usePluginInstallTasks();
|
} = usePluginInstallTasks();
|
||||||
const [showGithubInstall, setShowGithubInstall] = useState(false);
|
const [showGithubInstall, setShowGithubInstall] = useState(false);
|
||||||
const [installSource, setInstallSource] = useState<string>('local');
|
const [installSource, setInstallSource] = useState<string>('local');
|
||||||
const [installInfo] = useState<Record<string, any>>({}); // eslint-disable-line @typescript-eslint/no-explicit-any
|
const [installInfo] = useState<Record<string, any>>({});
|
||||||
const [pluginInstallStatus, setPluginInstallStatus] =
|
const [pluginInstallStatus, setPluginInstallStatus] =
|
||||||
useState<PluginInstallStatus>(PluginInstallStatus.WAIT_INPUT);
|
useState<PluginInstallStatus>(PluginInstallStatus.WAIT_INPUT);
|
||||||
const [installError, setInstallError] = useState<string | null>(null);
|
const [installError, setInstallError] = useState<string | null>(null);
|
||||||
@@ -125,6 +133,15 @@ function PluginListView() {
|
|||||||
const [debugPopoverOpen, setDebugPopoverOpen] = useState(false);
|
const [debugPopoverOpen, setDebugPopoverOpen] = useState(false);
|
||||||
const [copiedDebugUrl, setCopiedDebugUrl] = useState(false);
|
const [copiedDebugUrl, setCopiedDebugUrl] = useState(false);
|
||||||
const [copiedDebugKey, setCopiedDebugKey] = useState(false);
|
const [copiedDebugKey, setCopiedDebugKey] = useState(false);
|
||||||
|
const [filterType, setFilterType] = useState<FilterType>('all');
|
||||||
|
const [groupByType, setGroupByType] = useState<boolean>(() => {
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
const fetchPluginSystemStatus = async () => {
|
const fetchPluginSystemStatus = async () => {
|
||||||
@@ -280,13 +297,13 @@ function PluginListView() {
|
|||||||
release_tag: selectedRelease.tag_name,
|
release_tag: selectedRelease.tag_name,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
installPlugin(installSource, installInfo as Record<string, any>); // eslint-disable-line @typescript-eslint/no-explicit-any
|
installPlugin(installSource, installInfo as Record<string, any>);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function installPlugin(
|
function installPlugin(
|
||||||
installSource: string,
|
installSource: string,
|
||||||
installInfo: Record<string, any>, // eslint-disable-line @typescript-eslint/no-explicit-any
|
installInfo: Record<string, any>,
|
||||||
) {
|
) {
|
||||||
setPluginInstallStatus(PluginInstallStatus.INSTALLING);
|
setPluginInstallStatus(PluginInstallStatus.INSTALLING);
|
||||||
if (installSource === 'github') {
|
if (installSource === 'github') {
|
||||||
@@ -469,35 +486,30 @@ function PluginListView() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderPluginDisabledState = () => (
|
const renderPluginDisabledState = () => (
|
||||||
<div className="flex flex-col items-center justify-center h-[60vh] text-center pt-[10vh]">
|
<div className="flex justify-center pt-[10vh] px-4">
|
||||||
<Power className="w-16 h-16 text-gray-400 mb-4" />
|
<Alert className="max-w-md">
|
||||||
<h2 className="text-2xl font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
<Power />
|
||||||
{t('plugins.systemDisabled')}
|
<AlertTitle>{t('plugins.systemDisabled')}</AlertTitle>
|
||||||
</h2>
|
<AlertDescription>{t('plugins.systemDisabledDesc')}</AlertDescription>
|
||||||
<p className="text-gray-500 dark:text-gray-400 max-w-md">
|
</Alert>
|
||||||
{t('plugins.systemDisabledDesc')}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderPluginConnectionErrorState = () => (
|
const renderPluginConnectionErrorState = () => (
|
||||||
<div className="flex flex-col items-center justify-center h-[60vh] text-center pt-[10vh]">
|
<div className="flex justify-center pt-[10vh] px-4">
|
||||||
<Unlink className="w-[72px] h-[72px] text-[#BDBDBD]" />
|
<Alert variant="destructive" className="max-w-md">
|
||||||
|
<Unlink />
|
||||||
<h2 className="text-2xl font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
<AlertTitle>{t('plugins.connectionError')}</AlertTitle>
|
||||||
{t('plugins.connectionError')}
|
<AlertDescription>{t('plugins.connectionErrorDesc')}</AlertDescription>
|
||||||
</h2>
|
</Alert>
|
||||||
<p className="text-gray-500 dark:text-gray-400 max-w-md mb-4">
|
|
||||||
{t('plugins.connectionErrorDesc')}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderLoadingState = () => (
|
const renderLoadingState = () => (
|
||||||
<div className="flex flex-col items-center justify-center h-[60vh] pt-[10vh]">
|
<div className="flex flex-col gap-3 pt-[10vh] px-4 max-w-md mx-auto">
|
||||||
<p className="text-gray-500 dark:text-gray-400">
|
<Skeleton className="h-6 w-1/2" />
|
||||||
{t('plugins.loadingStatus')}
|
<Skeleton className="h-4 w-full" />
|
||||||
</p>
|
<Skeleton className="h-4 w-5/6" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -530,67 +542,66 @@ function PluginListView() {
|
|||||||
style={{ display: 'none' }}
|
style={{ display: 'none' }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Header bar with debug info, task queue, and install button */}
|
{/* Header bar with filter tabs, debug info, and task queue */}
|
||||||
<div className="flex flex-row justify-end items-center px-[0.8rem] pb-4 flex-shrink-0 gap-2">
|
<div className="flex flex-row justify-between items-center px-[0.8rem] pb-4 flex-shrink-0 gap-2">
|
||||||
<PluginInstallTaskQueue />
|
<Tabs
|
||||||
|
value={filterType}
|
||||||
<Popover open={debugPopoverOpen} onOpenChange={setDebugPopoverOpen}>
|
onValueChange={(value) => setFilterType(value as FilterType)}
|
||||||
<PopoverTrigger asChild>
|
>
|
||||||
<Button
|
<TabsList>
|
||||||
variant="outline"
|
{FilterOptions.map((option) => (
|
||||||
className="px-4 py-4 cursor-pointer"
|
<TabsTrigger key={option.value} value={option.value}>
|
||||||
onClick={handleShowDebugInfo}
|
{option.icon && <option.icon className="size-4" />}
|
||||||
|
{t(option.labelKey)}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
<div className="flex flex-row items-center gap-2">
|
||||||
|
<div className="flex items-center gap-2 px-2">
|
||||||
|
<Switch
|
||||||
|
id="group-by-type"
|
||||||
|
checked={groupByType}
|
||||||
|
onCheckedChange={setGroupByType}
|
||||||
|
disabled={filterType !== 'all'}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor="group-by-type"
|
||||||
|
className="text-sm cursor-pointer whitespace-nowrap"
|
||||||
>
|
>
|
||||||
<Code className="w-4 h-4 mr-2" />
|
{t('plugins.groupByType')}
|
||||||
{t('plugins.debugInfo')}
|
</Label>
|
||||||
</Button>
|
</div>
|
||||||
</PopoverTrigger>
|
<PluginInstallTaskQueue />
|
||||||
<PopoverContent className="w-[380px]" align="end">
|
|
||||||
<div className="space-y-3">
|
|
||||||
{/* Header with icon and title */}
|
|
||||||
<div className="flex items-center gap-2 pb-2 border-b">
|
|
||||||
<Bug className="w-4 h-4" />
|
|
||||||
<h4 className="font-semibold text-sm">
|
|
||||||
{t('plugins.debugInfoTitle')}
|
|
||||||
</h4>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Debug URL row */}
|
<Popover open={debugPopoverOpen} onOpenChange={setDebugPopoverOpen}>
|
||||||
<div className="flex items-center gap-2">
|
<PopoverTrigger asChild>
|
||||||
<label className="text-sm font-medium whitespace-nowrap min-w-[50px]">
|
<Button
|
||||||
{t('plugins.debugUrl')}:
|
variant="outline"
|
||||||
</label>
|
className="px-4 py-4 cursor-pointer"
|
||||||
<Input
|
onClick={handleShowDebugInfo}
|
||||||
value={debugInfo?.debug_url || ''}
|
>
|
||||||
readOnly
|
<Code className="w-4 h-4 mr-2" />
|
||||||
className="w-[220px] font-mono text-xs h-8"
|
{t('plugins.debugInfo')}
|
||||||
/>
|
</Button>
|
||||||
<Button
|
</PopoverTrigger>
|
||||||
variant="ghost"
|
<PopoverContent className="w-[380px]" align="end">
|
||||||
size="icon"
|
<div className="space-y-3">
|
||||||
className="h-8 w-8 shrink-0"
|
{/* Header with icon and title */}
|
||||||
onClick={() =>
|
<div className="flex items-center gap-2 pb-2 border-b">
|
||||||
handleCopyDebugInfo(debugInfo?.debug_url || '', 'url')
|
<Bug className="w-4 h-4" />
|
||||||
}
|
<h4 className="font-semibold text-sm">
|
||||||
>
|
{t('plugins.debugInfoTitle')}
|
||||||
{copiedDebugUrl ? (
|
</h4>
|
||||||
<Check className="w-3.5 h-3.5 text-green-600" />
|
</div>
|
||||||
) : (
|
|
||||||
<Copy className="w-3.5 h-3.5" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Debug Key row */}
|
{/* Debug URL row */}
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<label className="text-sm font-medium whitespace-nowrap min-w-[50px]">
|
<label className="text-sm font-medium whitespace-nowrap min-w-[50px]">
|
||||||
{t('plugins.debugKey')}:
|
{t('plugins.debugUrl')}:
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
value={
|
value={debugInfo?.debug_url || ''}
|
||||||
debugInfo?.plugin_debug_key || t('plugins.noDebugKey')
|
|
||||||
}
|
|
||||||
readOnly
|
readOnly
|
||||||
className="w-[220px] font-mono text-xs h-8"
|
className="w-[220px] font-mono text-xs h-8"
|
||||||
/>
|
/>
|
||||||
@@ -599,29 +610,59 @@ function PluginListView() {
|
|||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 shrink-0"
|
className="h-8 w-8 shrink-0"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
handleCopyDebugInfo(
|
handleCopyDebugInfo(debugInfo?.debug_url || '', 'url')
|
||||||
debugInfo?.plugin_debug_key || '',
|
|
||||||
'key',
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
disabled={!debugInfo?.plugin_debug_key}
|
|
||||||
>
|
>
|
||||||
{copiedDebugKey ? (
|
{copiedDebugUrl ? (
|
||||||
<Check className="w-3.5 h-3.5 text-green-600" />
|
<Check className="w-3.5 h-3.5 text-green-600" />
|
||||||
) : (
|
) : (
|
||||||
<Copy className="w-3.5 h-3.5" />
|
<Copy className="w-3.5 h-3.5" />
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{!debugInfo?.plugin_debug_key && (
|
|
||||||
<p className="text-xs text-muted-foreground ml-[58px]">
|
{/* Debug Key row */}
|
||||||
{t('plugins.debugKeyDisabled')}
|
<div className="space-y-1">
|
||||||
</p>
|
<div className="flex items-center gap-2">
|
||||||
)}
|
<label className="text-sm font-medium whitespace-nowrap min-w-[50px]">
|
||||||
|
{t('plugins.debugKey')}:
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={
|
||||||
|
debugInfo?.plugin_debug_key || t('plugins.noDebugKey')
|
||||||
|
}
|
||||||
|
readOnly
|
||||||
|
className="w-[220px] font-mono text-xs h-8"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 shrink-0"
|
||||||
|
onClick={() =>
|
||||||
|
handleCopyDebugInfo(
|
||||||
|
debugInfo?.plugin_debug_key || '',
|
||||||
|
'key',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={!debugInfo?.plugin_debug_key}
|
||||||
|
>
|
||||||
|
{copiedDebugKey ? (
|
||||||
|
<Check className="w-3.5 h-3.5 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<Copy className="w-3.5 h-3.5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{!debugInfo?.plugin_debug_key && (
|
||||||
|
<p className="text-xs text-muted-foreground ml-[58px]">
|
||||||
|
{t('plugins.debugKeyDisabled')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</PopoverContent>
|
||||||
</PopoverContent>
|
</Popover>
|
||||||
</Popover>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Inline GitHub install flow */}
|
{/* Inline GitHub install flow */}
|
||||||
@@ -712,9 +753,12 @@ function PluginListView() {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
{release.prerelease && (
|
{release.prerelease && (
|
||||||
<span className="text-xs bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200 px-2 py-0.5 rounded ml-2 shrink-0">
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="ml-2 shrink-0"
|
||||||
|
>
|
||||||
{t('plugins.prerelease')}
|
{t('plugins.prerelease')}
|
||||||
</span>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -867,19 +911,21 @@ function PluginListView() {
|
|||||||
|
|
||||||
{/* Installed plugins grid */}
|
{/* Installed plugins grid */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<PluginInstalledComponent ref={pluginInstalledRef} />
|
<PluginInstalledComponent
|
||||||
|
ref={pluginInstalledRef}
|
||||||
|
filterType={filterType}
|
||||||
|
groupByType={groupByType}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isDragOver && (
|
{isDragOver && (
|
||||||
<div className="fixed inset-0 bg-gray-500 bg-opacity-50 flex items-center justify-center z-50 pointer-events-none">
|
<div className="fixed inset-0 bg-foreground/40 flex items-center justify-center z-50 pointer-events-none">
|
||||||
<div className="bg-white rounded-lg p-8 shadow-lg border-2 border-dashed border-gray-500">
|
<Card className="border-2 border-dashed">
|
||||||
<div className="text-center">
|
<CardContent className="flex flex-col items-center justify-center px-8 py-6">
|
||||||
<UploadIcon className="mx-auto h-12 w-12 text-gray-500 mb-4" />
|
<UploadIcon className="h-12 w-12 text-muted-foreground mb-4" />
|
||||||
<p className="text-lg font-medium text-gray-700">
|
<p className="text-lg font-medium">{t('plugins.dragToUpload')}</p>
|
||||||
{t('plugins.dragToUpload')}
|
</CardContent>
|
||||||
</p>
|
</Card>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -280,6 +280,15 @@ export interface ApiRespPlugins {
|
|||||||
plugins: Plugin[];
|
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 {
|
export interface ApiRespPlugin {
|
||||||
plugin: Plugin;
|
plugin: Plugin;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
ApiRespPlugins,
|
ApiRespPlugins,
|
||||||
ApiRespPlugin,
|
ApiRespPlugin,
|
||||||
ApiRespPluginConfig,
|
ApiRespPluginConfig,
|
||||||
|
ApiRespExtensions,
|
||||||
AsyncTaskCreatedResp,
|
AsyncTaskCreatedResp,
|
||||||
ApiRespSystemInfo,
|
ApiRespSystemInfo,
|
||||||
ApiRespAsyncTasks,
|
ApiRespAsyncTasks,
|
||||||
@@ -543,6 +544,11 @@ export class BackendClient extends BaseHttpClient {
|
|||||||
return this.get(`/api/v1/knowledge/parsers${params}`);
|
return this.get(`/api/v1/knowledge/parsers${params}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============ Extensions API ============
|
||||||
|
public getExtensions(): Promise<ApiRespExtensions> {
|
||||||
|
return this.get('/api/v1/extensions');
|
||||||
|
}
|
||||||
|
|
||||||
// ============ Plugins API ============
|
// ============ Plugins API ============
|
||||||
public getPlugins(): Promise<ApiRespPlugins> {
|
public getPlugins(): Promise<ApiRespPlugins> {
|
||||||
return this.get('/api/v1/plugins');
|
return this.get('/api/v1/plugins');
|
||||||
@@ -815,7 +821,10 @@ export class BackendClient extends BaseHttpClient {
|
|||||||
serverName: string,
|
serverName: string,
|
||||||
server: Partial<MCPServer>,
|
server: Partial<MCPServer>,
|
||||||
): Promise<AsyncTaskCreatedResp> {
|
): Promise<AsyncTaskCreatedResp> {
|
||||||
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<AsyncTaskCreatedResp> {
|
public deleteMCPServer(serverName: string): Promise<AsyncTaskCreatedResp> {
|
||||||
@@ -835,7 +844,10 @@ export class BackendClient extends BaseHttpClient {
|
|||||||
serverName: string,
|
serverName: string,
|
||||||
serverData: object,
|
serverData: object,
|
||||||
): Promise<AsyncTaskCreatedResp> {
|
): Promise<AsyncTaskCreatedResp> {
|
||||||
return this.post(`/api/v1/mcp/servers/${encodeURIComponent(serverName)}/test`, serverData);
|
return this.post(
|
||||||
|
`/api/v1/mcp/servers/${encodeURIComponent(serverName)}/test`,
|
||||||
|
serverData,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public installMCPServerFromGithub(
|
public installMCPServerFromGithub(
|
||||||
|
|||||||
@@ -449,6 +449,9 @@ const enUS = {
|
|||||||
loading: 'Loading...',
|
loading: 'Loading...',
|
||||||
getPluginListError: 'Failed to get plugin list:',
|
getPluginListError: 'Failed to get plugin list:',
|
||||||
noPluginInstalled: 'No plugins installed',
|
noPluginInstalled: 'No plugins installed',
|
||||||
|
noExtensionInstalled: 'No extensions installed',
|
||||||
|
loadingExtensions: 'Loading extensions...',
|
||||||
|
groupByType: 'Group by type',
|
||||||
pluginConfig: 'Plugin Configuration',
|
pluginConfig: 'Plugin Configuration',
|
||||||
pluginSort: 'Plugin Sort',
|
pluginSort: 'Plugin Sort',
|
||||||
pluginSortDescription:
|
pluginSortDescription:
|
||||||
@@ -658,7 +661,7 @@ const enUS = {
|
|||||||
deprecatedTooltip:
|
deprecatedTooltip:
|
||||||
'Please install the corresponding Knowledge Engine plugin.',
|
'Please install the corresponding Knowledge Engine plugin.',
|
||||||
filters: {
|
filters: {
|
||||||
allFormats: 'All Formats',
|
allFormats: 'All Types',
|
||||||
more: 'More',
|
more: 'More',
|
||||||
advancedTitle: 'Advanced Filters',
|
advancedTitle: 'Advanced Filters',
|
||||||
advancedDescription: 'Filter by extension type',
|
advancedDescription: 'Filter by extension type',
|
||||||
|
|||||||
@@ -462,6 +462,9 @@ const esES = {
|
|||||||
loading: 'Cargando...',
|
loading: 'Cargando...',
|
||||||
getPluginListError: 'Error al obtener la lista de plugins:',
|
getPluginListError: 'Error al obtener la lista de plugins:',
|
||||||
noPluginInstalled: 'No hay plugins instalados',
|
noPluginInstalled: 'No hay plugins instalados',
|
||||||
|
noExtensionInstalled: 'No hay extensiones instaladas',
|
||||||
|
loadingExtensions: 'Cargando extensiones...',
|
||||||
|
groupByType: 'Agrupar por tipo',
|
||||||
pluginConfig: 'Configuración del plugin',
|
pluginConfig: 'Configuración del plugin',
|
||||||
pluginSort: 'Orden de plugins',
|
pluginSort: 'Orden de plugins',
|
||||||
pluginSortDescription:
|
pluginSortDescription:
|
||||||
@@ -665,7 +668,7 @@ const esES = {
|
|||||||
deprecatedTooltip:
|
deprecatedTooltip:
|
||||||
'Por favor, instala el plugin de motor de conocimiento correspondiente.',
|
'Por favor, instala el plugin de motor de conocimiento correspondiente.',
|
||||||
filters: {
|
filters: {
|
||||||
allFormats: 'Todos los formatos',
|
allFormats: 'Todos los tipos',
|
||||||
more: 'Más',
|
more: 'Más',
|
||||||
advancedTitle: 'Filtros avanzados',
|
advancedTitle: 'Filtros avanzados',
|
||||||
advancedDescription: 'Filtrar por tipo de extensión',
|
advancedDescription: 'Filtrar por tipo de extensión',
|
||||||
|
|||||||
@@ -454,6 +454,9 @@ const jaJP = {
|
|||||||
loading: '読み込み中...',
|
loading: '読み込み中...',
|
||||||
getPluginListError: 'プラグインリストの取得に失敗しました:',
|
getPluginListError: 'プラグインリストの取得に失敗しました:',
|
||||||
noPluginInstalled: 'プラグインがインストールされていません',
|
noPluginInstalled: 'プラグインがインストールされていません',
|
||||||
|
noExtensionInstalled: '拡張機能がインストールされていません',
|
||||||
|
loadingExtensions: '拡張機能を読み込み中...',
|
||||||
|
groupByType: '種類でグループ化',
|
||||||
pluginConfig: 'プラグイン設定',
|
pluginConfig: 'プラグイン設定',
|
||||||
pluginSort: 'プラグインの並び替え',
|
pluginSort: 'プラグインの並び替え',
|
||||||
pluginSortDescription:
|
pluginSortDescription:
|
||||||
@@ -659,7 +662,7 @@ const jaJP = {
|
|||||||
noTags: 'タグがありません',
|
noTags: 'タグがありません',
|
||||||
},
|
},
|
||||||
filters: {
|
filters: {
|
||||||
allFormats: 'すべての形式',
|
allFormats: 'すべての種類',
|
||||||
more: 'もっと',
|
more: 'もっと',
|
||||||
advancedTitle: '高度なフィルター',
|
advancedTitle: '高度なフィルター',
|
||||||
advancedDescription: '拡張子タイプでフィルター',
|
advancedDescription: '拡張子タイプでフィルター',
|
||||||
|
|||||||
@@ -458,6 +458,9 @@ const ruRU = {
|
|||||||
loading: 'Загрузка...',
|
loading: 'Загрузка...',
|
||||||
getPluginListError: 'Не удалось получить список плагинов:',
|
getPluginListError: 'Не удалось получить список плагинов:',
|
||||||
noPluginInstalled: 'Плагины не установлены',
|
noPluginInstalled: 'Плагины не установлены',
|
||||||
|
noExtensionInstalled: 'Расширения не установлены',
|
||||||
|
loadingExtensions: 'Загрузка расширений...',
|
||||||
|
groupByType: 'Группировать по типу',
|
||||||
pluginConfig: 'Настройка плагина',
|
pluginConfig: 'Настройка плагина',
|
||||||
pluginSort: 'Порядок плагинов',
|
pluginSort: 'Порядок плагинов',
|
||||||
pluginSortDescription:
|
pluginSortDescription:
|
||||||
@@ -661,7 +664,7 @@ const ruRU = {
|
|||||||
deprecatedTooltip:
|
deprecatedTooltip:
|
||||||
'Пожалуйста, установите соответствующий плагин движка знаний.',
|
'Пожалуйста, установите соответствующий плагин движка знаний.',
|
||||||
filters: {
|
filters: {
|
||||||
allFormats: 'Все форматы',
|
allFormats: 'Все типы',
|
||||||
more: 'Ещё',
|
more: 'Ещё',
|
||||||
advancedTitle: 'Расширенные фильтры',
|
advancedTitle: 'Расширенные фильтры',
|
||||||
advancedDescription: 'Фильтр по типу расширения',
|
advancedDescription: 'Фильтр по типу расширения',
|
||||||
|
|||||||
@@ -445,6 +445,9 @@ const thTH = {
|
|||||||
loading: 'กำลังโหลด...',
|
loading: 'กำลังโหลด...',
|
||||||
getPluginListError: 'ไม่สามารถดึงรายการปลั๊กอินได้:',
|
getPluginListError: 'ไม่สามารถดึงรายการปลั๊กอินได้:',
|
||||||
noPluginInstalled: 'ยังไม่มีปลั๊กอินที่ติดตั้ง',
|
noPluginInstalled: 'ยังไม่มีปลั๊กอินที่ติดตั้ง',
|
||||||
|
noExtensionInstalled: 'ยังไม่มีส่วนขยายที่ติดตั้ง',
|
||||||
|
loadingExtensions: 'กำลังโหลดส่วนขยาย...',
|
||||||
|
groupByType: 'จัดกลุ่มตามประเภท',
|
||||||
pluginConfig: 'การกำหนดค่าปลั๊กอิน',
|
pluginConfig: 'การกำหนดค่าปลั๊กอิน',
|
||||||
pluginSort: 'เรียงลำดับปลั๊กอิน',
|
pluginSort: 'เรียงลำดับปลั๊กอิน',
|
||||||
pluginSortDescription:
|
pluginSortDescription:
|
||||||
@@ -642,7 +645,7 @@ const thTH = {
|
|||||||
deprecated: 'เลิกใช้แล้ว',
|
deprecated: 'เลิกใช้แล้ว',
|
||||||
deprecatedTooltip: 'กรุณาติดตั้งปลั๊กอินเครื่องมือความรู้ที่เกี่ยวข้อง',
|
deprecatedTooltip: 'กรุณาติดตั้งปลั๊กอินเครื่องมือความรู้ที่เกี่ยวข้อง',
|
||||||
filters: {
|
filters: {
|
||||||
allFormats: 'ทุกรูปแบบ',
|
allFormats: 'ทุกประเภท',
|
||||||
more: 'เพิ่มเติม',
|
more: 'เพิ่มเติม',
|
||||||
advancedTitle: 'ตัวกรองขั้นสูง',
|
advancedTitle: 'ตัวกรองขั้นสูง',
|
||||||
advancedDescription: 'กรองตามประเภทส่วนขยาย',
|
advancedDescription: 'กรองตามประเภทส่วนขยาย',
|
||||||
|
|||||||
@@ -455,6 +455,9 @@ const viVN = {
|
|||||||
loading: 'Đang tải...',
|
loading: 'Đang tải...',
|
||||||
getPluginListError: 'Lấy danh sách plugin thất bại:',
|
getPluginListError: 'Lấy danh sách plugin thất bại:',
|
||||||
noPluginInstalled: 'Chưa cài đặt plugin nào',
|
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',
|
pluginConfig: 'Cấu hình Plugin',
|
||||||
pluginSort: 'Sắp xếp Plugin',
|
pluginSort: 'Sắp xếp Plugin',
|
||||||
pluginSortDescription:
|
pluginSortDescription:
|
||||||
@@ -655,7 +658,7 @@ const viVN = {
|
|||||||
deprecated: 'Không còn hỗ trợ',
|
deprecated: 'Không còn hỗ trợ',
|
||||||
deprecatedTooltip: 'Vui lòng cài đặt plugin Công cụ tri thức tương ứng.',
|
deprecatedTooltip: 'Vui lòng cài đặt plugin Công cụ tri thức tương ứng.',
|
||||||
filters: {
|
filters: {
|
||||||
allFormats: 'Tất cả định dạng',
|
allFormats: 'Tất cả loại',
|
||||||
more: 'Thêm',
|
more: 'Thêm',
|
||||||
advancedTitle: 'Bộ lọc nâng cao',
|
advancedTitle: 'Bộ lọc nâng cao',
|
||||||
advancedDescription: 'Lọc theo loại phần mở rộng',
|
advancedDescription: 'Lọc theo loại phần mở rộng',
|
||||||
|
|||||||
@@ -433,6 +433,9 @@ const zhHans = {
|
|||||||
getPluginListError: '获取插件列表失败:',
|
getPluginListError: '获取插件列表失败:',
|
||||||
pluginConfig: '插件配置',
|
pluginConfig: '插件配置',
|
||||||
noPluginInstalled: '暂未安装任何插件',
|
noPluginInstalled: '暂未安装任何插件',
|
||||||
|
noExtensionInstalled: '暂未安装任何扩展',
|
||||||
|
loadingExtensions: '正在加载扩展...',
|
||||||
|
groupByType: '按类型分组',
|
||||||
pluginSort: '插件排序',
|
pluginSort: '插件排序',
|
||||||
pluginSortDescription:
|
pluginSortDescription:
|
||||||
'插件顺序会影响同一事件内的处理顺序,请拖动插件卡片排序',
|
'插件顺序会影响同一事件内的处理顺序,请拖动插件卡片排序',
|
||||||
@@ -634,7 +637,7 @@ const zhHans = {
|
|||||||
noTags: '暂无标签',
|
noTags: '暂无标签',
|
||||||
},
|
},
|
||||||
filters: {
|
filters: {
|
||||||
allFormats: '全部格式',
|
allFormats: '全部类型',
|
||||||
more: '更多',
|
more: '更多',
|
||||||
advancedTitle: '高级筛选',
|
advancedTitle: '高级筛选',
|
||||||
advancedDescription: '按扩展类型筛选',
|
advancedDescription: '按扩展类型筛选',
|
||||||
|
|||||||
@@ -434,6 +434,9 @@ const zhHant = {
|
|||||||
getPluginListError: '取得外掛清單失敗:',
|
getPluginListError: '取得外掛清單失敗:',
|
||||||
pluginConfig: '外掛設定',
|
pluginConfig: '外掛設定',
|
||||||
noPluginInstalled: '暫未安裝任何外掛',
|
noPluginInstalled: '暫未安裝任何外掛',
|
||||||
|
noExtensionInstalled: '暫未安裝任何擴充功能',
|
||||||
|
loadingExtensions: '正在載入擴充功能...',
|
||||||
|
groupByType: '依類型分組',
|
||||||
pluginSort: '外掛排序',
|
pluginSort: '外掛排序',
|
||||||
pluginSortDescription:
|
pluginSortDescription:
|
||||||
'外掛順序會影響同一事件內的處理順序,請拖曳外掛卡片排序',
|
'外掛順序會影響同一事件內的處理順序,請拖曳外掛卡片排序',
|
||||||
@@ -627,7 +630,7 @@ const zhHant = {
|
|||||||
noTags: '暫無標籤',
|
noTags: '暫無標籤',
|
||||||
},
|
},
|
||||||
filters: {
|
filters: {
|
||||||
allFormats: '全部格式',
|
allFormats: '全部類型',
|
||||||
more: '更多',
|
more: '更多',
|
||||||
advancedTitle: '高級篩選',
|
advancedTitle: '高級篩選',
|
||||||
advancedDescription: '按擴展類型篩選',
|
advancedDescription: '按擴展類型篩選',
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ export const router = createBrowserRouter([
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/home/plugins',
|
path: '/home/extensions',
|
||||||
element: (
|
element: (
|
||||||
<Suspense fallback={<Loading />}>
|
<Suspense fallback={<Loading />}>
|
||||||
<HomeLayout>
|
<HomeLayout>
|
||||||
|
|||||||
Reference in New Issue
Block a user