From 7efedc7b44c055f936bd8c8d44afead485474b9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Tue, 2 Jun 2026 20:36:22 +0800 Subject: [PATCH 01/13] =?UTF-8?q?ai=E5=86=99=E4=BD=9C=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend-web/docs/embed-iframe-chat.md | 352 ++++++++++++++++++ frontend-web/docs/前端开发.md | 4 + frontend-web/src/App.tsx | 11 + frontend-web/src/core/embed/params.ts | 46 +++ frontend-web/src/core/embed/status-api.ts | 52 +++ frontend-web/src/core/embed/wujie.ts | 7 + .../ai-writing/AIWritingDraftPanel.tsx | 24 ++ .../components/ai-writing/AIWritingPanel.tsx | 17 +- .../ai-writing/DraftGeneratingView.tsx | 79 ++++ .../ai-writing/DraftStreamingPreview.tsx | 148 ++++++++ .../open-canvas/contexts/CanvasUIContext.tsx | 12 + .../open-canvas/hooks/useAIWritingStream.ts | 45 ++- frontend-web/src/pages/EmbedChatPage.tsx | 180 +++++++++ .../backend/app/gateway/app.py | 4 + .../backend/app/gateway/deps.py | 12 + .../app/gateway/routers/public_embed.py | 194 ++++++++++ .../persistence/embed_sessions/__init__.py | 18 + .../persistence/embed_sessions/base.py | 18 + .../persistence/embed_sessions/memory.py | 37 ++ .../persistence/embed_sessions/model.py | 21 ++ .../persistence/embed_sessions/sql.py | 49 +++ .../versions/20260602_01_embed_sessions.py | 39 ++ .../deerflow/persistence/models/__init__.py | 2 + 23 files changed, 1355 insertions(+), 16 deletions(-) create mode 100644 frontend-web/docs/embed-iframe-chat.md create mode 100644 frontend-web/src/core/embed/params.ts create mode 100644 frontend-web/src/core/embed/status-api.ts create mode 100644 frontend-web/src/open-canvas/components/ai-writing/DraftGeneratingView.tsx create mode 100644 frontend-web/src/open-canvas/components/ai-writing/DraftStreamingPreview.tsx create mode 100644 frontend-web/src/pages/EmbedChatPage.tsx create mode 100644 offline-backend-20260512/backend/app/gateway/routers/public_embed.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/embed_sessions/__init__.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/embed_sessions/base.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/embed_sessions/memory.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/embed_sessions/model.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/embed_sessions/sql.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260602_01_embed_sessions.py 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", From 9ae6184dc109dd59f954532b11c4aeb4e3c19987 Mon Sep 17 00:00:00 2001 From: fengxiangdebaozi <10845145+fengxiangdebaozi@user.noreply.gitee.com> Date: Tue, 2 Jun 2026 21:15:46 +0800 Subject: [PATCH 02/13] =?UTF-8?q?=E4=B8=9A=E5=8A=A1=E9=93=BE=E6=9D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend-web/docs/multi-agent-backend-dev.md | 25 + .../docs/multi-agent-business-chain-dev.md | 399 ++++++++++ frontend-web/docs/multi-agent-frontend-dev.md | 5 +- frontend-web/package.json | 3 + frontend-web/src/pages/WorkspaceRoutes.tsx | 9 + .../src/roundtable-planning/api/chains.ts | 129 ++++ .../src/roundtable-planning/api/drafts.ts | 9 + .../components/BusinessChainPicker.tsx | 266 +++++++ .../components/RecommendAgentsDialog.tsx | 95 ++- .../hooks/useStep2Orchestration.ts | 340 ++++++++- .../roundtable-planning/lib/business-chain.ts | 72 ++ .../pages/BusinessChainEditorPage.tsx | 708 ++++++++++++++++++ .../pages/RoundtablePlanningPage.tsx | 47 +- .../backend/app/gateway/app.py | 4 + .../backend/app/gateway/deps.py | 4 + .../app/gateway/routers/roundtable_chains.py | 162 ++++ .../deerflow/persistence/models/__init__.py | 2 + .../persistence/roundtable_chains/__init__.py | 18 + .../persistence/roundtable_chains/model.py | 44 ++ .../persistence/roundtable_chains/sql.py | 123 +++ 20 files changed, 2440 insertions(+), 24 deletions(-) create mode 100644 frontend-web/docs/multi-agent-business-chain-dev.md create mode 100644 frontend-web/src/roundtable-planning/api/chains.ts create mode 100644 frontend-web/src/roundtable-planning/components/BusinessChainPicker.tsx create mode 100644 frontend-web/src/roundtable-planning/lib/business-chain.ts create mode 100644 frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx create mode 100644 offline-backend-20260512/backend/app/gateway/routers/roundtable_chains.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_chains/__init__.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_chains/model.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_chains/sql.py diff --git a/frontend-web/docs/multi-agent-backend-dev.md b/frontend-web/docs/multi-agent-backend-dev.md index 3e5bf522..b0ab554a 100644 --- a/frontend-web/docs/multi-agent-backend-dev.md +++ b/frontend-web/docs/multi-agent-backend-dev.md @@ -649,6 +649,31 @@ JSON 列在 repo(`sql.py`)里用 `json.dumps/loads` 手动序列化(与 `a --- +## 7B. 业务链条持久化(`roundtable_chains.py`,2026-06) + +「业务链条」是用户在配置页编排的**可复用、命名、有序**的智能体编排模板;选中后 Step 2 由总控**按链条顺序派活**(前端编排,后端零改动)。链条持久化仿 §7A,新增一张表 + 一组 CRUD 路由。完整前后端设计见 `frontend-web/docs/multi-agent-business-chain-dev.md`。 + +### 7B.1 数据模型(`deerflow.persistence.roundtable_chains`) + +- 表 **`roundtable_chains`**:`id`(PK str64) / `user_id`(idx) / `title`(512) / `description`(512, nullable) / `seats`(**`PortableLongText`** JSON:有序 `[{agent_id,name,description}, ...]`,`seats[0]` 先发言) / `created_at` / `updated_at`(**`BeijingDateTime`**)。 +- ORM `RoundtableChainRow` 登记进 `persistence/models/__init__.py`,启动 `Base.metadata.create_all` 自动建表;store `make_roundtable_chain_store(sf)` 接到 `app.state.roundtable_chain_store`(`deps.py`)。 + +### 7B.2 路由(`app/gateway/routers/roundtable_chains.py`,前缀 `/api/roundtable-chains`) + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `` | 当前用户全部链条(含 seats,按 `updated_at` 倒序) | +| POST | `` | 新建(可带客户端 `id`);校验席位 `2 ≤ N ≤ 8` + 去重 | +| GET | `/{id}` | 取单条;非属主 → 404 | +| PUT | `/{id}` | partial 更新(`exclude_unset`);带 seats 时同样校验 | +| DELETE | `/{id}` | 204;非属主 → 404 | + +- 鉴权/属主用 `_current_user_id(request)`,跨用户一律 404,与 `roundtable_drafts` 一致。 +- 席位约束 `MIN_SEATS=2 / MAX_SEATS=8` 与前端 `CHAIN_MIN/MAX_SEATS`、推荐弹窗 `MIN/MAX_SELECTED` 对齐;前端配置页保存前先校验,后端 `_validate_seats` 兜底(400)。 +- 前端 `api/chains.ts` 直接打这组路由(snake↔camel 边界映射);测试 `tests/test_roundtable_chains.py`(CRUD + 部分更新 + 跨用户隔离)。 + +--- + ## 8. 内置席位 SOUL 设计要点 每个 `roundtable-*`(除 intent / recommender)遵循统一结构:角色定位、核心职责、输出风格(emoji + 简短结论)、边界(只产出本席位、等总控派活)。 diff --git a/frontend-web/docs/multi-agent-business-chain-dev.md b/frontend-web/docs/multi-agent-business-chain-dev.md new file mode 100644 index 00000000..6a9a5338 --- /dev/null +++ b/frontend-web/docs/multi-agent-business-chain-dev.md @@ -0,0 +1,399 @@ +# 业务链条模式 · 需求与分步实现文档 + +> 面向「智能体推荐弹窗」的扩展:在现有**智能推荐**之外,新增**业务链条选择**与**业务链条配置**两块能力;选定业务链条后,Step 2 圆桌研讨**按链条内的固定顺序**逐个发言(而非现在的总控派活)。 +> +> 配套阅读: +> - 前端总览:`multi-agent-frontend-dev.md`(推荐弹窗 §6.2、Step 2 编排 §5.4、草稿持久化 §5.7) +> - 后端总览:`multi-agent-backend-dev.md`(Step 2 §6、草稿/历史持久化 §7A) +> +> 本文档约定:**「席位 / 智能体」**指一个 agent;**「业务链条 / chain」**指一组有序排列的 agent 配置,是可命名、可复用、可持久化的实体。 + +--- + +## 0. 需求原文拆解 + +用户原始诉求逐条拆成可实现条目: + +| # | 原始诉求 | 拆解 | +|---|----------|------| +| 1 | 推荐弹窗要有「模式选择」 | 弹窗顶部加模式切换(智能推荐 / 业务链条),两种模式互斥,确认后都产出**有序**的 `SelectedAgent[]` | +| 2 | 「智能推荐」就是现在这个 | 现有 `RecommendAgentsDialog` 整体收进「智能推荐」模式,行为不变 | +| 3 | 「业务链条选择」是卡片列表 | 卡片 = 标题 + 一排智能体名 tag;tag 不同颜色;tag 之间有**向左箭头**连接,表达链条次序 | +| 4 | 业务链条需要一个**配置页面** | 左侧智能体列表(复用 `GET /api/agents`),右侧有序长条卡片(名称 + 描述);支持右侧内部拖拽排序、左→右拖入指定位置 | +| 5 | 配置页用弹窗还是新页面? | **本文档决策:独立路由页面**(理由见 §2.1) | +| 6 | 选定链条后,Step 2 用链条内智能体研讨 | 复用既有 Step 2 init/stream;**总控(leader)按链条顺序逐个派活**(§6) | +| 7 | 规划成分步可实现的需求文档 | 见 §7 分阶段实现 | + +--- + +## 1. 目标与非目标 + +### 1.1 目标 +- 推荐弹窗成为「**席位来源选择器**」:无论智能推荐还是业务链条,最终都向 `onConfirm` 交付一个**有序的 `{ agent_id, name }[]`** + 一个 `orchestrationMode`。 +- 业务链条是**用户可创建、命名、保存、复用、删除**的有序智能体编排模板。 +- Step 2 支持两种编排: + - `recommend` 模式 → 现有总控**自由派活**循环(不动)。 + - `chain` 模式 → 总控仍是派活方,但**被约束按链条顺序逐个派活**(新增)。 + +### 1.2 非目标(本期不做,留扩展点) +- 链条内每个席位的 per-seat prompt/temperature 真实覆盖(仍仅前端记录,沿用现状)。 +- 链条的分支 / 条件流转(只做线性顺序链)。 +- 链条跨用户共享 / 市场(先做「本人私有链条」)。 +- 链条版本管理 / 协作编辑。 + +--- + +## 2. 关键设计决策 + +### 2.1 决策 A:配置页面用「独立路由页面」而非弹窗 ✅ + +**结论:业务链条的「编辑/管理」用独立路由页面 `/page/workspace/roundtable/chains`;业务链条的「选择」仍内嵌在推荐弹窗里(卡片列表)。** + +理由: + +| 维度 | 弹窗 | 独立页面 ✅ | +|------|------|------------| +| 双栏 + 拖拽 + 长条卡片 | 弹窗宽度(`sm:max-w-3xl`)+ `max-h-[85vh]` 拖拽空间局促,左右栏挤 | 整屏宽度,拖拽手感好 | +| 复用入口 | 只能从推荐弹窗进 | 可从侧栏、推荐弹窗「管理链条」按钮、深链接进入 | +| 可深链接/刷新 | 不可 | `/roundtable/chains` 可直达、可分享 | +| 推荐弹窗内嵌编辑器 | 弹窗套弹窗 / 模式套编辑,状态复杂 | 关注点分离:弹窗只「选」,页面只「编」 | + +**分工**: +- **推荐弹窗**:模式选择 + 业务链条**选择**(只读卡片列表,可点「去配置」跳到页面)。 +- **配置页面**:业务链条的**增删改**(双栏拖拽编辑器 + 链条列表管理)。 + +> 备选:若产品坚持不加路由,可改为**全屏 Dialog(`max-w-[90vw] h-[90vh]`)**承载编辑器,从推荐弹窗内打开。结构与页面版一致,仅外壳不同。本文档按「独立页面」实现,§7 标注了切换成本。 + +### 2.2 决策 B:链条持久化 —— 后端 MySQL(确定) ✅ + +**结论:业务链条存后端 MySQL,仿 `roundtable_drafts`(§7A)新建 `roundtable_chains` 表 + `/api/roundtable-chains` 路由,按登录用户隔离、跨设备保留。** 不走 localStorage。 + +理由:链条是可复用、需跨设备/会话保留的模板,与草稿/推荐历史性质一致;草稿当年正是因 localStorage 换设备即丢才迁后端(§7A),链条不重复踩坑。 + +落地: +- 表/路由设计见 §5.2,是「复制 §7A 草稿持久化、把字段换成 chain」的机械工作。 +- 前端 `api/chains.ts` 直接打后端接口(snake_case↔camelCase 在这层收口,仿 `api/drafts.ts`),**不提供 localStorage 实现**。 +- 后端表必须先于前端 UI 落地(§7 阶段 0 即包含后端表/路由)。 + +### 2.3 决策 C:拖拽库选型 —— `@dnd-kit` + +仓库**当前无任何拖拽库**(`package.json` 无 `@dnd-kit` / `react-dnd` / `sortablejs`;`@xyflow/react` 是节点图,不适合列表排序)。 + +| 方案 | 取舍 | +|------|------| +| **`@dnd-kit/core` + `@dnd-kit/sortable`**(推荐) | React 19 友好、可访问性好、跨容器拖入 + 列表排序 + 插入指示器开箱即用;新增 ~30KB gzip 依赖 | +| 原生 HTML5 DnD(零依赖兜底) | 不加依赖,但跨容器插入位置指示、键盘可达性都要手写,易出 bug | + +**建议**:新增 `@dnd-kit/core`、`@dnd-kit/sortable`、`@dnd-kit/utilities`。若产品方禁止新增依赖,退回原生 HTML5 DnD(§7 阶段 2 标注差异)。 + +### 2.4 决策 D:Step 2「总控按链条顺序派活」,后端零改动 ✅ + +**形态:总控(leader)仍是唯一派活方,但每一轮被前端约束「只能派给链条里的下一个席位」,逐个走完整条链,最后由总控收口。** 区别于 recommend 模式的「总控自由决定派给谁」。 + +为什么后端无需改动: +- Step 2 后端 `streamMultiAgent({ agentType: "leader" })` 每轮返回 `dispatched = [[agent, task], ...]`;`agentType: "special"` 单独驱动某席位跑一轮。这两个原语已足够。 +- 每个 special 跑完,后端 `_broadcast` 会把「子智能体 X 完成…交付内容为:…」append 进**其它所有席位 + 总控 thread** 的历史(后端 §6.4),所以链路上下文天然累积。 +- chain 模式只是**改变前端怎么提示 leader、以及怎么校验它的派活**——把每轮 leader 的提示词约束成「请向链条第 i 位【X】派活」,并对返回的 `dispatched` 做「必须命中链条下一席位」的校验/纠偏。**后端路由、SOUL、表结构都不动。** + +→ chain 模式 = 在 `useStep2Orchestration` 里新增 `runChainOrchestration`:按链条游标逐位调 leader(约束派给该位)→ 拿到 task → 调 special → 下一位 → 链尾后调一次 leader 收口。详见 §6。 + +> 与「前端绕过 leader 直接顺序 special」的区别:本方案**保留总控在每一步的协调/串接职责**(leader 结合前序进展为下一席位编写针对性任务、并在派活说明里承上启下),更符合「总控按链条顺序派活」的语义,圆桌可视化里总控气泡也照常出现。 + +--- + +## 3. 数据模型与类型 + +新增前端类型,建议放 `frontend-web/src/roundtable-planning/lib/business-chain.ts`: + +```ts +/** 业务链条里的一个有序席位。 */ +export interface ChainSeat { + agent_id: string; + /** 落库时的快照名/描述,避免 agent 改名后链条显示错乱;展示优先用实时 agent 数据,回退到快照。 */ + name: string; + description?: string; +} + +/** 一条可复用的业务链条。 */ +export interface BusinessChain { + id: string; + title: string; + description?: string; + /** 有序席位列表;seats[0] 是第一个发言者。 */ + seats: ChainSeat[]; + createdAt?: string; + updatedAt?: string; +} + +/** 推荐弹窗确认后回传给主页面的统一结果。 */ +export interface SeatSelectionResult { + /** 有序席位(与现有 handleConfirmAgents 入参兼容)。 */ + agents: Array<{ agent_id: string; name: string }>; + /** 决定 Step 2 走哪种编排。 */ + mode: "recommend" | "chain"; + /** chain 模式下带上来源链条(用于草稿快照 / Step 2 展示链名)。 */ + chain?: Pick; +} +``` + +**约束**:链条席位数沿用圆桌的 `2 ≤ N ≤ 8`(`MIN_SELECTED`/`MAX_SELECTED`),配置页保存时校验。 + +**tag 配色**:复用 `roundtable-constants.ts::getAvatarTypeFor(agentId, index)`,得到 `blue|purple|emerald|amber|pink|human`,再映射到已有的 avatar 配色 token(与圆桌可视化、Step 2 气泡同一套色板,视觉统一)。 + +--- + +## 4. 现有代码改动点速查 + +| 位置 | 改动 | 说明 | +|------|------|------| +| `components/RecommendAgentsDialog.tsx` | 包一层「模式选择」外壳,现有内容收进「智能推荐」Tab | `onConfirm` 签名从 `agents[]` 升级为 `SeatSelectionResult`(或加可选第二参 `mode`,见 §7 阶段 3 的兼容策略) | +| `pages/RoundtablePlanningPage.tsx` | `handleConfirmAgents` 接收 `mode`,存到新状态 `orchestrationMode` | 透传给 `useStep2Orchestration` | +| `hooks/useStep2Orchestration.ts` | 新增 `runChainOrchestration` + `orchestrationMode` 分支 | init effect 里按 mode 选择 `runOrchestration` 或 `runChainOrchestration` | +| `lib/roundtable-constants.ts` | 复用 `getAvatarTypeFor` / `SelectedAgent` | 不改,仅引用 | +| `hooks/useDraftPersistence.ts` + `api/drafts.ts` | Step2 快照新增 `orchestrationMode` + `chain` 字段 | 草稿能记住「这次是链条模式」 | +| `pages/WorkspaceRoutes.tsx` | 注册 `roundtable/chains` 路由 | 仿 `roundtable/planning` | +| `components/page-sidebar.tsx`(如有侧栏入口) | 加「业务链条」入口(可选) | 也可只从推荐弹窗进 | +| `api/chains.ts`(新增) | 链条 CRUD 客户端 | 直接打后端 `/api/roundtable-chains` | +| 后端 `roundtable_chains` | 表 + 路由(MySQL) | 仿 `roundtable_drafts`,**必做** | + +--- + +## 5. 链条持久化 API 设计 + +### 5.1 前端客户端 `api/chains.ts`(直接打后端) + +```ts +listChains(): Promise // 当前用户全部链条 +getChain(id: string): Promise +createChain(input: Omit): Promise +updateChain(id: string, patch: Partial): Promise +deleteChain(id: string): Promise +``` + +- 实现用 `apiFetch` 打 `/api/roundtable-chains`(§5.2),snake_case↔camelCase 在这层收口(仿 `api/drafts.ts`)。 +- 不提供 localStorage 实现;后端表是前端 UI 的硬依赖。 + +### 5.2 后端表 + 路由(仿 §7A `roundtable_drafts`) + +- 表 `roundtable_chains`:`id`(PK) / `user_id`(idx) / `title`(512) / `description`(text) / `seats`(`PortableLongText`,存 `ChainSeat[]` JSON) / `created_at` / `updated_at`(`BeijingDateTime`)。 +- 路由 `app/gateway/routers/roundtable_chains.py`,前缀 `/api/roundtable-chains`:`GET ``(列表)/ `POST ``(建)/ `GET /{id}` / `PUT /{id}`(partial,`exclude_unset`)/ `DELETE /{id}`。 +- 鉴权/属主:`_current_user_id(request)`,跨用户访问一律 404,与 `roundtable_drafts` 完全一致。 +- 启动 `Base.metadata.create_all` 自动建表(MySQL 生产 / sqlite 本地走同一套 SQLAlchemy 代码,`PortableLongText` 各自降级),登记进 `persistence/models/__init__.py`,store 接到 `app.state` 并 `include_router`。 + +> 整体是「复制 §7A 的草稿持久化、把字段换成 chain」的机械工作,风险低。 + +--- + +## 6. Step 2 链条模式编排设计(`runChainOrchestration`) + +### 6.1 与现有 `runOrchestration` 的差异 + +| 维度 | `runOrchestration`(recommend) | `runChainOrchestration`(chain,新增) | +|------|--------------------------------|----------------------------------------| +| 谁派活 | 总控 leader | 总控 leader(**不变**) | +| 派给谁由谁定 | leader 自由决定 | 前端按链条游标**约束 leader 只能派给下一席位** | +| 发言顺序 | leader 每轮自由派 | **链条数组的固定顺序** | +| 循环结构 | `while` 直到 leader 返回 `[]` 共识 / MAX_CYCLES | 按链条游标走一趟(每位:leader 派活 → special 交付) | +| 收口 | leader 返回 `[]` 即共识 | 链尾后再调一次 leader 做总结收口,产出 `lastLeaderContent` + `hasConsensus` | + +> **核心**:leader 始终在场,只是**每轮的派活目标被前端钉死成链条的下一席位**——前端在 leader 的 `newMessage` 里写明「本轮请通过 `agent_orchestration` 仅向【X】派活」,并对 `leaderRes.dispatched` 做命中校验/纠偏。 + +### 6.2 伪代码 + +``` +runChainOrchestration(seedMessage, ids, coord, chainSeats): + isOrchestrating = true + cancel = false + N = chainSeats.length + + for (let i = 0; i < N; i++): // 链条游标,逐位推进 + if cancel: break + seat = chainSeats[i] + seatMeta = agentNameToRole[seat.agent_id] + + // ── (a) 总控派活给「链条下一席位」────────────────────────── + highlightSpeaker(null) // leader 阶段不高亮任何 sub + leaderBubble = appendStreamingDialogue("总控协调", confidence:"链条派活中") + leaderTask = buildChainLeaderTask({ + seedMessage, // 任务目标/约束(首轮) + position: i, total: N, + nextSeat: seat, // 「本轮请仅向【X(seat.agent_id)】派活」 + isFirst: i === 0, + }) + leaderRes = await streamMultiAgent(leader, ids, coord, leaderTask, model=globalModel) + + // 约束/纠偏:取 dispatched 中命中 seat.agent_id 的那条 task; + // 若 leader 没派或派错(派给非 seat、或派给多个),前端兜底: + // - 命中就用它给出的 task 文本(保留总控的承上启下措辞); + // - 没命中则用 leader 的正文 content 作为兜底 task,或回退到模板 task。 + task = pickChainTask(leaderRes, seat) + // leader 若返回 clarification → 暂停链条,走与现状一致的澄清条(§6.3) + finalizeDialogue(leaderBubble, leaderRes.content, "派活说明") + + // ── (b) 被派席位交付(special,顺序、不并行)────────────────── + markSpeakerStreaming(seatMeta.speakerId) + seatBubble = appendStreamingDialogue(seatMeta.displayName, confidence:"调度中") + perSeatModel = agentConfigs[seatMeta.speakerId]?.model || globalModel + specRes = await streamMultiAgent(special, ids, seat.agent_id, task, model=perSeatModel) + finalizeDialogue(seatBubble, specRes.content, "子智能体交付") + markSpeakerIdle(seatMeta.speakerId) + // 后端 _broadcast 已把本席位交付注入其余 thread 历史 → 下一席位天然可见 + + if cancel: return + + // ── (c) 总控收口:综合全链交付,产出最终结论 ────────────────── + leaderBubble = appendStreamingDialogue("总控协调", confidence:"汇总链条结论") + leaderRes = await streamMultiAgent(leader, ids, coord, + "各链条席位已按顺序完成发言,请综合所有交付,输出最终方案,不要再派活。") + finalizeDialogue(leaderBubble, leaderRes.content, "全案盖印") + setLastLeaderContent(leaderRes.content) + setHasConsensus(true); setConsensusPercentage(100) + isOrchestrating = false +``` + +要点: +- **派活方仍是总控**,符合「总控按链条顺序派活」的语义;圆桌可视化里每位席位前都先出现一个总控派活气泡,再出现该席位交付气泡。 +- **约束手段是 prompt + 前端校验,不动后端**:`buildChainLeaderTask` 把「本轮只能派给【X】」写进 leader 的 `newMessage`;`pickChainTask` 对 `leaderRes.dispatched` 做命中校验,派错/未派时兜底,保证链条游标严格推进。 +- **复用现有三段式气泡 / 高亮 / abort 基建**(`appendStreamingDialogue` / `finalizeDialogue` / `markSpeakerStreaming` / `activeAbortersRef` / `orchestrationCancelRef`),只是驱动顺序不同。 +- **special 仍是顺序**(不并行),与现状不变量一致(文件顶部注释 §1)。 +- **收口 leader 调用默认开**(产出 Step 3 结论);若链条末位本身是「总结陈述」类,可关掉收口,直接拿末位交付当 `lastLeaderContent`。配置位 `chainNeedsLeaderSummary`(默认 true)。 + +### 6.3 澄清 / 挂起 / 恢复 / 干预 +- **澄清**:链条中途某轮 leader 返回 `clarification` → 与现状一致,暂停链条、底部弹澄清条;用户回答后从**当前游标**继续(需记录 `chainCursor`)。 +- **挂起/恢复**:`isRoundTableRunning` toggle 复用现有机制;恢复时从 `chainCursor` 续跑。MVP 若不做断点续跑,可先只支持「整体停止 / 从头重跑」,UI 文案说明(扩展点,§7 阶段 4 标注)。 +- **干预**:可在两席位之间插入一条用户指令,由下一轮 leader 派活前消化。MVP 同上可后置。 + +### 6.4 多轮链条(扩展点,非 MVP) +单趟遍历是 MVP。若要「链条跑完后总控判断是否再来一轮」,可在收口 leader 处复用 `dispatched===[]?` 判定:收口 leader 若仍派活则回到链条头再跑一轮,直到共识或 MAX_CYCLES。MVP 不做,避免复杂度。 + +### 6.5 init 复用 +chain 模式的 Step 2 init **完全复用** `initMultiAgent`:`agentNames = chainSeats.map(s => s.agent_id)`(**按链条顺序**传入,使 `buildRoleTemplates` 的 seat id 顺序 = 链条顺序,且后端注入 coordinator 的「可调度席位清单」也按此序)。init effect 里按 `orchestrationMode` 决定 init 完成后调 `runOrchestration` 还是 `runChainOrchestration`。 + +--- + +## 7. 分阶段实现计划 + +> 每阶段都可独立交付、独立验收。建议顺序:**数据层 → 配置页 → 弹窗模式 → 链条选择 → Step2 编排 → 持久化兼容与收尾**。 + +### 阶段 0 · 后端链条表 + 脚手架(1.5 天) ✅ 已完成 +**目标**:打地基(含后端持久化),不含前端 UI。 +- [x] **后端**:仿 §7A `roundtable_drafts` 新建 `roundtable_chains` 表 + `/api/roundtable-chains` 路由(§5.2): + - [x] `persistence/roundtable_chains/`(model + sql repo + `make_roundtable_chain_store`),登记进 `persistence/models/__init__.py`。 + - [x] `app/gateway/routers/roundtable_chains.py`(GET 列表 / POST / GET{id} / PUT{id} / DELETE),鉴权属主同 `roundtable_drafts`;席位 2–8 + 去重校验。 + - [x] `deps.py` 接 `app.state.roundtable_chain_store`,`app.py` `include_router`。 + - [x] 后端测试 `tests/test_roundtable_chains.py`:CRUD + 部分更新 + 跨用户隔离(5 用例全过)。 +- [x] **前端**:新增 `lib/business-chain.ts`(`ChainSeat` / `BusinessChain` / `SeatSelectionResult`,§3)+ `api/chains.ts`(直接打后端,§5.1)。 +- [x] (决策 C)安装 `@dnd-kit/core` `@dnd-kit/sortable` `@dnd-kit/utilities`。 +- **验收**:✅ 后端 5 用例通过;✅ 表注册进 `Base.metadata`、路由 `/api/roundtable-chains` 加载正常;✅ ruff 0 错;✅ 前端 typecheck 新增文件 0 错。 + +### 阶段 1 · 业务链条配置页面(2–3 天) ✅ 已完成 +**目标**:能创建/编辑/保存/删除链条。 +- [x] 路由:`WorkspaceRoutes.tsx` 注册 `roundtable/chains`(套 `WorkspaceLayout`;本页**不需要** `ChatRuntime`,无沙箱依赖)。 +- [x] 页面 `roundtable-planning/pages/BusinessChainEditorPage.tsx`: + - [x] 顶部:链条列表下拉(` { + const id = e.target.value; + if (!id) { + startNewChain(); + return; + } + const chain = chains.find((c) => c.id === id); + if (chain) loadChain(chain); + }} + className="h-8 rounded-md border border-input bg-card px-2.5 text-sm font-medium shadow-sm outline-none focus:border-primary/60 focus:ring-2 focus:ring-primary/20" + > + + {chains.map((c) => ( + + ))} + + +
    + +
    + + +
    + + + {/* 标题 + 描述 */} +
    +
    + 标题 + setTitle(e.target.value)} + placeholder="如:应急方案三段链" + className="bg-card" + maxLength={120} + /> +
    +
    + 描述 + setDescription(e.target.value)} + placeholder="链条用途说明(可选)" + className="bg-card" + maxLength={500} + /> +
    +
    + + {/* 双栏拖拽编辑器 */} + +
    + {/* 左栏:候选智能体 */} +
    +
    +
    + + 候选智能体 + + {pool.length} + + + 拖到右侧 / 点击加入 + +
    + +
    +
    + {poolLoading ? ( +
    + + 正在加载候选智能体… +
    + ) : pool.length === 0 ? ( +
    + {search || filterTagIds.length + ? "没有匹配的智能体,换个关键词或标签试试。" + : `候选池为空,请先在「智能体」里创建至少 ${CHAIN_MIN_SEATS} 个智能体。`} +
    + ) : ( + pool.map((a) => ( + addAgentToSeats(a.agent_id)} + /> + )) + )} +
    +
    + + {/* 右栏:有序席位 */} + +
    + + + {activeDrag ? ( +
    + + {activeDrag.name} +
    + ) : null} +
    +
    +
    + ); +} + +// ── 左栏候选卡片(draggable)───────────────────────────────────────────── + +function PoolAgentCard({ + agent, + inChain, + onAdd, +}: { + agent: RecommendCandidate; + inChain: boolean; + onAdd: () => void; +}) { + const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ + id: POOL_PREFIX + agent.agent_id, + disabled: inChain, + }); + return ( +
    +
    +
    {agent.name || agent.agent_id}
    + {agent.description && ( +
    + {agent.description} +
    + )} +
    + {inChain ? ( + + 已加入 + + ) : ( + + )} +
    + ); +} + +// ── 右栏(droppable 容器 + sortable 列表)─────────────────────────────── + +function SeatsColumn({ + seats, + seatItemIds, + seatCountInvalid, + onRemove, +}: { + seats: ChainSeat[]; + seatItemIds: string[]; + seatCountInvalid: boolean; + onRemove: (agentId: string) => void; +}) { + const { setNodeRef, isOver } = useDroppable({ id: SEATS_DROPPABLE_ID }); + return ( +
    +
    + + 链条席位 + + 按顺序发言 + + + {seats.length} / {CHAIN_MAX_SEATS} + {seats.length < CHAIN_MIN_SEATS && ` · 至少 ${CHAIN_MIN_SEATS}`} + +
    +
    + {seats.length === 0 ? ( +
    + + 从左侧把智能体拖到这里,组成有序业务链条 +
    + ) : ( + + {seats.map((seat, index) => ( + onRemove(seat.agent_id)} + /> + ))} + + )} +
    +
    + ); +} + +// ── 单个席位长条(sortable)───────────────────────────────────────────── + +function SeatStrip({ + seat, + index, + isLast, + onRemove, +}: { + seat: ChainSeat; + index: number; + isLast: boolean; + onRemove: () => void; +}) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = + useSortable({ id: SEAT_PREFIX + seat.agent_id }); + const avatarType = getAvatarTypeFor(seat.agent_id, index); + return ( +
    + {/* 顺序连接线(最后一个不画) */} + {!isLast && ( + + )} + + + {index + 1} + +
    +
    {seat.name || seat.agent_id}
    + {seat.description && ( +
    + {seat.description} +
    + )} +
    + +
    + ); +} diff --git a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx index 71e990b1..84f9c34e 100644 --- a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx @@ -23,6 +23,7 @@ * 6) 模型选择全局默认 `availableModels[0].name`,Persona 覆盖更高优先级 */ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; import { Activity, Check, @@ -36,6 +37,7 @@ import { Clock, Cpu, History, + ListOrdered, Loader2, Pencil, Save, @@ -69,10 +71,12 @@ import { type RoleEntry, type SelectedAgent, } from "@/roundtable-planning/lib/roundtable-constants"; +import type { SeatSelectionResult } from "@/roundtable-planning/lib/business-chain"; export default function RoundtablePlanningPage() { const { theme } = useTheme(); const isDark = theme === "dark"; + const navigate = useNavigate(); const { models: availableModels, isLoading: modelsLoading } = useModels(); @@ -81,6 +85,13 @@ export default function RoundtablePlanningPage() { const [showToast, setShowToast] = useState(null); const [isRecommendDialogOpen, setIsRecommendDialogOpen] = useState(false); const [selectedModel, setSelectedModel] = useState(""); + // Step 2 编排模式:recommend = 总控自由派活;chain = 总控按业务链条顺序派活。 + // 由推荐弹窗确认时的 SeatSelectionResult.mode 决定(见 handleConfirmAgents)。 + const [orchestrationMode, setOrchestrationMode] = useState<"recommend" | "chain">( + "recommend", + ); + // chain 模式来源链条(仅展示/持久化用)。 + const [chainMeta, setChainMeta] = useState<{ id: string; title: string } | null>(null); const [step2SidebarExpanded, setStep2SidebarExpanded] = useState(true); // 角色面板(LeftSidebar)。原页面把它当成两步共用的展示数据,这里继续放在 @@ -154,6 +165,7 @@ export default function RoundtablePlanningPage() { const step2 = useStep2Orchestration({ currentStep, selectedModel, + orchestrationMode, buildInitialIntent, triggerToast, setRoles, @@ -205,9 +217,17 @@ export default function RoundtablePlanningPage() { consensusPercentage: step2.consensusPercentage, budgetLimit: step2.budgetLimit, seatStubMessages: step2.seatStubMessages, + orchestrationMode, + chain: chainMeta, }), hydrateStep1: step1.hydrateFromDraft, - hydrateStep2: step2.hydrateFromDraft, + // 包一层:先把页面持有的编排模式 / 链条恢复出来(老草稿无字段 → recommend), + // 再交给 step2 hook 恢复其余 Step 2 状态。 + hydrateStep2: (snapshot) => { + setOrchestrationMode(snapshot.orchestrationMode ?? "recommend"); + setChainMeta(snapshot.chain ?? null); + step2.hydrateFromDraft(snapshot); + }, cancelAllStreams: () => { step2.abortAllInFlight(); step1.stopIntentStream(); @@ -267,23 +287,32 @@ export default function RoundtablePlanningPage() { // ─── 步骤间转场 handler ─────────────────────────────────── const handleConfirmAgents = useCallback( - (agents: Array<{ agent_id: string; name: string }>) => { - const normalized: SelectedAgent[] = agents.map(({ agent_id, name }) => ({ + (result: SeatSelectionResult) => { + const normalized: SelectedAgent[] = result.agents.map(({ agent_id, name }) => ({ agent_id, // 内置 agent 走 BUILTIN_AGENT_META;非内置用候选池传过来的 name。 name: BUILTIN_AGENT_META[agent_id]?.displayName ?? name ?? agent_id, })); step2.setSelectedAgents(normalized); step2.resetInitGate(); + // result.mode 决定 Step 2 编排:recommend = 总控自由派活;chain = 按链条顺序派活。 + setOrchestrationMode(result.mode); + setChainMeta(result.mode === "chain" ? result.chain ?? null : null); setIsRecommendDialogOpen(false); setCurrentStep(2); - triggerToast("成功确认!已切换至第二阶段圆桌研讨系统"); + triggerToast( + result.mode === "chain" + ? `成功确认!将按业务链条「${result.chain?.title ?? ""}」顺序研讨` + : "成功确认!已切换至第二阶段圆桌研讨系统", + ); }, [step2, triggerToast], ); const handleStartNewTask = useCallback(() => { step2.resetStep2State(); + setOrchestrationMode("recommend"); + setChainMeta(null); step1.stopIntentStream(); drafts.loadedFromDraftRef.current = false; drafts.setCurrentDraftId(null); @@ -353,6 +382,16 @@ export default function RoundtablePlanningPage() { />
    + +
    diff --git a/frontend-web/src/open-canvas/components/ai-writing/DraftStreamingPreview.tsx b/frontend-web/src/open-canvas/components/ai-writing/DraftStreamingPreview.tsx index 960f7881..c4eb1eaa 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/DraftStreamingPreview.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/DraftStreamingPreview.tsx @@ -8,6 +8,8 @@ import { Button } from "@/components/ui/button"; interface DraftStreamingPreviewProps { /** 正在流式生成的草稿 Markdown(边写边推送,逐 chunk 增长) */ markdown: string; + /** 是否显示流式光标(生成中 true;静态只读预览传 false) */ + showCursor?: boolean; } /** @@ -21,7 +23,7 @@ interface DraftStreamingPreviewProps { * 行为:自动下滑跟随生成;用户向上滚动则停止自动下滑并浮出「回到底部」按钮, * 点击后滚到底部并恢复自动下滑。 */ -export function DraftStreamingPreview({ markdown }: DraftStreamingPreviewProps) { +export function DraftStreamingPreview({ markdown, showCursor = true }: DraftStreamingPreviewProps) { const scrollRef = useRef(null); const autoScrollRef = useRef(true); // 程序化滚动期间置 true,让 onScroll 忽略自己触发的滚动,避免 setState 抖动/回环 @@ -126,7 +128,9 @@ export function DraftStreamingPreview({ markdown }: DraftStreamingPreviewProps) > {markdown} - + {showCursor && ( + + )}
    diff --git a/frontend-web/src/open-canvas/contexts/CanvasUIContext.tsx b/frontend-web/src/open-canvas/contexts/CanvasUIContext.tsx index acff3bea..5463e532 100644 --- a/frontend-web/src/open-canvas/contexts/CanvasUIContext.tsx +++ b/frontend-web/src/open-canvas/contexts/CanvasUIContext.tsx @@ -21,6 +21,8 @@ export interface CanvasUIContextValue { isDraftStreaming: boolean; /** AI 写作:流式生成中的草稿 Markdown。生成期间走这条独立通道(不写全局 artifact),避免高频刷新触发编辑器更新风暴 */ draftStreamingMarkdown: string; + /** AI 写作:草稿是否进入"生成完成的稳定态"(可编辑)。仅此时才挂载带编辑器的渲染器;生成/审核中一律只读 */ + isDraftEditable: boolean; selection: CanvasSelectionState; /** Tiptap editor selection for AI rewrite flow */ writingSelection: WritingSelection | null; @@ -29,6 +31,7 @@ export interface CanvasUIContextValue { setIsWebSearchOpen: (v: boolean) => void; setIsDraftStreaming: (v: boolean) => void; setDraftStreamingMarkdown: (v: string) => void; + setIsDraftEditable: (v: boolean) => void; /** Set text selection; clears any existing code selection. */ setTextSelection: (s: CanvasTextSelection | null) => void; /** Set code selection; clears any existing text selection. */ @@ -50,6 +53,7 @@ export function CanvasUIProvider({ children }: { children: ReactNode }) { const [isWebSearchOpen, setIsWebSearchOpen] = useState(false); const [isDraftStreaming, setIsDraftStreaming] = useState(false); const [draftStreamingMarkdown, setDraftStreamingMarkdown] = useState(''); + const [isDraftEditable, setIsDraftEditable] = useState(false); const [selection, setSelection] = useState(EMPTY_SELECTION); const [writingSelection, setWritingSelection] = @@ -71,6 +75,7 @@ export function CanvasUIProvider({ children }: { children: ReactNode }) { isWebSearchOpen, isDraftStreaming, draftStreamingMarkdown, + isDraftEditable, selection, writingSelection, setIsChatCollapsed, @@ -78,6 +83,7 @@ export function CanvasUIProvider({ children }: { children: ReactNode }) { setIsWebSearchOpen, setIsDraftStreaming, setDraftStreamingMarkdown, + setIsDraftEditable, setTextSelection, setCodeSelection, clearSelection: () => setSelection(EMPTY_SELECTION), diff --git a/frontend-web/src/open-canvas/editor/TiptapExtensions.ts b/frontend-web/src/open-canvas/editor/TiptapExtensions.ts index 69e56948..157668c5 100644 --- a/frontend-web/src/open-canvas/editor/TiptapExtensions.ts +++ b/frontend-web/src/open-canvas/editor/TiptapExtensions.ts @@ -1,15 +1,15 @@ import StarterKit from "@tiptap/starter-kit"; -import Underline from "@tiptap/extension-underline"; import Placeholder from "@tiptap/extension-placeholder"; import { Markdown } from "tiptap-markdown"; import { AiHighlightExtension } from "./AiHighlightExtension"; +// 注意:@tiptap v3 的 StarterKit 已内置 underline,不要再单独引入 @tiptap/extension-underline, +// 否则会触发 "Duplicate extension names ['underline']" 警告并导致 useEditor schema 解析异常。 export const buildExtensions = (placeholder = "开始写作…") => [ StarterKit.configure({ // Disable hard break so Enter creates new paragraph hardBreak: false, }), - Underline, Placeholder.configure({ placeholder, emptyEditorClass: "is-editor-empty", diff --git a/frontend-web/src/open-canvas/hooks/useAIWritingStream.ts b/frontend-web/src/open-canvas/hooks/useAIWritingStream.ts index 088bc819..144b96a3 100644 --- a/frontend-web/src/open-canvas/hooks/useAIWritingStream.ts +++ b/frontend-web/src/open-canvas/hooks/useAIWritingStream.ts @@ -641,7 +641,7 @@ type AIWritingValues = { export function useAIWriting() { const { setArtifact } = useCanvasGraphContext(); - const { setIsDraftStreaming, setDraftStreamingMarkdown } = useCanvasUIContext(); + const { setIsDraftStreaming, setDraftStreamingMarkdown, setIsDraftEditable } = useCanvasUIContext(); const [state, dispatch] = useReducer(reducer, INITIAL_STATE); // useStream 要求受控 threadId;初次 startWriting 时 useStream 在 onCreated 里回灌 @@ -741,7 +741,11 @@ export function useAIWriting() { // - 生成完成(draft_ready 后):清空流式通道,把最终草稿写入全局 artifact 供编辑器编辑。 useEffect(() => { const streaming = state.status === 'writing_draft' || state.status === 'revising'; + // 仅"暂停等待用户 / 完成"这类已停止生成的稳定态才允许编辑(挂载编辑器)——此时没有事件流, + // 编辑器稳定;研究 / 大纲 / 写作 / 修改 / 审核等任一进行中状态都只读,杜绝编辑器在活跃流中挂载。 + const editable = state.status.startsWith('awaiting_') || state.status === 'done'; setIsDraftStreaming(streaming); + setIsDraftEditable(editable); if (streaming) { setDraftStreamingMarkdown(state.currentDraftMarkdown); return; @@ -758,7 +762,7 @@ export function useAIWriting() { }], }); } - }, [state.status, state.currentDraftMarkdown, state.currentDraftTitle, setArtifact, setIsDraftStreaming, setDraftStreamingMarkdown]); + }, [state.status, state.currentDraftMarkdown, state.currentDraftTitle, setArtifact, setIsDraftStreaming, setDraftStreamingMarkdown, setIsDraftEditable]); // transcript 持久化(保留老逻辑:进入暂停 / done / error 时全量上传) useEffect(() => { diff --git a/frontend-web/src/open-canvas/hooks/useWritingEditor.ts b/frontend-web/src/open-canvas/hooks/useWritingEditor.ts index f15f666c..f9afc30a 100644 --- a/frontend-web/src/open-canvas/hooks/useWritingEditor.ts +++ b/frontend-web/src/open-canvas/hooks/useWritingEditor.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useMemo, useRef } from "react"; import { useEditor } from "@tiptap/react"; import { buildExtensions } from "../editor/TiptapExtensions"; @@ -17,17 +17,26 @@ export function useWritingEditor({ }: UseWritingEditorOptions) { const prevMarkdownRef = useRef(markdown); + // 扩展集合 memo 一次,避免每次渲染生成新数组导致 useEditor 反复重建(v3 下会触发 + // 重复 schema 解析 / "Duplicate extension names" 警告,极端情况引发更新风暴)。 + const extensions = useMemo(() => buildExtensions(placeholder), [placeholder]); + + // onChange 用 ref 持有最新值,让 onUpdate 闭包保持稳定,不随父组件回调身份变化而重建编辑器。 + const onChangeRef = useRef(onChange); + useEffect(() => { onChangeRef.current = onChange; }, [onChange]); + const editor = useEditor({ - extensions: buildExtensions(placeholder), + extensions, editable, content: markdown, onUpdate({ editor }) { - if (!onChange) return; + const fn = onChangeRef.current; + if (!fn) return; // eslint-disable-next-line @typescript-eslint/no-explicit-any const md = (editor as any).storage?.markdown?.getMarkdown?.() ?? editor.getText(); - onChange(md); + fn(md); }, - }); + }, []); // Sync external markdown changes (e.g. after streaming completes) useEffect(() => { From 02eee4bd3eecbb206831c0ff73f11a767fa2a1a0 Mon Sep 17 00:00:00 2001 From: fengxiangdebaozi <10845145+fengxiangdebaozi@user.noreply.gitee.com> Date: Tue, 2 Jun 2026 21:25:44 +0800 Subject: [PATCH 04/13] =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../docs/multi-agent-business-chain-dev.md | 1 + .../pages/BusinessChainEditorPage.tsx | 124 +++++++++--------- 2 files changed, 63 insertions(+), 62 deletions(-) diff --git a/frontend-web/docs/multi-agent-business-chain-dev.md b/frontend-web/docs/multi-agent-business-chain-dev.md index 6a9a5338..f2946bc4 100644 --- a/frontend-web/docs/multi-agent-business-chain-dev.md +++ b/frontend-web/docs/multi-agent-business-chain-dev.md @@ -299,6 +299,7 @@ chain 模式的 Step 2 init **完全复用** `initMultiAgent`:`agentNames = ch - **验收**:✅ typecheck 0 新增错误(76 个预存量错误均在无关模块)。**人工验收待跑**:新建「情报→方案→风险→总结」链保存 → 刷新后下拉重载 → 右栏拖拽排序 → 左栏拖入指定位 → 2–8 边界禁用保存。 - **切换成本备注**:若改全屏 Dialog 方案,编辑器组件原样复用,仅去掉路由、外面套 `Dialog`。 - **入口说明**:圆桌本身未在侧栏(`sidebar-menu.ts` 入口被注释),本页暂经直链 `/page/workspace/roundtable/chains` 或顶栏「返回圆桌」往返;阶段 3 推荐弹窗「去配置」按钮会导航到此。 +- **视觉(后续重画)**:本页**不**套圆桌 `roundtable-planning` scope,直接遵循《浅红色/深蓝色 UI 规范》——用 app 语义 token(光 `--primary` 红 hue27 / 暗 蓝 hue259,已内置自适应)+ 规范小圆角(输入/按钮/tag/卡片内项 2px = `R_CTRL`,分栏/面板 4px = `R_CARD`)+ 克制阴影(`PANEL_SHADOW`)。筛选框复用通用 `TagSearchBar`,配色随 app 中性 token,不再被红 scope 染色。 ### 阶段 2 · 推荐弹窗「模式选择」外壳(1 天) ✅ 已完成 **目标**:弹窗顶部模式切换,智能推荐行为不变。 diff --git a/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx b/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx index 57d7d306..6144b8fb 100644 --- a/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx @@ -11,8 +11,9 @@ * 拖拽用 `@dnd-kit`:右栏是 `SortableContext`(席位互相排序),左栏卡片是 * `useDraggable`(pool→右栏插入)。落点计算在 `onDragEnd`。 * - * 视觉:套 `roundtable-planning`(+`--dark`) 作用域,与圆桌主页面同一套浅红/深蓝皮肤 - * (`styles/roundtable-planning.css`,对齐 docs/浅红色UI规范 + ui规范-深蓝)。 + * 视觉:直接遵循《浅红色 UI 规范》/《深蓝色 UI 规范》—— 用 app 语义 token(光红暗蓝 + * 已内置)+ 规范小圆角(输入/按钮/tag 2px、卡片/分栏 4px)+ 克制阴影。**不**套圆桌页 + * 的 `roundtable-planning` scope。 * * 持久化走 `api/chains.ts`(后端 MySQL `/api/roundtable-chains`)。席位数约束 * 2 ≤ N ≤ 8、agent 去重,保存时校验(后端 `_validate_seats` 也兜底)。 @@ -56,7 +57,6 @@ import { import { CSS } from "@dnd-kit/utilities"; import { cn } from "@/lib/utils"; -import { useTheme } from "@/contexts/ThemeContext"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar"; @@ -85,6 +85,13 @@ const POOL_PREFIX = "pool::"; const SEAT_PREFIX = "seat::"; const SEATS_DROPPABLE_ID = "seats-droppable"; +// 规范圆角(§4):输入/按钮/tag 2px,卡片/分栏 4px。app 默认 --radius=12px 偏大, +// 这里用 arbitrary 值精确命中规范。 +const R_CARD = "rounded-[4px]"; +const R_CTRL = "rounded-[2px]"; +// 规范阴影(§3):克制的浅投影,深色下几乎不可见。 +const PANEL_SHADOW = "shadow-[0_1px_2px_rgba(15,23,42,0.05)]"; + type ActiveDrag = | { kind: "pool"; agentId: string; name: string } | { kind: "seat"; agentId: string; name: string } @@ -92,8 +99,6 @@ type ActiveDrag = export default function BusinessChainEditorPage() { const navigate = useNavigate(); - const { theme } = useTheme(); - const isDark = theme === "dark"; // ─── 数据 ──────────────────────────────────────────────────────────── const [pool, setPool] = useState([]); @@ -117,8 +122,6 @@ export default function BusinessChainEditorPage() { useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), ); - // 候选池按 agent_id 取元数据。注意 pool 是「当前筛选视图」——已加入但被筛掉 - // 的席位名称回退用 seat 自带快照(loadChain 写入),所以 poolById 缺项不影响保存。 const poolById = useMemo(() => { const m = new Map(); for (const a of pool) m.set(a.agent_id, a); @@ -235,7 +238,6 @@ export default function BusinessChainEditorPage() { const overId = e.over ? String(e.over.id) : null; if (!overId) return; - // over 落在第几个席位之前(over 是席位则取其索引,否则追加到末尾)。 const overSeatIndex = overId.startsWith(SEAT_PREFIX) ? seats.findIndex((s) => SEAT_PREFIX + s.agent_id === overId) : -1; @@ -344,31 +346,26 @@ export default function BusinessChainEditorPage() { const seatItemIds = seats.map((s) => SEAT_PREFIX + s.agent_id); return ( -
    +
    {/* 顶栏 */} -
    +
    -
    +
    - +
    -
    业务链条配置
    -
    编排有序的智能体研讨链路
    +
    业务链条配置
    +
    编排有序的智能体研讨链路
    @@ -385,7 +382,10 @@ export default function BusinessChainEditorPage() { const chain = chains.find((c) => c.id === id); if (chain) loadChain(chain); }} - className="h-8 rounded-md border border-input bg-card px-2.5 text-sm font-medium shadow-sm outline-none focus:border-primary/60 focus:ring-2 focus:ring-primary/20" + className={cn( + "h-8 border border-input bg-card px-2.5 text-sm text-foreground outline-none transition-colors focus:border-primary focus:ring-1 focus:ring-primary/30", + R_CTRL, + )} > {chains.map((c) => ( @@ -394,7 +394,7 @@ export default function BusinessChainEditorPage() { ))} - @@ -406,7 +406,7 @@ export default function BusinessChainEditorPage() { size="sm" onClick={handleDelete} disabled={!currentChainId} - className="gap-1.5 text-destructive hover:text-destructive" + className={cn("h-8 gap-1.5 text-destructive hover:text-destructive", R_CTRL)} > 删除 @@ -415,7 +415,7 @@ export default function BusinessChainEditorPage() { size="sm" onClick={handleSave} disabled={!canSave} - className="gap-1.5 bg-primary text-primary-foreground hover:opacity-90" + className={cn("h-8 gap-1.5", R_CTRL)} > {saving ? : } 保存 @@ -424,24 +424,24 @@ export default function BusinessChainEditorPage() {
    {/* 标题 + 描述 */} -
    -
    - 标题 +
    +
    + 标题 setTitle(e.target.value)} placeholder="如:应急方案三段链" - className="bg-card" + className={cn("h-8 bg-background", R_CTRL)} maxLength={120} />
    - 描述 + 描述 setDescription(e.target.value)} placeholder="链条用途说明(可选)" - className="bg-card" + className={cn("h-8 bg-background", R_CTRL)} maxLength={500} />
    @@ -456,15 +456,15 @@ export default function BusinessChainEditorPage() { >
    {/* 左栏:候选智能体 */} -
    -
    -
    +
    +
    +
    候选智能体 - + {pool.length} - + 拖到右侧 / 点击加入
    @@ -475,7 +475,7 @@ export default function BusinessChainEditorPage() { onTagIdsChange={setFilterTagIds} />
    -
    +
    {poolLoading ? (
    @@ -511,7 +511,7 @@ export default function BusinessChainEditorPage() { {activeDrag ? ( -
    +
    {activeDrag.name}
    @@ -541,17 +541,18 @@ function PoolAgentCard({
    -
    {agent.name || agent.agent_id}
    +
    {agent.name || agent.agent_id}
    {agent.description && (
    {agent.description} @@ -559,7 +560,7 @@ function PoolAgentCard({ )}
    {inChain ? ( - + 已加入 ) : ( @@ -567,7 +568,10 @@ function PoolAgentCard({ type="button" onClick={onAdd} onPointerDown={(e) => e.stopPropagation()} - className="shrink-0 self-center rounded-lg p-1.5 text-muted-foreground opacity-0 transition-all hover:bg-primary hover:text-primary-foreground group-hover:opacity-100" + className={cn( + "shrink-0 self-center p-1.5 text-muted-foreground opacity-0 transition-all hover:bg-primary hover:text-primary-foreground group-hover:opacity-100", + R_CTRL, + )} title="加入链条" > @@ -592,16 +596,17 @@ function SeatsColumn({ }) { const { setNodeRef, isOver } = useDroppable({ id: SEATS_DROPPABLE_ID }); return ( -
    -
    +
    +
    链条席位 - + 按顺序发言 {seats.length === 0 ? ( -
    +
    从左侧把智能体拖到这里,组成有序业务链条
    @@ -630,7 +635,6 @@ function SeatsColumn({ key={seat.agent_id} seat={seat} index={index} - isLast={index === seats.length - 1} onRemove={() => onRemove(seat.agent_id)} /> ))} @@ -646,12 +650,10 @@ function SeatsColumn({ function SeatStrip({ seat, index, - isLast, onRemove, }: { seat: ChainSeat; index: number; - isLast: boolean; onRemove: () => void; }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = @@ -662,14 +664,11 @@ function SeatStrip({ ref={setNodeRef} style={{ transform: CSS.Transform.toString(transform), transition }} className={cn( - "relative flex items-center gap-3 rounded-xl border border-border bg-card p-3 shadow-sm transition-shadow", - isDragging && "z-10 opacity-70 shadow-lg ring-1 ring-primary/30", + "flex items-center gap-3 border border-border bg-card p-2.5", + R_CTRL, + isDragging && "z-10 opacity-70 shadow-[0_4px_12px_rgba(15,23,42,0.12)]", )} > - {/* 顺序连接线(最后一个不画) */} - {!isLast && ( - - )} -
    + + + {/* 品牌标识 */}
    - - - +
    + 🪶 +
    -
    业务链条配置
    -
    编排有序的智能体研讨链路
    +
    业务链条配置
    +
    + 编排有序的智能体研讨链路 +
    - {/* 链条选择 + 新建 */} -
    - - + {triggerLabel} + + + + {dropdownOpen && ( + <> +
    setDropdownOpen(false)} /> +
    +
    + 切换已有链条 +
    + {chains.length === 0 ? ( +
    暂无已保存链条
    + ) : ( + chains.map((c) => ( + + )) + )} +
    + +
    +
    + + )}
    + {/* 快捷新建 */} + + + {/* 右侧操作 */}
    - - +
    {/* 标题 + 描述 */} -
    -
    - 标题 - setTitle(e.target.value)} - placeholder="如:应急方案三段链" - className={cn("h-8 bg-background", R_CTRL)} - maxLength={120} - /> +
    +
    +
    + + 标题 + + setTitle(e.target.value)} + placeholder="如:应急方案三段链" + maxLength={120} + className="h-auto rounded-none border-0 bg-transparent px-4 py-2.5 text-sm font-medium shadow-none focus-visible:ring-0 dark:border-0 dark:bg-transparent" + /> +
    + +
    + + 描述 + + setDescription(e.target.value)} + placeholder="链条用途说明(可选)" + maxLength={500} + className="h-auto rounded-none border-0 bg-transparent px-4 py-2.5 text-sm font-medium shadow-none focus-visible:ring-0 dark:border-0 dark:bg-transparent" + /> +
    -
    - 描述 - setDescription(e.target.value)} - placeholder="链条用途说明(可选)" - className={cn("h-8 bg-background", R_CTRL)} - maxLength={500} - /> -
    -
    + {/* 双栏拖拽编辑器 */} -
    +
    {/* 左栏:候选智能体 */} -
    -
    -
    - - 候选智能体 - +
    +
    +
    + + + +

    候选智能体

    + {pool.length} - - 拖到右侧 / 点击加入 -
    + 拖到右侧 / 点击加入 +
    + +
    -
    + +
    {poolLoading ? (
    正在加载候选智能体…
    ) : pool.length === 0 ? ( -
    +
    {search || filterTagIds.length ? "没有匹配的智能体,换个关键词或标签试试。" : `候选池为空,请先在「智能体」里创建至少 ${CHAIN_MIN_SEATS} 个智能体。`} @@ -506,18 +619,29 @@ export default function BusinessChainEditorPage() { seatItemIds={seatItemIds} seatCountInvalid={seatCountInvalid} onRemove={removeSeat} + onMove={moveSeat} /> -
    +
    {activeDrag ? ( -
    +
    {activeDrag.name}
    ) : null} + + {/* 页脚 */} +
    + 编排有序的业务链条,让智能体决策更有深度。保存后,配置将自动同步至圆桌。 +
    ); } @@ -540,43 +664,57 @@ function PoolAgentCard({ return (
    -
    -
    {agent.name || agent.agent_id}
    +
    + +
    + +
    +
    + {agent.name || agent.agent_id} +
    {agent.description && ( -
    +
    {agent.description}
    )}
    - {inChain ? ( - - 已加入 - - ) : ( - - )} + +
    + {inChain ? ( + + 已加入 + + ) : ( + + 点击加入 + + )} +
    ); } @@ -588,45 +726,72 @@ function SeatsColumn({ seatItemIds, seatCountInvalid, onRemove, + onMove, }: { seats: ChainSeat[]; seatItemIds: string[]; seatCountInvalid: boolean; onRemove: (agentId: string) => void; + onMove: (index: number, dir: -1 | 1) => void; }) { const { setNodeRef, isOver } = useDroppable({ id: SEATS_DROPPABLE_ID }); return ( -
    -
    - - 链条席位 - - 按顺序发言 - - - {seats.length} / {CHAIN_MAX_SEATS} - {seats.length < CHAIN_MIN_SEATS && ` · 至少 ${CHAIN_MIN_SEATS}`} - +
    +
    +
    + + + +

    链条席位

    + + 按顺序发言 + +
    + +
    + 配置席位 + + {seats.length} / {CHAIN_MAX_SEATS} + {seats.length < CHAIN_MIN_SEATS && ` · 至少 ${CHAIN_MIN_SEATS}`} + +
    +
    {seats.length === 0 ? ( -
    - - 从左侧把智能体拖到这里,组成有序业务链条 +
    +
    + +
    +

    暂无席位

    +

    + 该业务链条当前为空。请从左侧「候选智能体」点击或拖拽,将其按顺序编排加入此处。 +

    ) : ( @@ -635,7 +800,9 @@ function SeatsColumn({ key={seat.agent_id} seat={seat} index={index} + total={seats.length} onRemove={() => onRemove(seat.agent_id)} + onMove={(dir) => onMove(index, dir)} /> ))} @@ -650,59 +817,110 @@ function SeatsColumn({ function SeatStrip({ seat, index, + total, onRemove, + onMove, }: { seat: ChainSeat; index: number; + total: number; onRemove: () => void; + onMove: (dir: -1 | 1) => void; }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: SEAT_PREFIX + seat.agent_id }); const avatarType = getAvatarTypeFor(seat.agent_id, index); + const isFirst = index === 0; + const isLast = index === total - 1; return (
    + {/* 拖拽手柄 */} - - {index + 1} - -
    -
    {seat.name || seat.agent_id}
    + + {/* 序号 */} +
    + + {index + 1} + +
    + + {/* 详情 */} +
    +
    {seat.name || seat.agent_id}
    {seat.description && ( -
    +
    {seat.description}
    )}
    - + + {/* 控制:上移 / 下移 / 移除 */} +
    + + + +
    ); } diff --git a/yewuliantiao/.env.example b/yewuliantiao/.env.example new file mode 100644 index 00000000..7a550fe7 --- /dev/null +++ b/yewuliantiao/.env.example @@ -0,0 +1,9 @@ +# GEMINI_API_KEY: Required for Gemini AI API calls. +# AI Studio automatically injects this at runtime from user secrets. +# Users configure this via the Secrets panel in the AI Studio UI. +GEMINI_API_KEY="MY_GEMINI_API_KEY" + +# APP_URL: The URL where this applet is hosted. +# AI Studio automatically injects this at runtime with the Cloud Run service URL. +# Used for self-referential links, OAuth callbacks, and API endpoints. +APP_URL="MY_APP_URL" diff --git a/yewuliantiao/README.md b/yewuliantiao/README.md new file mode 100644 index 00000000..283c70e2 --- /dev/null +++ b/yewuliantiao/README.md @@ -0,0 +1,20 @@ +
    +GHBanner +
    + +# Run and deploy your AI Studio app + +This contains everything you need to run your app locally. + +View your app in AI Studio: https://ai.studio/apps/afcf08ae-640d-4fb2-9eab-4d3094ffaa28 + +## Run Locally + +**Prerequisites:** Node.js + + +1. Install dependencies: + `npm install` +2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key +3. Run the app: + `npm run dev` diff --git a/yewuliantiao/index.html b/yewuliantiao/index.html new file mode 100644 index 00000000..254a3742 --- /dev/null +++ b/yewuliantiao/index.html @@ -0,0 +1,13 @@ + + + + + + 业务链条配置 + + +
    + + + + diff --git a/yewuliantiao/metadata.json b/yewuliantiao/metadata.json new file mode 100644 index 00000000..8f987a4a --- /dev/null +++ b/yewuliantiao/metadata.json @@ -0,0 +1,6 @@ +{ + "name": "业务链条配置", + "description": "编排有序的智能体研讨链路配置系统。", + "requestFramePermissions": [], + "majorCapabilities": ["MAJOR_CAPABILITY_SERVER_SIDE_GEMINI_API"] +} diff --git a/yewuliantiao/package.json b/yewuliantiao/package.json new file mode 100644 index 00000000..44745ed3 --- /dev/null +++ b/yewuliantiao/package.json @@ -0,0 +1,35 @@ +{ + "name": "react-example", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite --port=3000 --host=0.0.0.0", + "build": "vite build", + "preview": "vite preview", + "clean": "rm -rf dist server.js", + "lint": "tsc --noEmit" + }, + "dependencies": { + "@google/genai": "^2.4.0", + "@tailwindcss/vite": "^4.1.14", + "@vitejs/plugin-react": "^5.0.4", + "lucide-react": "^0.546.0", + "react": "^19.0.1", + "react-dom": "^19.0.1", + "vite": "^6.2.3", + "express": "^4.21.2", + "dotenv": "^17.2.3", + "motion": "^12.23.24" + }, + "devDependencies": { + "@types/node": "^22.14.0", + "autoprefixer": "^10.4.21", + "esbuild": "^0.25.0", + "tailwindcss": "^4.1.14", + "tsx": "^4.21.0", + "typescript": "~5.8.2", + "vite": "^6.2.3", + "@types/express": "^4.17.21" + } +} diff --git a/yewuliantiao/src/App.tsx b/yewuliantiao/src/App.tsx new file mode 100644 index 00000000..040d17a7 --- /dev/null +++ b/yewuliantiao/src/App.tsx @@ -0,0 +1,833 @@ +import React, { useState, useEffect, useMemo } from "react"; +import { + ArrowLeft, + Trash2, + Save, + Plus, + ChevronDown, + Search, + GripVertical, + X, + ListOrdered, + Languages, + Sparkles, + ChevronUp, + UserPlus, + Bot +} from "lucide-react"; +import { INITIAL_AGENTS, Agent } from "./initialAgents"; + +interface ChainConfig { + id: string; + title: string; + description: string; + seats: string[]; // Holds reference to Agent IDs in order +} + +const STORAGE_CHANNELS_KEY = "business_decision_chains"; +const STORAGE_CURRENT_KEY = "business_decision_current_id"; +const STORAGE_CUSTOM_AGENTS_KEY = "business_decision_custom_agents"; + +// Preset colors for the seat index badges +const SEAT_COLORS = [ + { bg: "bg-blue-50 border border-blue-200 text-blue-600", label: "蓝色" }, + { bg: "bg-purple-50 border border-purple-200 text-purple-600", label: "紫色" }, + { bg: "bg-emerald-50 border border-emerald-200 text-emerald-600", label: "绿色" }, + { bg: "bg-orange-50 border border-orange-200 text-orange-600", label: "橙色" }, + { bg: "bg-indigo-50 border border-indigo-200 text-indigo-600", label: "靛蓝" }, + { bg: "bg-pink-50 border border-pink-200 text-pink-600", label: "粉色" }, + { bg: "bg-amber-50 border border-amber-200 text-amber-600", label: "琥珀" }, + { bg: "bg-sky-50 border border-sky-200 text-sky-600", label: "天空蓝" }, +]; + +export default function App() { + // 1. Storage & Config states + const [chains, setChains] = useState([]); + const [currentChainId, setCurrentChainId] = useState(""); + const [customAgents, setCustomAgents] = useState([]); + + // 2. Search & UI states + const [searchTerm, setSearchTerm] = useState(""); + const [showDropdown, setShowDropdown] = useState(false); + const [draggedIndex, setDraggedIndex] = useState(null); + const [dragOverIndex, setDragOverIndex] = useState(null); + + // Custom Agent Form Modal + const [showAgentModal, setShowAgentModal] = useState(false); + const [newAgentName, setNewAgentName] = useState(""); + const [newAgentDesc, setNewAgentDesc] = useState(""); + + // Toast feedback + const [toast, setToast] = useState<{ message: string; type: "success" | "error" | "info" } | null>(null); + + // Initialize data on load + useEffect(() => { + // Load custom agents + const savedCustom = localStorage.getItem(STORAGE_CUSTOM_AGENTS_KEY); + let loadedCustom: Agent[] = []; + if (savedCustom) { + try { + loadedCustom = JSON.parse(savedCustom); + setCustomAgents(loadedCustom); + } catch (e) { + console.error("Failed to parse custom agents", e); + } + } + + // Load chains + const savedChains = localStorage.getItem(STORAGE_CHANNELS_KEY); + const savedCurrentId = localStorage.getItem(STORAGE_CURRENT_KEY); + + let loadedChains: ChainConfig[] = []; + if (savedChains) { + try { + loadedChains = JSON.parse(savedChains); + } catch (e) { + console.error("Failed to parse chains from localStorage", e); + } + } + + // Default configuration if empty + if (loadedChains.length === 0) { + loadedChains = [ + { + id: "chain-1", + title: "应急方案三段链", + description: "链条用途说明(可选)", + seats: ["agent-1", "agent-2", "agent-3"], + } + ]; + } + + setChains(loadedChains); + + // Set current active id + if (savedCurrentId && loadedChains.some(c => c.id === savedCurrentId)) { + setCurrentChainId(savedCurrentId); + } else { + setCurrentChainId(loadedChains[0].id); + } + }, []); + + // Save chains list to storage when it updates + const saveAllToLocalStorage = (newChainsList: ChainConfig[]) => { + localStorage.setItem(STORAGE_CHANNELS_KEY, JSON.stringify(newChainsList)); + }; + + // Toast helper + const triggerToast = (message: string, type: "success" | "error" | "info" = "success") => { + setToast({ message, type }); + setTimeout(() => { + setToast(null); + }, 2500); + }; + + // Complete list of agents (built-in + custom) + const allAgents = useMemo(() => { + return [...INITIAL_AGENTS, ...customAgents]; + }, [customAgents]); + + const activeChain = useMemo(() => { + return chains.find(c => c.id === currentChainId) || { + id: "fallback", + title: "", + description: "", + seats: [] + } as ChainConfig; + }, [chains, currentChainId]); + + // Handle text field updates + const handleTitleChange = (val: string) => { + setChains(prev => { + const updated = prev.map(c => c.id === currentChainId ? { ...c, title: val } : c); + saveAllToLocalStorage(updated); + return updated; + }); + }; + + const handleDescChange = (val: string) => { + setChains(prev => { + const updated = prev.map(c => c.id === currentChainId ? { ...c, description: val } : c); + saveAllToLocalStorage(updated); + return updated; + }); + }; + + // Add / Toggle seat inclusion + const handleToggleAgent = (agentId: string) => { + const isAlreadyIn = activeChain.seats.includes(agentId); + + if (isAlreadyIn) { + // Remove it + const newSeats = activeChain.seats.filter(id => id !== agentId); + updateActiveSeats(newSeats); + triggerToast("已将智能体移出席位"); + } else { + // Add it + if (activeChain.seats.length >= 8) { + triggerToast("席位已满,每个链条最多配置 8 个席位!", "error"); + return; + } + const newSeats = [...activeChain.seats, agentId]; + updateActiveSeats(newSeats); + triggerToast("成功添加到链条席位"); + } + }; + + const updateActiveSeats = (newSeats: string[]) => { + setChains(prev => { + const updated = prev.map(c => c.id === currentChainId ? { ...c, seats: newSeats } : c); + saveAllToLocalStorage(updated); + return updated; + }); + }; + + // Multi-chain operations + const createNewChain = () => { + const nextId = `chain-${Date.now()}`; + const newChainItem: ChainConfig = { + id: nextId, + title: `空白新链条 ${chains.length + 1}`, + description: "", + seats: [] + }; + const updated = [...chains, newChainItem]; + setChains(updated); + setCurrentChainId(nextId); + localStorage.setItem(STORAGE_CURRENT_KEY, nextId); + saveAllToLocalStorage(updated); + triggerToast("已创建新的空白链条", "success"); + setShowDropdown(false); + }; + + const handleSelectChain = (id: string) => { + setCurrentChainId(id); + localStorage.setItem(STORAGE_CURRENT_KEY, id); + setShowDropdown(false); + triggerToast(`切换到: ${chains.find(c => c.id === id)?.title || "新链条"}`); + }; + + const handleSaveChain = () => { + saveAllToLocalStorage(chains); + triggerToast("保存配置成功", "success"); + }; + + const handleDeleteChain = () => { + if (chains.length <= 1) { + // Create a blank placeholder instead of locking + const nextId = `chain-${Date.now()}`; + const placeholder: ChainConfig = { + id: nextId, + title: "新配置链条", + description: "", + seats: [] + }; + setChains([placeholder]); + setCurrentChainId(nextId); + localStorage.setItem(STORAGE_CURRENT_KEY, nextId); + saveAllToLocalStorage([placeholder]); + triggerToast("重置当前链条"); + return; + } + const filtered = chains.filter(c => c.id !== currentChainId); + setChains(filtered); + const nextId = filtered[0].id; + setCurrentChainId(nextId); + localStorage.setItem(STORAGE_CURRENT_KEY, nextId); + saveAllToLocalStorage(filtered); + triggerToast("删除成功", "success"); + }; + + // Reorder commands: Drag & Drop + const handleDragStart = (index: number) => { + setDraggedIndex(index); + }; + + const handleDragOver = (e: React.DragEvent, index: number) => { + e.preventDefault(); + setDragOverIndex(index); + }; + + const handleDrop = (targetIndex: number) => { + if (draggedIndex === null) return; + const reordered = [...activeChain.seats]; + const [movedItem] = reordered.splice(draggedIndex, 1); + reordered.splice(targetIndex, 0, movedItem); + updateActiveSeats(reordered); + setDraggedIndex(null); + setDragOverIndex(null); + }; + + // Fallback reordering commands: Move Up/Down button click + const moveDirection = (index: number, direction: "up" | "down") => { + const targetIdx = direction === "up" ? index - 1 : index + 1; + if (targetIdx < 0 || targetIdx >= activeChain.seats.length) return; + const items = [...activeChain.seats]; + const temp = items[index]; + items[index] = items[targetIdx]; + items[targetIdx] = temp; + updateActiveSeats(items); + }; + + // Create customized user agents + const handleAddCustomAgent = (e: React.FormEvent) => { + e.preventDefault(); + if (!newAgentName.trim()) { + triggerToast("请输入智能体名称", "error"); + return; + } + + const newAgent: Agent = { + id: `custom-agent-${Date.now()}`, + name: newAgentName, + description: newAgentDesc || "自定义创建的专属智能体角色。", + isCustom: true + }; + + const updatedCustom = [...customAgents, newAgent]; + setCustomAgents(updatedCustom); + localStorage.setItem(STORAGE_CUSTOM_AGENTS_KEY, JSON.stringify(updatedCustom)); + setNewAgentName(""); + setNewAgentDesc(""); + setShowAgentModal(false); + triggerToast(`智能体 '${newAgent.name}' 创建成功!`); + + // Optionally auto-add to the chain seats if space available + if (activeChain.seats.length < 8) { + updateActiveSeats([...activeChain.seats, newAgent.id]); + } + }; + + // Clear a custom agent + const handleDeleteCustomAgent = (e: React.MouseEvent, agentId: string) => { + e.stopPropagation(); // Avoid triggering seat addition + const updatedCustom = customAgents.filter(a => a.id !== agentId); + setCustomAgents(updatedCustom); + localStorage.setItem(STORAGE_CUSTOM_AGENTS_KEY, JSON.stringify(updatedCustom)); + // Also remove from any seats as well + if (activeChain.seats.includes(agentId)) { + updateActiveSeats(activeChain.seats.filter(id => id !== agentId)); + } + triggerToast("已彻底删除该自定义智能体"); + }; + + // Filter conditions + const filteredCandidates = allAgents.filter(agent => { + const text = (agent.name + " " + agent.description).toLowerCase(); + return text.includes(searchTerm.toLowerCase()); + }); + + return ( +
    + {/* 1. Header Toolbar */} + + + {/* 2. Top Config Form Cards (Title, Description) */} +
    +
    + + {/* Title input field */} +
    + + 标题 + + handleTitleChange(e.target.value)} + placeholder="如:应急方案三段链" + className="w-full px-4 py-2.5 text-sm text-[#1f2937] focus:outline-none placeholder-[#9ca3af] font-medium" + /> +
    + + {/* Description input field */} +
    + + 描述 + + handleDescChange(e.target.value)} + placeholder="链条用途说明(可选)" + className="w-full px-4 py-2.5 text-sm text-[#1f2937] focus:outline-none placeholder-[#9ca3af] font-medium" + /> +
    + +
    +
    + + {/* 3. Main Split Workspace Grid */} +
    + + {/* Column Left: 候选智能体 (Grid weight 4 of 12) */} +
    + {/* Candidate Header */} +
    +
    + + + +

    候选智能体

    + + {filteredCandidates.length} + +
    + 拖到右侧 / 点击加入 +
    + + {/* Search Bar with Creation Utility */} +
    +
    + + + + setSearchTerm(e.target.value)} + placeholder="按名称搜索智能体..." + className="w-full pl-9 pr-3 py-2 text-xs border border-[#ede9e9] rounded-md bg-white focus:outline-none focus:ring-1 focus:ring-[#c25555] focus:border-[#c25555] placeholder-slate-400 font-medium" + /> + {searchTerm && ( + + )} +
    + + {/* Inline Custom Agent Launcher */} + +
    + + {/* Candidates Scroll List */} +
    + {filteredCandidates.length === 0 ? ( +
    +

    未找到符合搜索条件的智能体

    + {searchTerm && ( + + )} +
    + ) : ( + filteredCandidates.map((agent) => { + const isAdded = activeChain.seats.includes(agent.id); + return ( +
    handleToggleAgent(agent.id)} + className={`group relative flex items-start gap-3 p-3.5 bg-white border rounded-lg cursor-pointer transition-all duration-150 ${ + isAdded + ? "border-[#fcdbda] bg-[#fdfdfd] shadow-[0_1px_3px_rgba(194,85,85,0.02)]" + : "border-[#ede9e9] hover:border-[#c25555] hover:shadow-[0_2px_8px_rgba(0,0,0,0.03)]" + }`} + > + {/* Left Icon status badge */} +
    +
    + +
    +
    + + {/* Agent Details */} +
    +
    +

    {agent.name}

    + {agent.isCustom && ( + 自定义 + )} +
    +

    + {agent.description} +

    +
    + + {/* Status Badge Action on Far Right */} +
    + {isAdded ? ( + + 已加入 + + ) : ( + + 点击加入 + + )} + + {/* Custom agents get an extra trash deletion trigger */} + {agent.isCustom && ( + + )} +
    +
    + ); + }) + )} +
    +
    + + {/* Column Right: 链条席位 (Grid weight 8 of 12) */} +
    + {/* Seats Header */} +
    +
    + + + +

    链条席位

    + 按顺序发言 +
    + + {/* Limit counter badge */} +
    + 配置席位 + 0 + ? "bg-[#fef4f3] border-[#fcdbda] text-[#c25555]" + : "bg-slate-50 border-slate-200 text-slate-400" + }`}> + {activeChain.seats.length} / 8 + +
    +
    + + {/* Seats Content Canvas */} +
    + {activeChain.seats.length === 0 ? ( +
    +
    + +
    +

    暂无席位

    +

    + 该业务链条当前为空。请从左侧“候选智能体”列表中点击或者选择,将其按顺序编排加入此处。 +

    +
    + {allAgents.slice(0, 3).map(agent => ( + + ))} +
    +
    + ) : ( +
    + {activeChain.seats.map((agentId, index) => { + const agent = allAgents.find(a => a.id === agentId); + if (!agent) return null; + + const theme = SEAT_COLORS[index] || { bg: "bg-slate-50 border border-slate-200 text-slate-600" }; + const isDragged = index === draggedIndex; + const isHoveredDrop = index === dragOverIndex; + + return ( +
    handleDragStart(index)} + onDragOver={(e) => handleDragOver(e, index)} + onDragEnd={() => { setDraggedIndex(null); setDragOverIndex(null); }} + onDrop={() => handleDrop(index)} + className={`flex items-stretch bg-white border rounded-lg shadow-sm transition-all duration-200 select-none ${ + isDragged ? "opacity-35 scale-[0.98] border-dashed border-[#c25555]" : "" + } ${ + isHoveredDrop ? "border-2 border-[#c25555] bg-[#fef4f3] scale-[1.01]" : "border-[#ede9e9]" + }`} + > + {/* Left Grip Handle */} +
    +
    + +
    +
    + + {/* Number Indicator Badge - Placed to the LEFT of details */} +
    +
    + {index + 1} +
    +
    + + {/* Seat info details */} +
    +

    + {agent.name} + {agent.isCustom && ( + Custom + )} +

    +

    + {agent.description} +

    +
    + + {/* Seat controller Actions (Move up/down & close delete) */} +
    + {/* Up button */} + + + {/* Down button */} + + + {/* Cross Delete button */} + +
    + +
    + ); + })} +
    + )} +
    +
    + +
    + + {/* 4. Float Helper Footer decoration */} +
    + 编排有序的业务链条,让智能体决策更有深度。保存后,配置将自动同步至圆桌。 +
    + + {/* 5. Custom Agent Modal (Popup Card) */} + {showAgentModal && ( +
    +
    + {/* Modal Header */} +
    +
    + 🧠 +

    添加自定义候选智能体

    +
    + +
    + + {/* Modal Form */} +
    +
    + + setNewAgentName(e.target.value)} + className="w-full px-3 py-2 text-xs border border-[#ede9e9] rounded-md focus:outline-none focus:ring-1 focus:ring-[#c25555] focus:border-[#c25555] placeholder-slate-400 font-medium" + /> +
    + +
    + +