Merge branch 'dhk-1' into 'master'
Dhk 1 See merge request knowledge-base-bj/deerflow-server!78
This commit is contained in:
commit
a4b14e4911
106 files changed
+9883
-509
No files matched your search
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`(自带大纲分支 + 素材关联)。
|
||||
@@ -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`):
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { resolveThemeFromQuery } from "@/core/auth/external-login";
|
||||
|
||||
const DEFAULT_USERNAME = "guest";
|
||||
|
||||
export type EmbedChatParams = {
|
||||
/** Parent correlation id — required for status polling without thread_id. */
|
||||
sessionId: string;
|
||||
message: string;
|
||||
username: string;
|
||||
theme: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve iframe embed params from URL query only (plain iframe, not wujie).
|
||||
*
|
||||
* Parent must generate ``sessionId`` and pass it on the iframe URL so it can
|
||||
* poll ``GET /api/public/embed/sessions/{sessionId}/status`` without reading
|
||||
* the child frame.
|
||||
*/
|
||||
export function resolveEmbedChatParams(search: string): EmbedChatParams {
|
||||
const params = new URLSearchParams(search.startsWith("?") ? search : `?${search}`);
|
||||
|
||||
const sessionId =
|
||||
params.get("sessionId")?.trim() ||
|
||||
params.get("session_id")?.trim() ||
|
||||
"";
|
||||
|
||||
const message =
|
||||
params.get("message")?.trim() ||
|
||||
params.get("question")?.trim() ||
|
||||
params.get("text")?.trim() ||
|
||||
"";
|
||||
|
||||
const username =
|
||||
params.get("username")?.trim() ||
|
||||
params.get("userName")?.trim() ||
|
||||
DEFAULT_USERNAME;
|
||||
|
||||
const theme = params.get("theme")?.trim() || "";
|
||||
|
||||
return { sessionId, message, username, theme };
|
||||
}
|
||||
|
||||
export function resolveEmbedTheme(themeParam: string): "dark" | "light" {
|
||||
return resolveThemeFromQuery(themeParam || null);
|
||||
}
|
||||
@@ -0,0 +1,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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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 会复制资源目录里的这些文件(存在即复制,缺失则跳过)。
|
||||
|
||||
+76
@@ -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` 重写)
|
||||
- ❌ 编造与原料明显矛盾的数据
|
||||
+3
@@ -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",
|
||||
|
||||
+120
-3
@@ -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(
|
||||
|
||||
+220
@@ -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)
|
||||
+54
@@ -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
|
||||
|
||||
+32
-3
@@ -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(
|
||||
|
||||
+58
-18
@@ -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 ""
|
||||
)
|
||||
|
||||
+129
@@ -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)
|
||||
+72
@@ -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)
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
"""Persistence for iframe embed session → thread mappings."""
|
||||
|
||||
from deerflow.persistence.embed_sessions.base import EmbedSessionStore
|
||||
from deerflow.persistence.embed_sessions.memory import MemoryEmbedSessionStore
|
||||
from deerflow.persistence.embed_sessions.sql import EmbedSessionRepository
|
||||
|
||||
__all__ = [
|
||||
"EmbedSessionStore",
|
||||
"MemoryEmbedSessionStore",
|
||||
"EmbedSessionRepository",
|
||||
"make_embed_session_store",
|
||||
]
|
||||
|
||||
|
||||
def make_embed_session_store(session_factory):
|
||||
if session_factory is None:
|
||||
return MemoryEmbedSessionStore()
|
||||
return EmbedSessionRepository(session_factory)
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
"""Abstract persistence API for iframe embed session → thread mappings."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
|
||||
class EmbedSessionStore(ABC):
|
||||
@abstractmethod
|
||||
async def upsert(self, session_id: str, thread_id: str) -> dict[str, Any]:
|
||||
"""Create or update the mapping for an embed session."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def get(self, session_id: str) -> dict[str, Any] | None:
|
||||
"""Return ``{session_id, thread_id, created_at, updated_at}`` or None."""
|
||||
raise NotImplementedError
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
"""In-memory ``EmbedSessionStore`` for tests / memory backend."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from deerflow.persistence.embed_sessions.base import EmbedSessionStore
|
||||
|
||||
|
||||
class MemoryEmbedSessionStore(EmbedSessionStore):
|
||||
def __init__(self) -> None:
|
||||
self._sessions: dict[str, dict[str, Any]] = {}
|
||||
|
||||
async def upsert(self, session_id: str, thread_id: str) -> dict[str, Any]:
|
||||
now = datetime.now(UTC).isoformat()
|
||||
existing = self._sessions.get(session_id)
|
||||
if existing is None:
|
||||
record = {
|
||||
"session_id": session_id,
|
||||
"thread_id": thread_id,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
else:
|
||||
record = {
|
||||
**existing,
|
||||
"thread_id": thread_id,
|
||||
"updated_at": now,
|
||||
}
|
||||
self._sessions[session_id] = record
|
||||
return copy.deepcopy(record)
|
||||
|
||||
async def get(self, session_id: str) -> dict[str, Any] | None:
|
||||
record = self._sessions.get(session_id)
|
||||
return copy.deepcopy(record) if record is not None else None
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
"""ORM model for iframe embed session → thread lookups."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from deerflow.persistence.base import Base
|
||||
from deerflow.persistence.types import BeijingDateTime
|
||||
|
||||
|
||||
class EmbedSessionRow(Base):
|
||||
__tablename__ = "embed_sessions"
|
||||
|
||||
# Opaque id generated by the parent page and passed into the iframe URL.
|
||||
session_id: Mapped[str] = mapped_column(String(128), primary_key=True)
|
||||
thread_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
|
||||
updated_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
"""SQLAlchemy-backed embed session repository."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from deerflow.persistence.embed_sessions.base import EmbedSessionStore
|
||||
from deerflow.persistence.embed_sessions.model import EmbedSessionRow
|
||||
|
||||
|
||||
def _row_to_dict(row: EmbedSessionRow) -> dict[str, Any]:
|
||||
data = row.to_dict()
|
||||
for key in ("created_at", "updated_at"):
|
||||
value = data.get(key)
|
||||
if isinstance(value, datetime):
|
||||
data[key] = value.isoformat()
|
||||
return data
|
||||
|
||||
|
||||
class EmbedSessionRepository(EmbedSessionStore):
|
||||
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||
self._sf = session_factory
|
||||
|
||||
async def upsert(self, session_id: str, thread_id: str) -> dict[str, Any]:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(EmbedSessionRow, session_id)
|
||||
now = datetime.now(UTC)
|
||||
if row is None:
|
||||
row = EmbedSessionRow(
|
||||
session_id=session_id,
|
||||
thread_id=thread_id,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
session.add(row)
|
||||
else:
|
||||
row.thread_id = thread_id
|
||||
row.updated_at = now
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return _row_to_dict(row)
|
||||
|
||||
async def get(self, session_id: str) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(EmbedSessionRow, session_id)
|
||||
return _row_to_dict(row) if row is not None else None
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
"""Create ``embed_sessions`` table for iframe embed status polling.
|
||||
|
||||
Revision ID: 20260602_01
|
||||
Revises: 20260528_02
|
||||
Create Date: 2026-06-02
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
|
||||
revision = "20260602_01"
|
||||
down_revision = "20260528_02"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _has_table(table_name: str) -> bool:
|
||||
inspector = inspect(op.get_bind())
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if not _has_table("embed_sessions"):
|
||||
op.create_table(
|
||||
"embed_sessions",
|
||||
sa.Column("session_id", sa.String(128), primary_key=True),
|
||||
sa.Column("thread_id", sa.String(64), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
op.create_index("ix_embed_sessions_thread_id", "embed_sessions", ["thread_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if _has_table("embed_sessions"):
|
||||
op.drop_table("embed_sessions")
|
||||
+49
@@ -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")
|
||||
+4
@@ -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",
|
||||
|
||||
+18
@@ -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)
|
||||
+44
@@ -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),
|
||||
)
|
||||
+123
@@ -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
|
||||
+4
@@ -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(),
|
||||
|
||||
+5
@@ -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",
|
||||
|
||||
+53
@@ -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,
|
||||
)
|
||||
]
|
||||
}
|
||||
)
|
||||
@@ -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"
|
||||
@@ -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`
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "业务链条配置",
|
||||
"description": "编排有序的智能体研讨链路配置系统。",
|
||||
"requestFramePermissions": [],
|
||||
"majorCapabilities": ["MAJOR_CAPABILITY_SERVER_SIDE_GEMINI_API"]
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user