diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 00000000..d30ab43d --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,12 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "md-viewer-frontend", + "runtimeExecutable": "npx", + "runtimeArgs": ["ng", "serve", "--port", "4200"], + "port": 4200, + "cwd": "F:\\angular\\md-viewer\\frontend" + } + ] +} diff --git a/frontend-web/.env.development b/frontend-web/.env.development index e66a65da..1d8b3666 100644 --- a/frontend-web/.env.development +++ b/frontend-web/.env.development @@ -28,6 +28,8 @@ VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn # 我的笔记前端 VITE_AIPI_FRONTEND_URL= xxx VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer +# MaxKey 鉴权(getTokenByOnlineTicket) +VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn # 可选:Canvas 独立 LangGraph 地址(不设则回退到 VITE_LANGGRAPH_BASE_URL) diff --git a/frontend-web/.env.development.local.example b/frontend-web/.env.development.local.example index f01f2187..a5092803 100644 --- a/frontend-web/.env.development.local.example +++ b/frontend-web/.env.development.local.example @@ -17,6 +17,7 @@ VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer +VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn diff --git a/frontend-web/.env.example b/frontend-web/.env.example index 80a2b0f7..0c211a17 100644 --- a/frontend-web/.env.example +++ b/frontend-web/.env.example @@ -25,6 +25,10 @@ # --- 生产打包(见 .env.production)--- # 部署前修改 .env.production 中的地址,再执行 pnpm build +# MaxKey 鉴权根地址(无界 onlineTicket 换 token) +# VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn +# VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer + # VITE_AIPI_B2ACTOR_URL=https://ty.b.uat.4.cn # VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1 # VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl diff --git a/frontend-web/.env.network b/frontend-web/.env.network index f8962ea6..4bcd8f15 100644 --- a/frontend-web/.env.network +++ b/frontend-web/.env.network @@ -23,6 +23,8 @@ VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red VITE_AIPI_MOPECHAT_URL=http://192.168.1.100/likun_b1_mope VITE_AIPI_BACKENP_URL=http://192.168.1.100 VITE_AIPI_CONSUMER_URL=http://192.168.1.100/consumer +# MaxKey 鉴权(getTokenByOnlineTicket,内网部署请改为实际地址) +VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn VITE_AIPI_PGLINKTO_URL=http://192.168.1.100 VITE_AIPI_XKLINKTO_URL=http://192.168.1.100 diff --git a/frontend-web/.env.production b/frontend-web/.env.production index 0f10b55c..67dabce1 100644 --- a/frontend-web/.env.production +++ b/frontend-web/.env.production @@ -21,6 +21,8 @@ VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer +# MaxKey 鉴权(getTokenByOnlineTicket) +VITE_AUTH_BASE_URL=https://auth.y.uat.4.cn VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn diff --git a/frontend-web/.env.sit b/frontend-web/.env.sit index 5ae77457..913fe3a3 100644 --- a/frontend-web/.env.sit +++ b/frontend-web/.env.sit @@ -21,6 +21,8 @@ VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red VITE_AIPI_MOPECHAT_URL=https://ch1.b.sit.4.cn/likun_b1_mope VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.sit.4.cn VITE_AIPI_CONSUMER_URL=https://ch1.b.sit.4.cn/consumer +# MaxKey 鉴权(getTokenByOnlineTicket) +VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn VITE_AIPI_PGLINKTO_URL=https://pg.b.sit.4.cn VITE_AIPI_XKLINKTO_URL=https://kz.b.sit.4.cn diff --git a/frontend-web/docs/ai-writing-右侧编辑器-滑词与段落菜单.md b/frontend-web/docs/ai-writing-右侧编辑器-滑词与段落菜单.md new file mode 100644 index 00000000..ea9c4d50 --- /dev/null +++ b/frontend-web/docs/ai-writing-右侧编辑器-滑词与段落菜单.md @@ -0,0 +1,228 @@ +# AI 写作 · 右侧文本编辑器(滑词 / 段落菜单) + +> 说明 AI 写作页、开放画布中文稿右侧编辑器的实现方式:**编辑器本体基于 Tiptap v3**;选中文本浮层(「滑词」)与段落悬停按钮为**自研 React 组件**,未使用 `@tiptap/extension-bubble-menu` 官方 BubbleMenu。 + +--- + +## 一、功能概览 + +| 交互 | 用户感知 | 组件 | 触发方式 | +|------|----------|------|----------| +| **滑词** | 拖选一段文字后,选区上方出现工具条(AI 改写、加粗、斜体、下划线、H1、复制) | `WritingBubbleMenu` | Tiptap `selectionUpdate` + 浏览器 `window.getSelection()` | +| **段落菜单** | 鼠标移入某段/标题/引用块时,段落左侧出现 ✨ 按钮,点击可 AI 改写整段 | `WritingParagraphMenu` | 编辑器 DOM `mousemove` + ProseMirror `posAtCoords` | +| **AI 改写面板** | 点击「AI 改写」后弹出润色/扩写等操作与预览 | `WritingAiMenu` / `WritingRewritePreview` | 由 `TextArtifactRenderer` + `useWritingRewrite` 串联 | + +画布线程页(`/page/canvas/:thread_id`)与 AI 写作页(`/page/workspace/ai-writing`)右侧草稿**共用同一套** `TextArtifactRenderer` 与菜单组件。 + +--- + +## 二、在 AI 写作中的挂载路径 + +```mermaid +flowchart TB + AIWritingPage --> CanvasResizableLayout + CanvasResizableLayout --> AIWritingDraftPanel + AIWritingDraftPanel --> DraftStreamingPreview + AIWritingDraftPanel --> TextArtifactRenderer + DraftStreamingPreview["DraftStreamingPreview
(流式只读 Markdown)"] + TextArtifactRenderer --> WritingBubbleMenu + TextArtifactRenderer --> WritingParagraphMenu + TextArtifactRenderer --> TiptapEditor + TextArtifactRenderer --> WritingAiMenu +``` + +| 阶段 | 右侧展示 | 滑词 / 段落菜单 | +|------|----------|-----------------| +| 草稿流式生成中(`writing_draft` / `revising` 且有 section 流) | `DraftStreamingPreview`(ReactMarkdown 只读) | **不可用** | +| 成稿可编辑(`isDraftEditable === true`) | `TextArtifactRenderer` + Tiptap | **可用** | +| 流式结束但尚未 editable | `TextArtifactRenderer` 且 `isStreaming={true}` | **不可用**(`editable: false`) | + +相关状态由 [CanvasUIContext.tsx](../src/open-canvas/contexts/CanvasUIContext.tsx) 的 `isDraftStreaming`、`isDraftEditable` 控制;注释中「禁用编辑/润色/滑词」即指生成完成前不进入可编辑渲染器。 + +入口文件: + +- [AIWritingDraftPanel.tsx](../src/open-canvas/components/ai-writing/AIWritingDraftPanel.tsx) — 决定流式预览 vs `TextArtifactRenderer` +- [TextArtifactRenderer.tsx](../src/open-canvas/components/artifact/TextArtifactRenderer.tsx) — 挂载编辑器与两套浮层菜单 + +--- + +## 三、技术栈:编辑器 vs 浮层 + +### 3.1 Tiptap 编辑器(有使用) + +| 包 | 用途 | +|----|------| +| `@tiptap/react` | `useEditor`、`EditorContent` | +| `@tiptap/starter-kit` | 段落、标题、加粗、斜体、下划线等 | +| `@tiptap/extension-placeholder` | 空文档占位符 | +| `tiptap-markdown` | Markdown ↔ 文档互转 | +| 自研 `AiHighlightExtension` | AI 改写前高亮选区(ProseMirror Decoration) | + +扩展注册见 [TiptapExtensions.ts](../src/open-canvas/editor/TiptapExtensions.ts);实例创建见 [useWritingEditor.ts](../src/open-canvas/hooks/useWritingEditor.ts)。 + +### 3.2 浮层菜单(自研,非 Tiptap 官方插件) + +| 包 | 是否用于滑词/段落菜单 | +|----|----------------------| +| `@tiptap/extension-bubble-menu` | **未使用**(`package.json` 中有依赖,代码未 import) | +| `@tiptap/react` 的 `` | **未使用** | + +两套菜单均采用: + +1. 监听 Tiptap / DOM 事件更新位置与选区; +2. `createPortal(..., document.body)` 挂到 `document.body`,避免被右侧滚动容器 `overflow` 裁切; +3. `position: fixed` + `getBoundingClientRect()` 定位。 + +命名上的 `WritingBubbleMenu` 仅表示「气泡式选区工具条」,实现上与 Tiptap 官方 Bubble Menu 扩展无关。 + +--- + +## 四、核心文件索引 + +| 文件 | 职责 | +|------|------| +| [TiptapEditor.tsx](../src/open-canvas/editor/TiptapEditor.tsx) | `EditorContent` 渲染容器 | +| [TiptapExtensions.ts](../src/open-canvas/editor/TiptapExtensions.ts) | StarterKit、Placeholder、Markdown、AiHighlight | +| [AiHighlightExtension.ts](../src/open-canvas/editor/AiHighlightExtension.ts) | `setAiHighlight` / `clearAiHighlight` 命令 | +| [useWritingEditor.ts](../src/open-canvas/hooks/useWritingEditor.ts) | 创建 editor、同步外部 markdown、editable | +| [WritingBubbleMenu.tsx](../src/open-canvas/components/artifact/WritingBubbleMenu.tsx) | **滑词** 选区浮层 | +| [WritingParagraphMenu.tsx](../src/open-canvas/components/artifact/WritingParagraphMenu.tsx) | **段落** 左侧 ✨ 按钮 | +| [WritingAiMenu.tsx](../src/open-canvas/components/artifact/WritingAiMenu.tsx) | AI 改写操作菜单 | +| [WritingRewritePreview.tsx](../src/open-canvas/components/artifact/WritingRewritePreview.tsx) | 改写结果预览 | +| [useWritingRewrite.ts](../src/open-canvas/hooks/useWritingRewrite.ts) | 调用润色 API、写回编辑器 | +| [apply-rewrite.ts](../src/open-canvas/utils/apply-rewrite.ts) | 将改写结果应用到 ProseMirror 选区 | +| [CanvasUIContext.tsx](../src/open-canvas/contexts/CanvasUIContext.tsx) | `writingSelection` 全局状态 | +| [selection.ts](../src/open-canvas/types/selection.ts) | `WritingSelection` 类型定义 | + +--- + +## 五、滑词:`WritingBubbleMenu` + +### 5.1 行为 + +- 用户在编辑器内拖选非空文本时显示浮层;选区为空或位于 `codeBlock` 内时隐藏。 +- 浮层按钮:`AI 改写`、`B` / `I` / `U`、`H1`、复制。 +- 按钮 `onMouseDown` 使用 `e.preventDefault()`,避免点击时编辑器失焦导致选区丢失。 + +### 5.2 实现要点 + +```text +editor.on("selectionUpdate") + → 读取 editor.state.selection (from, to, empty) + → window.getSelection() + Range.getBoundingClientRect() 算屏幕坐标 + → setWritingSelection({ from, to, text }) 写入 CanvasUIContext + → createPortal 渲染工具条,useLayoutEffect 做贴边 / 上方空间不足时翻到选区下方 +``` + +关键代码位置:[WritingBubbleMenu.tsx](../src/open-canvas/components/artifact/WritingBubbleMenu.tsx) 中 `useEffect` 监听 `selectionUpdate` 与 `blur`。 + +点击「AI 改写」时: + +1. `editor.commands.setAiHighlight(from, to)` 高亮选区; +2. 调用父组件传入的 `onAiRewrite()`,由 `TextArtifactRenderer` 打开 `WritingAiMenu`。 + +--- + +## 六、段落菜单:`WritingParagraphMenu` + +### 6.1 行为 + +- 鼠标在编辑器内移动时,检测光标下的**块级节点**; +- 仅对 `paragraph`、`heading`、`blockquote` 且文本非空时,在块左侧显示 ✨ 按钮; +- 鼠标离开编辑器或移出可改写块后,延迟约 **380ms** 隐藏(`HIDE_DELAY`),并在按钮与正文之间留有 hover 桥接区,避免移向按钮时闪烁消失。 + +### 6.2 实现要点 + +```text +editor.view.dom mousemove + → editor.view.posAtCoords({ left, top }) + → doc.resolve(pos).before(depth) 定位块起点 + → editor.view.nodeDOM(nodeStart) 取 DOM 矩形 + → createPortal 固定定位按钮在块左侧 (BTN_SIZE=24, BTN_GAP=6) +``` + +点击 ✨ 时: + +1. `setTextSelection` 选中整段 ProseMirror 范围(`nodeFrom` ~ `nodeTo`); +2. `setAiHighlight` + `setWritingSelection`; +3. 触发与滑词相同的 `onAiRewrite()` 流程。 + +关键代码位置:[WritingParagraphMenu.tsx](../src/open-canvas/components/artifact/WritingParagraphMenu.tsx)。 + +--- + +## 七、AI 改写数据流 + +```mermaid +sequenceDiagram + participant User + participant Menu as BubbleMenu / ParagraphMenu + participant Ctx as CanvasUIContext + participant Renderer as TextArtifactRenderer + participant API as rewrite API + + User->>Menu: 选中文本或点段落 ✨ + Menu->>Ctx: setWritingSelection + Menu->>Renderer: onAiRewrite() + Renderer->>Renderer: phase = menu, WritingAiMenu + User->>Renderer: 选择润色/扩写等 + Renderer->>API: useWritingRewrite.rewrite + API-->>Renderer: 流式结果 + Renderer->>Renderer: WritingRewritePreview + User->>Renderer: 接受改写 + Renderer->>Renderer: applyReplace / applyInsert +``` + +- 选区信息类型:`WritingSelection`(`from`、`to`、`text`),见 [selection.ts](../src/open-canvas/types/selection.ts)。 +- 与画布聊天侧选区 `CanvasTextSelection` 分离;后者用于快捷操作栏等,不用于 Tiptap 改写。 + +--- + +## 八、与顶部工具栏、目录栏的关系 + +同一 `TextArtifactRenderer` 编辑模式下还包含: + +| 组件 | 作用 | +|------|------| +| [WritingToolbar.tsx](../src/open-canvas/components/artifact/WritingToolbar.tsx) | 顶部固定格式栏、导出等 | +| [DraftOutlineSidebar.tsx](../src/open-canvas/components/artifact/DraftOutlineSidebar.tsx) | 左侧标题目录,点击滚动到对应 heading | + +滑词 / 段落菜单与工具栏互不替代:工具栏面向全局格式;浮层面向**当前选区或当前段落**的快捷操作与 AI 改写入口。 + +--- + +## 九、开发与改造建议 + +### 9.1 若要改用官方 Bubble Menu + +1. 在 [TiptapExtensions.ts](../src/open-canvas/editor/TiptapExtensions.ts) 或组件层引入 `@tiptap/react` 的 `BubbleMenu`(或注册 `@tiptap/extension-bubble-menu`); +2. 将 [WritingBubbleMenu.tsx](../src/open-canvas/components/artifact/WritingBubbleMenu.tsx) 内手写 `selectionUpdate` + Portal 逻辑替换为官方组件的 `shouldShow` / `tippyOptions`(v3 API 以官方文档为准); +3. **段落左侧按钮**仍建议保留自研 `WritingParagraphMenu`(官方 Bubble Menu 只覆盖选区,不覆盖 hover 段落场景)。 + +### 9.2 新增浮层按钮 + +- **滑词**:在 `WritingBubbleMenu` 的 `BubbleButton` 列表中增加,使用 `editor.chain().focus()....run()`。 +- **段落**:在 `WritingParagraphMenu` 的 `handleClick` 或拆出多按钮布局;注意 `REWRITABLE_TYPES` 与 hover 延迟逻辑。 + +### 9.3 流式阶段禁止交互 + +不要在 `DraftStreamingPreview` 中挂载 Tiptap 菜单:流式阶段 token 高频更新,挂载 `useWritingEditor` 易引发更新风暴(见 [DraftStreamingPreview.tsx](../src/open-canvas/components/ai-writing/DraftStreamingPreview.tsx) 文件头注释)。应等 `isDraftEditable` 后再走 `TextArtifactRenderer`。 + +--- + +## 十、相关文档 + +- [前端开发.md](./前端开发.md) §25.4 — Canvas / AI 写作路由与组件总表 +- [ai-writing-用户自带大纲-测试用例.md](./ai-writing-用户自带大纲-测试用例.md) — AI 写作业务流程测试(含右侧草稿层级校验) + +--- + +## 十一、依赖版本(摘录) + +以 [package.json](../package.json) 为准,编辑器相关主要为: + +- `@tiptap/core`、`@tiptap/react`、`@tiptap/starter-kit`:`^3.23.4` +- `@tiptap/extension-bubble-menu`:已安装但未接入滑词实现 +- `tiptap-markdown`:Markdown 扩展 + +升级 Tiptap 大版本前,请对照官方迁移指南,并重点回归:`WritingBubbleMenu` 选区坐标、`WritingParagraphMenu` 的 `posAtCoords`、以及 `AiHighlightExtension` 与 StarterKit 扩展名冲突(勿重复注册 `underline`)。 diff --git a/frontend-web/docs/ai-writing-用户自带大纲-测试用例.md b/frontend-web/docs/ai-writing-用户自带大纲-测试用例.md new file mode 100644 index 00000000..da73fbeb --- /dev/null +++ b/frontend-web/docs/ai-writing-用户自带大纲-测试用例.md @@ -0,0 +1,274 @@ +# AI 写作 ·「用户自带大纲」手动测试用例 + +> 功能:用户在 AI 写作表单粘贴已有大纲后,系统**完全按用户大纲生成正文**,兼容一/二/三级目录。 +> 入口页面:`http://localhost:5173/#/page/workspace/ai-writing`(或 `2026` 端口经 nginx) + +--- + +## 一、测试目标 + +验证三件事: +1. **识别**——用户粘贴的各种格式大纲,能被正确解析成 章节(##) → 小节(###) → 子要点(####) 三级结构。 +2. **保真**——生成的大纲标题、层级**与用户粘贴的完全一致**,不增、不删、不改写。 +3. **兼容**——只有一级 / 一+二级 / 一+二+三级 三种深度都能正确生成对应层级的正文。 + +--- + +## 二、前置条件 + +| 项 | 要求 | +|----|------| +| 服务 | 后端(Gateway 8001)+ 前端(Vite 5174/5173)均已启动,可正常发起一次普通 AI 写作 | +| 模型 | 写作模型已在表单可选、能正常输出(先用不带大纲的普通流程跑通一次作为基线) | +| 账号 | 已登录(或无鉴权模式) | +| 入口 | 进入 `/page/workspace/ai-writing`,能看到「写作配置选项」表单 | + +--- + +## 三、通用操作步骤 + +除非用例另有说明,每条用例都按以下步骤执行: + +1. 进入 AI 写作页面,点开**「已有大纲(可选)」** 旁的「我已有大纲,直接粘贴 ▾」。 +2. 在「写作意图」填一句话(如:`写一篇关于新能源汽车出口的分析文章`)。 +3. 把用例里的**「输入大纲」**整段粘贴进「已有大纲」输入框。 +4. 文章类型 / 目标字数 / 素材来源保持默认即可。 +5. 点击「开始 AI 写作」。 +6. 等待流程走到 **PAUSE-2「大纲确认」**,对照**「预期大纲结构」**核对左侧/卡片里的大纲。 +7. 点「确认」继续,等正文生成完,对照**「预期正文层级」**核对右侧草稿的标题层级。 + +> 关键校验点都在第 6 步(大纲确认卡)。正文层级(第 7 步)作为二次确认。 + +--- + +## 四、测试用例 + +### TC-01 Markdown 三级 + 文章标题 + +**输入大纲:** +``` +# 新能源汽车出口为何高速增长 +## 引言 +### 出口数据创新高 +#### 同比增幅 +#### 全球排名变化 +### 市场背景 +## 增长动因 +### 成本优势 +``` + +**预期大纲结构:** +- 文章标题 = `新能源汽车出口为何高速增长`(首个孤立 `#` 被识别为标题,不作为章节) +- 章节1 `引言` + - 小节 `出口数据创新高` → 子要点:`同比增幅`、`全球排名变化` + - 小节 `市场背景`(无子要点) +- 章节2 `增长动因` + - 小节 `成本优势` + +**预期正文层级:** `#` 文章标题 → `##` 引言/增长动因 → `###` 小节 → `####` 同比增幅/全球排名变化,各级都有正文。 + +--- + +### TC-02 中文编号三级(一、/(一)/ 1.) + +**输入大纲:** +``` +一、引言 +(一)背景 +(二)意义 +二、现状分析 +(一)市场数据 +1. 国内市场 +2. 海外市场 +``` + +**预期大纲结构:** +- 章节1 `引言` → 小节 `背景`、`意义` +- 章节2 `现状分析` → 小节 `市场数据` → 子要点 `国内市场`、`海外市场` +- 标题留空 → 用「写作意图」兜底(卡片标题约等于意图前 30 字) + +**校验重点:** `一、` > `(一)` > `1.` 的层级顺序正确(符合中文公文惯例)。 + +--- + +### TC-03 阿拉伯小数点编号(1. / 1.1 / 1.1.1) + +**输入大纲:** +``` +1. 引言 +1.1 研究背景 +1.2 研究意义 +2. 现状 +2.1 行业概况 +``` + +**预期大纲结构:** +- 章节1 `引言` → 小节 `研究背景`、`研究意义` +- 章节2 `现状` → 小节 `行业概况` + +--- + +### TC-04 缩进 bullet 表示层级 + +**输入大纲:** +``` +- 引言 + - 背景 + - 意义 +- 现状 + - 数据 + - 细节展开 +``` + +**预期大纲结构:** +- 章节1 `引言` → 小节 `背景`、`意义` +- 章节2 `现状` → 小节 `数据` → 子要点 `细节展开` + +**校验重点:** 缩进深度正确映射成层级。 + +--- + +### TC-05 只有一级目录(平铺 Markdown) + +**输入大纲:** +``` +## 引言 +## 现状 +## 对策 +## 结论 +``` + +**预期大纲结构:** 4 个章节 `引言/现状/对策/结论`,**均无小节**。 +**预期正文层级:** 每个章节 `##` 标题下是一整段连贯正文(不出现 `###`)。 + +**校验重点:** 一级目录场景不报错、不被强行拆出多余小节。 + +--- + +### TC-06 只有一级目录(中文编号) + +**输入大纲:** +``` +一、引言 +二、现状 +三、结论 +``` + +**预期大纲结构:** 3 个章节 `引言/现状/结论`,无小节。与 TC-05 等价,验证另一种一级写法。 + +--- + +### TC-07 纯文本平铺(无任何标记) + +**输入大纲:** +``` +引言 +现状 +对策 +总结 +``` + +**预期大纲结构:** 4 个章节 `引言/现状/对策/总结`(每行一个短标题 → 一级章节)。 + +**校验重点:** 无编号无缩进的短标题列表也能识别为一级大纲。 + +--- + +### TC-08 格式混乱 / 散文化(走 LLM 兜底) + +**输入大纲:** +``` +我想写一篇分析今年AI大模型发展趋势的文章,重点关注国内企业的布局。 +希望先讲背景,再讲现状,最后给出展望。 +``` + +**预期行为:** +- 确定性解析判定为「散文」→ 自动走 **LLM 归一兜底**,整理成一份结构化大纲。 +- 大纲确认卡能正常显示一份大纲(不报错、不卡死)。 +- 大纲内容应贴近原意(背景 / 现状 / 展望之类),**但不要求逐字一致**(这一条是兜底鲁棒性,不是保真用例)。 + +**校验重点:** 流程不崩、能产出可用大纲。 + +--- + +### TC-09 保真校验(标题原样保留) + +**输入大纲:** +``` +一、A股市场2024年回顾与展望 +(一)宏观经济(GDP/CPI)影响 +(二)流动性与利率 +二、行业轮动主线 +``` + +**预期大纲结构:** 标题文本**逐字保留**,仅编号被剥离: +- 章节1 `A股市场2024年回顾与展望` + - 小节 `宏观经济(GDP/CPI)影响`、`流动性与利率` +- 章节2 `行业轮动主线` + +**校验重点:** 标题里的中文括号、英文缩写、年份等**不被改写**。 + +--- + +### TC-10 大纲确认卡里再编辑 / 重新规划 + +**前置:** 先用 TC-02 的大纲跑到「大纲确认」卡。 + +**操作:** +1. 在确认卡里**手动改一个小节标题**(如把 `背景` 改成 `行业背景`)→ 点「确认」。 +2. 或点「重新规划」并填修改要求(如 `给"现状分析"再加一个小节`)。 + +**预期行为:** +- 手动改后确认:正文按改后的大纲生成。 +- 重新规划:基于**用户这份大纲**做编辑式调整(在原结构上加/改),而不是从零另起一份无关大纲。 + +**校验重点:** 自带大纲 → 确认 → 编辑 这条回路正常,不会丢掉用户原始结构。 + +--- + +### TC-11 回归:不填大纲(默认流程不受影响) + +**操作:** 「已有大纲」**留空**,只填写作意图,正常发起。 + +**预期行为:** 与功能上线前完全一致——系统自动检索素材、**自动规划大纲**(大纲由 AI 生成,非用户提供)。 + +**校验重点:** 增量功能不污染老流程。 + +--- + +### TC-12 素材关联(仍走检索) + +**前置:** 素材来源选「知识库」,用 TC-02 的大纲发起,等检索出素材并确认。 + +**预期行为:** +- 走到大纲时,用户的章节会被**关联上检索到的素材**(正文里能体现素材中的事实/数据)。 +- 关联**只填素材、不改大纲标题与结构**。 + +**校验重点:** 「自带大纲」与「检索素材」能共存——大纲是用户的,正文有素材支撑。 + +--- + +## 五、缺陷记录模板 + +| 用例 | 输入大纲(摘要) | 预期 | 实际 | 是否通过 | 备注 | +|------|------------------|------|------|----------|------| +| TC-01 | Markdown 三级 | 标题+2章节+子要点 | | ☐ | | +| TC-02 | 一、/(一)/1. | 中文三级 | | ☐ | | +| TC-03 | 1./1.1/1.1.1 | 阿拉伯三级 | | ☐ | | +| TC-04 | 缩进 bullet | 缩进三级 | | ☐ | | +| TC-05 | 平铺 ## | 4 章节无小节 | | ☐ | | +| TC-06 | 平铺 一、 | 3 章节无小节 | | ☐ | | +| TC-07 | 纯文本平铺 | 4 章节 | | ☐ | | +| TC-08 | 散文 | LLM 兜底产出大纲 | | ☐ | | +| TC-09 | 带括号/缩写标题 | 标题逐字保留 | | ☐ | | +| TC-10 | 确认卡编辑/重规划 | 在原大纲上调整 | | ☐ | | +| TC-11 | 留空 | 走自动规划 | | ☐ | | +| TC-12 | 检索+自带大纲 | 素材关联不改结构 | | ☐ | | + +--- + +## 六、备注 + +- TC-01~07、09 的解析逻辑已有**自动化单测**覆盖(`backend/tests/test_ai_writing_outline_parser.py`,19 个用例),本文档侧重**端到端真机表现**(流式展示、确认卡渲染、正文层级、素材关联)。 +- 若某条解析结果与预期不符,先看大纲确认卡里的**结构**是否正确(定位是解析问题还是正文生成问题)。 +- 相关后端实现:`packages/harness/deerflow/agents/ai_writing/outline_parser.py`(解析)、`nodes/writer_outline.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..45ed5de2 --- /dev/null +++ b/frontend-web/docs/embed-iframe-chat.md @@ -0,0 +1,436 @@ +# 工作台问答 · iframe 嵌入开发说明 + +本文档说明如何将 DeerFlow **通用工作台问答**(与 `/page/workspace/chats/*` 同一套 LangGraph 流式能力)以**普通 iframe** 形式嵌入第三方页面,并在**父页面无法读取子 frame 内 `thread_id`** 的前提下,轮询「本次问答是否已结束」。 + +后端公开 API 实现见:`offline-backend-20260512/backend/app/gateway/routers/public_embed.py` +数据表:`embed_sessions`(迁移 `20260602_01`) + +--- + +## 0. 速读 + +| 角色 | 要做的事 | +|------|----------| +| **父页面** | 自己生成 `sessionId`(UUID),拼进 iframe `src`;用 `sessionId` 调公开 API 轮询 `finished` | +| **iframe(DeerFlow)** | 打开 `/embed/chats/...`,自动登录、自动发问、登记 `sessionId → thread_id` | +| **不需要** | 无界(wujie)、父页面拿 `thread_id`、DeerFlow 登录态 Cookie | + +**完成条件**:`GET .../sessions/{sessionId}/status` 返回 `finished === true`(且无进行中的 run,非澄清中断)。 + +--- + +## 1. 背景与约束 + +### 1.1 业务目标 + +- 第三方系统用 **iframe** 嵌 DeerFlow 问答 UI。 +- 传入:**问题文案**、**用户名**(走现有 `login/username`)、**主题色**。 +- 展示:**消息列表** + **沙箱/产物侧栏**(与主聊天相同的 `ChatBox`);**隐藏**页面左侧栏、Workspace 侧栏、底部输入框。 +- 进入后 **自动发送** 首条用户消息并走流式问答。 + +### 1.2 为何需要 `sessionId` + +iframe 与父页面通常 **跨域**,父页面 **不能** 读取子应用 URL 里的 `thread_id`(hash 路由在子 frame 内)。 +因此约定: + +1. **父页面**在打开 iframe 前生成全局唯一的 `sessionId`; +2. **子页面**在创建/确定 `thread_id` 后,调用后端 **登记接口** 写入 `session_id → thread_id`; +3. **父页面**只凭 `sessionId` 查询状态,必要时响应里会带上 `thread_id`(可选使用)。 + +--- + +## 2. 端到端流程 + +```mermaid +sequenceDiagram + participant Parent as 父页面 + participant Iframe as DeerFlow iframe + participant API as Gateway /api/public/embed + + Parent->>Parent: sessionId = crypto.randomUUID() + Parent->>Iframe: iframe src 含 sessionId、message、username、theme + Iframe->>Iframe: loginByUsername + 自动 sendMessage + Iframe->>API: POST /sessions { session_id, thread_id } + loop 每 1–3s + Parent->>API: GET /sessions/{sessionId}/status + API-->>Parent: registered, finished, thread_id + end +``` + +--- + +## 3. 前端路由与页面 + +### 3.1 路由(独立于 `/page/*`) + +在 `App.tsx` 注册,**不经过** `PageRoutes` / `PageSidebar`,因此无外层导航与 Workspace 侧栏。 + +| 路由 | 组件 | 说明 | +|------|------|------| +| `/embed/chats/:thread_id` | `EmbedChatPage`(外包 `ChatRuntime`) | `thread_id` 可为 `new` 或已有 UUID | + +示例(HashRouter,注意 `#`): + +``` +http://localhost:5173/#/embed/chats/new?sessionId=...&message=...&username=guest&theme=red +``` + +开发环境端口以本地 Vite 为准(如 5173 / 5174)。 + +### 3.2 URL 查询参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `sessionId` 或 `session_id` | **是** | 父页面生成的关联 id,8–128 位,仅字母数字 `_` `-` | +| `message` / `question` / `text` | 建议 | 首条用户问题;有则进入后自动发送 | +| `username` / `userName` | 否 | 默认 `guest`,对应 `POST /api/v1/auth/login/username` | +| `theme` | 否 | `dark-blue` → 深色;`red` 等 → 浅色(与 `LoginPage` 一致) | + +缺少 `sessionId` 时,嵌入页会提示错误,不会发起登记。 + +### 3.3 相关源码 + +| 路径 | 职责 | +|------|------| +| [src/pages/EmbedChatPage.tsx](../src/pages/EmbedChatPage.tsx) | 嵌入页:登录、自动发问、登记 session、仅消息列表 + ChatBox | +| [src/core/embed/params.ts](../src/core/embed/params.ts) | 从 URL 解析嵌入参数 | +| [src/core/embed/status-api.ts](../src/core/embed/status-api.ts) | 登记 / 轮询 API 封装 | +| [src/App.tsx](../src/App.tsx) | 路由注册 | + +### 3.4 UI 行为说明 + +- **隐藏**:`PageSidebar`、`WorkspaceSidebar`、`InputBox`、顶栏标题栏等(相对完整 `ChatPage`)。 +- **保留**:`MessageList`、流式指示、澄清卡片(若 agent 触发 `ask_clarification`)、右侧 **产物/沙箱**(`ChatBox` 内 60/40 分栏逻辑与主聊天一致)。 +- **线程 id**:新建会话时客户端先分配 UUID,`onStart` 后 `history.replaceState` 更新 hash 为 `/embed/chats/{真实id}`(与主聊天 `ChatPage` 相同手法,避免整页重载)。 + +--- + +## 4. 公开 HTTP API(无需鉴权) + +前缀:`/api/public/embed/`(走 Gateway,与 `VITE_BACKEND_BASE_URL` 一致,默认 `http://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 获取生成结果(父页面使用) + +问答结束后(建议先确认 `finished === true`),拉取本次模型输出: + +```http +GET /api/public/embed/sessions/{session_id}/result +``` + +**尚未登记:** + +```json +{ + "session_id": "parent-generated-uuid", + "registered": false, + "thread_id": null, + "finished": false, + "thread_status": "pending", + "has_active_run": false, + "latest_run_status": null, + "messages": [], + "generated_text": "", + "generated_files": [] +} +``` + +**已登记且已有内容:** + +```json +{ + "session_id": "parent-generated-uuid", + "registered": true, + "thread_id": "e9c7d09e-821d-421f-ae24-e5ddfe0cff5b", + "finished": true, + "thread_status": "idle", + "has_active_run": false, + "latest_run_status": "success", + "messages": [ + { "type": "human", "content": "请总结要点", "id": "..." }, + { "type": "ai", "content": "根据分析,要点如下:...", "id": "..." } + ], + "generated_text": "根据分析,要点如下:...", + "generated_files": [ + "/mnt/user-data/outputs/summary.md", + "/mnt/user-data/outputs/chart.png" + ] +} +``` + +#### 结果字段说明 + +| 字段 | 含义 | +|------|------| +| `messages` | 完整消息列表(与 checkpoint 中序列化结构一致,含 human / ai / tool 等) | +| `generated_text` | 所有 `ai` / `assistant` 消息正文按顺序拼接(段落间空一行) | +| `generated_files` | 本次会话产物的虚拟路径列表(与 UI 产物栏 `artifacts` 一致) | + +未结束时也可调用,会返回当前已有内容;`finished` 仍为 false 表示可能还会继续生成。 + +### 4.4 按 thread_id 查询(可选) + +调试或父页面已持有 thread_id 时: + +```http +GET /api/public/embed/threads/{thread_id}/status +GET /api/public/embed/threads/{thread_id}/result +``` + +状态接口响应不含 `messages` / `generated_text` / `generated_files`;结果接口字段与 §4.3 相同(无 `session_id` / `registered`)。 + +### 4.5 前端封装 + +```ts +import { + getEmbedSessionResult, + getEmbedSessionStatus, + registerEmbedSession, +} from "@/core/embed/status-api"; + +// 父页面(或你们自己的脚本) +const status = await getEmbedSessionStatus(sessionId); +if (status.registered && status.finished) { + const result = await getEmbedSessionResult(sessionId); + console.log(result.generated_text, result.generated_files); +} + +// 仅 iframe 内需要 +await registerEmbedSession(sessionId, threadId); +``` + +`fetch` 使用 `credentials: "omit"`,不携带 DeerFlow 会话。 + +--- + +## 5. 父页面集成示例 + +### 5.1 拼接 iframe + +```html + +``` + +```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 poll = await pollUntilFinished(sessionId); + if (poll.done) { + clearInterval(timer); + const output = await fetch( + `${API}/api/public/embed/sessions/${encodeURIComponent(sessionId)}/result`, + { credentials: "omit" }, + ).then((r) => r.json()); + console.log("问答已结束", output.generated_text, output.generated_files); + } + } catch (e) { + console.error(e); + } +}, 2000); +``` + +### 5.3 跨域注意 + +父页面从浏览器 `fetch` Gateway(如 `http://127.0.0.1:8001`)时,**Origin 必须是后端 CORS 白名单里的完整地址**(含协议、主机、端口),与 API 地址是否写 `localhost` / `127.0.0.1` 无关。 + +**本地开发**(前端默认 `5174`)请在 Gateway 环境变量中配置: + +```bash +# offline-backend-20260512/.env 或启动脚本注入 +GATEWAY_CORS_ORIGINS=http://localhost:5174,http://127.0.0.1:5174,http://localhost:5173,http://127.0.0.1:5173 +``` + +修改后**重启 Gateway**(`make dev` / `make gateway`)。若父页面部署在其它域名,把该域名的 origin 一并加入逗号分隔列表。 + +临时调试(勿用于公网生产): + +```bash +GATEWAY_CORS_ALLOW_ALL=1 +``` + +常见报错:`No 'Access-Control-Allow-Origin' header` → 当前页面的 origin 未出现在 `GATEWAY_CORS_ORIGINS` 中。 + +### 5.4 其它部署注意 + +--- + +## 6. 部署与数据库 + +1. **跑迁移**(SQL 持久化后端): + + ```bash + cd offline-backend-20260512/backend + # 按项目惯例执行 alembic upgrade head + ``` + + 表:`embed_sessions(session_id PK, thread_id, created_at, updated_at)`。 + +2. **重启 Gateway**,确认路由已挂载:`public_embed.router`(`app/gateway/app.py`)。 + +3. **前端构建** 后,将嵌入地址配给第三方;`VITE_BACKEND_BASE_URL` 指向可达的 Gateway。 + +无 SQL 时开发环境会退化为 **内存** `MemoryEmbedSessionStore`(进程重启后映射丢失);生产请使用持久化库。 + +--- + +## 7. 安全说明 + +- `sessionId` 与 `thread_id` 均视为 **不透明能力令牌**:知晓即可查询状态(公开 API **无登录**)。 +- 建议:父系统自行生成不可猜测的 UUID;敏感场景在贵司 API 网关再加鉴权或 IP 限制。 +- 登记接口为 **POST upsert**,不校验「session 是否属于某租户」——若需多租户隔离,请在业务层约定 `sessionId` 命名空间或增加网关校验。 + +--- + +## 8. 与主聊天 `/page/workspace/chats` 的差异 + +| 项目 | 主聊天 | iframe 嵌入 | +|------|--------|-------------| +| 路由 | `/page/workspace/chats/:id` | `/embed/chats/:id` | +| 布局 | 完整 Workspace + 输入框 | 仅消息区 + 产物栏 | +| 登录 | 常规登录流 | URL `username` 自动 `login/username` | +| 首条消息 | 用户输入 | URL `message` 自动发送 | +| 完成态查询 | 无公开 API | `sessionId` + `/api/public/embed/sessions/.../status` | + +能力上仍走同一 `useThreadStream` + `lead_agent` 流式链路,沙箱与产物行为与主聊天一致。 + +--- + +## 9. 常见问题 + +**Q:`registered` 一直为 false?** +A:检查 iframe URL 是否带 `sessionId`;子页是否登录成功;Network 里是否有 `POST /api/public/embed/sessions` 且 200。 + +**Q:`finished` 一直 false?** +A:可能仍在流式生成(`has_active_run: true`),或 agent 进入澄清(`thread_status: interrupted`)。嵌入模式无底部输入框,澄清需产品侧另行处理或避免触发澄清类工具。 + +**Q:父页面能否不用轮询?** +A:当前标准方案为公开 GET 轮询。若同源可考虑 `postMessage` 扩展(未默认实现)。 + +**Q:能否继续用已有 thread_id 嵌入?** +A:可以:`#/embed/chats/{thread_id}?sessionId=...`,可选再带 `message` 追加一轮;登记会在已知 id 上 upsert。 + +--- + +## 10. 变更记录 + +| 日期 | 说明 | +|------|------| +| 2026-06-02 | 初版:iframe 嵌入路由、`sessionId` 映射表、公开登记与状态 API | +| 2026-06-02 | 新增 `GET /sessions/{session_id}/result`:返回 messages、generated_text、generated_files | 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..f2946bc4 --- /dev/null +++ b/frontend-web/docs/multi-agent-business-chain-dev.md @@ -0,0 +1,400 @@ +# 业务链条模式 · 需求与分步实现文档 + +> 面向「智能体推荐弹窗」的扩展:在现有**智能推荐**之外,新增**业务链条选择**与**业务链条配置**两块能力;选定业务链条后,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] 顶部:链条列表下拉(`