mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 03:55:55 +00:00
feat: add plugin extension pages (iframe rendering, Page SDK, security hardening, i18n)
Co-Authored-By: Typer_Body <mcjiekejiemi@163.com>
This commit is contained in:
@@ -92,6 +92,7 @@ import {
|
||||
} from '@/components/ui/popover';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useSidebarData, SidebarEntityItem } from './SidebarDataContext';
|
||||
import { LayoutDashboard } from 'lucide-react';
|
||||
|
||||
// Compare two version strings, returns true if v1 > v2
|
||||
function compareVersions(v1: string, v2: string): boolean {
|
||||
@@ -699,87 +700,103 @@ function NavItems({
|
||||
>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
isActive={false}
|
||||
onClick={() => {
|
||||
if (isCollapseOnly) {
|
||||
onSectionToggle(config.id, !isOpen);
|
||||
} else {
|
||||
onChildClick(config);
|
||||
}
|
||||
}}
|
||||
tooltip={config.name}
|
||||
className="group/category-header"
|
||||
>
|
||||
{config.icon}
|
||||
<span>{config.name}</span>
|
||||
<div className="ml-auto flex items-center gap-0.5 -mr-1">
|
||||
{canCreate &&
|
||||
(isPlugin ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{systemInfo.enable_marketplace && (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
if (isCollapseOnly) {
|
||||
onSectionToggle(config.id, !isOpen);
|
||||
} else {
|
||||
onChildClick(config);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
if (isCollapseOnly) {
|
||||
onSectionToggle(config.id, !isOpen);
|
||||
} else {
|
||||
onChildClick(config);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{config.icon}
|
||||
<span>{config.name}</span>
|
||||
<div className="ml-auto flex items-center gap-0.5 -mr-1">
|
||||
{canCreate &&
|
||||
(isPlugin ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{systemInfo.enable_marketplace && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/market');
|
||||
}}
|
||||
>
|
||||
<Store className="size-4" />
|
||||
{t('plugins.goToMarketplace')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/market');
|
||||
setPendingPluginInstallAction('local');
|
||||
navigate('/home/plugins');
|
||||
}}
|
||||
>
|
||||
<Store className="size-4" />
|
||||
{t('plugins.goToMarketplace')}
|
||||
<Upload className="size-4" />
|
||||
{t('plugins.uploadLocal')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setPendingPluginInstallAction('local');
|
||||
navigate('/home/plugins');
|
||||
}}
|
||||
>
|
||||
<Upload className="size-4" />
|
||||
{t('plugins.uploadLocal')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setPendingPluginInstallAction('github');
|
||||
navigate('/home/plugins');
|
||||
}}
|
||||
>
|
||||
<Github className="size-4" />
|
||||
{t('plugins.installFromGithub')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setPendingPluginInstallAction('github');
|
||||
navigate('/home/plugins');
|
||||
}}
|
||||
>
|
||||
<Github className="size-4" />
|
||||
{t('plugins.installFromGithub')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`${routePrefix}?id=new`);
|
||||
}}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
))}
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`${routePrefix}?id=new`);
|
||||
}}
|
||||
className="p-1 rounded-sm hover:bg-sidebar-accent"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
<ChevronRight className="size-4 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
|
||||
</button>
|
||||
))}
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded-sm hover:bg-sidebar-accent"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ChevronRight className="size-4 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
</CollapsibleTrigger>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
<CollapsibleContent>
|
||||
@@ -1023,6 +1040,47 @@ function PluginItemMenu({
|
||||
);
|
||||
}
|
||||
|
||||
// Plugin pages navigation section
|
||||
function PluginPagesNav() {
|
||||
const { pluginPages } = useSidebarData();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (pluginPages.length === 0) return null;
|
||||
|
||||
const pathname = location.pathname;
|
||||
const currentId =
|
||||
pathname === '/home/plugin-pages' ? searchParams.get('id') : null;
|
||||
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>{t('sidebar.pluginPages')}</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{pluginPages.map((page) => {
|
||||
const isActive = currentId === page.id;
|
||||
const route = `/home/plugin-pages?id=${encodeURIComponent(page.id)}`;
|
||||
return (
|
||||
<SidebarMenuItem key={page.id}>
|
||||
<SidebarMenuButton
|
||||
isActive={isActive}
|
||||
tooltip={page.name}
|
||||
onClick={() => navigate(route)}
|
||||
>
|
||||
<LayoutDashboard className="size-4 text-blue-500" />
|
||||
<span>{page.name}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HomeSidebar({
|
||||
onSelectedChangeAction,
|
||||
}: {
|
||||
@@ -1285,6 +1343,7 @@ export default function HomeSidebar({
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
<PluginPagesNav />
|
||||
</SidebarContent>
|
||||
|
||||
{/* Footer */}
|
||||
|
||||
@@ -31,6 +31,17 @@ export interface SidebarEntityItem {
|
||||
// Install action types that can be triggered from sidebar
|
||||
export type PluginInstallAction = 'local' | 'github' | null;
|
||||
|
||||
// Plugin page registered by a plugin
|
||||
export interface PluginPageItem {
|
||||
id: string; // "author/name/pageId"
|
||||
name: string; // display label
|
||||
pluginAuthor: string;
|
||||
pluginName: string;
|
||||
pageId: string;
|
||||
path: string; // asset path (HTML file)
|
||||
icon?: string; // optional icon name
|
||||
}
|
||||
|
||||
// Entity lists and refresh functions exposed via context
|
||||
export interface SidebarDataContextValue {
|
||||
bots: SidebarEntityItem[];
|
||||
@@ -38,6 +49,7 @@ export interface SidebarDataContextValue {
|
||||
knowledgeBases: SidebarEntityItem[];
|
||||
plugins: SidebarEntityItem[];
|
||||
mcpServers: SidebarEntityItem[];
|
||||
pluginPages: PluginPageItem[];
|
||||
refreshBots: () => Promise<void>;
|
||||
refreshPipelines: () => Promise<void>;
|
||||
refreshKnowledgeBases: () => Promise<void>;
|
||||
@@ -64,6 +76,7 @@ export function SidebarDataProvider({
|
||||
const [knowledgeBases, setKnowledgeBases] = useState<SidebarEntityItem[]>([]);
|
||||
const [plugins, setPlugins] = useState<SidebarEntityItem[]>([]);
|
||||
const [mcpServers, setMCPServers] = useState<SidebarEntityItem[]>([]);
|
||||
const [pluginPages, setPluginPages] = useState<PluginPageItem[]>([]);
|
||||
const [detailEntityName, setDetailEntityName] = useState<string | null>(null);
|
||||
const [pendingPluginInstallAction, setPendingPluginInstallAction] =
|
||||
useState<PluginInstallAction>(null);
|
||||
@@ -137,33 +150,67 @@ export function SidebarDataProvider({
|
||||
}
|
||||
}
|
||||
|
||||
setPlugins(
|
||||
pluginsResp.plugins.map((plugin) => {
|
||||
const meta = plugin.manifest.manifest.metadata;
|
||||
const author = meta.author ?? '';
|
||||
const name = meta.name;
|
||||
const compositeKey = `${author}/${name}`;
|
||||
const installedVersion = meta.version ?? '';
|
||||
// Deduplicate plugins by composite key (prefer debug over installed)
|
||||
const pluginMap = new Map<string, SidebarEntityItem>();
|
||||
for (const plugin of pluginsResp.plugins) {
|
||||
const meta = plugin.manifest.manifest.metadata;
|
||||
const author = meta.author ?? '';
|
||||
const name = meta.name;
|
||||
const compositeKey = `${author}/${name}`;
|
||||
const installedVersion = meta.version ?? '';
|
||||
|
||||
let hasUpdate = false;
|
||||
if (plugin.install_source === 'marketplace') {
|
||||
const latestVersion = marketplaceVersions.get(compositeKey);
|
||||
if (latestVersion) {
|
||||
hasUpdate = isNewerVersion(latestVersion, installedVersion);
|
||||
let hasUpdate = false;
|
||||
if (plugin.install_source === 'marketplace') {
|
||||
const latestVersion = marketplaceVersions.get(compositeKey);
|
||||
if (latestVersion) {
|
||||
hasUpdate = isNewerVersion(latestVersion, installedVersion);
|
||||
}
|
||||
}
|
||||
|
||||
const item: SidebarEntityItem = {
|
||||
id: compositeKey,
|
||||
name: extractI18nObject(meta.label),
|
||||
iconURL: httpClient.getPluginIconURL(author, name),
|
||||
installSource: plugin.install_source,
|
||||
installInfo: plugin.install_info,
|
||||
hasUpdate,
|
||||
debug: plugin.debug,
|
||||
};
|
||||
|
||||
// If duplicate, prefer debug version
|
||||
if (!pluginMap.has(compositeKey) || plugin.debug) {
|
||||
pluginMap.set(compositeKey, item);
|
||||
}
|
||||
}
|
||||
setPlugins(Array.from(pluginMap.values()));
|
||||
|
||||
// Extract plugin pages from spec.pages (deduplicate by id)
|
||||
const pages: PluginPageItem[] = [];
|
||||
const seenPageIds = new Set<string>();
|
||||
for (const plugin of pluginsResp.plugins) {
|
||||
const meta = plugin.manifest.manifest.metadata;
|
||||
const author = meta.author ?? '';
|
||||
const name = meta.name;
|
||||
const spec = plugin.manifest.manifest.spec;
|
||||
if (spec?.pages && Array.isArray(spec.pages)) {
|
||||
for (const page of spec.pages) {
|
||||
const pageId = `${author}/${name}/${page.id}`;
|
||||
if (page.id && page.path && !seenPageIds.has(pageId)) {
|
||||
seenPageIds.add(pageId);
|
||||
pages.push({
|
||||
id: pageId,
|
||||
name: page.label ? extractI18nObject(page.label) : page.id,
|
||||
pluginAuthor: author,
|
||||
pluginName: name,
|
||||
pageId: page.id,
|
||||
path: page.path,
|
||||
icon: page.icon,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: compositeKey,
|
||||
name: extractI18nObject(meta.label),
|
||||
iconURL: httpClient.getPluginIconURL(author, name),
|
||||
installSource: plugin.install_source,
|
||||
installInfo: plugin.install_info,
|
||||
hasUpdate,
|
||||
debug: plugin.debug,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
setPluginPages(pages);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch plugins for sidebar:', error);
|
||||
}
|
||||
@@ -214,6 +261,7 @@ export function SidebarDataProvider({
|
||||
knowledgeBases,
|
||||
plugins,
|
||||
mcpServers,
|
||||
pluginPages,
|
||||
refreshBots,
|
||||
refreshPipelines,
|
||||
refreshKnowledgeBases,
|
||||
|
||||
@@ -44,7 +44,12 @@ import {
|
||||
} from '@/app/home/plugins/components/plugin-install-task';
|
||||
|
||||
// Routes that belong to the "Extensions" section
|
||||
const EXTENSIONS_ROUTES = ['/home/plugins', '/home/market', '/home/mcp'];
|
||||
const EXTENSIONS_ROUTES = [
|
||||
'/home/plugins',
|
||||
'/home/market',
|
||||
'/home/mcp',
|
||||
'/home/plugin-pages',
|
||||
];
|
||||
|
||||
function isExtensionsRoute(pathname: string): boolean {
|
||||
return EXTENSIONS_ROUTES.some(
|
||||
|
||||
192
web/src/app/home/plugin-pages/page.tsx
Normal file
192
web/src/app/home/plugin-pages/page.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTheme } from '@/components/providers/theme-provider';
|
||||
|
||||
/**
|
||||
* Plugin page that renders a plugin-provided HTML page in an iframe.
|
||||
* URL format: /home/plugin-pages?id=author/name/pageId
|
||||
*
|
||||
* The iframe communicates with the parent via postMessage:
|
||||
*
|
||||
* Parent → iframe:
|
||||
* { type: 'langbot:context', theme: 'light'|'dark', language: 'zh-Hans'|'en-US' }
|
||||
*
|
||||
* iframe → Parent:
|
||||
* { type: 'langbot:api', requestId: string, endpoint: string, method: string, body?: any }
|
||||
*
|
||||
* Parent → iframe (response):
|
||||
* { type: 'langbot:api:response', requestId: string, data?: any, error?: string }
|
||||
*/
|
||||
export default function PluginPagesPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const id = searchParams.get('id');
|
||||
const { t } = useTranslation();
|
||||
const { setDetailEntityName, pluginPages } = useSidebarData();
|
||||
|
||||
// Find the matching page for breadcrumb
|
||||
const page = pluginPages.find((p) => p.id === id);
|
||||
|
||||
useEffect(() => {
|
||||
setDetailEntityName(page?.name ?? id ?? '');
|
||||
return () => setDetailEntityName(null);
|
||||
}, [page, id, setDetailEntityName]);
|
||||
|
||||
if (!id) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
{t('pluginPages.selectFromSidebar')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Parse "author/name/pageId"
|
||||
const parts = id.split('/');
|
||||
if (parts.length < 3) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
{t('pluginPages.invalidPage')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const author = parts[0];
|
||||
const pluginName = parts[1];
|
||||
// Use the asset path from the page manifest, not the page ID
|
||||
const assetPath = page?.path ?? parts.slice(2).join('/');
|
||||
const pageId = parts.slice(2).join('/');
|
||||
|
||||
return (
|
||||
<PluginPageIframe
|
||||
author={author}
|
||||
pluginName={pluginName}
|
||||
pagePath={assetPath}
|
||||
pageId={pageId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PluginPageIframe({
|
||||
author,
|
||||
pluginName,
|
||||
pagePath,
|
||||
pageId,
|
||||
}: {
|
||||
author: string;
|
||||
pluginName: string;
|
||||
pagePath: string;
|
||||
pageId: string;
|
||||
}) {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { resolvedTheme } = useTheme();
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
const assetUrl = httpClient.getPluginAssetURL(author, pluginName, pagePath);
|
||||
|
||||
// Send context (theme + language) to iframe
|
||||
// Use '*' as targetOrigin because sandboxed iframe has opaque (null) origin
|
||||
const sendContext = useCallback(() => {
|
||||
const iframe = iframeRef.current;
|
||||
if (iframe?.contentWindow) {
|
||||
iframe.contentWindow.postMessage(
|
||||
{
|
||||
type: 'langbot:context',
|
||||
theme: resolvedTheme,
|
||||
language: i18n.language,
|
||||
},
|
||||
'*',
|
||||
);
|
||||
}
|
||||
}, [resolvedTheme, i18n.language]);
|
||||
|
||||
// Re-send context when theme or language changes
|
||||
useEffect(() => {
|
||||
if (!loading) {
|
||||
sendContext();
|
||||
}
|
||||
}, [resolvedTheme, i18n.language, loading, sendContext]);
|
||||
|
||||
// Handle messages from iframe (API calls)
|
||||
useEffect(() => {
|
||||
const handleMessage = async (event: MessageEvent) => {
|
||||
// Validate source — only accept messages from our specific iframe window
|
||||
// This is more secure than origin checking: works with sandboxed (null-origin) iframes
|
||||
// and prevents spoofing from other windows/iframes
|
||||
if (event.source !== iframeRef.current?.contentWindow) return;
|
||||
|
||||
const data = event.data;
|
||||
if (!data || typeof data !== 'object') return;
|
||||
|
||||
// Validate requestId format to prevent injection
|
||||
if (data.type === 'langbot:api') {
|
||||
const { requestId, endpoint, method, body } = data;
|
||||
if (typeof requestId !== 'string' || typeof endpoint !== 'string')
|
||||
return;
|
||||
// Sanitize endpoint — must start with / and not contain ..
|
||||
if (!endpoint.startsWith('/') || endpoint.includes('..')) return;
|
||||
const normalizedMethod =
|
||||
typeof method === 'string' ? method.toUpperCase() : 'POST';
|
||||
if (
|
||||
!['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(normalizedMethod)
|
||||
)
|
||||
return;
|
||||
try {
|
||||
const result = await httpClient.pluginPageApi(
|
||||
author,
|
||||
pluginName,
|
||||
pageId,
|
||||
endpoint,
|
||||
normalizedMethod,
|
||||
body,
|
||||
);
|
||||
iframeRef.current?.contentWindow?.postMessage(
|
||||
{
|
||||
type: 'langbot:api:response',
|
||||
requestId,
|
||||
data: result,
|
||||
},
|
||||
'*',
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
iframeRef.current?.contentWindow?.postMessage(
|
||||
{
|
||||
type: 'langbot:api:response',
|
||||
requestId,
|
||||
error: errorMsg,
|
||||
},
|
||||
'*',
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', handleMessage);
|
||||
return () => window.removeEventListener('message', handleMessage);
|
||||
}, [author, pluginName, pageId]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full w-full">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
Loading...
|
||||
</div>
|
||||
)}
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={assetUrl}
|
||||
className="flex-1 w-full border-0 rounded-md"
|
||||
style={{ display: loading ? 'none' : 'block' }}
|
||||
onLoad={() => {
|
||||
setLoading(false);
|
||||
sendContext();
|
||||
}}
|
||||
sandbox="allow-scripts allow-forms"
|
||||
title={`${author}/${pluginName} - ${pagePath}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -596,6 +596,27 @@ export class BackendClient extends BaseHttpClient {
|
||||
);
|
||||
}
|
||||
|
||||
public async pluginPageApi(
|
||||
author: string,
|
||||
name: string,
|
||||
pageId: string,
|
||||
endpoint: string,
|
||||
method: string = 'POST',
|
||||
body?: unknown,
|
||||
): Promise<unknown> {
|
||||
const resp = await this.instance.request({
|
||||
url: `/api/v1/plugins/${author}/${name}/page-api`,
|
||||
method: 'POST',
|
||||
data: {
|
||||
page_id: pageId,
|
||||
endpoint,
|
||||
method,
|
||||
body,
|
||||
},
|
||||
});
|
||||
return resp.data?.data;
|
||||
}
|
||||
|
||||
public getPluginIconURL(author: string, name: string): string {
|
||||
if (this.instance.defaults.baseURL === '/') {
|
||||
const url = window.location.href;
|
||||
|
||||
@@ -5,6 +5,7 @@ const enUS = {
|
||||
installedPlugins: 'Installed Plugins',
|
||||
pluginMarket: 'Marketplace',
|
||||
mcpServers: 'MCP Servers',
|
||||
pluginPages: 'Extension Pages',
|
||||
quickStart: 'Quick Start',
|
||||
},
|
||||
common: {
|
||||
@@ -488,6 +489,7 @@ const enUS = {
|
||||
Command: 'Command',
|
||||
KnowledgeEngine: 'Knowledge Engine',
|
||||
Parser: 'Parser',
|
||||
Page: 'Page',
|
||||
},
|
||||
uploadLocal: 'Upload Local',
|
||||
debugging: 'Debugging',
|
||||
@@ -1291,6 +1293,10 @@ const enUS = {
|
||||
backToWorkbench: 'Back to Workbench',
|
||||
},
|
||||
},
|
||||
pluginPages: {
|
||||
selectFromSidebar: 'Select an extension page from the sidebar',
|
||||
invalidPage: 'Invalid extension page',
|
||||
},
|
||||
};
|
||||
|
||||
export default enUS;
|
||||
|
||||
@@ -500,6 +500,7 @@ const esES = {
|
||||
Command: 'Comando',
|
||||
KnowledgeEngine: 'Motor de conocimiento',
|
||||
Parser: 'Analizador',
|
||||
Page: 'Página',
|
||||
},
|
||||
uploadLocal: 'Subir local',
|
||||
debugging: 'Depuración',
|
||||
|
||||
@@ -492,6 +492,7 @@
|
||||
Command: 'コマンド',
|
||||
KnowledgeEngine: '知識エンジン',
|
||||
Parser: 'パーサー',
|
||||
Page: 'ページ',
|
||||
},
|
||||
uploadLocal: 'ローカルアップロード',
|
||||
debugging: 'デバッグ中',
|
||||
|
||||
@@ -482,6 +482,7 @@ const thTH = {
|
||||
Command: 'คำสั่ง',
|
||||
KnowledgeEngine: 'เครื่องมือความรู้',
|
||||
Parser: 'ตัวแยกวิเคราะห์',
|
||||
Page: 'หน้า',
|
||||
},
|
||||
uploadLocal: 'อัปโหลดจากเครื่อง',
|
||||
debugging: 'ดีบัก',
|
||||
|
||||
@@ -492,6 +492,7 @@ const viVN = {
|
||||
Command: 'Lệnh',
|
||||
KnowledgeEngine: 'Công cụ tri thức',
|
||||
Parser: 'Trình phân tích',
|
||||
Page: 'Trang',
|
||||
},
|
||||
uploadLocal: 'Tải lên cục bộ',
|
||||
debugging: 'Gỡ lỗi',
|
||||
|
||||
@@ -5,6 +5,7 @@ const zhHans = {
|
||||
installedPlugins: '已安装插件',
|
||||
pluginMarket: '插件市场',
|
||||
mcpServers: 'MCP 服务器',
|
||||
pluginPages: '扩展页',
|
||||
quickStart: '快速开始向导',
|
||||
},
|
||||
common: {
|
||||
@@ -465,6 +466,7 @@ const zhHans = {
|
||||
Command: '命令',
|
||||
KnowledgeEngine: '知识引擎',
|
||||
Parser: '解析器',
|
||||
Page: '扩展页',
|
||||
},
|
||||
uploadLocal: '本地上传',
|
||||
debugging: '调试中',
|
||||
@@ -1233,6 +1235,10 @@ const zhHans = {
|
||||
backToWorkbench: '返回工作台',
|
||||
},
|
||||
},
|
||||
pluginPages: {
|
||||
selectFromSidebar: '从侧边栏选择一个扩展页',
|
||||
invalidPage: '无效的扩展页',
|
||||
},
|
||||
};
|
||||
|
||||
export default zhHans;
|
||||
|
||||
@@ -466,6 +466,7 @@ const zhHant = {
|
||||
Command: '命令',
|
||||
KnowledgeEngine: '知識引擎',
|
||||
Parser: '解析器',
|
||||
Page: '擴展頁',
|
||||
},
|
||||
uploadLocal: '本地上傳',
|
||||
debugging: '調試中',
|
||||
|
||||
@@ -21,6 +21,7 @@ import PluginsPage from '@/app/home/plugins/page';
|
||||
import MarketPage from '@/app/home/market/page';
|
||||
import MCPPage from '@/app/home/mcp/page';
|
||||
import KnowledgePage from '@/app/home/knowledge/page';
|
||||
import PluginPagesPage from '@/app/home/plugin-pages/page';
|
||||
|
||||
const Loading = () => <div>Loading...</div>;
|
||||
|
||||
@@ -141,4 +142,14 @@ export const router = createBrowserRouter([
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/home/plugin-pages',
|
||||
element: (
|
||||
<Suspense fallback={<Loading />}>
|
||||
<HomeLayout>
|
||||
<PluginPagesPage />
|
||||
</HomeLayout>
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user