ai写作修改
This commit is contained in:
1 parent
30410b0a93
commit
7efedc7b44
23 files changed
+1355
-16
No files matched your search
@@ -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 |
|
||||
@@ -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 |
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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,
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
+18
@@ -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)
|
||||
+18
@@ -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
|
||||
+37
@@ -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
|
||||
+21
@@ -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))
|
||||
+49
@@ -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
|
||||
+39
@@ -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")
|
||||
+2
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user