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

1
.gitignore vendored
View File

@@ -47,6 +47,7 @@ plugins.bak
coverage.xml coverage.xml
.coverage .coverage
src/langbot/web/ src/langbot/web/
testsdk/
# Build artifacts # Build artifacts
/dist /dist

View File

@@ -6,11 +6,38 @@ import re
import httpx import httpx
import uuid import uuid
import os import os
import posixpath
from .....core import taskmgr from .....core import taskmgr
from .. import group from .. import group
from langbot_plugin.runtime.plugin.mgr import PluginInstallSource from langbot_plugin.runtime.plugin.mgr import PluginInstallSource
# Resolve the built-in page SDK JS from the langbot_plugin package
_PAGE_SDK_PATH = None
try:
import langbot_plugin.assets as _assets_pkg
_candidate = os.path.join(os.path.dirname(_assets_pkg.__file__), 'langbot-page-sdk.js')
if os.path.exists(_candidate):
_PAGE_SDK_PATH = _candidate
except Exception:
pass
def _normalize_plugin_asset_path(filepath: str) -> str | None:
filepath = filepath.replace('\\', '/')
if filepath.startswith('/'):
return None
normalized = posixpath.normpath(filepath)
if normalized == '.' or normalized.startswith('../') or normalized == '..':
return None
if normalized.startswith('components/pages/'):
return normalized
return f'assets/{normalized}'
@group.group_class('plugins', '/api/v1/plugins') @group.group_class('plugins', '/api/v1/plugins')
class PluginsRouterGroup(group.RouterGroup): class PluginsRouterGroup(group.RouterGroup):
@@ -27,6 +54,15 @@ class PluginsRouterGroup(group.RouterGroup):
return None return None
async def initialize(self) -> None: async def initialize(self) -> None:
@self.route('/_sdk/page-sdk.js', methods=['GET'], auth_type=group.AuthType.NONE)
async def _() -> quart.Response:
"""Serve the built-in LangBot page SDK JavaScript."""
if _PAGE_SDK_PATH and os.path.exists(_PAGE_SDK_PATH):
with open(_PAGE_SDK_PATH, 'r') as f:
content = f.read()
return quart.Response(content, mimetype='application/javascript')
return quart.Response('// SDK not found', status=404, mimetype='application/javascript')
@self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) @self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _() -> str: async def _() -> str:
plugins = await self.ap.plugin_connector.list_plugins() plugins = await self.ap.plugin_connector.list_plugins()
@@ -135,15 +171,62 @@ class PluginsRouterGroup(group.RouterGroup):
return quart.Response(icon_data, mimetype=mime_type) return quart.Response(icon_data, mimetype=mime_type)
@self.route( @self.route(
'/<author>/<plugin_name>/assets/<filepath>', '/<author>/<plugin_name>/assets/<path:filepath>',
methods=['GET'], methods=['GET'],
auth_type=group.AuthType.NONE, auth_type=group.AuthType.NONE,
) )
async def _(author: str, plugin_name: str, filepath: str) -> quart.Response: async def _(author: str, plugin_name: str, filepath: str) -> quart.Response:
asset_data = await self.ap.plugin_connector.get_plugin_assets(author, plugin_name, filepath) asset_path = _normalize_plugin_asset_path(filepath)
if asset_path is None:
return quart.Response('Asset not found', status=404)
asset_data = await self.ap.plugin_connector.get_plugin_assets(author, plugin_name, asset_path)
if not asset_data.get('asset_base64'):
return quart.Response('Asset not found', status=404)
asset_bytes = base64.b64decode(asset_data['asset_base64']) asset_bytes = base64.b64decode(asset_data['asset_base64'])
mime_type = asset_data['mime_type'] mime_type = asset_data['mime_type']
return quart.Response(asset_bytes, mimetype=mime_type) resp = quart.Response(asset_bytes, mimetype=mime_type)
# CSP for HTML pages served to sandboxed iframes (opaque origin).
# 'self' doesn't work in sandboxed iframes — use actual server origin.
if mime_type and mime_type.startswith('text/html'):
origin = f'{quart.request.scheme}://{quart.request.host}'
resp.headers['Content-Security-Policy'] = (
f'default-src {origin}; '
f"script-src {origin} 'unsafe-inline'; "
f"style-src {origin} 'unsafe-inline'; "
f'img-src {origin} data:; '
f'connect-src {origin}; '
"frame-src 'none'; "
"object-src 'none'"
)
return resp
@self.route(
'/<author>/<plugin_name>/page-api',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
)
async def _(author: str, plugin_name: str) -> str:
"""Forward a page API request to the plugin."""
data = await quart.request.json
if not isinstance(data, dict):
return self.http_status(400, -1, 'invalid request body')
page_id = data.get('page_id', '')
endpoint = data.get('endpoint', '')
method = data.get('method', 'POST')
body = data.get('body')
if not isinstance(page_id, str) or not isinstance(endpoint, str) or not isinstance(method, str):
return self.http_status(400, -1, 'invalid page api request')
if not endpoint.startswith('/') or '..' in endpoint:
return self.http_status(400, -1, 'invalid endpoint')
result = await self.ap.plugin_connector.handle_page_api(
author, plugin_name, page_id, endpoint, method.upper(), body
)
if result.get('error'):
return self.http_status(400, -1, result['error'])
return self.success(data=result.get('data'))
@self.route('/github/releases', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) @self.route('/github/releases', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _() -> str: async def _() -> str:

View File

@@ -431,6 +431,17 @@ class PluginRuntimeConnector:
async def get_plugin_assets(self, plugin_author: str, plugin_name: str, filepath: str) -> dict[str, Any]: async def get_plugin_assets(self, plugin_author: str, plugin_name: str, filepath: str) -> dict[str, Any]:
return await self.handler.get_plugin_assets(plugin_author, plugin_name, filepath) return await self.handler.get_plugin_assets(plugin_author, plugin_name, filepath)
async def handle_page_api(
self,
plugin_author: str,
plugin_name: str,
page_id: str,
endpoint: str,
method: str,
body: Any = None,
) -> dict[str, Any]:
return await self.handler.handle_page_api(plugin_author, plugin_name, page_id, endpoint, method, body)
async def get_debug_info(self) -> dict[str, Any]: async def get_debug_info(self) -> dict[str, Any]:
"""Get debug information including debug key and WS URL""" """Get debug information including debug key and WS URL"""
if not self.is_enable_plugin: if not self.is_enable_plugin:

View File

@@ -939,6 +939,11 @@ class RuntimeConnectionHandler(handler.Handler):
timeout=20, timeout=20,
) )
asset_file_key = result['file_file_key'] asset_file_key = result['file_file_key']
if not asset_file_key:
return {
'asset_base64': '',
'mime_type': '',
}
mime_type = result['mime_type'] mime_type = result['mime_type']
asset_bytes = await self.read_local_file(asset_file_key) asset_bytes = await self.read_local_file(asset_file_key)
await self.delete_local_file(asset_file_key) await self.delete_local_file(asset_file_key)
@@ -947,6 +952,30 @@ class RuntimeConnectionHandler(handler.Handler):
'mime_type': mime_type, 'mime_type': mime_type,
} }
async def handle_page_api(
self,
plugin_author: str,
plugin_name: str,
page_id: str,
endpoint: str,
method: str,
body: Any = None,
) -> dict[str, Any]:
"""Forward a page API call to the plugin via runtime."""
result = await self.call_action(
LangBotToRuntimeAction.PAGE_API,
{
'plugin_author': plugin_author,
'plugin_name': plugin_name,
'page_id': page_id,
'endpoint': endpoint,
'method': method,
'body': body,
},
timeout=30,
)
return result
async def cleanup_plugin_data(self, plugin_author: str, plugin_name: str) -> None: async def cleanup_plugin_data(self, plugin_author: str, plugin_name: str) -> None:
"""Cleanup plugin settings and binary storage""" """Cleanup plugin settings and binary storage"""
# Delete plugin settings # Delete plugin settings

View File

@@ -92,6 +92,7 @@ import {
} from '@/components/ui/popover'; } from '@/components/ui/popover';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useSidebarData, SidebarEntityItem } from './SidebarDataContext'; import { useSidebarData, SidebarEntityItem } from './SidebarDataContext';
import { LayoutDashboard } from 'lucide-react';
// Compare two version strings, returns true if v1 > v2 // Compare two version strings, returns true if v1 > v2
function compareVersions(v1: string, v2: string): boolean { function compareVersions(v1: string, v2: string): boolean {
@@ -699,87 +700,103 @@ function NavItems({
> >
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton <SidebarMenuButton
asChild
isActive={false} isActive={false}
onClick={() => {
if (isCollapseOnly) {
onSectionToggle(config.id, !isOpen);
} else {
onChildClick(config);
}
}}
tooltip={config.name} tooltip={config.name}
className="group/category-header" className="group/category-header"
> >
{config.icon} <div
<span>{config.name}</span> role="button"
<div className="ml-auto flex items-center gap-0.5 -mr-1"> tabIndex={0}
{canCreate && onClick={() => {
(isPlugin ? ( if (isCollapseOnly) {
<DropdownMenu> onSectionToggle(config.id, !isOpen);
<DropdownMenuTrigger asChild> } else {
<button onChildClick(config);
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()} onKeyDown={(e) => {
> if (e.key === 'Enter' || e.key === ' ') {
<Plus className="size-3.5" /> e.preventDefault();
</button> if (isCollapseOnly) {
</DropdownMenuTrigger> onSectionToggle(config.id, !isOpen);
<DropdownMenuContent align="end"> } else {
{systemInfo.enable_marketplace && ( 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 <DropdownMenuItem
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
navigate('/home/market'); setPendingPluginInstallAction('local');
navigate('/home/plugins');
}} }}
> >
<Store className="size-4" /> <Upload className="size-4" />
{t('plugins.goToMarketplace')} {t('plugins.uploadLocal')}
</DropdownMenuItem> </DropdownMenuItem>
)} <DropdownMenuItem
<DropdownMenuItem onClick={(e) => {
onClick={(e) => { e.stopPropagation();
e.stopPropagation(); setPendingPluginInstallAction('github');
setPendingPluginInstallAction('local'); navigate('/home/plugins');
navigate('/home/plugins'); }}
}} >
> <Github className="size-4" />
<Upload className="size-4" /> {t('plugins.installFromGithub')}
{t('plugins.uploadLocal')} </DropdownMenuItem>
</DropdownMenuItem> </DropdownMenuContent>
<DropdownMenuItem </DropdownMenu>
onClick={(e) => { ) : (
e.stopPropagation(); <button
setPendingPluginInstallAction('github'); type="button"
navigate('/home/plugins'); 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();
<Github className="size-4" /> navigate(`${routePrefix}?id=new`);
{t('plugins.installFromGithub')} }}
</DropdownMenuItem> >
</DropdownMenuContent> <Plus className="size-3.5" />
</DropdownMenu> </button>
) : ( ))}
<CollapsibleTrigger asChild>
<button <button
type="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" className="p-1 rounded-sm hover:bg-sidebar-accent"
onClick={(e) => { onClick={(e) => e.stopPropagation()}
e.stopPropagation();
navigate(`${routePrefix}?id=new`);
}}
> >
<Plus className="size-3.5" /> <ChevronRight className="size-4 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
</button> </button>
))} </CollapsibleTrigger>
<CollapsibleTrigger asChild> </div>
<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>
</div> </div>
</SidebarMenuButton> </SidebarMenuButton>
<CollapsibleContent> <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({ export default function HomeSidebar({
onSelectedChangeAction, onSelectedChangeAction,
}: { }: {
@@ -1285,6 +1343,7 @@ export default function HomeSidebar({
</SidebarMenu> </SidebarMenu>
</SidebarGroupContent> </SidebarGroupContent>
</SidebarGroup> </SidebarGroup>
<PluginPagesNav />
</SidebarContent> </SidebarContent>
{/* Footer */} {/* Footer */}

View File

@@ -31,6 +31,17 @@ export interface SidebarEntityItem {
// Install action types that can be triggered from sidebar // Install action types that can be triggered from sidebar
export type PluginInstallAction = 'local' | 'github' | null; 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 // Entity lists and refresh functions exposed via context
export interface SidebarDataContextValue { export interface SidebarDataContextValue {
bots: SidebarEntityItem[]; bots: SidebarEntityItem[];
@@ -38,6 +49,7 @@ export interface SidebarDataContextValue {
knowledgeBases: SidebarEntityItem[]; knowledgeBases: SidebarEntityItem[];
plugins: SidebarEntityItem[]; plugins: SidebarEntityItem[];
mcpServers: SidebarEntityItem[]; mcpServers: SidebarEntityItem[];
pluginPages: PluginPageItem[];
refreshBots: () => Promise<void>; refreshBots: () => Promise<void>;
refreshPipelines: () => Promise<void>; refreshPipelines: () => Promise<void>;
refreshKnowledgeBases: () => Promise<void>; refreshKnowledgeBases: () => Promise<void>;
@@ -64,6 +76,7 @@ export function SidebarDataProvider({
const [knowledgeBases, setKnowledgeBases] = useState<SidebarEntityItem[]>([]); const [knowledgeBases, setKnowledgeBases] = useState<SidebarEntityItem[]>([]);
const [plugins, setPlugins] = useState<SidebarEntityItem[]>([]); const [plugins, setPlugins] = useState<SidebarEntityItem[]>([]);
const [mcpServers, setMCPServers] = useState<SidebarEntityItem[]>([]); const [mcpServers, setMCPServers] = useState<SidebarEntityItem[]>([]);
const [pluginPages, setPluginPages] = useState<PluginPageItem[]>([]);
const [detailEntityName, setDetailEntityName] = useState<string | null>(null); const [detailEntityName, setDetailEntityName] = useState<string | null>(null);
const [pendingPluginInstallAction, setPendingPluginInstallAction] = const [pendingPluginInstallAction, setPendingPluginInstallAction] =
useState<PluginInstallAction>(null); useState<PluginInstallAction>(null);
@@ -137,33 +150,67 @@ export function SidebarDataProvider({
} }
} }
setPlugins( // Deduplicate plugins by composite key (prefer debug over installed)
pluginsResp.plugins.map((plugin) => { const pluginMap = new Map<string, SidebarEntityItem>();
const meta = plugin.manifest.manifest.metadata; for (const plugin of pluginsResp.plugins) {
const author = meta.author ?? ''; const meta = plugin.manifest.manifest.metadata;
const name = meta.name; const author = meta.author ?? '';
const compositeKey = `${author}/${name}`; const name = meta.name;
const installedVersion = meta.version ?? ''; const compositeKey = `${author}/${name}`;
const installedVersion = meta.version ?? '';
let hasUpdate = false; let hasUpdate = false;
if (plugin.install_source === 'marketplace') { if (plugin.install_source === 'marketplace') {
const latestVersion = marketplaceVersions.get(compositeKey); const latestVersion = marketplaceVersions.get(compositeKey);
if (latestVersion) { if (latestVersion) {
hasUpdate = isNewerVersion(latestVersion, installedVersion); 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, setPluginPages(pages);
name: extractI18nObject(meta.label),
iconURL: httpClient.getPluginIconURL(author, name),
installSource: plugin.install_source,
installInfo: plugin.install_info,
hasUpdate,
debug: plugin.debug,
};
}),
);
} catch (error) { } catch (error) {
console.error('Failed to fetch plugins for sidebar:', error); console.error('Failed to fetch plugins for sidebar:', error);
} }
@@ -214,6 +261,7 @@ export function SidebarDataProvider({
knowledgeBases, knowledgeBases,
plugins, plugins,
mcpServers, mcpServers,
pluginPages,
refreshBots, refreshBots,
refreshPipelines, refreshPipelines,
refreshKnowledgeBases, refreshKnowledgeBases,

View File

@@ -44,7 +44,12 @@ import {
} from '@/app/home/plugins/components/plugin-install-task'; } from '@/app/home/plugins/components/plugin-install-task';
// Routes that belong to the "Extensions" section // 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 { function isExtensionsRoute(pathname: string): boolean {
return EXTENSIONS_ROUTES.some( 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 { public getPluginIconURL(author: string, name: string): string {
if (this.instance.defaults.baseURL === '/') { if (this.instance.defaults.baseURL === '/') {
const url = window.location.href; const url = window.location.href;

View File

@@ -5,6 +5,7 @@ const enUS = {
installedPlugins: 'Installed Plugins', installedPlugins: 'Installed Plugins',
pluginMarket: 'Marketplace', pluginMarket: 'Marketplace',
mcpServers: 'MCP Servers', mcpServers: 'MCP Servers',
pluginPages: 'Extension Pages',
quickStart: 'Quick Start', quickStart: 'Quick Start',
}, },
common: { common: {
@@ -488,6 +489,7 @@ const enUS = {
Command: 'Command', Command: 'Command',
KnowledgeEngine: 'Knowledge Engine', KnowledgeEngine: 'Knowledge Engine',
Parser: 'Parser', Parser: 'Parser',
Page: 'Page',
}, },
uploadLocal: 'Upload Local', uploadLocal: 'Upload Local',
debugging: 'Debugging', debugging: 'Debugging',
@@ -1291,6 +1293,10 @@ const enUS = {
backToWorkbench: 'Back to Workbench', backToWorkbench: 'Back to Workbench',
}, },
}, },
pluginPages: {
selectFromSidebar: 'Select an extension page from the sidebar',
invalidPage: 'Invalid extension page',
},
}; };
export default enUS; export default enUS;

View File

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

View File

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

View File

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

View File

@@ -492,6 +492,7 @@ const viVN = {
Command: 'Lệnh', Command: 'Lệnh',
KnowledgeEngine: 'Công cụ tri thức', KnowledgeEngine: 'Công cụ tri thức',
Parser: 'Trình phân tích', Parser: 'Trình phân tích',
Page: 'Trang',
}, },
uploadLocal: 'Tải lên cục bộ', uploadLocal: 'Tải lên cục bộ',
debugging: 'Gỡ lỗi', debugging: 'Gỡ lỗi',

View File

@@ -5,6 +5,7 @@ const zhHans = {
installedPlugins: '已安装插件', installedPlugins: '已安装插件',
pluginMarket: '插件市场', pluginMarket: '插件市场',
mcpServers: 'MCP 服务器', mcpServers: 'MCP 服务器',
pluginPages: '扩展页',
quickStart: '快速开始向导', quickStart: '快速开始向导',
}, },
common: { common: {
@@ -465,6 +466,7 @@ const zhHans = {
Command: '命令', Command: '命令',
KnowledgeEngine: '知识引擎', KnowledgeEngine: '知识引擎',
Parser: '解析器', Parser: '解析器',
Page: '扩展页',
}, },
uploadLocal: '本地上传', uploadLocal: '本地上传',
debugging: '调试中', debugging: '调试中',
@@ -1233,6 +1235,10 @@ const zhHans = {
backToWorkbench: '返回工作台', backToWorkbench: '返回工作台',
}, },
}, },
pluginPages: {
selectFromSidebar: '从侧边栏选择一个扩展页',
invalidPage: '无效的扩展页',
},
}; };
export default zhHans; export default zhHans;

View File

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

View File

@@ -21,6 +21,7 @@ import PluginsPage from '@/app/home/plugins/page';
import MarketPage from '@/app/home/market/page'; import MarketPage from '@/app/home/market/page';
import MCPPage from '@/app/home/mcp/page'; import MCPPage from '@/app/home/mcp/page';
import KnowledgePage from '@/app/home/knowledge/page'; import KnowledgePage from '@/app/home/knowledge/page';
import PluginPagesPage from '@/app/home/plugin-pages/page';
const Loading = () => <div>Loading...</div>; const Loading = () => <div>Loading...</div>;
@@ -141,4 +142,14 @@ export const router = createBrowserRouter([
</Suspense> </Suspense>
), ),
}, },
{
path: '/home/plugin-pages',
element: (
<Suspense fallback={<Loading />}>
<HomeLayout>
<PluginPagesPage />
</HomeLayout>
</Suspense>
),
},
]); ]);