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:
Junyan Qin
2026-04-24 17:19:45 +08:00
parent 195f6efeff
commit 12df9d6ee9
17 changed files with 572 additions and 95 deletions

View File

@@ -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 */}

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -500,6 +500,7 @@ const esES = {
Command: 'Comando',
KnowledgeEngine: 'Motor de conocimiento',
Parser: 'Analizador',
Page: 'Página',
},
uploadLocal: 'Subir local',
debugging: 'Depuración',

View File

@@ -492,6 +492,7 @@
Command: 'コマンド',
KnowledgeEngine: '知識エンジン',
Parser: 'パーサー',
Page: 'ページ',
},
uploadLocal: 'ローカルアップロード',
debugging: 'デバッグ中',

View File

@@ -482,6 +482,7 @@ const thTH = {
Command: 'คำสั่ง',
KnowledgeEngine: 'เครื่องมือความรู้',
Parser: 'ตัวแยกวิเคราะห์',
Page: 'หน้า',
},
uploadLocal: 'อัปโหลดจากเครื่อง',
debugging: 'ดีบัก',

View File

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

View File

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

View File

@@ -466,6 +466,7 @@ const zhHant = {
Command: '命令',
KnowledgeEngine: '知識引擎',
Parser: '解析器',
Page: '擴展頁',
},
uploadLocal: '本地上傳',
debugging: '調試中',

View File

@@ -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>
),
},
]);