初始化

This commit is contained in:
gozeros committed 2026-09-07 18:24:55 +08:00
1 parent 34474bd5fc
commit b638b541d0
3008 files changed
+837699

No files matched your search

+436
View File
@@ -0,0 +1,436 @@
# 工作台问答 · 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 |