diff --git a/frontend-web/docs/embed-iframe-chat.md b/frontend-web/docs/embed-iframe-chat.md new file mode 100644 index 00000000..3706668e --- /dev/null +++ b/frontend-web/docs/embed-iframe-chat.md @@ -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 + +``` + +```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 | diff --git a/frontend-web/docs/前端开发.md b/frontend-web/docs/前端开发.md index 502f4bb8..a096639c 100644 --- a/frontend-web/docs/前端开发.md +++ b/frontend-web/docs/前端开发.md @@ -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 | diff --git a/frontend-web/src/App.tsx b/frontend-web/src/App.tsx index 5649c323..4503b615 100644 --- a/frontend-web/src/App.tsx +++ b/frontend-web/src/App.tsx @@ -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={} /> + {/* Embedded workspace Q&A — no page/workspace sidebars; parent passes message/username/theme */} + + + + } + /> } /> } /> diff --git a/frontend-web/src/core/embed/params.ts b/frontend-web/src/core/embed/params.ts new file mode 100644 index 00000000..677ae364 --- /dev/null +++ b/frontend-web/src/core/embed/params.ts @@ -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); +} diff --git a/frontend-web/src/core/embed/status-api.ts b/frontend-web/src/core/embed/status-api.ts new file mode 100644 index 00000000..c5f5fd24 --- /dev/null +++ b/frontend-web/src/core/embed/status-api.ts @@ -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 { + 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 { + 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; +} diff --git a/frontend-web/src/core/embed/wujie.ts b/frontend-web/src/core/embed/wujie.ts index df4b977a..cd98e224 100644 --- a/frontend-web/src/core/embed/wujie.ts +++ b/frontend-web/src/core/embed/wujie.ts @@ -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; } diff --git a/frontend-web/src/open-canvas/components/ai-writing/AIWritingDraftPanel.tsx b/frontend-web/src/open-canvas/components/ai-writing/AIWritingDraftPanel.tsx index 0e8df08a..ddcfbb9e 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/AIWritingDraftPanel.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/AIWritingDraftPanel.tsx @@ -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 ( +
+
+ + 草稿生成中… +
+
+ {draftStreamingMarkdown ? ( + + ) : ( + + )} +
+
+ ); + } if (!artifact) { return ( @@ -50,6 +73,7 @@ export function AIWritingDraftPanel() {
+ {/* 生成完成:带编辑器的渲染器,开放润色 / 滑词等编辑能力 */} {content && !isCodeArtifact(content) ? ( ) : null} diff --git a/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx b/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx index 87e75736..6cf45208 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx @@ -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({
)} - {/* 草稿流式打字机视图 */} - {state.status === 'writing_draft' && state.streamingDraftSections.length > 0 && ( - + {/* 草稿生成中:左侧只展示思考过程(若有)+「内容生成中」骨架屏;正文实时显示在右侧画布 */} + {state.status === 'writing_draft' && ( + )} {/* 技能选择 LLM 流式打字(含 思考分离),select_skills 完成后自动清空。 @@ -485,12 +482,6 @@ export function AIWritingPanel({ )} - {state.status === 'writing_draft' && state.streamingDraftSections.length === 0 && ( -
- -
- )} - {isError && state.error && (
{state.error} diff --git a/frontend-web/src/open-canvas/components/ai-writing/DraftGeneratingView.tsx b/frontend-web/src/open-canvas/components/ai-writing/DraftGeneratingView.tsx new file mode 100644 index 00000000..8f65dc81 --- /dev/null +++ b/frontend-web/src/open-canvas/components/ai-writing/DraftGeneratingView.tsx @@ -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(null); + useEffect(() => { + if (preRef.current) preRef.current.scrollTop = preRef.current.scrollHeight; + }, [text]); + + return ( +
+
+ + 思考过程 +
+
+        {text}
+        {!done && (
+          
+        )}
+      
+
+ ); +} + +/** 「内容生成中」骨架屏:闪烁的占位行,提示正文正在右侧实时生成 */ +function GeneratingSkeleton() { + return ( +
+
+ + 内容生成中… + 正文已在右侧实时显示 +
+
+
+
+
+
+
+ +
+ ); +} + +/** + * 作家写作 / 按意见修改阶段,左侧的轻量「生成中」视图。 + * + * 刻意不再逐章节渲染流式正文(那套 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 ( +
+ {thinking && } + +
+ ); +} diff --git a/frontend-web/src/open-canvas/components/ai-writing/DraftStreamingPreview.tsx b/frontend-web/src/open-canvas/components/ai-writing/DraftStreamingPreview.tsx new file mode 100644 index 00000000..960f7881 --- /dev/null +++ b/frontend-web/src/open-canvas/components/ai-writing/DraftStreamingPreview.tsx @@ -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(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 ( +
+
+
+ ( +

+ {children} +

+ ), + h2: ({ children }) => ( +

+ {children} +

+ ), + h3: ({ children }) => ( +

+ {children} +

+ ), + p: ({ children }) => ( +

+ {children} +

+ ), + ul: ({ children }) => ( +
    + {children} +
+ ), + ol: ({ children }) => ( +
    + {children} +
+ ), + li: ({ children }) =>
  • {children}
  • , + blockquote: ({ children }) => ( +
    + {children} +
    + ), + pre: ({ children }) => ( +
    +                  {children}
    +                
    + ), + code: ({ className, children }) => { + const isBlock = Boolean(className?.startsWith("language-")); + return isBlock ? ( + + {children} + + ) : ( + + {children} + + ); + }, + }} + > + {markdown} +
    + +
    +
    + + {showScrollBtn && ( +
    + +
    + )} +
    + ); +} diff --git a/frontend-web/src/open-canvas/contexts/CanvasUIContext.tsx b/frontend-web/src/open-canvas/contexts/CanvasUIContext.tsx index 6f08bcdc..acff3bea 100644 --- a/frontend-web/src/open-canvas/contexts/CanvasUIContext.tsx +++ b/frontend-web/src/open-canvas/contexts/CanvasUIContext.tsx @@ -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(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), diff --git a/frontend-web/src/open-canvas/hooks/useAIWritingStream.ts b/frontend-web/src/open-canvas/hooks/useAIWritingStream.ts index af30a2c7..088bc819 100644 --- a/frontend-web/src/open-canvas/hooks/useAIWritingStream.ts +++ b/frontend-web/src/open-canvas/hooks/useAIWritingStream.ts @@ -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(() => { diff --git a/frontend-web/src/pages/EmbedChatPage.tsx b/frontend-web/src/pages/EmbedChatPage.tsx new file mode 100644 index 00000000..b78c089d --- /dev/null +++ b/frontend-web/src/pages/EmbedChatPage.tsx @@ -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(null); + const [threadId, setThreadId] = useState(() => + threadIdFromPath === "new" ? uuid() : (threadIdFromPath ?? uuid()), + ); + const [isNewThread, setIsNewThread] = useState(threadIdFromPath === "new"); + const hasAutoSentRef = useRef(false); + const lastRegisteredThreadRef = useRef(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 ( +
    + 缺少参数 sessionId:父页面需在 iframe URL 中传入由己方生成的会话 id,用于查询问答是否结束。 +
    + ); + } + + if (authError) { + return ( +
    + 登录失败:{authError} +
    + ); + } + + if (!authReady) { + return ( +
    + 正在进入问答… +
    + ); + } + + return ( + + +
    +
    +
    + +
    +
    +
    +
    +
    + ); +} diff --git a/offline-backend-20260512/backend/app/gateway/app.py b/offline-backend-20260512/backend/app/gateway/app.py index d2181001..d469f67b 100644 --- a/offline-backend-20260512/backend/app/gateway/app.py +++ b/offline-backend-20260512/backend/app/gateway/app.py @@ -39,6 +39,7 @@ from app.gateway.routers import ( models, multi_agent, notifications, + public_embed, public_scheduled_tasks, recommend, recommended_questions, @@ -870,6 +871,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) diff --git a/offline-backend-20260512/backend/app/gateway/deps.py b/offline-backend-20260512/backend/app/gateway/deps.py index 27fb52ac..244240d0 100644 --- a/offline-backend-20260512/backend/app/gateway/deps.py +++ b/offline-backend-20260512/backend/app/gateway/deps.py @@ -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, @@ -269,6 +273,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) diff --git a/offline-backend-20260512/backend/app/gateway/routers/public_embed.py b/offline-backend-20260512/backend/app/gateway/routers/public_embed.py new file mode 100644 index 00000000..0aeacf0f --- /dev/null +++ b/offline-backend-20260512/backend/app/gateway/routers/public_embed.py @@ -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()) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/embed_sessions/__init__.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/embed_sessions/__init__.py new file mode 100644 index 00000000..4aa4f909 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/embed_sessions/__init__.py @@ -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) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/embed_sessions/base.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/embed_sessions/base.py new file mode 100644 index 00000000..3838ddbb --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/embed_sessions/base.py @@ -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 diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/embed_sessions/memory.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/embed_sessions/memory.py new file mode 100644 index 00000000..cf42232f --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/embed_sessions/memory.py @@ -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 diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/embed_sessions/model.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/embed_sessions/model.py new file mode 100644 index 00000000..0a0fb07d --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/embed_sessions/model.py @@ -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)) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/embed_sessions/sql.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/embed_sessions/sql.py new file mode 100644 index 00000000..6b5ea8b3 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/embed_sessions/sql.py @@ -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 diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260602_01_embed_sessions.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260602_01_embed_sessions.py new file mode 100644 index 00000000..bb3e3d66 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260602_01_embed_sessions.py @@ -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") diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py index 9766c380..ba73d910 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py @@ -37,6 +37,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 @@ -62,6 +63,7 @@ __all__ = [ "SkillDisplayAdapterRow", "TagAssignmentRow", "TagRow", + "EmbedSessionRow", "ThreadMetaRow", "ThreadShareRow", "ToolCallMetricRow",