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:
Junyan Qin
2026-05-09 23:50:17 +08:00
parent 46a9ed3da6
commit 8ff60c5b98
18 changed files with 756 additions and 551 deletions

View 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})

View File

@@ -177,7 +177,7 @@ const ENTITY_ROUTE_MAP: Record<EntityCategoryId, string> = {
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');
}}
>
<Upload className="size-4" />
@@ -848,7 +848,7 @@ function NavItems({
onClick={(e) => {
e.stopPropagation();
setPendingPluginInstallAction('github');
navigate('/home/plugins');
navigate('/home/extensions');
}}
>
<Github className="size-4" />

View File

@@ -86,7 +86,7 @@ export const sidebarConfigList = [
id: 'plugins',
name: t('sidebar.installedPlugins'),
icon: <Puzzle className="text-blue-500" />,
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',
}),
];
];

View File

@@ -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 (
<SidebarProvider>

View File

@@ -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 = () => (
<>
<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}
</div>
<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}
</div>
<Badge
variant="outline"
className="text-[0.7rem] flex-shrink-0"
>
<Badge variant="outline" className="text-[0.7rem] flex-shrink-0">
v{cardVO.version}
</Badge>
<Badge
@@ -126,7 +122,7 @@ export default function ExtensionCardComponent({
</>
)}
</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}
</div>
</>
@@ -134,11 +130,11 @@ export default function ExtensionCardComponent({
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
</div>
<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}
</div>
<Badge
@@ -166,10 +162,12 @@ export default function ExtensionCardComponent({
{cardVO.enabled ? t('mcp.statusConnected') : t('mcp.statusDisabled')}
</Badge>
</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.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>
</>
@@ -177,11 +175,11 @@ export default function ExtensionCardComponent({
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
</div>
<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}
</div>
<Badge
@@ -191,7 +189,7 @@ export default function ExtensionCardComponent({
{t('common.skill')}
</Badge>
</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}
</div>
</>
@@ -199,13 +197,16 @@ export default function ExtensionCardComponent({
return (
<>
<div
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]"
<Card
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()}
>
<div className="w-full h-full flex flex-row items-start justify-start gap-[1.2rem]">
<img
src={cardVO.iconURL || httpClient.getPluginIconURL(cardVO.author, cardVO.name)}
src={
cardVO.iconURL ||
httpClient.getPluginIconURL(cardVO.author, cardVO.name)
}
alt="extension icon"
className="w-16 h-16 rounded-[8%] flex-shrink-0"
/>
@@ -233,62 +234,65 @@ export default function ExtensionCardComponent({
>
<DropdownMenuTrigger asChild>
<div className="relative">
<Button
variant="ghost"
className="bg-white dark:bg-[#1f1f22] hover:bg-gray-100 dark:hover:bg-[#2a2a2d]"
>
<Button variant="ghost" size="icon">
<Ellipsis className="w-4 h-4" />
</Button>
{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>
</DropdownMenuTrigger>
<DropdownMenuContent>
{cardVO.type === 'plugin' && cardVO.install_source === 'marketplace' && (
<DropdownMenuItem
className="flex flex-row items-center justify-start gap-[0.4rem] cursor-pointer"
onClick={(e) => {
e.stopPropagation();
if (onUpgradeClick) {
onUpgradeClick(cardVO);
}
setDropdownOpen(false);
}}
>
<ArrowUp className="w-4 h-4" />
<span>{t('plugins.update')}</span>
{cardVO.hasUpdate && (
<Badge className="ml-auto bg-red-500 hover:bg-red-500 text-white text-[0.6rem] px-1.5 py-0 h-4">
{t('plugins.new')}
</Badge>
)}
</DropdownMenuItem>
)}
{cardVO.type === 'plugin' && (cardVO.install_source === 'github' || cardVO.install_source === 'marketplace') && (
<DropdownMenuItem
className="flex flex-row items-center justify-start gap-[0.4rem] cursor-pointer"
onClick={(e) => {
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);
}}
>
<ExternalLink className="w-4 h-4" />
<span>{t('plugins.viewSource')}</span>
</DropdownMenuItem>
)}
{cardVO.type === 'plugin' &&
cardVO.install_source === 'marketplace' && (
<DropdownMenuItem
className="flex flex-row items-center justify-start gap-[0.4rem] cursor-pointer"
onClick={(e) => {
e.stopPropagation();
if (onUpgradeClick) {
onUpgradeClick(cardVO);
}
setDropdownOpen(false);
}}
>
<ArrowUp className="w-4 h-4" />
<span>{t('plugins.update')}</span>
{cardVO.hasUpdate && (
<Badge className="ml-auto bg-red-500 hover:bg-red-500 text-white text-[0.6rem] px-1.5 py-0 h-4">
{t('plugins.new')}
</Badge>
)}
</DropdownMenuItem>
)}
{cardVO.type === 'plugin' &&
(cardVO.install_source === 'github' ||
cardVO.install_source === 'marketplace') && (
<DropdownMenuItem
className="flex flex-row items-center justify-start gap-[0.4rem] cursor-pointer"
onClick={(e) => {
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);
}}
>
<ExternalLink className="w-4 h-4" />
<span>{t('plugins.viewSource')}</span>
</DropdownMenuItem>
)}
<DropdownMenuItem
className="flex flex-row items-center justify-start gap-[0.4rem] cursor-pointer text-red-600 focus:text-red-600"
onClick={(e) => {
@@ -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')}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
@@ -311,7 +315,7 @@ export default function ExtensionCardComponent({
</div>
</div>
</div>
</div>
</Card>
</>
);
}
}

View File

@@ -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<PluginInstalledComponentRef>(
(props, ref) => {
const { t } = useTranslation();
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);
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<ExtensionCardVO[]>([]);
const [loading, setLoading] = useState<boolean>(true);
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);
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<string, any>();
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<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 author = meta.author ?? '';
const name = meta.name;
@@ -122,190 +137,215 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
}
}
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 (
<>
<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' && (
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 (
<>
<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">
<Checkbox
id="delete-data"
@@ -322,129 +362,147 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
</label>
</div>
)}
</div>
)}
{asyncTask.status === AsyncTaskStatus.RUNNING && (
<div>
{operationType === ExtensionOperationType.DELETE
? t('plugins.deleting')
: t('plugins.updating')}
</div>
)}
{asyncTask.status === AsyncTaskStatus.ERROR && (
<div>
{operationType === ExtensionOperationType.DELETE
? t('plugins.deleteError')
: t('plugins.updateError')}
<div className="text-red-500">{asyncTask.error}</div>
</div>
)}
</DialogDescription>
<DialogFooter>
{asyncTask.status === AsyncTaskStatus.WAIT_INPUT && (
<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}
</div>
)}
{asyncTask.status === AsyncTaskStatus.RUNNING && (
<div>
{operationType === ExtensionOperationType.DELETE
? t('plugins.deleting')
: t('plugins.updating')}
</div>
)}
{asyncTask.status === AsyncTaskStatus.ERROR && (
<div>
{operationType === ExtensionOperationType.DELETE
? t('plugins.deleteError')
: t('plugins.updateError')}
<div className="text-red-500">{asyncTask.error}</div>
</div>
)}
</DialogDescription>
<DialogFooter>
{asyncTask.status === AsyncTaskStatus.WAIT_INPUT && (
<Button
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(option.labelKey)}
</ToggleGroupItem>
))}
</ToggleGroup>
{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>
{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>
{filteredExtensions.length === 0 ? (
<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]" />
<div className="text-lg mb-2">{t('plugins.noPluginInstalled')}</div>
) : filteredExtensions.length === 0 ? (
<div className="flex flex-col items-center justify-center text-muted-foreground min-h-[60vh] w-full gap-2">
<Puzzle className="h-[3rem] w-[3rem]" />
<div className="text-lg mb-2">
{t('plugins.noExtensionInstalled')}
</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>
)}
</>
);
},
);
</div>
) : showGrouped ? (
<div className="flex flex-col gap-4 pb-4">
{groupedExtensions.map((group) => (
<div key={group.type} className="flex flex-col">
<div className="px-[0.8rem] flex items-center gap-2 mb-2">
<h3 className="text-sm font-semibold text-foreground">
{t(group.labelKey)}
</h3>
<span className="text-xs text-muted-foreground">
({group.items.length})
</span>
</div>
<div className="px-[0.8rem] grid gap-8 [grid-template-columns:repeat(auto-fill,minmax(30rem,1fr))] items-start">
{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;

View File

@@ -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<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] =
useState<PluginInstallStatus>(PluginInstallStatus.WAIT_INPUT);
const [installError, setInstallError] = useState<string | null>(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<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(() => {
const fetchPluginSystemStatus = async () => {
@@ -280,13 +297,13 @@ function PluginListView() {
release_tag: selectedRelease.tag_name,
});
} 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(
installSource: string,
installInfo: Record<string, any>, // eslint-disable-line @typescript-eslint/no-explicit-any
installInfo: Record<string, any>,
) {
setPluginInstallStatus(PluginInstallStatus.INSTALLING);
if (installSource === 'github') {
@@ -469,35 +486,30 @@ function PluginListView() {
};
const renderPluginDisabledState = () => (
<div className="flex flex-col items-center justify-center h-[60vh] text-center pt-[10vh]">
<Power className="w-16 h-16 text-gray-400 mb-4" />
<h2 className="text-2xl font-semibold text-gray-700 dark:text-gray-300 mb-2">
{t('plugins.systemDisabled')}
</h2>
<p className="text-gray-500 dark:text-gray-400 max-w-md">
{t('plugins.systemDisabledDesc')}
</p>
<div className="flex justify-center pt-[10vh] px-4">
<Alert className="max-w-md">
<Power />
<AlertTitle>{t('plugins.systemDisabled')}</AlertTitle>
<AlertDescription>{t('plugins.systemDisabledDesc')}</AlertDescription>
</Alert>
</div>
);
const renderPluginConnectionErrorState = () => (
<div className="flex flex-col items-center justify-center h-[60vh] text-center pt-[10vh]">
<Unlink className="w-[72px] h-[72px] text-[#BDBDBD]" />
<h2 className="text-2xl font-semibold text-gray-700 dark:text-gray-300 mb-2">
{t('plugins.connectionError')}
</h2>
<p className="text-gray-500 dark:text-gray-400 max-w-md mb-4">
{t('plugins.connectionErrorDesc')}
</p>
<div className="flex justify-center pt-[10vh] px-4">
<Alert variant="destructive" className="max-w-md">
<Unlink />
<AlertTitle>{t('plugins.connectionError')}</AlertTitle>
<AlertDescription>{t('plugins.connectionErrorDesc')}</AlertDescription>
</Alert>
</div>
);
const renderLoadingState = () => (
<div className="flex flex-col items-center justify-center h-[60vh] pt-[10vh]">
<p className="text-gray-500 dark:text-gray-400">
{t('plugins.loadingStatus')}
</p>
<div className="flex flex-col gap-3 pt-[10vh] px-4 max-w-md mx-auto">
<Skeleton className="h-6 w-1/2" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
</div>
);
@@ -530,67 +542,66 @@ function PluginListView() {
style={{ display: 'none' }}
/>
{/* Header bar with debug info, task queue, and install button */}
<div className="flex flex-row justify-end items-center px-[0.8rem] pb-4 flex-shrink-0 gap-2">
<PluginInstallTaskQueue />
<Popover open={debugPopoverOpen} onOpenChange={setDebugPopoverOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
className="px-4 py-4 cursor-pointer"
onClick={handleShowDebugInfo}
{/* Header bar with filter tabs, debug info, and task queue */}
<div className="flex flex-row justify-between items-center px-[0.8rem] pb-4 flex-shrink-0 gap-2">
<Tabs
value={filterType}
onValueChange={(value) => setFilterType(value as FilterType)}
>
<TabsList>
{FilterOptions.map((option) => (
<TabsTrigger key={option.value} value={option.value}>
{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.debugInfo')}
</Button>
</PopoverTrigger>
<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>
{t('plugins.groupByType')}
</Label>
</div>
<PluginInstallTaskQueue />
{/* Debug URL row */}
<div className="flex items-center gap-2">
<label className="text-sm font-medium whitespace-nowrap min-w-[50px]">
{t('plugins.debugUrl')}:
</label>
<Input
value={debugInfo?.debug_url || ''}
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?.debug_url || '', 'url')
}
>
{copiedDebugUrl ? (
<Check className="w-3.5 h-3.5 text-green-600" />
) : (
<Copy className="w-3.5 h-3.5" />
)}
</Button>
</div>
<Popover open={debugPopoverOpen} onOpenChange={setDebugPopoverOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
className="px-4 py-4 cursor-pointer"
onClick={handleShowDebugInfo}
>
<Code className="w-4 h-4 mr-2" />
{t('plugins.debugInfo')}
</Button>
</PopoverTrigger>
<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 Key row */}
<div className="space-y-1">
{/* Debug URL row */}
<div className="flex items-center gap-2">
<label className="text-sm font-medium whitespace-nowrap min-w-[50px]">
{t('plugins.debugKey')}:
{t('plugins.debugUrl')}:
</label>
<Input
value={
debugInfo?.plugin_debug_key || t('plugins.noDebugKey')
}
value={debugInfo?.debug_url || ''}
readOnly
className="w-[220px] font-mono text-xs h-8"
/>
@@ -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 ? (
<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>
)}
{/* Debug Key row */}
<div className="space-y-1">
<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>
</PopoverContent>
</Popover>
</PopoverContent>
</Popover>
</div>
</div>
{/* Inline GitHub install flow */}
@@ -712,9 +753,12 @@ function PluginListView() {
</CardDescription>
</div>
{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')}
</span>
</Badge>
)}
</CardHeader>
</Card>
@@ -867,19 +911,21 @@ function PluginListView() {
{/* Installed plugins grid */}
<div className="flex-1 overflow-y-auto">
<PluginInstalledComponent ref={pluginInstalledRef} />
<PluginInstalledComponent
ref={pluginInstalledRef}
filterType={filterType}
groupByType={groupByType}
/>
</div>
{isDragOver && (
<div className="fixed inset-0 bg-gray-500 bg-opacity-50 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">
<div className="text-center">
<UploadIcon className="mx-auto h-12 w-12 text-gray-500 mb-4" />
<p className="text-lg font-medium text-gray-700">
{t('plugins.dragToUpload')}
</p>
</div>
</div>
<div className="fixed inset-0 bg-foreground/40 flex items-center justify-center z-50 pointer-events-none">
<Card className="border-2 border-dashed">
<CardContent className="flex flex-col items-center justify-center px-8 py-6">
<UploadIcon className="h-12 w-12 text-muted-foreground mb-4" />
<p className="text-lg font-medium">{t('plugins.dragToUpload')}</p>
</CardContent>
</Card>
</div>
)}
</div>

View File

@@ -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;
}

View File

@@ -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<ApiRespExtensions> {
return this.get('/api/v1/extensions');
}
// ============ Plugins API ============
public getPlugins(): Promise<ApiRespPlugins> {
return this.get('/api/v1/plugins');
@@ -815,7 +821,10 @@ export class BackendClient extends BaseHttpClient {
serverName: string,
server: Partial<MCPServer>,
): 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> {
@@ -835,7 +844,10 @@ export class BackendClient extends BaseHttpClient {
serverName: string,
serverData: object,
): 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(

View File

@@ -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',

View File

@@ -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',

View File

@@ -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: '拡張子タイプでフィルター',

View File

@@ -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: 'Фильтр по типу расширения',

View File

@@ -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: 'กรองตามประเภทส่วนขยาย',

View File

@@ -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',

View File

@@ -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: '按扩展类型筛选',

View File

@@ -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: '按擴展類型篩選',

View File

@@ -110,7 +110,7 @@ export const router = createBrowserRouter([
),
},
{
path: '/home/plugins',
path: '/home/extensions',
element: (
<Suspense fallback={<Loading />}>
<HomeLayout>