# 工作台问答 · iframe 嵌入开发说明 本文档说明如何将 DeerFlow **通用工作台问答**(与 `/page/workspace/chats/*` 同一套 LangGraph 流式能力)以**普通 iframe** 形式嵌入第三方页面,并在**父页面无法读取子 frame 内 `thread_id`** 的前提下,轮询「本次问答是否已结束」。 后端公开 API 实现见:`offline-backend-20260512/backend/app/gateway/routers/public_embed.py` 数据表:`embed_sessions`(迁移 `20260602_01`) --- ## 0. 速读 | 角色 | 要做的事 | |------|----------| | **父页面** | 自己生成 `sessionId`(UUID),拼进 iframe `src`;用 `sessionId` 调公开 API 轮询 `finished` | | **iframe(DeerFlow)** | 打开 `/embed/chats/...`,自动登录、自动发问、登记 `sessionId → thread_id` | | **不需要** | 无界(wujie)、父页面拿 `thread_id`、DeerFlow 登录态 Cookie | **完成条件**:`GET .../sessions/{sessionId}/status` 返回 `finished === true`(且无进行中的 run,非澄清中断)。 --- ## 1. 背景与约束 ### 1.1 业务目标 - 第三方系统用 **iframe** 嵌 DeerFlow 问答 UI。 - 传入:**问题文案**、**用户名**(走现有 `login/username`)、**主题色**。 - 展示:**消息列表** + **沙箱/产物侧栏**(与主聊天相同的 `ChatBox`);**隐藏**页面左侧栏、Workspace 侧栏、底部输入框。 - 进入后 **自动发送** 首条用户消息并走流式问答。 ### 1.2 为何需要 `sessionId` iframe 与父页面通常 **跨域**,父页面 **不能** 读取子应用 URL 里的 `thread_id`(hash 路由在子 frame 内)。 因此约定: 1. **父页面**在打开 iframe 前生成全局唯一的 `sessionId`; 2. **子页面**在创建/确定 `thread_id` 后,调用后端 **登记接口** 写入 `session_id → thread_id`; 3. **父页面**只凭 `sessionId` 查询状态,必要时响应里会带上 `thread_id`(可选使用)。 --- ## 2. 端到端流程 ```mermaid sequenceDiagram participant Parent as 父页面 participant Iframe as DeerFlow iframe participant API as Gateway /api/public/embed Parent->>Parent: sessionId = crypto.randomUUID() Parent->>Iframe: iframe src 含 sessionId、message、username、theme Iframe->>Iframe: loginByUsername + 自动 sendMessage Iframe->>API: POST /sessions { session_id, thread_id } loop 每 1–3s Parent->>API: GET /sessions/{sessionId}/status API-->>Parent: registered, finished, thread_id end ``` --- ## 3. 前端路由与页面 ### 3.1 路由(独立于 `/page/*`) 在 `App.tsx` 注册,**不经过** `PageRoutes` / `PageSidebar`,因此无外层导航与 Workspace 侧栏。 | 路由 | 组件 | 说明 | |------|------|------| | `/embed/chats/:thread_id` | `EmbedChatPage`(外包 `ChatRuntime`) | `thread_id` 可为 `new` 或已有 UUID | 示例(HashRouter,注意 `#`): ``` http://localhost:5173/#/embed/chats/new?sessionId=...&message=...&username=guest&theme=red ``` 开发环境端口以本地 Vite 为准(如 5173 / 5174)。 ### 3.2 URL 查询参数 | 参数 | 必填 | 说明 | |------|------|------| | `sessionId` 或 `session_id` | **是** | 父页面生成的关联 id,8–128 位,仅字母数字 `_` `-` | | `message` / `question` / `text` | 建议 | 首条用户问题;有则进入后自动发送 | | `username` / `userName` | 否 | 默认 `guest`,对应 `POST /api/v1/auth/login/username` | | `theme` | 否 | `dark-blue` → 深色;`red` 等 → 浅色(与 `LoginPage` 一致) | 缺少 `sessionId` 时,嵌入页会提示错误,不会发起登记。 ### 3.3 相关源码 | 路径 | 职责 | |------|------| | [src/pages/EmbedChatPage.tsx](../src/pages/EmbedChatPage.tsx) | 嵌入页:登录、自动发问、登记 session、仅消息列表 + ChatBox | | [src/core/embed/params.ts](../src/core/embed/params.ts) | 从 URL 解析嵌入参数 | | [src/core/embed/status-api.ts](../src/core/embed/status-api.ts) | 登记 / 轮询 API 封装 | | [src/App.tsx](../src/App.tsx) | 路由注册 | ### 3.4 UI 行为说明 - **隐藏**:`PageSidebar`、`WorkspaceSidebar`、`InputBox`、顶栏标题栏等(相对完整 `ChatPage`)。 - **保留**:`MessageList`、流式指示、澄清卡片(若 agent 触发 `ask_clarification`)、右侧 **产物/沙箱**(`ChatBox` 内 60/40 分栏逻辑与主聊天一致)。 - **线程 id**:新建会话时客户端先分配 UUID,`onStart` 后 `history.replaceState` 更新 hash 为 `/embed/chats/{真实id}`(与主聊天 `ChatPage` 相同手法,避免整页重载)。 --- ## 4. 公开 HTTP API(无需鉴权) 前缀:`/api/public/embed/`(走 Gateway,与 `VITE_BACKEND_BASE_URL` 一致,默认 `http://47.88.25.99:7001/`)。 CSRF / Auth 中间件对 `/api/public/` 放行;请求 **不需要** Cookie。 ### 4.1 登记映射(iframe 内自动调用) ```http POST /api/public/embed/sessions Content-Type: application/json { "session_id": "parent-generated-uuid", "thread_id": "e9c7d09e-821d-421f-ae24-e5ddfe0cff5b" } ``` **响应:** ```json { "session_id": "parent-generated-uuid", "thread_id": "e9c7d09e-821d-421f-ae24-e5ddfe0cff5b", "registered": true } ``` 同一 `session_id` 再次 POST 会 **更新** `thread_id`(例如 `onStart` 后 id 变化)。 ### 4.2 轮询状态(父页面使用) ```http GET /api/public/embed/sessions/{session_id}/status ``` **尚未登记**(iframe 仍在加载或未 POST): ```json { "session_id": "parent-generated-uuid", "registered": false, "thread_id": null, "finished": false, "thread_status": "pending", "has_active_run": false, "latest_run_status": null } ``` **已登记且问答进行中:** ```json { "session_id": "parent-generated-uuid", "registered": true, "thread_id": "e9c7d09e-821d-421f-ae24-e5ddfe0cff5b", "finished": false, "thread_status": "idle", "has_active_run": true, "latest_run_status": "running" } ``` **已登记且已结束:** ```json { "session_id": "parent-generated-uuid", "registered": true, "thread_id": "e9c7d09e-821d-421f-ae24-e5ddfe0cff5b", "finished": true, "thread_status": "idle", "has_active_run": false, "latest_run_status": "success" } ``` #### 字段说明 | 字段 | 含义 | |------|------| | `registered` | 是否已收到 iframe 的 POST 登记 | | `finished` | **父页面应以此为准**:无 inflight run,且线程非 running/busy,且非 `interrupted`(澄清等待用户) | | `thread_status` | 从 checkpoint 推导:`idle` / `interrupted` / `error` 等 | | `has_active_run` | 是否存在 pending/running 的 LangGraph run | | `latest_run_status` | 该线程最近一次 run 的状态:`success` / `error` / … | | `thread_id` | 解析出的 DeerFlow 线程 id(父页面可选用,非必须) | ### 4.3 获取生成结果(父页面使用) 问答结束后(建议先确认 `finished === true`),拉取本次模型输出: ```http GET /api/public/embed/sessions/{session_id}/result ``` **尚未登记:** ```json { "session_id": "parent-generated-uuid", "registered": false, "thread_id": null, "finished": false, "thread_status": "pending", "has_active_run": false, "latest_run_status": null, "messages": [], "generated_text": "", "generated_files": [] } ``` **已登记且已有内容:** ```json { "session_id": "parent-generated-uuid", "registered": true, "thread_id": "e9c7d09e-821d-421f-ae24-e5ddfe0cff5b", "finished": true, "thread_status": "idle", "has_active_run": false, "latest_run_status": "success", "messages": [ { "type": "human", "content": "请总结要点", "id": "..." }, { "type": "ai", "content": "根据分析,要点如下:...", "id": "..." } ], "generated_text": "根据分析,要点如下:...", "generated_files": [ "/mnt/user-data/outputs/summary.md", "/mnt/user-data/outputs/chart.png" ] } ``` #### 结果字段说明 | 字段 | 含义 | |------|------| | `messages` | 完整消息列表(与 checkpoint 中序列化结构一致,含 human / ai / tool 等) | | `generated_text` | 所有 `ai` / `assistant` 消息正文按顺序拼接(段落间空一行) | | `generated_files` | 本次会话产物的虚拟路径列表(与 UI 产物栏 `artifacts` 一致) | 未结束时也可调用,会返回当前已有内容;`finished` 仍为 false 表示可能还会继续生成。 ### 4.4 按 thread_id 查询(可选) 调试或父页面已持有 thread_id 时: ```http GET /api/public/embed/threads/{thread_id}/status GET /api/public/embed/threads/{thread_id}/result ``` 状态接口响应不含 `messages` / `generated_text` / `generated_files`;结果接口字段与 §4.3 相同(无 `session_id` / `registered`)。 ### 4.5 前端封装 ```ts import { getEmbedSessionResult, getEmbedSessionStatus, registerEmbedSession, } from "@/core/embed/status-api"; // 父页面(或你们自己的脚本) const status = await getEmbedSessionStatus(sessionId); if (status.registered && status.finished) { const result = await getEmbedSessionResult(sessionId); console.log(result.generated_text, result.generated_files); } // 仅 iframe 内需要 await registerEmbedSession(sessionId, threadId); ``` `fetch` 使用 `credentials: "omit"`,不携带 DeerFlow 会话。 --- ## 5. 父页面集成示例 ### 5.1 拼接 iframe ```html ``` ```javascript const sessionId = crypto.randomUUID(); const base = "http://localhost:5173"; // 换成你们部署的前端 origin const params = new URLSearchParams({ sessionId, message: "请根据附件总结要点", username: "guest", theme: "red", }); document.getElementById("deerflow-embed").src = `${base}/#/embed/chats/new?${params.toString()}`; ``` ### 5.2 轮询直到结束 ```javascript const API = "http://47.88.25.99:7001/"; // VITE_BACKEND_BASE_URL async function pollUntilFinished(sessionId) { const res = await fetch( `${API}/api/public/embed/sessions/${encodeURIComponent(sessionId)}/status`, { credentials: "omit" }, ); if (!res.ok) throw new Error(`status HTTP ${res.status}`); const data = await res.json(); if (!data.registered) return { done: false, reason: "pending" }; if (data.finished) return { done: true, data }; return { done: false, reason: "running" }; } const timer = setInterval(async () => { try { const poll = await pollUntilFinished(sessionId); if (poll.done) { clearInterval(timer); const output = await fetch( `${API}/api/public/embed/sessions/${encodeURIComponent(sessionId)}/result`, { credentials: "omit" }, ).then((r) => r.json()); console.log("问答已结束", output.generated_text, output.generated_files); } } catch (e) { console.error(e); } }, 2000); ``` ### 5.3 跨域注意 父页面从浏览器 `fetch` Gateway(如 `http://47.88.25.99:7001/`)时,**Origin 必须是后端 CORS 白名单里的完整地址**(含协议、主机、端口),与 API 地址是否写 `localhost` / `127.0.0.1` 无关。 **本地开发**(前端默认 `5174`)请在 Gateway 环境变量中配置: ```bash # offline-backend-20260512/.env 或启动脚本注入 GATEWAY_CORS_ORIGINS=http://localhost:5174,http://127.0.0.1:5174,http://localhost:5173,http://127.0.0.1:5173 ``` 修改后**重启 Gateway**(`make dev` / `make gateway`)。若父页面部署在其它域名,把该域名的 origin 一并加入逗号分隔列表。 临时调试(勿用于公网生产): ```bash GATEWAY_CORS_ALLOW_ALL=1 ``` 常见报错:`No 'Access-Control-Allow-Origin' header` → 当前页面的 origin 未出现在 `GATEWAY_CORS_ORIGINS` 中。 ### 5.4 其它部署注意 --- ## 6. 部署与数据库 1. **跑迁移**(SQL 持久化后端): ```bash cd offline-backend-20260512/backend # 按项目惯例执行 alembic upgrade head ``` 表:`embed_sessions(session_id PK, thread_id, created_at, updated_at)`。 2. **重启 Gateway**,确认路由已挂载:`public_embed.router`(`app/gateway/app.py`)。 3. **前端构建** 后,将嵌入地址配给第三方;`VITE_BACKEND_BASE_URL` 指向可达的 Gateway。 无 SQL 时开发环境会退化为 **内存** `MemoryEmbedSessionStore`(进程重启后映射丢失);生产请使用持久化库。 --- ## 7. 安全说明 - `sessionId` 与 `thread_id` 均视为 **不透明能力令牌**:知晓即可查询状态(公开 API **无登录**)。 - 建议:父系统自行生成不可猜测的 UUID;敏感场景在贵司 API 网关再加鉴权或 IP 限制。 - 登记接口为 **POST upsert**,不校验「session 是否属于某租户」——若需多租户隔离,请在业务层约定 `sessionId` 命名空间或增加网关校验。 --- ## 8. 与主聊天 `/page/workspace/chats` 的差异 | 项目 | 主聊天 | iframe 嵌入 | |------|--------|-------------| | 路由 | `/page/workspace/chats/:id` | `/embed/chats/:id` | | 布局 | 完整 Workspace + 输入框 | 仅消息区 + 产物栏 | | 登录 | 常规登录流 | URL `username` 自动 `login/username` | | 首条消息 | 用户输入 | URL `message` 自动发送 | | 完成态查询 | 无公开 API | `sessionId` + `/api/public/embed/sessions/.../status` | 能力上仍走同一 `useThreadStream` + `lead_agent` 流式链路,沙箱与产物行为与主聊天一致。 --- ## 9. 常见问题 **Q:`registered` 一直为 false?** A:检查 iframe URL 是否带 `sessionId`;子页是否登录成功;Network 里是否有 `POST /api/public/embed/sessions` 且 200。 **Q:`finished` 一直 false?** A:可能仍在流式生成(`has_active_run: true`),或 agent 进入澄清(`thread_status: interrupted`)。嵌入模式无底部输入框,澄清需产品侧另行处理或避免触发澄清类工具。 **Q:父页面能否不用轮询?** A:当前标准方案为公开 GET 轮询。若同源可考虑 `postMessage` 扩展(未默认实现)。 **Q:能否继续用已有 thread_id 嵌入?** A:可以:`#/embed/chats/{thread_id}?sessionId=...`,可选再带 `message` 追加一轮;登记会在已知 id 上 upsert。 --- ## 10. 变更记录 | 日期 | 说明 | |------|------| | 2026-06-02 | 初版:iframe 嵌入路由、`sessionId` 映射表、公开登记与状态 API | | 2026-06-02 | 新增 `GET /sessions/{session_id}/result`:返回 messages、generated_text、generated_files |