14 KiB
工作台问答 · 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 内)。
因此约定:
- 父页面在打开 iframe 前生成全局唯一的
sessionId; - 子页面在创建/确定
thread_id后,调用后端 登记接口 写入session_id → thread_id; - 父页面只凭
sessionId查询状态,必要时响应里会带上thread_id(可选使用)。
2. 端到端流程
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 | 嵌入页:登录、自动发问、登记 session、仅消息列表 + ChatBox |
| src/core/embed/params.ts | 从 URL 解析嵌入参数 |
| src/core/embed/status-api.ts | 登记 / 轮询 API 封装 |
| 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 内自动调用)
POST /api/public/embed/sessions
Content-Type: application/json
{
"session_id": "parent-generated-uuid",
"thread_id": "e9c7d09e-821d-421f-ae24-e5ddfe0cff5b"
}
响应:
{
"session_id": "parent-generated-uuid",
"thread_id": "e9c7d09e-821d-421f-ae24-e5ddfe0cff5b",
"registered": true
}
同一 session_id 再次 POST 会 更新 thread_id(例如 onStart 后 id 变化)。
4.2 轮询状态(父页面使用)
GET /api/public/embed/sessions/{session_id}/status
尚未登记(iframe 仍在加载或未 POST):
{
"session_id": "parent-generated-uuid",
"registered": false,
"thread_id": null,
"finished": false,
"thread_status": "pending",
"has_active_run": false,
"latest_run_status": null
}
已登记且问答进行中:
{
"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"
}
已登记且已结束:
{
"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),拉取本次模型输出:
GET /api/public/embed/sessions/{session_id}/result
尚未登记:
{
"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": []
}
已登记且已有内容:
{
"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 时:
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 前端封装
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
<iframe
id="deerflow-embed"
title="DeerFlow 问答"
style="width:100%;height:600px;border:0"
></iframe>
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 轮询直到结束
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 环境变量中配置:
# 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 一并加入逗号分隔列表。
临时调试(勿用于公网生产):
GATEWAY_CORS_ALLOW_ALL=1
常见报错:No 'Access-Control-Allow-Origin' header → 当前页面的 origin 未出现在 GATEWAY_CORS_ORIGINS 中。
5.4 其它部署注意
6. 部署与数据库
-
跑迁移(SQL 持久化后端):
cd offline-backend-20260512/backend # 按项目惯例执行 alembic upgrade head表:
embed_sessions(session_id PK, thread_id, created_at, updated_at)。 -
重启 Gateway,确认路由已挂载:
public_embed.router(app/gateway/app.py)。 -
前端构建 后,将嵌入地址配给第三方;
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 |