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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
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 {
|
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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -492,6 +492,7 @@
|
|||||||
Command: 'コマンド',
|
Command: 'コマンド',
|
||||||
KnowledgeEngine: '知識エンジン',
|
KnowledgeEngine: '知識エンジン',
|
||||||
Parser: 'パーサー',
|
Parser: 'パーサー',
|
||||||
|
Page: 'ページ',
|
||||||
},
|
},
|
||||||
uploadLocal: 'ローカルアップロード',
|
uploadLocal: 'ローカルアップロード',
|
||||||
debugging: 'デバッグ中',
|
debugging: 'デバッグ中',
|
||||||
|
|||||||
@@ -482,6 +482,7 @@ const thTH = {
|
|||||||
Command: 'คำสั่ง',
|
Command: 'คำสั่ง',
|
||||||
KnowledgeEngine: 'เครื่องมือความรู้',
|
KnowledgeEngine: 'เครื่องมือความรู้',
|
||||||
Parser: 'ตัวแยกวิเคราะห์',
|
Parser: 'ตัวแยกวิเคราะห์',
|
||||||
|
Page: 'หน้า',
|
||||||
},
|
},
|
||||||
uploadLocal: 'อัปโหลดจากเครื่อง',
|
uploadLocal: 'อัปโหลดจากเครื่อง',
|
||||||
debugging: 'ดีบัก',
|
debugging: 'ดีบัก',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -466,6 +466,7 @@ const zhHant = {
|
|||||||
Command: '命令',
|
Command: '命令',
|
||||||
KnowledgeEngine: '知識引擎',
|
KnowledgeEngine: '知識引擎',
|
||||||
Parser: '解析器',
|
Parser: '解析器',
|
||||||
|
Page: '擴展頁',
|
||||||
},
|
},
|
||||||
uploadLocal: '本地上傳',
|
uploadLocal: '本地上傳',
|
||||||
debugging: '調試中',
|
debugging: '調試中',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
),
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
Reference in New Issue
Block a user