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