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',
|
||||
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" />
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
];
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '拡張子タイプでフィルター',
|
||||
|
||||
@@ -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: 'Фильтр по типу расширения',
|
||||
|
||||
@@ -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: 'กรองตามประเภทส่วนขยาย',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '按扩展类型筛选',
|
||||
|
||||
@@ -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: '按擴展類型篩選',
|
||||
|
||||
@@ -110,7 +110,7 @@ export const router = createBrowserRouter([
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/home/plugins',
|
||||
path: '/home/extensions',
|
||||
element: (
|
||||
<Suspense fallback={<Loading />}>
|
||||
<HomeLayout>
|
||||
|
||||
Reference in New Issue
Block a user