feat(iframe): add iframe bridge for ragflow integration

- Implement Penpal-based iframe communication bridge between host and child apps
- Add route handling for ragflow integration with '/route-ragflow' prefix
- Update navigation hooks to support embedded mode via iframe bridge
- Configure build and dependencies for new iframe-bridge package
- Adjust nginx config for proper SPA routing in subpath deployments
This commit is contained in:
2025-11-10 16:11:21 +08:00
parent 81fa34669a
commit a3ff72e575
17 changed files with 377 additions and 37137 deletions

View File

@@ -50,7 +50,9 @@ server {
# ragflow_webUmi部署在子路径支持 SPA 路由
location ${RAGFLOW_BASE} {
alias /usr/share/nginx/html/ragflow/;
try_files \$uri \$uri/ /index.html;
# 注意使用别名目录时SPA 回退必须指向子路径下的 index.html
# 否则会错误地回退到根应用的 index.html导致页面刷新后空白或错路由
try_files \$uri \$uri/ ${RAGFLOW_BASE}index.html;
}
# 静态资源缓存

View File

@@ -5,12 +5,14 @@
"type": "module",
"scripts": {
"dev": "vite",
"dev:both": "pnpm -r --parallel --filter teres_web_frontend --filter ragflow_web run dev",
"dev:ragflow": "pnpm -r --filter ragflow_web run dev",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@teres/iframe-bridge": "workspace:*",
"penpal": "^6.2.1",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.3.4",

View File

@@ -7,6 +7,9 @@
"main": "dist/index.js",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc -b"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
@@ -29,5 +32,8 @@
"penpal": {
"optional": true
}
},
"devDependencies": {
"typescript": "~5.9.3"
}
}

View File

@@ -0,0 +1,158 @@
/**
* @teres/iframe-bridge
* Penpal-based utilities for host ↔ iframe communication,
* plus minimal helpers for embed detection and path conversion.
*/
import { connectToChild, connectToParent } from 'penpal';
// Penpal RPC API definitions
export interface HostApi {
navigate: (to: string) => void;
close: () => void;
agentReady?: (payload?: any) => void;
}
export interface ChildApi {
navigate?: (to: string) => void;
close?: () => void;
ready?: (payload?: any) => void;
}
// Unified logger for debugging host/child interactions
const LOG_PREFIX = '[@teres/iframe-bridge]';
function log(...args: any[]) {
try {
// eslint-disable-next-line no-console
console.log(LOG_PREFIX, ...args);
} catch {}
}
function safePathname(): string {
try {
const p = window.location?.pathname || '';
log('safePathname()', p);
return p;
} catch {
log('safePathname() error, return empty pathname');
return '';
}
}
function isInIframe(): boolean {
try {
const inIframe = window.self !== window.top;
log('isInIframe()', inIframe);
return inIframe;
} catch {
// Cross-origin access throws, treat as embedded
log('isInIframe() cross-origin error, treat as embedded');
return true;
}
}
export function isEmbedded(): boolean {
const path = safePathname();
const inIframe = isInIframe();
const embedded = path.includes('/route-ragflow') || inIframe;
log('isEmbedded()', { path, inIframe, embedded });
return embedded;
}
/** Add "/ragflow" prefix when targeting host routes. */
export function toHostPath(to: string): string {
const normalized = to.startsWith('/') ? to : `/${to}`;
const target = normalized.startsWith('/route-ragflow') ? normalized : `/ragflow${normalized}`;
log('toHostPath()', { input: to, normalized, target });
return target;
}
/** Remove leading "/route-ragflow" prefix when targeting child routes. */
export function toChildPath(to: string): string {
const target = to.replace(/^\/route-ragflow/, '') || '/';
log('toChildPath()', { input: to, target });
return target;
}
// Cached Penpal connections
let clientHostApiPromise: Promise<HostApi> | null = null;
/** Create a Penpal connection from child to parent and cache the Host API. */
export function getClientHostApi(options?: { parentOrigin?: string }): Promise<HostApi> {
if (!isEmbedded()) {
log('getClientHostApi() aborted: not embedded');
return Promise.reject(new Error('Not embedded'));
}
if (!clientHostApiPromise) {
const parentOrigin = options?.parentOrigin ?? window.location.origin;
log('getClientHostApi() connecting to parent', { parentOrigin });
clientHostApiPromise = connectToParent<HostApi>({ parentOrigin }).promise.then((host) => {
log('getClientHostApi() connected', {
hasNavigate: typeof host.navigate === 'function',
hasClose: typeof host.close === 'function',
});
return host;
});
}
return clientHostApiPromise;
}
export async function clientNavigate(to: string): Promise<void> {
log('clientNavigate() begin', { to });
const host = await getClientHostApi();
await host.navigate(to);
log('clientNavigate() done', { to });
}
export async function clientClose(): Promise<void> {
log('clientClose() begin');
const host = await getClientHostApi();
log('clientClose() host api', {
hasClose: typeof host.close === 'function',
});
await host.close();
log('clientClose() done');
}
export async function clientReady(payload?: any): Promise<void> {
log('clientReady() begin', { payload });
const host = await getClientHostApi();
const agentReady = host.agentReady;
const hasAgentReady = typeof agentReady === 'function';
log('clientReady() host agentReady', { hasAgentReady });
if (hasAgentReady) await agentReady!(payload);
log('clientReady() done');
}
/** Establish Penpal host bridge bound to an iframe and expose Host API to child. */
export function createPenpalHostBridge(params: {
iframe: HTMLIFrameElement;
origin?: string;
methods: HostApi;
}) {
const childOrigin = params.origin ?? window.location.origin;
log('createPenpalHostBridge() start', { childOrigin });
const connection = connectToChild<ChildApi>({ iframe: params.iframe, childOrigin, methods: params.methods as any });
connection.promise.then(() => log('createPenpalHostBridge() child connected')).catch((err) => log('createPenpalHostBridge() connect error', err));
return {
child: connection.promise,
destroy: () => {
log('createPenpalHostBridge() destroy');
return connection.destroy?.();
},
};
}
// Provide a default export with all APIs to maximize compatibility with bundlers
// that prefer default-only federation or mis-handle named ESM exports in dev.
const bridge = {
isEmbedded,
toHostPath,
toChildPath,
getClientHostApi,
clientNavigate,
clientClose,
clientReady,
createPenpalHostBridge,
};
export default bridge;

View File

@@ -0,0 +1,15 @@
{
"extends": "../../tsconfig.node.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"tsBuildInfoFile": "./dist/.tsbuildinfo",
"composite": true,
"noEmit": false,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowImportingTsExtensions": false,
"declaration": true,
"declarationMap": true
},
"include": ["src"]
}

16
pnpm-lock.yaml generated
View File

@@ -26,6 +26,9 @@ importers:
'@mui/x-date-pickers':
specifier: ^8.14.0
version: 8.14.0(@emotion/react@11.14.0(@types/react@19.2.2)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@18.3.1))(@types/react@19.2.2)(react@18.3.1))(@mui/material@7.3.4(@emotion/react@11.14.0(@types/react@19.2.2)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@18.3.1))(@types/react@19.2.2)(react@18.3.1))(@types/react@19.2.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mui/system@7.3.3(@emotion/react@11.14.0(@types/react@19.2.2)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@18.3.1))(@types/react@19.2.2)(react@18.3.1))(@types/react@19.2.2)(react@18.3.1))(@types/react@19.2.2)(date-fns@4.1.0)(dayjs@1.11.18)(moment@2.30.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@teres/iframe-bridge':
specifier: workspace:*
version: link:packages/iframe-bridge
'@xyflow/react':
specifier: ^12.8.6
version: 12.8.6(@types/react@19.2.2)(immer@10.2.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -56,6 +59,9 @@ importers:
loglevel:
specifier: ^1.9.2
version: 1.9.2
penpal:
specifier: ^6.2.1
version: 6.2.2
react:
specifier: ^18.3.1
version: 18.3.1
@@ -160,6 +166,10 @@ importers:
penpal:
specifier: ^6.2.1
version: 6.2.2
devDependencies:
typescript:
specifier: ~5.9.3
version: 5.9.3
packages/shared-auth: {}
@@ -291,6 +301,9 @@ importers:
'@tanstack/react-table':
specifier: ^8.20.5
version: 8.21.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@teres/iframe-bridge':
specifier: workspace:*
version: link:../packages/iframe-bridge
'@uiw/react-markdown-preview':
specifier: ^5.1.3
version: 5.1.5(@types/react@18.3.26)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -375,6 +388,9 @@ importers:
openai-speech-stream-player:
specifier: ^1.0.8
version: 1.0.8
penpal:
specifier: ^6.2.1
version: 6.2.2
pptx-preview:
specifier: ^1.0.5
version: 1.0.7

View File

@@ -0,0 +1,3 @@
PORT=9222
RAGFLOW_BASE=/ragflow/
UMI_APP_API_BASE_URL=http://150.158.121.95

View File

@@ -11,6 +11,7 @@ export default defineConfig({
outputPath: 'dist',
alias: { '@parent': path.resolve(__dirname, '../') },
npmClient: 'pnpm',
mfsu: false,
base: RAGFLOW_BASE,
routes,
publicPath: RAGFLOW_BASE,

File diff suppressed because it is too large Load Diff

View File

@@ -21,6 +21,8 @@
]
},
"dependencies": {
"@teres/iframe-bridge": "workspace:*",
"penpal": "^6.2.1",
"@ant-design/icons": "^5.2.6",
"@ant-design/pro-components": "^2.6.46",
"@ant-design/pro-layout": "^7.17.16",

View File

@@ -2,6 +2,7 @@ import { AgentCategory, AgentQuery } from '@/constants/agent';
import { NavigateToDataflowResultProps } from '@/pages/dataflow-result/interface';
import { Routes } from '@/routes';
import { useCallback } from 'react';
import Bridge from '@teres/iframe-bridge';
import { useNavigate, useParams, useSearchParams } from 'umi';
export enum QueryStringMap {
@@ -14,106 +15,131 @@ export const useNavigatePage = () => {
const [searchParams] = useSearchParams();
const { id } = useParams();
// 统一由 @teres/iframe-bridge 提供嵌入判断与消息封装
// 统一的跳转封装:嵌入模式下向父页面发送消息,否则使用内部 navigate
const navigateOrPost = useCallback(
(to: string, options?: { hostAgents?: boolean }) => {
console.log('Bridge: ', Bridge);
console.log('Bridge.isEmbedded', Bridge.isEmbedded());
if (Bridge.isEmbedded()) {
if (options?.hostAgents) {
// 返回到宿主应用的 /agents
if (typeof Bridge.clientClose === 'function') {
Bridge.clientClose();
} else {
Bridge.clientNavigate(Bridge.toHostPath(Routes.Agents));
}
return;
}
Bridge.clientNavigate(Bridge.toHostPath(to));
return;
}
navigate(to);
},
[navigate],
);
const navigateToDatasetList = useCallback(() => {
navigate(Routes.Datasets);
}, [navigate]);
navigateOrPost(Routes.Datasets);
}, [navigateOrPost]);
const navigateToDataset = useCallback(
(id: string) => () => {
// navigate(`${Routes.DatasetBase}${Routes.DataSetOverview}/${id}`);
navigate(`${Routes.Dataset}/${id}`);
navigateOrPost(`${Routes.Dataset}/${id}`);
},
[navigate],
[navigateOrPost],
);
const navigateToDatasetOverview = useCallback(
(id: string) => () => {
navigate(`${Routes.DatasetBase}${Routes.DataSetOverview}/${id}`);
navigateOrPost(`${Routes.DatasetBase}${Routes.DataSetOverview}/${id}`);
},
[navigate],
[navigateOrPost],
);
const navigateToDataFile = useCallback(
(id: string) => () => {
navigate(`${Routes.DatasetBase}${Routes.DatasetBase}/${id}`);
navigateOrPost(`${Routes.DatasetBase}${Routes.DatasetBase}/${id}`);
},
[navigate],
[navigateOrPost],
);
const navigateToHome = useCallback(() => {
navigate(Routes.Root);
}, [navigate]);
navigateOrPost(Routes.Root);
}, [navigateOrPost]);
const navigateToProfile = useCallback(() => {
navigate(Routes.ProfileSetting);
}, [navigate]);
navigateOrPost(Routes.ProfileSetting);
}, [navigateOrPost]);
const navigateToOldProfile = useCallback(() => {
navigate(Routes.UserSetting);
}, [navigate]);
navigateOrPost(Routes.UserSetting);
}, [navigateOrPost]);
const navigateToChatList = useCallback(() => {
navigate(Routes.Chats);
}, [navigate]);
navigateOrPost(Routes.Chats);
}, [navigateOrPost]);
const navigateToChat = useCallback(
(id: string) => () => {
navigate(`${Routes.Chat}/${id}`);
navigateOrPost(`${Routes.Chat}/${id}`);
},
[navigate],
[navigateOrPost],
);
const navigateToAgents = useCallback(() => {
navigate(Routes.Agents);
}, [navigate]);
// 嵌入模式下返回宿主应用的 /agents独立模式跳转 ragflow 内部 /agents
navigateOrPost(Routes.Agents, { hostAgents: true });
}, [navigateOrPost]);
const navigateToAgentList = useCallback(() => {
navigate(Routes.AgentList);
}, [navigate]);
navigateOrPost(Routes.AgentList);
}, [navigateOrPost]);
const navigateToAgent = useCallback(
(id: string, category?: AgentCategory) => () => {
navigate(`${Routes.Agent}/${id}?${AgentQuery.Category}=${category}`);
navigateOrPost(`${Routes.Agent}/${id}?${AgentQuery.Category}=${category}`);
},
[navigate],
[navigateOrPost],
);
const navigateToDataflow = useCallback(
(id: string) => () => {
navigate(`${Routes.DataFlow}/${id}`);
navigateOrPost(`${Routes.DataFlow}/${id}`);
},
[navigate],
[navigateOrPost],
);
const navigateToAgentLogs = useCallback(
(id: string) => () => {
navigate(`${Routes.AgentLogPage}/${id}`);
navigateOrPost(`${Routes.AgentLogPage}/${id}`);
},
[navigate],
[navigateOrPost],
);
const navigateToAgentTemplates = useCallback(() => {
navigate(Routes.AgentTemplates);
}, [navigate]);
navigateOrPost(Routes.AgentTemplates);
}, [navigateOrPost]);
const navigateToSearchList = useCallback(() => {
navigate(Routes.Searches);
}, [navigate]);
navigateOrPost(Routes.Searches);
}, [navigateOrPost]);
const navigateToSearch = useCallback(
(id: string) => () => {
navigate(`${Routes.Search}/${id}`);
navigateOrPost(`${Routes.Search}/${id}`);
},
[navigate],
[navigateOrPost],
);
const navigateToChunkParsedResult = useCallback(
(id: string, knowledgeId?: string) => () => {
navigate(
navigateOrPost(
`${Routes.ParsedResult}/chunks?id=${knowledgeId}&doc_id=${id}`,
// `${Routes.DataflowResult}?id=${knowledgeId}&doc_id=${id}&type=chunk`,
);
},
[navigate],
[navigateOrPost],
);
const getQueryString = useCallback(
@@ -134,34 +160,36 @@ export const useNavigatePage = () => {
const navigateToChunk = useCallback(
(route: Routes) => {
navigate(
navigateOrPost(
`${route}/${id}?${QueryStringMap.KnowledgeId}=${getQueryString(QueryStringMap.KnowledgeId)}`,
);
},
[getQueryString, id, navigate],
[getQueryString, id, navigateOrPost],
);
const navigateToFiles = useCallback(
(folderId?: string) => {
navigate(`${Routes.Files}?folderId=${folderId}`);
navigateOrPost(`${Routes.Files}?folderId=${folderId}`);
},
[navigate],
[navigateOrPost],
);
const navigateToDataflowResult = useCallback(
(props: NavigateToDataflowResultProps) => () => {
let params: string[] = [];
Object.keys(props).forEach((key) => {
// @ts-ignore
if (props[key]) {
// @ts-ignore
params.push(`${key}=${props[key]}`);
}
});
navigate(
navigateOrPost(
// `${Routes.ParsedResult}/${id}?${QueryStringMap.KnowledgeId}=${knowledgeId}`,
`${Routes.DataflowResult}?${params.join('&')}`,
);
},
[navigate],
[navigateOrPost],
);
return {

View File

@@ -108,7 +108,7 @@ function AgentListPage() {
onCreateAgent={() => setCreateOpen(true)}
onEdit={(agent) => { setEditTarget(agent); setEditOpen(true); }}
onView={(agent) => {
navigate(`/agent/${agent.id}`);
navigate(`/route-ragflow/agent/${agent.id}`);
}}
onDelete={async (agent) => {
const confirmed = await dialog.confirm({

View File

@@ -0,0 +1,40 @@
import { useEffect, useMemo, useRef } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { createPenpalHostBridge } from '@teres/iframe-bridge';
import logger from '@/utils/logger';
export default function RagflowAgentPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const iframeRef = useRef<HTMLIFrameElement>(null);
const src = useMemo(() => `/ragflow/agent/${id ?? ''}`, [id]);
logger.info('RagflowAgentPage', id, src);
useEffect(() => {
const el = iframeRef.current;
if (!el) return;
const { destroy } = createPenpalHostBridge({
iframe: el,
methods: {
navigate: (to: string) => navigate(to),
close: () => navigate('/agents'),
agentReady: () => {
// 可选:在需要时记录或触发后续逻辑
},
},
});
return () => destroy();
}, [navigate]);
// 如需兼容旧的 postMessage 事件,可保留以下监听;为了纯 Penpal此处移除
return (
<iframe
ref={iframeRef}
title="ragflow-agent"
src={src}
style={{ width: '100%', height: '100vh', border: 'none' }}
/>
);
}

View File

@@ -0,0 +1,37 @@
import { useEffect, useRef } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { createPenpalHostBridge, toChildPath } from '@teres/iframe-bridge';
export default function RagflowIframePage() {
const location = useLocation();
const navigate = useNavigate();
// 将宿主的 "/route-ragflow/**" 路径转换为子应用的 "/ragflow/**" 路径
const childPath = toChildPath(location.pathname);
const src = `/ragflow${childPath}${location.search}${location.hash}`;
const iframeRef = useRef<HTMLIFrameElement>(null);
useEffect(() => {
const el = iframeRef.current;
if (!el) return;
const { destroy } = createPenpalHostBridge({
iframe: el,
methods: {
navigate: (to: string) => navigate(to),
close: () => navigate('/agents'),
agentReady: () => {
// 可选:记录或触发后续逻辑
},
},
});
return () => destroy();
}, [navigate, src]);
return (
<iframe
ref={iframeRef}
title="ragflow"
src={src}
style={{ width: '100%', height: '100vh', border: 'none' }}
/>
);
}

View File

@@ -0,0 +1,9 @@
import { Outlet } from 'react-router-dom';
export default function RagflowLayout() {
return (
<div style={{ width: '100%', height: '100vh' }}>
<Outlet />
</div>
);
}

View File

@@ -27,6 +27,9 @@ import ChunkParsedResult from '@/pages/chunk/parsed-result';
import DocumentPreview from '@/pages/chunk/document-preview';
import AgentDetailPage from '@/pages/agent-mui/detail';
// import AgentDetailPage from '@/pages/agent';
import RagflowLayout from '@/pages/ragflow/layout';
import RagflowIframePage from '@/pages/ragflow/iframe';
import RagflowAgentPage from '@/pages/ragflow/agent';
const AppRoutes = () => {
return (
@@ -70,6 +73,13 @@ const AppRoutes = () => {
<Route path="mcp" element={<MCPSetting />} />
</Route>
{/* 通过 iframe 承载 ragflow 应用,避免路由冲突并保持同源 */}
{/* ragflow 路由分组layout 承载agent 为特定页面,其余走通用 iframe */}
<Route path="route-ragflow" element={<RagflowLayout />}>
<Route path="agent/:id" element={<RagflowAgentPage />} />
<Route path="*" element={<RagflowIframePage />} />
</Route>
{/* 处理未匹配的路由 */}
<Route path="*" element={<Navigate to="/knowledge" replace />} />
</Routes>

View File

@@ -2,7 +2,8 @@
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
{ "path": "./tsconfig.node.json" },
{ "path": "./packages/iframe-bridge/tsconfig.json" }
],
// exclude rag_web_core/**/*
"exclude": [