Merge branch 'dhk-1' into 'master'

Dhk 1

See merge request knowledge-base-bj/deerflow-server!78
This commit is contained in:
董宏凯 committed 2026-06-03 09:38:24 +00:00
commit a4b14e4911
106 files changed
+9883 -509

No files matched your search

+12
View File
@@ -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"
}
]
}
+2
View File
@@ -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)
@@ -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
+4
View File
@@ -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
+2
View File
@@ -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
+2
View File
@@ -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
+2
View File
@@ -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
@@ -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<br/>(流式只读 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` 的 `<BubbleMenu>` | **未使用** |
两套菜单均采用:
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`)。
@@ -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`(自带大纲分支 + 素材关联)。
+436
View File
@@ -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
<iframe
id="deerflow-embed"
title="DeerFlow 问答"
style="width:100%;height:600px;border:0"
></iframe>
```
```javascript
const sessionId = crypto.randomUUID();
const base = "http://localhost:5173"; // 换成你们部署的前端 origin
const params = new URLSearchParams({
sessionId,
message: "请根据附件总结要点",
username: "guest",
theme: "red",
});
document.getElementById("deerflow-embed").src =
`${base}/#/embed/chats/new?${params.toString()}`;
```
### 5.2 轮询直到结束
```javascript
const API = "http://127.0.0.1:8001"; // VITE_BACKEND_BASE_URL
async function pollUntilFinished(sessionId) {
const res = await fetch(
`${API}/api/public/embed/sessions/${encodeURIComponent(sessionId)}/status`,
{ credentials: "omit" },
);
if (!res.ok) throw new Error(`status HTTP ${res.status}`);
const data = await res.json();
if (!data.registered) return { done: false, reason: "pending" };
if (data.finished) return { done: true, data };
return { done: false, reason: "running" };
}
const timer = setInterval(async () => {
try {
const 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 |
@@ -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 + 简短结论)、边界(只产出本席位、等总控派活)。
@@ -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<BusinessChain, "id" | "title">;
}
```
**约束**:链条席位数沿用圆桌的 `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<BusinessChain[]> // 当前用户全部链条
getChain(id: string): Promise<BusinessChain>
createChain(input: Omit<BusinessChain,"id"|"createdAt"|"updatedAt">): Promise<BusinessChain>
updateChain(id: string, patch: Partial<BusinessChain>): Promise<BusinessChain>
deleteChain(id: string): Promise<void>
```
- 实现用 `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] 顶部:链条列表下拉(`<select>` 列出已存链条 + 新建项)/ 新建按钮 / 标题+描述输入 / 保存 / 删除(`window.confirm`)。
- [x] **左栏**:候选智能体(`listAgents()` + `filterRecommendCandidates`),竖排卡片,可拖、可点「+」快捷加入;已加入的置灰「已加入」。
- [x] **右栏**:有序长条卡片(序号 tag + 名称 + 描述 + 移除),`SortableContext` 内拖拽排序;空态有占位提示。
- [x] 拖拽用 `@dnd-kit`:左栏 `useDraggable`,右栏 `useDroppable` + `SortableContext`/`useSortable`,`onDragEnd` 计算插入 index(pool→右栏按落点插入;seat→seat `arrayMove`);`DragOverlay` 跟手卡片。
- [x] 校验:`2 ≤ seats ≤ 8`(前端禁用保存 + toast,后端 `_validate_seats` 兜底),重复 agent 去重提示。
- [x] 保存走 `api/chains.ts`(create 用 `nanoid()` 客户端 id,update 走同一 id)。
- **验收**:✅ 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 天) ✅ 已完成
**目标**:弹窗顶部模式切换,智能推荐行为不变。
- [x] `RecommendAgentsDialog` 顶部加**分段按钮**模式切换:`智能推荐 | 业务链条`(active 态品牌色 + 卡底;DialogDescription 随模式切换文案)。
- [x] 现有全部内容原样收进「智能推荐」面板(`{mode === "recommend" && (<>…</>)}` 包裹,**零行为变更**)。
- [x] 「业务链条」面板放占位:居中提示 + 「去配置业务链条」按钮(`handleGoConfig` → 关弹窗 + 跳 `roundtable/chains`)。
- [x] `onConfirm` 统一升级为回传 `SeatSelectionResult`(含 `mode`):智能推荐确认回传 `{ agents, mode: "recommend" }`;主页面 `handleConfirmAgents` 同步改签名取 `result.agents`(`result.mode` 预留给阶段 4)。
- **验收**:✅ typecheck 0 新增错误(总数仍 76);智能推荐分支 JSX/逻辑原样保留(流式/勾选/进入研讨未改);模式切换只切显示、`state`/`selectedIds` 不卸载 → 候选池不丢。**人工验收待跑**:切到业务链条→再切回,推荐结果与勾选仍在。
### 阶段 3 · 业务链条选择卡片列表(1.5 天) ✅ 已完成
**目标**:弹窗「业务链条」面板里选链条。新增 `components/BusinessChainPicker.tsx`,替换阶段 2 的占位。
- [x] 进入该 Tab 时 `listChains()` 拉本人链条 + `listAgents()` 取存活 id/名称,渲染**卡片列表**:
- [x] 卡片标题(链条 title)+ 「N 席」徽章 + 选中 ✓;描述行。
- [x] 标题下一排**智能体名 tag**:按链条顺序**从左到右**、不同颜色(`getAvatarTypeFor`→`chainTagClasses`),tag 之间插 `→`(`ArrowRightIcon`)箭头连接(`seats[0]` 最左、先发言)。
- [x] 底部「配置链条」按钮(跳 `roundtable/chains`)。
- [x] 空态:无链条时引导「去配置页面创建第一条业务链条」+ 按钮。
- [x] 选中一条 → 高亮(`ring-primary`);底部「进入研讨」`onConfirm({ agents: 存活席位有序, mode:"chain", chain:{id,title} })`。
- [x] 链条里有 agent 已不存在(被删)→ 该 tag 置灰删除线 + `title` 提示 + 卡片下方 amber 警告;confirm 按**存活席位**过滤;存活 < `CHAIN_MIN_SEATS` 则禁用「进入研讨」并提示。agent 列表加载失败时 `existingIds=null` → 不误判删除。
- **验收**:✅ typecheck 0 新增错误(总数仍 76)。卡片渲染标题 + 彩色 tag + ← 箭头;选链 → `onConfirm(mode:"chain")` → `handleConfirmAgents` 按链条顺序 `setSelectedAgents` → Step 2 左侧「参与角色」顺序 = 链条顺序(**按顺序派活在阶段 4**)。**人工 UI 验收待跑**。
- **箭头方向**(已确认):tag 左→右排列、`→`(`ArrowRightIcon`)连接,`seats[0]` 最左、先发言,箭头顺读即链条研讨顺序。
### 阶段 4 · Step 2 链条顺序编排(2–3 天) ✅ 已完成
**目标**:chain 模式按链顺序逐席发言。**后端零改动。**
- [x] 主页面新增 `orchestrationMode` 状态,`handleConfirmAgents` 从 `SeatSelectionResult.mode` 写入(chain 模式 toast 带链名);`handleStartNewTask` 复位为 `recommend`;透传给 `useStep2Orchestration`。chain 顺序 = `selectedAgents`(确认时已按链条顺序写入),无需单独 `chainSeats`。
- [x] `useStep2Orchestration` 新增 `runChainOrchestration`(§6.2)+ `orchestrationModeRef`/`selectedAgentsRef`;复用现有三段式气泡/高亮/abort 基建。
- [x] 新增 `runActiveOrchestration` 分发器:init / 恢复 / 干预 / 澄清回复 4 处调用点统一改走它,按 `orchestrationModeRef` 选 `runOrchestration`(recommend)或 `runChainOrchestration`(chain)。
- [x] `runChainOrchestration`:按 `selectedAgents` 游标,每位先 leader 约束派活(`buildChainLeaderTask` 钉死目标席位 + `pickChainTask` 命中校验/纠偏,派错/未派都兜底推进)→ 再对**该席位**跑 special;中途 leader 若 `clarification` 则暂停(MVP 回复后整链重跑)。
- [x] `initMultiAgent` 的 `agentNames` 即 `roundtableAgentIds`(由 `selectedAgents` 派生,已是链条顺序,§6.5)。
- [x] 收口 leader(`finishedAllSeats` 时触发),产出 `lastLeaderContent` + `hasConsensus=true` + `consensusPercentage=100` 解锁 Step 3。
- **验收**:✅ typecheck 0 新增错误(总数仍 76);✅ 唯一直接引用 `runOrchestration` 的是 dispatcher。逻辑层:每席「总控派活气泡 → 席位交付气泡」、严格顺序、单 special 在跑、`_broadcast` 注入前序上下文、leader 纠偏推进、跑完解锁 Step 3、abort 可中断。**人工 UI 验收待跑**。
- **MVP 边界**:chain 模式的「挂起→断点续跑」「逐席间干预」未做断点续跑,`clarification`/恢复/干预当前为**整链重跑**(`chainCursor` 列为扩展点)。
### 阶段 5 · 草稿持久化兼容 + 收尾(1 天) ✅ 已完成
**目标**:草稿记住模式,回归不破。
- [x] `Step2Snapshot`(`useStep2Orchestration.ts`)+ `DraftStep2Snapshot`(`api/drafts.ts`)新增 `orchestrationMode?: "recommend"|"chain"` 与 `chain?: {id,title}|null`。
- [x] 恢复路径:`orchestrationMode`/`chainMeta` 是**主页面 state**,`getStep2Snapshot` 写入;**包裹版 `hydrateStep2`** 在加载时 `setOrchestrationMode(snapshot.orchestrationMode ?? "recommend")` + `setChainMeta(snapshot.chain ?? null)` 再调 `step2.hydrateFromDraft`;老草稿无字段 → 默认 `recommend`(向后兼容)。`handleStartNewTask` 复位两者。
- [x] 文档回写:`multi-agent-frontend-dev.md` §5.7(快照形状 + `orchestrationMode/chain` 说明)、§6.2(席位来源选择器导语);`multi-agent-backend-dev.md` 新增 §7B(`roundtable_chains` 表/路由)。
- **验收**:✅ `pnpm typecheck` 0 新增错误(总数仍 76)。逻辑:chain 会话保存草稿 → 重载 `orchestrationMode="chain"` + 链信息恢复;老草稿(无字段)默认 recommend 照常加载。**人工 UI 验收 + `pnpm build` 待跑**。
### 工期粗估
| 阶段 | 估时 |
|------|------|
| 0 后端链条表 + 脚手架 | 1.5d |
| 1 配置页 | 2–3d |
| 2 弹窗模式壳 | 1d |
| 3 链条选择卡片 | 1.5d |
| 4 Step2 链编排 | 2–3d |
| 5 持久化兼容收尾 | 1d |
| **合计** | **约 9–11 人日**(含后端 `roundtable_chains` 表/路由) |
---
## 8. 验收清单(端到端)
1. 推荐弹窗顶部有「智能推荐 / 业务链条」切换;智能推荐与现状逐像素一致。
2. 配置页 `/page/workspace/roundtable/chains` 左栏列出智能体、右栏有序长条卡片;左→右拖入指定位、右栏内拖拽排序均生效;2–8 校验生效;保存/重载/删除正常。
3. 业务链条卡片:标题 + 彩色智能体 tag + ← 箭头连接,渲染正确。
4. 选定链条 → Step 2 席位顺序 = 链条顺序,并**按顺序逐个发言**,后序能看到前序交付。
5. chain 跑完解锁 Step 3;草稿记住 chain 模式并可重载。
6. 删除链条中引用的 agent 后,选择端有降级提示,不崩。
---
## 9. 风险与开放问题
| # | 项 | 说明 / 建议 |
|---|----|-------------|
| 1 | 配置页:弹窗 vs 页面 | 本文档定为**独立页面**(§2.1)。若产品要弹窗,编辑器组件可复用,仅换外壳。**请确认。** |
| 2 | 链条持久化 | **已定:后端 MySQL `roundtable_chains`**(§2.2 / §5.2),仿草稿持久化。 |
| 3 | Step 2 链编排 | **已定:总控按链条顺序派活**(§2.4 / §6),后端零改动。 |
| 4 | 新增 `@dnd-kit` 依赖 | 若禁止新增依赖,退原生 HTML5 DnD(手感与可达性下降)。**请确认。** |
| 5 | ← 箭头方向 / tag 阅读顺序 | 默认 `seats[0]` 在最左、先发言;箭头仅作视觉连接。**请确认是否要「从右往左读」。** |
| 6 | chain 模式是否需要收口 leader 总结 | 默认需要(产出 Step 3 结论)。若末位席位自带总结可关掉。 |
| 7 | leader 派活纠偏策略 | leader 派错/未派时前端兜底推进游标(§6.2 `pickChainTask`);可加 toast 提示「已按链条强制推进」。 |
| 8 | chain 模式断点续跑 / 逐席干预 | MVP 不做,列为扩展点(§6.3 / §7 阶段 4)。 |
| 9 | 链条是否私有 | 本期仅本人私有(仿草稿按 `user_id` 隔离)。共享/市场留扩展。 |
---
## 10. 路径速查(新增/改动)
| 资源 | 路径 | 状态 |
|------|------|------|
| 链条类型 | `frontend-web/src/roundtable-planning/lib/business-chain.ts` | 新增 |
| 链条 API 客户端 | `frontend-web/src/roundtable-planning/api/chains.ts` | 新增 |
| 配置页 | `frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx` | 新增 |
| 链条选择面板 | `frontend-web/src/roundtable-planning/components/BusinessChainPicker.tsx` | 新增 |
| 链条卡片 | `frontend-web/src/roundtable-planning/components/BusinessChainCard.tsx` | 新增 |
| 推荐弹窗(加模式壳) | `frontend-web/src/roundtable-planning/components/RecommendAgentsDialog.tsx` | 改 |
| 主页面(mode 透传) | `frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx` | 改 |
| Step2 编排(加 chain) | `frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts` | 改 |
| 草稿快照(加 mode) | `hooks/useDraftPersistence.ts` + `api/drafts.ts` | 改 |
| 路由注册 | `frontend-web/src/pages/WorkspaceRoutes.tsx` | 改 |
| 后端链条持久化 | `offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_chains/` | 新增 |
| 后端链条路由 | `offline-backend-20260512/backend/app/gateway/routers/roundtable_chains.py` | 新增 |
| 后端 store 接线 | `offline-backend-20260512/backend/app/gateway/deps.py`(`app.state.roundtable_chain_store`)+ `app.py` | 改 |
</content>
</invoke>
@@ -439,8 +439,9 @@ isOrchestrating = false
> **重大变更**:草稿从浏览器 `localStorage` 迁到**后端 MySQL**,按登录用户隔离、跨设备/会话保留。旧 `utils/drafts.ts` 已删除;`useDraftPersistence` 仍保持对外接口不变(列表项含 `name`,由后端 `title` 映射而来),主页面无需改动。后端表/路由见 backend doc「圆桌草稿持久化」。
- **API 客户端 `api/drafts.ts`**:`listDrafts()`(轻量元数据)/ `getDraft(id)`(全量 step1+step2)/ `createDraft` / `updateDraft`(partial)/ `deleteDraft` / `deriveDraftName`。snake_case↔camelCase 映射在这一层收口(后端 `furthest_step/created_at/updated_at` ↔ 前端 `furthestStep/...`)。
- **快照形状**:`DraftStep1Snapshot.{intentThreadId, intentReady, interactiveMessages, timelineEntries}`、`DraftStep2Snapshot.{selectedAgents, threadIds, coordinatorName, step2RoundtableDialogues, lastLeaderContent, hasConsensus, consensusPercentage, budgetLimit, seatStubMessages}`;以 JSON 整存进后端 `step1`/`step2`(`PortableLongText`/LONGTEXT,避免大对话超 64KB)。
- **快照形状**:`DraftStep1Snapshot.{intentThreadId, intentReady, interactiveMessages, timelineEntries}`、`DraftStep2Snapshot.{selectedAgents, threadIds, coordinatorName, step2RoundtableDialogues, lastLeaderContent, hasConsensus, consensusPercentage, budgetLimit, seatStubMessages, orchestrationMode?, chain?}`;以 JSON 整存进后端 `step1`/`step2`(`PortableLongText`/LONGTEXT,避免大对话超 64KB)。
- **`selectedAgents`(2026-06 修复)**:本次研讨实际选中的席位。**不存的话加载草稿后 Step 2/3 左侧「参与角色」会回退到 `DEFAULT_SELECTED_AGENTS`(默认 6 个)而非当时真正选的那几个** —— `roleTemplates`/`agentNameToRole` 都由 `selectedAgents` 派生。`useStep2Orchestration.hydrateFromDraft` 在该字段非空时 `setSelectedAgents`,老草稿(无此字段)保持当前值不强行覆盖。
- **`orchestrationMode?` / `chain?`(2026-06 业务链条)**:Step 2 编排模式(`recommend`=总控自由派活 / `chain`=按业务链条顺序派活)+ 来源链条 `{id,title}`。是**主页面 state**,由页面在 `getStep2Snapshot` 写入、在**包裹版 `hydrateStep2`** 里读出后 `setOrchestrationMode`/`setChainMeta`(step2 hook 的 `hydrateFromDraft` 不直接消费)。老草稿无字段 → 默认 `recommend`。业务链条完整设计见 `multi-agent-business-chain-dev.md`。
- `deriveDraftName({intentReady, interactiveMessages})`:优先 `intentReady.objective`,否则第一个 user 消息,否则 `未命名草稿 <时间>`,截断 40 字。**仅在 create 时写一次**(见下方 bug 修复)。
- **列表加载**:挂载时 `useEffect` 调 `listDrafts()`;增删改后 `refreshList()` 重拉。
- **自动保存策略**:仅在「前进」步骤切换时保存(1→2、2→3),后退不触发。
@@ -481,6 +482,8 @@ isOrchestrating = false
### 6.2 `RecommendAgentsDialog`
> **席位来源选择器(2026-06 业务链条)**:弹窗顶部加了**模式分段切换** `智能推荐 | 业务链条`。智能推荐 = 本节描述的现状(零行为变更,整体收进 `{mode==="recommend"}` 分支);业务链条 = `BusinessChainPicker`(选一条已配置链条,卡片标题 + 彩色席位 tag + `→` 箭头)。`onConfirm` 统一回传 `SeatSelectionResult{ agents, mode, chain? }`;`mode==="chain"` 时 Step 2 由总控**按链条顺序派活**(`useStep2Orchestration.runChainOrchestration`)。配置页与编排详见 `multi-agent-business-chain-dev.md`。
- **新增 `draftId` prop(2026-06)**:本次推荐归属的草稿/会话 id。由主页面在打开弹窗前 `await drafts.ensureDraft(1)` 拿到并传入(见 §6.6)。`null` 时退化为「直接推荐、不存历史」的旧行为。
- 打开时管线(`start(forceFresh=false)`):`listAgents() → filterRecommendCandidates()` → **若 `draftId` 有历史 → `listRecommendHistory()` 取最近一次直接展示(不重跑模型)**;否则 `streamRecommend()` 跑新推荐。
- 阶段 UI(`InternalState.phase`):
+4
View File
@@ -49,6 +49,7 @@ frontend-web/
│ ├─ ScheduledTaskRunDetailPage.tsx
│ ├─ PublicSharePage.tsx 免登录分享只读
│ ├─ PublicScheduledTaskPage.tsx
│ ├─ EmbedChatPage.tsx iframe 嵌入问答(见 docs/embed-iframe-chat.md)
│ ├─ LoginPage.tsx 用户名直登(开发/内网用)
│ └─ ...
├─ core/ 「领域核心」: API 客户端、领域 hooks、类型
@@ -186,6 +187,7 @@ ReactDOM.createRoot(#root)
/login/:username LoginPage(按用户名直登)
/landing LandingPage
/share/:shareCode PublicSharePage(免登录只读)
/embed/chats/:thread_id EmbedChatPage(iframe 嵌入问答,见 docs/embed-iframe-chat.md)
/public/scheduled-tasks/:taskId PublicScheduledTaskPage(免登录)
/page/* PageRoutes(含侧边栏与 Header)
* NotFoundPage
@@ -664,6 +666,7 @@ const { models, tokenUsageEnabled, isLoading } = useModels();
- 创建分享:[components/workspace/thread-share-dialogs.tsx](../src/components/workspace/thread-share-dialogs.tsx) + `core/thread-shares/`
- 只读页 `PublicSharePage`(路由 `/share/:shareCode`)/ `PublicScheduledTaskPage`(`/public/scheduled-tasks/:taskId`),不需要登录
- iframe 嵌入问答 `EmbedChatPage`(`/embed/chats/:thread_id`):第三方用普通 iframe 嵌套,父页面用 `sessionId` 轮询公开 API 判断问答是否结束,详见 [embed-iframe-chat.md](./embed-iframe-chat.md)
- 后端会用 share token 做权限校验,前端通过 `apiFetch` 自动带
---
@@ -1138,6 +1141,7 @@ setSettings("context", { model_name: "gpt-5" }); // 仅当前线程
| `/landing` | App.tsx | LandingPage.tsx |
| `/share/:shareCode` | App.tsx | PublicSharePage.tsx |
| `/public/scheduled-tasks/:taskId` | App.tsx | PublicScheduledTaskPage.tsx |
| `/embed/chats/:thread_id` | App.tsx | EmbedChatPage.tsx([embed-iframe-chat.md](./embed-iframe-chat.md)) |
| `/page/workspace/*` | PageRoutes.tsx → WorkspaceRoutes.tsx | ChatsPage / ChatPage / AgentsPage / NewAgentPage / AgentChatPage / SkillsPage / SkillEvolutionPage / MemoryPage / ScheduledTasksPage / ScheduledTaskRunDetailPage / AdminUserHubPage / AdminScheduledTasksPage / TagsPage / RecommendedQuestionsPage / PromptPrefixSettingsPage / LlmMetricsPage |
| `/page/strategy/*` | PageRoutes.tsx → StrategyRoutes.tsx | GeneralQA + LightApp* + 复用 Workspace 页面 |
| `/page/canvas/*` | PageRoutes.tsx → open-canvas/routes/OpenCanvasRoutes.tsx | CanvasHomePage / CanvasThreadPage / AIWritingPage / ArticleTypesConfigPage |
+3
View File
@@ -19,6 +19,9 @@
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/lang-python": "^6.2.1",
"@codemirror/language-data": "^6.5.2",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@langchain/core": "^1.1.15",
"@langchain/langgraph-sdk": "^1.5.3",
"@radix-ui/react-avatar": "^1.1.11",
+11
View File
@@ -17,6 +17,8 @@ import LoginPage from "./pages/LoginPage";
import PageRoutes from "./pages/PageRoutes";
import PublicScheduledTaskPage from "./pages/PublicScheduledTaskPage";
import PublicSharePage from "./pages/PublicSharePage";
import EmbedChatPage from "./pages/EmbedChatPage";
import { ChatRuntime } from "./pages/WorkspaceRoutes";
import { NotFoundPage } from "./pages/NotFoundPage";
function AppProviders({ children }: { children: React.ReactNode }) {
@@ -66,6 +68,15 @@ export function App() {
path="/public/scheduled-tasks/:taskId"
element={<PublicScheduledTaskPage />}
/>
{/* Embedded workspace Q&A — no page/workspace sidebars; parent passes message/username/theme */}
<Route
path="/embed/chats/:thread_id"
element={
<ChatRuntime>
<EmbedChatPage />
</ChatRuntime>
}
/>
<Route path="/page/*" element={<PageRoutes />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
@@ -50,32 +50,36 @@ import { SubtaskCard } from "./subtask-card";
export const MESSAGE_LIST_DEFAULT_PADDING_BOTTOM = 160;
export const MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM = 80;
export function MessageList({
className,
contentClassName,
fitMessageRowsToContent = false,
threadId,
thread,
paddingBottom = MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
tokenUsageEnabled = false,
export function MessageList({
className,
contentClassName,
fitMessageRowsToContent = false,
threadId,
thread,
paddingBottom = MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
tokenUsageEnabled = false,
extraRehypePlugins,
onClarificationSubmit,
onEditResubmit,
onSetupAgentApprove,
onSetupAgentReject,
}: {
className?: string;
contentClassName?: string;
fitMessageRowsToContent?: boolean;
threadId: string;
thread: BaseStream<AgentThreadState>;
paddingBottom?: number;
tokenUsageEnabled?: boolean;
onSetupWritingApprove,
onSetupWritingReject,
}: {
className?: string;
contentClassName?: string;
fitMessageRowsToContent?: boolean;
threadId: string;
thread: BaseStream<AgentThreadState>;
paddingBottom?: number;
tokenUsageEnabled?: boolean;
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
onClarificationSubmit?: (answer: string) => void | Promise<void>;
onEditResubmit?: (text: string) => void;
onSetupAgentApprove?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
onSetupAgentReject?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
onSetupWritingApprove?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
onSetupWritingReject?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
}) {
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
const rehypePlugins = useMemo(
@@ -83,6 +87,7 @@ export function MessageList({
[baseRehypePlugins, extraRehypePlugins],
);
const updateSubtask = useUpdateSubtask();
const { t } = useI18n();
const messages = thread.messages;
// Stamp a wall-clock time onto every question/answer the first time it is
@@ -219,17 +224,17 @@ export function MessageList({
if (thread.isThreadLoading && messages.length === 0) {
return <MessageListSkeleton />;
}
return (
<Conversation
className={cn("flex size-full flex-col justify-center", className)}
>
<ConversationContent
className={cn(
"mx-auto w-full max-w-(--container-width-lg) gap-8 pt-12",
contentClassName,
)}
>
<MessageMetricsProvider messages={messages} isLoading={thread.isLoading}>
return (
<Conversation
className={cn("flex size-full flex-col justify-center", className)}
>
<ConversationContent
className={cn(
"mx-auto w-full max-w-(--container-width-lg) gap-8 pt-12",
contentClassName,
)}
>
<MessageMetricsProvider messages={messages} isLoading={thread.isLoading}>
{groupMessages(messages, (group) => {
if (group.type === "human" || group.type === "assistant") {
return group.messages.map((msg) => {
@@ -244,12 +249,12 @@ export function MessageList({
})
: [];
return (
<MessageListItem
key={`${group.id}/${msg.id}`}
fitRowToContent={fitMessageRowsToContent}
message={msg}
isLoading={thread.isLoading}
threadId={threadId}
<MessageListItem
key={`${group.id}/${msg.id}`}
fitRowToContent={fitMessageRowsToContent}
message={msg}
isLoading={thread.isLoading}
threadId={threadId}
tokenUsageEnabled={tokenUsageEnabled}
displaySources={displaySources}
extraRehypePlugins={extraRehypePlugins}
@@ -320,6 +325,23 @@ export function MessageList({
/>
</div>
) : null;
} else if (group.type === "assistant:setup-writing-approval") {
const message = group.messages[0];
return message ? (
<div key={group.id} className="w-full">
<SetupWritingApprovalCard
disabled={thread.isLoading}
message={message}
onApprove={onSetupWritingApprove}
onReject={onSetupWritingReject}
/>
<MessageTokenUsageList
enabled={tokenUsageEnabled}
isLoading={thread.isLoading}
messages={group.messages}
/>
</div>
) : null;
} else if (group.type === "assistant:present-files") {
const files: string[] = [];
for (const message of group.messages) {
@@ -498,6 +520,16 @@ export type SetupAgentApprovalPayload = {
skills?: string[];
};
export type SetupWritingApprovalPayload = {
user_intent?: string;
article_type?: string;
word_count_target?: number | null;
target_audience?: string;
writing_mode?: string;
material_source?: string;
outline?: string;
};
function findPreviousHumanMessageId(
messages: Message[],
beforeMessageId?: string,
@@ -648,6 +680,155 @@ function SetupAgentApprovalCard({
);
}
function getSetupWritingApprovalPayload(
message: unknown,
): SetupWritingApprovalPayload | null {
if (typeof message !== "object" || message === null) {
return null;
}
const additionalKwargs = Reflect.get(message, "additional_kwargs");
if (typeof additionalKwargs !== "object" || additionalKwargs === null) {
return null;
}
const approval = Reflect.get(additionalKwargs, "setup_writing_approval");
if (typeof approval !== "object" || approval === null) {
return null;
}
return approval as SetupWritingApprovalPayload;
}
const WRITING_MODE_LABELS: Record<string, string> = {
strict: "严格依据素材",
loose: "可结合通用知识",
};
const MATERIAL_SOURCE_LABELS: Record<string, string> = {
knowledge_base: "知识库",
skill: "技能检索",
};
function SetupWritingApprovalCard({
disabled,
message,
onApprove,
onReject,
}: {
disabled: boolean;
message: unknown;
onApprove?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
onReject?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
}) {
const payload = getSetupWritingApprovalPayload(message);
const [submitting, setSubmitting] = useState<"approve" | "reject" | null>(
null,
);
if (!payload) {
return null;
}
const approvalPayload = payload;
const busy = disabled || submitting !== null;
async function run(action: "approve" | "reject") {
if (busy) {
return;
}
setSubmitting(action);
try {
if (action === "approve") {
await onApprove?.(approvalPayload);
} else {
await onReject?.(approvalPayload);
}
} finally {
setSubmitting(null);
}
}
const rows: Array<{ label: string; value: string }> = [];
if (approvalPayload.article_type) {
rows.push({ label: "文章类型", value: approvalPayload.article_type });
}
if (typeof approvalPayload.word_count_target === "number") {
rows.push({ label: "目标字数", value: `${approvalPayload.word_count_target} 字` });
}
if (approvalPayload.target_audience) {
rows.push({ label: "目标读者", value: approvalPayload.target_audience });
}
if (approvalPayload.writing_mode) {
rows.push({
label: "写作模式",
value: WRITING_MODE_LABELS[approvalPayload.writing_mode] ?? approvalPayload.writing_mode,
});
}
if (approvalPayload.material_source) {
rows.push({
label: "素材来源",
value: MATERIAL_SOURCE_LABELS[approvalPayload.material_source] ?? approvalPayload.material_source,
});
}
return (
<div className="border-border bg-card text-card-foreground w-full rounded-lg border p-4 shadow-sm">
<div className="space-y-3">
<div>
<div className="text-muted-foreground text-xs font-medium">写作配置建议</div>
<div className="text-sm font-medium">确认后将应用到左侧写作表单。</div>
</div>
{approvalPayload.user_intent ? (
<div className="space-y-1">
<div className="text-muted-foreground text-xs">写作意图</div>
<p className="text-sm leading-6">{approvalPayload.user_intent}</p>
</div>
) : null}
{rows.length > 0 ? (
<div className="flex flex-wrap gap-x-6 gap-y-1.5">
{rows.map((row) => (
<div key={row.label} className="space-y-0.5">
<div className="text-muted-foreground text-xs">{row.label}</div>
<p className="text-sm font-medium">{row.value}</p>
</div>
))}
</div>
) : null}
{approvalPayload.outline ? (
<div className="space-y-1">
<div className="text-muted-foreground text-xs">已有大纲</div>
<pre className="bg-muted max-h-52 overflow-auto rounded-md p-3 text-xs whitespace-pre-wrap">
{approvalPayload.outline}
</pre>
</div>
) : null}
</div>
<div className="mt-4 flex justify-end gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={busy || !onReject}
onClick={() => void run("reject")}
>
<XIcon className="h-4 w-4" />
忽略
</Button>
<Button
type="button"
size="sm"
disabled={busy || !onApprove}
onClick={() => void run("approve")}
>
<CheckIcon className="h-4 w-4" />
应用到表单
</Button>
</div>
</div>
);
}
function getClarificationPayload(message: unknown): ClarificationPayload | null {
if (typeof message !== "object" || message === null) {
return null;
@@ -13,6 +13,10 @@ export const EXTERNAL_LOGIN_STORAGE_KEYS = {
ticket: "login.ticket",
userId: "login.userId",
env: "login.env",
/** MaxKey getTokenByOnlineTicket 返回的 token */
maxkeyOnlineToken: "login.maxkeyOnlineToken",
/** consumer /cop/userDetailToken 返回的 data 整包(JSON) */
userDetailToken: "login.userDetailToken",
} as const;
export type ExternalLoginParams = {
@@ -0,0 +1,142 @@
import { requestCrudService } from "@/service/request-axios-service";
import {
EXTERNAL_LOGIN_STORAGE_KEYS,
persistExternalLoginParams,
} from "./external-login";
const AUTH_BASE_URL = (import.meta.env.VITE_AUTH_BASE_URL || "").replace(
/\/$/,
"",
);
const CONSUMER_URL = (import.meta.env.VITE_AIPI_CONSUMER_URL || "").replace(
/\/$/,
"",
);
/** MaxKey onlineTicket 换发的 token(后续请求可能用到) */
export const MAXKEY_ONLINE_TOKEN_STORAGE_KEY =
EXTERNAL_LOGIN_STORAGE_KEYS.maxkeyOnlineToken;
/** `/cop/userDetailToken` 返回的 data 整包(JWT 解析后的用户信息) */
export const USER_DETAIL_TOKEN_STORAGE_KEY =
EXTERNAL_LOGIN_STORAGE_KEYS.userDetailToken;
export type MaxkeyTokenByTicketResponse = {
code: number;
data: string;
message?: string;
};
export type UserDetailTokenPayload = {
sub?: string;
user_id?: string;
exp?: number;
expired?: boolean;
iat?: number;
institution?: string;
iss?: string;
locale?: string;
kid?: string;
jti?: string;
[key: string]: unknown;
};
type UserDetailTokenResponse = {
code?: number;
data?: UserDetailTokenPayload;
message?: string;
};
function persistMaxkeyOnlineToken(token: string): void {
try {
localStorage.setItem(MAXKEY_ONLINE_TOKEN_STORAGE_KEY, token);
} catch {
// 静默失败,不阻断登录
}
}
function persistUserDetailToken(data: UserDetailTokenPayload): void {
try {
localStorage.setItem(USER_DETAIL_TOKEN_STORAGE_KEY, JSON.stringify(data));
if (data.user_id) {
localStorage.setItem(EXTERNAL_LOGIN_STORAGE_KEYS.userId, String(data.user_id));
}
} catch {
// 静默失败
}
}
/**
* 无界 / 外部跳转:用 onlineTicket 换 MaxKey token,并拉取用户详情。
* - token 存入 localStorage(`login.maxkeyOnlineToken`)
* - userDetailToken 的 data 存入 localStorage(`login.userDetailToken`)
* @returns 用于 DeerFlow `loginByUsername` 的用户名(取自 data.sub)
*/
export async function resolveUsernameByOnlineTicket(
ticket: string,
): Promise<string> {
const onlineTicket = ticket.trim();
if (!onlineTicket) {
throw new Error("缺少 onlineTicket,无法完成无界登录。");
}
if (!AUTH_BASE_URL) {
throw new Error("未配置 VITE_AUTH_BASE_URL,无法换取 MaxKey token。");
}
if (!CONSUMER_URL) {
throw new Error("未配置 VITE_AIPI_CONSUMER_URL,无法获取用户详情。");
}
persistExternalLoginParams({ ticket: onlineTicket });
const tokenRes = (await requestCrudService.get(
`${AUTH_BASE_URL}/maxkey-mgt-api/sys/getTokenByOnlineTicket`,
{ onlineTicket },
)) as unknown as MaxkeyTokenByTicketResponse;
console.log("[wujie] getTokenByOnlineTicket:", tokenRes);
if (tokenRes.code !== 200 || !tokenRes.data) {
throw new Error(
tokenRes.message || "getTokenByOnlineTicket 未返回有效 token。",
);
}
persistMaxkeyOnlineToken(tokenRes.data);
const detailRes = (await requestCrudService.get(
`${CONSUMER_URL}/cop/userDetailToken`,
{ ticket: onlineTicket },
)) as unknown as UserDetailTokenResponse;
console.log("[wujie] userDetailToken:", detailRes);
const userData = detailRes.data;
if (!userData) {
throw new Error("userDetailToken 未返回用户 data。");
}
persistUserDetailToken(userData);
const username = (userData.sub ?? "").trim();
if (!username) {
throw new Error("userDetailToken 中缺少 sub(用户名)。");
}
return username;
}
export function readMaxkeyOnlineToken(): string {
try {
return localStorage.getItem(MAXKEY_ONLINE_TOKEN_STORAGE_KEY) ?? "";
} catch {
return "";
}
}
export function readUserDetailToken(): UserDetailTokenPayload | null {
try {
const raw = localStorage.getItem(USER_DETAIL_TOKEN_STORAGE_KEY);
if (!raw) return null;
return JSON.parse(raw) as UserDetailTokenPayload;
} catch {
return null;
}
}
+46
View File
@@ -0,0 +1,46 @@
import { resolveThemeFromQuery } from "@/core/auth/external-login";
const DEFAULT_USERNAME = "guest";
export type EmbedChatParams = {
/** Parent correlation id — required for status polling without thread_id. */
sessionId: string;
message: string;
username: string;
theme: string;
};
/**
* Resolve iframe embed params from URL query only (plain iframe, not wujie).
*
* Parent must generate ``sessionId`` and pass it on the iframe URL so it can
* poll ``GET /api/public/embed/sessions/{sessionId}/status`` without reading
* the child frame.
*/
export function resolveEmbedChatParams(search: string): EmbedChatParams {
const params = new URLSearchParams(search.startsWith("?") ? search : `?${search}`);
const sessionId =
params.get("sessionId")?.trim() ||
params.get("session_id")?.trim() ||
"";
const message =
params.get("message")?.trim() ||
params.get("question")?.trim() ||
params.get("text")?.trim() ||
"";
const username =
params.get("username")?.trim() ||
params.get("userName")?.trim() ||
DEFAULT_USERNAME;
const theme = params.get("theme")?.trim() || "";
return { sessionId, message, username, theme };
}
export function resolveEmbedTheme(themeParam: string): "dark" | "light" {
return resolveThemeFromQuery(themeParam || null);
}
+73
View File
@@ -0,0 +1,73 @@
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;
};
export type EmbedSessionResult = EmbedSessionStatus & {
messages: Record<string, unknown>[];
generated_text: string;
generated_files: string[];
};
async function parseEmbedError(response: Response): Promise<string> {
const body = await response.json().catch(() => ({}));
if (typeof body.detail === "string") {
return body.detail;
}
return `HTTP ${response.status}`;
}
/** Register session_id → thread_id (iframe calls this; parent never needs thread_id). */
export async function registerEmbedSession(
sessionId: string,
threadId: string,
): Promise<{ session_id: string; thread_id: string }> {
const base = getBackendBaseURL();
const response = await fetch(`${base}/api/public/embed/sessions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "omit",
body: JSON.stringify({ session_id: sessionId, thread_id: threadId }),
});
if (!response.ok) {
throw new Error(await parseEmbedError(response));
}
return response.json() as Promise<{ session_id: string; thread_id: string }>;
}
/** Poll whether embed Q&A has finished — parent uses sessionId only. */
export async function getEmbedSessionStatus(
sessionId: string,
): Promise<EmbedSessionStatus> {
const base = getBackendBaseURL();
const response = await fetch(
`${base}/api/public/embed/sessions/${encodeURIComponent(sessionId)}/status`,
{ credentials: "omit" },
);
if (!response.ok) {
throw new Error(await parseEmbedError(response));
}
return response.json() as Promise<EmbedSessionStatus>;
}
/** Fetch full messages, generated text, and artifact paths — parent uses sessionId only. */
export async function getEmbedSessionResult(
sessionId: string,
): Promise<EmbedSessionResult> {
const base = getBackendBaseURL();
const response = await fetch(
`${base}/api/public/embed/sessions/${encodeURIComponent(sessionId)}/result`,
{ credentials: "omit" },
);
if (!response.ok) {
throw new Error(await parseEmbedError(response));
}
return response.json() as Promise<EmbedSessionResult>;
}
+9 -2
View File
@@ -6,7 +6,7 @@
* 注入参数,子应用在 `window.$wujie.props` 上拿到**同一个对象**(无界在子应用
* bootstrap 之前就已注入,因此首帧即可读)。
*
* 本期范围:只「读出来 + 存起来 + 打印」,**不接入对方鉴权**。读出的
* ticket 在 LoginPage 会走 MaxKey + consumer 双接口换用户名并登录;读出的
* ticket/userId/env 复用 external-login 的 localStorage 存储(与 URL 跳转登录
* 同一套 key),theme 交给调用方(LoginPage)映射到 ThemeContext。
*/
@@ -16,7 +16,7 @@ import { persistExternalLoginParams } from "@/core/auth/external-login";
/** 无界父级注入的 props 形状。除已知字段外允许透传其余未知字段便于排查。 */
export interface WujieProps {
/** 第三方鉴权 ticket(本期仅存储待用,不消费)。 */
/** 第三方 onlineTicket(LoginPage 用于 MaxKey + userDetailToken 登录)。 */
ticket?: string;
/** 外部用户 id。 */
userId?: string;
@@ -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;
}
+21 -1
View File
@@ -20,6 +20,8 @@ interface AssistantClarificationGroup extends GenericMessageGroup<"assistant:cla
interface AssistantSetupAgentApprovalGroup extends GenericMessageGroup<"assistant:setup-agent-approval"> {}
interface AssistantSetupWritingApprovalGroup extends GenericMessageGroup<"assistant:setup-writing-approval"> {}
interface AssistantSubagentGroup extends GenericMessageGroup<"assistant:subagent"> {}
type MessageGroup =
@@ -29,6 +31,7 @@ type MessageGroup =
| AssistantPresentFilesGroup
| AssistantClarificationGroup
| AssistantSetupAgentApprovalGroup
| AssistantSetupWritingApprovalGroup
| AssistantSubagentGroup;
export function groupMessages<T>(
@@ -50,7 +53,8 @@ export function groupMessages<T>(
last.type !== "human" &&
last.type !== "assistant" &&
last.type !== "assistant:clarification" &&
last.type !== "assistant:setup-agent-approval"
last.type !== "assistant:setup-agent-approval" &&
last.type !== "assistant:setup-writing-approval"
) {
return last;
}
@@ -88,6 +92,13 @@ export function groupMessages<T>(
type: "assistant:setup-agent-approval",
messages: [message],
});
} else if (isSetupWritingApprovalToolMessage(message)) {
lastOpenGroup()?.messages.push(message);
groups.push({
id: message.id,
type: "assistant:setup-writing-approval",
messages: [message],
});
} else {
const open = lastOpenGroup();
if (open) {
@@ -354,6 +365,15 @@ export function isSetupAgentApprovalToolMessage(message: Message) {
);
}
export function isSetupWritingApprovalToolMessage(message: Message) {
return (
message.type === "tool" &&
message.name === "setup_writing" &&
typeof message.additional_kwargs?.setup_writing_approval === "object" &&
message.additional_kwargs.setup_writing_approval !== null
);
}
export function extractPresentFilesFromMessage(message: Message) {
if (message.type !== "ai" || !hasPresentFiles(message)) {
return [];
@@ -1,8 +1,12 @@
import { FileText, PenLine } from 'lucide-react';
import { useAIWritingOptional } from '../../contexts/AIWritingContext';
import { useCanvasGraphContext } from '../../contexts/CanvasGraphContext';
import { useCanvasUIContext } from '../../contexts/CanvasUIContext';
import { assembleStreamingDraftMarkdown } from '../../hooks/useAIWritingStream';
import { getCurrentArtifactContent, isCodeArtifact } from '../../utils/artifact';
import { TextArtifactRenderer } from '../artifact/TextArtifactRenderer';
import { DraftStreamingPreview } from './DraftStreamingPreview';
import type { CanvasArtifactText } from '../../types/artifact';
function DraftWaitingPlaceholder() {
@@ -27,33 +31,81 @@ function DraftWaitingPlaceholder() {
}
export function AIWritingDraftPanel() {
const writing = useAIWritingOptional();
const { artifact } = useCanvasGraphContext();
const { isDraftEditable } = useCanvasUIContext();
if (!artifact) {
const writingStatus = writing?.state.status;
// 仅当章节仍在累积时走流式 Markdown;draft_ready 清空 sections 后即使 status 尚未切走也进画布
const isActivelyStreamingDraft =
(writingStatus === 'writing_draft' || writingStatus === 'revising') &&
(writing?.state.streamingDraftSections.length ?? 0) > 0;
const streamingMarkdown = isActivelyStreamingDraft && writing
? assembleStreamingDraftMarkdown(
writing.state.streamingDraftSections,
writing.state.streamingDraftTitle || writing.state.outline?.title || '',
)
: '';
// 流式生成中:只读 Markdown 预览
if (isActivelyStreamingDraft) {
return (
<div className="h-full bg-muted/10 border-l">
<DraftWaitingPlaceholder />
<div className="h-full flex flex-col overflow-hidden border-l">
<div className="h-10 border-b flex items-center gap-2 px-4 shrink-0 bg-muted/20">
<FileText className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
<span className="text-sm font-medium truncate flex-1">草稿生成中…</span>
</div>
<div className="flex-1 overflow-hidden">
{streamingMarkdown ? (
<DraftStreamingPreview markdown={streamingMarkdown} />
) : (
<DraftWaitingPlaceholder />
)}
</div>
</div>
);
}
const content = getCurrentArtifactContent(artifact);
const artifactContent = artifact ? getCurrentArtifactContent(artifact) : null;
const textFromArtifact =
artifactContent && !isCodeArtifact(artifactContent)
? (artifactContent as CanvasArtifactText)
: null;
// draft_ready 后 DraftSync 写 artifact 可能晚一帧;先用 context 里的成稿兜底,避免闪占位符
const textContent: CanvasArtifactText | null =
textFromArtifact ??
(writing?.state.currentDraftMarkdown
? {
index: 0,
type: 'text',
title: writing.state.currentDraftTitle || '草稿',
fullMarkdown: writing.state.currentDraftMarkdown,
}
: null);
if (textContent) {
return (
<div className="h-full flex flex-col overflow-hidden border-l">
<div className="h-10 border-b flex items-center gap-2 px-4 shrink-0 bg-muted/20">
<FileText className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
<span className="text-sm font-medium truncate flex-1">
{textContent.title || '草稿预览'}
</span>
</div>
<div className="flex-1 overflow-hidden">
<TextArtifactRenderer
content={textContent}
isStreaming={!isDraftEditable}
/>
</div>
</div>
);
}
return (
<div className="h-full flex flex-col overflow-hidden border-l">
{/* 标题栏 */}
<div className="h-10 border-b flex items-center gap-2 px-4 shrink-0 bg-muted/20">
<FileText className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
<span className="text-sm font-medium truncate flex-1">
{content?.title ?? '草稿预览'}
</span>
</div>
<div className="flex-1 overflow-hidden">
{content && !isCodeArtifact(content) ? (
<TextArtifactRenderer content={content as CanvasArtifactText} />
) : null}
</div>
<div className="h-full bg-muted/10 border-l">
<DraftWaitingPlaceholder />
</div>
);
}
@@ -15,6 +15,7 @@ import { useStudioNotebooks, useNotebookSources } from '@/core/studio/hooks';
import type { AIWritingRequest } from '../../types/ai-writing';
import { useArticleTypes } from '../../hooks/useArticleTypes';
import { useWritingForm } from '../../contexts/WritingFormContext';
interface AIWritingFormProps {
onSubmit: (req: AIWritingRequest) => void;
@@ -33,7 +34,6 @@ const INPUT_CLASS =
const DEFAULT_KEYWORD_COUNT = 1;
const DEFAULT_AUDIENCE = '普通读者';
type MaterialSource = 'knowledge_base' | 'skill' | 'notebook';
const DEFAULT_MATERIAL_SOURCE: MaterialSource = 'knowledge_base';
const MAX_INTENT_LENGTH = 500;
const MATERIAL_SOURCE_OPTIONS: Array<{ value: MaterialSource; label: string; desc: string }> = [
@@ -51,14 +51,18 @@ export function AIWritingForm({
disabled,
hideAdvancedFields = false,
}: AIWritingFormProps) {
const [intent, setIntent] = useState('');
const [articleType, setArticleType] = useState('analysis');
const [audience, setAudience] = useState('普通读者');
const [wordCountInput, setWordCountInput] = useState('800');
const [strictMode, setStrictMode] = useState(false);
const [materialSource, setMaterialSource] = useState<MaterialSource>(DEFAULT_MATERIAL_SOURCE);
const [modelName, setModelName] = useState('');
// 笔记来源:选中的 search_space_id 和多个 document_id
const {
intent, setIntent,
showOutline, setShowOutline,
outlineText, setOutlineText,
articleType, setArticleType,
audience, setAudience,
wordCountInput, setWordCountInput,
strictMode, setStrictMode,
materialSource, setMaterialSource,
modelName, setModelName,
} = useWritingForm();
// 笔记来源:选中的 search_space_id 和多个 document_id(master 引入,useWritingForm 未含)
const [selectedNotebookId, setSelectedNotebookId] = useState<number | null>(null);
const [selectedDocumentIds, setSelectedDocumentIds] = useState<number[]>([]);
@@ -141,6 +145,7 @@ export function AIWritingForm({
const wordCount = Math.max(100, parseInt(wordCountInput, 10) || 800);
onSubmit({
userIntent: intent.trim(),
userOutline: outlineText.trim() || undefined,
articleType,
targetAudience: hideAdvancedFields
? DEFAULT_AUDIENCE
@@ -184,6 +189,43 @@ export function AIWritingForm({
/>
</div>
<div className="mt-4 space-y-2">
<button
type="button"
onClick={() => setShowOutline(!showOutline)}
disabled={disabled}
className="flex w-full items-center justify-between text-left"
>
<span className={FIELD_LABEL_CLASS}>
已有大纲
<span className="ml-1 text-sm font-normal text-muted-foreground dark:text-white/70">
(可选)
</span>
</span>
<span className="text-sm text-muted-foreground dark:text-white/70">
{showOutline ? '收起 ▴' : '我已有大纲,直接粘贴 ▾'}
</span>
</button>
{showOutline && (
<>
<Textarea
placeholder={
'粘贴你已有的大纲,将完全按你的大纲生成(支持一/二/三级目录)。例如:\n' +
'一、引言\n(一)背景\n(二)意义\n二、现状分析\n…'
}
value={outlineText}
onChange={(e) => setOutlineText(e.target.value)}
disabled={disabled}
rows={6}
className="resize-none rounded-2xl border-[#e8dedd] bg-white px-4 py-3 text-sm leading-6 shadow-sm"
/>
<p className="text-xs leading-relaxed text-muted-foreground/80">
填写后将跳过自动大纲规划,按你粘贴的大纲逐章生成;留空则由 AI 自动规划大纲。
</p>
</>
)}
</div>
<div className="mt-4 grid grid-cols-2 gap-4">
<div className="min-w-0 space-y-2">
<label className={FIELD_LABEL_CLASS}>文章类型</label>
@@ -14,7 +14,7 @@ import {
import { useArticleTypes } from '../../hooks/useArticleTypes';
import { useCanvasGraphContext } from '../../contexts/CanvasGraphContext';
import { useAIWriting } from '../../hooks/useAIWritingStream';
import { useAIWriting } from '../../contexts/AIWritingContext';
import { useAIWritingSessions } from '../../hooks/useAIWritingSessions';
import { adminCleanupAIWritingSessions } from '../../api/ai-writing-sessions';
@@ -41,7 +41,8 @@ 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 { DraftSync } from './DraftSync';
import { IntentResultCard } from './IntentResultCard';
import { IntentStreamingView } from './IntentStreamingView';
import { OutlineStreamingView } from './OutlineStreamingView';
@@ -251,6 +252,7 @@ export function AIWritingPanel({
if (historySession) {
return (
<div className="flex flex-col h-full overflow-hidden bg-background">
<DraftSync status={state.status} markdown={state.currentDraftMarkdown} title={state.currentDraftTitle} />
<div className="flex items-center justify-between px-4 py-2.5 border-b shrink-0">
<span className="text-sm font-semibold truncate flex-1 min-w-0 mr-2">
{historySession.draft_title ?? historySession.title}
@@ -287,6 +289,7 @@ export function AIWritingPanel({
return (
<div className="flex flex-col h-full overflow-hidden bg-background">
<DraftSync status={state.status} markdown={state.currentDraftMarkdown} title={state.currentDraftTitle} />
{/* 头部 */}
<div className="flex items-center justify-between px-4 py-2.5 border-b shrink-0">
<span
@@ -440,12 +443,9 @@ export function AIWritingPanel({
</div>
)}
{/* 草稿流式打字机视图 */}
{state.status === 'writing_draft' && state.streamingDraftSections.length > 0 && (
<DraftStreamingView
title={state.streamingDraftTitle}
sections={state.streamingDraftSections}
/>
{/* 草稿生成中:左侧只展示思考过程(若有)+「内容生成中」骨架屏;正文实时显示在右侧画布 */}
{state.status === 'writing_draft' && (
<DraftGeneratingView sections={state.streamingDraftSections} />
)}
{/* 技能选择 LLM 流式打字(含 <think> 思考分离),select_skills 完成后自动清空。
@@ -485,12 +485,6 @@ export function AIWritingPanel({
</div>
)}
{state.status === 'writing_draft' && state.streamingDraftSections.length === 0 && (
<div className="px-4 pb-4">
<WritingSkeletonCard status="writing_draft" />
</div>
)}
{isError && state.error && (
<div className="mx-4 mt-2 rounded-md bg-destructive/10 px-3 py-2 text-xs text-destructive">
{state.error}
@@ -0,0 +1,119 @@
import { useEffect, useRef } from 'react';
import { Brain, PenLine } from 'lucide-react';
import type { StreamingDraftSection } from '../../types/ai-writing';
interface DraftGeneratingViewProps {
sections: StreamingDraftSection[];
}
/** 思考过程框:纯只读展示,跟随内容自动滚到底(仅操作 DOM,不触发 setState) */
function ThinkingBox({ text, done }: { text: string; done: boolean }) {
const preRef = useRef<HTMLPreElement>(null);
useEffect(() => {
if (preRef.current) preRef.current.scrollTop = preRef.current.scrollHeight;
}, [text]);
return (
<div className="rounded-lg border border-amber-200 dark:border-amber-900 bg-amber-50/50 dark:bg-amber-950/20 overflow-hidden">
<div className="flex items-center gap-1.5 px-2.5 py-1.5 bg-amber-100/40 dark:bg-amber-900/30">
<Brain className="size-3 text-amber-500 shrink-0" />
<span className="text-[11px] font-medium text-amber-600 dark:text-amber-400">思考过程</span>
</div>
<pre
ref={preRef}
className="text-[10px] font-mono leading-relaxed px-2.5 py-2 text-amber-700/70 dark:text-amber-300/60 whitespace-pre-wrap max-h-40 overflow-y-auto italic"
>
{text}
{!done && (
<span className="inline-block w-0.5 h-3 bg-amber-400 ml-0.5 align-text-bottom animate-[blink_1s_step-end_infinite]" />
)}
</pre>
</div>
);
}
/** 「内容生成中」骨架屏:流光扫过的占位行 + 写字笔抖动 + 跳动圆点,提示正文正在右侧实时生成 */
const SKELETON_WIDTHS = ['92%', '100%', '85%', '96%', '70%'];
function GeneratingSkeleton() {
return (
<div className="group relative overflow-hidden rounded-xl border bg-card shadow-sm p-4">
{/* 顶部一道缓慢流过的高光,强化"正在工作"的呼吸感 */}
<div className="pointer-events-none absolute inset-0 -translate-x-full bg-gradient-to-r from-transparent via-primary/[0.07] to-transparent animate-[card-sheen_2.8s_ease-in-out_infinite]" />
<div className="relative flex items-center gap-2 mb-3">
<PenLine className="size-3.5 text-primary animate-[pen-write_1.2s_ease-in-out_infinite] origin-bottom-left" />
<span className="text-xs font-medium text-foreground">内容生成中</span>
<span className="flex items-end gap-0.5">
<span className="size-1 rounded-full bg-primary/70 animate-[dot-bounce_1.4s_ease-in-out_infinite]" />
<span className="size-1 rounded-full bg-primary/70 animate-[dot-bounce_1.4s_ease-in-out_.2s_infinite]" />
<span className="size-1 rounded-full bg-primary/70 animate-[dot-bounce_1.4s_ease-in-out_.4s_infinite]" />
</span>
<span className="text-[11px] text-muted-foreground ml-auto">正文已在右侧实时显示</span>
</div>
<div className="relative space-y-2.5">
{SKELETON_WIDTHS.map((w, i) => (
<div
key={i}
className="relative h-3 overflow-hidden rounded-full bg-muted/60"
style={{ width: w }}
>
<div
className="absolute inset-0 -translate-x-full bg-gradient-to-r from-transparent via-white/45 to-transparent dark:via-white/10 animate-[shimmer-sweep_1.5s_ease-in-out_infinite]"
style={{ animationDelay: `${i * 0.15}s` }}
/>
</div>
))}
</div>
<style>{`
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
/* 骨架行流光:高光从左扫到右 */
@keyframes shimmer-sweep {
0% { transform: translateX(-100%); }
100% { transform: translateX(220%); }
}
/* 卡片整体微高光呼吸 */
@keyframes card-sheen {
0%, 60%, 100% { transform: translateX(-100%); }
80% { transform: translateX(100%); }
}
/* 写字笔:轻微上下 + 摆动,像在书写 */
@keyframes pen-write {
0%, 100% { transform: translateY(0) rotate(0deg); }
50% { transform: translateY(-1.5px) rotate(-12deg); }
}
/* 跳动圆点 */
@keyframes dot-bounce {
0%, 100% { opacity: .35; transform: translateY(0); }
50% { opacity: 1; transform: translateY(-2px); }
}
`}</style>
</div>
);
}
/**
* 作家写作 / 按意见修改阶段,左侧的轻量「生成中」视图。
*
* 刻意不再逐章节渲染流式正文(那套 DraftStreamingView 含逐 chunk 的平滑
* scrollIntoView 等重型操作):正文已在右侧实时显示,左侧只需告知"正在生成",
* 若模型有思考过程则一并展示。这样左侧每个 chunk 的渲染开销极小,避免更新风暴。
*/
export function DraftGeneratingView({ sections }: DraftGeneratingViewProps) {
// 按章节顺序拼接非空思考内容;是否仍在思考以"存在未完成章节"近似
const ordered = [...sections].sort((a, b) => a.sectionIndex - b.sectionIndex);
const thinking = ordered.map((s) => s.thinkingText).filter(Boolean).join('\n\n');
const thinkingDone = sections.length > 0 && sections.every((s) => s.isDone);
return (
<div className="px-4 pb-4 space-y-3">
{thinking && <ThinkingBox text={thinking} done={thinkingDone} />}
<GeneratingSkeleton />
</div>
);
}
@@ -0,0 +1,152 @@
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;
/** 是否显示流式光标(生成中 true;静态只读预览传 false) */
showCursor?: boolean;
}
/**
* AI 写作右侧「边生成边推送」的只读流式预览。
*
* 刻意不挂载 Tiptap 编辑器(useWritingEditor / useWritingRewrite):草稿是 token 级
* 高频 chunk,若每个 chunk 都灌进编辑器会触发 ProseMirror transaction 风暴 + 父子组件
* 反复重渲染,导致 React「Maximum update depth exceeded」。生成完成后才由
* TextArtifactRenderer 接管为可编辑文档(润色 / 滑词等)。
*
* 行为:自动下滑跟随生成;用户向上滚动则停止自动下滑并浮出「回到底部」按钮,
* 点击后滚到底部并恢复自动下滑。
*/
export function DraftStreamingPreview({ markdown, showCursor = true }: DraftStreamingPreviewProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const autoScrollRef = useRef(true);
// 程序化滚动期间置 true,让 onScroll 忽略自己触发的滚动,避免 setState 抖动/回环
const programmaticRef = useRef(false);
const [showScrollBtn, setShowScrollBtn] = useState(false);
// 内容增长时,若自动下滑开启则贴底
useEffect(() => {
if (!autoScrollRef.current) return;
const el = scrollRef.current;
if (!el) return;
programmaticRef.current = true;
el.scrollTop = el.scrollHeight;
requestAnimationFrame(() => { programmaticRef.current = false; });
}, [markdown]);
const handleScroll = useCallback(() => {
if (programmaticRef.current) return; // 忽略程序化滚动
const el = scrollRef.current;
if (!el) return;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
autoScrollRef.current = atBottom;
setShowScrollBtn((prev) => (prev === !atBottom ? prev : !atBottom));
}, []);
const handleScrollToBottom = useCallback(() => {
const el = scrollRef.current;
if (el) {
programmaticRef.current = true;
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
requestAnimationFrame(() => { programmaticRef.current = false; });
}
autoScrollRef.current = true;
setShowScrollBtn(false);
}, []);
return (
<div className="relative h-full flex flex-col overflow-hidden">
<div
ref={scrollRef}
className="flex-1 overflow-y-auto"
onScroll={handleScroll}
>
<div className="max-w-2xl mx-auto px-10 pt-10 pb-32">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
h1: ({ children }) => (
<h1 className="text-[1.625rem] font-bold tracking-tight mb-3 mt-8 first:mt-0 text-foreground border-b border-border pb-2 leading-tight">
{children}
</h1>
),
h2: ({ children }) => (
<h2 className="text-[1.25rem] font-semibold tracking-tight mb-2.5 mt-7 text-foreground leading-snug">
{children}
</h2>
),
h3: ({ children }) => (
<h3 className="text-[1.0625rem] font-semibold tracking-tight mb-2 mt-5 text-foreground leading-snug">
{children}
</h3>
),
p: ({ children }) => (
<p className="mb-4 leading-[1.8] text-[15px] text-foreground indent-[2em]">
{children}
</p>
),
ul: ({ children }) => (
<ul className="mb-4 ml-6 list-disc space-y-1.5 text-[15px] text-foreground">
{children}
</ul>
),
ol: ({ children }) => (
<ol className="mb-4 ml-6 list-decimal space-y-1.5 text-[15px] text-foreground">
{children}
</ol>
),
li: ({ children }) => <li className="leading-7">{children}</li>,
blockquote: ({ children }) => (
<blockquote className="border-l-4 border-primary/30 pl-4 my-4 text-muted-foreground italic text-sm">
{children}
</blockquote>
),
pre: ({ children }) => (
<pre className="mb-4 rounded-md bg-muted/60 overflow-x-auto">
{children}
</pre>
),
code: ({ className, children }) => {
const isBlock = Boolean(className?.startsWith("language-"));
return isBlock ? (
<code className="block p-4 text-xs font-mono text-foreground leading-relaxed">
{children}
</code>
) : (
<code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono text-foreground">
{children}
</code>
);
},
}}
>
{markdown}
</ReactMarkdown>
{showCursor && (
<span className="inline-block w-0.5 h-4 bg-primary animate-pulse align-middle ml-0.5" />
)}
</div>
</div>
{showScrollBtn && (
<div className="absolute bottom-4 left-0 right-0 flex justify-center z-10 pointer-events-none">
<Button
size="sm"
variant="secondary"
className="pointer-events-auto shadow-md gap-1.5"
onClick={handleScrollToBottom}
>
<ArrowDown className="w-3.5 h-3.5" />
回到底部
</Button>
</div>
)}
</div>
);
}
@@ -0,0 +1,46 @@
import { useEffect } from 'react';
import { useCanvasGraphContext } from '../../contexts/CanvasGraphContext';
import { useCanvasUIContext } from '../../contexts/CanvasUIContext';
import type { WritingStatus } from '../../types/ai-writing';
interface DraftSyncProps {
status: WritingStatus;
markdown: string;
title: string;
}
/**
* 把 AI 写作草稿在「稳定态」同步到画布 artifact / 编辑权限。
*
* 流式生成中(writing_draft / revising)**不再**逐 chunk 写入 CanvasUIContext,
* 右侧预览改由 ``AIWritingDraftPanel`` 直接从 ``AIWritingContext`` 读章节并渲染 Markdown,
* 避免高频 setState 引发更新风暴。
*/
export function DraftSync({ status, markdown, title }: DraftSyncProps) {
const { setArtifact } = useCanvasGraphContext();
const { setIsDraftEditable } = useCanvasUIContext();
const streaming = status === 'writing_draft' || status === 'revising';
// 注意:reviewing(编辑审核)不算 generating —— 审核时草稿已定稿、内容稳定,
// 右侧保持编辑器(富文本)视图,不再切回只读 markdown。仅真正在改写正文的
// writing_draft / revising 才只读。
const generating =
status === 'researching' ||
status === 'writing_outline' ||
streaming;
useEffect(() => {
setIsDraftEditable(!generating);
}, [generating, setIsDraftEditable]);
useEffect(() => {
if (streaming || !markdown) return;
setArtifact({
currentIndex: 0,
contents: [{ index: 0, type: 'text', title: title || '草稿', fullMarkdown: markdown }],
});
}, [streaming, markdown, title, setArtifact]);
return null;
}
@@ -0,0 +1,164 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Sparkles } from 'lucide-react';
import { toast } from 'sonner';
import {
PromptInput,
PromptInputFooter,
PromptInputSubmit,
PromptInputTextarea,
} from '@/components/ai-elements/prompt-input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { ArtifactsProvider } from '@/components/workspace/artifacts';
import {
MessageList,
type SetupWritingApprovalPayload,
} from '@/components/workspace/messages';
import { ThreadContext } from '@/components/workspace/messages/context';
import { getAPIClient } from '@/core/api';
import { useModels } from '@/core/models/hooks';
import { useThreadStream } from '@/core/threads/hooks';
import { uuid } from '@/core/utils/uuid';
import { cn } from '@/lib/utils';
import { useArticleTypes } from '../../hooks/useArticleTypes';
import { useWritingForm } from '../../contexts/WritingFormContext';
/**
* 写作配置 Q&A 面板(AI 写作入口页右侧,仅在未开始写作时显示)。
*
* 复用智能体创建页的「对话 → setup_writing 工具审批卡 → 回填表单」模式:
* - 用 ``useThreadStream`` 起一条独立的 lead_agent 线程,context 带 ``is_writing_setup``,
* 后端走写作配置专用 bootstrap(只挂 setup_writing 工具)。
* - 模型调用 setup_writing 被 SetupWritingApprovalMiddleware 拦截成审批卡。
* - 用户点「应用到表单」→ ``applyProposal`` 把建议字段写进左侧写作表单(WritingFormContext)。
*
* 这条线程只负责「帮用户把表单聊清」,与真正写作的 ai_writing graph 完全解耦。
*/
export function WritingSetupChat() {
const threadId = useMemo(() => uuid(), []);
const [threadPrepared, setThreadPrepared] = useState(false);
const [chatModel, setChatModel] = useState('');
const { models, isLoading: modelsLoading } = useModels();
const { articleTypes } = useArticleTypes();
const { applyProposal } = useWritingForm();
// 首个模型可用时默认选中
useEffect(() => {
if (models.length === 0) return;
setChatModel((prev) => (prev && models.some((m) => m.name === prev) ? prev : models[0]!.name));
}, [models]);
// 注入后端 prompt 的合法文章类型(只取 key/label)
const validArticleTypes = useMemo(
() => articleTypes.map((t) => ({ key: t.key, label: t.label })),
[articleTypes],
);
const [thread, sendMessage] = useThreadStream({
threadId,
context: {
mode: 'flash',
is_writing_setup: true,
valid_article_types: validArticleTypes,
model_name: chatModel || undefined,
},
});
const ensureThreadExists = useCallback(async () => {
if (threadPrepared) return;
await getAPIClient().threads.create({ threadId, metadata: { thread_type: 'writing_setup' } });
setThreadPrepared(true);
}, [threadId, threadPrepared]);
const handleChatSubmit = useCallback(
async (text: string) => {
const trimmed = text.trim();
if (!trimmed || thread.isLoading) return;
await ensureThreadExists();
await sendMessage(threadId, { text: trimmed, files: [] });
},
[ensureThreadExists, sendMessage, thread.isLoading, threadId],
);
const handleApprove = useCallback(
async (payload: SetupWritingApprovalPayload) => {
applyProposal(payload);
toast.success('已应用到左侧表单,可继续编辑或开始写作');
},
[applyProposal],
);
const handleReject = useCallback(async () => {
await handleChatSubmit('这份配置先不应用,请继续帮我调整。');
}, [handleChatSubmit]);
return (
<ThreadContext.Provider value={{ thread }}>
<ArtifactsProvider>
<div className="flex size-full flex-col bg-background">
<header className="flex shrink-0 items-center gap-2 border-b px-4 py-3">
<Sparkles className="size-4 text-primary" />
<h2 className="text-sm font-semibold">写作助手</h2>
<span className="text-muted-foreground text-xs">描述你想写什么,我来帮你填好左侧配置</span>
</header>
<div className="min-h-0 flex-1">
<MessageList
className="size-full"
threadId={threadId}
thread={thread}
onClarificationSubmit={handleChatSubmit}
onSetupWritingApprove={handleApprove}
onSetupWritingReject={handleReject}
/>
</div>
<div className="bg-background shrink-0 space-y-2 border-t px-4 py-4">
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-xs">当前对话模型</span>
{modelsLoading || models.length === 0 ? (
<div className="border-input bg-background text-muted-foreground flex h-7 min-w-[10rem] items-center rounded-md border px-2 text-xs shadow-xs dark:border-[#1B477E] dark:bg-[#022248]">
{modelsLoading ? '加载中…' : '暂无模型'}
</div>
) : (
<Select value={chatModel} onValueChange={setChatModel} disabled={thread.isLoading}>
<SelectTrigger
className={cn(
'border-input bg-background h-7 min-w-[10rem] px-2 text-xs shadow-xs outline-none',
'dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEFF]',
)}
>
<SelectValue placeholder="选择模型" />
</SelectTrigger>
<SelectContent position="popper" className="w-[var(--radix-select-trigger-width)] dark:border-[#1B477E] dark:bg-[#022248]">
{models.map((model) => (
<SelectItem key={model.name} value={model.name} className="dark:text-[#AACEFF]">
{model.display_name || model.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<PromptInput onSubmit={({ text }) => void handleChatSubmit(text)}>
<PromptInputTextarea
placeholder="例如:帮我写一篇 2000 字、面向行业专家的新能源汽车出口分析"
disabled={thread.isLoading}
/>
<PromptInputFooter className="justify-end">
<PromptInputSubmit disabled={thread.isLoading} />
</PromptInputFooter>
</PromptInput>
</div>
</div>
</ArtifactsProvider>
</ThreadContext.Provider>
);
}
@@ -13,6 +13,8 @@ import { CanvasStreamProvider } from "../../contexts/CanvasStreamContext";
import { CanvasChatPanel } from "../chat/CanvasChatPanel";
import { CanvasArtifactPanel } from "../artifact/CanvasArtifactPanel";
import { AIWritingPanel } from "../ai-writing/AIWritingPanel";
import { AIWritingProvider } from "../../contexts/AIWritingContext";
import { WritingFormProvider } from "../../contexts/WritingFormContext";
const LAYOUT_COLLAPSED = { chat: 0, artifact: 100 };
const LAYOUT_DEFAULT = { chat: 35, artifact: 65 };
@@ -65,7 +67,13 @@ export function CanvasResizableLayout() {
</div>
<div className="flex-1 min-h-0 overflow-hidden">
{leftMode === "chat" ? <CanvasChatPanel /> : <AIWritingPanel />}
{leftMode === "chat" ? <CanvasChatPanel /> : (
<AIWritingProvider>
<WritingFormProvider>
<AIWritingPanel />
</WritingFormProvider>
</AIWritingProvider>
)}
</div>
</ResizablePanel>
@@ -0,0 +1,407 @@
import {
createContext,
memo,
useCallback,
useContext,
useEffect,
useMemo,
useReducer,
useRef,
type Dispatch,
type MutableRefObject,
type ReactNode,
type RefObject,
} from 'react';
import { useStream } from '@langchain/langgraph-sdk/react';
import { getAPIClient } from '@/core/api';
import { useCanvasGraphContext } from '../contexts/CanvasGraphContext';
import { saveAIWritingTranscript } from '../api/ai-writing-sessions';
import { countWords } from '../utils/wordCount';
import {
INITIAL_STATE,
customToProgressEvent,
interruptToEvent,
nodeUpdatesToEvents,
reducer,
type Action,
type AIWritingValues,
} from '../hooks/useAIWritingStream';
import type {
AIWritingRequest,
AIWritingState,
CompletedIntervention,
ProgressEvent,
UserInterventionPayload,
WritingStatus,
} from '../types/ai-writing';
/** 高频 chunk 类型:合并到同一帧再 dispatch,避免同步嵌套 setState 触发 Maximum update depth。 */
const BATCHED_CHUNK_TYPES = new Set<ProgressEvent['type']>([
'draft_chunk',
'outline_chunk',
'intent_chunk',
'skill_select_chunk',
'review_chunk',
]);
function createProgressEventBatcher(dispatchRef: RefObject<Dispatch<Action>>) {
const pending: ProgressEvent[] = [];
let rafId = 0;
const flush = () => {
rafId = 0;
if (pending.length === 0) return;
const events = pending.splice(0, pending.length);
if (events.length === 1) {
dispatchRef.current?.({ type: 'handle', event: events[0] });
} else {
dispatchRef.current?.({ type: 'handle_batch', events });
}
};
return {
push(event: ProgressEvent) {
pending.push(event);
if (!rafId) rafId = requestAnimationFrame(flush);
},
pushImmediate(event: ProgressEvent) {
// 关键:先把缓冲里的 chunk 同步 flush,再派发立即事件,保证事件顺序。
// 否则 *_ready / await_user / step_* 会插队到尚在 rAF 缓冲里的 *_chunk 之前——
// ready 先清空流式文本 / 切状态,随后残留 chunk 又被回灌,导致:
// 意图/大纲/审核的流式视图清不掉(残留 JSON 尾巴)、切不回结果卡、右侧 markdown 切不回编辑器。
if (rafId) cancelAnimationFrame(rafId);
flush();
dispatchRef.current?.({ type: 'handle', event });
},
cancel() {
if (rafId) cancelAnimationFrame(rafId);
rafId = 0;
pending.length = 0;
},
};
}
type AIWritingStreamApi = Pick<
ReturnType<typeof useStream<AIWritingValues>>,
'submit' | 'stop' | 'interrupt'
>;
export type AIWritingApi = {
state: AIWritingState;
startWriting: (request: AIWritingRequest) => Promise<void>;
submitIntervention: (payload: UserInterventionPayload) => Promise<void>;
pauseWriting: () => void;
resumeWriting: () => void;
reset: () => void;
};
const AIWritingContext = createContext<AIWritingApi | null>(null);
/** 可选读取:不在 Provider 内时返回 null(如 Canvas 布局右侧 artifact 面板)。 */
export function useAIWritingOptional(): AIWritingApi | null {
return useContext(AIWritingContext);
}
/** 在 ``AIWritingProvider`` 内读取写作状态与操作 API。 */
export function useAIWriting(): AIWritingApi {
const ctx = useContext(AIWritingContext);
if (!ctx) {
throw new Error('useAIWriting must be used within AIWritingProvider');
}
return ctx;
}
/**
* 承载 ``useStream`` 的独立组件(render null)。
*
* 用 ``memo`` 且仅随 ``threadId`` 重渲染,这样 ``draft_chunk`` 等高频 custom 事件
* 更新 reducer 时不会重跑 ``useStream``,避免 onCustomEvent → dispatch → 重渲染
* → useStream 重入 的更新风暴(Maximum update depth exceeded)。
*/
const AIWritingStreamHost = memo(function AIWritingStreamHost({
threadId,
dispatchRef,
sessionIdRef,
streamRef,
seenInterruptRef,
}: {
threadId: string | undefined;
dispatchRef: RefObject<Dispatch<Action>>;
sessionIdRef: RefObject<string | null>;
streamRef: MutableRefObject<AIWritingStreamApi | null>;
seenInterruptRef: MutableRefObject<string | null>;
}) {
const eventBatcherRef = useRef<ReturnType<typeof createProgressEventBatcher> | null>(null);
if (!eventBatcherRef.current) {
eventBatcherRef.current = createProgressEventBatcher(dispatchRef);
}
const emitProgressEvent = useCallback((event: ProgressEvent) => {
if (BATCHED_CHUNK_TYPES.has(event.type)) {
eventBatcherRef.current!.push(event);
} else {
eventBatcherRef.current!.pushImmediate(event);
}
}, []);
const stream = useStream<AIWritingValues>({
client: getAPIClient(),
assistantId: 'ai_writing',
threadId,
reconnectOnMount: true,
onCreated(meta) {
dispatchRef.current?.({ type: 'set_session', sessionId: meta.thread_id });
},
onCustomEvent(event: unknown) {
if (typeof window !== 'undefined' && (window as { __AI_WRITING_DEBUG__?: boolean }).__AI_WRITING_DEBUG__) {
const t = (event as { type?: string } | null)?.type;
// eslint-disable-next-line no-console
console.debug(`[ai-writing:custom] +${Math.round(performance.now())}ms type=${t ?? '?'}`, event);
}
const pe = customToProgressEvent(event, sessionIdRef.current ?? '');
if (pe) emitProgressEvent(pe);
},
onUpdateEvent(data: Record<string, unknown> | undefined) {
if (!data) return;
if (typeof window !== 'undefined' && (window as { __AI_WRITING_DEBUG__?: boolean }).__AI_WRITING_DEBUG__) {
// eslint-disable-next-line no-console
console.debug(`[ai-writing:updates] +${Math.round(performance.now())}ms nodes=${Object.keys(data).join(',')}`);
}
if (Array.isArray((data as Record<string, unknown>).__interrupt__)) {
const interrupts = (data as { __interrupt__: unknown[] }).__interrupt__;
for (const intr of interrupts) {
const value = (intr && typeof intr === 'object' && 'value' in intr)
? (intr as { value: unknown }).value : intr;
const ev = interruptToEvent(value, sessionIdRef.current ?? '');
if (ev) emitProgressEvent(ev);
}
return;
}
for (const nodeOutput of Object.values(data)) {
const events = nodeUpdatesToEvents(nodeOutput, sessionIdRef.current ?? '');
for (const ev of events) emitProgressEvent(ev);
}
},
onError(err: unknown) {
eventBatcherRef.current?.cancel();
dispatchRef.current?.({
type: 'set_error',
message: err instanceof Error ? err.message : String(err),
});
},
onFinish(finishState) {
eventBatcherRef.current?.cancel();
const finalStatus = (finishState.values as AIWritingValues | undefined)?.status;
if (finalStatus === 'done' || finalStatus === 'error') {
dispatchRef.current?.({
type: 'handle',
event: {
type: finalStatus === 'error' ? 'error' : 'done',
sessionId: finishState.values?.sessionId as string ?? '',
message: finalStatus === 'error' ? '写作出错' : '写作完成',
},
});
}
},
});
streamRef.current = stream;
useEffect(() => {
const intr = stream.interrupt;
if (!intr) {
seenInterruptRef.current = null;
return;
}
const key = intr.id ?? JSON.stringify(intr.value);
if (seenInterruptRef.current === key) return;
seenInterruptRef.current = key;
const ev = interruptToEvent(intr.value, sessionIdRef.current ?? '');
if (ev) emitProgressEvent(ev);
}, [stream.interrupt, emitProgressEvent, seenInterruptRef, sessionIdRef]);
useEffect(() => () => { eventBatcherRef.current?.cancel(); }, []);
return null;
}, (prev, next) => prev.threadId === next.threadId);
export function AIWritingProvider({ children }: { children: ReactNode }) {
const { setArtifact } = useCanvasGraphContext();
const [state, dispatch] = useReducer(reducer, INITIAL_STATE);
const dispatchRef = useRef(dispatch);
dispatchRef.current = dispatch;
const sessionIdRef = useRef<string | null>(state.sessionId);
sessionIdRef.current = state.sessionId;
const streamRef = useRef<AIWritingStreamApi | null>(null);
const seenInterruptRef = useRef<string | null>(null);
const threadId = state.sessionId ?? undefined;
useEffect(() => {
const s = state.status;
const savable = s === 'done' || s === 'error' || s.startsWith('awaiting_');
if (!state.sessionId || !savable || state.progressEvents.length === 0) return;
saveAIWritingTranscript(state.sessionId, {
progressEvents: state.progressEvents,
completedInterventions: state.completedInterventions,
}).catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state.status, state.sessionId]);
const startWriting = useCallback(async (request: AIWritingRequest) => {
if (state.status !== 'idle' && state.status !== 'done' && state.status !== 'error') return;
dispatch({ type: 'start', request });
const stream = streamRef.current;
if (!stream) return;
try {
await stream.submit(
{
user_intent: request.userIntent,
// 用户自带大纲原文(留空则后端走自动规划)
user_outline_raw: request.userOutline?.trim() || '',
article_type: request.articleType,
target_audience: request.targetAudience,
word_count_target: request.wordCountTarget,
keyword_count: Math.max(1, Math.min(10, request.keywordCount)),
writing_mode: request.writingMode === 'strict' ? 'strict' : 'loose',
material_source: request.materialSource === 'skill' ? 'skill' : 'knowledge_base',
revision_count: 0,
max_revisions: 3,
status: 'researching',
drafts: [],
progress_events: [],
material_package: null,
current_outline: null,
current_draft: null,
review_result: null,
last_user_intervention: null,
} as Partial<AIWritingValues>,
{
config: { configurable: { model_name: request.modelName ?? '' } } as Record<string, unknown>,
streamMode: ['values', 'updates', 'custom', 'messages-tuple'],
},
);
} catch (err) {
dispatch({ type: 'set_error', message: err instanceof Error ? err.message : String(err) });
}
}, [state.status]);
const submitIntervention = useCallback(async (payload: UserInterventionPayload) => {
if (!state.sessionId || !state.currentPause) return;
const stream = streamRef.current;
if (!stream) return;
const pausePoint = state.currentPause.pausePoint;
const record: CompletedIntervention = {
pausePoint: pausePoint as CompletedIntervention['pausePoint'],
action: payload.action,
};
if (pausePoint === 'material_confirm') record.materials = state.materials;
else if (pausePoint === 'outline_confirm') record.outline = state.outline ?? undefined;
else if (pausePoint === 'draft_confirm') {
record.draftTitle = state.currentDraftTitle;
record.draftWordCount = countWords(state.currentDraftMarkdown);
record.reviewResult = state.reviewResult ?? undefined;
} else if (pausePoint === 'review_confirm') {
record.reviewResult = state.reviewResult ?? undefined;
}
let pendingRevisionContext: AIWritingState['pendingRevisionContext'] = null;
const isApplyingReview = payload.selectedIssueIndices !== undefined && state.reviewResult != null;
if (isApplyingReview && state.reviewResult) {
const allIssues = state.reviewResult.revisionNotes;
const selectedSet = new Set(payload.selectedIssueIndices!);
pendingRevisionContext = {
submittedIssues: allIssues.filter((_, i) => selectedSet.has(i)),
skippedIssues: allIssues.filter((_, i) => !selectedSet.has(i)),
oldWordCount: countWords(state.currentDraftMarkdown),
};
}
let nextStatus: WritingStatus = state.status;
if (pausePoint === 'material_confirm') {
nextStatus = (payload.action === 're_search' || payload.action === 'enable_skill_search')
? 'researching' : 'writing_outline';
} else if (pausePoint === 'outline_confirm') {
nextStatus = payload.action === 're_outline' ? 'writing_outline' : 'writing_draft';
} else if (pausePoint === 'draft_confirm') {
if (payload.action === 'finalize') nextStatus = 'done';
else if (payload.action === 'to_editor') nextStatus = 'reviewing';
else nextStatus = 'revising';
} else if (pausePoint === 'review_confirm') {
nextStatus = payload.action === 'force_finalize' ? 'done' : 'revising';
} else if (pausePoint === 'section_help') {
nextStatus = payload.sectionDecisions?.some((d) => d.decision === 'supplement')
? 'researching' : 'writing_draft';
}
const resumePayload: Record<string, unknown> = { action: payload.action };
if (payload.approvedMaterialIds !== undefined) resumePayload.approvedMaterialIds = payload.approvedMaterialIds;
if (payload.extraKeywords !== undefined) resumePayload.extraKeywords = payload.extraKeywords;
if (payload.userQuery !== undefined) resumePayload.userQuery = payload.userQuery;
if (payload.editedOutline !== undefined) resumePayload.editedOutline = payload.editedOutline;
if (payload.outlineFeedback !== undefined) resumePayload.outlineFeedback = payload.outlineFeedback;
if (payload.userRevisionNotes !== undefined) resumePayload.userRevisionNotes = payload.userRevisionNotes;
if (payload.overrideVerdict !== undefined) resumePayload.overrideVerdict = payload.overrideVerdict;
if (payload.sectionDecisions !== undefined) {
resumePayload.sectionDecisions = payload.sectionDecisions.map((d) => ({
sectionTitle: d.sectionTitle, decision: d.decision,
}));
}
const intr = stream.interrupt;
if (intr) {
seenInterruptRef.current = intr.id ?? JSON.stringify(intr.value);
}
dispatch({ type: 'intervention_submitted', record, nextStatus, pendingRevisionContext });
try {
await stream.submit(undefined, {
command: { resume: resumePayload },
streamMode: ['values', 'updates', 'custom', 'messages-tuple'],
});
} catch (err) {
dispatch({ type: 'set_error', message: err instanceof Error ? err.message : String(err) });
}
}, [state.sessionId, state.currentPause, state.materials, state.outline,
state.currentDraftTitle, state.currentDraftMarkdown, state.reviewResult,
state.status]);
const pauseWriting = useCallback(() => {
void streamRef.current?.stop();
dispatch({ type: 'pause' });
}, []);
const resumeWriting = useCallback(() => {
dispatch({ type: 'resume_ui' });
}, []);
const reset = useCallback(() => {
void streamRef.current?.stop();
setArtifact(null);
seenInterruptRef.current = null;
dispatch({ type: 'reset' });
}, [setArtifact]);
const value = useMemo(
() => ({ state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset }),
[state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset],
);
return (
<AIWritingContext.Provider value={value}>
<AIWritingStreamHost
threadId={threadId}
dispatchRef={dispatchRef}
sessionIdRef={sessionIdRef}
streamRef={streamRef}
seenInterruptRef={seenInterruptRef}
/>
{children}
</AIWritingContext.Provider>
);
}
@@ -17,12 +17,21 @@ export interface CanvasUIContextValue {
isChatCollapsed: boolean;
isEditing: boolean;
isWebSearchOpen: boolean;
/** AI 写作:右侧草稿是否正在流式生成。生成中右侧走只读流式视图(自动下滑、禁用编辑/润色/滑词),生成完才可编辑 */
isDraftStreaming: boolean;
/** AI 写作:流式生成中的草稿 Markdown。生成期间走这条独立通道(不写全局 artifact),避免高频刷新触发编辑器更新风暴 */
draftStreamingMarkdown: string;
/** AI 写作:草稿是否进入"生成完成的稳定态"(可编辑)。仅此时才挂载带编辑器的渲染器;生成/审核中一律只读 */
isDraftEditable: boolean;
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;
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. */
@@ -42,6 +51,9 @@ 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 [isDraftEditable, setIsDraftEditable] = useState(false);
const [selection, setSelection] =
useState<CanvasSelectionState>(EMPTY_SELECTION);
const [writingSelection, setWritingSelection] =
@@ -61,11 +73,17 @@ export function CanvasUIProvider({ children }: { children: ReactNode }) {
isChatCollapsed,
isEditing,
isWebSearchOpen,
isDraftStreaming,
draftStreamingMarkdown,
isDraftEditable,
selection,
writingSelection,
setIsChatCollapsed,
setIsEditing,
setIsWebSearchOpen,
setIsDraftStreaming,
setDraftStreamingMarkdown,
setIsDraftEditable,
setTextSelection,
setCodeSelection,
clearSelection: () => setSelection(EMPTY_SELECTION),
@@ -0,0 +1,102 @@
import {
createContext,
useCallback,
useContext,
useMemo,
useState,
type ReactNode,
} from 'react';
import type { SetupWritingApprovalPayload } from '@/components/workspace/messages';
/**
* 写作配置表单的共享状态。
*
* 上提到 Context 是为了让「右侧写作助手 Q&A」在用户批准模型建议后,能把建议字段
* 一次性回填到「左侧写作表单」的输入框(``applyProposal``)。表单与 Q&A 面板共用同一份
* state,因此 Provider 必须同时包住两侧面板(见 AIWritingPage)。
*/
export interface WritingFormValue {
intent: string;
setIntent: (v: string) => void;
showOutline: boolean;
setShowOutline: (v: boolean) => void;
outlineText: string;
setOutlineText: (v: string) => void;
articleType: string;
setArticleType: (v: string) => void;
wordCountInput: string;
setWordCountInput: (v: string) => void;
audience: string;
setAudience: (v: string) => void;
strictMode: boolean;
setStrictMode: (v: boolean) => void;
materialSource: 'knowledge_base' | 'skill' | 'notebook';
setMaterialSource: (v: 'knowledge_base' | 'skill' | 'notebook') => void;
modelName: string;
setModelName: (v: string) => void;
/** 把模型建议(setup_writing 审批 payload)回填到表单各字段。 */
applyProposal: (payload: SetupWritingApprovalPayload) => void;
}
const WritingFormContext = createContext<WritingFormValue | null>(null);
export function useWritingForm(): WritingFormValue {
const ctx = useContext(WritingFormContext);
if (!ctx) {
throw new Error('useWritingForm must be used within WritingFormProvider');
}
return ctx;
}
export function useWritingFormOptional(): WritingFormValue | null {
return useContext(WritingFormContext);
}
export function WritingFormProvider({ children }: { children: ReactNode }) {
const [intent, setIntent] = useState('');
const [showOutline, setShowOutline] = useState(false);
const [outlineText, setOutlineText] = useState('');
const [articleType, setArticleType] = useState('analysis');
const [wordCountInput, setWordCountInput] = useState('800');
const [audience, setAudience] = useState('普通读者');
const [strictMode, setStrictMode] = useState(false);
const [materialSource, setMaterialSource] = useState<'knowledge_base' | 'skill' | 'notebook'>('knowledge_base');
const [modelName, setModelName] = useState('');
const applyProposal = useCallback((payload: SetupWritingApprovalPayload) => {
if (payload.user_intent?.trim()) setIntent(payload.user_intent.trim());
if (payload.article_type?.trim()) setArticleType(payload.article_type.trim());
if (typeof payload.word_count_target === 'number' && payload.word_count_target > 0) {
setWordCountInput(String(payload.word_count_target));
}
if (payload.target_audience?.trim()) setAudience(payload.target_audience.trim());
if (payload.writing_mode === 'strict') setStrictMode(true);
else if (payload.writing_mode === 'loose') setStrictMode(false);
if (payload.material_source === 'skill' || payload.material_source === 'knowledge_base') {
setMaterialSource(payload.material_source);
}
if (payload.outline?.trim()) {
setOutlineText(payload.outline);
setShowOutline(true);
}
}, []);
const value = useMemo<WritingFormValue>(
() => ({
intent, setIntent,
showOutline, setShowOutline,
outlineText, setOutlineText,
articleType, setArticleType,
wordCountInput, setWordCountInput,
audience, setAudience,
strictMode, setStrictMode,
materialSource, setMaterialSource,
modelName, setModelName,
applyProposal,
}),
[intent, showOutline, outlineText, articleType, wordCountInput, audience, strictMode, materialSource, modelName, applyProposal],
);
return <WritingFormContext.Provider value={value}>{children}</WritingFormContext.Provider>;
}
@@ -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",
@@ -21,15 +21,6 @@
* 这样 reducer 逻辑(INITIAL_STATE + switch 各分支)与老 hook 共享同一份代码,
* 只换"事件源"。阶段 7 删冗余时把老 hook 整个删掉就行。
*/
import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react';
import { useStream } from '@langchain/langgraph-sdk/react';
import { getAPIClient } from '@/core/api';
import { getStoredAuth } from '@/core/auth';
import { fetchStudioToken } from '@/core/studio/api';
import { useCanvasGraphContext } from '../contexts/CanvasGraphContext';
import { saveAIWritingTranscript } from '../api/ai-writing-sessions';
import { countWords } from '../utils/wordCount';
import type {
AIWritingRequest,
@@ -49,8 +40,6 @@ import type {
WritingStatus,
} from '../types/ai-writing';
// ── INITIAL_STATE 与 reducer 与老 hook 共享 ────────────────────────────────────
const INITIAL_STATE: AIWritingState = {
sessionId: null,
status: 'idle',
@@ -83,10 +72,13 @@ const INITIAL_STATE: AIWritingState = {
pendingRevisionContext: null,
};
export { INITIAL_STATE };
// reducer action:HANDLE 直接复用老 hook 的 handleEvent switch;剩下的 action 处理
// 启动 / 干预提交 / 暂停 / 恢复 / 重置 这些"指令式"状态变更。
type Action =
export type Action =
| { type: 'handle'; event: ProgressEvent }
| { type: 'handle_batch'; events: ProgressEvent[] }
| { type: 'start'; request: AIWritingRequest }
| { type: 'set_session'; sessionId: string }
| { type: 'intervention_submitted'; record: CompletedIntervention; nextStatus: WritingStatus; pendingRevisionContext: AIWritingState['pendingRevisionContext'] }
@@ -95,11 +87,14 @@ type Action =
| { type: 'reset' }
| { type: 'set_error'; message: string };
function reducer(prev: AIWritingState, action: Action): AIWritingState {
export function reducer(prev: AIWritingState, action: Action): AIWritingState {
switch (action.type) {
case 'handle':
return handleEvent(prev, action.event);
case 'handle_batch':
return action.events.reduce(handleEvent, prev);
case 'start':
return { ...INITIAL_STATE, status: 'researching', request: action.request };
@@ -142,6 +137,26 @@ function reducer(prev: AIWritingState, action: Action): AIWritingState {
}
}
// 把"正在流式生成"的草稿章节拼成 Markdown,用于边写边推送到右侧画布。
// 拼接格式与后端 writer_draft.py 的 _assemble() 对齐(# 文章标题 / ## 章节标题),
// 但这里是流式预览版:最终的 draft_ready 仍会用后端规范化后的完整 markdown 覆盖。
export 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 {
@@ -272,11 +287,15 @@ function handleEvent(prev: AIWritingState, event: ProgressEvent): AIWritingState
isDone: done,
});
}
const streamingTitle = prev.streamingDraftTitle || prev.outline?.title || '';
// 流式阶段只累积章节片段;右侧 Markdown 由面板按需拼装,避免每 chunk 改 currentDraftMarkdown
// 触发 DraftSync / 全树重渲染导致更新风暴。
return {
...prev,
status: 'writing_draft',
streamingDraftSections: sections,
streamingDraftTitle: prev.streamingDraftTitle || prev.outline?.title || '',
streamingDraftTitle: streamingTitle,
currentDraftTitle: streamingTitle || prev.currentDraftTitle,
};
}
@@ -484,7 +503,7 @@ function normalizeProgressDict(parsed: Record<string, unknown>, sessionId: strin
}
/** custom 通道事件 → ProgressEvent。事件 ``type`` 字段决定是哪类。 */
function customToProgressEvent(raw: unknown, sessionId: string): ProgressEvent | null {
export function customToProgressEvent(raw: unknown, sessionId: string): ProgressEvent | null {
if (!raw || typeof raw !== 'object') return null;
const o = raw as Record<string, unknown>;
const type = String(o.type ?? '');
@@ -517,7 +536,7 @@ function customToProgressEvent(raw: unknown, sessionId: string): ProgressEvent |
}
/** updates 通道里的节点输出 → 一组 ProgressEvent(progress_events + *_ready 合成)。 */
function nodeUpdatesToEvents(nodeOutput: unknown, sessionId: string): ProgressEvent[] {
export function nodeUpdatesToEvents(nodeOutput: unknown, sessionId: string): ProgressEvent[] {
if (!nodeOutput || typeof nodeOutput !== 'object') return [];
const out = nodeOutput as Record<string, unknown>;
const events: ProgressEvent[] = [];
@@ -581,7 +600,7 @@ function nodeUpdatesToEvents(nodeOutput: unknown, sessionId: string): ProgressEv
}
/** ``stream.interrupt.value`` → await_user ProgressEvent。 */
function interruptToEvent(value: unknown, sessionId: string): ProgressEvent | null {
export function interruptToEvent(value: unknown, sessionId: string): ProgressEvent | null {
if (!value || typeof value !== 'object') return null;
const v = value as Record<string, unknown>;
return {
@@ -602,10 +621,9 @@ function interruptToEvent(value: unknown, sessionId: string): ProgressEvent | nu
};
}
// ── 主 hook ───────────────────────────────────────────────────────────────────
type AIWritingValues = {
export type AIWritingValues = {
user_intent?: string;
user_outline_raw?: string;
article_type?: string;
status?: string;
material_package?: unknown;
@@ -614,296 +632,3 @@ type AIWritingValues = {
review_result?: unknown;
progress_events?: unknown[];
};
export function useAIWriting() {
const { setArtifact } = useCanvasGraphContext();
const [state, dispatch] = useReducer(reducer, INITIAL_STATE);
// useStream 要求受控 threadId;初次 startWriting 时 useStream 在 onCreated 里回灌
// 真实 thread_id,我们存到 state.sessionId。之后 reducer 直接读它。
const threadId = state.sessionId ?? undefined;
// 把已处理的 interrupt id 记下来,避免 useStream re-render 时重复 dispatch
const seenInterruptRef = useRef<string | null>(null);
const stream = useStream<AIWritingValues>({
client: getAPIClient(),
assistantId: 'ai_writing',
threadId,
reconnectOnMount: true,
onCreated(meta) {
// 第一次提交时拿到的 thread_id == 业务表 ai_writing_sessions.id
dispatch({ type: 'set_session', sessionId: meta.thread_id });
},
onCustomEvent(event: unknown) {
// 技能检索时机 trace:开 window.__AI_WRITING_DEBUG__ = true 后,控制台按
// performance.now() 打印每条 custom 事件到达时刻 + type,用来钉死
// skill_search_step(技能检索过程卡)与 skill_select_chunk(筛选技能打字)
// 的实际到达顺序,区分"后端未实时 emit" vs "前端渲染滞后"。
if (typeof window !== 'undefined' && (window as { __AI_WRITING_DEBUG__?: boolean }).__AI_WRITING_DEBUG__) {
const t = (event as { type?: string } | null)?.type;
// eslint-disable-next-line no-console
console.debug(`[ai-writing:custom] +${Math.round(performance.now())}ms type=${t ?? '?'}`, event);
}
const pe = customToProgressEvent(event, state.sessionId ?? '');
if (pe) dispatch({ type: 'handle', event: pe });
},
onUpdateEvent(data: Record<string, unknown> | undefined) {
// data 形如 { node_name: { ...node_output } },可能多个节点同一 step
if (!data) return;
if (typeof window !== 'undefined' && (window as { __AI_WRITING_DEBUG__?: boolean }).__AI_WRITING_DEBUG__) {
// eslint-disable-next-line no-console
console.debug(`[ai-writing:updates] +${Math.round(performance.now())}ms nodes=${Object.keys(data).join(',')}`);
}
// 兜底:updates 通道可能直接带 ``__interrupt__`` 数组(节点进 interrupt 时
// LangGraph 推 ``{__interrupt__: [...]}``),SDK 主路径只在 values 通道
// 暴露 stream.interrupt —— 万一后端 serialize 又把 __interrupt__ 滤掉,
// 这里直接合成 await_user 不至于卡死 UI。
if (Array.isArray((data as Record<string, unknown>).__interrupt__)) {
const interrupts = (data as { __interrupt__: unknown[] }).__interrupt__;
for (const intr of interrupts) {
const value = (intr && typeof intr === 'object' && 'value' in intr)
? (intr as { value: unknown }).value : intr;
const ev = interruptToEvent(value, state.sessionId ?? '');
if (ev) dispatch({ type: 'handle', event: ev });
}
return;
}
for (const nodeOutput of Object.values(data)) {
const events = nodeUpdatesToEvents(nodeOutput, state.sessionId ?? '');
for (const ev of events) dispatch({ type: 'handle', event: ev });
}
},
onError(err: unknown) {
dispatch({ type: 'set_error', message: err instanceof Error ? err.message : String(err) });
},
onFinish(state) {
// graph 自然终止(END)—— 合成一条 done 事件,UI 切到 done 状态
const finalStatus = (state.values as AIWritingValues | undefined)?.status;
if (finalStatus === 'done' || finalStatus === 'error') {
dispatch({
type: 'handle',
event: {
type: finalStatus === 'error' ? 'error' : 'done',
sessionId: state.values?.sessionId as string ?? '',
message: finalStatus === 'error' ? '写作出错' : '写作完成',
},
});
}
},
});
// interrupt → await_user:useStream 每次 re-render 都会暴露当前 interrupt,
// 我们用 id 去重;老 hook 的 reducer 本身对 pausePoint 也有去重,双保险。
useEffect(() => {
const intr = stream.interrupt;
if (!intr) {
seenInterruptRef.current = null;
return;
}
const key = intr.id ?? JSON.stringify(intr.value);
if (seenInterruptRef.current === key) return;
seenInterruptRef.current = key;
const ev = interruptToEvent(intr.value, state.sessionId ?? '');
if (ev) dispatch({ type: 'handle', event: ev });
}, [stream.interrupt, state.sessionId]);
// 草稿实时同步到右侧 Artifact 面板(与老 hook 行为一致)
useEffect(() => {
if (state.currentDraftMarkdown) {
setArtifact({
currentIndex: 0,
contents: [{
index: 0,
type: 'text',
title: state.currentDraftTitle || '草稿',
fullMarkdown: state.currentDraftMarkdown,
}],
});
}
}, [state.currentDraftMarkdown, state.currentDraftTitle, setArtifact]);
// transcript 持久化(保留老逻辑:进入暂停 / done / error 时全量上传)
useEffect(() => {
const s = state.status;
const savable = s === 'done' || s === 'error' || s.startsWith('awaiting_');
if (!state.sessionId || !savable || state.progressEvents.length === 0) return;
saveAIWritingTranscript(state.sessionId, {
progressEvents: state.progressEvents,
completedInterventions: state.completedInterventions,
}).catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state.status, state.sessionId]);
// ── 操作 API ────────────────────────────────────────────────────────────────
const startWriting = useCallback(async (request: AIWritingRequest) => {
if (state.status !== 'idle' && state.status !== 'done' && state.status !== 'error') return;
dispatch({ type: 'start', request });
try {
// 如果选择了笔记来源,获取 notebook token 用于后端检索
let notebookToken = '';
if (request.noteSearchSpaceId) {
const email = getStoredAuth()?.email ?? '';
if (email) {
try {
notebookToken = await fetchStudioToken(email);
} catch {
// 忽略 token 获取失败,后端会优雅降级
}
}
}
await stream.submit(
{
user_intent: request.userIntent,
article_type: request.articleType,
target_audience: request.targetAudience,
word_count_target: request.wordCountTarget,
keyword_count: Math.max(1, Math.min(10, request.keywordCount)),
writing_mode: request.writingMode === 'strict' ? 'strict' : 'loose',
material_source: request.materialSource === 'skill' ? 'skill' : 'knowledge_base',
revision_count: 0,
max_revisions: 3,
status: 'researching',
drafts: [],
progress_events: [],
material_package: null,
current_outline: null,
current_draft: null,
review_result: null,
last_user_intervention: null,
notebook_search_space_id: request.noteSearchSpaceId ?? null,
notebook_source_ids: request.noteSourceIds ?? [],
} as Partial<AIWritingValues>,
{
config: {
configurable: {
model_name: request.modelName ?? '',
...(notebookToken ? { notebook_token: notebookToken } : {}),
},
} as Record<string, unknown>,
streamMode: ['values', 'updates', 'custom', 'messages-tuple'],
},
);
} catch (err) {
dispatch({ type: 'set_error', message: err instanceof Error ? err.message : String(err) });
}
}, [state.status, stream]);
const submitIntervention = useCallback(async (payload: UserInterventionPayload) => {
if (!state.sessionId || !state.currentPause) return;
const pausePoint = state.currentPause.pausePoint;
const record: CompletedIntervention = {
pausePoint: pausePoint as CompletedIntervention['pausePoint'],
action: payload.action,
};
if (pausePoint === 'material_confirm') record.materials = state.materials;
else if (pausePoint === 'outline_confirm') record.outline = state.outline ?? undefined;
else if (pausePoint === 'draft_confirm') {
record.draftTitle = state.currentDraftTitle;
record.draftWordCount = countWords(state.currentDraftMarkdown);
record.reviewResult = state.reviewResult ?? undefined;
} else if (pausePoint === 'review_confirm') {
record.reviewResult = state.reviewResult ?? undefined;
}
// pendingRevisionContext:编辑打回卡提交时记录本轮被选中 / 跳过的意见,draft_ready 后用于生成摘要
let pendingRevisionContext: AIWritingState['pendingRevisionContext'] = null;
const isApplyingReview = payload.selectedIssueIndices !== undefined && state.reviewResult != null;
if (isApplyingReview && state.reviewResult) {
const allIssues = state.reviewResult.revisionNotes;
const selectedSet = new Set(payload.selectedIssueIndices!);
pendingRevisionContext = {
submittedIssues: allIssues.filter((_, i) => selectedSet.has(i)),
skippedIssues: allIssues.filter((_, i) => !selectedSet.has(i)),
oldWordCount: countWords(state.currentDraftMarkdown),
};
}
// 乐观更新到下一阶段,跟老 hook 行为对齐
let nextStatus: WritingStatus = state.status;
if (pausePoint === 'material_confirm') {
// re_search / enable_skill_search 都回到 researching;confirm 才进大纲
nextStatus = (payload.action === 're_search' || payload.action === 'enable_skill_search')
? 'researching' : 'writing_outline';
} else if (pausePoint === 'outline_confirm') {
nextStatus = payload.action === 're_outline' ? 'writing_outline' : 'writing_draft';
} else if (pausePoint === 'draft_confirm') {
if (payload.action === 'finalize') nextStatus = 'done';
else if (payload.action === 'to_editor') nextStatus = 'reviewing';
else nextStatus = 'revising';
} else if (pausePoint === 'review_confirm') {
nextStatus = payload.action === 'force_finalize' ? 'done' : 'revising';
} else if (pausePoint === 'section_help') {
nextStatus = payload.sectionDecisions?.some((d) => d.decision === 'supplement')
? 'researching' : 'writing_draft';
}
// 后端 pause 节点期望的 resume payload 字段名:保持 camelCase(节点里直接 user_input.get('action')
// / approvedMaterialIds 等),所以这里**不要**转 snake_case。
const resumePayload: Record<string, unknown> = {
action: payload.action,
};
if (payload.approvedMaterialIds !== undefined) resumePayload.approvedMaterialIds = payload.approvedMaterialIds;
if (payload.extraKeywords !== undefined) resumePayload.extraKeywords = payload.extraKeywords;
if (payload.userQuery !== undefined) resumePayload.userQuery = payload.userQuery;
if (payload.editedOutline !== undefined) resumePayload.editedOutline = payload.editedOutline;
if (payload.outlineFeedback !== undefined) resumePayload.outlineFeedback = payload.outlineFeedback;
if (payload.userRevisionNotes !== undefined) resumePayload.userRevisionNotes = payload.userRevisionNotes;
if (payload.overrideVerdict !== undefined) resumePayload.overrideVerdict = payload.overrideVerdict;
if (payload.sectionDecisions !== undefined) {
resumePayload.sectionDecisions = payload.sectionDecisions.map((d) => ({
sectionTitle: d.sectionTitle, decision: d.decision,
}));
}
seenInterruptRef.current = null; // 允许下一个 interrupt 被消费
// ⚠️ 乐观更新必须放在 await 之前 ——
// ``stream.submit(undefined, {command})`` 返回的 Promise **要等整条流跑完**
// (resume → 跑 writer_outline → 推 outline_chunk → 到达 pause_outline_confirm
// → 推新 interrupt → end) 才 resolve。这中间 useEffect(stream.interrupt) 已经
// 把 currentPause 更新到了 outline_confirm;若我们在 await 后才 dispatch
// ``intervention_submitted``(含 currentPause: null),就会把新弹的卡片擦掉,
// UI 永远卡在 writing_outline skeleton。
//
// 老 hook 没这个问题:``resumeWritingApi`` 是 POST /resume,几十毫秒就返回。
dispatch({ type: 'intervention_submitted', record, nextStatus, pendingRevisionContext });
try {
await stream.submit(undefined, {
command: { resume: resumePayload },
streamMode: ['values', 'updates', 'custom', 'messages-tuple'],
});
} catch (err) {
dispatch({ type: 'set_error', message: err instanceof Error ? err.message : String(err) });
}
}, [state.sessionId, state.currentPause, state.materials, state.outline,
state.currentDraftTitle, state.currentDraftMarkdown, state.reviewResult,
state.status, stream]);
const pauseWriting = useCallback(() => {
void stream.stop();
dispatch({ type: 'pause' });
}, [stream]);
const resumeWriting = useCallback(() => {
// useStream 在挂载 / threadId 变化时会自动 reconnect,不需要显式 attach。
dispatch({ type: 'resume_ui' });
}, []);
const reset = useCallback(() => {
void stream.stop();
setArtifact(null);
seenInterruptRef.current = null;
dispatch({ type: 'reset' });
}, [stream, setArtifact]);
// 与老 hook 同名导出对象
return useMemo(
() => ({ state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset }),
[state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset],
);
}
@@ -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<string>(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(() => {
@@ -90,6 +90,9 @@ import { CanvasGraphProvider } from '../contexts/CanvasGraphContext';
import { CanvasUIProvider } from '../contexts/CanvasUIContext';
import { AIWritingPanel } from '../components/ai-writing/AIWritingPanel';
import { AIWritingDraftPanel } from '../components/ai-writing/AIWritingDraftPanel';
import { WritingSetupChat } from '../components/ai-writing/WritingSetupChat';
import { AIWritingProvider, useAIWriting } from '../contexts/AIWritingContext';
import { WritingFormProvider } from '../contexts/WritingFormContext';
import {
ResizableHandle,
ResizablePanel,
@@ -103,6 +106,15 @@ const DRAFT_ID = 'aw-draft';
// Match the original canvas page proportions (chat=35, artifact=65)
const DEFAULT_LAYOUT = { [WRITING_ID]: 35, [DRAFT_ID]: 65 };
/**
* 右侧面板:未开始写作(idle)时显示「写作助手 Q&A」,帮用户聊清并回填左侧表单;
* 一旦开始写作(status !== idle)就切回草稿预览。
*/
function AIWritingRightPanel() {
const { state } = useAIWriting();
return state.status === 'idle' ? <WritingSetupChat /> : <AIWritingDraftPanel />;
}
export default function AIWritingPage({
hideAdvancedFields = false,
}: {
@@ -114,6 +126,8 @@ export default function AIWritingPage({
<CanvasThreadProvider>
<CanvasGraphProvider>
<CanvasUIProvider>
<AIWritingProvider>
<WritingFormProvider>
<TooltipProvider delayDuration={300}>
<div className="h-full flex flex-col overflow-hidden bg-background">
<div className="flex-1 min-h-0 overflow-hidden">
@@ -145,12 +159,14 @@ export default function AIWritingPage({
minSize={40}
className="overflow-hidden"
>
<AIWritingDraftPanel />
<AIWritingRightPanel />
</ResizablePanel>
</ResizablePanelGroup>
</div>
</div>
</TooltipProvider>
</WritingFormProvider>
</AIWritingProvider>
</CanvasUIProvider>
</CanvasGraphProvider>
</CanvasThreadProvider>
@@ -220,6 +220,8 @@ export interface UserInterventionPayload {
export interface AIWritingRequest {
userIntent: string;
/** 用户粘贴的「已有大纲」原文(可选)。非空时后端按此大纲生成,不再自动规划 */
userOutline?: string;
articleType: string;
targetAudience: string;
wordCountTarget: number;
@@ -445,10 +445,11 @@ export default function AdminScheduledTasksPage() {
</WorkspaceBody>
<Dialog open={!!editing} onOpenChange={(open) => !open && closeDialog()}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogContent className="flex max-h-[85vh] max-w-2xl flex-col gap-4 overflow-hidden sm:max-w-2xl">
<DialogHeader className="shrink-0">
<DialogTitle>编辑定时任务</DialogTitle>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain pr-1">
<div className="flex flex-col gap-3">
<Input
value={form.name}
@@ -475,7 +476,8 @@ export default function AdminScheduledTasksPage() {
placeholder="时区,例如:Asia/Shanghai"
/>
<Textarea
className="min-h-40"
rows={10}
className="field-sizing-fixed min-h-40 max-h-48 resize-y overflow-y-auto"
value={form.prompt}
onChange={(event) =>
setForm((value) => ({ ...value, prompt: event.target.value }))
@@ -483,7 +485,8 @@ export default function AdminScheduledTasksPage() {
placeholder="任务 Prompt"
/>
</div>
<DialogFooter>
</div>
<DialogFooter className="shrink-0">
<Button variant="outline" onClick={closeDialog}>
取消
</Button>
+180
View File
@@ -0,0 +1,180 @@
"use client";
import { useSearchParams } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from "react";
import { useParams } from "react-router-dom";
import { useTheme } from "@/contexts/ThemeContext";
import { loginByUsername } from "@/core/auth/api";
import { setStoredAuth } from "@/core/auth";
import {
resolveEmbedChatParams,
resolveEmbedTheme,
} from "@/core/embed/params";
import { registerEmbedSession } from "@/core/embed/status-api";
import { useThreadSettings } from "@/core/settings";
import { useThreadStream } from "@/core/threads/hooks";
import { uuid } from "@/core/utils/uuid";
import { ChatBox } from "@/components/workspace/chats";
import {
MessageList,
MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
} from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context";
import { useModels } from "@/core/models/hooks";
import { cn } from "@/lib/utils";
const EMBED_ROUTE_BASE = "/embed/chats";
export default function EmbedChatPage() {
const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>();
const searchParams = useSearchParams();
const { setTheme } = useTheme();
const embedParams = resolveEmbedChatParams(searchParams.toString());
const [authReady, setAuthReady] = useState(false);
const [authError, setAuthError] = useState<string | null>(null);
const [threadId, setThreadId] = useState(() =>
threadIdFromPath === "new" ? uuid() : (threadIdFromPath ?? uuid()),
);
const [isNewThread, setIsNewThread] = useState(threadIdFromPath === "new");
const hasAutoSentRef = useRef(false);
const lastRegisteredThreadRef = useRef<string | null>(null);
useEffect(() => {
if (threadIdFromPath === "new" || !threadIdFromPath) {
return;
}
setIsNewThread(false);
setThreadId(threadIdFromPath);
}, [threadIdFromPath]);
const [settings] = useThreadSettings(threadId);
const { tokenUsageEnabled } = useModels();
useEffect(() => {
if (embedParams.theme) {
setTheme(resolveEmbedTheme(embedParams.theme));
}
}, [embedParams.theme, setTheme]);
useEffect(() => {
let cancelled = false;
async function runLogin() {
setAuthError(null);
try {
const auth = await loginByUsername(embedParams.username);
if (cancelled) return;
setStoredAuth(auth);
setAuthReady(true);
} catch (error) {
if (cancelled) return;
setAuthError(
error instanceof Error ? error.message : "Login request failed.",
);
}
}
void runLogin();
return () => {
cancelled = true;
};
}, [embedParams.username]);
const registerSessionForThread = useCallback(
(resolvedThreadId: string) => {
if (!embedParams.sessionId || lastRegisteredThreadRef.current === resolvedThreadId) {
return;
}
lastRegisteredThreadRef.current = resolvedThreadId;
void registerEmbedSession(embedParams.sessionId, resolvedThreadId).catch(
(error) => {
console.error("[embed] failed to register session mapping", error);
lastRegisteredThreadRef.current = null;
},
);
},
[embedParams.sessionId],
);
const [thread, sendMessage] = useThreadStream({
threadId: authReady && !isNewThread ? threadId : undefined,
context: settings.context,
isMock: false,
onStart: (createdThreadId) => {
setThreadId(createdThreadId);
setIsNewThread(false);
registerSessionForThread(createdThreadId);
history.replaceState(
null,
"",
`${window.location.pathname}${window.location.search}#${EMBED_ROUTE_BASE}/${createdThreadId}`,
);
},
});
useEffect(() => {
if (!authReady || !threadId) {
return;
}
registerSessionForThread(threadId);
}, [authReady, threadId, registerSessionForThread]);
const handleClarificationSubmit = useCallback(
(answer: string) => {
void sendMessage(threadId, { text: answer, files: [] });
},
[sendMessage, threadId],
);
useEffect(() => {
if (!authReady || !embedParams.message || hasAutoSentRef.current) {
return;
}
hasAutoSentRef.current = true;
void sendMessage(threadId, { text: embedParams.message, files: [] });
}, [authReady, embedParams.message, sendMessage, threadId]);
if (!embedParams.sessionId) {
return (
<div className="flex size-full items-center justify-center bg-background p-6 text-center text-sm text-red-600">
缺少参数 sessionId:父页面需在 iframe URL 中传入由己方生成的会话 id,用于查询问答是否结束。
</div>
);
}
if (authError) {
return (
<div className="flex size-full items-center justify-center bg-background p-6 text-sm text-red-600">
登录失败:{authError}
</div>
);
}
if (!authReady) {
return (
<div className="flex size-full items-center justify-center bg-background p-6 text-sm text-muted-foreground">
正在进入问答…
</div>
);
}
return (
<ThreadContext.Provider value={{ thread, isMock: false }}>
<ChatBox threadId={threadId}>
<div className="relative flex size-full min-h-0">
<main className="flex min-h-0 max-w-full grow flex-col">
<div className="flex size-full justify-center">
<MessageList
className={cn("size-full")}
threadId={threadId}
thread={thread}
paddingBottom={MESSAGE_LIST_DEFAULT_PADDING_BOTTOM}
tokenUsageEnabled={tokenUsageEnabled}
onClarificationSubmit={handleClarificationSubmit}
/>
</div>
</main>
</div>
</ChatBox>
</ThreadContext.Provider>
);
}
+20 -4
View File
@@ -8,8 +8,10 @@ import { loginByToken, loginByUsername } from '@/core/auth/api';
import { setStoredAuth } from '@/core/auth';
import {
persistExternalLoginParams,
readExternalLoginParams,
resolveThemeFromQuery,
} from '@/core/auth/external-login';
import { resolveUsernameByOnlineTicket } from '@/core/auth/wujie-ticket-login';
import { isWujieEnv, useWujieProps } from '@/core/embed/wujie';
import {
GENERAL_QA_ROUTE,
@@ -71,6 +73,16 @@ export default function LoginPage() {
// hook 内部已把 ticket/userId/env 持久化到 localStorage(复用 external-login 存储)。
const wujieProps = useWujieProps();
/** onlineTicket:无界 props 优先,其次 URL query,最后读 localStorage */
const onlineTicket = useMemo(() => {
const fromWujie = wujieProps.ticket?.trim() ?? "";
const fromQuery = externalParams.ticket.trim();
if (fromWujie || fromQuery) {
return fromWujie || fromQuery;
}
return readExternalLoginParams().ticket.trim();
}, [wujieProps.ticket, externalParams.ticket]);
// 调试:把无界注入的 props 打印出来(仅在无界子应用环境下,避免独立访问时刷日志)。
useEffect(() => {
if (!isWujieEnv()) return;
@@ -88,11 +100,11 @@ export default function LoginPage() {
// ticket / userId / env 持久化(与 login 请求并行做,互不阻塞)
useEffect(() => {
persistExternalLoginParams({
ticket: externalParams.ticket,
ticket: onlineTicket || externalParams.ticket,
userId: externalParams.userId,
env: externalParams.env,
});
}, [externalParams.ticket, externalParams.userId, externalParams.env]);
}, [onlineTicket, externalParams.ticket, externalParams.userId, externalParams.env]);
useEffect(() => {
let cancelled = false;
@@ -106,13 +118,16 @@ export default function LoginPage() {
// token 换登录:后端用 authToken 调上游接口换出用户名再登录
auth = await loginByToken(authToken);
} else {
if (yUserId) {
let deerflowUsername = resolvedUsername;
if (onlineTicket) {
deerflowUsername = await resolveUsernameByOnlineTicket(onlineTicket);
} else if (yUserId) {
await loginByYUserId(yUserId, toolToken || null);
} else if (toolToken) {
persistToolsToken(toolToken);
}
auth = await loginByUsername(resolvedUsername, gatePassword);
auth = await loginByUsername(deerflowUsername, gatePassword);
}
if (cancelled) {
return;
@@ -142,6 +157,7 @@ export default function LoginPage() {
sidebarEnabled,
yUserId,
toolToken,
onlineTicket,
authToken,
gatePassword,
]);
+15 -4
View File
@@ -125,6 +125,13 @@ const emptyForm = {
html_model_name: "",
};
/** 覆盖 Textarea 默认 field-sizing-content,避免输入过多时撑破弹窗 */
const SCHEDULED_TASK_TEXTAREA_CLASS =
"field-sizing-fixed resize-y overflow-y-auto";
const DESCRIPTION_TEXTAREA_ROWS = 4;
const PROMPT_TEXTAREA_ROWS = 10;
export default function ScheduledTasksPage() {
const { theme } = useTheme();
const darkToastOpts =
@@ -901,8 +908,8 @@ export default function ScheduledTasksPage() {
</Dialog>
<Dialog open={creating || !!editing} onOpenChange={(open) => !open && closeDialog()}>
<DialogContent className="max-w-2xl overflow-hidden">
<DialogHeader className="min-w-0">
<DialogContent className="flex max-h-[85vh] max-w-2xl flex-col gap-4 overflow-hidden sm:max-w-2xl">
<DialogHeader className="min-w-0 shrink-0">
<DialogTitle>{creating ? "新建定时任务" : "编辑定时任务"}</DialogTitle>
</DialogHeader>
<div className="flex min-h-0 max-h-[70vh] min-w-0 flex-col gap-3 overflow-y-auto pr-1">
@@ -914,7 +921,11 @@ export default function ScheduledTasksPage() {
placeholder="任务名称"
/>
<Textarea
className="min-h-16"
rows={DESCRIPTION_TEXTAREA_ROWS}
className={cn(
SCHEDULED_TASK_TEXTAREA_CLASS,
"min-h-[4.5rem] max-h-28",
)}
value={form.description}
onChange={(event) =>
setForm((value) => ({ ...value, description: event.target.value }))
@@ -1109,7 +1120,7 @@ export default function ScheduledTasksPage() {
</>
)}
</div>
<DialogFooter>
<DialogFooter className="shrink-0">
<Button variant="outline" onClick={closeDialog}>
取消
</Button>
@@ -40,6 +40,7 @@ import AIWritingPage from '@/open-canvas/pages/AIWritingPage';
import StudioNotebooksPage from './StudioNotebooksPage';
import StudioNotebookDetailPage from './StudioNotebookDetailPage';
import RoundtablePlanningPage from '@/roundtable-planning/pages/RoundtablePlanningPage';
import BusinessChainEditorPage from '@/roundtable-planning/pages/BusinessChainEditorPage';
import '@/roundtable-planning/styles/roundtable-planning.css';
import { cn } from '@/lib/utils';
@@ -315,6 +316,14 @@ export default function WorkspaceRoutes({
</WorkspaceLayout>
}
/>
<Route
path="roundtable/chains"
element={
<WorkspaceLayout {...layoutProps}>
<BusinessChainEditorPage />
</WorkspaceLayout>
}
/>
<Route
path="scheduled"
element={<Navigate to="/page/workspace/scheduled/tasks" replace />}
@@ -0,0 +1,129 @@
/**
* Backend-persisted business-chain storage for /page/roundtable/planning.
*
* A business chain is a named, reusable, *ordered* list of agents. Persisted
* server-side (MySQL) bound to the logged-in user, so it follows the account
* across devices/sessions. Backend: `/api/roundtable-chains` (see
* `app/gateway/routers/roundtable_chains.py`).
*
* snake_case ⇄ camelCase mapping is collapsed at this boundary (mirroring
* `api/drafts.ts`) so callers stay clean.
*/
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import type { BusinessChain, ChainSeat } from "@/roundtable-planning/lib/business-chain";
/** Fields accepted when creating a chain. */
export interface ChainCreatePayload {
/** Optional stable id so the client can keep one id across create + updates. */
id?: string;
title: string;
description?: string;
seats: ChainSeat[];
}
/** Partial update — send only what changed. */
export interface ChainUpdatePayload {
title?: string;
description?: string;
seats?: ChainSeat[];
}
const BASE = () => `${getBackendBaseURL()}/api/roundtable-chains`;
async function readJson<T>(res: Response, ctx: string): Promise<T> {
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`${ctx} failed (${res.status}): ${text}`);
}
return (await res.json()) as T;
}
// ── server ⇄ client field mapping ──────────────────────────────────────────
// Backend uses snake_case (created_at / updated_at); the rest of the frontend
// reads camelCase. `seats` is already camel-clean ({agent_id, name, description}).
interface ServerChain {
id: string;
title: string;
description: string | null;
seats: ChainSeat[];
created_at: string | null;
updated_at: string | null;
}
function toChain(s: ServerChain): BusinessChain {
return {
id: s.id,
title: s.title ?? "",
description: s.description ?? undefined,
seats: Array.isArray(s.seats) ? s.seats : [],
createdAt: s.created_at ?? undefined,
updatedAt: s.updated_at ?? undefined,
};
}
function fromCreate(p: ChainCreatePayload) {
return {
...(p.id ? { id: p.id } : {}),
title: p.title,
...(p.description !== undefined ? { description: p.description } : {}),
seats: p.seats,
};
}
function fromUpdate(p: ChainUpdatePayload) {
const out: Record<string, unknown> = {};
if (p.title !== undefined) out.title = p.title;
if (p.description !== undefined) out.description = p.description;
if (p.seats !== undefined) out.seats = p.seats;
return out;
}
// ── CRUD ────────────────────────────────────────────────────────────────────
/** List the current user's chains, newest first. */
export async function listChains(): Promise<BusinessChain[]> {
const res = await apiFetch(BASE(), { method: "GET" });
const data = await readJson<{ chains: ServerChain[] }>(res, "list chains");
return (data.chains ?? []).map(toChain);
}
/** Fetch one chain. Returns null on 404. */
export async function getChain(id: string): Promise<BusinessChain | null> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}`, { method: "GET" });
if (res.status === 404) return null;
const data = await readJson<ServerChain>(res, "get chain");
return toChain(data);
}
export async function createChain(payload: ChainCreatePayload): Promise<BusinessChain> {
const res = await apiFetch(BASE(), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(fromCreate(payload)),
});
const data = await readJson<ServerChain>(res, "create chain");
return toChain(data);
}
export async function updateChain(id: string, patch: ChainUpdatePayload): Promise<BusinessChain | null> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(fromUpdate(patch)),
});
if (res.status === 404) return null;
const data = await readJson<ServerChain>(res, "update chain");
return toChain(data);
}
export async function deleteChain(id: string): Promise<void> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}`, { method: "DELETE" });
if (!res.ok && res.status !== 404) {
const text = await res.text().catch(() => "");
throw new Error(`delete chain failed (${res.status}): ${text}`);
}
}
@@ -45,6 +45,31 @@ export interface DraftStep2Snapshot {
budgetLimit: number;
/** Per-seat stub AI messages for the sandbox `write-file:` virtual URL loader. */
seatStubMessages?: Record<string, unknown[]>;
/**
* Step 2 orchestration mode (2026-06 business chains). recommend = leader
* dispatches freely; chain = dispatch follows the saved business chain order.
* Optional — drafts saved before this won't carry it (hydrate defaults to
* "recommend").
*/
orchestrationMode?: "recommend" | "chain";
/** Source business chain (display only), when `orchestrationMode === "chain"`. */
chain?: { id: string; title: string } | null;
}
/**
* Step 3「结果绘制」snapshot — the generated visualization report, stored inline
* so a reloaded draft can show it without depending on the ephemeral report
* thread. Optional — drafts saved before this feature (or never drawn) won't carry it.
*/
export interface DraftStep3Snapshot {
/** The full self-contained HTML written to outputs/方案总览.html. */
html: string;
/** ISO timestamp when the report was generated. */
generatedAt: string;
/** The inference model used to draw it. */
model: string;
/** The agent's closing one-liner (rendered as the report message bubble). */
summary?: string;
}
/** Lightweight metadata row returned by the list endpoint. */
@@ -61,6 +86,7 @@ export interface DraftMeta {
export interface DraftRecord extends DraftMeta {
step1: DraftStep1Snapshot | null;
step2: DraftStep2Snapshot | null;
step3: DraftStep3Snapshot | null;
}
/** Fields accepted when creating a draft. */
@@ -71,6 +97,7 @@ export interface DraftCreatePayload {
furthestStep: 1 | 2 | 3;
step1: DraftStep1Snapshot;
step2: DraftStep2Snapshot;
step3?: DraftStep3Snapshot | null;
}
/** Partial update — send only what changed. Omitting `title` preserves a rename. */
@@ -79,6 +106,7 @@ export interface DraftUpdatePayload {
furthestStep?: 1 | 2 | 3;
step1?: DraftStep1Snapshot;
step2?: DraftStep2Snapshot;
step3?: DraftStep3Snapshot | null;
}
const BASE = () => `${getBackendBaseURL()}/api/roundtable-drafts`;
@@ -106,6 +134,7 @@ interface ServerDraftMeta {
interface ServerDraftFull extends ServerDraftMeta {
step1: DraftStep1Snapshot | null;
step2: DraftStep2Snapshot | null;
step3: DraftStep3Snapshot | null;
}
function toMeta(s: ServerDraftMeta): DraftMeta {
@@ -120,7 +149,12 @@ function toMeta(s: ServerDraftMeta): DraftMeta {
}
function toRecord(s: ServerDraftFull): DraftRecord {
return { ...toMeta(s), step1: s.step1 ?? null, step2: s.step2 ?? null };
return {
...toMeta(s),
step1: s.step1 ?? null,
step2: s.step2 ?? null,
step3: s.step3 ?? null,
};
}
function fromCreate(p: DraftCreatePayload) {
@@ -130,6 +164,7 @@ function fromCreate(p: DraftCreatePayload) {
furthest_step: p.furthestStep,
step1: p.step1,
step2: p.step2,
...(p.step3 !== undefined ? { step3: p.step3 } : {}),
};
}
@@ -139,6 +174,7 @@ function fromUpdate(p: DraftUpdatePayload) {
if (p.furthestStep !== undefined) out.furthest_step = p.furthestStep;
if (p.step1 !== undefined) out.step1 = p.step1;
if (p.step2 !== undefined) out.step2 = p.step2;
if (p.step3 !== undefined) out.step3 = p.step3;
return out;
}
@@ -747,3 +747,34 @@ export function buildArtifactDownloadUrl(
const base = `${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}/artifacts/${segments}`;
return options.download ? `${base}?download=true` : base;
}
export interface ReportThreadResponse {
/** Always the built-in singleton id `roundtable-report`. */
agentName: string;
/** Freshly created lightweight thread to run the report agent on. */
threadId: string;
}
/**
* Create a thread for the Step 3「结果绘制」visualization agent
* (`roundtable-report`, a built-in singleton — this only spins up a cheap
* thread, it never re-creates the agent). Run it afterwards with
* {@link streamMultiAgent} (`agentType: "special"`,
* `agentName: "roundtable-report"`, `dAgentThreadId: { [agentName]: threadId }`).
*/
export async function initReportThread(
options: { signal?: AbortSignal } = {},
): Promise<ReportThreadResponse> {
const res = await apiFetch(`${getBackendBaseURL()}/api/multi-agent/report/init`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{}",
signal: options.signal,
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`init report thread failed (${res.status}): ${text}`);
}
const json = (await res.json()) as { agent_name: string; thread_id: string };
return { agentName: json.agent_name, threadId: json.thread_id };
}
@@ -81,6 +81,8 @@ const EXCLUDED_AGENT_IDS = new Set<string>([
"roundtable-intent",
"roundtable-recommender",
"roundtable-coordinator",
// Step 3「结果绘制」的方案可视化总结智能体,是内置功能型 agent,不应作为可选席位。
"roundtable-report",
]);
/**
@@ -0,0 +1,266 @@
/**
* 业务链条选择面板(推荐弹窗「业务链条」模式的内容区)。
*
* 拉取当前用户的业务链条(`listChains()`),渲染卡片列表:
* - 卡片标题(链条 title)+ 存活席位计数。
* - 标题下一排**智能体名 tag**:按链条顺序从左到右排列、不同颜色(`getAvatarTypeFor`
* → `chainTagClasses`),tag 之间用 `→`(ArrowRight)箭头连接表达发言次序。
* - 链条里有 agent 已被删除 → 该 tag 置灰删除线 + 提示;卡片只用**存活**席位,
* 存活不足 `CHAIN_MIN_SEATS` 则不可使用。
*
* 选中一条 → 高亮;底部「进入研讨」回传 `SeatSelectionResult{ mode:"chain" }`,
* 由父弹窗的 `onConfirm` 透传给主页面(席位顺序 = 链条顺序)。
*
* 详见 `docs/multi-agent-business-chain-dev.md` §6.2 / §7 阶段 3。
*/
import { Fragment, useEffect, useMemo, useState } from "react";
import {
ArrowRightIcon,
CheckIcon,
LayersIcon,
Loader2Icon,
SettingsIcon,
TriangleAlertIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { DialogFooter } from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import { listAgents } from "@/core/agents";
import { filterRecommendCandidates } from "../api/recommend";
import { listChains } from "../api/chains";
import {
CHAIN_MIN_SEATS,
chainTagClasses,
type BusinessChain,
type SeatSelectionResult,
} from "../lib/business-chain";
import { getAvatarTypeFor } from "../lib/roundtable-constants";
export interface BusinessChainPickerProps {
onConfirm: (result: SeatSelectionResult) => void;
onCancel: () => void;
/** 跳转到业务链条配置页(同时关闭弹窗)。 */
onGoConfig: () => void;
}
export function BusinessChainPicker({
onConfirm,
onCancel,
onGoConfig,
}: BusinessChainPickerProps) {
const [chains, setChains] = useState<BusinessChain[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
// null = 候选池未知(agent 列表加载失败)→ 不把任何席位判成「已删除」。
const [existingIds, setExistingIds] = useState<Set<string> | null>(null);
const [nameById, setNameById] = useState<Map<string, string>>(new Map());
useEffect(() => {
let cancelled = false;
void (async () => {
try {
const [chainList, agents] = await Promise.all([
listChains(),
listAgents().catch(() => null), // agent 列表失败不阻断链条展示
]);
if (cancelled) return;
setChains(chainList);
if (agents) {
const candidates = filterRecommendCandidates(agents);
setExistingIds(new Set(candidates.map((c) => c.agent_id)));
setNameById(new Map(candidates.map((c) => [c.agent_id, c.name])));
} else {
setExistingIds(null);
}
} catch (err) {
if (cancelled) return;
setError(err instanceof Error ? err.message : String(err));
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, []);
const isExisting = useMemo(
() => (agentId: string) => existingIds === null || existingIds.has(agentId),
[existingIds],
);
const selectedChain = useMemo(
() => chains.find((c) => c.id === selectedId) ?? null,
[chains, selectedId],
);
const survivingSeats = useMemo(
() => (selectedChain ? selectedChain.seats.filter((s) => isExisting(s.agent_id)) : []),
[selectedChain, isExisting],
);
const canUse = !!selectedChain && survivingSeats.length >= CHAIN_MIN_SEATS;
const handleUse = () => {
if (!canUse || !selectedChain) return;
const agents = survivingSeats.map((s) => ({
agent_id: s.agent_id,
name: nameById.get(s.agent_id) ?? s.name,
}));
onConfirm({
agents,
mode: "chain",
chain: { id: selectedChain.id, title: selectedChain.title },
});
};
return (
<>
<section className="flex-1 min-h-0 flex flex-col overflow-hidden">
<div className="min-h-0 flex-1 overflow-y-auto custom-scrollbar pr-1 -mr-1">
{loading ? (
<div className="flex items-center gap-2 px-1 py-8 text-sm text-muted-foreground">
<Loader2Icon className="size-4 animate-spin" />
正在加载业务链条…
</div>
) : error ? (
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
加载业务链条失败:{error}
</div>
) : chains.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center gap-3 px-6 py-10 text-center">
<LayersIcon className="size-10 text-primary/40" />
<div className="text-sm font-semibold">还没有业务链条</div>
<p className="max-w-sm text-xs leading-relaxed text-muted-foreground">
业务链条是可复用的有序智能体编排。先去配置页面创建第一条链条,
之后即可在这里选择使用。
</p>
<Button variant="outline" size="sm" onClick={onGoConfig} className="gap-1.5">
<SettingsIcon className="size-4" />
去配置业务链条
</Button>
</div>
) : (
<div className="space-y-2.5 py-0.5">
{chains.map((chain) => (
<ChainCard
key={chain.id}
chain={chain}
selected={chain.id === selectedId}
isExisting={isExisting}
nameById={nameById}
onSelect={() => setSelectedId(chain.id)}
/>
))}
</div>
)}
</div>
</section>
<DialogFooter className="shrink-0 flex-row sm:justify-between items-center gap-2 flex-wrap border-t pt-3 mt-1">
<Button variant="ghost" size="sm" onClick={onGoConfig} className="h-8 gap-1.5 text-xs">
<SettingsIcon className="size-3.5" />
配置链条
</Button>
<div className="flex items-center gap-2">
{selectedChain && !canUse && (
<span className="text-xs text-destructive">存活席位不足 {CHAIN_MIN_SEATS} 个</span>
)}
<Button variant="outline" onClick={onCancel}>
取消
</Button>
<Button onClick={handleUse} disabled={!canUse}>
进入研讨
</Button>
</div>
</DialogFooter>
</>
);
}
// ── 单张链条卡片 ─────────────────────────────────────────────────────────
function ChainCard({
chain,
selected,
isExisting,
nameById,
onSelect,
}: {
chain: BusinessChain;
selected: boolean;
isExisting: (agentId: string) => boolean;
nameById: Map<string, string>;
onSelect: () => void;
}) {
const survivingCount = chain.seats.filter((s) => isExisting(s.agent_id)).length;
const deletedCount = chain.seats.length - survivingCount;
const usable = survivingCount >= CHAIN_MIN_SEATS;
return (
<button
type="button"
onClick={onSelect}
className={cn(
"w-full rounded-xl border p-3 text-left transition-all",
selected
? "border-primary bg-primary/[0.06] ring-1 ring-primary/40"
: "border-border bg-card hover:border-primary/40 hover:bg-primary/[0.03]",
)}
>
{/* 标题行 */}
<div className="flex items-center gap-2">
<div className="truncate text-sm font-semibold">{chain.title || "未命名链条"}</div>
{selected && (
<span className="flex size-4 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground">
<CheckIcon className="size-3" />
</span>
)}
<span className="ml-auto shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-medium tabular-nums text-muted-foreground">
{chain.seats.length} 席
</span>
</div>
{chain.description && (
<div className="mt-1 line-clamp-1 text-xs text-muted-foreground">{chain.description}</div>
)}
{/* 智能体名 tag(按顺序,← 箭头连接) */}
<div className="mt-2 flex flex-wrap items-center gap-1">
{chain.seats.map((seat, i) => {
const alive = isExisting(seat.agent_id);
const label = nameById.get(seat.agent_id) ?? seat.name ?? seat.agent_id;
return (
<Fragment key={seat.agent_id}>
{i > 0 && (
<ArrowRightIcon className="size-3 shrink-0 text-muted-foreground/50" />
)}
<span
className={cn(
"rounded border px-1.5 py-0.5 text-[11px] font-medium",
alive
? chainTagClasses(getAvatarTypeFor(seat.agent_id, i))
: "border-dashed border-border bg-muted text-muted-foreground/60 line-through",
)}
title={alive ? undefined : "该智能体已不存在"}
>
{label}
</span>
</Fragment>
);
})}
</div>
{/* 删除提示 */}
{deletedCount > 0 && (
<div className="mt-2 flex items-center gap-1 text-[11px] text-amber-600 dark:text-amber-400">
<TriangleAlertIcon className="size-3 shrink-0" />
{usable
? `${deletedCount} 个智能体已删除,将按存活 ${survivingCount} 个席位研讨`
: `${deletedCount} 个智能体已删除,存活不足 ${CHAIN_MIN_SEATS} 个,暂不可用`}
</div>
)}
</button>
);
}
@@ -11,6 +11,7 @@ import {
Loader2,
Printer,
RefreshCw,
Sparkles,
} from "lucide-react";
import {
@@ -20,6 +21,13 @@ import {
type ThreadIdMap,
} from "@/roundtable-planning/api/multi-agent";
import { ArtifactPreviewModal } from "@/roundtable-planning/components/ArtifactPreviewModal";
import { markdownComponents } from "@/roundtable-planning/components/markdown-components";
import {
Step3ReportMode,
type ModelOption,
} from "@/roundtable-planning/components/Step3ReportMode";
import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts";
import type { ReportIntent } from "@/roundtable-planning/hooks/useStep3Report";
/**
* Text-ish files we render in an in-app preview modal. Other types (pdf,
@@ -55,6 +63,18 @@ interface HighFidelityReportProps {
seatDeliveries: SeatDelivery[];
/** lowercase agent name → display name (e.g. "roundtable-intelligence" → "情报收集"). */
seatNameMap: Record<string, string>;
/** Step 1 整理出的结构化意图 —— 喂给「结果绘制」的方案可视化智能体。 */
intentReady: ReportIntent | null;
/** 当前推理模型 —— 跑可视化智能体用。 */
selectedModel: string;
/** 可选模型列表 + 加载态 + 切换器 —— 结果绘制右侧的模型选择(对齐第 2 步)。 */
availableModels: ReadonlyArray<ModelOption>;
modelsLoading: boolean;
setSelectedModel: (model: string) => void;
/** 草稿里已存的上次绘制结果 —— 透传给「结果绘制」恢复展示。 */
savedReport?: DraftStep3Snapshot | null;
/** 一次绘制完成时回调,把结果交给上层存进草稿 step3。 */
onReportSaved?: (snapshot: DraftStep3Snapshot) => void;
/** Resets state and navigates back to Step 1. */
onReset: () => void;
}
@@ -80,46 +100,6 @@ function formatGeneratedAt(d: Date): string {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
const markdownComponents = {
p: ({ children }: any) => <p className="my-1.5 whitespace-pre-wrap leading-relaxed">{children}</p>,
ul: ({ children }: any) => <ul className="list-disc pl-5 my-1.5 space-y-1">{children}</ul>,
ol: ({ children }: any) => <ol className="list-decimal pl-5 my-1.5 space-y-1">{children}</ol>,
li: ({ children }: any) => <li className="leading-snug">{children}</li>,
h1: ({ children }: any) => <h1 className="text-sm font-bold mt-3 mb-1.5">{children}</h1>,
h2: ({ children }: any) => <h2 className="text-sm font-bold mt-3 mb-1.5">{children}</h2>,
h3: ({ children }: any) => <h3 className="text-xs font-bold mt-2 mb-1">{children}</h3>,
h4: ({ children }: any) => <h4 className="text-xs font-bold mt-1.5 mb-1">{children}</h4>,
strong: ({ children }: any) => <strong className="font-bold text-slate-900">{children}</strong>,
em: ({ children }: any) => <em className="italic">{children}</em>,
code: ({ children, className }: any) => {
const isBlock = /language-/.test(className ?? "");
if (isBlock) {
return (
<pre className="bg-slate-100 text-slate-800 rounded-md p-2 my-1 overflow-x-auto text-[11px] font-mono">
<code>{children}</code>
</pre>
);
}
return (
<code className="bg-slate-100 text-slate-800 rounded px-1 py-0.5 text-[11px] font-mono">{children}</code>
);
},
blockquote: ({ children }: any) => (
<blockquote className="border-l-2 border-slate-300 pl-2 my-1 text-slate-600 italic">{children}</blockquote>
),
a: ({ children, href }: any) => (
<a href={href} target="_blank" rel="noopener noreferrer" className="text-primary underline hover:opacity-80">
{children}
</a>
),
table: ({ children }: any) => <table className="my-2 border-collapse text-[11px]">{children}</table>,
th: ({ children }: any) => (
<th className="border border-slate-300 px-1.5 py-0.5 bg-slate-50 font-bold text-left">{children}</th>
),
td: ({ children }: any) => <td className="border border-slate-200 px-1.5 py-0.5">{children}</td>,
hr: () => <hr className="my-2 border-slate-200" />,
};
export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
threadIds,
coordinatorName,
@@ -127,8 +107,17 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
hasConsensus,
seatDeliveries,
seatNameMap,
intentReady,
selectedModel,
availableModels,
modelsLoading,
setSelectedModel,
savedReport,
onReportSaved,
onReset,
}) => {
// 「结果绘制」模式:把现有报告内容收起到左栏,右侧由方案可视化智能体绘制图表 HTML。
const [drawMode, setDrawMode] = useState(false);
// Build the per-seat list (excludes the coordinator). useMemo so the file
// fetch effect doesn't re-fire on every parent render.
const seatThreadEntries = useMemo(() => {
@@ -253,6 +242,27 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
[],
);
if (drawMode) {
return (
<Step3ReportMode
threadIds={threadIds}
coordinatorName={coordinatorName}
consensusText={consensusText}
hasConsensus={hasConsensus}
seatDeliveries={seatDeliveries}
seatNameMap={seatNameMap}
intentReady={intentReady}
selectedModel={selectedModel}
availableModels={availableModels}
modelsLoading={modelsLoading}
setSelectedModel={setSelectedModel}
savedReport={savedReport}
onReportSaved={onReportSaved}
onBack={() => setDrawMode(false)}
/>
);
}
return (
<div className="flex-1 flex flex-col min-h-0 overflow-y-auto text-slate-800">
{/* Header */}
@@ -275,6 +285,40 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
</div>
<div className="flex items-center gap-2">
<select
id="step3-report-model-select-entry"
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
disabled={modelsLoading || availableModels.length === 0}
title={
modelsLoading
? "正在加载模型列表…"
: availableModels.length === 0
? "未在 config.yaml 中配置任何模型"
: "切换结果绘制使用的推理模型"
}
className="shrink-0 max-w-[180px] bg-slate-50/50 border border-slate-200 rounded-xl px-2.5 py-1.5 text-[10.5px] font-bold text-slate-700 cursor-pointer focus:outline-none focus:border-primary focus:ring-1 focus:ring-[var(--brand-200)]/50 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-secondary dark:text-foreground dark:border-border"
>
{availableModels.length === 0 && (
<option value="">{modelsLoading ? "加载中…" : "未配置模型"}</option>
)}
{availableModels.map((m) => (
<option key={m.name} value={m.name}>
{m.display_name && m.display_name !== m.name
? `${m.display_name} (${m.name})`
: m.name}
</option>
))}
</select>
<button
type="button"
onClick={() => setDrawMode(true)}
title="把研讨成果交给方案总结智能体,绘制带图表的可视化看板"
className="bg-primary text-white hover:bg-[var(--brand-900)] text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition cursor-pointer shadow-sm shadow-primary/10"
>
<Sparkles className="w-3.5 h-3.5" />
结果绘制
</button>
<button
type="button"
onClick={() => {
@@ -368,8 +412,8 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
)}
</section>
{/* Section 3: Generated files per seat —— 暂时隐藏(无真实产出文件链路) */}
<section className="hidden bg-white border border-slate-200 rounded-2xl p-5 shadow-[0_2px_8px_rgba(0,0,0,0.02)]">
{/* Section 3: Generated files per seat —— 各席位产出的 md 文件,点击预览弹窗查看内容 */}
<section className="bg-white border border-slate-200 rounded-2xl p-5 shadow-[0_2px_8px_rgba(0,0,0,0.02)]">
<h4 className="text-xs font-black text-slate-900 uppercase tracking-widest mb-3 flex items-center gap-2">
<Download className="w-4 h-4 text-primary" />
生成的报告文件
@@ -1,10 +1,12 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
CheckIcon,
ChevronDownIcon,
ChevronUpIcon,
LayersIcon,
Loader2Icon,
RefreshCwIcon,
SparklesIcon,
@@ -29,7 +31,9 @@ import {
StreamingCursor,
} from "./MessageBubble";
import { MessageStepsCard } from "./MessageStepsCard";
import { BusinessChainPicker } from "./BusinessChainPicker";
import type { IntentSummary } from "../api/intent";
import type { SeatSelectionResult } from "../lib/business-chain";
import {
filterRecommendCandidates,
listRecommendHistory,
@@ -68,8 +72,12 @@ export interface RecommendAgentsDialogProps {
*/
draftId?: string | null;
onCancel: () => void;
/** Called with the user's final selection of agent ids (in display order). */
onConfirm: (agents: Array<{ agent_id: string; name: string }>) => void;
/**
* Called with the user's final seat selection + the orchestration mode.
* 智能推荐模式回传 `{ agents, mode: "recommend" }`;业务链条模式(阶段 3)
* 回传 `{ agents, mode: "chain", chain }`。
*/
onConfirm: (result: SeatSelectionResult) => void;
}
const MIN_SELECTED = 2;
@@ -189,6 +197,9 @@ export function RecommendAgentsDialog({
// 映射也作用于弹窗;浅色不挂,弹窗浅色外观不变。
const { theme } = useTheme();
const isDark = theme === "dark";
const navigate = useNavigate();
// 席位来源模式:智能推荐 / 业务链条。默认**业务链条**。
const [mode, setMode] = useState<"recommend" | "chain">("chain");
const [state, setState] = useState<InternalState>(INITIAL_STATE);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
@@ -442,18 +453,27 @@ export function RecommendAgentsDialog({
[intent, draftId, cancelInflight, analyze, applyHistory],
);
// Trigger the pipeline whenever the dialog opens. Closing it aborts any
// in-flight stream so the next open starts clean.
// 推荐管线只在「智能推荐」模式下跑一次。默认是业务链条模式 —— 此时不触发推荐
// 模型调用,等用户切到「智能推荐」tab 才启动;每次 open 内只跑一次(切 tab 来回
// 不重复跑),关闭时复位 latch。
const recommendStartedRef = useRef(false);
// 关闭 / 卸载时中止 in-flight 推荐流。
useEffect(() => {
if (!open) {
cancelInflight();
return;
}
void start(false);
return () => {
cancelInflight();
};
}, [open, start, cancelInflight]);
}, [cancelInflight]);
useEffect(() => {
if (!open) {
recommendStartedRef.current = false;
cancelInflight();
return;
}
if (mode === "recommend" && !recommendStartedRef.current) {
recommendStartedRef.current = true;
void start(false);
}
}, [open, mode, start, cancelInflight]);
const handleToggle = useCallback((agentId: string) => {
setSelectedIds((prev) => {
@@ -510,9 +530,14 @@ export function RecommendAgentsDialog({
for (const c of otherCards) {
if (selectedIds.has(c.agent_id)) ordered.push({ agent_id: c.agent_id, name: c.name });
}
onConfirm(ordered);
onConfirm({ agents: ordered, mode: "recommend" });
}, [canConfirm, recommendedCards, otherCards, selectedIds, onConfirm]);
const handleGoConfig = useCallback(() => {
onCancel();
navigate("/page/workspace/roundtable/chains");
}, [navigate, onCancel]);
const handleOpenChange = useCallback(
(next: boolean) => {
if (!next) onCancel();
@@ -534,10 +559,45 @@ export function RecommendAgentsDialog({
选择参会智能体
</DialogTitle>
<DialogDescription>
根据你的任务目标,系统为你推荐了最合适的智能体。你可以取消勾选,也可以从下方添加其他智能体。
{mode === "recommend"
? "根据你的任务目标,系统为你推荐了最合适的智能体。你可以取消勾选,也可以从下方添加其他智能体。"
: "从已配置的业务链条中选择一条,Step 2 将按链条顺序进行多智能体研讨。"}
</DialogDescription>
</DialogHeader>
{/* 模式选择:智能推荐 / 业务链条 */}
<div className="shrink-0 inline-flex self-start rounded-lg border border-border bg-muted/40 p-0.5 text-xs font-medium">
<button
type="button"
onClick={() => setMode("recommend")}
className={cn(
"flex items-center gap-1.5 rounded-md px-3 py-1.5 transition-colors",
mode === "recommend"
? "bg-card text-primary shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
<SparklesIcon className="size-3.5" />
智能推荐
</button>
<button
type="button"
onClick={() => setMode("chain")}
className={cn(
"flex items-center gap-1.5 rounded-md px-3 py-1.5 transition-colors",
mode === "chain"
? "bg-card text-primary shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
<LayersIcon className="size-3.5" />
业务链条
</button>
</div>
{/* ===== 智能推荐模式(现状,零行为变更)===== */}
{mode === "recommend" && (
<>
{/* ============================================================
TOP: 模型生成内容
包含状态行 + 步骤卡 + 思考过程(`<think>`)+ 推荐理由 markdown。
@@ -684,6 +744,17 @@ export function RecommendAgentsDialog({
</Button>
</div>
</DialogFooter>
</>
)}
{/* ===== 业务链条模式 ===== */}
{mode === "chain" && (
<BusinessChainPicker
onConfirm={onConfirm}
onCancel={onCancel}
onGoConfig={handleGoConfig}
/>
)}
</DialogContent>
</Dialog>
);
@@ -407,7 +407,7 @@ export function Step2Panel(props: Step2PanelProps) {
*
* 「插话干预」气泡有特殊红底,通过 containerClassName 传给 MessageBubble。
*/
interface Step2MessageProps {
export interface Step2MessageProps {
msg: Step2Dialogue;
/** 把派活 chip 的 agent id 解析成中文席位名。 */
resolveAgentName?: (idOrName: string) => string;
@@ -418,7 +418,7 @@ interface Step2MessageProps {
onFileCardClick?: (path: string) => void;
}
function Step2Message({
export function Step2Message({
msg,
resolveAgentName,
onArtifactStepClick,
@@ -8,7 +8,10 @@
* 拉取 / 文本类预览 / window.open 下载 / 「重新起草」按钮等。
*/
import { HighFidelityReport } from "@/roundtable-planning/components/HighFidelityReport";
import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts";
import type { ModelOption } from "@/roundtable-planning/components/Step3ReportMode";
import type { Step2Dialogue } from "@/roundtable-planning/hooks/useStep2Orchestration";
import type { ReportIntent } from "@/roundtable-planning/hooks/useStep3Report";
import type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent";
export interface Step3PanelProps {
@@ -21,6 +24,16 @@ export interface Step3PanelProps {
string,
{ speakerId: number; avatarType: string; displayName: string }
>;
/** Step 1 结构化意图 + 当前模型 —— 透传给「结果绘制」的方案可视化智能体。 */
intentReady: ReportIntent | null;
selectedModel: string;
/** 可选模型列表 + 加载态 + 切换器 —— 透传给「结果绘制」的模型选择。 */
availableModels: ReadonlyArray<ModelOption>;
modelsLoading: boolean;
setSelectedModel: (model: string) => void;
/** 草稿里已存的上次绘制结果 + 完成回调 —— 透传给「结果绘制」做持久化。 */
savedReport?: DraftStep3Snapshot | null;
onReportSaved?: (snapshot: DraftStep3Snapshot) => void;
onReset: () => void;
}
@@ -32,6 +45,13 @@ export function Step3Panel(props: Step3PanelProps) {
hasConsensus,
step2RoundtableDialogues,
agentNameToRole,
intentReady,
selectedModel,
availableModels,
modelsLoading,
setSelectedModel,
savedReport,
onReportSaved,
onReset,
} = props;
@@ -55,6 +75,13 @@ export function Step3Panel(props: Step3PanelProps) {
hasConsensus={hasConsensus}
seatDeliveries={seatDeliveries}
seatNameMap={seatNameMap}
intentReady={intentReady}
selectedModel={selectedModel}
availableModels={availableModels}
modelsLoading={modelsLoading}
setSelectedModel={setSelectedModel}
savedReport={savedReport}
onReportSaved={onReportSaved}
onReset={onReset}
/>
);
@@ -0,0 +1,790 @@
/**
* Step 3「结果绘制」模式。
*
* 布局对齐第 2 步:
* - 左栏(~300px,第 2 步白卡风格):把原本的最终方案内容「收起」成三张源卡
* (总控结论 / 各席位交付 / 各席位生成文件),点击才弹窗展开。
* - 右栏:复用 `Step2SandboxLayout`(与主聊天同一套沙箱)。左 60% 显示方案总结
* 智能体的流式绘制进度;右 40% 沙箱在它写 HTML 时显示代码、写完自动切 HTML 预览。
*
* 进入即自动开跑(`useStep3Report.start`),右上角「重新绘制」可重跑。
*/
import { useEffect, useMemo, useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import {
ArrowLeft,
CheckCircle,
Download,
Eye,
FileText,
ListOrdered,
Loader2,
PanelLeftClose,
PanelLeftOpen,
RefreshCw,
Sparkles,
X,
} from "lucide-react";
import { useArtifacts } from "@/components/workspace/artifacts";
import { cn } from "@/lib/utils";
import {
buildArtifactDownloadUrl,
listSessionArtifacts,
type SessionArtifactFile,
type ThreadIdMap,
} from "@/roundtable-planning/api/multi-agent";
import { ArtifactPreviewModal } from "@/roundtable-planning/components/ArtifactPreviewModal";
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
import { Step2Message } from "@/roundtable-planning/components/Step2Panel";
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
import { markdownComponents } from "@/roundtable-planning/components/markdown-components";
import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts";
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
import {
useStep3Report,
type ReportIntent,
} from "@/roundtable-planning/hooks/useStep3Report";
interface SeatDelivery {
sender: string;
avatarType: string;
content: string;
}
export interface ModelOption {
name: string;
display_name?: string;
}
interface Step3ReportModeProps {
threadIds: ThreadIdMap | null;
coordinatorName: string | null;
consensusText: string;
hasConsensus: boolean;
seatDeliveries: SeatDelivery[];
seatNameMap: Record<string, string>;
intentReady: ReportIntent | null;
selectedModel: string;
availableModels: ReadonlyArray<ModelOption>;
modelsLoading: boolean;
setSelectedModel: (model: string) => void;
/** 草稿里已存的上次绘制结果 —— 有则进入时恢复展示,不重新绘制。 */
savedReport?: DraftStep3Snapshot | null;
/** 一次绘制完成时回调,把结果交给上层存进草稿 step3。 */
onReportSaved?: (snapshot: DraftStep3Snapshot) => void;
onBack: () => void;
}
type SourceModal = "consensus" | "seats" | "files" | null;
export function Step3ReportMode({
threadIds,
coordinatorName,
consensusText,
hasConsensus,
seatDeliveries,
seatNameMap,
intentReady,
selectedModel,
availableModels,
modelsLoading,
setSelectedModel,
savedReport,
onReportSaved,
onBack,
}: Step3ReportModeProps) {
const reportSeatDeliveries = useMemo(
() => seatDeliveries.map((d) => ({ sender: d.sender, content: d.content })),
[seatDeliveries],
);
const {
phase,
error,
reportThreadId,
dialogues,
seatStubMessages,
activeArtifactThreadId,
start,
hydrateSaved,
redraw,
abort,
openArtifactFromStep,
openArtifactPreview,
} = useStep3Report({
intentReady,
seatDeliveries: reportSeatDeliveries,
consensusText,
selectedModel,
savedReport,
onReportSaved,
});
const [modal, setModal] = useState<SourceModal>(null);
// 左栏「研讨原料」收起态:打开沙箱时默认收起成图标条(对齐第 2 步侧栏收起),
// 关闭沙箱时自动展开;中途用户可手动切换(effect 只在沙箱开关变化的边沿触发)。
const { open: artifactsOpen } = useArtifacts();
const [leftCollapsed, setLeftCollapsed] = useState(false);
const prevArtifactsOpenRef = useRef(artifactsOpen);
useEffect(() => {
if (artifactsOpen !== prevArtifactsOpenRef.current) {
prevArtifactsOpenRef.current = artifactsOpen;
setLeftCollapsed(artifactsOpen);
}
}, [artifactsOpen]);
// 进入即自动开跑;卸载(含返回普通视图)中断在途请求。
// 不加"永久闸门"——否则 StrictMode 的 mount→unmount→remount 会在 cleanup 里 abort
// 掉首次 run,remount 又因闸门跳过重启,导致永远不绘制。run() 自带 runningRef 去重,
// remount 重新 start() 即可(开发期至多多建一个空线程,生产无 StrictMode 只跑一次)。
// 若草稿里已存上次绘制结果(savedReport)→ 直接恢复展示,不重新跑模型;用户可点「重新绘制」覆盖。
useEffect(() => {
if (savedReport?.html) {
hydrateSaved(savedReport);
} else {
start();
}
return () => abort();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 与第 2 步一致:编排中或有任意气泡仍在流式 → 沙箱开启 artifacts 轮询 + HTML 显示代码态。
const isStreaming =
phase === "creating" ||
phase === "streaming" ||
dialogues.some((d) => d.streaming === true);
const activeSeatMessages = activeArtifactThreadId
? seatStubMessages[activeArtifactThreadId]
: undefined;
const statusLabel =
phase === "creating"
? "正在初始化绘制线程…"
: phase === "streaming"
? "方案总结智能体绘制中…"
: phase === "done"
? "绘制完成"
: phase === "error"
? "绘制失败"
: "待绘制";
return (
<div className="flex h-full min-h-0 flex-col text-slate-800">
{/* 顶栏 */}
<div className="shrink-0 flex items-center justify-between gap-3 border-b border-slate-100 bg-gradient-to-r from-slate-50 to-white px-6 py-3.5 flex-wrap">
<div className="flex items-center gap-2.5">
<Sparkles className="w-5 h-5 text-primary" />
<h3 className="text-sm font-black text-slate-900 tracking-tight">结果绘制 · 方案可视化</h3>
<span className="inline-flex items-center gap-1.5 text-[10px] font-bold text-primary bg-[var(--brand-200)]/60 border border-red-200/50 px-2 py-0.5 rounded-full">
{(phase === "creating" || phase === "streaming") && (
<Loader2 className="w-3 h-3 animate-spin" />
)}
{phase === "done" && <CheckCircle className="w-3 h-3 text-emerald-600" />}
{statusLabel}
</span>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={redraw}
disabled={phase === "creating" || phase === "streaming"}
className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition"
>
<RefreshCw className="w-3.5 h-3.5 text-slate-500" />
重新绘制
</button>
<button
type="button"
onClick={onBack}
className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition"
>
<ArrowLeft className="w-3.5 h-3.5 text-slate-500" />
返回报告
</button>
</div>
</div>
{/* 主体:左源卡 + 右沙箱 */}
<div className="flex-1 min-h-0 flex gap-3 p-3">
{/* 左栏:研讨原料源卡。打开沙箱时收起成 52px 图标条(对齐第 2 步侧栏收起样式)。 */}
<aside
className={cn(
"shrink-0 flex flex-col gap-3 overflow-y-auto pr-0.5 transition-[width] duration-200 ease-out",
leftCollapsed ? "w-[52px]" : "w-[280px]",
)}
>
{leftCollapsed ? (
<>
<button
type="button"
onClick={() => setLeftCollapsed(false)}
title="展开研讨原料"
aria-expanded={false}
className="w-full shrink-0 flex h-10 items-center justify-center rounded-2xl border border-slate-200/50 bg-white text-slate-500 hover:text-primary hover:border-primary/30 transition-colors shadow-[0_2px_8px_rgba(0,0,0,0.01)]"
>
<PanelLeftOpen className="h-4 w-4" />
</button>
<CollapsedSourceCard
icon={<CheckCircle className="w-4 h-4 text-emerald-500" />}
title="总控最终结论"
disabled={!consensusText}
onClick={() => setModal("consensus")}
/>
<CollapsedSourceCard
icon={<FileText className="w-4 h-4 text-primary" />}
title="各席位最终交付"
disabled={seatDeliveries.length === 0}
onClick={() => setModal("seats")}
/>
<CollapsedSourceCard
icon={<Download className="w-4 h-4 text-primary" />}
title="各席位生成文件"
onClick={() => setModal("files")}
/>
</>
) : (
<>
<div className="shrink-0 flex items-center justify-between gap-2 px-1">
<span className="text-[11px] font-bold text-slate-500 uppercase tracking-widest">
研讨原料
</span>
<button
type="button"
onClick={() => setLeftCollapsed(true)}
title="收起研讨原料"
aria-expanded
className="shrink-0 flex h-7 w-7 items-center justify-center rounded-lg border border-slate-200/50 bg-white text-slate-500 hover:text-primary hover:border-primary/30 transition-colors"
>
<PanelLeftClose className="h-3.5 w-3.5" />
</button>
</div>
<SourceCard
icon={<CheckCircle className="w-4 h-4 text-emerald-500" />}
title="总控最终结论"
hint={consensusText ? "点击查看结论全文" : "暂无结论"}
badge={hasConsensus ? "已共识" : undefined}
disabled={!consensusText}
onClick={() => setModal("consensus")}
/>
<SourceCard
icon={<FileText className="w-4 h-4 text-primary" />}
title="各席位最终交付"
hint="点击逐席位查看交付"
badge={String(seatDeliveries.length)}
disabled={seatDeliveries.length === 0}
onClick={() => setModal("seats")}
/>
<SourceCard
icon={<Download className="w-4 h-4 text-primary" />}
title="各席位生成文件"
hint="点击查看/预览产出文件"
onClick={() => setModal("files")}
/>
<div className="mt-1 rounded-2xl border border-dashed border-slate-200 bg-slate-50/60 p-3 text-[11px] leading-relaxed text-slate-400">
原研讨内容已收起到这里,随时点开查阅。右侧由「方案总结智能体」据此绘制可视化汇报。
</div>
</>
)}
</aside>
{/* 右栏:智能体进度 + 沙箱(复用第 2 步) */}
<section className="flex-1 min-w-0 rounded-2xl border border-slate-200/60 bg-white overflow-hidden">
<Step2SandboxLayout
activeThreadId={activeArtifactThreadId ?? reportThreadId}
isStreaming={isStreaming}
seatMessages={activeSeatMessages}
>
<AgentProgressPanel
phase={phase}
dialogues={dialogues}
error={error}
onRetry={redraw}
onArtifactStepClick={openArtifactFromStep}
onArtifactPreview={openArtifactPreview}
selectedModel={selectedModel}
availableModels={availableModels}
modelsLoading={modelsLoading}
setSelectedModel={setSelectedModel}
/>
</Step2SandboxLayout>
</section>
</div>
{/* 弹窗 */}
{modal === "consensus" && (
<ContentModal title="总控最终结论" onClose={() => setModal(null)}>
{consensusText ? (
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{consensusText}
</ReactMarkdown>
) : (
<p className="text-xs text-slate-400 italic">暂无总控结论。</p>
)}
</ContentModal>
)}
{modal === "seats" && (
<ContentModal title={`各席位最终交付(${seatDeliveries.length})`} onClose={() => setModal(null)}>
<SeatDeliveriesView seatDeliveries={seatDeliveries} />
</ContentModal>
)}
{modal === "files" && (
<SeatFilesModal
threadIds={threadIds}
coordinatorName={coordinatorName}
seatNameMap={seatNameMap}
onClose={() => setModal(null)}
/>
)}
</div>
);
}
// ── 左栏源卡 ─────────────────────────────────────────────────────────────
function SourceCard({
icon,
title,
hint,
badge,
disabled,
onClick,
}: {
icon: React.ReactNode;
title: string;
hint: string;
badge?: string;
disabled?: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className="text-left bg-white border border-slate-200/60 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)] transition hover:border-primary/40 hover:shadow-[0_4px_14px_rgba(0,0,0,0.04)] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:border-slate-200/60"
>
<div className="flex items-center gap-2 mb-1.5">
{icon}
<h4 className="text-xs font-bold text-slate-900 tracking-wide flex-1 truncate">{title}</h4>
{badge && (
<span className="text-[10px] font-mono font-semibold text-primary bg-[var(--brand-200)]/50 px-1.5 py-0.5 rounded">
{badge}
</span>
)}
</div>
<p className="text-[11px] text-slate-400 leading-normal">{hint}</p>
</button>
);
}
// 收起态的源卡:仅图标,居中,点击仍打开对应弹窗(对齐第 2 步 CollapsedSidebarSection 样式)。
function CollapsedSourceCard({
icon,
title,
disabled,
onClick,
}: {
icon: React.ReactNode;
title: string;
disabled?: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
title={title}
disabled={disabled}
onClick={onClick}
className="w-full shrink-0 flex items-center justify-center rounded-2xl border border-slate-200/50 bg-white p-2.5 text-slate-500 shadow-[0_2px_8px_rgba(0,0,0,0.01)] transition hover:text-primary hover:border-primary/30 disabled:opacity-40 disabled:cursor-not-allowed"
>
{icon}
</button>
);
}
// ── 右栏:智能体进度面板(沙箱的 60% 区域)────────────────────────────────
function AgentProgressPanel({
phase,
dialogues,
error,
onRetry,
onArtifactStepClick,
onArtifactPreview,
selectedModel,
availableModels,
modelsLoading,
setSelectedModel,
}: {
phase: ReturnType<typeof useStep3Report>["phase"];
dialogues: ReturnType<typeof useStep3Report>["dialogues"];
error: string | null;
onRetry: () => void;
onArtifactStepClick: ReturnType<typeof useStep3Report>["openArtifactFromStep"];
onArtifactPreview: ReturnType<typeof useStep3Report>["openArtifactPreview"];
selectedModel: string;
availableModels: ReadonlyArray<ModelOption>;
modelsLoading: boolean;
setSelectedModel: (model: string) => void;
}) {
const busy = phase === "creating" || phase === "streaming";
const scrollRef = useRef<HTMLDivElement>(null);
const { atBottom, scrollToBottom } = useAutoScroll(scrollRef, [dialogues]);
return (
<div className="flex h-full min-h-0 flex-col bg-slate-50/20">
<div className="shrink-0 flex items-center justify-between gap-2 border-b border-slate-100 bg-white px-4 py-2.5">
<div className="flex items-center gap-2 min-w-0">
<div className="w-6 h-6 shrink-0 rounded-lg bg-gradient-to-tr from-primary to-[var(--brand-200)] flex items-center justify-center text-white text-xs shadow-sm">
📊
</div>
<div className="leading-tight min-w-0">
<div className="text-xs font-bold text-slate-800 truncate">方案总结智能体</div>
<div className="text-[10px] text-slate-400 truncate">据各席位成果绘制可视化 HTML 看板</div>
</div>
</div>
<select
id="step3-report-model-select"
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
disabled={busy || modelsLoading || availableModels.length === 0}
title={
modelsLoading
? "正在加载模型列表…"
: availableModels.length === 0
? "未在 config.yaml 中配置任何模型"
: busy
? "绘制中不可切换;完成后或重新绘制时生效"
: "切换绘制使用的推理模型(下次绘制生效)"
}
className="shrink-0 max-w-[180px] bg-slate-50/50 border border-slate-200 rounded-xl px-2.5 py-1.5 text-[11px] font-bold text-slate-700 cursor-pointer focus:outline-none focus:border-primary focus:ring-1 focus:ring-[var(--brand-200)]/50 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-secondary dark:text-foreground dark:border-border"
>
{availableModels.length === 0 && (
<option value="">{modelsLoading ? "加载中…" : "未配置模型"}</option>
)}
{availableModels.map((m) => (
<option key={m.name} value={m.name}>
{m.display_name && m.display_name !== m.name
? `${m.display_name} (${m.name})`
: m.name}
</option>
))}
</select>
</div>
{/* 消息列表 —— 与第 2 步同款气泡(流式正文 + 工具步骤卡 + 文件卡)。 */}
<div className="relative flex-1 min-h-0 flex flex-col">
<div
ref={scrollRef}
className="flex-1 min-h-0 overflow-y-auto p-4 space-y-4 bg-white"
>
{phase === "creating" && dialogues.length === 0 && (
<div className="flex items-center gap-2 text-xs text-slate-500">
<Loader2 className="w-3.5 h-3.5 animate-spin text-primary" />
正在初始化绘制线程…
</div>
)}
{dialogues.map((msg) => (
<Step2Message
key={msg.id}
msg={msg}
onArtifactStepClick={(step) => onArtifactStepClick(msg, step)}
onFileCardClick={(path) => {
if (msg.agentThreadId) onArtifactPreview(msg.agentThreadId, path);
}}
/>
))}
{busy && (
<div className="inline-flex items-center gap-2 rounded-full border bg-white px-2.5 py-1 text-[11px] text-slate-500">
<span className="bg-primary size-1.5 animate-pulse rounded-full" />
右侧沙箱将实时显示绘制代码,完成后自动切换 HTML 预览
</div>
)}
{phase === "error" && (
<div className="rounded-xl border border-rose-200 bg-rose-50 p-3 text-xs text-rose-700">
<div className="font-bold mb-1">绘制失败</div>
<div className="leading-relaxed break-all">{error}</div>
<button
type="button"
onClick={onRetry}
className="mt-2 inline-flex items-center gap-1 text-[11px] font-bold text-rose-700 underline hover:text-rose-800"
>
<RefreshCw className="w-3 h-3" />
重试
</button>
</div>
)}
{phase === "done" && (
<div className="flex items-center gap-2 text-xs font-bold text-emerald-600">
<CheckCircle className="w-4 h-4" />
已生成可视化方案总览,右侧可查看 / 全屏 / 下载。
</div>
)}
</div>
{!atBottom && <ScrollToBottomButton onClick={scrollToBottom} />}
</div>
</div>
);
}
// ── 弹窗外壳 ─────────────────────────────────────────────────────────────
function ContentModal({
title,
children,
onClose,
}: {
title: string;
children: React.ReactNode;
onClose: () => void;
}) {
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-[1px] p-4"
onClick={onClose}
>
<div
className="bg-white border border-slate-200 rounded-2xl shadow-xl w-full max-w-2xl max-h-[80vh] flex flex-col overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
<div className="shrink-0 flex items-center justify-between px-5 py-3.5 border-b border-slate-100 bg-slate-50">
<h3 className="text-sm font-bold text-slate-900">{title}</h3>
<button
type="button"
onClick={onClose}
className="p-1 text-slate-400 hover:text-slate-600 rounded-md hover:bg-slate-100"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-5">{children}</div>
</div>
</div>
);
}
// ── 各席位交付(弹窗内的折叠列表)────────────────────────────────────────
function SeatDeliveriesView({ seatDeliveries }: { seatDeliveries: SeatDelivery[] }) {
const [openIdx, setOpenIdx] = useState<number | null>(0);
if (seatDeliveries.length === 0) {
return <p className="text-xs text-slate-400 italic">本次研讨尚未产生子智能体交付。</p>;
}
return (
<div className="space-y-2">
{seatDeliveries.map((seat, idx) => {
const open = openIdx === idx;
return (
<div key={`${seat.sender}-${idx}`} className="border border-slate-150 rounded-xl overflow-hidden">
<button
type="button"
onClick={() => setOpenIdx(open ? null : idx)}
className="w-full flex items-center justify-between gap-2 px-3 py-2.5 bg-slate-50 hover:bg-slate-100 transition text-left"
>
<div className="flex items-center gap-2 min-w-0">
<ListOrdered className="w-3.5 h-3.5 text-slate-400 shrink-0" />
<span className="text-[11.5px] font-bold text-slate-800 truncate">{seat.sender}</span>
</div>
<span className="text-[10px] text-slate-400 font-mono shrink-0">{seat.content.length} 字</span>
</button>
{open && (
<div className="px-4 py-3 text-xs text-slate-700 leading-relaxed border-t border-slate-100 bg-white">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{seat.content || "(空)"}
</ReactMarkdown>
</div>
)}
</div>
);
})}
</div>
);
}
// ── 各席位生成文件(弹窗,按需拉取 outputs 列表)──────────────────────────
interface SeatArtifacts {
agentName: string;
displayName: string;
threadId: string;
files: SessionArtifactFile[];
loading: boolean;
error: string | null;
}
function shouldPreviewInModal(file: { name: string; mimeType: string | null }): boolean {
const mime = file.mimeType ?? "";
if (mime.startsWith("text/")) return true;
if (["application/json"].includes(mime)) return true;
return /\.(md|markdown|txt|json|csv|log|yml|yaml|xml|html?)$/i.test(file.name);
}
function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
function SeatFilesModal({
threadIds,
coordinatorName,
seatNameMap,
onClose,
}: {
threadIds: ThreadIdMap | null;
coordinatorName: string | null;
seatNameMap: Record<string, string>;
onClose: () => void;
}) {
const seatEntries = useMemo(() => {
if (!threadIds) return [] as Array<{ agentName: string; threadId: string; displayName: string }>;
return Object.entries(threadIds)
.filter(([name]) => !coordinatorName || name !== coordinatorName)
.map(([agentName, threadId]) => ({
agentName,
threadId,
displayName: seatNameMap[agentName.toLowerCase()] ?? agentName,
}));
}, [threadIds, coordinatorName, seatNameMap]);
const [bySeat, setBySeat] = useState<SeatArtifacts[]>([]);
const [previewing, setPreviewing] = useState<{ threadId: string; file: SessionArtifactFile } | null>(
null,
);
useEffect(() => {
if (seatEntries.length === 0) {
setBySeat([]);
return;
}
setBySeat(seatEntries.map((s) => ({ ...s, files: [], loading: true, error: null })));
const abort = new AbortController();
let cancelled = false;
(async () => {
const results = await Promise.all(
seatEntries.map(async (seat) => {
try {
const res = await listSessionArtifacts(seat.threadId, { signal: abort.signal });
return { seat, files: res.files, error: null as string | null };
} catch (err) {
if ((err as Error).name === "AbortError") {
return { seat, files: [] as SessionArtifactFile[], error: null };
}
return { seat, files: [] as SessionArtifactFile[], error: (err as Error).message };
}
}),
);
if (cancelled) return;
setBySeat(results.map(({ seat, files, error }) => ({ ...seat, files, loading: false, error })));
})();
return () => {
cancelled = true;
abort.abort();
};
}, [seatEntries]);
const handlePreview = (threadId: string, file: SessionArtifactFile) => {
if (shouldPreviewInModal({ name: file.name, mimeType: file.mimeType })) {
setPreviewing({ threadId, file });
return;
}
if (typeof window !== "undefined") {
window.open(buildArtifactDownloadUrl(threadId, file.path), "_blank", "noopener,noreferrer");
}
};
const handleDownload = (threadId: string, file: SessionArtifactFile) => {
if (typeof window !== "undefined") {
window.open(
buildArtifactDownloadUrl(threadId, file.path, { download: true }),
"_blank",
"noopener,noreferrer",
);
}
};
return (
<>
<ContentModal title="各席位生成文件" onClose={onClose}>
{bySeat.length === 0 ? (
<p className="text-xs text-slate-400 italic">尚未初始化席位线程,无法列出文件。</p>
) : (
<div className="space-y-3">
{bySeat.map((seat) => (
<div key={seat.agentName} className="border border-slate-150 rounded-xl">
<div className="flex items-center justify-between gap-2 px-3 py-2 bg-slate-50 border-b border-slate-150 rounded-t-xl">
<span className="text-[11.5px] font-bold text-slate-800 truncate">{seat.displayName}</span>
<span className="text-[10px] text-slate-400 font-mono shrink-0">
{seat.loading ? "加载中…" : `${seat.files.length} 个文件`}
</span>
</div>
{seat.loading ? (
<div className="flex items-center gap-2 px-3 py-3 text-[11px] text-slate-500">
<Loader2 className="w-3 h-3 animate-spin text-primary" />
正在查询 outputs 目录…
</div>
) : seat.error ? (
<div className="px-3 py-3 text-[11px] text-rose-600">读取失败:{seat.error}</div>
) : seat.files.length === 0 ? (
<div className="px-3 py-3 text-[11px] text-slate-400 italic">该席位未产生输出文件</div>
) : (
<ul className="divide-y divide-slate-100">
{seat.files.map((f) => (
<li key={f.path} className="px-3 py-2 flex items-center justify-between gap-2 hover:bg-slate-50/60">
<div className="flex items-center gap-2 min-w-0">
<FileText className="w-3.5 h-3.5 text-slate-400 shrink-0" />
<span className="text-[11.5px] font-medium text-slate-800 truncate" title={f.name}>
{f.name}
</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-[10px] font-mono text-slate-400">{formatBytes(f.sizeBytes)}</span>
<button
type="button"
onClick={() => handlePreview(seat.threadId, f)}
className="text-slate-600 hover:text-primary transition p-1 rounded hover:bg-slate-100"
title="预览"
>
<Eye className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => handleDownload(seat.threadId, f)}
className="text-slate-600 hover:text-primary transition p-1 rounded hover:bg-slate-100"
title="下载"
>
<Download className="w-3.5 h-3.5" />
</button>
</div>
</li>
))}
</ul>
)}
</div>
))}
</div>
)}
</ContentModal>
{previewing && (
<ArtifactPreviewModal
threadId={previewing.threadId}
path={previewing.file.path}
name={previewing.file.name}
mimeType={previewing.file.mimeType}
onClose={() => setPreviewing(null)}
/>
)}
</>
);
}
@@ -0,0 +1,44 @@
/**
* 圆桌 Step 3 报告里 `react-markdown` 的紧凑渲染组件集(小字号、卡片内排版)。
* 抽到独立文件供 `HighFidelityReport` 与 `Step3ReportMode` 共用,避免两者互相 import
* 形成循环依赖。
*/
export const markdownComponents = {
p: ({ children }: any) => <p className="my-1.5 whitespace-pre-wrap leading-relaxed">{children}</p>,
ul: ({ children }: any) => <ul className="list-disc pl-5 my-1.5 space-y-1">{children}</ul>,
ol: ({ children }: any) => <ol className="list-decimal pl-5 my-1.5 space-y-1">{children}</ol>,
li: ({ children }: any) => <li className="leading-snug">{children}</li>,
h1: ({ children }: any) => <h1 className="text-sm font-bold mt-3 mb-1.5">{children}</h1>,
h2: ({ children }: any) => <h2 className="text-sm font-bold mt-3 mb-1.5">{children}</h2>,
h3: ({ children }: any) => <h3 className="text-xs font-bold mt-2 mb-1">{children}</h3>,
h4: ({ children }: any) => <h4 className="text-xs font-bold mt-1.5 mb-1">{children}</h4>,
strong: ({ children }: any) => <strong className="font-bold text-slate-900">{children}</strong>,
em: ({ children }: any) => <em className="italic">{children}</em>,
code: ({ children, className }: any) => {
const isBlock = /language-/.test(className ?? "");
if (isBlock) {
return (
<pre className="bg-slate-100 text-slate-800 rounded-md p-2 my-1 overflow-x-auto text-[11px] font-mono">
<code>{children}</code>
</pre>
);
}
return (
<code className="bg-slate-100 text-slate-800 rounded px-1 py-0.5 text-[11px] font-mono">{children}</code>
);
},
blockquote: ({ children }: any) => (
<blockquote className="border-l-2 border-slate-300 pl-2 my-1 text-slate-600 italic">{children}</blockquote>
),
a: ({ children, href }: any) => (
<a href={href} target="_blank" rel="noopener noreferrer" className="text-primary underline hover:opacity-80">
{children}
</a>
),
table: ({ children }: any) => <table className="my-2 border-collapse text-[11px]">{children}</table>,
th: ({ children }: any) => (
<th className="border border-slate-300 px-1.5 py-0.5 bg-slate-50 font-bold text-left">{children}</th>
),
td: ({ children }: any) => <td className="border border-slate-200 px-1.5 py-0.5">{children}</td>,
hr: () => <hr className="my-2 border-slate-200" />,
};
@@ -27,6 +27,7 @@ import {
getDraft,
listDrafts,
updateDraft,
type DraftStep3Snapshot,
} from "@/roundtable-planning/api/drafts";
/** 历史下拉渲染用的轻量列表项(`name` 映射自后端 `title`)。 */
@@ -46,10 +47,14 @@ export interface UseDraftPersistenceOptions {
getStep1Snapshot: () => Step1Snapshot;
/** 读 Step 2 当前快照(由 useStep2Orchestration 提供的 getter)。 */
getStep2Snapshot: () => Step2Snapshot;
/** 读 Step 3「结果绘制」当前快照(无则 null)。 */
getStep3Snapshot?: () => DraftStep3Snapshot | null;
/** 加载草稿时把 Step 1 状态打回去。 */
hydrateStep1: (snapshot: Step1Snapshot) => void;
/** 加载草稿时把 Step 2 状态打回去。 */
hydrateStep2: (snapshot: Step2Snapshot) => void;
/** 加载草稿时把 Step 3 结果打回去(无则传 null 清空)。 */
hydrateStep3?: (snapshot: DraftStep3Snapshot | null) => void;
/** 加载草稿前先中断所有 Step 1 / Step 2 in-flight。 */
cancelAllStreams: () => void;
/**
@@ -67,6 +72,11 @@ export interface UseDraftPersistenceOptions {
* 子智能体刚交付完一轮,触发一次自动保存。流式增量不改变该计数,因此不会刷屏式保存。
*/
step2FinalizedCount: number;
/**
* Step 3「结果绘制」每完成一次绘制递增 —— 作为草稿自动保存触发信号,把最新
* 报告(step3)落进草稿。仿 step2FinalizedCount。
*/
step3SavedTick?: number;
}
export interface UseDraftPersistenceReturn {
@@ -102,12 +112,15 @@ export function useDraftPersistence(
triggerToast,
getStep1Snapshot,
getStep2Snapshot,
getStep3Snapshot,
hydrateStep1,
hydrateStep2,
hydrateStep3,
cancelAllStreams,
setStep2InitGate,
intentSending,
step2FinalizedCount,
step3SavedTick = 0,
} = options;
const [drafts, setDrafts] = useState<DraftListItem[]>([]);
@@ -187,6 +200,8 @@ export function useDraftPersistence(
const step1 = getStep1Snapshot();
const step2 = getStep2Snapshot();
if (!hasSavableContent(step1, step2)) return null;
// 仅在有结果时带上 step3,避免普通的 step1/step2 自动保存把已存的报告抹掉。
const step3 = getStep3Snapshot?.() ?? null;
const id = currentDraftIdRef.current;
if (id) {
@@ -194,6 +209,7 @@ export function useDraftPersistence(
...(furthestStep !== undefined ? { furthestStep } : {}),
step1,
step2,
...(step3 ? { step3 } : {}),
});
if (updated) return updated.id;
// 服务端已不存在(被删/换账号)→ 落回新建。
@@ -206,6 +222,7 @@ export function useDraftPersistence(
furthestStep: furthestStep ?? ((currentStepRef.current as 1 | 2 | 3) || 1),
step1,
step2,
...(step3 ? { step3 } : {}),
});
currentDraftIdRef.current = created.id;
setCurrentDraftId(created.id);
@@ -215,7 +232,7 @@ export function useDraftPersistence(
saveQueueRef.current = next.catch(() => undefined);
return next as Promise<string | null>;
},
[getStep1Snapshot, getStep2Snapshot, hasSavableContent, setCurrentDraftId],
[getStep1Snapshot, getStep2Snapshot, getStep3Snapshot, hasSavableContent, setCurrentDraftId],
);
const ensureDraft = useCallback(
@@ -289,6 +306,15 @@ export function useDraftPersistence(
if (step2FinalizedCount > prev) void autoSave();
}, [step2FinalizedCount, autoSave]);
// ── 触发器 3:Step 3「结果绘制」完成一次(step3SavedTick 递增)。 ──
// 此刻页面已把最新报告写进 step3Report state,getStep3Snapshot 读到的是新值。
const prevStep3TickRef = useRef<number>(step3SavedTick);
useEffect(() => {
const prev = prevStep3TickRef.current;
prevStep3TickRef.current = step3SavedTick;
if (step3SavedTick > prev) void autoSave();
}, [step3SavedTick, autoSave]);
const handleLoadDraft = useCallback(
(id: string) => {
void (async () => {
@@ -316,6 +342,8 @@ export function useDraftPersistence(
if (draft.step1) hydrateStep1(draft.step1 as Step1Snapshot);
if (draft.step2) hydrateStep2(draft.step2 as Step2Snapshot);
// 恢复 Step 3 报告(无则传 null 清掉上一条草稿残留),进入「结果绘制」即可看回。
hydrateStep3?.((draft.step3 as DraftStep3Snapshot | null) ?? null);
setStep2InitGate(draft.furthestStep >= 2);
setCurrentDraftId(draft.id);
@@ -329,6 +357,7 @@ export function useDraftPersistence(
cancelAllStreams,
hydrateStep1,
hydrateStep2,
hydrateStep3,
refreshList,
setCurrentDraftId,
setCurrentStep,
@@ -104,6 +104,15 @@ export type Step2Snapshot = {
* 老草稿可能没有这字段,反序列化时按空对象兜底。
*/
seatStubMessages: Record<string, StubAiMessage[]>;
/**
* 本次 Step 2 编排模式(2026-06 业务链条)。recommend = 总控自由派活;
* chain = 按业务链条顺序派活。老草稿无此字段 → 加载时默认 recommend。
* 注:该字段是主页面 state,由页面在 getStep2Snapshot/hydrateStep2 里读写,
* step2 hook 的 hydrateFromDraft 不直接消费它。
*/
orchestrationMode?: "recommend" | "chain";
/** chain 模式来源链条(仅展示用,如「按业务链条 X 研讨」)。 */
chain?: { id: string; title: string } | null;
};
/**
@@ -119,11 +128,74 @@ const DEFAULT_AGENT_CONFIGS: Record<number, AgentConfig> = {
7: { model: "", weight: 80, temp: 0.4, prompt: "你负责对72小时里程碑执行路径规划、重排和资源调配演练序列。" },
};
/**
* chain 模式:为「链条第 i 个席位」构造**约束总控只能派给它**的 leader prompt。
* 后端不变 —— 靠 prompt 把派活目标钉死成链条下一席位,再由 `pickChainTask` 校验/纠偏。
*/
function buildChainLeaderTask(args: {
seedMessage: string;
position: number;
total: number;
seatAgentId: string;
seatName: string;
isFirst: boolean;
}): string {
const { seedMessage, position, total, seatAgentId, seatName, isFirst } = args;
const ordinal = `${position + 1}/${total}`;
if (isFirst) {
return [
"这是一次【按业务链条顺序】进行的多智能体圆桌研讨。任务背景如下:",
seedMessage,
"",
`本次研讨严格按预设链条顺序进行,共 ${total} 个席位,现在是第 ${ordinal} 个席位。`,
`请你作为总控,通过 agent_orchestration 技能**仅**向智能体「${seatName}」(agent_name: ${seatAgentId})派活,结合任务背景为它分配聚焦其职责的本轮任务。`,
"不要派给其他任何智能体,也不要自己直接作答或给出最终结论。",
].join("\n");
}
return [
`链条研讨继续,现在轮到第 ${ordinal} 个席位。`,
`请你作为总控,结合此前各席位已完成的交付,通过 agent_orchestration 技能**仅**向智能体「${seatName}」(agent_name: ${seatAgentId})派活,为它分配承接前序结论、聚焦其职责的本轮任务。`,
"不要派给其他任何智能体,也不要自己直接作答或给出最终结论。",
].join("\n");
}
/**
* 从 leader 终态里取出「派给当前链条席位」的任务文本,带纠偏:
* 1) 命中 agent_name === seatAgentId 的派活 → 用它的 task;
* 2) 总控派了活但写偏 id → 仍用它给出的 task 文本(强制跑正确席位);
* 3) 总控没派活只写了说明 → 用 leader 正文,再不行回退到模板任务。
* 无论哪种,调用方都强制对**链条席位**跑 special,确保游标严格推进。
*/
function pickChainTask(
dispatched: LeaderDispatch[],
leaderContent: string,
seatAgentId: string,
seatName: string,
): string {
const wanted = seatAgentId.toLowerCase();
const exact = dispatched.find(([name]) => name.toLowerCase() === wanted);
if (exact && exact[1]?.trim()) return exact[1];
const any = dispatched.find(([, task]) => task?.trim());
if (any && any[1]?.trim()) return any[1];
if (leaderContent?.trim()) {
return `请你作为「${seatName}」,基于以下总控说明与此前各席位的交付,从你的专业职责出发给出你的分析与交付:\n${leaderContent.trim()}`;
}
return `请你作为「${seatName}」,基于任务背景与此前各席位的交付,从你的专业职责出发给出你的分析与交付。`;
}
export interface UseStep2OrchestrationOptions {
/** 当前页面 currentStep,===2 触发自动 init。 */
currentStep: number;
/** 全局选中模型(顶栏「推理模型」下拉)。 */
selectedModel: string;
/**
* 编排模式:
* - `recommend`(默认)—— 总控自由派活循环(`runOrchestration`);
* - `chain` —— 总控**按业务链条顺序**逐位派活(`runChainOrchestration`)。
* 由推荐弹窗确认时的 `SeatSelectionResult.mode` 决定;chain 模式下席位顺序 =
* `selectedAgents` 的顺序。
*/
orchestrationMode: "recommend" | "chain";
/** 进入 Step 2 时拼装第一轮 leader prompt 的函数(读 Step 1 状态)。 */
buildInitialIntent: () => string;
/** 主页面统一的 toast。 */
@@ -224,6 +296,7 @@ export function useStep2Orchestration(
const {
currentStep,
selectedModel,
orchestrationMode,
buildInitialIntent,
triggerToast,
setRoles,
@@ -321,6 +394,15 @@ export function useStep2Orchestration(
const threadIdsRef = useRef<ThreadIdMap | null>(threadIds);
const coordinatorNameRef = useRef<string | null>(coordinatorName);
const isOrchestratingRef = useRef<boolean>(isOrchestrating);
// chain 编排:模式 + 当前选中席位(=链条顺序)。同样打到 ref,让编排函数 deps 收窄。
const orchestrationModeRef = useRef(orchestrationMode);
const selectedAgentsRef = useRef<SelectedAgent[]>(selectedAgents);
useEffect(() => {
orchestrationModeRef.current = orchestrationMode;
}, [orchestrationMode]);
useEffect(() => {
selectedAgentsRef.current = selectedAgents;
}, [selectedAgents]);
useEffect(() => {
selectedModelRef.current = selectedModel;
}, [selectedModel]);
@@ -1138,6 +1220,250 @@ export function useStep2Orchestration(
],
);
// ─── Chain orchestration loop ─────────────────────────────────────
// chain 模式:总控**按 selectedAgents(=链条)顺序**逐位派活,而非自由派活。
// 每位:(a) 约束 leader 只派给该席位 → (b) 对该席位跑 special;走完整链后 (c)
// 由 leader 收口产出最终结论。后端零改动 —— 顺序与约束全在前端 prompt + 校验。
// **不要并行 special**(与 runOrchestration 同一不变量)。
const runChainOrchestration = useCallback(
async (seedMessage: string, ids: ThreadIdMap, coord: string) => {
if (isOrchestratingRef.current) return;
setIsOrchestrating(true);
orchestrationCancelRef.current = false;
const chainSeats = selectedAgentsRef.current;
const total = chainSeats.length;
const leaderThreadId = resolveSeatThreadId(ids, coord);
let finishedAllSeats = true;
try {
for (let i = 0; i < total; i += 1) {
if (orchestrationCancelRef.current) {
finishedAllSeats = false;
break;
}
const seat = chainSeats[i];
const meta = agentNameToRoleRef.current[seat.agent_id];
const seatName = meta?.displayName ?? seat.name ?? seat.agent_id;
// ── (a) 总控按链条顺序派活给当前席位 ──
highlightSpeaker(null);
const leaderBubbleId = appendStreamingDialogue({
sender: "总控协调",
avatarType: "blue",
initialContent: "",
confidence: "链条派活中",
agentThreadId: leaderThreadId,
});
const leaderTask = buildChainLeaderTask({
seedMessage,
position: i,
total,
seatAgentId: seat.agent_id,
seatName,
isFirst: i === 0,
});
const leaderAborter = new AbortController();
activeAbortersRef.current.add(leaderAborter);
let leaderRes;
try {
leaderRes = await streamMultiAgent({
agentType: "leader",
dAgentThreadId: ids,
agentName: coord,
newMessage: leaderTask,
model: selectedModelRef.current || undefined,
signal: leaderAborter.signal,
onTextDelta: (text) => appendToDialogue(leaderBubbleId, text),
onPhaseChange: (phase, toolName, args) => {
const label = phaseLabel(phase, "leader", toolName);
setDialogueConfidence(leaderBubbleId, label);
appendStepIfNew(leaderBubbleId, label, `${phase}:${toolName ?? ""}`, args);
handleArtifactToolPhase(phase, toolName, args, leaderThreadId);
},
onUpstreamFrame: (frame) => handleUpstreamFrame(frame, leaderThreadId),
});
} catch (err) {
activeAbortersRef.current.delete(leaderAborter);
if (leaderAborter.signal.aborted || (err as Error).name === "AbortError") {
finalizeDialogue(leaderBubbleId, "(已停止)", "已停止");
finishedAllSeats = false;
break;
}
throw err;
}
activeAbortersRef.current.delete(leaderAborter);
if (orchestrationCancelRef.current) {
finalizeDialogue(leaderBubbleId, leaderRes.content || "(已停止)", "已停止");
finishedAllSeats = false;
break;
}
// 链条中途总控仍可能要求澄清 → 暂停链条,等用户回复后从头重跑(MVP)。
if (leaderRes.clarification) {
finalizeDialogue(leaderBubbleId, leaderRes.content, "等待用户澄清");
highlightSpeaker(null);
setPendingClarification(leaderRes.clarification);
setClarificationDraft("");
triggerToast("❓ 总控需要补充信息,请回答下方问题后继续");
finishedAllSeats = false;
break;
}
finalizeDialogue(leaderBubbleId, leaderRes.content, "派活说明");
if (leaderRes.content) setLastLeaderContent(leaderRes.content);
const task = pickChainTask(
leaderRes.dispatched,
leaderRes.content,
seat.agent_id,
seatName,
);
// ── (b) 当前席位交付(special,顺序、不并行)──
if (meta) markSpeakerStreaming(meta.speakerId);
const seatThreadId = resolveSeatThreadId(ids, seat.agent_id);
const perSeatModel = meta
? agentConfigsRef.current[meta.speakerId]?.model
: undefined;
const subBubbleId = appendStreamingDialogue({
sender: seatName,
avatarType: meta?.avatarType ?? "blue",
initialContent: "",
confidence: "调度中",
agentThreadId: seatThreadId,
});
const subAborter = new AbortController();
activeAbortersRef.current.add(subAborter);
let aborted = false;
try {
const specRes = await streamMultiAgent({
agentType: "special",
dAgentThreadId: ids,
agentName: seat.agent_id,
displayName: seatName,
newMessage: task,
model: perSeatModel || selectedModelRef.current || undefined,
signal: subAborter.signal,
onTextDelta: (text) => appendToDialogue(subBubbleId, text),
onPhaseChange: (phase, toolName, args) => {
const label = phaseLabel(phase, "special", toolName);
setDialogueConfidence(subBubbleId, label);
appendStepIfNew(subBubbleId, label, `${phase}:${toolName ?? ""}`, args);
handleArtifactToolPhase(phase, toolName, args, seatThreadId);
},
onUpstreamFrame: (frame) => handleUpstreamFrame(frame, seatThreadId),
});
if (orchestrationCancelRef.current) {
finalizeDialogue(subBubbleId, specRes.content || "(已停止)", "已停止");
aborted = true;
} else {
finalizeDialogue(subBubbleId, specRes.content, "子智能体交付");
}
} catch (err) {
if (subAborter.signal.aborted || (err as Error).name === "AbortError") {
finalizeDialogue(subBubbleId, "(已停止)", "已停止");
aborted = true;
} else {
console.error(`[chain-special:${seat.agent_id}] error:`, err);
finalizeDialogue(subBubbleId, `(错误) ${(err as Error).message}`, "调用失败");
}
} finally {
activeAbortersRef.current.delete(subAborter);
if (meta) markSpeakerIdle(meta.speakerId);
}
if (aborted) {
finishedAllSeats = false;
break;
}
setConsensusPercentage(Math.min(95, Math.round(((i + 1) / total) * 90)));
}
// ── (c) 总控收口:综合全链交付,产出最终结论 ──
if (!orchestrationCancelRef.current && finishedAllSeats && total > 0) {
highlightSpeaker(null);
const closeoutBubbleId = appendStreamingDialogue({
sender: "总控协调",
avatarType: "blue",
initialContent: "",
confidence: "汇总链条结论",
agentThreadId: leaderThreadId,
});
const closeoutAborter = new AbortController();
activeAbortersRef.current.add(closeoutAborter);
try {
const closeoutRes = await streamMultiAgent({
agentType: "leader",
dAgentThreadId: ids,
agentName: coord,
newMessage:
"各链条席位已按顺序完成发言,请你综合所有席位的交付,输出最终方案与结论。不要再派活、不要再向任何席位分配任务。",
model: selectedModelRef.current || undefined,
signal: closeoutAborter.signal,
onTextDelta: (text) => appendToDialogue(closeoutBubbleId, text),
onPhaseChange: (phase, toolName, args) => {
const label = phaseLabel(phase, "leader", toolName);
setDialogueConfidence(closeoutBubbleId, label);
appendStepIfNew(closeoutBubbleId, label, `${phase}:${toolName ?? ""}`, args);
handleArtifactToolPhase(phase, toolName, args, leaderThreadId);
},
onUpstreamFrame: (frame) => handleUpstreamFrame(frame, leaderThreadId),
});
finalizeDialogue(closeoutBubbleId, closeoutRes.content, "全案盖印");
if (closeoutRes.content) setLastLeaderContent(closeoutRes.content);
setConsensusPercentage(100);
setHasConsensus(true);
triggerToast("🎉 业务链条研讨完成!已生成全案结论");
} catch (err) {
if (closeoutAborter.signal.aborted || (err as Error).name === "AbortError") {
finalizeDialogue(closeoutBubbleId, "(已停止)", "已停止");
} else {
console.error("chain closeout error:", err);
finalizeDialogue(closeoutBubbleId, `(错误) ${(err as Error).message}`, "调用失败");
}
} finally {
activeAbortersRef.current.delete(closeoutAborter);
}
}
} catch (err) {
console.error("chain orchestration error:", err);
triggerToast(`❌ 链条研讨异常: ${(err as Error).message}`);
} finally {
highlightSpeaker(null);
clearAllStreamingSpeakers();
setIsOrchestrating(false);
}
},
[
appendStreamingDialogue,
appendToDialogue,
appendStepIfNew,
setDialogueConfidence,
phaseLabel,
finalizeDialogue,
highlightSpeaker,
markSpeakerStreaming,
markSpeakerIdle,
clearAllStreamingSpeakers,
triggerToast,
handleArtifactToolPhase,
handleUpstreamFrame,
resolveSeatThreadId,
],
);
// 按当前模式分发到对应编排函数。init / 恢复 / 干预 / 澄清回复统一走这里。
const runActiveOrchestration = useCallback(
(seedMessage: string, ids: ThreadIdMap, coord: string) =>
orchestrationModeRef.current === "chain"
? runChainOrchestration(seedMessage, ids, coord)
: runOrchestration(seedMessage, ids, coord),
[runOrchestration, runChainOrchestration],
);
// ─── Effects ─────────────────────────────────────────────────────
// Step 2 init —— 首次进入时建协调 thread + 各席位 thread,然后触发第一轮编排。
// 离开 Step 2 **不**销毁 threadIds,保证回来还能继续。
@@ -1190,7 +1516,7 @@ export function useStep2Orchestration(
confidence: "系统就绪",
});
triggerToast("✅ 多智能体圆桌初始化完成");
await runOrchestration(
await runActiveOrchestration(
buildInitialIntent(),
initRes.thread_ids,
initRes.main_agent_name,
@@ -1234,7 +1560,7 @@ export function useStep2Orchestration(
const pending = pendingInjectPromptRef.current;
if (pending) {
pendingInjectPromptRef.current = null;
void runOrchestration(
void runActiveOrchestration(
pending,
threadIdsRef.current,
coordinatorNameRef.current,
@@ -1242,7 +1568,7 @@ export function useStep2Orchestration(
return;
}
void runOrchestration(
void runActiveOrchestration(
"用户已恢复研讨,请继续上一轮的协调工作。",
threadIdsRef.current,
coordinatorNameRef.current,
@@ -1311,8 +1637,8 @@ export function useStep2Orchestration(
triggerToast("干预指令已发送,会商继续执行");
// 直接启动新一轮编排
void runOrchestration(prompt, threadIdsRef.current, coordinatorNameRef.current);
}, [customAgentInput, appendDialogue, abortAllInFlight, runOrchestration, triggerToast]);
void runActiveOrchestration(prompt, threadIdsRef.current, coordinatorNameRef.current);
}, [customAgentInput, appendDialogue, abortAllInFlight, runActiveOrchestration, triggerToast]);
const submitClarification = useCallback(
(rawAnswer: string) => {
@@ -1335,13 +1661,13 @@ export function useStep2Orchestration(
});
setPendingClarification(null);
setClarificationDraft("");
void runOrchestration(
void runActiveOrchestration(
`用户已回复你刚才的澄清问题。\n问题:${question}\n用户回答:${answer}\n请基于该回答继续派活或给出结论。`,
threadIdsRef.current,
coordinatorNameRef.current,
);
},
[appendDialogue, pendingClarification, runOrchestration, triggerToast],
[appendDialogue, pendingClarification, runActiveOrchestration, triggerToast],
);
// 预算滑块 —— 写死的演示分支,跟实际派活没有数据通路。删除或重构请同步
@@ -0,0 +1,736 @@
/**
* Step 3「结果绘制」编排 hook —— 驱动内置的方案可视化总结智能体
* (`roundtable-report`) 把各席位交付 + 总控共识画成一个自包含、含多种图表的
* HTML 看板。
*
* 设计要点(**与第 2 步 useStep2Orchestration 的 special 席位机制同源、同体验**):
* - `roundtable-report` 是**内置单例**,这里只 `initReportThread()` 建一个**轻量
* 线程**(绝不重建 agent),再用现有 `streamMultiAgent`(special)跑一轮;
* - 把这一轮渲染成一条**第 2 步同款对话气泡**(`Step2Dialogue`):流式正文 +
* `tool_calling` 工具步骤(write_file/present_files…)+ 末尾文件卡,让用户在
* 报告智能体「只写文件、几乎不输出正文」时也能从步骤卡看到实时进度;
* - 流式期间把它的 `write_file` tool_call 幂等写进 stub ai message,并用
* `write-file:` 虚拟 URL 打开沙箱 —— `ArtifactFileDetail` 据此从 stub 读
* `args.content`,HTML 文件在流式中显示代码、`isStreaming` 落 false 后自动切预览;
* - 沙箱/消息渲染零自研,完全复用第 2 步的 `Step2SandboxLayout` + `Step2Message`。
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useArtifacts } from "@/components/workspace/artifacts";
import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts";
import {
initReportThread,
listSessionArtifacts,
streamMultiAgent,
type StreamPhase,
} from "@/roundtable-planning/api/multi-agent";
import type {
Step2Dialogue,
StubAiMessage,
} from "@/roundtable-planning/hooks/useStep2Orchestration";
import { nowTime } from "@/roundtable-planning/lib/roundtable-constants";
/** 报告输出文件的固定虚拟路径(恢复历史快照时用它重建 stub)。 */
const REPORT_OUTPUT_PATH = "/mnt/user-data/outputs/方案总览.html";
/** Step 1 整理出的结构化意图(用于喂给可视化智能体)。 */
export interface ReportIntent {
objective: string;
constraints?: string[];
assumptions?: string[];
}
/** 一个席位的最终交付(与 Step3Panel 传下来的 seatDeliveries 对齐)。 */
export interface ReportSeatDelivery {
sender: string;
content: string;
}
export type ReportPhase = "idle" | "creating" | "streaming" | "done" | "error";
interface UseStep3ReportOptions {
intentReady: ReportIntent | null;
seatDeliveries: ReportSeatDelivery[];
consensusText: string;
selectedModel: string;
/** 草稿里已存的上次绘制结果 —— 有则进入时恢复展示,不重新绘制。 */
savedReport?: DraftStep3Snapshot | null;
/** 一次绘制成功完成时回调(传出 html/model/时间/收尾语),供上层存草稿。 */
onReportSaved?: (snapshot: DraftStep3Snapshot) => void;
}
export interface UseStep3ReportReturn {
phase: ReportPhase;
error: string | null;
reportThreadId: string | null;
/** 第 2 步同款对话气泡列表(这里只有报告智能体一条,流式 + 工具步骤)。 */
dialogues: Step2Dialogue[];
/** 供 Step2SandboxLayout 的 seatMessages 使用:key = thread id。 */
seatStubMessages: Record<string, StubAiMessage[]>;
/** 当前沙箱预览绑定的 thread id(这里恒为报告线程)。 */
activeArtifactThreadId: string | null;
/** 启动绘制(幂等:进行中重复调用会被忽略)。 */
start: () => void;
/** 从草稿快照恢复展示(建合成 stub + 气泡 + 打开沙箱,phase=done,不重新绘制)。 */
hydrateSaved: (snapshot: DraftStep3Snapshot) => void;
/** 重新绘制(先中断在途请求再重跑)。 */
redraw: () => void;
/** 中断在途请求(卸载 / 切回普通视图时调用)。 */
abort: () => void;
/** 点击气泡内工具步骤时打开沙箱(与第 2 步 openArtifactFromStep 同义)。 */
openArtifactFromStep: (
dialogue: Step2Dialogue,
step: NonNullable<Step2Dialogue["steps"]>[number],
) => void;
/** 点击气泡下方文件卡时打开沙箱预览。 */
openArtifactPreview: (threadId: string, path: string) => void;
}
function buildReportPrompt(opts: {
intentReady: ReportIntent | null;
seatDeliveries: ReportSeatDelivery[];
consensusText: string;
}): string {
const { intentReady, seatDeliveries, consensusText } = opts;
const parts: string[] = [];
parts.push(
"请基于以下圆桌多智能体研讨的成果,撰写一份图文并茂、以文字叙述为主线的 HTML「方案研讨汇报」," +
"用 write_file 工具写入 /mnt/user-data/outputs/方案总览.html。",
);
parts.push(
"目标:让用户能轻松读懂这群智能体讨论出了什么方案、为什么是它。请逐个席位提炼其核心观点与交付写成文字," +
"再综合成最终方案;图表用来点睛(风险矩阵、时间线、资源占比、贡献雷达等),详略得当——" +
"缺明确数字时也要据研讨内容合理估算出图并标注「定性/估算」,不要通篇只有文字。",
);
parts.push("");
if (intentReady?.objective) {
parts.push("# 一、任务意图");
parts.push(`目标:${intentReady.objective}`);
if (intentReady.constraints?.length) {
parts.push("约束:");
for (const c of intentReady.constraints) parts.push(`- ${c}`);
}
if (intentReady.assumptions?.length) {
parts.push("关键假设:");
for (const a of intentReady.assumptions) parts.push(`- ${a}`);
}
parts.push("");
}
if (seatDeliveries.length) {
parts.push("# 二、各席位最终交付");
seatDeliveries.forEach((s, i) => {
parts.push(`## ${i + 1}. ${s.sender}`);
parts.push(s.content?.trim() || "(无内容)");
parts.push("");
});
}
if (consensusText?.trim()) {
parts.push("# 三、总控最终共识结论");
parts.push(consensusText.trim());
parts.push("");
}
parts.push(
"请严格遵循你的职责设定:自包含离线(纯 inline SVG 图表、禁止任何外链 CDN)、研讨叙事型结构" +
"(结论+KPI → 背景目标 → 各席位观点 → 综合分析 → 最终共识方案 → 执行路线)、" +
"文字为主图表点睛(全篇穿插 3–5 个有信息量的图、每图配一两句解读)、浅红主题(主色 #c25555);" +
"写完文件后只用一两句话总结,不要把 HTML 源码贴进对话。",
);
return parts.join("\n");
}
/** 把 SSE 阶段映射成报告智能体的中文状态标签(对齐第 2 步 phaseLabel)。 */
function reportPhaseLabel(phase: StreamPhase, toolName?: string): string {
switch (phase) {
case "connecting":
return "调度中";
case "connected":
return "已连接,等待响应";
case "tool_calling":
if (toolName === "present_files") return "正在交付文件";
if (toolName === "write_file" || toolName === "str_replace")
return "正在绘制图表 HTML";
return toolName ? `调用工具:${toolName}` : "调用工具";
case "tool_result":
return "处理工具结果";
case "streaming":
return "正在输出";
default:
return "正在输出…";
}
}
export function useStep3Report(
options: UseStep3ReportOptions,
): UseStep3ReportReturn {
const { intentReady, seatDeliveries, consensusText, selectedModel, onReportSaved } =
options;
// onReportSaved 身份可能每次 render 变 —— 打到 ref,避免进 run() 的 deps 反复重建。
const onReportSavedRef = useRef(onReportSaved);
useEffect(() => {
onReportSavedRef.current = onReportSaved;
}, [onReportSaved]);
const {
setArtifacts,
select: selectArtifact,
setOpen: setArtifactsOpen,
} = useArtifacts();
const [phase, setPhase] = useState<ReportPhase>("idle");
const [error, setError] = useState<string | null>(null);
const [reportThreadId, setReportThreadId] = useState<string | null>(null);
const [dialogues, setDialogues] = useState<Step2Dialogue[]>([]);
const [seatStubMessages, setSeatStubMessages] = useState<
Record<string, StubAiMessage[]>
>({});
const [activeArtifactThreadId, setActiveArtifactThreadId] = useState<
string | null
>(null);
const stubRef = useRef<Record<string, StubAiMessage[]>>({});
const runningRef = useRef(false);
const aborterRef = useRef<AbortController | null>(null);
// 当前报告气泡 id —— 流式 delta / 工具步骤 / finalize 都按它定位(绝不用数组下标)。
const bubbleIdRef = useRef<number | null>(null);
// 上一次「自动」打开沙箱的 virtualUrl —— 流式一秒回调几十次,url 没变就跳过;
// url 变了(路径在流式中逐步长全:/m→/mnt→…→方案总览.html)就用更完整的取代旧的。
// **不锁定首帧**:早期 parsePartialJson 会给出半截路径,必须能被后续完整路径覆盖
//(这正是之前「沙箱卡在 /mnt、TEXT、空内容」的根因)。对齐第 2 步 / 主页行为。
const lastAutoOpenedUrlRef = useRef<string | null>(null);
// ── 对话气泡三段式(append → delta → finalize)+ 工具步骤,全部对齐第 2 步 ──
const appendStreamingDialogue = useCallback(
(card: {
sender: string;
avatarType: string;
agentThreadId?: string;
}): number => {
const id = Date.now() + Math.random();
setDialogues((prev) => [
...prev,
{
id,
sender: card.sender,
avatarType: card.avatarType,
content: "",
time: nowTime(),
confidence: "调度中",
streaming: true,
steps: [],
agentThreadId: card.agentThreadId,
},
]);
return id;
},
[],
);
const appendToDialogue = useCallback((id: number, deltaText: string) => {
if (!deltaText) return;
setDialogues((prev) =>
prev.map((d) =>
d.id === id ? { ...d, content: (d.content ?? "") + deltaText } : d,
),
);
}, []);
const setDialogueConfidence = useCallback((id: number, confidence: string) => {
setDialogues((prev) =>
prev.map((d) => (d.id === id ? { ...d, confidence } : d)),
);
}, []);
const appendStepIfNew = useCallback(
(
bubbleId: number,
label: string,
key: string,
args?: Record<string, unknown>,
) => {
setDialogues((prev) =>
prev.map((d) => {
if (d.id !== bubbleId) return d;
const steps: Step2Dialogue["steps"] = d.steps ?? [];
const last = steps[steps.length - 1];
if (last && last.key === key) {
// 同 phase 工具重复回调:args 在流式期间持续长大,拿最新覆盖。
if (args && args !== last.args) {
const next = [...steps];
next[next.length - 1] = { ...last, args };
return { ...d, steps: next };
}
return d;
}
return {
...d,
steps: [
...steps,
{ id: `${bubbleId}-${steps.length}`, key, label, ts: nowTime(), args },
],
};
}),
);
},
[],
);
const finalizeDialogue = useCallback(
(id: number, finalContent: string, confidence?: string) => {
setDialogues((prev) =>
prev.map((d) => {
if (d.id !== id) return d;
const next =
finalContent && finalContent.trim().length > 0
? finalContent
: d.content || "(已写入文件,无额外正文)";
return {
...d,
content: next,
confidence: confidence ?? d.confidence,
streaming: false,
};
}),
);
},
[],
);
// ── stub 写入(幂等,按 (toolName, path) 视为同一次调用,后续帧覆盖 args)──
// 仅 write_file 走这里(str_replace 不写 stub,见 handleArtifactToolPhase)。
const upsertStub = useCallback(
(threadId: string, toolName: string, args: Record<string, unknown>) => {
const path = typeof args.path === "string" ? args.path : "";
const current = stubRef.current[threadId] ?? [];
const base: StubAiMessage = current[0] ?? {
id: `roundtable-report-stub-${threadId}`,
type: "ai",
content: "",
tool_calls: [],
};
const idx = base.tool_calls.findIndex(
(tc) =>
tc.name === toolName &&
String((tc.args as { path?: unknown })?.path ?? "") === path,
);
const toolCallId =
idx >= 0 ? base.tool_calls[idx].id : `tc-${threadId}-${base.tool_calls.length}`;
// 防抹空:智能体「校验→重新绘制」时,重写的早期帧 content 可能是空/缺失(partial
// JSON 还没解析到 content),若直接覆盖会把已显示的完整 HTML 抹成空白。只要新内容
// 还是空、而旧内容非空,就保留旧内容;等重写真正吐出非空内容再替换。
let storeArgs = args;
if (idx >= 0) {
const newContent =
typeof args.content === "string" ? args.content : "";
const prevContent =
typeof (base.tool_calls[idx].args as { content?: unknown })?.content === "string"
? ((base.tool_calls[idx].args as { content?: string }).content as string)
: "";
if (!newContent.trim() && prevContent.trim()) {
storeArgs = { ...args, content: prevContent };
}
}
const nextTc = { id: toolCallId, name: toolName, args: storeArgs };
const nextToolCalls =
idx >= 0
? base.tool_calls.map((tc, i) => (i === idx ? nextTc : tc))
: [...base.tool_calls, nextTc];
const nextMsg: StubAiMessage = { ...base, tool_calls: nextToolCalls };
const nextMap = { ...stubRef.current, [threadId]: [nextMsg] };
stubRef.current = nextMap;
setSeatStubMessages(nextMap);
return { messageId: nextMsg.id, toolCallId };
},
[],
);
const buildWriteFileVirtualUrl = useCallback(
(path: string, messageId: string, toolCallId: string) =>
new URL(
`write-file:${path}?message_id=${encodeURIComponent(
messageId,
)}&tool_call_id=${encodeURIComponent(toolCallId)}`,
).toString(),
[],
);
/**
* 显式打开沙箱(用户点步骤 / 文件卡,或绘制收尾兜底)—— 始终打开、选中目标。
* 用户点了「结果绘制」意图明确,这里不走 autoOpen 闸门。
*/
const openArtifact = useCallback(
(threadId: string, virtualUrl: string) => {
lastAutoOpenedUrlRef.current = virtualUrl;
setActiveArtifactThreadId(threadId);
selectArtifact(virtualUrl, false);
setArtifactsOpen(true);
},
[selectArtifact, setArtifactsOpen],
);
/**
* 流式自动打开沙箱 —— url 与上次相同(仅内容在长大)就跳过,避免每秒重复 select;
* url 变了(路径逐步长全)就用更完整的取代。沙箱内容随 stub 更新自动刷新,无需重开。
*/
const autoOpenArtifact = useCallback(
(threadId: string, virtualUrl: string) => {
if (lastAutoOpenedUrlRef.current === virtualUrl) return;
openArtifact(threadId, virtualUrl);
},
[openArtifact],
);
// 反查某 path 是哪条 stub 写的 —— 关闭沙箱后再点文件卡也能复用内存内容做虚拟预览。
const findStubSource = useCallback(
(
rawPath: string,
): { threadId: string; messageId: string; toolCallId: string } | null => {
const target = rawPath.trim();
if (!target) return null;
const targetBase = target.split("/").pop() ?? target;
let basenameMatch:
| { threadId: string; messageId: string; toolCallId: string }
| null = null;
for (const [threadId, msgs] of Object.entries(stubRef.current)) {
const msg = msgs?.[0];
if (!msg) continue;
for (const tc of msg.tool_calls) {
if (tc.name !== "write_file" && tc.name !== "str_replace") continue;
const p =
typeof (tc.args as { path?: unknown })?.path === "string"
? ((tc.args as { path?: string }).path as string)
: "";
if (!p) continue;
if (p === target) return { threadId, messageId: msg.id, toolCallId: tc.id };
if (!basenameMatch && (p.split("/").pop() ?? p) === targetBase) {
basenameMatch = { threadId, messageId: msg.id, toolCallId: tc.id };
}
}
}
return basenameMatch;
},
[],
);
// 流式期间:phase==="tool_calling" 且 write_file/str_replace 有可读 path → 写 stub + 自动开沙箱。
const handleArtifactToolPhase = useCallback(
(
streamPhase: StreamPhase,
toolName: string | undefined,
args: Record<string, unknown> | undefined,
threadId: string,
) => {
if (streamPhase !== "tool_calling") return;
// 只让 write_file 驱动沙箱预览。str_replace 的 args 是 {path,old_str,new_str},
// **没有 content 字段** —— 若也给它建虚拟 URL 并切过去,沙箱读 args.content 得到
// undefined 就会变空白(用户「校验→修补」后沙箱被清空的根因)。str_replace 直接忽略,
// 沙箱继续显示已写入的 write_file 内容。
if (toolName !== "write_file") return;
const path = args?.path;
if (typeof path !== "string" || !path || !args) return;
// 守卫:流式早期 parsePartialJson 会给出「半截路径」(如 /mnt、/mnt/user-data/out),
// 它们 basename 没有扩展名 → 既会污染 stub,也会让沙箱卡在错的空文件。只有 basename
// 带扩展名(含个 `.`)的路径才认为「足够完整、可写 stub / 开沙箱」。完整 .html
// 出现后每帧 url 不变(仅内容长大),autoOpenArtifact 自动去重。
const basename = path.split("/").pop() ?? path;
if (!basename.includes(".")) return;
const { messageId, toolCallId } = upsertStub(threadId, toolName, args);
const virtualUrl = buildWriteFileVirtualUrl(path, messageId, toolCallId);
autoOpenArtifact(threadId, virtualUrl);
},
[upsertStub, buildWriteFileVirtualUrl, autoOpenArtifact],
);
// 兜底:收口阶段上游可能再发完整 tool_calls,持续把 args.content 灌进 stub(不自动开)。
const handleUpstreamFrame = useCallback(
(frame: unknown, threadId: string) => {
if (!Array.isArray(frame) || frame.length === 0) return;
const chunk = frame[0] as Record<string, unknown> | undefined;
const toolCalls = chunk?.tool_calls;
if (!Array.isArray(toolCalls)) return;
for (const tc of toolCalls) {
if (!tc || typeof tc !== "object") continue;
const o = tc as Record<string, unknown>;
const name = typeof o.name === "string" ? o.name : "";
// 同 handleArtifactToolPhase:只接 write_file(str_replace 无 content,忽略)。
if (name !== "write_file") continue;
const a = o.args;
if (!a || typeof a !== "object") continue;
const p = (a as Record<string, unknown>).path;
if (typeof p !== "string") continue;
// 只接「basename 带扩展名」的完整路径,过滤半截路径。
if (!(p.split("/").pop() ?? p).includes(".")) continue;
upsertStub(threadId, name, a as Record<string, unknown>);
}
},
[upsertStub],
);
// 点击文件卡打开沙箱:优先反查 stub 走虚拟预览,未命中再走真实 outputs 路径。
const openArtifactPreview = useCallback(
(threadId: string, path: string) => {
const normalized = path.trim();
if (!normalized) return;
const source = findStubSource(normalized);
if (source) {
const virtualUrl = buildWriteFileVirtualUrl(
normalized,
source.messageId,
source.toolCallId,
);
openArtifact(source.threadId, virtualUrl);
return;
}
if (!threadId) return;
setActiveArtifactThreadId(threadId);
setArtifacts([normalized]);
selectArtifact(normalized, false);
setArtifactsOpen(true);
},
[
findStubSource,
buildWriteFileVirtualUrl,
openArtifact,
setArtifacts,
selectArtifact,
setArtifactsOpen,
],
);
// 点击工具步骤打开沙箱。只对 write_file 直接建虚拟 URL;str_replace 无 content,
// 按 path 反查到 write_file 的内容来预览(修补不改虚拟 URL、不会清空沙箱)。
const openArtifactFromStep = useCallback(
(
dialogue: Step2Dialogue,
step: NonNullable<Step2Dialogue["steps"]>[number],
) => {
const threadId = dialogue.agentThreadId;
if (!threadId) return;
const [, toolName] = step.key.split(":");
const path = step.args?.path;
if (typeof path !== "string" || !path) return;
if (toolName === "write_file") {
const { messageId, toolCallId } = upsertStub(threadId, toolName, step.args!);
const virtualUrl = buildWriteFileVirtualUrl(path, messageId, toolCallId);
openArtifact(threadId, virtualUrl);
} else if (toolName === "str_replace") {
openArtifactPreview(threadId, path);
}
},
[upsertStub, buildWriteFileVirtualUrl, openArtifact, openArtifactPreview],
);
// 兜底:流式期间没捕捉到 write_file(极少见),收尾后从 outputs 列表直接选真实文件。
const openRealArtifactFallback = useCallback(
async (threadId: string) => {
try {
const res = await listSessionArtifacts(threadId);
const file =
res.files.find((f) => /\.html?$/i.test(f.name)) ?? res.files[0];
if (!file) return;
setActiveArtifactThreadId(threadId);
setArtifacts([file.path]);
selectArtifact(file.path, false);
setArtifactsOpen(true);
} catch {
/* 列文件失败就保持当前状态,不打断主流程 */
}
},
[setArtifacts, selectArtifact, setArtifactsOpen],
);
const abort = useCallback(() => {
aborterRef.current?.abort();
aborterRef.current = null;
runningRef.current = false;
}, []);
const run = useCallback(async () => {
if (runningRef.current) return;
runningRef.current = true;
setError(null);
setPhase("creating");
setDialogues([]);
bubbleIdRef.current = null;
lastAutoOpenedUrlRef.current = null;
stubRef.current = {};
setSeatStubMessages({});
const aborter = new AbortController();
aborterRef.current = aborter;
// 先建一条「调度中」气泡,让用户立刻看到报告智能体已启动(即便还没首 token)。
const bubbleId = appendStreamingDialogue({
sender: "方案总结智能体",
avatarType: "blue",
});
bubbleIdRef.current = bubbleId;
try {
const { agentName, threadId } = await initReportThread({ signal: aborter.signal });
if (aborter.signal.aborted) return;
setReportThreadId(threadId);
setActiveArtifactThreadId(threadId);
setDialogues((prev) =>
prev.map((d) => (d.id === bubbleId ? { ...d, agentThreadId: threadId } : d)),
);
setPhase("streaming");
const prompt = buildReportPrompt({ intentReady, seatDeliveries, consensusText });
const res = await streamMultiAgent({
agentType: "special",
agentName,
dAgentThreadId: { [agentName]: threadId },
displayName: "方案总结",
newMessage: prompt,
model: selectedModel || undefined,
signal: aborter.signal,
onTextDelta: (text) => appendToDialogue(bubbleId, text),
onPhaseChange: (p, toolName, args) => {
const label = reportPhaseLabel(p, toolName);
setDialogueConfidence(bubbleId, label);
appendStepIfNew(bubbleId, label, `${p}:${toolName ?? ""}`, args);
handleArtifactToolPhase(p, toolName, args, threadId);
},
onUpstreamFrame: (frame) => handleUpstreamFrame(frame, threadId),
});
if (aborter.signal.aborted) return;
// stub 里有 write_file 就说明流式期间已抓到产物;否则收尾从 outputs 兜底打开。
const hasStubFile = (stubRef.current[threadId]?.[0]?.tool_calls.length ?? 0) > 0;
if (!hasStubFile) await openRealArtifactFallback(threadId);
finalizeDialogue(bubbleId, res.content, "绘制完成");
setPhase("done");
// 绘制成功 → 把完整 HTML 等抛给上层存进草稿(step3)。从 stub 取最新 write_file 内容。
const writeTc = stubRef.current[threadId]?.[0]?.tool_calls.find(
(tc) => tc.name === "write_file",
);
const html =
typeof (writeTc?.args as { content?: unknown })?.content === "string"
? ((writeTc!.args as { content?: string }).content as string)
: "";
if (html.trim()) {
onReportSavedRef.current?.({
html,
generatedAt: new Date().toISOString(),
model: selectedModel || "",
summary: res.content || "",
});
}
} catch (err) {
if (aborter.signal.aborted) return;
const msg = err instanceof Error ? err.message : String(err);
setError(msg);
finalizeDialogue(bubbleId, `(绘制失败) ${msg}`, "绘制失败");
setPhase("error");
} finally {
runningRef.current = false;
}
}, [
intentReady,
seatDeliveries,
consensusText,
selectedModel,
appendStreamingDialogue,
appendToDialogue,
setDialogueConfidence,
appendStepIfNew,
finalizeDialogue,
handleArtifactToolPhase,
handleUpstreamFrame,
openRealArtifactFallback,
]);
const start = useCallback(() => {
void run();
}, [run]);
// 从草稿快照恢复:不跑 SSE,用保存的 HTML 重建一条「完成」气泡 + 合成 stub,
// 并打开沙箱预览。合成 thread id 只用于虚拟 URL 解析(不依赖原报告线程是否还在)。
const hydrateSaved = useCallback(
(snap: DraftStep3Snapshot) => {
abort();
runningRef.current = false;
const synthThreadId = "roundtable-report-restored";
const messageId = `roundtable-report-stub-${synthThreadId}`;
const toolCallId = `tc-${synthThreadId}-0`;
const stub: Record<string, StubAiMessage[]> = {
[synthThreadId]: [
{
id: messageId,
type: "ai",
content: "",
tool_calls: [
{
id: toolCallId,
name: "write_file",
args: { path: REPORT_OUTPUT_PATH, content: snap.html },
},
],
},
],
};
stubRef.current = stub;
lastAutoOpenedUrlRef.current = null;
setSeatStubMessages(stub);
setReportThreadId(synthThreadId);
setActiveArtifactThreadId(synthThreadId);
const bubbleId = Date.now() + Math.random();
bubbleIdRef.current = bubbleId;
setDialogues([
{
id: bubbleId,
sender: "方案总结智能体",
avatarType: "blue",
content: snap.summary?.trim() || "已从历史记录恢复上次生成的可视化研讨汇报。",
time: nowTime(),
confidence: "历史记录",
streaming: false,
steps: [
{
id: `${bubbleId}-0`,
key: "tool_calling:write_file",
label: "已绘制图表 HTML",
ts: nowTime(),
args: { path: REPORT_OUTPUT_PATH },
},
],
agentThreadId: synthThreadId,
},
]);
setError(null);
setPhase("done");
const virtualUrl = buildWriteFileVirtualUrl(REPORT_OUTPUT_PATH, messageId, toolCallId);
openArtifact(synthThreadId, virtualUrl);
},
[abort, buildWriteFileVirtualUrl, openArtifact],
);
const redraw = useCallback(() => {
abort();
void run();
}, [abort, run]);
return {
phase,
error,
reportThreadId,
dialogues,
seatStubMessages,
activeArtifactThreadId,
start,
hydrateSaved,
redraw,
abort,
openArtifactFromStep,
openArtifactPreview,
};
}
@@ -0,0 +1,72 @@
/**
* 业务链条(Business Chain)共享类型。
*
* 业务链条 = 一组**有序**的智能体编排模板,可命名、可复用、持久化在后端 MySQL
* (`/api/roundtable-chains`,按登录用户隔离)。在 Step 2 圆桌研讨里,总控会**按
* 链条顺序逐个派活**(见 `docs/multi-agent-business-chain-dev.md` §6)。
*
* 这里只放纯类型 / 常量,不含 React 状态或请求逻辑(CRUD 在 `api/chains.ts`)。
*/
/** 链条里的一个有序席位。`seats[0]` 是第一个发言者。 */
export interface ChainSeat {
agent_id: string;
/**
* 落库时的名称快照。展示时优先用实时 agent 数据(`listAgents()`),agent 被删
* 或改名时回退到此快照,避免链条卡片显示错乱。
*/
name: string;
/** 描述快照,同样作为实时数据的回退。 */
description?: string;
}
/** 一条可复用的业务链条(与后端 `/api/roundtable-chains` 记录对应)。 */
export interface BusinessChain {
id: string;
title: string;
description?: string;
/** 有序席位列表。 */
seats: ChainSeat[];
createdAt?: string;
updatedAt?: string;
}
/**
* 推荐弹窗(席位来源选择器)确认后回传给主页面的统一结果。
* 两种模式(智能推荐 / 业务链条)最终都产出**有序**的 `agents` + 一个 `mode`。
*/
export interface SeatSelectionResult {
/** 有序席位 —— 与现有 `handleConfirmAgents` 入参兼容。 */
agents: Array<{ agent_id: string; name: string }>;
/** 决定 Step 2 走哪种编排:自由派活 vs 按链条顺序派活。 */
mode: "recommend" | "chain";
/** chain 模式带上来源链条(用于草稿快照 / Step 2 展示链名)。 */
chain?: Pick<BusinessChain, "id" | "title">;
}
/** 链条席位数硬约束,与圆桌推荐弹窗的 `MIN_SELECTED`/`MAX_SELECTED` 对齐。 */
export const CHAIN_MIN_SEATS = 2;
export const CHAIN_MAX_SEATS = 8;
/**
* 把 `getAvatarTypeFor()` 产出的头像配色(blue/purple/emerald/amber/pink/human)
* 映射成 tag chip 的 tailwind 类(背景 + 文字 + 边框,含深色变体)。
* 配置页的智能体 tag 与链条选择卡片的席位 tag 复用同一套色,视觉统一。
*/
export function chainTagClasses(avatarType: string): string {
switch (avatarType) {
case "purple":
return "bg-purple-500/10 text-purple-700 border-purple-300 dark:text-purple-300 dark:border-purple-700/60";
case "emerald":
return "bg-emerald-500/10 text-emerald-700 border-emerald-300 dark:text-emerald-300 dark:border-emerald-700/60";
case "amber":
return "bg-amber-500/10 text-amber-700 border-amber-300 dark:text-amber-300 dark:border-amber-700/60";
case "pink":
return "bg-pink-500/10 text-pink-700 border-pink-300 dark:text-pink-300 dark:border-pink-700/60";
case "human":
return "bg-slate-500/10 text-slate-700 border-slate-300 dark:text-slate-300 dark:border-slate-600/60";
case "blue":
default:
return "bg-blue-500/10 text-blue-700 border-blue-300 dark:text-blue-300 dark:border-blue-700/60";
}
}
@@ -0,0 +1,926 @@
/**
* 业务链条配置页面(路由 `/page/workspace/roundtable/chains`)。
*
* 双栏编辑器:
* - 左栏:候选智能体列表(`listAgents()` + `filterRecommendCandidates` 复用,剔除
* intent/recommender/coordinator),支持**名称检索 + 标签筛选**(`TagSearchBar`,
* 服务端过滤),竖排卡片,可拖到右栏、也可点击快捷加入。
* - 右栏:当前链条的**有序**长条卡片(序号 + 名称 + 描述),可拖拽排序、上/下移、
* 删除单项;左栏拖入时按落点插入到对应位置。
*
* 拖拽用 `@dnd-kit`:右栏是 `SortableContext`(席位互相排序),左栏卡片是
* `useDraggable`(pool→右栏插入)。落点计算在 `onDragEnd`。
*
* 视觉:对齐设计稿 `yewuliantiao`(浅红 / 绛红风格)。统一走 app 语义 token
* (`primary` 在浅色主题即设计稿的绛红 #c25555,深色主题自动切蓝),配规范小圆角
* (输入/按钮/tag 2px、卡片/分栏 4px)+ 克制阴影。**不**套圆桌页的
* `roundtable-planning` scope。
*
* 持久化走 `api/chains.ts`(后端 MySQL `/api/roundtable-chains`)。席位数约束
* 2 ≤ N ≤ 8、agent 去重,保存时校验(后端 `_validate_seats` 也兜底)。
*
* 详见 `docs/multi-agent-business-chain-dev.md` §7 阶段 1。
*/
import { useCallback, useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { nanoid } from "nanoid";
import {
ArrowLeft,
Bot,
ChevronDown,
ChevronUp,
GripVertical,
ListOrdered,
Loader2,
Plus,
Save,
Trash2,
UserPlus,
X,
} from "lucide-react";
import {
DndContext,
DragOverlay,
PointerSensor,
closestCenter,
useDraggable,
useDroppable,
useSensor,
useSensors,
type DragEndEvent,
type DragStartEvent,
} from "@dnd-kit/core";
import {
SortableContext,
arrayMove,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { cn } from "@/lib/utils";
import { Input } from "@/components/ui/input";
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
import { listAgents } from "@/core/agents";
import {
filterRecommendCandidates,
type RecommendCandidate,
} from "@/roundtable-planning/api/recommend";
import {
createChain,
deleteChain,
listChains,
updateChain,
} from "@/roundtable-planning/api/chains";
import {
CHAIN_MAX_SEATS,
CHAIN_MIN_SEATS,
chainTagClasses,
type BusinessChain,
type ChainSeat,
} from "@/roundtable-planning/lib/business-chain";
import { getAvatarTypeFor } from "@/roundtable-planning/lib/roundtable-constants";
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 }
| null;
export default function BusinessChainEditorPage() {
const navigate = useNavigate();
// ─── 数据 ────────────────────────────────────────────────────────────
const [pool, setPool] = useState<RecommendCandidate[]>([]);
const [poolLoading, setPoolLoading] = useState(true);
const [chains, setChains] = useState<BusinessChain[]>([]);
// 左栏检索/筛选 —— 服务端过滤(listAgents 支持 search + tag_ids)。
const [search, setSearch] = useState("");
const [filterTagIds, setFilterTagIds] = useState<string[]>([]);
// ─── 当前编辑中的链条 ─────────────────────────────────────────────────
const [currentChainId, setCurrentChainId] = useState<string | null>(null);
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [seats, setSeats] = useState<ChainSeat[]>([]);
const [saving, setSaving] = useState(false);
const [activeDrag, setActiveDrag] = useState<ActiveDrag>(null);
const [dropdownOpen, setDropdownOpen] = useState(false);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
);
const poolById = useMemo(() => {
const m = new Map<string, RecommendCandidate>();
for (const a of pool) m.set(a.agent_id, a);
return m;
}, [pool]);
const seatIdSet = useMemo(() => new Set(seats.map((s) => s.agent_id)), [seats]);
// ─── 链条列表(一次性)─────────────────────────────────────────────────
const refreshChains = useCallback(async () => {
try {
setChains(await listChains());
} catch (err) {
console.warn("[chains] refresh list failed:", err);
}
}, []);
useEffect(() => {
void refreshChains();
}, [refreshChains]);
// ─── 候选池:随 search / tagIds 服务端过滤 ────────────────────────────
useEffect(() => {
let cancelled = false;
setPoolLoading(true);
void (async () => {
try {
const agents = await listAgents({
search: search || undefined,
tagIds: filterTagIds.length ? filterTagIds : undefined,
});
if (cancelled) return;
setPool(filterRecommendCandidates(agents));
} catch (err) {
if (cancelled) return;
toast.error(
`加载候选智能体失败:${err instanceof Error ? err.message : String(err)}`,
);
} finally {
if (!cancelled) setPoolLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [search, filterTagIds]);
// ─── 链条选择 / 新建 ──────────────────────────────────────────────────
const loadChain = useCallback((chain: BusinessChain) => {
setCurrentChainId(chain.id);
setTitle(chain.title);
setDescription(chain.description ?? "");
setSeats(chain.seats.map((s) => ({ ...s })));
}, []);
const startNewChain = useCallback(() => {
setCurrentChainId(null);
setTitle("");
setDescription("");
setSeats([]);
}, []);
// ─── 席位增删改 ───────────────────────────────────────────────────────
const addAgentToSeats = useCallback(
(agentId: string, atIndex?: number) => {
const agent = poolById.get(agentId);
if (!agent) return;
setSeats((prev) => {
if (prev.some((s) => s.agent_id === agentId)) {
toast.info(`「${agent.name}」已在链条中`);
return prev;
}
if (prev.length >= CHAIN_MAX_SEATS) {
toast.warning(`链条最多 ${CHAIN_MAX_SEATS} 个席位`);
return prev;
}
const seat: ChainSeat = {
agent_id: agent.agent_id,
name: agent.name,
description: agent.description,
};
const next = [...prev];
const i = atIndex == null ? next.length : Math.max(0, Math.min(atIndex, next.length));
next.splice(i, 0, seat);
return next;
});
},
[poolById],
);
const removeSeat = useCallback((agentId: string) => {
setSeats((prev) => prev.filter((s) => s.agent_id !== agentId));
}, []);
const moveSeat = useCallback((index: number, dir: -1 | 1) => {
setSeats((prev) => {
const target = index + dir;
if (target < 0 || target >= prev.length) return prev;
return arrayMove(prev, index, target);
});
}, []);
// ─── DnD ─────────────────────────────────────────────────────────────
const onDragStart = useCallback(
(e: DragStartEvent) => {
const id = String(e.active.id);
if (id.startsWith(POOL_PREFIX)) {
const agentId = id.slice(POOL_PREFIX.length);
const a = poolById.get(agentId);
setActiveDrag({ kind: "pool", agentId, name: a?.name ?? agentId });
} else if (id.startsWith(SEAT_PREFIX)) {
const agentId = id.slice(SEAT_PREFIX.length);
const s = seats.find((x) => x.agent_id === agentId);
setActiveDrag({ kind: "seat", agentId, name: s?.name ?? agentId });
}
},
[poolById, seats],
);
const onDragEnd = useCallback(
(e: DragEndEvent) => {
setActiveDrag(null);
const activeId = String(e.active.id);
const overId = e.over ? String(e.over.id) : null;
if (!overId) return;
const overSeatIndex = overId.startsWith(SEAT_PREFIX)
? seats.findIndex((s) => SEAT_PREFIX + s.agent_id === overId)
: -1;
if (activeId.startsWith(POOL_PREFIX)) {
const agentId = activeId.slice(POOL_PREFIX.length);
const insertAt = overSeatIndex >= 0 ? overSeatIndex : seats.length;
addAgentToSeats(agentId, insertAt);
return;
}
if (activeId.startsWith(SEAT_PREFIX)) {
const fromIndex = seats.findIndex(
(s) => SEAT_PREFIX + s.agent_id === activeId,
);
if (fromIndex < 0) return;
const toIndex = overSeatIndex >= 0 ? overSeatIndex : seats.length - 1;
if (fromIndex === toIndex) return;
setSeats((prev) => arrayMove(prev, fromIndex, toIndex));
}
},
[seats, addAgentToSeats],
);
// ─── 保存 / 删除 ──────────────────────────────────────────────────────
const seatCountInvalid =
seats.length < CHAIN_MIN_SEATS || seats.length > CHAIN_MAX_SEATS;
const canSave = title.trim().length > 0 && !seatCountInvalid && !saving;
const handleSave = useCallback(async () => {
if (!title.trim()) {
toast.warning("请先填写链条标题");
return;
}
if (seatCountInvalid) {
toast.warning(`链条席位数须在 ${CHAIN_MIN_SEATS}–${CHAIN_MAX_SEATS} 之间`);
return;
}
setSaving(true);
try {
const payloadSeats = seats.map((s) => ({
agent_id: s.agent_id,
name: poolById.get(s.agent_id)?.name ?? s.name,
description: poolById.get(s.agent_id)?.description ?? s.description ?? "",
}));
if (currentChainId) {
const updated = await updateChain(currentChainId, {
title: title.trim(),
description: description.trim() || undefined,
seats: payloadSeats,
});
if (!updated) {
toast.error("链条已不存在,可能被删除");
startNewChain();
await refreshChains();
return;
}
toast.success("已保存");
} else {
const id = nanoid();
const created = await createChain({
id,
title: title.trim(),
description: description.trim() || undefined,
seats: payloadSeats,
});
setCurrentChainId(created.id);
toast.success("已创建业务链条");
}
await refreshChains();
} catch (err) {
toast.error(`保存失败:${err instanceof Error ? err.message : String(err)}`);
} finally {
setSaving(false);
}
}, [
title,
description,
seats,
seatCountInvalid,
currentChainId,
poolById,
refreshChains,
startNewChain,
]);
const handleDelete = useCallback(async () => {
if (!currentChainId) {
startNewChain();
return;
}
if (!window.confirm(`确认删除业务链条「${title || "未命名"}」?此操作不可恢复。`)) {
return;
}
try {
await deleteChain(currentChainId);
toast.success("已删除");
startNewChain();
await refreshChains();
} catch (err) {
toast.error(`删除失败:${err instanceof Error ? err.message : String(err)}`);
}
}, [currentChainId, title, startNewChain, refreshChains]);
// ─── Render ──────────────────────────────────────────────────────────
const seatItemIds = seats.map((s) => SEAT_PREFIX + s.agent_id);
const triggerLabel = currentChainId ? title || "未命名链条" : "+ 新建链条…";
return (
<div className="flex h-full min-h-0 flex-col bg-muted/40 text-foreground">
{/* 顶栏 */}
<header className="flex shrink-0 flex-wrap items-center gap-3 border-b border-border bg-card px-6 py-3">
<button
type="button"
onClick={() => navigate("/page/workspace/roundtable/planning")}
className={cn(
"flex items-center gap-1.5 border border-border bg-card px-3 py-1.5 text-sm font-medium text-muted-foreground transition hover:bg-muted hover:text-foreground active:scale-[0.98]",
R_CTRL,
)}
>
<ArrowLeft className="size-4" />
返回圆桌
</button>
{/* 品牌标识 */}
<div className="flex items-center gap-2.5">
<div
className={cn(
"flex size-8 select-none items-center justify-center bg-gradient-to-tr from-primary to-primary/40 text-sm font-bold text-primary-foreground shadow-sm",
R_CARD,
)}
>
🪶
</div>
<div className="leading-tight">
<div className="text-base font-bold text-foreground">业务链条配置</div>
<div className="text-[11px] font-medium tracking-wide text-muted-foreground">
编排有序的智能体研讨链路
</div>
</div>
</div>
{/* 链条选择下拉 */}
<div className="relative ml-2">
<button
type="button"
onClick={() => setDropdownOpen((o) => !o)}
className={cn(
"flex min-w-[150px] items-center justify-between gap-3 border border-border bg-card px-3 py-1.5 text-sm text-foreground transition hover:border-primary/50 focus:outline-none focus:ring-1 focus:ring-primary/30",
R_CTRL,
)}
>
<span className="truncate font-medium">{triggerLabel}</span>
<ChevronDown className="size-4 text-muted-foreground" />
</button>
{dropdownOpen && (
<>
<div className="fixed inset-0 z-30" onClick={() => setDropdownOpen(false)} />
<div
className={cn(
"absolute left-0 z-40 mt-1 max-h-[280px] w-[260px] overflow-y-auto border border-border bg-card py-1 shadow-lg",
R_CARD,
)}
>
<div className="border-b border-border px-3 py-1 text-xs font-semibold text-muted-foreground">
切换已有链条
</div>
{chains.length === 0 ? (
<div className="px-3 py-2 text-xs text-muted-foreground">暂无已保存链条</div>
) : (
chains.map((c) => (
<button
key={c.id}
type="button"
onClick={() => {
loadChain(c);
setDropdownOpen(false);
}}
className={cn(
"flex w-full items-center justify-between px-3 py-2 text-xs transition hover:bg-primary/5",
c.id === currentChainId
? "bg-primary/5 font-bold text-primary"
: "text-muted-foreground",
)}
>
<span className="truncate">
{c.title || "未命名链条"}({c.seats.length} 席)
</span>
{c.id === currentChainId && (
<span className={cn("ml-2 shrink-0 bg-primary/15 px-1.5 py-0.5 text-[10px] text-primary", R_CTRL)}>
当前
</span>
)}
</button>
))
)}
<div className="mt-1 border-t border-border pt-1">
<button
type="button"
onClick={() => {
startNewChain();
setDropdownOpen(false);
}}
className="flex w-full items-center gap-1.5 px-3 py-2 text-xs font-medium text-primary transition hover:bg-primary/5"
>
<Plus className="size-3.5" />
新建另一条链路
</button>
</div>
</div>
</>
)}
</div>
{/* 快捷新建 */}
<button
type="button"
onClick={startNewChain}
className={cn(
"flex items-center gap-1.5 border border-border bg-card px-3 py-1.5 text-sm text-muted-foreground transition hover:bg-muted hover:text-foreground active:scale-[0.98]",
R_CTRL,
)}
>
<Plus className="size-4" />
新建
</button>
{/* 右侧操作 */}
<div className="ml-auto flex items-center gap-2">
<button
type="button"
onClick={handleDelete}
className={cn(
"flex items-center gap-1.5 border border-primary/30 bg-card px-3 py-1.5 text-sm font-medium text-primary transition hover:bg-primary/5 active:scale-[0.98]",
R_CTRL,
)}
>
<Trash2 className="size-4" />
删除
</button>
<button
type="button"
onClick={handleSave}
disabled={!canSave}
className={cn(
"flex items-center gap-1.5 bg-primary px-4 py-1.5 text-sm font-medium text-primary-foreground shadow-sm transition hover:bg-primary/90 active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-50",
R_CTRL,
)}
>
{saving ? <Loader2 className="size-4 animate-spin" /> : <Save className="size-4" />}
保存
</button>
</div>
</header>
{/* 标题 + 描述 */}
<section className="shrink-0 border-b border-border bg-card px-6 py-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div
className={cn(
"flex items-stretch overflow-hidden border border-border bg-card transition focus-within:border-primary focus-within:ring-1 focus-within:ring-primary/30",
R_CTRL,
)}
>
<span className="flex min-w-[64px] select-none items-center justify-center border-r border-border bg-muted/50 px-4 text-sm font-medium text-muted-foreground">
标题
</span>
<Input
value={title}
onChange={(e) => 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"
/>
</div>
<div
className={cn(
"flex items-stretch overflow-hidden border border-border bg-card transition focus-within:border-primary focus-within:ring-1 focus-within:ring-primary/30",
R_CTRL,
)}
>
<span className="flex min-w-[64px] select-none items-center justify-center border-r border-border bg-muted/50 px-4 text-sm font-medium text-muted-foreground">
描述
</span>
<Input
value={description}
onChange={(e) => 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"
/>
</div>
</div>
</section>
{/* 双栏拖拽编辑器 */}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
>
<main className="grid min-h-0 flex-1 grid-cols-1 gap-6 overflow-hidden px-6 py-6 lg:grid-cols-12">
{/* 左栏:候选智能体 */}
<section
className={cn(
"flex min-h-0 flex-col overflow-hidden border border-border bg-card lg:col-span-4",
R_CARD,
PANEL_SHADOW,
)}
>
<div className="flex shrink-0 items-center justify-between border-b border-border bg-muted/40 px-5 py-4">
<div className="flex items-center gap-2">
<span className={cn("flex size-5 items-center justify-center bg-primary/10", R_CTRL)}>
<UserPlus className="size-3.5 text-primary" />
</span>
<h2 className="text-sm font-bold tracking-wide text-foreground">候选智能体</h2>
<span
className={cn(
"min-w-[20px] border border-primary/30 bg-primary/10 px-2 py-0.5 text-center text-xs font-bold text-primary tabular-nums",
"rounded-full",
)}
>
{pool.length}
</span>
</div>
<span className="text-[11px] font-medium text-muted-foreground">拖到右侧 / 点击加入</span>
</div>
<div className="shrink-0 border-b border-border px-4 py-3">
<TagSearchBar
targetType="agent"
onSearchChange={setSearch}
tagIds={filterTagIds}
onTagIdsChange={setFilterTagIds}
/>
</div>
<div className="min-h-0 flex-1 space-y-2.5 overflow-y-auto p-4">
{poolLoading ? (
<div className="flex items-center gap-2 px-2 py-6 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
正在加载候选智能体…
</div>
) : pool.length === 0 ? (
<div className="px-2 py-12 text-center text-sm text-muted-foreground">
{search || filterTagIds.length
? "没有匹配的智能体,换个关键词或标签试试。"
: `候选池为空,请先在「智能体」里创建至少 ${CHAIN_MIN_SEATS} 个智能体。`}
</div>
) : (
pool.map((a) => (
<PoolAgentCard
key={a.agent_id}
agent={a}
inChain={seatIdSet.has(a.agent_id)}
onAdd={() => addAgentToSeats(a.agent_id)}
/>
))
)}
</div>
</section>
{/* 右栏:有序席位 */}
<SeatsColumn
seats={seats}
seatItemIds={seatItemIds}
seatCountInvalid={seatCountInvalid}
onRemove={removeSeat}
onMove={moveSeat}
/>
</main>
<DragOverlay>
{activeDrag ? (
<div
className={cn(
"flex items-center gap-2 border border-primary/50 bg-card px-3 py-2 text-sm font-medium text-foreground shadow-[0_4px_12px_rgba(15,23,42,0.12)]",
R_CTRL,
)}
>
<GripVertical className="size-4 text-primary" />
{activeDrag.name}
</div>
) : null}
</DragOverlay>
</DndContext>
{/* 页脚 */}
<footer className="shrink-0 select-none border-t border-border bg-card px-6 py-3 text-center text-[11px] text-muted-foreground">
编排有序的业务链条,让智能体决策更有深度。保存后,配置将自动同步至圆桌。
</footer>
</div>
);
}
// ── 左栏候选卡片(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 (
<div
ref={setNodeRef}
onClick={inChain ? undefined : onAdd}
className={cn(
"group relative flex items-start gap-3 border p-3.5 transition-all duration-150",
R_CARD,
inChain
? "cursor-default border-primary/30 bg-primary/[0.03]"
: "cursor-pointer border-border bg-card hover:border-primary hover:shadow-[0_2px_8px_rgba(0,0,0,0.04)]",
isDragging && "opacity-30",
)}
{...(inChain ? {} : listeners)}
{...attributes}
>
<div
className={cn(
"mt-0.5 flex size-6 shrink-0 items-center justify-center transition-colors",
R_CARD,
inChain
? "bg-primary/10 text-primary"
: "bg-muted text-muted-foreground group-hover:bg-primary/10 group-hover:text-primary",
)}
>
<Bot className="size-3.5" />
</div>
<div className="min-w-0 flex-1 pr-16">
<div className="truncate text-xs font-bold leading-tight text-foreground transition-colors group-hover:text-primary">
{agent.name || agent.agent_id}
</div>
{agent.description && (
<div className="mt-1 line-clamp-2 text-[11px] font-medium leading-normal text-muted-foreground">
{agent.description}
</div>
)}
</div>
<div className="absolute right-3.5 top-3.5 shrink-0 select-none">
{inChain ? (
<span className={cn("border border-primary/30 bg-primary/10 px-2 py-1 text-[10px] font-bold text-primary", R_CTRL)}>
已加入
</span>
) : (
<span
className={cn(
"border border-transparent bg-muted px-2 py-1 text-[10px] font-semibold text-muted-foreground transition-all group-hover:border-primary/30 group-hover:bg-primary/10 group-hover:text-primary",
R_CTRL,
)}
>
点击加入
</span>
)}
</div>
</div>
);
}
// ── 右栏(droppable 容器 + sortable 列表)───────────────────────────────
function SeatsColumn({
seats,
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 (
<section
className={cn(
"flex min-h-0 flex-col overflow-hidden border border-border bg-card lg:col-span-8",
R_CARD,
PANEL_SHADOW,
)}
>
<div className="flex shrink-0 items-center justify-between border-b border-border bg-muted/40 px-5 py-4">
<div className="flex items-center gap-2">
<span className={cn("flex size-5 items-center justify-center bg-primary/10", R_CTRL)}>
<ListOrdered className="size-3.5 text-primary" />
</span>
<h2 className="text-sm font-bold tracking-wide text-foreground">链条席位</h2>
<span className={cn("border border-border bg-muted px-2 py-0.5 text-[10px] font-bold text-muted-foreground", R_CTRL)}>
按顺序发言
</span>
</div>
<div className="flex select-none items-center gap-1 text-xs">
<span className="font-medium text-muted-foreground">配置席位</span>
<span
className={cn(
"border px-2 py-0.5 font-bold tabular-nums",
R_CTRL,
seatCountInvalid
? "border-destructive/30 bg-destructive/10 text-destructive"
: "border-primary/30 bg-primary/10 text-primary",
)}
>
{seats.length} / {CHAIN_MAX_SEATS}
{seats.length < CHAIN_MIN_SEATS && ` · 至少 ${CHAIN_MIN_SEATS}`}
</span>
</div>
</div>
<div
ref={setNodeRef}
className={cn(
"min-h-0 flex-1 space-y-3 overflow-y-auto p-5 transition-colors",
isOver && "bg-primary/[0.03]",
)}
>
{seats.length === 0 ? (
<div
className={cn(
"m-1 flex h-full flex-col items-center justify-center border border-dashed border-border bg-card/50 px-8 py-16 text-center",
R_CARD,
)}
>
<div className="mb-3 flex size-12 items-center justify-center rounded-full bg-primary/10 text-primary">
<ListOrdered className="size-6" />
</div>
<h3 className="mb-1 text-sm font-bold text-foreground">暂无席位</h3>
<p className="max-w-sm text-xs leading-relaxed text-muted-foreground">
该业务链条当前为空。请从左侧「候选智能体」点击或拖拽,将其按顺序编排加入此处。
</p>
</div>
) : (
<SortableContext items={seatItemIds} strategy={verticalListSortingStrategy}>
{seats.map((seat, index) => (
<SeatStrip
key={seat.agent_id}
seat={seat}
index={index}
total={seats.length}
onRemove={() => onRemove(seat.agent_id)}
onMove={(dir) => onMove(index, dir)}
/>
))}
</SortableContext>
)}
</div>
</section>
);
}
// ── 单个席位长条(sortable)─────────────────────────────────────────────
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 (
<div
ref={setNodeRef}
style={{ transform: CSS.Transform.toString(transform), transition }}
className={cn(
"flex items-stretch overflow-hidden border border-border bg-card",
R_CARD,
PANEL_SHADOW,
isDragging && "z-10 opacity-70 shadow-[0_4px_12px_rgba(15,23,42,0.12)]",
)}
>
{/* 拖拽手柄 */}
<button
type="button"
className="flex w-7 shrink-0 cursor-grab touch-none items-center justify-center border-r border-border bg-muted/40 text-muted-foreground/60 transition-colors hover:text-foreground active:cursor-grabbing"
{...attributes}
{...listeners}
title="拖动排序"
>
<GripVertical className="size-3.5" />
</button>
{/* 序号 */}
<div className="flex shrink-0 select-none items-center pl-2.5">
<span
className={cn(
"flex size-6 items-center justify-center border text-xs font-bold tabular-nums",
R_CTRL,
chainTagClasses(avatarType),
)}
>
{index + 1}
</span>
</div>
{/* 详情 */}
<div className="flex min-w-0 flex-1 flex-col justify-center px-2.5 py-2">
<div className="truncate text-xs font-bold text-foreground">{seat.name || seat.agent_id}</div>
{seat.description && (
<div className="mt-0.5 line-clamp-1 text-[11px] font-medium text-muted-foreground">
{seat.description}
</div>
)}
</div>
{/* 控制:上移 / 下移 / 移除 */}
<div className="flex shrink-0 items-center gap-0.5 border-l border-border px-2">
<button
type="button"
onClick={() => onMove(-1)}
disabled={isFirst}
title="上移席位"
className={cn(
"p-1 transition",
R_CTRL,
isFirst
? "pointer-events-none text-muted-foreground/30"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<ChevronUp className="size-3.5" />
</button>
<button
type="button"
onClick={() => onMove(1)}
disabled={isLast}
title="下移席位"
className={cn(
"p-1 transition",
R_CTRL,
isLast
? "pointer-events-none text-muted-foreground/30"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<ChevronDown className="size-3.5" />
</button>
<button
type="button"
onClick={onRemove}
title="移出此席位"
className={cn(
"ml-0.5 p-1 text-muted-foreground/60 transition-colors hover:bg-primary/10 hover:text-primary",
R_CTRL,
)}
>
<X className="size-3.5" />
</button>
</div>
</div>
);
}
@@ -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,
@@ -56,6 +58,7 @@ import { RecommendAgentsDialog } from "@/roundtable-planning/components/Recommen
import { Step1Panel } from "@/roundtable-planning/components/Step1Panel";
import { Step2Panel } from "@/roundtable-planning/components/Step2Panel";
import { Step3Panel } from "@/roundtable-planning/components/Step3Panel";
import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts";
import {
useStep1Intent,
type InteractiveMessage,
@@ -69,10 +72,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,8 +86,20 @@ export default function RoundtablePlanningPage() {
const [showToast, setShowToast] = useState<string | null>(null);
const [isRecommendDialogOpen, setIsRecommendDialogOpen] = useState<boolean>(false);
const [selectedModel, setSelectedModel] = useState<string>("");
// 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);
// Step 3「结果绘制」结果 —— 持久化进草稿 step3。绘制完成由 onReportSaved 写入;
// 加载草稿时 hydrateStep3 恢复;新建任务清空。step3SavedTick 递增触发一次草稿自动保存。
const [step3Report, setStep3Report] = useState<DraftStep3Snapshot | null>(null);
const [step3SavedTick, setStep3SavedTick] = useState<number>(0);
// 角色面板(LeftSidebar)。原页面把它当成两步共用的展示数据,这里继续放在
// 主页 —— Step 1 / Step 2 / Step 3 都会读 / 写它,任何 hook 单独持有都不合适。
const [roles, setRoles] = useState<RoleEntry[]>([]);
@@ -154,6 +171,7 @@ export default function RoundtablePlanningPage() {
const step2 = useStep2Orchestration({
currentStep,
selectedModel,
orchestrationMode,
buildInitialIntent,
triggerToast,
setRoles,
@@ -189,6 +207,9 @@ export default function RoundtablePlanningPage() {
triggerToast,
intentSending: step1.isIntentSending,
step2FinalizedCount,
step3SavedTick,
getStep3Snapshot: () => step3Report,
hydrateStep3: (snapshot) => setStep3Report(snapshot),
getStep1Snapshot: () => ({
intentThreadId: step1.intentThreadId,
intentReady: step1.intentReady,
@@ -205,9 +226,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 +296,33 @@ 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);
setStep3Report(null);
step1.stopIntentStream();
drafts.loadedFromDraftRef.current = false;
drafts.setCurrentDraftId(null);
@@ -353,6 +392,16 @@ export default function RoundtablePlanningPage() {
/>
<div className="flex items-center gap-2">
<button
id="business-chain-btn"
onClick={() => navigate("/page/workspace/roundtable/chains")}
title="配置可复用的业务链条(有序智能体编排)"
className="bg-white hover:bg-slate-50 border border-[#e2e8f0] text-[#334155] text-xs font-bold px-3 py-2 rounded-xl flex items-center gap-1.5 transition dark:border-border dark:bg-secondary dark:text-foreground dark:hover:bg-accent"
>
<ListOrdered className="w-3.5 h-3.5 text-primary" />
业务链条
</button>
<button
id="new-task-btn"
onClick={handleStartNewTask}
@@ -540,6 +589,16 @@ export default function RoundtablePlanningPage() {
hasConsensus={step2.hasConsensus}
step2RoundtableDialogues={step2.step2RoundtableDialogues}
agentNameToRole={step2.agentNameToRole}
intentReady={step1.intentReady}
selectedModel={selectedModel}
availableModels={availableModels}
modelsLoading={modelsLoading}
setSelectedModel={setSelectedModel}
savedReport={step3Report}
onReportSaved={(snap) => {
setStep3Report(snap);
setStep3SavedTick((t) => t + 1);
}}
onReset={handleStartNewTask}
/>
)}
+2 -2
View File
@@ -17,8 +17,8 @@ SERPER_API_KEY=your-serper-api-key
# AUTH_JWT_SECRET must stay stable across restarts, or existing logins expire.
# GATEWAY_CORS_ORIGINS must include the exact frontend origins, separated by commas.
AUTH_JWT_SECRET=i3_BiBK44sYHiFIkChhDs46y57ZewJKO3diQDw01anI
GATEWAY_CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
GATEWAY_CORS_ALLOW_ALL=1
GATEWAY_CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173,http://localhost:5174,http://127.0.0.1:5174
GATEWAY_CORS_ALLOW_ALL=0
GATEWAY_ENABLE_DOCS=true
GATEWAY_WORKERS=2
DEER_FLOW_AUTH_DISABLED=false
@@ -40,9 +40,11 @@ from app.gateway.routers import (
models,
multi_agent,
notifications,
public_embed,
public_scheduled_tasks,
recommend,
recommended_questions,
roundtable_chains,
roundtable_drafts,
runs,
scheduled_tasks,
@@ -806,6 +808,10 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
dev_origins = [
"http://localhost:5173",
"http://127.0.0.1:5173",
"http://localhost:5174",
"http://127.0.0.1:5174",
"http://localhost:3000",
"http://127.0.0.1:3000",
]
logger.warning(
"No CORS configuration found; enabling local dev origins only: %s. "
@@ -854,6 +860,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# Roundtable-planning drafts + recommendation history at /api/roundtable-drafts
app.include_router(roundtable_drafts.router)
# Roundtable-planning business chains at /api/roundtable-chains
app.include_router(roundtable_chains.router)
# Suggestions API is mounted at /api/threads/{thread_id}/suggestions
app.include_router(suggestions.router)
@@ -871,6 +880,8 @@ 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)
app.include_router(html_page_favorites.router)
# System settings API is mounted at /api/system-settings
@@ -134,6 +134,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,
@@ -160,6 +164,10 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.roundtable_draft_store = make_roundtable_draft_store(sf)
from deerflow.persistence.roundtable_chains import make_roundtable_chain_store
app.state.roundtable_chain_store = make_roundtable_chain_store(sf)
# Run event store (has its own factory with config-driven backend selection)
run_events_config = getattr(config, "run_events", None)
app.state.run_event_store = make_run_event_store(run_events_config)
@@ -273,6 +281,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)
@@ -60,16 +60,21 @@ logger = logging.getLogger(__name__)
INTENT_AGENT_ID = "roundtable-intent"
RECOMMENDER_AGENT_ID = "roundtable-recommender"
COORDINATOR_AGENT_ID = "roundtable-coordinator"
# Step 3「结果绘制」用的方案可视化总结智能体:读各席位交付 + 共识,画自包含图表 HTML。
# 与上面三个一样是内置单例,种子化一次常驻磁盘+DB,绝不每次调用重建。
REPORT_AGENT_ID = "roundtable-report"
# 验证版 SOUL.md + config.yaml 的源码资源目录(随 git 发布)。每个子目录名即 agent id,
# 内含该 agent 的 config.yaml 与 SOUL.md,是 seeder 落盘时字节级复制的来源。
_ASSETS_DIR = Path(__file__).parent / "_roundtable_seed_assets"
# 这三个功能型 agent 任何部署都必须存在;顺序无关紧要(逐个独立补建)。
# 这几个功能型/内置 agent 任何部署都必须存在;顺序无关紧要(逐个独立补建)。
# 前三个支撑 Step 1 澄清 / 推荐 / Step 2 总控;roundtable-report 支撑 Step 3 结果绘制。
_FUNCTIONAL_AGENT_IDS: tuple[str, ...] = (
INTENT_AGENT_ID,
RECOMMENDER_AGENT_ID,
COORDINATOR_AGENT_ID,
REPORT_AGENT_ID,
)
# seeder 会复制资源目录里的这些文件(存在即复制,缺失则跳过)。
@@ -0,0 +1,76 @@
# 圆桌方案可视化研讨汇报智能体
## 核心定位
你是多智能体圆桌研讨 **Step 3** 的「方案可视化研讨汇报智能体」。各席位(情报收集 / 环境评估 / 方案设计 / 风险审查 / 执行规划 / 总结陈述等)已经完成研讨并产出交付,总控也给出了最终共识。
你的**唯一职责**:把这些文字成果归纳、提炼,**撰写成一份图文并茂、以文字叙述为主线、图表点睛的 HTML 研讨汇报**,用 `write_file` 工具写入到 `/mnt/user-data/outputs/方案总览.html`。
这份汇报是给**用户阅读**的——目的是让用户能清晰、轻松地看懂「这群智能体讨论出了什么方案、为什么是它」。所以**可读性第一**:用通顺的中文把研讨脉络和方案讲清楚,再用图表辅助理解。
你**不**调研、**不**联网搜索、**不**派活、**不**追问用户——只做归纳、撰写与可视化。
## 输入说明
用户消息里会给你三部分原料(可能有缺省):
1. **任务意图摘要**:目标、约束、关键假设(来自 Step 1);
2. **各席位最终交付**:每个席位的名称 + 它产出的完整 markdown 正文;
3. **总控最终共识结论**。
请通读全部原料:一方面**提炼每个席位的核心观点/交付**用于文字叙述;另一方面**抽取可量化、可结构化的信息**(指标、阶段、里程碑、风险项、预算/资源占比、优劣势等)来驱动图表。
## 产出要求(最重要)
### 1. 必须是「自包含、离线可用」的单文件 HTML
- 所有 CSS 内联在 `<style>`,所有图表用**纯 inline SVG** 直接画(坐标、路径、文字都由你计算好写死);
- **绝对禁止**引用任何外部资源:不要 `<script src=...>` 引 CDN(Chart.js/ECharts/D3 等一律不行)、不要外链字体/图片/CSS。本系统离线部署,外链一律加载失败;
- 如确需交互/动画,只能用**内联的原生 JS**(`<script>...</script>` 里自己写),但默认优先纯 SVG 静态图,稳定优先;
- 字体用系统字体栈(`system-ui, -apple-system, "Microsoft YaHei", sans-serif`)。
### 2. 报告结构(**研讨叙事型**,文字为主线、图表点睛)
按下面的章节顺序组织,**文字叙述是骨架**,图表穿插在最契合的章节里辅助理解。缺料的章节可省略或合并:
| # | 章节 | 内容(文字) | 配图建议 |
|---|------|------|----------|
| ① | 报告头 | 标题 + 生成时间占位 + 「AI 自动生成」标签 | — |
| ② | 一句话结论 + 关键指标卡 | 用一句话点明最终方案/结论 | 4–6 个 KPI 数字卡(共识度、参与席位数、识别风险数、里程碑数、预估周期、预算规模等) |
| ③ | 任务背景与目标 | 2–4 句交代要解决什么、目标与关键约束 | 可选:目标/约束要点列表 |
| ④ | 各席位观点与交付 | **每个席位一段提炼文字**(这位专家关注什么、给出了什么关键结论/建议),用小标题+正文,让读者读懂每方的贡献 | 关键席位视情况配小图(如情报席位的对比、环境席位的承载评估) |
| ⑤ | 综合分析 | 把跨席位的信息串起来分析(风险全景、资源权衡、阶段安排等) | **此处是图表主战场**:风险概率×影响矩阵、资源/预算饼图、方案对比柱状、各席位贡献雷达等 |
| ⑥ | 总控最终共识方案 | **重点章节**,详写最终拍板的方案是什么、核心要点、为什么这么定 | 可选:共识达成度环形仪表 |
| ⑦ | 执行路线 | 落地步骤/阶段说明 | 甘特图 / 阶段时间线(阶段条 + 里程碑点) |
每个图表都要有**标题、图例、坐标轴或数值标注**,并在图旁配**一两句文字解读**,让图不孤立。
### 3. 图表分量:按需点睛,但不要"全是文字"
- **详略得当**:文字承载论证与叙述,图表承载可量化/可结构化的信息。不要为了凑数硬画无意义的图,也**不要因为缺数据就完全不画**。
- **没有明确数字时也要给图**:基于文字内容**合理提炼/估算**出图(如把定性的高/中/低映射成评分、把阶段描述排成时间线、把优劣势排成 SWOT 四象限),并**显式标注「定性评估 / 据研讨内容估算」**,不要凭空编造与原料矛盾的数据。
- 目标手感:全篇**穿插 3–5 个**有信息量的图表,分布在 ②⑤⑥⑦ 等章节,使页面图文并茂、读起来不枯燥。
常用图表素材库(按章节按需取用,能画哪些画哪些):
共识达成度 → 环形进度仪表;各席位贡献画像 → 雷达图(完整性/可行性/风险覆盖/创新性/落地性);风险评估 → 概率×影响气泡矩阵;执行路线 → 甘特/时间线;资源预算 → 饼图/环形图;方案权衡 → SWOT 四象限或对比柱状;决策链路 → 流程图(链条模式尤其适用)。
### 4. 视觉风格
- 主色对齐圆桌的**浅红 / 绛红**主题:主色 `#c25555`,浅底 `#fef4f3`,描边 `#ede9e9`,文字 `#1f2937`,辅助灰 `#717b88`;图表配色在主色基础上取同色系/邻近色,保持克制专业;
- 整体是**研讨汇报**版式:报告头 + 分章节卡片化分区、清晰的章节标题层级、正文行距舒适、留白充足、响应式(`max-width` 容器 + 网格自适应),打印友好;
- 文字排版讲究可读性:段落不要过长,关键结论可用强调色/加粗,列表化呈现要点。
## 工作流程
1. 通读原料,在思考里列两份清单:**各席位要点**(用于文字叙述)+ **可量化信息**(用于图表,含需要估算的项);
2. **用一次 `write_file` 把完整 HTML 文本直接写入** `/mnt/user-data/outputs/方案总览.html`(`description` 写「方案可视化研讨汇报」)。
- `write_file` **支持很长的内容,不会截断**——请放心一次性把整份 HTML 写进去;
- **严禁**写 Python / shell / 任何脚本去"生成"HTML,也**严禁**用 bash 跑脚本产出文件——你必须自己把 HTML 文本直接写进 `write_file`;
- **严禁**把 HTML 拆成多个文件或分多次拼接,就一个 `方案总览.html`;
- 控制好体量:聚焦叙述 + 3–5 个图表,别堆砌到写不完。
3. 写完后,用**一两句话**向用户说明「已生成研讨汇报,含 X 个章节、Y 个图表」即可——**不要**把 HTML 源码贴进对话,也**不要**再去读取/校验文件。
> **修改/返工规则**:如果写完后校验发现问题需要修正,请**用 `write_file` 整文件重写**到同一路径,**不要用 `str_replace` 做局部替换**。整文件重写能让右侧预览实时刷新;局部替换不会反映到预览里。
## 禁止事项
- ❌ 引用任何外部 CDN / 外链资源(离线必然失败)
- ❌ 把大段 HTML 源码输出到对话正文(只写文件 + 一句话总结)
- ❌ 联网搜索、派活给其他智能体、反问用户
- ❌ 通篇只有图表、没有像样的文字叙述(这是给用户读的汇报,不是纯看板)
- ❌ 通篇只有文字、完全不配图(缺数据也要合理估算出图并标注)
- ❌ 写 Python / shell / 任何脚本去生成 HTML,或用 bash 跑脚本产出文件(必须自己把 HTML 文本直接写进 `write_file`)
- ❌ 把 HTML 拆成多文件或分多次拼接、或写完后再去读取/校验文件
- ❌ 用 `str_replace` 局部修改 HTML(返工请整文件 `write_file` 重写)
- ❌ 编造与原料明显矛盾的数据
@@ -0,0 +1,3 @@
description: 圆桌方案可视化总结智能体,读取各席位的最终交付与总控共识,绘制一个自包含、含多种图表的 HTML 可视化看板(写入 outputs/方案总览.html)。本身不调研、不联网、不派活,只做归纳与可视化。
id: roundtable-report
name: 方案总结
@@ -59,6 +59,7 @@ from typing import Any, AsyncIterator
import httpx
from app.gateway.routers._roundtable_seed import (
COORDINATOR_AGENT_ID,
REPORT_AGENT_ID,
ensure_roundtable_functional_agents,
)
from deerflow.config import get_app_config
@@ -822,6 +823,25 @@ async def init_multi_agent(request: Request, payload: dict = Body(...)) -> Strea
)
@router.post("/report/init")
async def init_report_thread(request: Request) -> dict[str, str]:
"""为 Step 3「结果绘制」创建一个供方案可视化总结智能体(``roundtable-report``)
运行的线程,返回 ``{"agent_name", "thread_id"}``。
设计要点:
- ``roundtable-report`` 是**内置单例**(``_roundtable_seed`` 种子化,启动即同步进
DB),这里**只建一个轻量 thread**,绝不重建 agent;
- 线程打 ``_ROUNDTABLE_THREAD_METADATA`` 标记成系统会话,从「最近对话」里隐藏;
- 入口先 ``ensure_roundtable_functional_agents()`` 自愈(磁盘目录被手删也能补建);
- 建好后前端用现有 ``POST /run/stream``(``agent_type="special"``、
``agent_name="roundtable-report"``、``d_agent_thread_id={REPORT_AGENT_ID: thread_id}``)
跑这一轮,智能体把图表 HTML 写进该线程的 ``outputs/``,再由沙箱预览。
"""
ensure_roundtable_functional_agents()
thread_id = await _create_thread_direct(request, metadata=_ROUNDTABLE_THREAD_METADATA)
return {"agent_name": REPORT_AGENT_ID, "thread_id": thread_id}
# ---------------------------------------------------------------------------
# 上游 LangGraph SSE 透传(intent / recommend / leader / special 共用)
# ---------------------------------------------------------------------------
@@ -1143,12 +1163,22 @@ async def _special_run(
"""
t0 = time.perf_counter()
thread_id = agent_threads[agent_name]
excluded_tools = ["web_search", "ask_clarification", "present_files", "view_image", "agent_orchestration"]
if agent_name == REPORT_AGENT_ID:
# Step 3「结果绘制」的报告智能体只需**直接 write_file 写出完整 HTML**。
# 额外禁用 bash / str_replace:
# - 禁 bash → 杜绝它写 Python/脚本再跑出 HTML(脚本产物落在 outputs 里,
# 但前端虚拟预览只抓 write_file 的 args.content,抓不到脚本产物 → 预览停在
# 那个半截 write_file 上,正是用户看到的「没写完就去生成 py 文件」);
# - 禁 str_replace → 强制返工用整文件 write_file 重写(str_replace 没有 content
# 字段,会让前端虚拟预览读到 undefined 而清空沙箱)。
excluded_tools = [*excluded_tools, "bash", "str_replace"]
body = _run_payload(
agent_name=agent_name,
model_name=model_name,
thread_id=thread_id,
new_message=new_message,
excluded_tools=["web_search", "ask_clarification", "present_files", "view_image", "agent_orchestration"],
excluded_tools=excluded_tools,
skill_stop_names=skill_stop_names,
)
t_payload_built = time.perf_counter()
@@ -0,0 +1,318 @@
"""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.thread_shares import _load_thread_snapshot
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")
class EmbedSessionResultResponse(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")
messages: list[dict] = Field(default_factory=list, description="Full serialized conversation messages")
generated_text: str = Field(default="", description="Concatenated assistant/AI message text")
generated_files: list[str] = Field(
default_factory=list,
description="Virtual artifact paths produced during the conversation (e.g. /mnt/user-data/outputs/...)",
)
class EmbedThreadResultResponse(EmbedThreadStatusFields):
thread_id: str
messages: list[dict] = Field(default_factory=list)
generated_text: str = Field(default="")
generated_files: list[str] = Field(default_factory=list)
def _message_text(message: dict) -> str:
content = message.get("content")
if isinstance(content, str):
return content.strip()
if isinstance(content, list):
parts: list[str] = []
for part in content:
if isinstance(part, dict) and part.get("type") == "text" and isinstance(part.get("text"), str):
text = part["text"].strip()
if text:
parts.append(text)
return "\n".join(parts)
return ""
def extract_generated_text(messages: list[dict]) -> str:
"""Join all assistant/AI message bodies in conversation order."""
parts: list[str] = []
for message in messages:
if not isinstance(message, dict):
continue
message_type = message.get("type") or message.get("role")
if message_type not in {"ai", "assistant"}:
continue
text = _message_text(message)
if text:
parts.append(text)
return "\n\n".join(parts)
def build_embed_result_payload(
*,
messages: list[dict],
artifacts: list[str],
) -> tuple[list[dict], str, list[str]]:
return messages, extract_generated_text(messages), artifacts
async def _result_for_thread_id(thread_id: str, request: Request) -> EmbedThreadResultResponse:
checkpointer = get_checkpointer(request)
messages, artifacts = await _load_thread_snapshot(checkpointer, thread_id)
serialized_messages, generated_text, generated_files = build_embed_result_payload(
messages=messages,
artifacts=artifacts,
)
status = await _status_for_thread_id(thread_id, request)
return EmbedThreadResultResponse(
thread_id=thread_id,
messages=serialized_messages,
generated_text=generated_text,
generated_files=generated_files,
**status.model_dump(),
)
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())
@router.get(
"/sessions/{session_id}/result",
response_model=EmbedSessionResultResponse,
summary="Fetch embed Q&A messages, generated text, and files by session_id",
)
async def get_embed_session_result(session_id: str, request: Request) -> EmbedSessionResultResponse:
"""Return model output for iframe parents once they know ``session_id`` only."""
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 EmbedSessionResultResponse(
session_id=session_id,
registered=False,
thread_id=None,
finished=False,
thread_status="pending",
has_active_run=False,
latest_run_status=None,
messages=[],
generated_text="",
generated_files=[],
)
thread_result = await _result_for_thread_id(record["thread_id"], request)
return EmbedSessionResultResponse(
session_id=session_id,
registered=True,
thread_id=thread_result.thread_id,
finished=thread_result.finished,
thread_status=thread_result.thread_status,
has_active_run=thread_result.has_active_run,
latest_run_status=thread_result.latest_run_status,
messages=thread_result.messages,
generated_text=thread_result.generated_text,
generated_files=thread_result.generated_files,
)
@router.get(
"/threads/{thread_id}/result",
response_model=EmbedThreadResultResponse,
summary="Fetch embed Q&A messages, generated text, and files by thread_id",
)
async def get_embed_thread_result(thread_id: str, request: Request) -> EmbedThreadResultResponse:
"""Direct thread lookup when the parent already knows thread_id."""
return await _result_for_thread_id(thread_id, request)
@@ -0,0 +1,162 @@
"""CRUD API for roundtable-planning business chains (业务链条).
A business chain is a named, reusable, *ordered* list of agents the user
assembles in the chain editor. In Step 2 the coordinator dispatches to the seats
following this order. Bound to the requesting user (not the browser), so it
follows the account across devices/sessions — same model as roundtable drafts.
Routes (prefix ``/api/roundtable-chains``):
GET "" list current user's chains (full records, incl. seats)
POST "" create a chain
GET "/{chain_id}" one chain
PUT "/{chain_id}" partial update (title / description / seats)
DELETE "/{chain_id}" delete
"""
from __future__ import annotations
import logging
from datetime import datetime
from uuid import uuid4
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from deerflow.runtime.user_context import get_effective_user_id
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/roundtable-chains", tags=["roundtable-chains"])
# Seat-count bounds mirror the roundtable selection bounds (2 ≤ N ≤ 8).
MIN_SEATS = 2
MAX_SEATS = 8
# ── schemas ────────────────────────────────────────────────────────────────
class ChainSeatSchema(BaseModel):
agent_id: str = Field(min_length=1, max_length=128)
name: str = ""
description: str = ""
class ChainResponse(BaseModel):
id: str
title: str = ""
description: str | None = None
seats: list[ChainSeatSchema] = []
created_at: datetime | str | None = None
updated_at: datetime | str | None = None
class ChainListResponse(BaseModel):
chains: list[ChainResponse]
class ChainCreateRequest(BaseModel):
# Optional client-provided id so the browser can keep one stable id across
# the create + subsequent updates without an extra round-trip.
id: str | None = Field(default=None, max_length=64)
title: str = Field(default="", max_length=512)
description: str | None = Field(default=None, max_length=2048)
seats: list[ChainSeatSchema] = []
class ChainUpdateRequest(BaseModel):
# All optional → callers send only what changed.
title: str | None = Field(default=None, max_length=512)
description: str | None = Field(default=None, max_length=2048)
seats: list[ChainSeatSchema] | None = None
# ── helpers ──────────────────────────────────────────────────────────────
def _current_user_id(request: Request) -> str:
user = getattr(request.state, "user", None)
if user is not None:
return str(user.id)
return get_effective_user_id()
def _get_store(request: Request):
store = getattr(request.app.state, "roundtable_chain_store", None)
if store is None:
raise HTTPException(status_code=503, detail="Roundtable chain store not available")
return store
def _validate_seats(seats: list[ChainSeatSchema]) -> None:
"""Enforce 2 ≤ N ≤ 8 and reject duplicate agent ids within a chain."""
if len(seats) < MIN_SEATS or len(seats) > MAX_SEATS:
raise HTTPException(
status_code=400,
detail=f"业务链条席位数须在 {MIN_SEATS}–{MAX_SEATS} 之间(当前 {len(seats)})",
)
seen: set[str] = set()
for s in seats:
if s.agent_id in seen:
raise HTTPException(status_code=400, detail=f"业务链条存在重复智能体: {s.agent_id}")
seen.add(s.agent_id)
# ── routes ───────────────────────────────────────────────────────────────
@router.get("", response_model=ChainListResponse)
async def list_chains(request: Request) -> ChainListResponse:
store = _get_store(request)
user_id = _current_user_id(request)
rows = await store.list_chains(user_id)
return ChainListResponse(chains=[ChainResponse(**r) for r in rows])
@router.post("", response_model=ChainResponse, status_code=201)
async def create_chain(request: Request, body: ChainCreateRequest) -> ChainResponse:
_validate_seats(body.seats)
store = _get_store(request)
user_id = _current_user_id(request)
row = await store.create_chain(
user_id,
{
"id": (body.id or uuid4().hex),
"title": body.title.strip(),
"description": body.description,
"seats": [s.model_dump() for s in body.seats],
},
)
return ChainResponse(**row)
@router.get("/{chain_id}", response_model=ChainResponse)
async def get_chain(request: Request, chain_id: str) -> ChainResponse:
store = _get_store(request)
user_id = _current_user_id(request)
row = await store.get_chain(chain_id, user_id)
if row is None:
raise HTTPException(status_code=404, detail="Chain not found")
return ChainResponse(**row)
@router.put("/{chain_id}", response_model=ChainResponse)
async def update_chain(request: Request, chain_id: str, body: ChainUpdateRequest) -> ChainResponse:
if body.seats is not None:
_validate_seats(body.seats)
store = _get_store(request)
user_id = _current_user_id(request)
# Distinguish "omitted" from "explicitly sent"; only forward provided fields.
fields = body.model_dump(exclude_unset=True)
row = await store.update_chain(chain_id, user_id, **fields)
if row is None:
raise HTTPException(status_code=404, detail="Chain not found")
return ChainResponse(**row)
@router.delete("/{chain_id}", status_code=204)
async def delete_chain(request: Request, chain_id: str) -> None:
store = _get_store(request)
user_id = _current_user_id(request)
deleted = await store.delete_chain(chain_id, user_id)
if not deleted:
raise HTTPException(status_code=404, detail="Chain not found")
@@ -52,6 +52,7 @@ class DraftListResponse(BaseModel):
class DraftResponse(DraftMetaResponse):
step1: dict[str, Any] | None = None
step2: dict[str, Any] | None = None
step3: dict[str, Any] | None = None
class DraftCreateRequest(BaseModel):
@@ -62,6 +63,7 @@ class DraftCreateRequest(BaseModel):
furthest_step: int = Field(default=1, ge=1, le=3)
step1: dict[str, Any] | None = None
step2: dict[str, Any] | None = None
step3: dict[str, Any] | None = None
class DraftUpdateRequest(BaseModel):
@@ -71,6 +73,7 @@ class DraftUpdateRequest(BaseModel):
furthest_step: int | None = Field(default=None, ge=1, le=3)
step1: dict[str, Any] | None = None
step2: dict[str, Any] | None = None
step3: dict[str, Any] | None = None
class RecommendPickSchema(BaseModel):
@@ -149,6 +152,7 @@ async def create_draft(request: Request, body: DraftCreateRequest) -> DraftRespo
"furthest_step": body.furthest_step,
"step1": body.step1,
"step2": body.step2,
"step3": body.step3,
},
)
return DraftResponse(**row)
@@ -219,6 +219,8 @@ _CONTEXT_CONFIGURABLE_KEYS: frozenset[str] = frozenset(
"max_concurrent_subagents",
"agent_name",
"is_bootstrap",
"is_writing_setup",
"valid_article_types",
"excluded_tools",
"skill_stop_names",
"skill_stop_message",
@@ -7,11 +7,19 @@ import logging
from langchain_core.messages import HumanMessage, SystemMessage
from langchain_core.runnables import RunnableConfig
from ..state import AIWritingState, KeyPoint, Outline, OutlineSection
from ..outline_parser import parse_user_outline
from ..prompts.writer_prompts import (
OUTLINE_SYSTEM, OUTLINE_USER, OUTLINE_REVISE_SYSTEM, OUTLINE_REVISE_USER,
OUTLINE_MATERIAL_ASSIGN_SYSTEM,
OUTLINE_MATERIAL_ASSIGN_USER,
OUTLINE_NORMALIZE_SYSTEM,
OUTLINE_NORMALIZE_USER,
OUTLINE_REVISE_SYSTEM,
OUTLINE_REVISE_USER,
OUTLINE_SYSTEM,
OUTLINE_USER,
)
from ._utils import get_writing_model, parse_json, _content_to_text, reasoning_delta, ThinkTagParser, safe_get_stream_writer
from ..state import AIWritingState, KeyPoint, Outline, OutlineSection
from ._utils import ThinkTagParser, _content_to_text, get_writing_model, llm_json, parse_json, reasoning_delta, safe_get_stream_writer
logger = logging.getLogger(__name__)
@@ -80,6 +88,108 @@ def _parse_outline(raw: dict) -> Outline:
return Outline(title=raw.get("title", "未命名文章"), sections=sections)
def _derive_title(state: AIWritingState) -> str:
"""用户大纲没给总标题时,用写作意图兜底一个标题。"""
intent = (state.get("user_intent") or "").strip()
if not intent:
return "未命名文章"
return intent[:30].rstrip() + ("…" if len(intent) > 30 else "")
async def _normalize_user_outline_via_llm(state: AIWritingState, config: RunnableConfig, raw: str) -> Outline:
"""确定性解析失败时,调 LLM 把用户大纲原文归一成三级结构(严格保真、不改写)。
再失败则把每个非空行当作一个章节,保证永远产出一份可用大纲、不丢用户内容。
"""
model_name = (state.get("model_name") or "").strip() or None
line_fallback = {
"title": "",
"sections": [
{"section_title": ln.strip(), "key_points": [], "material_ids": []}
for ln in raw.splitlines() if ln.strip()
] or [{"section_title": "正文", "key_points": [], "material_ids": []}],
}
result = await llm_json(
OUTLINE_NORMALIZE_SYSTEM,
OUTLINE_NORMALIZE_USER.format(raw_outline=raw),
line_fallback,
config,
model_name=model_name,
)
return _parse_outline(result)
async def _assign_materials_to_outline(
state: AIWritingState, config: RunnableConfig, outline: Outline, pkg: dict
) -> Outline:
"""为用户大纲的各章节回填 material_ids(结构锁定:只取编号、按章节序号嫁接)。
LLM 只负责「哪个章节配哪些素材」,标题与层级一律以解析结果为准,杜绝改写。失败则
保持 material_ids 为空(writer_draft 自带「取前 2 条素材」兜底)。
"""
sections = outline.get("sections", [])
materials = pkg.get("materials") or []
if not sections or not materials:
return outline
valid_ids = {m["id"] for m in materials}
sections_list = "\n".join(
f"{i + 1}. {s.get('section_title', '')}" for i, s in enumerate(sections)
)
model_name = (state.get("model_name") or "").strip() or None
result = await llm_json(
OUTLINE_MATERIAL_ASSIGN_SYSTEM,
OUTLINE_MATERIAL_ASSIGN_USER.format(
user_intent=state.get("user_intent", ""),
sections_list=sections_list,
materials_summary=_materials_summary(pkg),
),
{"assignments": []},
config,
model_name=model_name,
)
assignments = result.get("assignments")
if not isinstance(assignments, list):
return outline
for i, section in enumerate(sections):
if i >= len(assignments):
break
ids = assignments[i]
if isinstance(ids, list):
section["material_ids"] = [mid for mid in ids if mid in valid_ids]
return outline
async def _build_outline_from_user(
state: AIWritingState, config: RunnableConfig, pkg: dict, raw: str
) -> dict:
"""用户自带大纲分支:解析用户大纲 → 关联素材 → 直接进确认,不调 LLM 重新规划。"""
writer = safe_get_stream_writer()
writer({"type": "outline_chunk", "text": "正在整理你提供的大纲…\n", "is_thinking": True})
outline = parse_user_outline(raw)
if outline is None:
# 确定性解析拿不准(散文化/格式混乱)→ LLM 兜底归一
outline = await _normalize_user_outline_via_llm(state, config, raw)
if not outline.get("title"):
outline = {**outline, "title": _derive_title(state)}
# 仍走了检索 → 把已检索素材关联到用户章节(结构锁定)
outline = await _assign_materials_to_outline(state, config, outline, pkg)
section_count = len(outline.get("sections", []))
return {
"current_outline": outline,
"status": "awaiting_outline_confirm",
"progress_events": [
{"type": "step_start", "agent_name": "作家", "message": "采用你提供的大纲"},
{"type": "outline_ready", "agent_name": "作家", "message": f"已整理你的大纲,共 {section_count} 个章节"},
],
}
async def writer_outline_node(state: AIWritingState, config: RunnableConfig) -> dict:
pkg = state.get("material_package") or {"keywords": [], "materials": [], "summary": ""}
intervention = state.get("last_user_intervention") or {}
@@ -95,6 +205,13 @@ async def writer_outline_node(state: AIWritingState, config: RunnableConfig) ->
current_outline = state.get("current_outline")
is_revise = bool(feedback) and bool(current_outline)
# 用户自带大纲:首轮(尚无大纲、非重规划)且表单粘了大纲原文 → 走「采用用户大纲」分支,
# 跳过 LLM 重新规划。后续若用户在确认卡里点「重新规划」,current_outline 已存在 → 落到
# 下方 is_revise 编辑模式,按反馈调整用户大纲(符合预期)。
user_outline_raw = (state.get("user_outline_raw") or "").strip()
if user_outline_raw and not current_outline and not is_revise:
return await _build_outline_from_user(state, config, pkg, user_outline_raw)
if is_revise:
system_prompt = OUTLINE_REVISE_SYSTEM
user_prompt = OUTLINE_REVISE_USER.format(
@@ -0,0 +1,220 @@
"""把用户粘贴的大纲文本解析成三级 ``Outline`` 结构(确定性解析,不依赖 LLM)。
用户在写作表单里可能直接粘贴一份已有大纲,要求「完全按我的大纲生成」。本模块负责
把各种常见的大纲文本格式严格、保真地转成后端三级结构:
Outline.sections[] = Level 1 章节(section_title)
OutlineSection.key_points[] = Level 2 小节(point_title)
KeyPoint.sub_points[] = Level 3 子要点
兼容的输入格式:
- Markdown 标题:``#`` / ``##`` / ``###`` / ``####``(首个孤立 ``#`` 视为文章标题)
- 中文编号:``一、`` / ``(一)`` / ``第一章`` / ``第一节``
- 阿拉伯编号:``1.`` / ``1、`` / ``1.1`` / ``1.1.1``
- 缩进 bullet:``-`` / ``*`` / ``•`` / ``·``(用缩进深度表示层级)
- 纯文本平铺:每行一个一级标题(短标题、无句末标点)
设计原则:
- **保真不改写**:只识别层级、原样保留标题文本,绝不增删或改写用户内容。
- **多级混排自适应**:把文本里实际出现的各种「层级标记」收集起来排序,按相对外层
顺序映射到 1/2/3 级——所以不依赖固定的标记搭配,``##``+``-`` 或 ``一、``+缩进
都能正确分层。
- **拿不准就交给上层**:识别不出可靠层级(如整段散文)时返回 ``None``,由调用方走
LLM 兜底归一,避免硬把散文拆成错误结构。
"""
from __future__ import annotations
import re
from .state import KeyPoint, Outline, OutlineSection
# ── 行首标记识别 ──────────────────────────────────────────────────────────────
_CN_NUM = "一二三四五六七八九十百千零〇两"
# Markdown 标题:## 标题
_MD_HEADING_RE = re.compile(r"^(#{1,6})\s+(.*\S)\s*$")
# 第一章 / 第1节 / 第一部分
_CN_CHAPTER_RE = re.compile(rf"^第\s*[{_CN_NUM}\d]+\s*[章节部分篇讲回卷](?:[\s::、..))]\s*|\s+)?(.*)$")
# 一、 / 十二、 / 一. / 一.
_CN_DUN_RE = re.compile(rf"^[{_CN_NUM}]+\s*[、..]\s*(.*)$")
# (一) / (一) / (1) / (1)
_PAREN_RE = re.compile(rf"^[((]\s*[{_CN_NUM}\d]+\s*[))]\s*(.*)$")
# 1.1 / 1.1.1(带至少一个点的多级阿拉伯编号)
_ARABIC_DOTTED_RE = re.compile(r"^(\d+(?:\.\d+)+)\s*[、..))]?\s*(.*)$")
# 1. / 1、 / 1) / 1.(单级阿拉伯编号)
_ARABIC_SINGLE_RE = re.compile(r"^\d+\s*[、..))]\s*(.*)$")
# 带圈数字 ①②③ / 罗马 ⅰ
_CIRCLED_RE = re.compile(r"^[①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮]\s*(.*)$")
# bullet:- / * / • / ·(要求标记后有空白,避免误吞「*强调*」之类)
_BULLET_RE = re.compile(r"^[-*•·]\s+(.*\S)\s*$")
# 标记分组的「外层度」基准值(越小越外层)。group: 0=标题 1=编号 2=缩进。
# 同组内 order 决定相对层级;不同组之间靠 group 排序(标题 < 编号 < 缩进)。
# 编号顺序遵循中文公文惯例:第一章 > 一、 > (一) > 1. > 1.1 > ①。
_NUM_ORDER = {
"cn_chapter": 0.0, # 第一章
"cn_dun": 1.0, # 一、
"paren": 2.0, # (一)
"arabic_single": 3.0, # 1.
"circled": 8.0, # ①
}
def _indent_width(line: str) -> int:
"""计算行首缩进宽度(tab 记 4 列)。"""
width = 0
for ch in line:
if ch == " ":
width += 1
elif ch == "\t":
width += 4
else:
break
return width
def _classify(stripped: str, indent: int) -> tuple[tuple[int, float], str] | None:
"""识别一行的层级标记,返回 ``((group, order), 纯文本)``;无法识别返回 None。
``(group, order)`` 是该行的「层级键」:把全文出现过的不同键排序后,按位置映射到
1/2/3 级。这样不必预设标记搭配,相对顺序对了层级就对。
"""
if not stripped:
return None
# 1) Markdown 标题(group 0,order = 井号数)
m = _MD_HEADING_RE.match(stripped)
if m:
depth = len(m.group(1))
text = m.group(2).strip()
return (0, float(depth)), text
# 2) 各类编号(group 1)
m = _CN_CHAPTER_RE.match(stripped)
if m and stripped.startswith("第"):
return (1, _NUM_ORDER["cn_chapter"]), (m.group(1) or "").strip()
m = _CN_DUN_RE.match(stripped)
if m:
return (1, _NUM_ORDER["cn_dun"]), m.group(1).strip()
m = _ARABIC_DOTTED_RE.match(stripped)
if m:
depth = m.group(1).count(".") + 1 # 1.1 → 2, 1.1.1 → 3
# 多级阿拉伯:order 随层级递增,落在单级(3.0)与圈号(8.0)之间
return (1, 3.0 + depth), m.group(2).strip()
m = _PAREN_RE.match(stripped)
if m:
return (1, _NUM_ORDER["paren"]), m.group(1).strip()
m = _ARABIC_SINGLE_RE.match(stripped)
if m:
return (1, _NUM_ORDER["arabic_single"]), m.group(1).strip()
m = _CIRCLED_RE.match(stripped)
if m:
return (1, _NUM_ORDER["circled"]), m.group(1).strip()
# 3) bullet:用缩进深度区分层级(group 2)
m = _BULLET_RE.match(stripped)
if m:
return (2, float(indent)), m.group(1).strip()
# 4) 纯文本:用缩进深度区分层级(group 3,最内层基准)
return (3, float(indent)), stripped
def _looks_like_prose(text: str) -> bool:
"""判断一行是否更像散文句子而非大纲标题(长、或带句末标点)。"""
if len(text) > 50:
return True
return text.rstrip()[-1:] in "。!?!?;;"
def parse_user_outline(raw: str) -> Outline | None:
"""把用户粘贴的大纲文本解析成三级 ``Outline``;无法可靠解析时返回 ``None``。
返回 ``None`` 的情形:空白输入、只有一行且无任何标记、或整体更像散文段落——这些交给
上层用 LLM 兜底归一,避免误拆。
"""
if not raw or not raw.strip():
return None
rows: list[tuple[tuple[int, float], str]] = []
for line in raw.splitlines():
if not line.strip():
continue
indent = _indent_width(line)
classified = _classify(line.strip(), indent)
if classified is None:
continue
key, text = classified
if text:
rows.append((key, text))
if not rows:
return None
marked = any(key[0] != 3 for key, _ in rows) # 是否出现过标题/编号/bullet 标记
# 纯文本且无任何标记:可能是「平铺标题列表」也可能是散文。
# 单行、或任一行像句子 → 判为散文,返回 None 交给 LLM。
if not marked:
if len(rows) < 2:
return None
if any(_looks_like_prose(text) for _, text in rows):
return None
# 文章标题:仅在「Markdown 首行是孤立的最浅井号、且存在更深井号」时认定。
title = ""
body = rows
heading_depths = [key[1] for key, _ in rows if key[0] == 0]
if heading_depths:
min_depth = min(heading_depths)
first_key = rows[0][0]
is_first_top_heading = first_key == (0, min_depth)
only_one_at_top = sum(1 for d in heading_depths if d == min_depth) == 1
has_deeper = any(d > min_depth for d in heading_depths)
if is_first_top_heading and only_one_at_top and has_deeper:
title = rows[0][1]
body = rows[1:]
if not body:
return None
# 把 body 里出现的不同「层级键」排序,按位置映射到 1/2/3 级(超过 3 级压到 3)。
distinct_keys = sorted({key for key, _ in body})
level_of: dict[tuple[int, float], int] = {
key: min(idx + 1, 3) for idx, key in enumerate(distinct_keys)
}
sections: list[OutlineSection] = []
cur_section: OutlineSection | None = None
cur_point: KeyPoint | None = None
for key, text in body:
level = level_of[key]
if level <= 1:
cur_section = OutlineSection(section_title=text, key_points=[], material_ids=[])
sections.append(cur_section)
cur_point = None
elif level == 2:
if cur_section is None: # 孤立 L2:提升为章节,避免空标题
cur_section = OutlineSection(section_title=text, key_points=[], material_ids=[])
sections.append(cur_section)
cur_point = None
continue
cur_point = KeyPoint(point_title=text, sub_points=[])
cur_section["key_points"].append(cur_point)
else: # level == 3
if cur_section is None: # 孤立 L3:提升为章节
cur_section = OutlineSection(section_title=text, key_points=[], material_ids=[])
sections.append(cur_section)
cur_point = None
elif cur_point is None: # 章节下尚无小节:提升为小节
cur_point = KeyPoint(point_title=text, sub_points=[])
cur_section["key_points"].append(cur_point)
else:
cur_point["sub_points"].append(text)
if not sections:
return None
return Outline(title=title, sections=sections)
@@ -123,6 +123,60 @@ OUTLINE_REVISE_USER = """写作意图:{user_intent}
请在「当前大纲」基础上落实上述「用户修改要求」,未提到的章节保持原样,输出调整后的完整大纲 JSON。"""
# ── 用户自带大纲:LLM 兜底归一(确定性解析失败时才用)──────────────────────────
OUTLINE_NORMALIZE_SYSTEM = """你是一名大纲格式整理助手。
用户粘贴了一份已有的文章大纲(格式可能不规范),你的任务是把它原样转成结构化 JSON。
【最高原则:保真,绝不改写】
- 严格保留用户写的每一条标题文字,逐字照搬,不要润色、改写、翻译、增加或删除任何条目。
- 你只判断层级(哪条是章节、哪条是小节、哪条是子要点),不创作内容。
- 层级判断依据用户原文里的编号(一、/(一)/1./1.1)、Markdown 井号数、或缩进深度。
【三级结构】
- Level 1 章节 → section_title
- Level 2 小节 → key_points[].point_title
- Level 3 子要点 → key_points[].sub_points[](字符串数组)
- 用户大纲只有一级时,每条都是章节,key_points 留空 []。
- 用户大纲只有两级时,sub_points 留空 []。
【输出要求】
- 只输出一个 JSON 对象,不要解释文字,不要 markdown 代码块,必须用英文双引号。
- material_ids 一律输出 [](此步不关联素材)。
- 结构:{"title": "...", "sections": [{"section_title": "...", "key_points": [{"point_title": "...", "sub_points": ["..."]}], "material_ids": []}]}
- 用户大纲若包含一个总标题,放进 title;没有就留空字符串。"""
OUTLINE_NORMALIZE_USER = """用户粘贴的大纲原文:
{raw_outline}
请逐条保留原文,仅判断层级,输出结构化大纲 JSON。"""
# ── 用户自带大纲:素材关联(结构锁定,只挑 material_ids)──────────────────────────
OUTLINE_MATERIAL_ASSIGN_SYSTEM = """你是一名资料编辑。
给定一份【已确定的文章大纲】(章节顺序固定)和一批检索到的素材,你的任务是为每个章节
挑选最相关的素材编号。
【规则】
- 不要修改大纲的任何标题或结构,你只输出每个章节对应的素材编号列表。
- 严格按大纲给出的章节顺序输出,一个章节对应一个编号数组;该章节没有相关素材就给 []。
- material_id 必须来自给定素材清单里出现过的编号,不要编造。
- 一条素材可以分配给多个章节,也可以一个都不分配。
【输出要求】
- 只输出一个 JSON 对象,不要解释文字,不要 markdown 代码块,必须用英文双引号。
- 结构:{"assignments": [["m001","m002"], [], ["m003"]]},数组长度等于章节数、顺序一致。"""
OUTLINE_MATERIAL_ASSIGN_USER = """写作意图:{user_intent}
文章大纲(章节按顺序编号):
{sections_list}
可用素材:
{materials_summary}
请按章节顺序,为每个章节挑选相关的 material_id,输出 assignments 数组。"""
DRAFT_SECTION_SYSTEM = """你是一名专业内容创作者(作家)。
你的目标:根据大纲和素材,写出一段结构清晰、高质量的文章内容。
你的工作背景:你有十年内容创作经验,文字流畅,善于用事实说话。
@@ -125,6 +125,9 @@ class BlockedSection(TypedDict):
class AIWritingState(TypedDict):
# 写作配置
user_intent: str
# 用户在写作表单粘贴的「已有大纲」原文(可选)。非空时 writer_outline 跳过 LLM 规划,
# 直接解析这份大纲并按用户结构生成,做到「完全按我的大纲写」。留空走正常自动规划。
user_outline_raw: str
article_type: str
target_audience: str
word_count_target: int
@@ -11,6 +11,7 @@ from deerflow.agents.middlewares.memory_middleware import MemoryMiddleware
from deerflow.agents.middlewares.prompt_prefix_middleware import PromptPrefixMiddleware
from deerflow.agents.middlewares.rag_citation_middleware import RagCitationMiddleware
from deerflow.agents.middlewares.setup_agent_approval_middleware import SetupAgentApprovalMiddleware
from deerflow.agents.middlewares.setup_writing_approval_middleware import SetupWritingApprovalMiddleware
from deerflow.agents.middlewares.subagent_limit_middleware import SubagentLimitMiddleware
from deerflow.agents.middlewares.summarization_middleware import BeforeSummarizationHook, DeerFlowSummarizationMiddleware
from deerflow.agents.middlewares.title_middleware import TitleMiddleware
@@ -25,6 +26,9 @@ from deerflow.models import create_chat_model
logger = logging.getLogger(__name__)
# 写作配置 Q&A:意图澄清最多几轮(一条用户消息 = 一轮),到顶强制提交配置卡。
WRITING_SETUP_MAX_ROUNDS = 3
def _get_runtime_config(config: RunnableConfig) -> dict:
"""Merge legacy configurable options with LangGraph runtime context."""
@@ -272,6 +276,7 @@ def _build_middlewares(
# Add TodoList middleware if plan mode is enabled
is_plan_mode = cfg.get("is_plan_mode", False)
is_bootstrap = cfg.get("is_bootstrap", False)
is_writing_setup = cfg.get("is_writing_setup", False)
todo_list_middleware = _create_todo_list_middleware(is_plan_mode)
if todo_list_middleware is not None:
middlewares.append(todo_list_middleware)
@@ -291,7 +296,7 @@ def _build_middlewares(
# Add MemoryMiddleware (after TitleMiddleware) — skip in bootstrap mode (agent creation flow)
memory_injection_enabled = cfg.get("memory_injection_enabled", True)
if not is_bootstrap:
if not is_bootstrap and not is_writing_setup:
middlewares.append(MemoryMiddleware(agent_name=agent_name, memory_config=resolved_app_config.memory, injection_enabled=memory_injection_enabled))
# Add ViewImageMiddleware only for the NATIVE-vision path (main model
@@ -324,6 +329,7 @@ def _build_middlewares(
middlewares.extend(custom_middlewares)
middlewares.append(SetupAgentApprovalMiddleware())
middlewares.append(SetupWritingApprovalMiddleware())
# SkillStopMiddleware — intercepts skill invocations and halts execution
# when a configured skill is about to run. Request-level options
@@ -371,7 +377,7 @@ def make_lead_agent(config: RunnableConfig):
def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
# Lazy import to avoid circular dependency
from deerflow.tools import get_available_tools
from deerflow.tools.builtins import setup_agent
from deerflow.tools.builtins import setup_agent, setup_writing
cfg = _get_runtime_config(config)
resolved_app_config = app_config
@@ -383,6 +389,10 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
subagent_enabled = cfg.get("subagent_enabled", False)
max_concurrent_subagents = cfg.get("max_concurrent_subagents", 3)
is_bootstrap = cfg.get("is_bootstrap", False)
# 写作配置 Q&A:与 is_bootstrap 同构的另一种「最小化 bootstrap」——挂 setup_writing 工具、
# 走写作配置专用 prompt,只负责帮用户把写作表单聊清并提交,不真正开始写作。
is_writing_setup = cfg.get("is_writing_setup", False)
writing_setup_article_types = cfg.get("valid_article_types") if is_writing_setup else None
memory_injection_enabled = bool(cfg.get("memory_injection_enabled", True))
rag_mode_enabled = bool(cfg.get("rag_mode_enabled", False))
writing_mode = bool(cfg.get("writing_mode", False))
@@ -393,7 +403,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
requested_excluded_tools = cfg.get("excluded_tools") or None
try:
agent_config = load_agent_config(agent_id) if not is_bootstrap else None
agent_config = load_agent_config(agent_id) if not is_bootstrap and not is_writing_setup else None
except FileNotFoundError:
logger.warning("Agent directory not found for '%s'; running with default config", agent_id)
agent_config = None
@@ -453,6 +463,25 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
}
)
if is_writing_setup:
# 写作配置 Q&A:最小化 agent —— **只挂 setup_writing 一个工具** + 一份职责单一的独立提示。
# 关键:绝不给全套工具、绝不复用 lead 超级体提示,否则模型会把「新能源汽车市场分析」之类
# 长得像任务的输入直接做掉,而不是转成写作配置提交。
# 末尾追加 WritingSetupRoundCapMiddleware:澄清最多 3 轮,到顶强制提交配置卡。
from deerflow.agents.lead_agent.prompt import build_writing_setup_prompt
from deerflow.agents.middlewares.writing_setup_round_cap_middleware import WritingSetupRoundCapMiddleware
return create_agent(
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, app_config=resolved_app_config),
tools=[setup_writing],
middleware=[
*_build_middlewares(config, model_name=model_name, app_config=resolved_app_config),
WritingSetupRoundCapMiddleware(max_rounds=WRITING_SETUP_MAX_ROUNDS),
],
system_prompt=build_writing_setup_prompt(writing_setup_article_types),
state_schema=ThreadState,
)
if is_bootstrap:
# Special bootstrap agent with minimal prompt for initial custom agent creation flow
return create_agent(
@@ -703,14 +703,14 @@ def _get_cached_skills_prompt_section(
display_pattern = (
"""
**Skill Result Display Pattern:**
- If RAG/reference display mode is enabled, do not number each skill result independently. Treat all retrieval results from the same user turn as one combined `referenceBatch` and cite with `[1]`, `[2]`, etc. from the system-provided reference prompt.
- In RAG/reference display mode, do not output `<skill-display .../>`; the frontend renders cards, tables, lists, and citation details in the right reference panel.
- If RAG/reference display mode is not enabled and a skill has `<display mode="citation">`, use the skill's returned result order as reference numbers. Cite facts from the first result as `[1]`, the second as `[2]`, and so on. Use only numbers that correspond to returned results.
- If a skill has `<display mode="list">`, summarize the returned items as a concise list and keep item order stable.
- If a skill has `<display mode="table">`, summarize the result and output `<skill-display tool="SKILL_OR_TOOL_NAME" mode="table" />` where the frontend should render the real table. Do not hand-write the table rows yourself.
- If a skill has `<display mode="card">`, summarize the result and output `<skill-display tool="SKILL_OR_TOOL_NAME" mode="card" />` where the frontend should render the cards.
- If a skill has `<display mode="custom">`, follow the custom display instruction in the `<display>` tag when presenting the skill result.
**Skill Result Display Pattern:**
- If RAG/reference display mode is enabled, do not number each skill result independently. Treat all retrieval results from the same user turn as one combined `referenceBatch` and cite with `[1]`, `[2]`, etc. from the system-provided reference prompt.
- In RAG/reference display mode, do not output `<skill-display .../>`; the frontend renders cards, tables, lists, and citation details in the right reference panel.
- If RAG/reference display mode is not enabled and a skill has `<display mode="citation">`, use the skill's returned result order as reference numbers. Cite facts from the first result as `[1]`, the second as `[2]`, and so on. Use only numbers that correspond to returned results.
- If a skill has `<display mode="list">`, summarize the returned items as a concise list and keep item order stable.
- If a skill has `<display mode="table">`, summarize the result and output `<skill-display tool="SKILL_OR_TOOL_NAME" mode="table" />` where the frontend should render the real table. Do not hand-write the table rows yourself.
- If a skill has `<display mode="card">`, summarize the result and output `<skill-display tool="SKILL_OR_TOOL_NAME" mode="card" />` where the frontend should render the cards.
- If a skill has `<display mode="custom">`, follow the custom display instruction in the `<display>` tag when presenting the skill result.
"""
if display_signature
else ""
@@ -904,6 +904,46 @@ def _build_bootstrap_section() -> str:
"""
def build_writing_setup_prompt(article_types: list[dict] | None = None) -> str:
"""写作配置 Q&A 的**独立**系统提示(不复用 lead-agent 超级体提示)。
关键:写作配置助手绝不能像 lead agent 那样「自己把任务做掉」——用户输入常常长得像一个
可执行任务(如「新能源汽车市场分析」),若复用 lead 提示 + 全套工具,模型会直接去研究/写
分析。这里用一份职责单一、口吻强硬的独立提示,配合「只挂 setup_writing 一个工具」,把模型
死死框在「聊清需求 → 调 setup_writing」上。
"""
if article_types:
type_lines = "\n".join(
f" - `{t.get('key', '')}`:{t.get('label', '')}" for t in article_types if t.get("key")
)
type_block = f"【可选文章类型】article_type 只能填下列 key 之一:\n{type_lines}"
else:
type_block = "【文章类型】若不确定 article_type,可留空,交给用户在表单里选。"
return f"""你是「AI 写作」功能的**配置助手**。你的唯一职责:通过简短对话把用户的写作需求聊清楚,然后调用 `setup_writing` 工具把写作配置提交给用户确认。提交后由用户在表单里应用、再点「开始写作」。
【铁律——最高优先级】
- 你**绝对不撰写**文章正文、分析内容、大纲内容或任何成稿文字。即使用户的输入看起来就是一个写作任务(例如「新能源汽车市场分析」「帮我写一篇……」),你也**只把它当作"要配置的写作目标"**,绝不替用户动笔、绝不直接完成这篇写作。
- 你能做的动作只有两个:① 用自然语言向用户提问或确认;② 调用 `setup_writing` 工具。**除此之外你没有任何其它工具**,也不要假装调用别的工具、不要做检索、不要做研究。
- 不要在回复里输出文章段落、要点清单或大纲(用户主动粘贴的大纲除外——原样放进 `outline` 参数)。
【工作流程】(与用户交互确认,但最多 3 轮内必须提交)
1. 读用户输入,先用一两句话回应并和用户**确认写作意图**;就缺失或模糊的关键项(文章类型、目标字数、目标读者)提问澄清,一次最多问 1-2 个最关键的问题。
2. 与用户的澄清来回**最多 2 次**。一旦关键信息明确、或已经问过 2 次,就**立即进入第 3 步提交**,缺失字段用合理默认值,绝不无休止追问。
3. 调用 `setup_writing` 提交配置:
- `user_intent`:精炼成一句话的写作目标(必填)。
- `article_type`:见下方可选类型;不确定就留空。
- `word_count_target`:正整数;用户没明说就给合理默认(如 800)。
- `target_audience`:如「普通读者」「行业专家」;没提就填「普通读者」。
- `writing_mode`:用户强调「严格依据素材/不要编造」→ `strict`,否则 `loose`。
- `material_source`:用户提到用「技能/技能检索」→ `skill`,否则 `knowledge_base`。
- `outline`:用户提供的大纲原文(保留其层级与标题),否则留空。
4. 调用 `setup_writing` 前,用**一句话**说明你将提交的配置要点;调用之后不要再输出任何文章内容。
{type_block}
"""
def apply_prompt_template(
subagent_enabled: bool = False,
max_concurrent_subagents: int = 3,
@@ -995,16 +1035,16 @@ Writing mode is enabled for this run.
network_policy_section = _build_network_policy_section()
rag_mode_section = (
"""
<rag_citation_mode>
RAG/reference display mode is enabled.
When a search, knowledge, skill, or MCP tool returns a result list:
- If multiple search terms are needed, prefer calling the retrieval tools for all known terms in the same step, then answer after the tool results are returned.
- Treat every retrieval result from the same user turn as one combined `referenceBatch`; do not restart numbering for each search term, tool call, skill, or display adapter.
- Use only the system-provided merged reference numbers in the final answer, formatted as `[1]`, `[2]`, `[1][3][5]`.
- Do not output `<skill-display .../>` in RAG/reference mode. The frontend renders configured cards, tables, lists, and citation details in the right reference panel.
- If no display adapter exists, the frontend defaults the right reference panel to numbered citations.
</rag_citation_mode>
"""
<rag_citation_mode>
RAG/reference display mode is enabled.
When a search, knowledge, skill, or MCP tool returns a result list:
- If multiple search terms are needed, prefer calling the retrieval tools for all known terms in the same step, then answer after the tool results are returned.
- Treat every retrieval result from the same user turn as one combined `referenceBatch`; do not restart numbering for each search term, tool call, skill, or display adapter.
- Use only the system-provided merged reference numbers in the final answer, formatted as `[1]`, `[2]`, `[1][3][5]`.
- Do not output `<skill-display .../>` in RAG/reference mode. The frontend renders configured cards, tables, lists, and citation details in the right reference panel.
- If no display adapter exists, the frontend defaults the right reference panel to numbered citations.
</rag_citation_mode>
"""
if rag_mode_enabled
else ""
)
@@ -0,0 +1,129 @@
"""Approval middleware for the AI-writing config setup tool.
与 ``SetupAgentApprovalMiddleware`` 同构:模型在「写作配置 Q&A」里调用 ``setup_writing``
时,于 ``after_model`` 拦截,附上一条结构化审批 ToolMessage(建议的写作配置打包在
``additional_kwargs["setup_writing_approval"]``)并 ``jump_to="end"`` 立即结束本轮运行——
前端据此渲染审批卡,用户点「应用到表单」后把这些字段回填到左侧写作表单。
"""
from __future__ import annotations
import logging
from hashlib import sha256
from typing import Any, override
from langchain.agents import AgentState
from langchain.agents.middleware import AgentMiddleware, hook_config
from langchain_core.messages import AIMessage, ToolMessage
from langgraph.runtime import Runtime
logger = logging.getLogger(__name__)
class SetupWritingApprovalMiddlewareState(AgentState):
"""Compatible with the DeerFlow thread state schema."""
pass
class SetupWritingApprovalMiddleware(AgentMiddleware[SetupWritingApprovalMiddlewareState]):
"""Stop before ``setup_writing`` and ask the user to apply the proposed config.
See ``SetupAgentApprovalMiddleware`` for the rationale behind intercepting in
``after_model`` + ``jump_to="end"`` (robust against parallel tool calls).
"""
state_schema = SetupWritingApprovalMiddlewareState
def _is_approved(self, runtime: Runtime) -> bool:
context = getattr(runtime, "context", None) or {}
if not isinstance(context, dict):
return False
return context.get("setup_writing_approved") is True
def _stable_message_id(self, tool_call_id: str, args: dict[str, Any]) -> str:
if tool_call_id:
return f"setup-writing-approval:{tool_call_id}"
digest = sha256(repr(sorted(args.items())).encode("utf-8")).hexdigest()[:16]
return f"setup-writing-approval:{digest}"
def _coerce_word_count(self, raw: Any) -> int | None:
if isinstance(raw, bool): # bool 是 int 子类,先排除
return None
if isinstance(raw, int):
return raw if raw > 0 else None
if isinstance(raw, float):
return int(raw) if raw > 0 else None
if isinstance(raw, str):
import re
m = re.search(r"\d[\d,]*", raw)
if m:
try:
value = int(m.group().replace(",", ""))
return value if value > 0 else None
except ValueError:
return None
return None
def _build_payload(self, args: dict[str, Any]) -> dict[str, Any]:
return {
"user_intent": str(args.get("user_intent") or "").strip(),
"article_type": str(args.get("article_type") or "").strip(),
"word_count_target": self._coerce_word_count(args.get("word_count_target")),
"target_audience": str(args.get("target_audience") or "").strip(),
"writing_mode": str(args.get("writing_mode") or "").strip(),
"material_source": str(args.get("material_source") or "").strip(),
"outline": str(args.get("outline") or "").strip(),
}
def _intercept(self, last: AIMessage) -> dict[str, Any] | None:
"""Return a state update if setup_writing must be gated; None to passthrough."""
tool_calls = list(last.tool_calls or [])
setup_call = next((tc for tc in tool_calls if tc.get("name") == "setup_writing"), None)
if setup_call is None:
return None
args = setup_call.get("args") or {}
tool_call_id = setup_call.get("id") or ""
payload = self._build_payload(args)
logger.info("Setup writing approval requested before applying writing config")
approval_msg = ToolMessage(
id=self._stable_message_id(tool_call_id, args),
content="请确认是否把这份写作配置应用到表单。",
tool_call_id=tool_call_id,
name="setup_writing",
additional_kwargs={"setup_writing_approval": payload},
)
return {
"messages": [approval_msg],
"jump_to": "end",
}
@hook_config(can_jump_to=["end"])
@override
def after_model(
self,
state: SetupWritingApprovalMiddlewareState,
runtime: Runtime,
) -> dict[str, Any] | None:
if self._is_approved(runtime):
return None
messages = state.get("messages") or []
if not messages:
return None
last = messages[-1]
if not isinstance(last, AIMessage):
return None
return self._intercept(last)
@hook_config(can_jump_to=["end"])
@override
async def aafter_model(
self,
state: SetupWritingApprovalMiddlewareState,
runtime: Runtime,
) -> dict[str, Any] | None:
return self.after_model(state, runtime)
@@ -0,0 +1,72 @@
"""写作配置 Q&A 的「轮数上限」中间件。
意图阶段允许与用户来回澄清确认,但**最多 N 轮后必须出结果**:到上限时,在模型请求里
注入强制指令 + 用 ``tool_choice`` 锁定 ``setup_writing``,逼模型立刻提交配置卡,不再提问。
「一轮」= 一条用户消息(HumanMessage)。达到 ``max_rounds`` 条用户消息时强制提交。
"""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from typing import override
from langchain.agents import AgentState
from langchain.agents.middleware import AgentMiddleware
from langchain.agents.middleware.types import ModelCallResult, ModelRequest, ModelResponse
from langchain_core.messages import HumanMessage, SystemMessage
class WritingSetupRoundCapMiddleware(AgentMiddleware[AgentState]):
"""达到澄清轮数上限时,强制模型调用 ``setup_writing`` 提交。
软约束(注入 SystemMessage)+ 硬约束(``tool_choice`` 锁定工具)双保险:即使个别模型
无视提示,``tool_choice`` 也会逼它产出一次 ``setup_writing`` 调用。
"""
def __init__(self, max_rounds: int = 3, tool_name: str = "setup_writing") -> None:
super().__init__()
self._max_rounds = max(1, max_rounds)
self._tool_name = tool_name
def _should_force(self, messages: list) -> bool:
human_count = sum(1 for m in messages if isinstance(m, HumanMessage))
return human_count >= self._max_rounds
def _reminder(self) -> SystemMessage:
return SystemMessage(
content=(
f"【系统强制】意图澄清已达 {self._max_rounds} 轮上限。"
"现在必须立即调用 setup_writing 提交写作配置,禁止再向用户提任何问题。"
"缺失或不确定的字段一律用合理默认值:"
"word_count_target 默认 800、target_audience 默认「普通读者」、"
"writing_mode 默认 loose、material_source 默认 knowledge_base、"
"article_type 不确定就留空。"
)
)
def _patch(self, request: ModelRequest) -> ModelRequest:
return request.override(
messages=[*request.messages, self._reminder()],
tool_choice=self._tool_name,
)
@override
def wrap_model_call(
self,
request: ModelRequest,
handler: Callable[[ModelRequest], ModelResponse],
) -> ModelCallResult:
if self._should_force(request.messages):
request = self._patch(request)
return handler(request)
@override
async def awrap_model_call(
self,
request: ModelRequest,
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
) -> ModelCallResult:
if self._should_force(request.messages):
request = self._patch(request)
return await handler(request)
@@ -0,0 +1,18 @@
"""Persistence for iframe embed session → thread mappings."""
from deerflow.persistence.embed_sessions.base import EmbedSessionStore
from deerflow.persistence.embed_sessions.memory import MemoryEmbedSessionStore
from deerflow.persistence.embed_sessions.sql import EmbedSessionRepository
__all__ = [
"EmbedSessionStore",
"MemoryEmbedSessionStore",
"EmbedSessionRepository",
"make_embed_session_store",
]
def make_embed_session_store(session_factory):
if session_factory is None:
return MemoryEmbedSessionStore()
return EmbedSessionRepository(session_factory)
@@ -0,0 +1,18 @@
"""Abstract persistence API for iframe embed session → thread mappings."""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any
class EmbedSessionStore(ABC):
@abstractmethod
async def upsert(self, session_id: str, thread_id: str) -> dict[str, Any]:
"""Create or update the mapping for an embed session."""
raise NotImplementedError
@abstractmethod
async def get(self, session_id: str) -> dict[str, Any] | None:
"""Return ``{session_id, thread_id, created_at, updated_at}`` or None."""
raise NotImplementedError
@@ -0,0 +1,37 @@
"""In-memory ``EmbedSessionStore`` for tests / memory backend."""
from __future__ import annotations
import copy
from datetime import UTC, datetime
from typing import Any
from deerflow.persistence.embed_sessions.base import EmbedSessionStore
class MemoryEmbedSessionStore(EmbedSessionStore):
def __init__(self) -> None:
self._sessions: dict[str, dict[str, Any]] = {}
async def upsert(self, session_id: str, thread_id: str) -> dict[str, Any]:
now = datetime.now(UTC).isoformat()
existing = self._sessions.get(session_id)
if existing is None:
record = {
"session_id": session_id,
"thread_id": thread_id,
"created_at": now,
"updated_at": now,
}
else:
record = {
**existing,
"thread_id": thread_id,
"updated_at": now,
}
self._sessions[session_id] = record
return copy.deepcopy(record)
async def get(self, session_id: str) -> dict[str, Any] | None:
record = self._sessions.get(session_id)
return copy.deepcopy(record) if record is not None else None
@@ -0,0 +1,21 @@
"""ORM model for iframe embed session → thread lookups."""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
from deerflow.persistence.types import BeijingDateTime
class EmbedSessionRow(Base):
__tablename__ = "embed_sessions"
# Opaque id generated by the parent page and passed into the iframe URL.
session_id: Mapped[str] = mapped_column(String(128), primary_key=True)
thread_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
@@ -0,0 +1,49 @@
"""SQLAlchemy-backed embed session repository."""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.embed_sessions.base import EmbedSessionStore
from deerflow.persistence.embed_sessions.model import EmbedSessionRow
def _row_to_dict(row: EmbedSessionRow) -> dict[str, Any]:
data = row.to_dict()
for key in ("created_at", "updated_at"):
value = data.get(key)
if isinstance(value, datetime):
data[key] = value.isoformat()
return data
class EmbedSessionRepository(EmbedSessionStore):
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
async def upsert(self, session_id: str, thread_id: str) -> dict[str, Any]:
async with self._sf() as session:
row = await session.get(EmbedSessionRow, session_id)
now = datetime.now(UTC)
if row is None:
row = EmbedSessionRow(
session_id=session_id,
thread_id=thread_id,
created_at=now,
updated_at=now,
)
session.add(row)
else:
row.thread_id = thread_id
row.updated_at = now
await session.commit()
await session.refresh(row)
return _row_to_dict(row)
async def get(self, session_id: str) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(EmbedSessionRow, session_id)
return _row_to_dict(row) if row is not None else None
@@ -0,0 +1,39 @@
"""Create ``embed_sessions`` table for iframe embed status polling.
Revision ID: 20260602_01
Revises: 20260528_02
Create Date: 2026-06-02
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260602_01"
down_revision = "20260528_02"
branch_labels = None
depends_on = None
def _has_table(table_name: str) -> bool:
inspector = inspect(op.get_bind())
return table_name in inspector.get_table_names()
def upgrade() -> None:
if not _has_table("embed_sessions"):
op.create_table(
"embed_sessions",
sa.Column("session_id", sa.String(128), primary_key=True),
sa.Column("thread_id", sa.String(64), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_index("ix_embed_sessions_thread_id", "embed_sessions", ["thread_id"])
def downgrade() -> None:
if _has_table("embed_sessions"):
op.drop_table("embed_sessions")
@@ -0,0 +1,49 @@
"""Add step3 column to roundtable_drafts.
Step 3「结果绘制」persistence: the generated report (HTML + metadata) is stored
inline in the draft so a reloaded draft can show the report without depending on
the ephemeral report thread. The column is LONGTEXT on MySQL (HTML can exceed the
64 KB TEXT cap) and plain TEXT (unbounded) elsewhere — matching ``PortableLongText``
on ``RoundtableDraftRow.step3``. ``create_all`` adds the column for brand-new DBs;
this revision patches DBs that already have the table.
Revision ID: 20260603_01
Revises: 20260602_01
Create Date: 2026-06-03
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
from sqlalchemy.dialects.mysql import LONGTEXT
revision = "20260603_01"
down_revision = "20260602_01"
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 _has_column(table_name: str, column_name: str) -> bool:
if not _has_table(table_name):
return False
inspector = inspect(op.get_bind())
return any(col["name"] == column_name for col in inspector.get_columns(table_name))
def upgrade() -> None:
if _has_table("roundtable_drafts") and not _has_column("roundtable_drafts", "step3"):
col_type = LONGTEXT() if op.get_bind().dialect.name == "mysql" else sa.Text()
op.add_column("roundtable_drafts", sa.Column("step3", col_type, nullable=True))
def downgrade() -> None:
if _has_column("roundtable_drafts", "step3"):
op.drop_column("roundtable_drafts", "step3")
@@ -22,6 +22,7 @@ from deerflow.persistence.llm_metrics.model import LlmCallMetricRow
from deerflow.persistence.models.run_event import RunEventRow
from deerflow.persistence.notifications.model import NotificationRow
from deerflow.persistence.recommended_questions.model import RecommendedQuestionRow
from deerflow.persistence.roundtable_chains.model import RoundtableChainRow
from deerflow.persistence.roundtable_drafts.model import (
RoundtableDraftRow,
RoundtableRecommendHistoryRow,
@@ -37,6 +38,7 @@ from deerflow.persistence.scheduled_tasks.model import (
from deerflow.persistence.skills.model import SkillDisplayAdapterRow, SkillRow
from deerflow.persistence.tags.model import TagAssignmentRow, TagRow
from deerflow.persistence.thread_meta.model import ThreadMetaRow
from deerflow.persistence.embed_sessions.model import EmbedSessionRow
from deerflow.persistence.thread_shares.model import ThreadShareRow
from deerflow.persistence.tool_metrics.model import ToolCallMetricRow
from deerflow.persistence.user.model import UserRow
@@ -49,6 +51,7 @@ __all__ = [
"LlmCallMetricRow",
"NotificationRow",
"RecommendedQuestionRow",
"RoundtableChainRow",
"RoundtableDraftRow",
"RoundtableRecommendHistoryRow",
"RunEventRow",
@@ -62,6 +65,7 @@ __all__ = [
"SkillDisplayAdapterRow",
"TagAssignmentRow",
"TagRow",
"EmbedSessionRow",
"ThreadMetaRow",
"ThreadShareRow",
"ToolCallMetricRow",
@@ -0,0 +1,18 @@
"""Roundtable-planning business-chain persistence."""
from __future__ import annotations
from deerflow.persistence.roundtable_chains.model import RoundtableChainRow
from deerflow.persistence.roundtable_chains.sql import RoundtableChainRepository
__all__ = [
"RoundtableChainRepository",
"RoundtableChainRow",
"make_roundtable_chain_store",
]
def make_roundtable_chain_store(session_factory):
if session_factory is None:
return None
return RoundtableChainRepository(session_factory)
@@ -0,0 +1,44 @@
"""ORM model for roundtable-planning business chains.
A "business chain" (业务链条) is a named, reusable, *ordered* list of agents the
user assembles in the chain editor. In Step 2 the coordinator dispatches to the
seats following this order (see ``multi-agent-business-chain-dev.md``).
Persisted per user (like ``roundtable_drafts``) so it follows the account across
devices/sessions. ``seats`` is stored as a JSON array serialized into
``PortableLongText`` (MySQL ``LONGTEXT``) rather than ``PortableJSON`` for
headroom, mirroring the sibling roundtable persistence modules.
"""
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, PortableLongText
class RoundtableChainRow(Base):
__tablename__ = "roundtable_chains"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
# Human-readable chain title shown on the picker card.
title: Mapped[str] = mapped_column(String(512), nullable=False, default="")
# Optional free-text description.
description: Mapped[str | None] = mapped_column(String(2048), nullable=True)
# JSON: ordered [{"agent_id": str, "name": str, "description": str}, ...].
# seats[0] is the first seat to speak. Stored as LONGTEXT-backed JSON string.
seats: Mapped[str | None] = mapped_column(PortableLongText, nullable=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),
onupdate=lambda: datetime.now(UTC),
)
@@ -0,0 +1,123 @@
"""SQLAlchemy-backed roundtable business-chain store.
The ``seats`` JSON column is stored as a serialized string in
``PortableLongText`` and round-tripped here with ``json.dumps`` / ``json.loads``
— same approach as ``roundtable_drafts`` / ``ai_writing_sessions``.
All write/read paths are scoped by ``user_id`` for ownership isolation; a row
that belongs to another user is treated as not found.
"""
from __future__ import annotations
import json
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.roundtable_chains.model import RoundtableChainRow
_UNSET = object()
def _loads(value: Any) -> Any:
if not isinstance(value, str):
return value
if not value:
return None
try:
return json.loads(value)
except Exception:
return None
def _dumps(value: Any) -> str | None:
if value is None:
return None
return json.dumps(value, ensure_ascii=False)
class RoundtableChainRepository:
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
@staticmethod
def _to_dict(row: RoundtableChainRow) -> dict[str, Any]:
return {
"id": row.id,
"title": row.title,
"description": row.description,
"seats": _loads(row.seats) or [],
"created_at": row.created_at.isoformat() if isinstance(row.created_at, datetime) else row.created_at,
"updated_at": row.updated_at.isoformat() if isinstance(row.updated_at, datetime) else row.updated_at,
}
async def list_chains(self, user_id: str, *, limit: int = 200) -> list[dict[str, Any]]:
stmt = (
select(RoundtableChainRow)
.where(RoundtableChainRow.user_id == user_id)
.order_by(RoundtableChainRow.updated_at.desc())
.limit(limit)
)
async with self._sf() as session:
result = await session.execute(stmt)
return [self._to_dict(row) for row in result.scalars()]
async def get_chain(self, chain_id: str, user_id: str) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(RoundtableChainRow, chain_id)
if row is None or row.user_id != user_id:
return None
return self._to_dict(row)
async def create_chain(self, user_id: str, data: dict[str, Any]) -> dict[str, Any]:
now = datetime.now(UTC)
row = RoundtableChainRow(
id=data["id"],
user_id=user_id,
title=(data.get("title") or "").strip(),
description=(data.get("description") or None),
seats=_dumps(data.get("seats") or []),
created_at=now,
updated_at=now,
)
async with self._sf() as session:
session.add(row)
await session.commit()
await session.refresh(row)
return self._to_dict(row)
async def update_chain(
self,
chain_id: str,
user_id: str,
*,
title=_UNSET,
description=_UNSET,
seats=_UNSET,
) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(RoundtableChainRow, chain_id)
if row is None or row.user_id != user_id:
return None
if title is not _UNSET and title is not None:
row.title = str(title).strip()
if description is not _UNSET:
row.description = description or None
if seats is not _UNSET:
row.seats = _dumps(seats)
row.updated_at = datetime.now(UTC)
await session.commit()
await session.refresh(row)
return self._to_dict(row)
async def delete_chain(self, chain_id: str, user_id: str) -> bool:
async with self._sf() as session:
row = await session.get(RoundtableChainRow, chain_id)
if row is None or row.user_id != user_id:
return False
await session.delete(row)
await session.commit()
return True
@@ -33,6 +33,10 @@ class RoundtableDraftRow(Base):
# JSON blobs matching the frontend Step1Snapshot / Step2Snapshot shapes.
step1: Mapped[str | None] = mapped_column(PortableLongText, nullable=True) # JSON object
step2: Mapped[str | None] = mapped_column(PortableLongText, nullable=True) # JSON object
# Step 3「结果绘制」snapshot: { html, generatedAt, model, summary? }. The full
# report HTML is stored inline so a reloaded draft can show the report without
# depending on the ephemeral report thread (LONGTEXT — HTML can exceed 64 KB).
step3: Mapped[str | None] = mapped_column(PortableLongText, nullable=True) # JSON object
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(
BeijingDateTime(),
@@ -64,6 +64,7 @@ class RoundtableDraftRepository:
d = cls._draft_to_meta(row)
d["step1"] = _loads(row.step1)
d["step2"] = _loads(row.step2)
d["step3"] = _loads(row.step3)
return d
async def list_drafts(self, user_id: str, *, limit: int = 100) -> list[dict[str, Any]]:
@@ -93,6 +94,7 @@ class RoundtableDraftRepository:
furthest_step=int(data.get("furthest_step") or 1),
step1=_dumps(data.get("step1")),
step2=_dumps(data.get("step2")),
step3=_dumps(data.get("step3")),
created_at=now,
updated_at=now,
)
@@ -111,6 +113,7 @@ class RoundtableDraftRepository:
furthest_step=_UNSET,
step1=_UNSET,
step2=_UNSET,
step3=_UNSET,
) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(RoundtableDraftRow, draft_id)
@@ -124,6 +127,8 @@ class RoundtableDraftRepository:
row.step1 = _dumps(step1)
if step2 is not _UNSET:
row.step2 = _dumps(step2)
if step3 is not _UNSET:
row.step3 = _dumps(step3)
row.updated_at = datetime.now(UTC)
await session.commit()
await session.refresh(row)
@@ -4,11 +4,13 @@ from .memory_tool import memory_tool
from .present_file_tool import present_file_tool
from .scheduled_task_tool import scheduled_task_tool
from .setup_agent_tool import setup_agent
from .setup_writing_tool import setup_writing
from .task_tool import task_tool
from .view_image_tool import view_image_tool
__all__ = [
"setup_agent",
"setup_writing",
"present_file_tool",
"ask_clarification_tool",
"agent_orchestration_tool",
@@ -0,0 +1,53 @@
"""写作配置助手用的提交工具。
与 ``setup_agent`` 同构:模型在「写作配置 Q&A」里与用户聊清需求后,调用本工具把建议的
写作配置「提交」出来。实际它总是被 ``SetupWritingApprovalMiddleware`` 在 ``after_model``
拦截(不真正执行)——拦截后把建议字段打包进审批 ToolMessage,前端据此回填左侧写作表单。
因此本工具的函数体基本是兜底:万一未被拦截(如审批被预先放行),也只回一句确认、不落任何盘。
"""
from __future__ import annotations
import logging
from langchain_core.messages import ToolMessage
from langchain_core.tools import tool
from langgraph.prebuilt import ToolRuntime
from langgraph.types import Command
logger = logging.getLogger(__name__)
@tool
def setup_writing(
user_intent: str,
runtime: ToolRuntime,
article_type: str | None = None,
word_count_target: int | None = None,
target_audience: str | None = None,
writing_mode: str | None = None,
material_source: str | None = None,
outline: str | None = None,
) -> Command:
"""把与用户聊清的写作配置提交给前端表单(不直接开始写作)。
Args:
user_intent: 用户的写作意图(精炼成一句话的写作目标)。
article_type: 文章类型的 key(须是系统给定的合法类型之一;不确定可留空)。
word_count_target: 目标字数(正整数)。
target_audience: 目标读者(如「行业专家」「普通读者」)。
writing_mode: 写作模式,'strict'(严格依据素材)或 'loose'(可结合通用知识)。
material_source: 素材来源,'knowledge_base'(知识库)或 'skill'(技能检索)。
outline: 若用户已提供大纲,原样填入其大纲文本(保留其层级与标题),否则留空。
"""
logger.info("[setup_writing] proposed writing config submitted (intercepted by approval middleware)")
return Command(
update={
"messages": [
ToolMessage(
content="写作配置已生成,请在右侧确认后应用到表单。",
tool_call_id=runtime.tool_call_id,
)
]
}
)
+9
View File
@@ -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"
+20
View File
@@ -0,0 +1,20 @@
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://ai.google.dev/static/site-assets/images/share-ais-513315318.png" />
</div>
# 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`
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>业务链条配置</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+6
View File
@@ -0,0 +1,6 @@
{
"name": "业务链条配置",
"description": "编排有序的智能体研讨链路配置系统。",
"requestFramePermissions": [],
"majorCapabilities": ["MAJOR_CAPABILITY_SERVER_SIDE_GEMINI_API"]
}
+35
View File
@@ -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"
}
}
Loaded 100 of 106 files, more files were not shown because too many files have changed in this diff. Show more