437 lines
14 KiB
Markdown
437 lines
14 KiB
Markdown
# 工作台问答 · 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
|
||
<iframe
|
||
id="deerflow-embed"
|
||
title="DeerFlow 问答"
|
||
style="width:100%;height:600px;border:0"
|
||
></iframe>
|
||
```
|
||
|
||
```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 |
|