This commit is contained in:
fengxiangdebaozi committed 2026-06-02 21:15:53 +08:00
commit fb43c036f6
23 files changed
+1355 -16

No files matched your search

+352
View File
@@ -0,0 +1,352 @@
# 工作台问答 · 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://127.0.0.1:8001`)。
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 按 thread_id 查询(可选)
调试或父页面已持有 thread_id 时:
```http
GET /api/public/embed/threads/{thread_id}/status
```
响应不含 `session_id` / `registered`,其余与上表类似。
### 4.4 前端封装
```ts
import {
getEmbedSessionStatus,
registerEmbedSession,
} from "@/core/embed/status-api";
// 父页面(或你们自己的脚本)
const status = await getEmbedSessionStatus(sessionId);
if (status.registered && status.finished) {
// 问答结束
}
// 仅 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://127.0.0.1:8001"; // 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 result = await pollUntilFinished(sessionId);
if (result.done) {
clearInterval(timer);
console.log("问答已结束", result.data);
}
} catch (e) {
console.error(e);
}
}, 2000);
```
### 5.3 跨域注意
- 父页面 `fetch` 的是 **Gateway 源**(如 `8001`),不是 iframe 的 Vite 源;需 Gateway 配置 CORS 允许父页面 origin(若跨域)。
- iframe `src` 的前端域名需与你们部署一致;后端地址由 DeerFlow 前端 `.env` 的 `VITE_BACKEND_BASE_URL` 决定。
---
## 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 |
+4
View File
@@ -49,6 +49,7 @@ frontend-web/
│ ├─ ScheduledTaskRunDetailPage.tsx
│ ├─ PublicSharePage.tsx 免登录分享只读
│ ├─ PublicScheduledTaskPage.tsx
│ ├─ EmbedChatPage.tsx iframe 嵌入问答(见 docs/embed-iframe-chat.md)
│ ├─ LoginPage.tsx 用户名直登(开发/内网用)
│ └─ ...
├─ core/ 「领域核心」: API 客户端、领域 hooks、类型
@@ -186,6 +187,7 @@ ReactDOM.createRoot(#root)
/login/:username LoginPage(按用户名直登)
/landing LandingPage
/share/:shareCode PublicSharePage(免登录只读)
/embed/chats/:thread_id EmbedChatPage(iframe 嵌入问答,见 docs/embed-iframe-chat.md)
/public/scheduled-tasks/:taskId PublicScheduledTaskPage(免登录)
/page/* PageRoutes(含侧边栏与 Header)
* NotFoundPage
@@ -664,6 +666,7 @@ const { models, tokenUsageEnabled, isLoading } = useModels();
- 创建分享:[components/workspace/thread-share-dialogs.tsx](../src/components/workspace/thread-share-dialogs.tsx) + `core/thread-shares/`
- 只读页 `PublicSharePage`(路由 `/share/:shareCode`)/ `PublicScheduledTaskPage`(`/public/scheduled-tasks/:taskId`),不需要登录
- iframe 嵌入问答 `EmbedChatPage`(`/embed/chats/:thread_id`):第三方用普通 iframe 嵌套,父页面用 `sessionId` 轮询公开 API 判断问答是否结束,详见 [embed-iframe-chat.md](./embed-iframe-chat.md)
- 后端会用 share token 做权限校验,前端通过 `apiFetch` 自动带
---
@@ -1138,6 +1141,7 @@ setSettings("context", { model_name: "gpt-5" }); // 仅当前线程
| `/landing` | App.tsx | LandingPage.tsx |
| `/share/:shareCode` | App.tsx | PublicSharePage.tsx |
| `/public/scheduled-tasks/:taskId` | App.tsx | PublicScheduledTaskPage.tsx |
| `/embed/chats/:thread_id` | App.tsx | EmbedChatPage.tsx([embed-iframe-chat.md](./embed-iframe-chat.md)) |
| `/page/workspace/*` | PageRoutes.tsx → WorkspaceRoutes.tsx | ChatsPage / ChatPage / AgentsPage / NewAgentPage / AgentChatPage / SkillsPage / SkillEvolutionPage / MemoryPage / ScheduledTasksPage / ScheduledTaskRunDetailPage / AdminUserHubPage / AdminScheduledTasksPage / TagsPage / RecommendedQuestionsPage / PromptPrefixSettingsPage / LlmMetricsPage |
| `/page/strategy/*` | PageRoutes.tsx → StrategyRoutes.tsx | GeneralQA + LightApp* + 复用 Workspace 页面 |
| `/page/canvas/*` | PageRoutes.tsx → open-canvas/routes/OpenCanvasRoutes.tsx | CanvasHomePage / CanvasThreadPage / AIWritingPage / ArticleTypesConfigPage |
+11
View File
@@ -17,6 +17,8 @@ import LoginPage from "./pages/LoginPage";
import PageRoutes from "./pages/PageRoutes";
import PublicScheduledTaskPage from "./pages/PublicScheduledTaskPage";
import PublicSharePage from "./pages/PublicSharePage";
import EmbedChatPage from "./pages/EmbedChatPage";
import { ChatRuntime } from "./pages/WorkspaceRoutes";
import { NotFoundPage } from "./pages/NotFoundPage";
function AppProviders({ children }: { children: React.ReactNode }) {
@@ -66,6 +68,15 @@ export function App() {
path="/public/scheduled-tasks/:taskId"
element={<PublicScheduledTaskPage />}
/>
{/* Embedded workspace Q&A — no page/workspace sidebars; parent passes message/username/theme */}
<Route
path="/embed/chats/:thread_id"
element={
<ChatRuntime>
<EmbedChatPage />
</ChatRuntime>
}
/>
<Route path="/page/*" element={<PageRoutes />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
+46
View File
@@ -0,0 +1,46 @@
import { resolveThemeFromQuery } from "@/core/auth/external-login";
const DEFAULT_USERNAME = "guest";
export type EmbedChatParams = {
/** Parent correlation id — required for status polling without thread_id. */
sessionId: string;
message: string;
username: string;
theme: string;
};
/**
* Resolve iframe embed params from URL query only (plain iframe, not wujie).
*
* Parent must generate ``sessionId`` and pass it on the iframe URL so it can
* poll ``GET /api/public/embed/sessions/{sessionId}/status`` without reading
* the child frame.
*/
export function resolveEmbedChatParams(search: string): EmbedChatParams {
const params = new URLSearchParams(search.startsWith("?") ? search : `?${search}`);
const sessionId =
params.get("sessionId")?.trim() ||
params.get("session_id")?.trim() ||
"";
const message =
params.get("message")?.trim() ||
params.get("question")?.trim() ||
params.get("text")?.trim() ||
"";
const username =
params.get("username")?.trim() ||
params.get("userName")?.trim() ||
DEFAULT_USERNAME;
const theme = params.get("theme")?.trim() || "";
return { sessionId, message, username, theme };
}
export function resolveEmbedTheme(themeParam: string): "dark" | "light" {
return resolveThemeFromQuery(themeParam || null);
}
+52
View File
@@ -0,0 +1,52 @@
import { getBackendBaseURL } from "@/core/config";
export type EmbedSessionStatus = {
session_id: string;
registered: boolean;
thread_id: string | null;
finished: boolean;
thread_status: string;
has_active_run: boolean;
latest_run_status: string | null;
};
async function parseEmbedError(response: Response): Promise<string> {
const body = await response.json().catch(() => ({}));
if (typeof body.detail === "string") {
return body.detail;
}
return `HTTP ${response.status}`;
}
/** Register session_id → thread_id (iframe calls this; parent never needs thread_id). */
export async function registerEmbedSession(
sessionId: string,
threadId: string,
): Promise<{ session_id: string; thread_id: string }> {
const base = getBackendBaseURL();
const response = await fetch(`${base}/api/public/embed/sessions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "omit",
body: JSON.stringify({ session_id: sessionId, thread_id: threadId }),
});
if (!response.ok) {
throw new Error(await parseEmbedError(response));
}
return response.json() as Promise<{ session_id: string; thread_id: string }>;
}
/** Poll whether embed Q&A has finished — parent uses sessionId only. */
export async function getEmbedSessionStatus(
sessionId: string,
): Promise<EmbedSessionStatus> {
const base = getBackendBaseURL();
const response = await fetch(
`${base}/api/public/embed/sessions/${encodeURIComponent(sessionId)}/status`,
{ credentials: "omit" },
);
if (!response.ok) {
throw new Error(await parseEmbedError(response));
}
return response.json() as Promise<EmbedSessionStatus>;
}
+7
View File
@@ -24,6 +24,13 @@ export interface WujieProps {
env?: string;
/** 主题色标识(映射到深/浅色)。 */
theme?: string;
/** 嵌入问答:首条用户问题(与 question / text 同义)。 */
message?: string;
question?: string;
text?: string;
/** 嵌入问答:DeerFlow 本地用户名登录。 */
username?: string;
userName?: string;
[key: string]: unknown;
}
@@ -1,8 +1,10 @@
import { FileText, PenLine } from 'lucide-react';
import { useCanvasGraphContext } from '../../contexts/CanvasGraphContext';
import { useCanvasUIContext } from '../../contexts/CanvasUIContext';
import { getCurrentArtifactContent, isCodeArtifact } from '../../utils/artifact';
import { TextArtifactRenderer } from '../artifact/TextArtifactRenderer';
import { DraftStreamingPreview } from './DraftStreamingPreview';
import type { CanvasArtifactText } from '../../types/artifact';
function DraftWaitingPlaceholder() {
@@ -28,6 +30,27 @@ function DraftWaitingPlaceholder() {
export function AIWritingDraftPanel() {
const { artifact } = useCanvasGraphContext();
const { isDraftStreaming, draftStreamingMarkdown } = useCanvasUIContext();
// 流式生成中:只读流式预览,数据走独立通道(不依赖全局 artifact),全程不挂载编辑器,
// 彻底隔绝高频内容刷新触发的编辑器更新风暴。
if (isDraftStreaming) {
return (
<div className="h-full flex flex-col overflow-hidden border-l">
<div className="h-10 border-b flex items-center gap-2 px-4 shrink-0 bg-muted/20">
<FileText className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
<span className="text-sm font-medium truncate flex-1">草稿生成中…</span>
</div>
<div className="flex-1 overflow-hidden">
{draftStreamingMarkdown ? (
<DraftStreamingPreview markdown={draftStreamingMarkdown} />
) : (
<DraftWaitingPlaceholder />
)}
</div>
</div>
);
}
if (!artifact) {
return (
@@ -50,6 +73,7 @@ export function AIWritingDraftPanel() {
</div>
<div className="flex-1 overflow-hidden">
{/* 生成完成:带编辑器的渲染器,开放润色 / 滑词等编辑能力 */}
{content && !isCodeArtifact(content) ? (
<TextArtifactRenderer content={content as CanvasArtifactText} />
) : null}
@@ -41,7 +41,7 @@ import { InterventionCard } from './InterventionCard';
import { SkillStepsCard } from './SkillStepsCard';
import { SkillSelectStreamingView } from './SkillSelectStreamingView';
import { WritingSkeletonCard } from './WritingSkeletonCard';
import { DraftStreamingView } from './DraftStreamingView';
import { DraftGeneratingView } from './DraftGeneratingView';
import { IntentResultCard } from './IntentResultCard';
import { IntentStreamingView } from './IntentStreamingView';
import { OutlineStreamingView } from './OutlineStreamingView';
@@ -440,12 +440,9 @@ export function AIWritingPanel({
</div>
)}
{/* 草稿流式打字机视图 */}
{state.status === 'writing_draft' && state.streamingDraftSections.length > 0 && (
<DraftStreamingView
title={state.streamingDraftTitle}
sections={state.streamingDraftSections}
/>
{/* 草稿生成中:左侧只展示思考过程(若有)+「内容生成中」骨架屏;正文实时显示在右侧画布 */}
{state.status === 'writing_draft' && (
<DraftGeneratingView sections={state.streamingDraftSections} />
)}
{/* 技能选择 LLM 流式打字(含 <think> 思考分离),select_skills 完成后自动清空。
@@ -485,12 +482,6 @@ export function AIWritingPanel({
</div>
)}
{state.status === 'writing_draft' && state.streamingDraftSections.length === 0 && (
<div className="px-4 pb-4">
<WritingSkeletonCard status="writing_draft" />
</div>
)}
{isError && state.error && (
<div className="mx-4 mt-2 rounded-md bg-destructive/10 px-3 py-2 text-xs text-destructive">
{state.error}
@@ -0,0 +1,79 @@
import { useEffect, useRef } from 'react';
import { Brain, PenLine } from 'lucide-react';
import type { StreamingDraftSection } from '../../types/ai-writing';
interface DraftGeneratingViewProps {
sections: StreamingDraftSection[];
}
/** 思考过程框:纯只读展示,跟随内容自动滚到底(仅操作 DOM,不触发 setState) */
function ThinkingBox({ text, done }: { text: string; done: boolean }) {
const preRef = useRef<HTMLPreElement>(null);
useEffect(() => {
if (preRef.current) preRef.current.scrollTop = preRef.current.scrollHeight;
}, [text]);
return (
<div className="rounded-lg border border-amber-200 dark:border-amber-900 bg-amber-50/50 dark:bg-amber-950/20 overflow-hidden">
<div className="flex items-center gap-1.5 px-2.5 py-1.5 bg-amber-100/40 dark:bg-amber-900/30">
<Brain className="size-3 text-amber-500 shrink-0" />
<span className="text-[11px] font-medium text-amber-600 dark:text-amber-400">思考过程</span>
</div>
<pre
ref={preRef}
className="text-[10px] font-mono leading-relaxed px-2.5 py-2 text-amber-700/70 dark:text-amber-300/60 whitespace-pre-wrap max-h-40 overflow-y-auto italic"
>
{text}
{!done && (
<span className="inline-block w-0.5 h-3 bg-amber-400 ml-0.5 align-text-bottom animate-[blink_1s_step-end_infinite]" />
)}
</pre>
</div>
);
}
/** 「内容生成中」骨架屏:闪烁的占位行,提示正文正在右侧实时生成 */
function GeneratingSkeleton() {
return (
<div className="rounded-xl border bg-card shadow-sm p-4">
<div className="flex items-center gap-2 mb-3">
<PenLine className="size-3.5 text-primary animate-pulse" />
<span className="text-xs font-medium text-foreground">内容生成中…</span>
<span className="text-[11px] text-muted-foreground ml-auto">正文已在右侧实时显示</span>
</div>
<div className="space-y-2.5">
<div className="h-3 rounded bg-muted/70 animate-pulse w-[92%]" />
<div className="h-3 rounded bg-muted/70 animate-pulse w-full" />
<div className="h-3 rounded bg-muted/70 animate-pulse w-[85%]" />
<div className="h-3 rounded bg-muted/70 animate-pulse w-[70%]" />
</div>
<style>{`
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
`}</style>
</div>
);
}
/**
* 作家写作 / 按意见修改阶段,左侧的轻量「生成中」视图。
*
* 刻意不再逐章节渲染流式正文(那套 DraftStreamingView 含逐 chunk 的平滑
* scrollIntoView 等重型操作):正文已在右侧实时显示,左侧只需告知"正在生成",
* 若模型有思考过程则一并展示。这样左侧每个 chunk 的渲染开销极小,避免更新风暴。
*/
export function DraftGeneratingView({ sections }: DraftGeneratingViewProps) {
// 按章节顺序拼接非空思考内容;是否仍在思考以"存在未完成章节"近似
const ordered = [...sections].sort((a, b) => a.sectionIndex - b.sectionIndex);
const thinking = ordered.map((s) => s.thinkingText).filter(Boolean).join('\n\n');
const thinkingDone = sections.length > 0 && sections.every((s) => s.isDone);
return (
<div className="px-4 pb-4 space-y-3">
{thinking && <ThinkingBox text={thinking} done={thinkingDone} />}
<GeneratingSkeleton />
</div>
);
}
@@ -0,0 +1,148 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { ArrowDown } from "lucide-react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { Button } from "@/components/ui/button";
interface DraftStreamingPreviewProps {
/** 正在流式生成的草稿 Markdown(边写边推送,逐 chunk 增长) */
markdown: string;
}
/**
* AI 写作右侧「边生成边推送」的只读流式预览。
*
* 刻意不挂载 Tiptap 编辑器(useWritingEditor / useWritingRewrite):草稿是 token 级
* 高频 chunk,若每个 chunk 都灌进编辑器会触发 ProseMirror transaction 风暴 + 父子组件
* 反复重渲染,导致 React「Maximum update depth exceeded」。生成完成后才由
* TextArtifactRenderer 接管为可编辑文档(润色 / 滑词等)。
*
* 行为:自动下滑跟随生成;用户向上滚动则停止自动下滑并浮出「回到底部」按钮,
* 点击后滚到底部并恢复自动下滑。
*/
export function DraftStreamingPreview({ markdown }: DraftStreamingPreviewProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const autoScrollRef = useRef(true);
// 程序化滚动期间置 true,让 onScroll 忽略自己触发的滚动,避免 setState 抖动/回环
const programmaticRef = useRef(false);
const [showScrollBtn, setShowScrollBtn] = useState(false);
// 内容增长时,若自动下滑开启则贴底
useEffect(() => {
if (!autoScrollRef.current) return;
const el = scrollRef.current;
if (!el) return;
programmaticRef.current = true;
el.scrollTop = el.scrollHeight;
requestAnimationFrame(() => { programmaticRef.current = false; });
}, [markdown]);
const handleScroll = useCallback(() => {
if (programmaticRef.current) return; // 忽略程序化滚动
const el = scrollRef.current;
if (!el) return;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
autoScrollRef.current = atBottom;
setShowScrollBtn((prev) => (prev === !atBottom ? prev : !atBottom));
}, []);
const handleScrollToBottom = useCallback(() => {
const el = scrollRef.current;
if (el) {
programmaticRef.current = true;
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
requestAnimationFrame(() => { programmaticRef.current = false; });
}
autoScrollRef.current = true;
setShowScrollBtn(false);
}, []);
return (
<div className="relative h-full flex flex-col overflow-hidden">
<div
ref={scrollRef}
className="flex-1 overflow-y-auto"
onScroll={handleScroll}
>
<div className="max-w-2xl mx-auto px-10 pt-10 pb-32">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
h1: ({ children }) => (
<h1 className="text-[1.625rem] font-bold tracking-tight mb-3 mt-8 first:mt-0 text-foreground border-b border-border pb-2 leading-tight">
{children}
</h1>
),
h2: ({ children }) => (
<h2 className="text-[1.25rem] font-semibold tracking-tight mb-2.5 mt-7 text-foreground leading-snug">
{children}
</h2>
),
h3: ({ children }) => (
<h3 className="text-[1.0625rem] font-semibold tracking-tight mb-2 mt-5 text-foreground leading-snug">
{children}
</h3>
),
p: ({ children }) => (
<p className="mb-4 leading-[1.8] text-[15px] text-foreground indent-[2em]">
{children}
</p>
),
ul: ({ children }) => (
<ul className="mb-4 ml-6 list-disc space-y-1.5 text-[15px] text-foreground">
{children}
</ul>
),
ol: ({ children }) => (
<ol className="mb-4 ml-6 list-decimal space-y-1.5 text-[15px] text-foreground">
{children}
</ol>
),
li: ({ children }) => <li className="leading-7">{children}</li>,
blockquote: ({ children }) => (
<blockquote className="border-l-4 border-primary/30 pl-4 my-4 text-muted-foreground italic text-sm">
{children}
</blockquote>
),
pre: ({ children }) => (
<pre className="mb-4 rounded-md bg-muted/60 overflow-x-auto">
{children}
</pre>
),
code: ({ className, children }) => {
const isBlock = Boolean(className?.startsWith("language-"));
return isBlock ? (
<code className="block p-4 text-xs font-mono text-foreground leading-relaxed">
{children}
</code>
) : (
<code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono text-foreground">
{children}
</code>
);
},
}}
>
{markdown}
</ReactMarkdown>
<span className="inline-block w-0.5 h-4 bg-primary animate-pulse align-middle ml-0.5" />
</div>
</div>
{showScrollBtn && (
<div className="absolute bottom-4 left-0 right-0 flex justify-center z-10 pointer-events-none">
<Button
size="sm"
variant="secondary"
className="pointer-events-auto shadow-md gap-1.5"
onClick={handleScrollToBottom}
>
<ArrowDown className="w-3.5 h-3.5" />
回到底部
</Button>
</div>
)}
</div>
);
}
@@ -17,12 +17,18 @@ export interface CanvasUIContextValue {
isChatCollapsed: boolean;
isEditing: boolean;
isWebSearchOpen: boolean;
/** AI 写作:右侧草稿是否正在流式生成。生成中右侧走只读流式视图(自动下滑、禁用编辑/润色/滑词),生成完才可编辑 */
isDraftStreaming: boolean;
/** AI 写作:流式生成中的草稿 Markdown。生成期间走这条独立通道(不写全局 artifact),避免高频刷新触发编辑器更新风暴 */
draftStreamingMarkdown: string;
selection: CanvasSelectionState;
/** Tiptap editor selection for AI rewrite flow */
writingSelection: WritingSelection | null;
setIsChatCollapsed: (v: boolean) => void;
setIsEditing: (v: boolean) => void;
setIsWebSearchOpen: (v: boolean) => void;
setIsDraftStreaming: (v: boolean) => void;
setDraftStreamingMarkdown: (v: string) => void;
/** Set text selection; clears any existing code selection. */
setTextSelection: (s: CanvasTextSelection | null) => void;
/** Set code selection; clears any existing text selection. */
@@ -42,6 +48,8 @@ export function CanvasUIProvider({ children }: { children: ReactNode }) {
const [isChatCollapsed, setIsChatCollapsed] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [isWebSearchOpen, setIsWebSearchOpen] = useState(false);
const [isDraftStreaming, setIsDraftStreaming] = useState(false);
const [draftStreamingMarkdown, setDraftStreamingMarkdown] = useState('');
const [selection, setSelection] =
useState<CanvasSelectionState>(EMPTY_SELECTION);
const [writingSelection, setWritingSelection] =
@@ -61,11 +69,15 @@ export function CanvasUIProvider({ children }: { children: ReactNode }) {
isChatCollapsed,
isEditing,
isWebSearchOpen,
isDraftStreaming,
draftStreamingMarkdown,
selection,
writingSelection,
setIsChatCollapsed,
setIsEditing,
setIsWebSearchOpen,
setIsDraftStreaming,
setDraftStreamingMarkdown,
setTextSelection,
setCodeSelection,
clearSelection: () => setSelection(EMPTY_SELECTION),
@@ -27,6 +27,7 @@ import { useStream } from '@langchain/langgraph-sdk/react';
import { getAPIClient } from '@/core/api';
import { useCanvasGraphContext } from '../contexts/CanvasGraphContext';
import { useCanvasUIContext } from '../contexts/CanvasUIContext';
import { saveAIWritingTranscript } from '../api/ai-writing-sessions';
import { countWords } from '../utils/wordCount';
import type {
@@ -140,6 +141,26 @@ function reducer(prev: AIWritingState, action: Action): AIWritingState {
}
}
// 把"正在流式生成"的草稿章节拼成 Markdown,用于边写边推送到右侧画布。
// 拼接格式与后端 writer_draft.py 的 _assemble() 对齐(# 文章标题 / ## 章节标题),
// 但这里是流式预览版:最终的 draft_ready 仍会用后端规范化后的完整 markdown 覆盖。
function assembleStreamingDraftMarkdown(
sections: AIWritingState['streamingDraftSections'],
title: string,
): string {
const ordered = [...sections].sort((a, b) => a.sectionIndex - b.sectionIndex);
const body = ordered
.map((s) => {
const heading = s.sectionTitle ? `## ${s.sectionTitle}` : '';
const text = s.text ?? '';
return heading ? `${heading}\n\n${text}` : text;
})
.filter((part) => part.trim().length > 0)
.join('\n\n');
const head = title ? `# ${title}\n\n` : '';
return `${head}${body}`;
}
// 与老 hook 行为对齐的事件 reducer —— 直接从 useAIWriting.ts 移植,删掉 setArtifact 副作用
// (副作用单独提到 hook 顶层 useEffect 里处理)。
function handleEvent(prev: AIWritingState, event: ProgressEvent): AIWritingState {
@@ -270,11 +291,16 @@ function handleEvent(prev: AIWritingState, event: ProgressEvent): AIWritingState
isDone: done,
});
}
const streamingTitle = prev.streamingDraftTitle || prev.outline?.title || '';
// 边写边推送:把已生成的章节实时拼成 Markdown 写进 currentDraftMarkdown,
// 触发顶层 useEffect 把内容同步到右侧画布(无需等 draft_ready)。
return {
...prev,
status: 'writing_draft',
streamingDraftSections: sections,
streamingDraftTitle: prev.streamingDraftTitle || prev.outline?.title || '',
streamingDraftTitle: streamingTitle,
currentDraftMarkdown: assembleStreamingDraftMarkdown(sections, streamingTitle),
currentDraftTitle: streamingTitle || prev.currentDraftTitle,
};
}
@@ -615,6 +641,7 @@ type AIWritingValues = {
export function useAIWriting() {
const { setArtifact } = useCanvasGraphContext();
const { setIsDraftStreaming, setDraftStreamingMarkdown } = useCanvasUIContext();
const [state, dispatch] = useReducer(reducer, INITIAL_STATE);
// useStream 要求受控 threadId;初次 startWriting 时 useStream 在 onCreated 里回灌
@@ -706,8 +733,20 @@ export function useAIWriting() {
if (ev) dispatch({ type: 'handle', event: ev });
}, [stream.interrupt, state.sessionId]);
// 草稿实时同步到右侧 Artifact 面板(与老 hook 行为一致)
// 草稿同步到右侧面板:流式生成中与生成完成,走两条互斥通道,三个状态在同一 effect 内
// 原子更新(同一次 commit,React 18 批处理),杜绝"artifact 已刷新但 isDraftStreaming 仍滞后"
// 的时序窗口——那会让带编辑器的 TextArtifactRenderer 在高频 artifact 变化下挂载并触发更新风暴。
// - 生成中(writing_draft / revising):只更新轻量字符串通道 draftStreamingMarkdown,
// **完全不动全局 artifact**,右侧用纯只读组件流式显示,全程不挂载编辑器。
// - 生成完成(draft_ready 后):清空流式通道,把最终草稿写入全局 artifact 供编辑器编辑。
useEffect(() => {
const streaming = state.status === 'writing_draft' || state.status === 'revising';
setIsDraftStreaming(streaming);
if (streaming) {
setDraftStreamingMarkdown(state.currentDraftMarkdown);
return;
}
setDraftStreamingMarkdown('');
if (state.currentDraftMarkdown) {
setArtifact({
currentIndex: 0,
@@ -719,7 +758,7 @@ export function useAIWriting() {
}],
});
}
}, [state.currentDraftMarkdown, state.currentDraftTitle, setArtifact]);
}, [state.status, state.currentDraftMarkdown, state.currentDraftTitle, setArtifact, setIsDraftStreaming, setDraftStreamingMarkdown]);
// transcript 持久化(保留老逻辑:进入暂停 / done / error 时全量上传)
useEffect(() => {
+180
View File
@@ -0,0 +1,180 @@
"use client";
import { useSearchParams } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from "react";
import { useParams } from "react-router-dom";
import { useTheme } from "@/contexts/ThemeContext";
import { loginByUsername } from "@/core/auth/api";
import { setStoredAuth } from "@/core/auth";
import {
resolveEmbedChatParams,
resolveEmbedTheme,
} from "@/core/embed/params";
import { registerEmbedSession } from "@/core/embed/status-api";
import { useThreadSettings } from "@/core/settings";
import { useThreadStream } from "@/core/threads/hooks";
import { uuid } from "@/core/utils/uuid";
import { ChatBox } from "@/components/workspace/chats";
import {
MessageList,
MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
} from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context";
import { useModels } from "@/core/models/hooks";
import { cn } from "@/lib/utils";
const EMBED_ROUTE_BASE = "/embed/chats";
export default function EmbedChatPage() {
const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>();
const searchParams = useSearchParams();
const { setTheme } = useTheme();
const embedParams = resolveEmbedChatParams(searchParams.toString());
const [authReady, setAuthReady] = useState(false);
const [authError, setAuthError] = useState<string | null>(null);
const [threadId, setThreadId] = useState(() =>
threadIdFromPath === "new" ? uuid() : (threadIdFromPath ?? uuid()),
);
const [isNewThread, setIsNewThread] = useState(threadIdFromPath === "new");
const hasAutoSentRef = useRef(false);
const lastRegisteredThreadRef = useRef<string | null>(null);
useEffect(() => {
if (threadIdFromPath === "new" || !threadIdFromPath) {
return;
}
setIsNewThread(false);
setThreadId(threadIdFromPath);
}, [threadIdFromPath]);
const [settings] = useThreadSettings(threadId);
const { tokenUsageEnabled } = useModels();
useEffect(() => {
if (embedParams.theme) {
setTheme(resolveEmbedTheme(embedParams.theme));
}
}, [embedParams.theme, setTheme]);
useEffect(() => {
let cancelled = false;
async function runLogin() {
setAuthError(null);
try {
const auth = await loginByUsername(embedParams.username);
if (cancelled) return;
setStoredAuth(auth);
setAuthReady(true);
} catch (error) {
if (cancelled) return;
setAuthError(
error instanceof Error ? error.message : "Login request failed.",
);
}
}
void runLogin();
return () => {
cancelled = true;
};
}, [embedParams.username]);
const registerSessionForThread = useCallback(
(resolvedThreadId: string) => {
if (!embedParams.sessionId || lastRegisteredThreadRef.current === resolvedThreadId) {
return;
}
lastRegisteredThreadRef.current = resolvedThreadId;
void registerEmbedSession(embedParams.sessionId, resolvedThreadId).catch(
(error) => {
console.error("[embed] failed to register session mapping", error);
lastRegisteredThreadRef.current = null;
},
);
},
[embedParams.sessionId],
);
const [thread, sendMessage] = useThreadStream({
threadId: authReady && !isNewThread ? threadId : undefined,
context: settings.context,
isMock: false,
onStart: (createdThreadId) => {
setThreadId(createdThreadId);
setIsNewThread(false);
registerSessionForThread(createdThreadId);
history.replaceState(
null,
"",
`${window.location.pathname}${window.location.search}#${EMBED_ROUTE_BASE}/${createdThreadId}`,
);
},
});
useEffect(() => {
if (!authReady || !threadId) {
return;
}
registerSessionForThread(threadId);
}, [authReady, threadId, registerSessionForThread]);
const handleClarificationSubmit = useCallback(
(answer: string) => {
void sendMessage(threadId, { text: answer, files: [] });
},
[sendMessage, threadId],
);
useEffect(() => {
if (!authReady || !embedParams.message || hasAutoSentRef.current) {
return;
}
hasAutoSentRef.current = true;
void sendMessage(threadId, { text: embedParams.message, files: [] });
}, [authReady, embedParams.message, sendMessage, threadId]);
if (!embedParams.sessionId) {
return (
<div className="flex size-full items-center justify-center bg-background p-6 text-center text-sm text-red-600">
缺少参数 sessionId:父页面需在 iframe URL 中传入由己方生成的会话 id,用于查询问答是否结束。
</div>
);
}
if (authError) {
return (
<div className="flex size-full items-center justify-center bg-background p-6 text-sm text-red-600">
登录失败:{authError}
</div>
);
}
if (!authReady) {
return (
<div className="flex size-full items-center justify-center bg-background p-6 text-sm text-muted-foreground">
正在进入问答…
</div>
);
}
return (
<ThreadContext.Provider value={{ thread, isMock: false }}>
<ChatBox threadId={threadId}>
<div className="relative flex size-full min-h-0">
<main className="flex min-h-0 max-w-full grow flex-col">
<div className="flex size-full justify-center">
<MessageList
className={cn("size-full")}
threadId={threadId}
thread={thread}
paddingBottom={MESSAGE_LIST_DEFAULT_PADDING_BOTTOM}
tokenUsageEnabled={tokenUsageEnabled}
onClarificationSubmit={handleClarificationSubmit}
/>
</div>
</main>
</div>
</ChatBox>
</ThreadContext.Provider>
);
}
@@ -39,6 +39,7 @@ from app.gateway.routers import (
models,
multi_agent,
notifications,
public_embed,
public_scheduled_tasks,
recommend,
recommended_questions,
@@ -874,6 +875,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# surface results without a DeerFlow login.
app.include_router(public_scheduled_tasks.router)
# Public embed Q&A status polling (/api/public/embed/*)
app.include_router(public_embed.router)
# System settings API is mounted at /api/system-settings
app.include_router(system_settings.router)
@@ -130,6 +130,10 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.thread_share_store = make_thread_share_store(sf)
from deerflow.persistence.embed_sessions import make_embed_session_store
app.state.embed_session_store = make_embed_session_store(sf)
from deerflow.persistence.ai_writing_sessions import (
make_ai_writing_session_store,
register_default_store as _register_ai_writing_default_store,
@@ -273,6 +277,14 @@ def get_thread_share_store(request: Request):
return val
def get_embed_session_store(request: Request):
"""Return the iframe embed session → thread mapping store."""
val = getattr(request.app.state, "embed_session_store", None)
if val is None:
raise HTTPException(status_code=503, detail="Embed session store not available")
return val
def get_ai_writing_session_store(request: Request):
"""Return the AI writing session store, or None when not configured."""
return getattr(request.app.state, "ai_writing_session_store", None)
@@ -0,0 +1,194 @@
"""Public (unauthenticated) API for embedded workspace chat integrations.
Parent pages embed DeerFlow in a plain iframe and cannot read ``thread_id``
from the child frame. They pass a ``session_id`` (their own correlation id)
in the iframe URL; the embed page registers ``session_id → thread_id`` here,
then the parent polls ``GET /sessions/{session_id}/status``.
"""
from __future__ import annotations
import logging
import re
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field, field_validator
from app.gateway.deps import get_checkpointer, get_embed_session_store, get_run_manager, get_thread_store
from app.gateway.routers.threads import _derive_thread_status
from deerflow.persistence.embed_sessions.base import EmbedSessionStore
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/public/embed", tags=["public-embed"])
_SESSION_ID_RE = re.compile(r"^[A-Za-z0-9_-]{8,128}$")
_UNFINISHED_THREAD_STATUSES = frozenset({"running", "busy"})
def compute_embed_finished(
*,
has_active_run: bool,
meta_status: str,
derived_status: str,
) -> bool:
"""Return whether an embed parent may treat the Q&A session as complete."""
if has_active_run or meta_status in _UNFINISHED_THREAD_STATUSES:
return False
if derived_status == "interrupted":
return False
return True
def _validate_session_id(session_id: str) -> str:
value = session_id.strip()
if not _SESSION_ID_RE.fullmatch(value):
raise HTTPException(
status_code=422,
detail="session_id must be 8-128 characters (letters, digits, underscore, hyphen)",
)
return value
class RegisterEmbedSessionRequest(BaseModel):
session_id: str = Field(..., description="Correlation id from the parent page (iframe query param)")
thread_id: str = Field(..., description="DeerFlow conversation thread id")
@field_validator("session_id")
@classmethod
def _check_session_id(cls, value: str) -> str:
if not _SESSION_ID_RE.fullmatch(value.strip()):
raise ValueError("session_id must be 8-128 characters (letters, digits, underscore, hyphen)")
return value.strip()
@field_validator("thread_id")
@classmethod
def _check_thread_id(cls, value: str) -> str:
trimmed = value.strip()
if not trimmed:
raise ValueError("thread_id is required")
return trimmed
class RegisterEmbedSessionResponse(BaseModel):
session_id: str
thread_id: str
registered: bool = True
class EmbedThreadStatusFields(BaseModel):
finished: bool
thread_status: str
has_active_run: bool
latest_run_status: str | None = None
class EmbedThreadStatusResponse(EmbedThreadStatusFields):
thread_id: str
class EmbedSessionStatusResponse(EmbedThreadStatusFields):
session_id: str
registered: bool = Field(description="False until the iframe has registered this session_id")
thread_id: str | None = Field(default=None, description="Resolved thread id when registered")
async def _status_for_thread_id(thread_id: str, request: Request) -> EmbedThreadStatusFields:
thread_store = get_thread_store(request)
run_mgr = get_run_manager(request)
checkpointer = get_checkpointer(request)
record = await thread_store.get(thread_id, user_id=None)
config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
try:
checkpoint_tuple = await checkpointer.aget_tuple(config)
except Exception:
logger.exception("Failed to read checkpoint for embed status on %s", thread_id)
raise HTTPException(status_code=500, detail="Failed to read thread state") from None
if record is None and checkpoint_tuple is None:
raise HTTPException(status_code=404, detail=f"Thread {thread_id} not found")
derived_status = (
_derive_thread_status(checkpoint_tuple)
if checkpoint_tuple is not None
else (record or {}).get("status", "idle")
)
meta_status = str((record or {}).get("status") or derived_status)
has_active_run = await run_mgr.has_inflight(thread_id)
runs = await run_mgr.list_by_thread(thread_id)
latest_run_status = runs[-1].status.value if runs else None
finished = compute_embed_finished(
has_active_run=has_active_run,
meta_status=meta_status,
derived_status=derived_status,
)
return EmbedThreadStatusFields(
finished=finished,
thread_status=derived_status,
has_active_run=has_active_run,
latest_run_status=latest_run_status,
)
@router.post(
"/sessions",
response_model=RegisterEmbedSessionResponse,
summary="Register embed session_id → thread_id (called from iframe)",
)
async def register_embed_session(
body: RegisterEmbedSessionRequest,
request: Request,
) -> RegisterEmbedSessionResponse:
"""Upsert a mapping so the parent page can poll status by session_id only."""
store: EmbedSessionStore = get_embed_session_store(request)
record = await store.upsert(body.session_id, body.thread_id)
return RegisterEmbedSessionResponse(
session_id=record["session_id"],
thread_id=record["thread_id"],
)
@router.get(
"/sessions/{session_id}/status",
response_model=EmbedSessionStatusResponse,
summary="Poll embed Q&A completion by parent session_id",
)
async def get_embed_session_status(session_id: str, request: Request) -> EmbedSessionStatusResponse:
"""Primary status endpoint for iframe parents — no thread_id required."""
session_id = _validate_session_id(session_id)
store: EmbedSessionStore = get_embed_session_store(request)
record = await store.get(session_id)
if record is None:
return EmbedSessionStatusResponse(
session_id=session_id,
registered=False,
thread_id=None,
finished=False,
thread_status="pending",
has_active_run=False,
latest_run_status=None,
)
thread_id = record["thread_id"]
status = await _status_for_thread_id(thread_id, request)
return EmbedSessionStatusResponse(
session_id=session_id,
registered=True,
thread_id=thread_id,
**status.model_dump(),
)
@router.get(
"/threads/{thread_id}/status",
response_model=EmbedThreadStatusResponse,
summary="Poll embed Q&A completion by thread_id (optional / debugging)",
)
async def get_embed_thread_status(thread_id: str, request: Request) -> EmbedThreadStatusResponse:
"""Direct thread lookup when the parent already knows thread_id."""
status = await _status_for_thread_id(thread_id, request)
return EmbedThreadStatusResponse(thread_id=thread_id, **status.model_dump())
@@ -0,0 +1,18 @@
"""Persistence for iframe embed session → thread mappings."""
from deerflow.persistence.embed_sessions.base import EmbedSessionStore
from deerflow.persistence.embed_sessions.memory import MemoryEmbedSessionStore
from deerflow.persistence.embed_sessions.sql import EmbedSessionRepository
__all__ = [
"EmbedSessionStore",
"MemoryEmbedSessionStore",
"EmbedSessionRepository",
"make_embed_session_store",
]
def make_embed_session_store(session_factory):
if session_factory is None:
return MemoryEmbedSessionStore()
return EmbedSessionRepository(session_factory)
@@ -0,0 +1,18 @@
"""Abstract persistence API for iframe embed session → thread mappings."""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any
class EmbedSessionStore(ABC):
@abstractmethod
async def upsert(self, session_id: str, thread_id: str) -> dict[str, Any]:
"""Create or update the mapping for an embed session."""
raise NotImplementedError
@abstractmethod
async def get(self, session_id: str) -> dict[str, Any] | None:
"""Return ``{session_id, thread_id, created_at, updated_at}`` or None."""
raise NotImplementedError
@@ -0,0 +1,37 @@
"""In-memory ``EmbedSessionStore`` for tests / memory backend."""
from __future__ import annotations
import copy
from datetime import UTC, datetime
from typing import Any
from deerflow.persistence.embed_sessions.base import EmbedSessionStore
class MemoryEmbedSessionStore(EmbedSessionStore):
def __init__(self) -> None:
self._sessions: dict[str, dict[str, Any]] = {}
async def upsert(self, session_id: str, thread_id: str) -> dict[str, Any]:
now = datetime.now(UTC).isoformat()
existing = self._sessions.get(session_id)
if existing is None:
record = {
"session_id": session_id,
"thread_id": thread_id,
"created_at": now,
"updated_at": now,
}
else:
record = {
**existing,
"thread_id": thread_id,
"updated_at": now,
}
self._sessions[session_id] = record
return copy.deepcopy(record)
async def get(self, session_id: str) -> dict[str, Any] | None:
record = self._sessions.get(session_id)
return copy.deepcopy(record) if record is not None else None
@@ -0,0 +1,21 @@
"""ORM model for iframe embed session → thread lookups."""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
from deerflow.persistence.types import BeijingDateTime
class EmbedSessionRow(Base):
__tablename__ = "embed_sessions"
# Opaque id generated by the parent page and passed into the iframe URL.
session_id: Mapped[str] = mapped_column(String(128), primary_key=True)
thread_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
@@ -0,0 +1,49 @@
"""SQLAlchemy-backed embed session repository."""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.embed_sessions.base import EmbedSessionStore
from deerflow.persistence.embed_sessions.model import EmbedSessionRow
def _row_to_dict(row: EmbedSessionRow) -> dict[str, Any]:
data = row.to_dict()
for key in ("created_at", "updated_at"):
value = data.get(key)
if isinstance(value, datetime):
data[key] = value.isoformat()
return data
class EmbedSessionRepository(EmbedSessionStore):
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
async def upsert(self, session_id: str, thread_id: str) -> dict[str, Any]:
async with self._sf() as session:
row = await session.get(EmbedSessionRow, session_id)
now = datetime.now(UTC)
if row is None:
row = EmbedSessionRow(
session_id=session_id,
thread_id=thread_id,
created_at=now,
updated_at=now,
)
session.add(row)
else:
row.thread_id = thread_id
row.updated_at = now
await session.commit()
await session.refresh(row)
return _row_to_dict(row)
async def get(self, session_id: str) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(EmbedSessionRow, session_id)
return _row_to_dict(row) if row is not None else None
@@ -0,0 +1,39 @@
"""Create ``embed_sessions`` table for iframe embed status polling.
Revision ID: 20260602_01
Revises: 20260528_02
Create Date: 2026-06-02
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260602_01"
down_revision = "20260528_02"
branch_labels = None
depends_on = None
def _has_table(table_name: str) -> bool:
inspector = inspect(op.get_bind())
return table_name in inspector.get_table_names()
def upgrade() -> None:
if not _has_table("embed_sessions"):
op.create_table(
"embed_sessions",
sa.Column("session_id", sa.String(128), primary_key=True),
sa.Column("thread_id", sa.String(64), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_index("ix_embed_sessions_thread_id", "embed_sessions", ["thread_id"])
def downgrade() -> None:
if _has_table("embed_sessions"):
op.drop_table("embed_sessions")
@@ -38,6 +38,7 @@ from deerflow.persistence.scheduled_tasks.model import (
from deerflow.persistence.skills.model import SkillDisplayAdapterRow, SkillRow
from deerflow.persistence.tags.model import TagAssignmentRow, TagRow
from deerflow.persistence.thread_meta.model import ThreadMetaRow
from deerflow.persistence.embed_sessions.model import EmbedSessionRow
from deerflow.persistence.thread_shares.model import ThreadShareRow
from deerflow.persistence.tool_metrics.model import ToolCallMetricRow
from deerflow.persistence.user.model import UserRow
@@ -64,6 +65,7 @@ __all__ = [
"SkillDisplayAdapterRow",
"TagAssignmentRow",
"TagRow",
"EmbedSessionRow",
"ThreadMetaRow",
"ThreadShareRow",
"ToolCallMetricRow",