diff --git a/.gitignore b/.gitignore index 48f20b6a..d0fe6acb 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,7 @@ plugins.bak coverage.xml .coverage src/langbot/web/ +testsdk/ # Build artifacts /dist diff --git a/src/langbot/pkg/api/http/controller/groups/plugins.py b/src/langbot/pkg/api/http/controller/groups/plugins.py index c4d28bb4..5a565997 100644 --- a/src/langbot/pkg/api/http/controller/groups/plugins.py +++ b/src/langbot/pkg/api/http/controller/groups/plugins.py @@ -6,11 +6,38 @@ import re import httpx import uuid import os +import posixpath from .....core import taskmgr from .. import group 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') class PluginsRouterGroup(group.RouterGroup): @@ -27,6 +54,15 @@ class PluginsRouterGroup(group.RouterGroup): return 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) async def _() -> str: plugins = await self.ap.plugin_connector.list_plugins() @@ -135,15 +171,62 @@ class PluginsRouterGroup(group.RouterGroup): return quart.Response(icon_data, mimetype=mime_type) @self.route( - '///assets/', + '///assets/', methods=['GET'], auth_type=group.AuthType.NONE, ) 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']) 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( + '///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) async def _() -> str: diff --git a/src/langbot/pkg/plugin/connector.py b/src/langbot/pkg/plugin/connector.py index 5d3236e0..90be428c 100644 --- a/src/langbot/pkg/plugin/connector.py +++ b/src/langbot/pkg/plugin/connector.py @@ -431,6 +431,17 @@ class PluginRuntimeConnector: 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) + 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]: """Get debug information including debug key and WS URL""" if not self.is_enable_plugin: diff --git a/src/langbot/pkg/plugin/handler.py b/src/langbot/pkg/plugin/handler.py index 8236126b..58d6a4aa 100644 --- a/src/langbot/pkg/plugin/handler.py +++ b/src/langbot/pkg/plugin/handler.py @@ -939,6 +939,11 @@ class RuntimeConnectionHandler(handler.Handler): timeout=20, ) asset_file_key = result['file_file_key'] + if not asset_file_key: + return { + 'asset_base64': '', + 'mime_type': '', + } mime_type = result['mime_type'] asset_bytes = await self.read_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, } + 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: """Cleanup plugin settings and binary storage""" # Delete plugin settings diff --git a/web/src/app/home/components/home-sidebar/HomeSidebar.tsx b/web/src/app/home/components/home-sidebar/HomeSidebar.tsx index 88fcc137..cdc330a9 100644 --- a/web/src/app/home/components/home-sidebar/HomeSidebar.tsx +++ b/web/src/app/home/components/home-sidebar/HomeSidebar.tsx @@ -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({ > { - if (isCollapseOnly) { - onSectionToggle(config.id, !isOpen); - } else { - onChildClick(config); - } - }} tooltip={config.name} className="group/category-header" > - {config.icon} - {config.name} -
- {canCreate && - (isPlugin ? ( - - - - - - {systemInfo.enable_marketplace && ( +
{ + 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} + {config.name} +
+ {canCreate && + (isPlugin ? ( + + + + + + {systemInfo.enable_marketplace && ( + { + e.stopPropagation(); + navigate('/home/market'); + }} + > + + {t('plugins.goToMarketplace')} + + )} { e.stopPropagation(); - navigate('/home/market'); + setPendingPluginInstallAction('local'); + navigate('/home/plugins'); }} > - - {t('plugins.goToMarketplace')} + + {t('plugins.uploadLocal')} - )} - { - e.stopPropagation(); - setPendingPluginInstallAction('local'); - navigate('/home/plugins'); - }} - > - - {t('plugins.uploadLocal')} - - { - e.stopPropagation(); - setPendingPluginInstallAction('github'); - navigate('/home/plugins'); - }} - > - - {t('plugins.installFromGithub')} - - - - ) : ( + { + e.stopPropagation(); + setPendingPluginInstallAction('github'); + navigate('/home/plugins'); + }} + > + + {t('plugins.installFromGithub')} + + + + ) : ( + + ))} + - ))} - - - + +
@@ -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 ( + + {t('sidebar.pluginPages')} + + + {pluginPages.map((page) => { + const isActive = currentId === page.id; + const route = `/home/plugin-pages?id=${encodeURIComponent(page.id)}`; + return ( + + navigate(route)} + > + + {page.name} + + + ); + })} + + + + ); +} + export default function HomeSidebar({ onSelectedChangeAction, }: { @@ -1285,6 +1343,7 @@ export default function HomeSidebar({ + {/* Footer */} diff --git a/web/src/app/home/components/home-sidebar/SidebarDataContext.tsx b/web/src/app/home/components/home-sidebar/SidebarDataContext.tsx index 24077cb9..fb917f89 100644 --- a/web/src/app/home/components/home-sidebar/SidebarDataContext.tsx +++ b/web/src/app/home/components/home-sidebar/SidebarDataContext.tsx @@ -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; refreshPipelines: () => Promise; refreshKnowledgeBases: () => Promise; @@ -64,6 +76,7 @@ export function SidebarDataProvider({ const [knowledgeBases, setKnowledgeBases] = useState([]); const [plugins, setPlugins] = useState([]); const [mcpServers, setMCPServers] = useState([]); + const [pluginPages, setPluginPages] = useState([]); const [detailEntityName, setDetailEntityName] = useState(null); const [pendingPluginInstallAction, setPendingPluginInstallAction] = useState(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(); + 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(); + 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, diff --git a/web/src/app/home/layout.tsx b/web/src/app/home/layout.tsx index 55a66de7..71985679 100644 --- a/web/src/app/home/layout.tsx +++ b/web/src/app/home/layout.tsx @@ -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( diff --git a/web/src/app/home/plugin-pages/page.tsx b/web/src/app/home/plugin-pages/page.tsx new file mode 100644 index 00000000..73b4812d --- /dev/null +++ b/web/src/app/home/plugin-pages/page.tsx @@ -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 ( +
+ {t('pluginPages.selectFromSidebar')} +
+ ); + } + + // Parse "author/name/pageId" + const parts = id.split('/'); + if (parts.length < 3) { + return ( +
+ {t('pluginPages.invalidPage')} +
+ ); + } + + 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 ( + + ); +} + +function PluginPageIframe({ + author, + pluginName, + pagePath, + pageId, +}: { + author: string; + pluginName: string; + pagePath: string; + pageId: string; +}) { + const iframeRef = useRef(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 ( +
+ {loading && ( +
+ Loading... +
+ )} +