This commit is contained in:
fengxiangdebaozi committed 2026-06-12 09:19:47 +08:00
commit b33426416b
36 files changed
+2900 -719

No files matched your search

@@ -0,0 +1,225 @@
# AI 写作「四阶段内置智能体化」改造方案
> 目标:把 AI 写作现有的 **素材收集专家 / 写作大纲 / 作家写作 / 编辑审核** 四个功能,
> 从「硬编码的 LangGraph 节点 + 硬编码提示词」改造为由**内置功能型智能体**驱动——
> 每个阶段对应一个可配置的内置 agent(独立人设 SOUL、可配模型、可挂技能/工具)。
本文给出现状梳理、目标设计、以及**分步可独立交付的实现计划**。
---
## 0. 一句话结论
不推倒重来。**保留现有图的编排骨架、4 个用户干预点、流式与状态机,只把每个阶段节点的「大脑」从「硬编码 prompt 直调 LLM」替换为「加载对应内置 agent 的 SOUL+配置、在子线程上跑该 agent、按结构化契约解析其输出」。** 这样前端几乎不动,复杂逻辑(严格模式、素材不足求助、逐章流式、技能检索步骤)全部保留。
---
## 1. 现状梳理(精确到文件)
AI 写作是一条 LangGraph 图:`packages/harness/deerflow/agents/ai_writing/graph.py`
```
intent_parser → researcher → (pause 素材确认) → writer_outline → (pause 大纲确认)
→ writer_draft → (pause 草稿确认 / 素材不足求助) → editor → (pause 编辑打回) → done
```
四个待改造阶段(均为**硬编码节点**,直接调 LLM、不走 agent 运行时):
| 阶段(用户语言) | 节点文件 | 提示词 | 产出(`state.py`) |
|---|---|---|---|
| 素材收集专家 | `nodes/researcher.py` | `prompts/researcher_prompts.py` | `material_package`(keywords + materials) |
| 写作大纲 | `nodes/writer_outline.py` | `prompts/writer_prompts.py` | `current_outline`(Outline JSON) |
| 作家写作 | `nodes/writer_draft.py` | `prompts/writer_prompts.py` | `current_draft`(Markdown,逐章流式) |
| 编辑审核 | `nodes/editor.py` | `prompts/editor_prompts.py` | `review_result`(评分 + 问题列表 JSON) |
关键事实:
- **LLM 调用方式**:节点通过 `nodes/_utils.py` 的 `get_model()` + `llm_json()` **直接调 LLM**,提示词写死在 `prompts/*.py`。**没有**走 agent 人设 / 技能 / 工具体系。
- **模型**:全程单一 `state.model_name`(用户在写作表单里选的主模型),各阶段无法分别配。
- **干预点 / 流式 / 严格模式 / 素材不足求助**:都在 `graph.py` 的 pause 节点和 `writer_draft` 里,逻辑较重,需原样保留。
- **前端**:`frontend-web/src/open-canvas/contexts/AIWritingContext.tsx` 是状态机(`researching/writing_outline/writing_draft/reviewing/...`),时间线 `AIWritingTimeline.tsx` 按 `progress_events`(已带 `agent_name` 字段)渲染。
---
## 2. 可复用的现成机制(不用造轮子)
代码库里**已经有**一套成熟的「内置功能型智能体」范式——圆桌的 `roundtable-intent / roundtable-recommender / roundtable-coordinator`:
1. **资源即代码**:`app/gateway/routers/_roundtable_seed_assets/<agent-id>/`
- `config.yaml`(`id` / `name` / `description`,可扩展 `model` / `skills` / `tool_groups`)
- `SOUL.md`(人设 + 职责 + 工作流 + **结构化输出契约**)
2. **种子器**:`app/gateway/routers/_roundtable_seed.py::ensure_roundtable_functional_agents`
- 启动时 + 相关路由入口自愈:目录不存在才从资源字节级落盘到 `.deer-flow/agents/<id>/` + DB(**已存在则永不覆盖**,尊重本地编辑)。
3. **运行**:在**独立 thread** 上跑该内置 agent(见 `app/gateway/routers/intent.py`,`INTENT_AGENT_ID = "roundtable-intent"`),通过 lead agent 运行时把 SOUL.md 作系统提示,流式经 LangGraph `custom`/`messages` 通道透传。
4. **结构化输出约定**:SOUL 规定模型末尾输出固定 **marker + ```json```**(如 `[INTENT_READY]\n```json{...}```),后端用正则解析(`intent.py::_INTENT_READY_PATTERN`)。
> 我们要做的,本质就是**照搬这套范式,给 AI 写作做 4 个功能型 agent**,并让对应的图节点改成「跑这个 agent + 解析其结构化输出」。
另有 agents 通用体系可复用:`agents` 表、`/api/agents` CRUD、智能体管理页(配模型/技能/工具)——4 个写作 agent 落盘后天然出现在这里,可被 admin 编辑。
---
## 3. 目标设计
### 3.1 四个内置写作智能体(建议 id / 职责)
| Agent id | 名称 | 取代节点 | 结构化输出 marker | 产出 schema |
|---|---|---|---|---|
| `ai-writing-researcher` | 素材收集专家 | `researcher` | `[MATERIALS_READY]` | `MaterialPackage` |
| `ai-writing-outliner` | 大纲规划师 | `writer_outline` | `[OUTLINE_READY]` | `Outline` |
| `ai-writing-writer` | 作家 | `writer_draft` | `[SECTION_READY]`(逐章) | 章节 Markdown |
| `ai-writing-editor` | 编辑 | `editor` | `[REVIEW_READY]` | `ReviewResult` |
每个 agent 自带:
- `config.yaml`:`id / name / description` +(扩展)`model`(该阶段专用模型,可空=回退用户主模型)、`skills`(如 researcher 挂检索技能)、`tool_groups`。
- `SOUL.md`:由现有 `prompts/*.py` 内容迁移改写,**追加结构化输出契约**(严格 marker + JSON 格式 + 反例,仿 `roundtable-intent` SOUL 的「硬规则」写法,压制弱模型乱输出)。
### 3.2 节点改造方式(核心)
每个阶段节点保留**输入/输出契约不变**(仍读写 `state` 的同一批字段),内部从:
```
# 旧:硬编码 prompt 直调
model = get_model(config, state["model_name"])
result = llm_json(model, SYSTEM_PROMPT, USER_PROMPT.format(...))
```
改为:
```
# 新:跑对应内置 agent,按 marker 解析
result = await run_writing_agent(
agent_id="ai-writing-outliner",
state=state, config=config,
user_payload=<本阶段输入>, # 意图/素材/大纲/草稿等拼成的一段输入
marker="[OUTLINE_READY]", # 结构化输出契约
)
```
新增统一助手 `nodes/_agent_runner.py::run_writing_agent(...)`:在子 thread 上跑指定内置 agent(复用 `intent.py` 的 run/stream 机制)、把流式 chunk 透传到 `custom` 通道(前端时间线不回归)、用复用的 `_utils.parse_json` 多级容错解析 marker 后的 JSON。
### 3.3 为什么选这个方案(A)而非全多智能体重写(B)
- **方案 A(推荐)**:保留图骨架,逐节点替换大脑。前端基本不动;干预点/流式/严格模式/素材求助原样保留;可**按阶段灰度 + feature flag 回退**,风险可控。
- **方案 B(不推荐)**:仿圆桌重做成多 agent 编排,需重写编排、状态、流式、4 个干预点对接,工作量数倍、回归面巨大,收益(更"纯"的多 agent)对本场景并不必要。
---
## 4. 配置与可管理性
- **落盘**:仿 roundtable 新增 `_ai_writing_seed_assets/` + 一个 `ensure_ai_writing_functional_agents()` 种子器,在 Gateway 启动与 ai_writing 会话入口自愈。
- **管理界面**:4 个 agent 落盘后即出现在智能体管理页,admin 可编辑 **SOUL(人设)/ 模型 / 挂载技能**。可选:在「文章类型配置页」旁加一个「写作智能体配置」入口,或给这 4 个打一个 `tag` 便于筛选。
- **模型优先级**:节点跑 agent 时 `该 agent config.model > 用户写作表单选的主模型 model_name`(保留现有"用户选模型"语义,新增 per-阶段覆盖能力)。
- **技能**:researcher 现有的技能检索能力(`search/skill_search.py`)改为 `ai-writing-researcher` 的**挂载技能**,admin 可增减;检索步骤事件(`SkillStep`)形态保留给前端展示。
---
## 5. 分步实现计划(每步可独立交付 / 验收)
### Phase 0 · 契约冻结
- 定 4 个 agent 的 `id / name`、每个的 **marker + JSON schema**(与 `state.py` 的 `MaterialPackage/Outline/DraftArticle/ReviewResult` 严格对齐)。
- **验收**:本文 §3.1 / §6 的契约评审通过、字段与现有 TypedDict 一一对应。
### Phase 1 · 内置 agent 种子化(后端,纯新增,不接管流程)
- 新建 `app/gateway/routers/_ai_writing_seed_assets/<4 个 agent>/{config.yaml, SOUL.md}`;SOUL 由现有 `prompts/*.py` 迁移改写 + 追加结构化输出契约。
- 仿 `_roundtable_seed.py` 写 `ensure_ai_writing_functional_agents()`,挂到启动 lifespan + ai_writing 入口。
- **验收**:启动后 4 个 agent 出现在 `.deer-flow/agents/`、DB、`/api/agents`,能在智能体管理页查看/编辑;**此阶段不改图,写作流程行为不变**。
### Phase 2 · 节点接入「跑内置 agent」(后端,逐阶段灰度)
- 新增 `nodes/_agent_runner.py::run_writing_agent(...)`(子线程跑 agent + 流式透传 + marker 解析)。
- 加 feature flag:`config.yaml → ai_writing.use_builtin_agents.{editor,outliner,researcher,writer}`(默认 false=走旧节点)。
- **接入顺序(由易到难)**:
1. **editor**(最简单:小 JSON、无检索、无逐章流式)→ flag 打开端到端验证。
2. **outliner**(输出 Outline JSON)。
3. **researcher**(保留技能检索步骤事件、素材确认暂停)。
4. **writer**(最难:保留**逐章流式** + **严格模式素材不足求助** `blocked_sections`;建议**仍由节点控制"逐章循环"**,每章调一次 `ai-writing-writer`,`section_help` 暂停逻辑不动)。
- 每接一个,旧 prompt 与旧逻辑**保留**,节点按 flag 二选一。
- **验收**:每个阶段 flag 打开后端到端走通,产出结构与旧版一致,干预点 / 流式 / 严格模式 / 求助暂停均不回归。
### Phase 3 · 每阶段 agent 配置化
- per-agent 模型覆盖生效(`agent.config.model > model_name`)。
- researcher 的检索能力改为挂载技能,admin 可增减。
- 智能体管理页可编辑这 4 个的 SOUL / 模型 / 技能;config 热加载,新会话即生效、不重启前端。
- **验收**:admin 改某阶段 agent 的模型 / SOUL / 技能后,新会话立即按新配置运行。
### Phase 4 · 前端适配(小改)
- 时间线 / 状态文案:阶段名读对应 agent 的 `name`(`progress_events.agent_name` 已存在,基本沿用,去掉写死文案)。
- 可选:写作表单 / 设置加「查看·配置写作智能体」入口,跳智能体管理页。
- **验收**:前端展示各阶段对应的智能体名;配置入口可达。
### Phase 5 · 清理收口
- 四阶段稳定后,默认打开全部 flag、移除旧 prompt 直调路径与 flag(prompts 历史留在 git)。
- 更新 `offline-backend-20260512/backend/CLAUDE.md` 架构描述。
- **验收**:回归测试通过、冗余删除、文档同步。
---
## 6. 结构化输出契约(与现有 state 对齐)
各 agent SOUL 末尾必须输出 `marker + ```json``` 块,schema 直接对齐 `state.py`:
- `ai-writing-researcher` → `[MATERIALS_READY]`:`{ "keywords": string[], "materials": Material[] }`(`Material` 见 `state.py`,含 `id/title/content/source/relevance_score/source_type/...`)。
- `ai-writing-outliner` → `[OUTLINE_READY]`:`Outline = { "title": string, "sections": OutlineSection[] }`(`OutlineSection = { section_title, key_points: KeyPoint[], material_ids }`)。
- `ai-writing-writer` → 逐章 `[SECTION_READY]`:`{ "section_title": string, "markdown": string, "needs_more_material"?: { "reason": string } }`(`needs_more_material` 触发严格模式求助 `blocked_sections`)。
- `ai-writing-editor` → `[REVIEW_READY]`:`ReviewResult = { "verdict": "pass"|"reject", "fact_score", "logic_score", "language_score", "overall_score", "pass_threshold", "revision_notes": ReviewIssue[] }`。
解析复用 `nodes/_utils.py::parse_json`(多级容错),marker 正则仿 `intent.py::_INTENT_READY_PATTERN`。
---
## 7. 风险与注意
1. **弱模型结构化输出不稳**:SOUL 写严格格式 + 反例 + 「输出 marker 后立刻闭嘴」硬规则(仿 `roundtable-intent` SOUL §0);解析层复用 `parse_json` 多级降级。
2. **writer 的逐章流式 + 严格模式求助最复杂**:建议**循环控制权留在节点**(每章调一次 agent),不要让 agent 一次吐全文,否则 `blocked_sections` / `section_help` / 逐章流式都要重做。
3. **researcher 技能检索步骤事件**(`SkillStep`)前端有专门卡片:迁移时保留这些 `progress_events` 形态。
4. **种子器 never-overwrite**:迭代 SOUL 后**老部署不会自动更新**(与圆桌同一问题)。运维更新办法:删 `.deer-flow/agents/<id>/` 重新种子,或后续给种子器加「内置功能 agent 按版本/hash 覆盖」开关。
5. **子线程隔离**:跑写作 agent 的子 thread 与主 ai_writing thread 状态隔离,注意 checkpointer 与清理(沿用 ai_writing 既有的会话清理)。
6. **离线内网**:researcher 的检索一律走技能(不联网),与现有「离线部署禁 web_search、走 skill」策略一致。
---
## 8. 涉及文件清单(落地时对照)
**后端(主要改动)**
- 新增 `app/gateway/routers/_ai_writing_seed_assets/{ai-writing-researcher,ai-writing-outliner,ai-writing-writer,ai-writing-editor}/{config.yaml,SOUL.md}`
- 新增 `app/gateway/routers/_ai_writing_seed.py`(种子器,仿 `_roundtable_seed.py`)
- 新增 `packages/harness/deerflow/agents/ai_writing/nodes/_agent_runner.py`(`run_writing_agent`)
- 改 `nodes/{researcher,writer_outline,writer_draft,editor}.py`(按 flag 二选一)
- 改 `config.yaml`(新增 `ai_writing.use_builtin_agents.*` 开关;可选 per-agent 默认模型)
- 保留 `prompts/*.py`(回退用,Phase 5 删)
**前端(小改)**
- 改 `src/open-canvas/components/ai-writing/AIWritingTimeline.tsx`(阶段名读 agent name)
- 可选:写作表单/设置加「配置写作智能体」入口(跳 `/page/...` 智能体管理页)
**文档**
- 本文件
- Phase 5 收口时更新 `offline-backend-20260512/backend/CLAUDE.md`
---
## 9. 实施记录(2026-06-11,Phase 1-4 已落地)
### 已交付
- **Phase 1**:`_ai_writing_seed_assets/{ai-writing-researcher,ai-writing-outliner,ai-writing-writer,ai-writing-editor}/{config.yaml,SOUL.md}` + `_ai_writing_seed.py::ensure_ai_writing_functional_agents()`,挂启动 lifespan(`_sync_legacy_agents` 之前)+ `ai_writing.list_sessions / list_article_types` 入口自愈。
- **Phase 2**:`nodes/_agent_runner.py`(`stream_writing_agent` / `run_writing_agent` / `parse_marker_json` / `resolve_agent_model_name` / `use_builtin_agent`);四节点按 `config.yaml → ai_writing.use_builtin_agents.{researcher,outliner,writer,editor}` 二选一(默认全 false,config 按 mtime 热加载,改完即对新会话生效)。agent 目录缺失时节点**静默回退旧 prompt 路径**,写作流程永不因 agent 被删而中断。
- **Phase 3(部分)**:per-agent 模型覆盖已生效(优先级 `agent config.model > 节点显式回退模型(keyword_model/rank_model)> 表单主模型 model_name`);SOUL/模型在智能体管理页可编辑、按 mtime 热加载。researcher「检索能力改挂载技能」未做(仍走节点内 SkillPicker 编排)。
- **Phase 4**:时间线 agent 名由后端 `progress_events.agent_name` 动态下发(flag 开启时读 agent config 的 `name`);`TimelineStep.tsx` 补「大纲规划师」配色(未命中名字回退灰色,admin 改名不报错)。
- **测试**:`tests/test_ai_writing_seed.py`(资源完整性 / 契约 marker / never-overwrite)+ `tests/test_ai_writing_agent_runner.py`(marker 多级解析 / 模型优先级 / flag / 流式端到端 / SOUL 缺失降级),共 31 例。
### 与 §6 契约的修订(以实现为准)
为保住「逐章流式打字机」「review_chunk/review_item_done 分维度卡片」「素材确认/求助暂停」等前端形态,编排控制权全部留在节点,agent 只承担**单次结构化产出**:
| Agent | 实际契约 |
|---|---|
| `ai-writing-researcher` | 两个子任务:`[KEYWORDS_READY]` + `{"keywords": []}`;`[MATERIALS_RANKED]` + `{"ranked_ids": [], "summary": ""}`。素材本体来自节点的检索编排(agent 不联网),`MaterialPackage` 仍由节点组装 |
| `ai-writing-outliner` | `[OUTLINE_READY]` + 完整 `Outline` JSON(新规划 / 修改模式共用,payload 开头标明任务模式) |
| `ai-writing-writer` | **无 JSON marker**:每章直接输出 Markdown 正文(保流式);严格模式素材不足只输出一行 `[[SECTION_BLOCKED]] 原因`(沿用原哨兵) |
| `ai-writing-editor` | 每个维度一次调用:`[REVIEW_READY]` + `{"score": int, "issues": []}`;综合分/verdict/pass_threshold 仍由节点按 config 计算(不信任 LLM 自判通过) |
另一处架构修订:**不在子 thread 上经 lead agent 运行时跑**(§3.2 原设想复用 `intent.py` 机制,但其 loopback 在 app 层,harness 图节点不可 import;且单轮结构化调用无需 thread/checkpointer/中间件成本)。`run_writing_agent` 直接「SOUL 作 system prompt + 单条输入」调模型,规避了 §7.5 的子线程隔离/清理风险。researcher 挂技能(Phase 3 剩余项)如需完整工具体系再评估接入运行时。
### 已知存量测试失败(与本次无关)
`test_ai_writing_graph.py`(mock 已删除的 `intent_parser.llm_json`)、`test_ai_writing_word_count.py`(mock 已删除的 `writer_draft.get_model`)、`test_ai_writing_draft_headings.py`(断言三级结构升级前的 `####→###` 行为)、`test_ai_writing_outline_revise.py`(断言旧 prompt 文案),改造前即失败,待另行清理。
@@ -1,228 +0,0 @@
# 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`)。
@@ -1,274 +0,0 @@
# 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`(自带大纲分支 + 素材关联)。
@@ -22,6 +22,8 @@ function statusOf(event: ProgressEvent, isLast: boolean): StepStatus {
const AGENT_COLOR: Record<string, string> = {
素材收集专家: 'text-blue-500',
作家: 'text-purple-500',
// 内置智能体模式(ai_writing.use_builtin_agents.outliner)下大纲阶段的默认 agent 名
大纲规划师: 'text-violet-500',
编辑: 'text-orange-500',
系统: 'text-muted-foreground',
};
@@ -174,3 +174,34 @@ export async function deleteChain(id: string): Promise<void> {
throw new Error(`delete chain failed (${res.status}): ${text}`);
}
}
// ── 提示词优化 ───────────────────────────────────────────────────────────────
export interface OptimizeChainPromptRequest {
/** 用户当前写的提示词原文(非空)。 */
promptText: string;
/** coordinator = 总控提示词;stage_goal = 某个 Stage 的阶段目标。 */
kind: "coordinator" | "stage_goal";
/** 链条结构上下文(标题 / 各 Stage 席位与目标),帮助模型贴着链条优化。 */
context?: string;
/** 可选模型覆盖,缺省由后端用 config models[0]。 */
modelName?: string;
signal?: AbortSignal;
}
/** 调后端单次 LLM 重写提示词,返回优化后的全文(失败抛错,错误信息可直接 toast)。 */
export async function optimizeChainPrompt(req: OptimizeChainPromptRequest): Promise<string> {
const res = await apiFetch(`${BASE()}/optimize-prompt`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
prompt_text: req.promptText,
kind: req.kind,
context: req.context ?? "",
...(req.modelName ? { model_name: req.modelName } : {}),
}),
signal: req.signal,
});
const data = await readJson<{ optimized: string }>(res, "optimize chain prompt");
return data.optimized ?? "";
}
@@ -115,6 +115,11 @@ export interface DraftStep3Snapshot {
model?: string;
/** The HTML agent's closing one-liner (rendered as the report message bubble). */
summary?: string;
/**
* 绘制这份 HTML 时的界面主题(light=浅红 / dark=深蓝)。恢复草稿时与当前主题
* 比对,不一致则提示「重新绘制以匹配当前主题」。旧草稿没有该字段 → 不提示。
*/
themeMode?: "light" | "dark";
/**
* Step 3「总结报告」snapshot — output of the `roundtable-summary` agent
* (Markdown report). Stored alongside (not instead of) the HTML report so the
@@ -0,0 +1,400 @@
/**
* Step 3「总结报告」结构化流程图画布。
*
* 数据来自总结智能体回复末尾的 ```flow-json 块(见 lib/flow-graph.ts),流式期间
* 随解析增量重绘:每出现一个新节点,它的入边挂蚂蚁线动画(React Flow `animated`),
* 画布自动 fitView 跟随生长;结束后全部转实线。
*
* 交互:画布拖动(pan)/ 滚轮缩放 / 节点可拖拽(手动位置在数据更新间保留)/
* 工具栏 放大·缩小·复位·全屏 / 点节点弹详情(类型、说明、上下游)。
*/
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
applyNodeChanges,
Background,
BackgroundVariant,
Handle,
MarkerType,
Position,
ReactFlow,
ReactFlowProvider,
useReactFlow,
type Edge,
type Node,
type NodeChange,
type NodeProps,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import {
Loader2,
LocateFixed,
Maximize2,
Minimize2,
Workflow,
X,
ZoomIn,
ZoomOut,
} from "lucide-react";
import { cn } from "@/lib/utils";
import {
FLOW_NODE_TYPE_META,
FLOW_NODE_W,
layoutFlowGraph,
type FlowGraphData,
type FlowGraphNode,
} from "@/roundtable-planning/lib/flow-graph";
interface FlowGraphPanelProps {
data: FlowGraphData;
/** 流式绘制中(决定蚂蚁线 + 自动 fitView 跟随)。 */
streaming: boolean;
onClose: () => void;
}
// ── 自定义节点(按 type 着色的卡片)────────────────────────────────────────
function FlowCardNode({ data }: NodeProps) {
const node = (data as { node?: FlowGraphNode }).node;
if (!node) return null;
const meta = FLOW_NODE_TYPE_META[node.type];
return (
<div
style={{
background: meta.bg,
borderColor: meta.border,
color: meta.text,
width: FLOW_NODE_W,
}}
className="cursor-pointer rounded-xl border-2 px-3 py-2 shadow-sm transition hover:shadow-md"
title="点击查看详情"
>
<Handle type="target" position={Position.Top} className="!h-1.5 !w-1.5 !bg-slate-400" />
<div className="mb-0.5 flex items-center gap-1.5">
<span style={{ background: meta.dot }} className="size-2 shrink-0 rounded-full" />
<span className="text-[9px] font-bold uppercase tracking-wider opacity-70">
{meta.label}
</span>
</div>
<div className="break-words text-[11.5px] font-bold leading-snug">{node.label}</div>
<Handle type="source" position={Position.Bottom} className="!h-1.5 !w-1.5 !bg-slate-400" />
</div>
);
}
// 必须模块级常量:每次 render 新建会让 React Flow 整树重挂。
const NODE_TYPES = { flowCard: FlowCardNode };
// ── 画布内层(需在 ReactFlowProvider 内才能 useReactFlow)──────────────────
function FlowCanvasInner({
data,
streaming,
fullscreen,
onToggleFullscreen,
onClose,
}: FlowGraphPanelProps & { fullscreen: boolean; onToggleFullscreen: () => void }) {
const rf = useReactFlow();
const [selectedId, setSelectedId] = useState<string | null>(null);
// 用户手动拖过的节点位置 —— 数据增量更新重新布局时保留,不被布局覆盖。
const manualPosRef = useRef<Map<string, { x: number; y: number }>>(new Map());
const layoutNodes = useMemo(() => layoutFlowGraph(data), [data]);
const derivedNodes: Node[] = useMemo(
() =>
layoutNodes.map((n) => ({
id: n.id,
type: "flowCard",
position: manualPosRef.current.get(n.id) ?? { x: n.x, y: n.y },
data: { node: n },
})),
[layoutNodes],
);
const [nodes, setNodes] = useState<Node[]>(derivedNodes);
useEffect(() => setNodes(derivedNodes), [derivedNodes]);
const onNodesChange = useCallback((changes: NodeChange[]) => {
setNodes((nds) => applyNodeChanges(changes, nds));
for (const c of changes) {
if (c.type === "position" && c.position) manualPosRef.current.set(c.id, c.position);
}
}, []);
// 流式增量绘制:最新节点的连线用蚂蚁线动画(React Flow animated = marching ants)。
const newestNodeId = data.nodes[data.nodes.length - 1]?.id;
const edges: Edge[] = useMemo(
() =>
data.edges.map((e) => {
const active = streaming && (e.to === newestNodeId || e.from === newestNodeId);
const color = active ? "#3b82f6" : "#94a3b8";
return {
id: `e-${e.from}->${e.to}`,
source: e.from,
target: e.to,
label: e.label,
animated: active,
style: { stroke: color, strokeWidth: active ? 2 : 1.5 },
markerEnd: { type: MarkerType.ArrowClosed, color, width: 16, height: 16 },
labelStyle: { fontSize: 10, fill: "#64748b", fontWeight: 700 },
labelBgStyle: { fill: "#ffffff", fillOpacity: 0.85 },
} satisfies Edge;
}),
[data.edges, streaming, newestNodeId],
);
// 流式中节点增加 → 画布平滑 fitView 跟随生长(用户暂停流后不再打扰)。
useEffect(() => {
if (!streaming) return;
const t = setTimeout(() => {
void rf.fitView({ duration: 250, padding: 0.18 });
}, 80);
return () => clearTimeout(t);
}, [data.nodes.length, streaming, rf]);
const selectedNode = selectedId ? data.nodes.find((n) => n.id === selectedId) ?? null : null;
return (
<div className="flex h-full min-h-0 flex-col">
{/* 头部:标题 + 工具栏 */}
<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 min-w-0 items-center gap-2">
<Workflow className="h-4 w-4 shrink-0 text-primary" />
<span className="truncate text-xs font-bold text-slate-800">
{data.title || "方案流程图"}
</span>
<span className="shrink-0 rounded bg-slate-100 px-1.5 py-0.5 font-mono text-[10px] text-slate-500">
{data.nodes.length} 节点 · {data.edges.length} 连线
</span>
{streaming && (
<span className="inline-flex shrink-0 items-center gap-1 rounded-full border border-blue-200 bg-blue-50 px-2 py-0.5 text-[10px] font-bold text-blue-600">
<Loader2 className="h-3 w-3 animate-spin" />
绘制中
</span>
)}
</div>
<div className="flex shrink-0 items-center gap-1">
<ToolButton title="放大" onClick={() => void rf.zoomIn({ duration: 150 })}>
<ZoomIn className="h-3.5 w-3.5" />
</ToolButton>
<ToolButton title="缩小" onClick={() => void rf.zoomOut({ duration: 150 })}>
<ZoomOut className="h-3.5 w-3.5" />
</ToolButton>
<ToolButton
title="恢复原位置"
onClick={() => void rf.fitView({ duration: 250, padding: 0.18 })}
>
<LocateFixed className="h-3.5 w-3.5" />
</ToolButton>
<ToolButton title={fullscreen ? "退出全屏" : "全屏"} onClick={onToggleFullscreen}>
{fullscreen ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
</ToolButton>
<ToolButton title="关闭流程图" onClick={onClose}>
<X className="h-3.5 w-3.5" />
</ToolButton>
</div>
</div>
{/* 画布 */}
<div className="relative min-h-0 flex-1">
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={NODE_TYPES}
onNodesChange={onNodesChange}
onNodeClick={(_, n) => setSelectedId(n.id)}
fitView
fitViewOptions={{ padding: 0.18 }}
minZoom={0.2}
maxZoom={2.5}
panOnDrag
zoomOnScroll
nodesConnectable={false}
deleteKeyCode={null}
proOptions={{ hideAttribution: true }}
>
<Background variant={BackgroundVariant.Dots} gap={18} size={1} color="#cbd5e1" />
</ReactFlow>
{/* 图例 */}
<div className="pointer-events-none absolute bottom-2 left-2 flex flex-wrap items-center gap-x-2.5 gap-y-1 rounded-lg border border-slate-200/70 bg-white/85 px-2.5 py-1.5 backdrop-blur-sm">
{Object.entries(FLOW_NODE_TYPE_META).map(([type, meta]) => (
<span key={type} className="inline-flex items-center gap-1 text-[9.5px] font-semibold text-slate-500">
<span style={{ background: meta.dot }} className="size-1.5 rounded-full" />
{meta.label}
</span>
))}
</div>
</div>
{/* 节点详情弹窗 */}
{selectedNode && (
<NodeDetailModal
node={selectedNode}
data={data}
onClose={() => setSelectedId(null)}
onJump={(id) => setSelectedId(id)}
/>
)}
</div>
);
}
function ToolButton({
title,
onClick,
children,
}: {
title: string;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
title={title}
onClick={onClick}
className="flex h-7 w-7 items-center justify-center rounded-lg border border-slate-200/70 bg-white text-slate-500 transition hover:border-primary/40 hover:text-primary"
>
{children}
</button>
);
}
// ── 节点详情弹窗(类型 / 说明 / 上下游连接)─────────────────────────────────
function NodeDetailModal({
node,
data,
onClose,
onJump,
}: {
node: FlowGraphNode;
data: FlowGraphData;
onClose: () => void;
onJump: (id: string) => void;
}) {
const meta = FLOW_NODE_TYPE_META[node.type];
const labelOf = (id: string) => data.nodes.find((n) => n.id === id)?.label ?? id;
const incoming = data.edges.filter((e) => e.to === node.id);
const outgoing = data.edges.filter((e) => e.from === node.id);
return (
<div
className="absolute inset-0 z-30 flex items-center justify-center bg-black/30 p-4 backdrop-blur-[1px]"
onClick={onClose}
>
<div
className="w-full max-w-md overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<div
className="flex items-center justify-between gap-2 border-b px-4 py-3"
style={{ background: meta.bg, borderColor: meta.border }}
>
<div className="flex min-w-0 items-center gap-2">
<span style={{ background: meta.dot }} className="size-2.5 shrink-0 rounded-full" />
<span
className="shrink-0 rounded px-1.5 py-0.5 text-[10px] font-bold"
style={{ background: "#ffffffaa", color: meta.text }}
>
{meta.label}
</span>
<h3 className="min-w-0 truncate text-sm font-bold" style={{ color: meta.text }}>
{node.label}
</h3>
</div>
<button
type="button"
onClick={onClose}
className="shrink-0 rounded-md p-1 text-slate-500 hover:bg-white/60 hover:text-slate-700"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="max-h-[50vh] space-y-3 overflow-y-auto p-4">
<div>
<div className="mb-1 text-[10px] font-bold uppercase tracking-widest text-slate-400">
详细说明
</div>
<p className="text-xs leading-relaxed text-slate-700">
{node.detail?.trim() || "(该节点未提供详细说明)"}
</p>
</div>
{incoming.length > 0 && (
<ConnectionList title="上游(来自)" entries={incoming.map((e) => ({ id: e.from, label: labelOf(e.from), edgeLabel: e.label }))} onJump={onJump} />
)}
{outgoing.length > 0 && (
<ConnectionList title="下游(去往)" entries={outgoing.map((e) => ({ id: e.to, label: labelOf(e.to), edgeLabel: e.label }))} onJump={onJump} />
)}
</div>
</div>
</div>
);
}
function ConnectionList({
title,
entries,
onJump,
}: {
title: string;
entries: Array<{ id: string; label: string; edgeLabel?: string }>;
onJump: (id: string) => void;
}) {
return (
<div>
<div className="mb-1 text-[10px] font-bold uppercase tracking-widest text-slate-400">
{title}
</div>
<div className="flex flex-wrap gap-1.5">
{entries.map((entry) => (
<button
key={`${title}-${entry.id}`}
type="button"
onClick={() => onJump(entry.id)}
className="inline-flex items-center gap-1 rounded-lg border border-slate-200 bg-slate-50 px-2 py-1 text-[11px] font-semibold text-slate-700 transition hover:border-primary/40 hover:text-primary"
title="查看该节点详情"
>
{entry.label}
{entry.edgeLabel && (
<span className="rounded bg-slate-200/80 px-1 text-[9px] text-slate-500">
{entry.edgeLabel}
</span>
)}
</button>
))}
</div>
</div>
);
}
// ── 外壳:覆盖右栏 / 全屏切换 ───────────────────────────────────────────────
export function FlowGraphPanel({ data, streaming, onClose }: FlowGraphPanelProps) {
const [fullscreen, setFullscreen] = useState(false);
return (
<div
className={cn(
"flex flex-col bg-white",
fullscreen ? "fixed inset-0 z-[80]" : "absolute inset-0 z-20 rounded-2xl",
)}
>
<ReactFlowProvider>
<FlowCanvasInner
data={data}
streaming={streaming}
fullscreen={fullscreen}
onToggleFullscreen={() => setFullscreen((f) => !f)}
onClose={onClose}
/>
</ReactFlowProvider>
</div>
);
}
@@ -22,6 +22,7 @@ import {
Loader2,
PanelLeftClose,
PanelLeftOpen,
Pause,
RefreshCw,
SendHorizontal,
Sparkles,
@@ -119,6 +120,9 @@ export function Step3ReportMode({
start,
hydrateSaved,
redraw,
pause,
interject,
themeMismatch,
abort,
openArtifactFromStep,
openArtifactPreview,
@@ -178,7 +182,9 @@ export function Step3ReportMode({
? "绘制完成"
: phase === "error"
? "绘制失败"
: "待绘制";
: phase === "paused"
? "已暂停"
: "待绘制";
return (
<div className="flex h-full min-h-0 flex-col text-slate-800">
@@ -196,6 +202,17 @@ export function Step3ReportMode({
</span>
</div>
<div className="flex items-center gap-2">
{(phase === "creating" || phase === "streaming") && (
<button
type="button"
onClick={pause}
title="暂停绘制(沙箱保留已生成内容,之后可输入修改要求继续或重新绘制)"
className="bg-white border border-amber-200 text-amber-700 hover:bg-amber-50 text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition"
>
<Pause className="w-3.5 h-3.5" />
暂停
</button>
)}
<button
type="button"
onClick={() => redraw()}
@@ -311,8 +328,9 @@ export function Step3ReportMode({
phase={phase}
dialogues={dialogues}
error={error}
themeMismatch={themeMismatch}
onRetry={redraw}
onRevisionSubmit={redraw}
onRevisionSubmit={interject}
onArtifactStepClick={openArtifactFromStep}
onArtifactPreview={openArtifactPreview}
selectedModel={selectedModel}
@@ -433,6 +451,7 @@ function AgentProgressPanel({
phase,
dialogues,
error,
themeMismatch,
onRetry,
onRevisionSubmit,
onArtifactStepClick,
@@ -445,6 +464,7 @@ function AgentProgressPanel({
phase: ReturnType<typeof useStep3Report>["phase"];
dialogues: ReturnType<typeof useStep3Report>["dialogues"];
error: string | null;
themeMismatch: boolean;
onRetry: () => void;
onRevisionSubmit: (revisionInstruction: string) => void;
onArtifactStepClick: ReturnType<typeof useStep3Report>["openArtifactFromStep"];
@@ -455,6 +475,9 @@ function AgentProgressPanel({
setSelectedModel: (model: string) => void;
}) {
const busy = phase === "creating" || phase === "streaming";
// 只有「正在建线程」时锁输入(线程没建好没法插话);绘制中允许输入并发送——
// 发送会先打断当前轮,再按新要求继续(与第 3 步总结报告的随时插话一致)。
const inputLocked = phase === "creating";
const scrollRef = useRef<HTMLDivElement>(null);
const { atBottom, scrollToBottom } = useAutoScroll(scrollRef, [dialogues]);
const [revisionText, setRevisionText] = useState("");
@@ -462,7 +485,7 @@ function AgentProgressPanel({
const handleRevisionSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const text = revisionText.trim();
if (!text || busy) return;
if (!text || inputLocked) return;
setRevisionText("");
onRevisionSubmit(text);
};
@@ -554,12 +577,35 @@ function AgentProgressPanel({
</div>
)}
{phase === "paused" && (
<div className="flex items-center gap-2 text-xs font-bold text-amber-600">
<Pause className="w-4 h-4" />
已暂停。可在下方输入修改要求继续,或点右上角「重新绘制」。
</div>
)}
{phase === "done" && (
<div className="flex items-center gap-2 text-xs font-bold text-emerald-600">
<CheckCircle className="w-4 h-4" />
已生成可视化方案总览,右侧可查看 / 全屏 / 下载。
</div>
)}
{themeMismatch && (
<div className="flex items-center justify-between gap-2 rounded-xl border border-amber-200 bg-amber-50 p-3 text-xs text-amber-700">
<span className="leading-relaxed">
界面主题已切换,这份报告还是旧主题的配色。
</span>
<button
type="button"
onClick={onRetry}
className="shrink-0 inline-flex items-center gap-1 text-[11px] font-bold text-amber-700 underline hover:text-amber-800"
>
<RefreshCw className="w-3 h-3" />
按当前主题重新绘制
</button>
</div>
)}
</div>
{!atBottom && <ScrollToBottomButton onClick={scrollToBottom} />}
<form
@@ -576,18 +622,28 @@ function AgentProgressPanel({
e.currentTarget.form?.requestSubmit();
}
}}
disabled={busy}
disabled={inputLocked}
rows={1}
placeholder="输入修改要求,例如:把右侧页面改成深色风格,并增加风险矩阵"
placeholder={
busy
? "随时输入修改要求——发送会打断当前绘制并按新要求继续"
: "输入修改要求,例如:增加风险矩阵,执行路线改成时间轴"
}
className="min-h-8 max-h-28 flex-1 resize-none bg-transparent py-1.5 text-xs leading-relaxed text-slate-800 placeholder:text-slate-400 focus:outline-none disabled:cursor-not-allowed disabled:opacity-60"
/>
<button
type="submit"
disabled={busy || revisionText.trim().length === 0}
title={busy ? "正在绘制,请稍后发送" : "发送修改要求"}
disabled={inputLocked || revisionText.trim().length === 0}
title={
inputLocked
? "正在初始化线程,请稍候"
: busy
? "发送修改要求(将打断当前绘制并按新要求继续)"
: "发送修改要求"
}
className="mb-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-primary text-white transition hover:bg-[var(--brand-900)] disabled:cursor-not-allowed disabled:opacity-50"
>
{busy ? (
{inputLocked ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<SendHorizontal className="h-4 w-4" />
@@ -26,6 +26,7 @@ import {
RefreshCw,
SendHorizontal,
Sparkles,
Workflow,
X,
} from "lucide-react";
@@ -39,6 +40,12 @@ import {
} from "@/roundtable-planning/api/multi-agent";
import { ArtifactPreviewModal } from "@/roundtable-planning/components/ArtifactPreviewModal";
import { ConsensusDocxDownloadButton } from "@/roundtable-planning/components/ConsensusDocxDownloadButton";
import { FlowGraphPanel } from "@/roundtable-planning/components/FlowGraphPanel";
import {
extractFlowGraph,
stripFlowFence,
FLOW_FENCE_OPEN,
} from "@/roundtable-planning/lib/flow-graph";
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
import { Step2Message } from "@/roundtable-planning/components/Step2Panel";
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
@@ -137,6 +144,41 @@ export function Step3SummaryMode({
const [modal, setModal] = useState<SourceModal>(null);
const { open: artifactsOpen } = useArtifacts();
// ── 结构化流程图:流式提取回复末尾的 flow-json,增量绘制 ──────────────────
// 取最后一条含 flow-json 的智能体气泡(多轮修改后以最新一份为准)。
const flowGraph = useMemo(() => {
for (let i = dialogues.length - 1; i >= 0; i--) {
const d = dialogues[i];
if (d.avatarType === "human") continue;
const content = d.content ?? "";
if (!content.includes(FLOW_FENCE_OPEN)) continue;
const graph = extractFlowGraph(content);
if (graph) return { graph, streaming: d.streaming === true };
}
return null;
}, [dialogues]);
const [flowView, setFlowView] = useState(false);
const flowAutoOpenedRef = useRef(false);
useEffect(() => {
if (!flowGraph || flowGraph.graph.nodes.length === 0) return;
if (flowAutoOpenedRef.current) return;
flowAutoOpenedRef.current = true;
// 没开沙箱 → 自动打开流程图画布;沙箱开着 → 不抢占,只露出切换按钮。
if (!artifactsOpen) setFlowView(true);
}, [flowGraph, artifactsOpen]);
// 气泡里不展示生 JSON:把 flow-json 块替换成一句引导语。
const displayDialogues = useMemo(
() =>
dialogues.map((d) => {
const content = d.content ?? "";
if (!content.includes(FLOW_FENCE_OPEN)) return d;
return { ...d, content: stripFlowFence(content, d.streaming === true) };
}),
[dialogues],
);
const [leftCollapsed, setLeftCollapsed] = useState(false);
const prevArtifactsOpenRef = useRef(artifactsOpen);
useEffect(() => {
@@ -295,8 +337,8 @@ export function Step3SummaryMode({
)}
</aside>
{/* 右栏:智能体进度 + 沙箱(复用第 2 步) */}
<section className="flex-1 min-w-0 rounded-2xl border border-slate-200/60 bg-white overflow-hidden">
{/* 右栏:智能体进度 + 沙箱(复用第 2 步)。流程图画布作为覆盖层叠在其上。 */}
<section className="relative flex-1 min-w-0 rounded-2xl border border-slate-200/60 bg-white overflow-hidden">
<Step2SandboxLayout
activeThreadId={activeArtifactThreadId ?? summaryThreadId}
isStreaming={isStreaming}
@@ -304,7 +346,7 @@ export function Step3SummaryMode({
>
<AgentProgressPanel
phase={phase}
dialogues={dialogues}
dialogues={displayDialogues}
error={error}
busy={busy}
onRetry={redraw}
@@ -318,6 +360,35 @@ export function Step3SummaryMode({
setSelectedModel={setSelectedModel}
/>
</Step2SandboxLayout>
{/* 流程图入口按钮(有结构化数据且画布未开时显示;沙箱开着时即「切换」按钮) */}
{flowGraph && !flowView && (
<button
type="button"
onClick={() => setFlowView(true)}
title={artifactsOpen ? "切换到流程图画布" : "打开流程图画布"}
className="absolute bottom-4 right-4 z-20 inline-flex items-center gap-1.5 rounded-full border border-primary/30 bg-white px-3.5 py-2 text-[11px] font-bold text-primary shadow-lg transition hover:bg-primary/5"
>
{flowGraph.streaming ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Workflow className="h-3.5 w-3.5" />
)}
流程图
<span className="rounded-full bg-primary/10 px-1.5 font-mono text-[10px]">
{flowGraph.graph.nodes.length}
</span>
</button>
)}
{/* 流程图画布(覆盖右栏;全屏在组件内部切换) */}
{flowGraph && flowView && (
<FlowGraphPanel
data={flowGraph.graph}
streaming={flowGraph.streaming}
onClose={() => setFlowView(false)}
/>
)}
</section>
</div>
@@ -17,6 +17,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useArtifacts } from "@/components/workspace/artifacts";
import { useTheme } from "@/contexts/ThemeContext";
import { apiFetch } from "@/core/api";
import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts";
import {
@@ -48,7 +49,13 @@ export interface ReportSeatDelivery {
content: string;
}
export type ReportPhase = "idle" | "creating" | "streaming" | "done" | "error";
export type ReportPhase =
| "idle"
| "creating"
| "streaming"
| "done"
| "error"
| "paused";
interface UseStep3ReportOptions {
intentReady: ReportIntent | null;
@@ -77,6 +84,15 @@ export interface UseStep3ReportReturn {
hydrateSaved: (snapshot: DraftStep3Snapshot) => void;
/** 重新绘制(先中断在途请求再重跑)。 */
redraw: (revisionInstruction?: string) => void;
/** 暂停:中断在途绘制并把气泡冻结为「已暂停」(沙箱保留已写内容)。 */
pause: () => void;
/**
* 插话 / 修改要求(随时可发):绘制中先打断再发;报告线程存活时免带研讨原料
* 与旧 HTML 直接续同一线程,线程不在(草稿恢复等)才回落全量 prompt。
*/
interject: (message: string) => void;
/** 当前展示的报告主题与界面主题不一致(建议重新绘制以匹配)。 */
themeMismatch: boolean;
/** 中断在途请求(卸载 / 切回普通视图时调用)。 */
abort: () => void;
/** 点击气泡内工具步骤时打开沙箱(与第 2 步 openArtifactFromStep 同义)。 */
@@ -92,15 +108,22 @@ function buildReportPrompt(opts: {
intentReady: ReportIntent | null;
seatDeliveries: ReportSeatDelivery[];
consensusText: string;
/** 当前界面主题——深色时报告改用深蓝设计令牌绘制(与 SOUL 的 4a/4b 主题约定对应)。 */
themeMode: "light" | "dark";
revisionInstruction?: string;
previousHtml?: string;
}): string {
const { intentReady, seatDeliveries, consensusText, revisionInstruction, previousHtml } = opts;
const { intentReady, seatDeliveries, consensusText, themeMode, revisionInstruction, previousHtml } =
opts;
const parts: string[] = [];
parts.push(
"请基于以下圆桌多智能体研讨的成果,撰写一份图文并茂、以文字叙述为主线的 HTML「方案研讨汇报」," +
"用 write_file 工具写入 /mnt/user-data/outputs/方案总览.html。",
);
// 模型不知道当下日期——报告头的「生成时间」由前端注入,避免它编一个占位日期。
parts.push(
`本次报告生成时间:${new Date().toLocaleString("zh-CN", { dateStyle: "long", timeStyle: "short" })}(请原样写进报告头的生成时间处)。`,
);
parts.push(
"目标:让用户能轻松读懂这群智能体讨论出了什么方案、为什么是它。请逐个席位提炼其核心观点与交付写成文字," +
"再综合成最终方案;图表用来点睛(风险矩阵、时间线、资源占比、贡献雷达等),详略得当——" +
@@ -154,10 +177,33 @@ function buildReportPrompt(opts: {
}
}
// 页面主题跟随当前界面的日/夜间模式:浅色保持原浅红效果,深色按「深蓝 UI 规范」令牌绘制。
// 令牌在 prompt 里再列一遍(而不只靠 SOUL 的 4b 节),确保已落盘 SOUL 未同步时深色也生效。
parts.push("# 页面主题");
if (themeMode === "dark") {
parts.push(
"本次绘制使用【深色(深蓝)主题】——用户当前界面为深色模式。整页必须深蓝底、浅色字," +
"严格按你职责设定「深色主题(深蓝)」的设计令牌取色:" +
"背景层级 页面 #060E24 → 容器 #0C1836 → 卡片 #122347(边框 1px #1A2F5A,圆角 4px,阴影 0 2px 8px rgba(22,119,255,.12));" +
"文字 标题 #FFFFFF / 正文 #C5CFE0 / 次级 #6E88B0 / 弱化 #4A6491;" +
"主色 #1677FF、辅色青 #13C2C2、操作强调 #4096FF;报告头标题下用发光分割线 linear-gradient(90deg, transparent, #1677FF, transparent),普通分割线 #2D4473;" +
"图表系列色序依次 #1677FF #13C2C2 #4096FF #36CFC9 #69B1FF #5CDBD3 #91CAFF #87E8DE,坐标轴/网格线 #2D4473、轴文字 #6E88B0;" +
"语义色 成功 #52C41A / 警告 #FAAD14 / 风险·错误 #F5222D / 信息 #1677FF / 处理中 #13C2C2;" +
"KPI 卡 卡底 #122347、大数值 #FFFFFF、标签 #C5CFE0。" +
"禁止使用浅红体系(#c25555 / #fef4f3);若底稿 HTML 是浅色配色,必须把整页配色重绘成上述深色。",
);
} else {
parts.push(
"本次绘制使用【浅色(浅红)主题】:主色 #c25555、浅底 #fef4f3、描边 #ede9e9、文字 #1f2937" +
"(即你职责设定里的默认浅色主题)。若底稿 HTML 是深色配色,必须把整页配色重绘成浅色。",
);
}
parts.push("");
parts.push(
"请严格遵循你的职责设定:自包含离线(纯 inline SVG 图表、禁止任何外链 CDN)、研讨叙事型结构" +
"(结论+KPI → 背景目标 → 各席位观点 → 综合分析 → 最终共识方案 → 执行路线)、" +
"文字为主图表点睛(全篇穿插 3–5 个有信息量的图、每图配一两句解读)、浅红主题(主色 #c25555);" +
"文字为主图表点睛(全篇穿插 3–5 个有信息量的图、每图配一两句解读)、页面配色严格按上文「页面主题」执行;" +
"写完文件后只用一两句话总结,不要把 HTML 源码贴进对话。",
);
// 语言 + 编码硬约束:偶发整篇输出英文 / 中文乱码的两个根因都在这里堵住。
@@ -168,6 +214,23 @@ function buildReportPrompt(opts: {
return parts.join("\n");
}
/**
* 报告线程仍存活时的插话 / 修订 prompt:不重复贴研讨原料与旧 HTML——线程上下文里
* 都有,token 直接省一半以上;让智能体必要时 read_file 自己写过的底稿找回细节。
*/
function buildInterjectPrompt(message: string, themeMode: "light" | "dark"): string {
const themeLine = themeMode === "dark" ? "深色(深蓝)" : "浅色(浅红)";
return [
"用户对当前的方案总览 HTML 提出了新的修改要求:",
message,
"",
"请在你本线程里已写的 /mnt/user-data/outputs/方案总览.html 基础上落实修改" +
"(必要时可先 read_file 该文件找回最新内容;若该文件尚不存在或只写了一半,就按原始要求重新完整绘制一份)," +
`然后用 write_file 把修改后的完整 HTML 整文件重写到同一路径。页面保持【${themeLine}】主题配色。` +
"写完只用一两句话说明本次改了什么,不要把 HTML 源码贴进对话。",
].join("\n");
}
/** 把 SSE 阶段映射成报告智能体的中文状态标签(对齐第 2 步 phaseLabel)。 */
function reportPhaseLabel(phase: StreamPhase, toolName?: string): string {
switch (phase) {
@@ -195,6 +258,9 @@ export function useStep3Report(
const { intentReady, seatDeliveries, consensusText, selectedModel, savedReport, onReportSaved } =
options;
// 页面主题:浅色沿用浅红效果,深色让报告智能体按「深蓝 UI 规范」绘制。
const { theme } = useTheme();
// onReportSaved 身份可能每次 render 变 —— 打到 ref,避免进 run() 的 deps 反复重建。
const onReportSavedRef = useRef(onReportSaved);
useEffect(() => {
@@ -221,6 +287,12 @@ export function useStep3Report(
const stubRef = useRef<Record<string, StubAiMessage[]>>({});
const runningRef = useRef(false);
const aborterRef = useRef<AbortController | null>(null);
// 报告线程是否仍「存活」(本会话里跑过 run/interject 建的真线程)。存活时插话
// 直接续线程(免带原料与旧 HTML);草稿恢复的合成线程不算存活。
const liveThreadRef = useRef<string | null>(null);
const agentNameRef = useRef<string>("roundtable-report");
// 当前展示的报告是用哪个主题绘制的(null = 未知,旧草稿没存该字段)。
const [reportTheme, setReportTheme] = useState<"light" | "dark" | null>(null);
// 当前报告气泡 id —— 流式 delta / 工具步骤 / finalize 都按它定位(绝不用数组下标)。
const bubbleIdRef = useRef<number | null>(null);
// 上一次「自动」打开沙箱的 virtualUrl —— 流式一秒回调几十次,url 没变就跳过;
@@ -305,6 +377,30 @@ export function useStep3Report(
[],
);
const appendUserDialogue = useCallback((text: string) => {
const id = Date.now() + Math.random();
setDialogues((prev) => [
...prev,
{
id,
sender: "指挥官(修改要求)",
avatarType: "human",
content: text,
time: nowTime(),
confidence: "已发送",
streaming: false,
steps: [],
},
]);
}, []);
/** 把所有仍在流式的气泡冻结(暂停 / 插话打断时用),正文保留已收到的部分。 */
const freezeStreaming = useCallback((note: string) => {
setDialogues((prev) =>
prev.map((d) => (d.streaming ? { ...d, streaming: false, confidence: note } : d)),
);
}, []);
const finalizeDialogue = useCallback(
(id: number, finalContent: string, confidence?: string) => {
setDialogues((prev) =>
@@ -620,6 +716,93 @@ export function useStep3Report(
runningRef.current = false;
}, []);
/** 单轮 special 流式(带一次自动重试):失败且非用户中断时同线程重发一次再抛。 */
const streamRound = useCallback(
async (args: {
bubbleId: number;
threadId: string;
agentName: string;
prompt: string;
aborter: AbortController;
}) => {
const { bubbleId, threadId, agentName, prompt, aborter } = args;
for (let attempt = 0; ; attempt += 1) {
try {
return 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, toolArgs) => {
const label = reportPhaseLabel(p, toolName);
setDialogueConfidence(bubbleId, label);
appendStepIfNew(bubbleId, label, `${p}:${toolName ?? ""}`, toolArgs);
handleArtifactToolPhase(p, toolName, toolArgs, threadId);
},
onUpstreamFrame: (frame) => handleUpstreamFrame(frame, threadId),
});
} catch (err) {
// 写大 HTML 偶发超时 / 断流:非用户中断时自动重试一次,重试仍失败才抛给上层。
if (aborter.signal.aborted || attempt >= 1) throw err;
appendStepIfNew(bubbleId, "生成中断,自动重试中", `retry:${attempt + 1}`);
setDialogueConfidence(bubbleId, "自动重试中");
}
}
},
[
selectedModel,
appendToDialogue,
setDialogueConfidence,
appendStepIfNew,
handleArtifactToolPhase,
handleUpstreamFrame,
],
);
/** 一轮结束后的公共收尾:兜底开沙箱 → 对账真实文件回灌 stub → 完成气泡 → 存草稿。 */
const finishRound = useCallback(
async (args: {
bubbleId: number;
threadId: string;
aborter: AbortController;
closing: string;
}) => {
const { bubbleId, threadId, aborter, closing } = args;
// stub 里有 write_file 就说明流式期间已抓到产物;否则收尾从 outputs 兜底打开。
const hasStubFile = (stubRef.current[threadId]?.[0]?.tool_calls.length ?? 0) > 0;
if (!hasStubFile) await openRealArtifactFallback(threadId);
// 以沙箱真实文件回灌 stub(修复 append 续写只剩后半段的问题),并拿到完整 HTML。
const html = hasStubFile
? await reconcileStubFromRealFile(threadId, aborter.signal)
: "";
if (aborter.signal.aborted) return;
finalizeDialogue(bubbleId, closing, "绘制完成");
setPhase("done");
setReportTheme(theme);
// 绘制成功 → 把完整 HTML 等抛给上层存进草稿(step3),连同绘制主题。
if (html.trim()) {
onReportSavedRef.current?.({
html,
generatedAt: new Date().toISOString(),
model: selectedModel || "",
summary: closing || "",
themeMode: theme,
});
}
},
[
theme,
selectedModel,
openRealArtifactFallback,
reconcileStubFromRealFile,
finalizeDialogue,
],
);
const run = useCallback(async (revisionInstruction?: string) => {
if (runningRef.current) return;
runningRef.current = true;
@@ -628,6 +811,7 @@ export function useStep3Report(
setDialogues([]);
bubbleIdRef.current = null;
lastAutoOpenedUrlRef.current = null;
liveThreadRef.current = null;
stubRef.current = {};
setSeatStubMessages({});
@@ -644,6 +828,8 @@ export function useStep3Report(
try {
const { agentName, threadId } = await initReportThread({ signal: aborter.signal });
if (aborter.signal.aborted) return;
liveThreadRef.current = threadId;
agentNameRef.current = agentName;
setReportThreadId(threadId);
setActiveArtifactThreadId(threadId);
setDialogues((prev) =>
@@ -655,50 +841,13 @@ export function useStep3Report(
intentReady,
seatDeliveries,
consensusText,
themeMode: theme,
revisionInstruction,
previousHtml: savedReport?.html,
});
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),
});
const res = await streamRound({ bubbleId, threadId, agentName, prompt, aborter });
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);
// 以沙箱真实文件回灌 stub(修复 append 续写只剩后半段的问题),并拿到完整 HTML。
const html = hasStubFile
? await reconcileStubFromRealFile(threadId, aborter.signal)
: "";
if (aborter.signal.aborted) return;
finalizeDialogue(bubbleId, res.content, "绘制完成");
setPhase("done");
// 绘制成功 → 把完整 HTML 等抛给上层存进草稿(step3)。
if (html.trim()) {
onReportSavedRef.current?.({
html,
generatedAt: new Date().toISOString(),
model: selectedModel || "",
summary: res.content || "",
});
}
await finishRound({ bubbleId, threadId, aborter, closing: res.content });
} catch (err) {
if (aborter.signal.aborted) return;
const msg = err instanceof Error ? err.message : String(err);
@@ -706,25 +855,118 @@ export function useStep3Report(
finalizeDialogue(bubbleId, `(绘制失败) ${msg}`, "绘制失败");
setPhase("error");
} finally {
runningRef.current = false;
// 插话可能已打断本轮并接管 runningRef —— 只有自己还是当前请求时才释放。
if (aborterRef.current === aborter) runningRef.current = false;
}
}, [
intentReady,
seatDeliveries,
consensusText,
theme,
savedReport?.html,
selectedModel,
appendStreamingDialogue,
appendToDialogue,
setDialogueConfidence,
appendStepIfNew,
streamRound,
finishRound,
finalizeDialogue,
handleArtifactToolPhase,
handleUpstreamFrame,
openRealArtifactFallback,
reconcileStubFromRealFile,
]);
/** 暂停:中断在途绘制,流式气泡冻结为「已暂停」,沙箱保留已写内容。 */
const pause = useCallback(() => {
if (!runningRef.current) return;
abort();
freezeStreaming("已暂停");
setPhase("paused");
}, [abort, freezeStreaming]);
/**
* 插话 / 修改要求(随时可发):
* - 绘制中:先打断当前轮(气泡冻结为「已打断」),再发修改要求;
* - 报告线程存活:免带研讨原料与旧 HTML,直接续同一线程(智能体上下文里都有,
* 必要时它会 read_file 自己写过的底稿);
* - 线程不在(草稿恢复 / 首轮线程没建成):新建线程,回落「原料 + 旧 HTML 底稿」全量 prompt。
*/
const interject = useCallback(
async (message: string) => {
const text = message.trim();
if (!text) return;
const wasBusy = runningRef.current;
abort();
if (wasBusy) freezeStreaming("已打断");
runningRef.current = true;
setError(null);
const aborter = new AbortController();
aborterRef.current = aborter;
appendUserDialogue(text);
const bubbleId = appendStreamingDialogue({
sender: "方案总结智能体",
avatarType: "blue",
});
bubbleIdRef.current = bubbleId;
try {
let threadId = liveThreadRef.current;
let prompt: string;
if (threadId) {
prompt = buildInterjectPrompt(text, theme);
} else {
setPhase("creating");
const init = await initReportThread({ signal: aborter.signal });
if (aborter.signal.aborted) return;
threadId = init.threadId;
liveThreadRef.current = threadId;
agentNameRef.current = init.agentName;
setReportThreadId(threadId);
setActiveArtifactThreadId(threadId);
prompt = buildReportPrompt({
intentReady,
seatDeliveries,
consensusText,
themeMode: theme,
revisionInstruction: text,
previousHtml: savedReport?.html,
});
}
setDialogues((prev) =>
prev.map((d) => (d.id === bubbleId ? { ...d, agentThreadId: threadId } : d)),
);
setPhase("streaming");
const res = await streamRound({
bubbleId,
threadId,
agentName: agentNameRef.current,
prompt,
aborter,
});
if (aborter.signal.aborted) return;
await finishRound({ bubbleId, threadId, aborter, closing: 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 {
if (aborterRef.current === aborter) runningRef.current = false;
}
},
[
abort,
freezeStreaming,
appendUserDialogue,
appendStreamingDialogue,
theme,
intentReady,
seatDeliveries,
consensusText,
savedReport?.html,
streamRound,
finishRound,
finalizeDialogue,
],
);
const start = useCallback((revisionInstruction?: string) => {
void run(revisionInstruction);
}, [run]);
@@ -735,6 +977,10 @@ export function useStep3Report(
(snap: DraftStep3Snapshot) => {
abort();
runningRef.current = false;
// 合成线程不是真线程:插话时走「新建线程 + 旧 HTML 底稿」的回落路径。
liveThreadRef.current = null;
// 记录快照绘制主题(旧草稿可能没存 → null,不提示不匹配)。
setReportTheme(snap.themeMode ?? null);
const synthThreadId = "roundtable-report-restored";
const messageId = `roundtable-report-stub-${synthThreadId}`;
const toolCallId = `tc-${synthThreadId}-0`;
@@ -797,6 +1043,10 @@ export function useStep3Report(
void run(revisionInstruction);
}, [abort, run]);
// 报告已展示(done)且记录过绘制主题、而界面主题已切换 → 提示重新绘制。
const themeMismatch =
phase === "done" && reportTheme !== null && reportTheme !== theme;
return {
phase,
error,
@@ -807,6 +1057,9 @@ export function useStep3Report(
start,
hydrateSaved,
redraw,
pause,
interject,
themeMismatch,
abort,
openArtifactFromStep,
openArtifactPreview,
@@ -108,6 +108,14 @@ function buildMaterials(opts: {
parts.push("# 业务链总控编排提示(最高优先级,按它的关注点/结构/口径来组织报告)");
parts.push(cp);
parts.push("");
// 编排提示里常藏着用户对"最终总结"的明确要求——让模型先提取再动笔,
// 否则结构化输出/重点席位这类要求容易被默认章节结构盖掉。
parts.push(
"> 动笔前请先从上面这段编排提示里提取用户对最终总结报告的明确要求(输出结构/格式、重点总结哪些席位、口径风格、详略取舍),并逐条满足:" +
"用户指定了输出结构(章节、表格、编号清单、固定模板等)就完全按用户结构输出、放弃默认章节结构;" +
"点名了重点席位就把它们前置并显著加大篇幅,未点名席位一两句带过;要求了详略取舍就照办,未被要求的章节可直接省略。",
);
parts.push("");
}
if (intentReady?.objective) {
@@ -157,9 +165,11 @@ function buildSummaryPrompt(opts: {
parts.push("");
parts.push(buildMaterials(opts));
parts.push(
"请严格遵循你的职责设定:纯 Markdown 文本(不画 HTML / 图表)、叙事型结构" +
"(一句话结论 → 背景目标 → 各席位观点 → 综合分析 → 最终共识方案 → 执行路线)、全程简体中文;" +
"若上面给了「业务链总控编排提示」,以它的要求优先组织报告。",
"请严格遵循你的职责设定:纯 Markdown 文本(不画 HTML / 图表)、全程简体中文。" +
"报告结构:默认用叙事型结构(一句话结论 → 背景目标 → 各席位观点 → 综合分析 → 最终共识方案 → 执行路线);" +
"但若上面的「业务链总控编排提示」对最终总结提出了要求(输出结构/格式、重点席位、口径、详略)," +
"那些要求优先于默认结构——用户指定的结构可以完全替换默认章节。" +
"若用户要求了结构化输出/流程图,按你职责设定在对话回复末尾追加 ```flow-json 代码块(前端据此实时绘制流程图)。",
);
return parts.join("\n");
}
@@ -0,0 +1,122 @@
/**
* flow-graph.ts 纯函数单测。
* 运行方式(项目无 vitest,对齐 dag-types.test.ts):
* npx esbuild src/roundtable-planning/lib/flow-graph.test.ts --bundle --format=esm \
* --platform=node --outfile=.tmp-tests/flow-graph.test.mjs
* node --test .tmp-tests/flow-graph.test.mjs
*/
import assert from "node:assert/strict";
import test from "node:test";
import {
extractFlowGraph,
layoutFlowGraph,
stripFlowFence,
} from "./flow-graph";
const FULL = [
"报告正文……",
"```flow-json",
'{"title":"方案执行流程","nodes":[',
'{"id":"n1","label":"环境评估","type":"stage","detail":"评估现场环境"},',
'{"id":"n2","label":"编制方案","type":"task","from":["n1"]},',
'{"id":"n3","label":"是否可行","type":"decision","from":[{"id":"n2","label":"评审"}]}',
"]}",
"```",
].join("\n");
test("extractFlowGraph:完整块解析出节点/边/标题", () => {
const g = extractFlowGraph(FULL);
assert.ok(g);
assert.equal(g.title, "方案执行流程");
assert.equal(g.nodes.length, 3);
assert.equal(g.nodes[0].type, "stage");
assert.deepEqual(g.edges, [
{ from: "n1", to: "n2" },
{ from: "n2", to: "n3", label: "评审" },
]);
});
test("extractFlowGraph:流式半截(fence 未闭合 + 尾对象截断)只取完整节点", () => {
const partial = [
"正文",
"```flow-json",
'{"title":"T","nodes":[',
'{"id":"a","label":"A","type":"start"},',
'{"id":"b","label":"B","type":"task","from":["a"]},',
'{"id":"c","label":"C","ty', // 截断
].join("\n");
const g = extractFlowGraph(partial);
assert.ok(g);
assert.equal(g.nodes.length, 2);
assert.deepEqual(g.edges, [{ from: "a", to: "b" }]);
});
test("extractFlowGraph:未知 type 落 task、重复 id 去重、悬空边丢弃", () => {
const text = [
"```flow-json",
'{"nodes":[',
'{"id":"x","label":"X","type":"banana"},',
'{"id":"x","label":"X-dup","type":"task"},',
'{"id":"y","label":"Y","type":"risk","from":["x","ghost"]}',
"]}",
"```",
].join("\n");
const g = extractFlowGraph(text);
assert.ok(g);
assert.equal(g.nodes.length, 2);
assert.equal(g.nodes[0].type, "task");
assert.equal(g.nodes[0].label, "X");
assert.deepEqual(g.edges, [{ from: "x", to: "y" }]);
});
test("extractFlowGraph:无 fence / 无完整节点返回 null", () => {
assert.equal(extractFlowGraph("普通回复,没有结构化数据"), null);
assert.equal(extractFlowGraph('```flow-json\n{"nodes":[{"id":"a"'), null);
});
test("extractFlowGraph:兼容独立 edges 数组(source/target 别名)", () => {
const text = [
"```flow-json",
'{"nodes":[{"id":"a","label":"A"},{"id":"b","label":"B"}],',
'"edges":[{"source":"a","target":"b","label":"走"}]}',
"```",
].join("\n");
const g = extractFlowGraph(text);
assert.ok(g);
assert.deepEqual(g.edges, [{ from: "a", to: "b", label: "走" }]);
});
test("stripFlowFence:闭合块替换为引导语并保留前后文", () => {
const out = stripFlowFence(FULL, false);
assert.ok(out.includes("报告正文"));
assert.ok(!out.includes("```flow-json"));
assert.ok(out.includes("已生成结构化流程数据"));
});
test("stripFlowFence:未闭合(流式中)用「正在输出」措辞", () => {
const out = stripFlowFence('正文\n```flow-json\n{"nodes":[', true);
assert.ok(out.includes("正在输出结构化流程数据"));
assert.ok(!out.includes("```flow-json"));
});
test("layoutFlowGraph:按前驱分层(根 0 层,子层递增),保持原始顺序", () => {
const g = extractFlowGraph(FULL)!;
const positioned = layoutFlowGraph(g);
assert.equal(positioned[0].y, 0);
assert.ok(positioned[1].y > positioned[0].y);
assert.ok(positioned[2].y > positioned[1].y);
assert.deepEqual(positioned.map((n) => n.id), ["n1", "n2", "n3"]);
});
test("layoutFlowGraph:环不死循环", () => {
const text = [
"```flow-json",
'{"nodes":[{"id":"a","label":"A","from":["b"]},{"id":"b","label":"B","from":["a"]}]}',
"```",
].join("\n");
const g = extractFlowGraph(text)!;
const positioned = layoutFlowGraph(g);
assert.equal(positioned.length, 2);
positioned.forEach((n) => assert.ok(Number.isFinite(n.x) && Number.isFinite(n.y)));
});
@@ -0,0 +1,282 @@
/**
* Step 3「总结报告」结构化流程图 —— flow-json 契约的纯函数层。
*
* 契约(与 roundtable-summary SOUL 对齐):用户要求结构化输出/流程图时,总结智能体
* 在对话回复末尾追加一个 ```flow-json 代码块:
*
* ```flow-json
* {"title":"…","nodes":[
* {"id":"n1","label":"…","type":"stage","detail":"…"},
* {"id":"n2","label":"…","type":"task","detail":"…","from":["n1"]},
* {"id":"n3","label":"…","type":"decision","detail":"…","from":[{"id":"n2","label":"是"}]}
* ]}
* ```
*
* 关键设计:**边长在节点身上**(`from` = 前驱列表)——流式输出时每解析出一个完整
* 节点,它的连线随之出现,前端给「最新节点的连线」挂蚂蚁线动画即可实现「画到哪、
* 哪条线在爬」。解析必须**容错半截 JSON**(流式中代码块尚未闭合):用平衡括号扫描
* 把 nodes 数组里**已完整**的对象逐个 JSON.parse,截断的尾对象直接忽略。
*/
export type FlowNodeType =
| "start"
| "end"
| "stage"
| "task"
| "decision"
| "risk"
| "deliverable"
| "role";
export interface FlowGraphNode {
id: string;
label: string;
type: FlowNodeType;
detail?: string;
}
export interface FlowGraphEdge {
from: string;
to: string;
label?: string;
}
export interface FlowGraphData {
title: string;
nodes: FlowGraphNode[];
edges: FlowGraphEdge[];
}
/** 各类型节点的配色(浅底 + 深字 + 强调点),前端按 type 区分颜色。 */
export const FLOW_NODE_TYPE_META: Record<
FlowNodeType,
{ label: string; bg: string; border: string; text: string; dot: string }
> = {
start: { label: "开始", bg: "#f1f5f9", border: "#94a3b8", text: "#334155", dot: "#64748b" },
end: { label: "结束", bg: "#f1f5f9", border: "#94a3b8", text: "#334155", dot: "#64748b" },
stage: { label: "阶段", bg: "#eff6ff", border: "#60a5fa", text: "#1d4ed8", dot: "#3b82f6" },
task: { label: "任务", bg: "#ecfeff", border: "#22d3ee", text: "#0e7490", dot: "#06b6d4" },
decision: { label: "判断", bg: "#fffbeb", border: "#fbbf24", text: "#b45309", dot: "#f59e0b" },
risk: { label: "风险", bg: "#fef2f2", border: "#f87171", text: "#b91c1c", dot: "#ef4444" },
deliverable: { label: "交付物", bg: "#f0fdf4", border: "#4ade80", text: "#15803d", dot: "#22c55e" },
role: { label: "角色", bg: "#faf5ff", border: "#c084fc", text: "#7e22ce", dot: "#a855f7" },
};
const VALID_TYPES = new Set<string>(Object.keys(FLOW_NODE_TYPE_META));
export const FLOW_FENCE_OPEN = "```flow-json";
/**
* 平衡括号扫描:从 `key`(如 `"nodes"`)后面的 `[` 开始,把数组里**已完整闭合**的
* 顶层 `{…}` 逐个 JSON.parse 收集;跳过字符串内的括号与转义;数组未闭合(流式半截)
* 时收集到哪算哪,截断的尾对象忽略。
*/
function collectObjects(text: string, key: string): Record<string, unknown>[] {
const keyIdx = text.indexOf(key);
if (keyIdx < 0) return [];
const arrStart = text.indexOf("[", keyIdx);
if (arrStart < 0) return [];
const out: Record<string, unknown>[] = [];
let depth = 0;
let objStart = -1;
let inStr = false;
let esc = false;
for (let i = arrStart + 1; i < text.length; i++) {
const ch = text[i];
if (inStr) {
if (esc) esc = false;
else if (ch === "\\") esc = true;
else if (ch === '"') inStr = false;
continue;
}
if (ch === '"') {
inStr = true;
continue;
}
if (ch === "{") {
if (depth === 0) objStart = i;
depth++;
continue;
}
if (ch === "}") {
depth--;
if (depth === 0 && objStart >= 0) {
try {
const parsed = JSON.parse(text.slice(objStart, i + 1)) as unknown;
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
out.push(parsed as Record<string, unknown>);
}
} catch {
/* 截断/非法对象:跳过 */
}
objStart = -1;
}
continue;
}
if (ch === "]" && depth === 0) break; // 数组正常闭合
}
return out;
}
function asString(v: unknown): string {
return typeof v === "string" ? v.trim() : "";
}
/** 把 raw 节点对象规范化;id/label 缺失返回 null。未知 type 落到 task。 */
function toNode(raw: Record<string, unknown>): FlowGraphNode | null {
const id = asString(raw.id);
const label = asString(raw.label) || asString(raw.name);
if (!id || !label) return null;
const rawType = asString(raw.type).toLowerCase();
const type = (VALID_TYPES.has(rawType) ? rawType : "task") as FlowNodeType;
const detail = asString(raw.detail) || asString(raw.description) || undefined;
return { id, label, type, detail };
}
/** 节点 `from` 字段(前驱列表,元素是 "n1" 或 {id:"n1",label:"是"})→ 入边列表。 */
function edgesFromNode(raw: Record<string, unknown>, nodeId: string): FlowGraphEdge[] {
const from = raw.from ?? raw.prev;
if (!Array.isArray(from)) return [];
const out: FlowGraphEdge[] = [];
for (const entry of from) {
if (typeof entry === "string" && entry.trim()) {
out.push({ from: entry.trim(), to: nodeId });
} else if (entry && typeof entry === "object") {
const o = entry as Record<string, unknown>;
const fromId = asString(o.id) || asString(o.from);
if (!fromId) continue;
const label = asString(o.label) || undefined;
out.push({ from: fromId, to: nodeId, label });
}
}
return out;
}
/** 独立 `edges` 数组(兼容形态:{from,to,label} / {source,target})→ 边。 */
function toEdge(raw: Record<string, unknown>): FlowGraphEdge | null {
const from = asString(raw.from) || asString(raw.source);
const to = asString(raw.to) || asString(raw.target);
if (!from || !to) return null;
const label = asString(raw.label) || undefined;
return { from, to, label };
}
/**
* 从(可能流式半截的)回复文本里提取最后一个 ```flow-json 块并解析。
* 没有 fence 或一个完整节点都没有 → null。
*/
export function extractFlowGraph(text: string): FlowGraphData | null {
const start = text.lastIndexOf(FLOW_FENCE_OPEN);
if (start < 0) return null;
let body = text.slice(start + FLOW_FENCE_OPEN.length);
const close = body.indexOf("```");
if (close >= 0) body = body.slice(0, close);
const titleMatch = /"title"\s*:\s*"((?:[^"\\]|\\.)*)"/.exec(body);
let title = "";
if (titleMatch) {
try {
title = JSON.parse(`"${titleMatch[1]}"`) as string;
} catch {
title = titleMatch[1];
}
}
const rawNodes = collectObjects(body, '"nodes"');
const nodes: FlowGraphNode[] = [];
const seen = new Set<string>();
const edges: FlowGraphEdge[] = [];
for (const raw of rawNodes) {
const node = toNode(raw);
if (!node || seen.has(node.id)) continue;
seen.add(node.id);
nodes.push(node);
edges.push(...edgesFromNode(raw, node.id));
}
// 兼容独立 edges 数组(contract 主推 from-on-node,但模型可能两种都写)。
for (const raw of collectObjects(body, '"edges"')) {
const edge = toEdge(raw);
if (edge) edges.push(edge);
}
if (nodes.length === 0) return null;
// 只保留两端节点都已出现的边 + 去重(流式中前驱未出现的边等下一轮解析再进来)。
const edgeKeys = new Set<string>();
const validEdges = edges.filter((e) => {
if (!seen.has(e.from) || !seen.has(e.to) || e.from === e.to) return false;
const key = `${e.from}->${e.to}`;
if (edgeKeys.has(key)) return false;
edgeKeys.add(key);
return true;
});
return { title, nodes, edges: validEdges };
}
/**
* 把回复里的 flow-json 块替换成一句人话(气泡里不展示生 JSON)。
* 块未闭合(流式中)→「正在输出」措辞;已闭合 → 引导去看流程图画布。
*/
export function stripFlowFence(text: string, streaming: boolean): string {
const start = text.lastIndexOf(FLOW_FENCE_OPEN);
if (start < 0) return text;
const after = text.slice(start + FLOW_FENCE_OPEN.length);
const close = after.indexOf("```");
const tail = close >= 0 ? after.slice(close + 3) : "";
const placeholder =
close < 0 && streaming
? "\n\n> 📊 正在输出结构化流程数据,流程图画布同步绘制中…"
: "\n\n> 📊 已生成结构化流程数据,可在右侧「流程图」画布查看(点击节点看详情)。";
return `${text.slice(0, start).trimEnd()}${placeholder}${tail}`;
}
// ── 分层布局(自上而下:前驱层 +1,同层横向铺开)────────────────────────────
export interface PositionedFlowNode extends FlowGraphNode {
x: number;
y: number;
}
export const FLOW_NODE_W = 176;
const X_GAP = 224;
const Y_GAP = 132;
/** 最长路径分层(环保护:访问中的节点按 0 层处理,保证终止)。 */
export function layoutFlowGraph(data: FlowGraphData): PositionedFlowNode[] {
const preds = new Map<string, string[]>();
for (const e of data.edges) {
const list = preds.get(e.to);
if (list) list.push(e.from);
else preds.set(e.to, [e.from]);
}
const memo = new Map<string, number>();
const visiting = new Set<string>();
const layerOf = (id: string): number => {
const cached = memo.get(id);
if (cached !== undefined) return cached;
if (visiting.has(id)) return 0; // 环:斩断,按根层处理
visiting.add(id);
const ps = preds.get(id) ?? [];
const layer = ps.length === 0 ? 0 : Math.max(...ps.map(layerOf)) + 1;
visiting.delete(id);
memo.set(id, layer);
return layer;
};
const byLayer = new Map<number, FlowGraphNode[]>();
for (const n of data.nodes) {
const layer = layerOf(n.id);
const list = byLayer.get(layer);
if (list) list.push(n);
else byLayer.set(layer, [n]);
}
const pos = new Map<string, { x: number; y: number }>();
for (const [layer, nodes] of byLayer) {
const rowWidth = (nodes.length - 1) * X_GAP;
nodes.forEach((n, i) => {
pos.set(n.id, { x: i * X_GAP - rowWidth / 2, y: layer * Y_GAP });
});
}
// 保持原始(流式)顺序输出,方便调用方做「最新节点」判断。
return data.nodes.map((n) => ({ ...n, ...pos.get(n.id)! }));
}
@@ -39,6 +39,7 @@ import {
SplitSquareVertical,
Trash2,
UserPlus,
Wand2,
X,
} from "lucide-react";
import {
@@ -79,6 +80,7 @@ import {
createChain,
deleteChain,
listChains,
optimizeChainPrompt,
setChainPublic,
updateChain,
} from "@/roundtable-planning/api/chains";
@@ -317,6 +319,80 @@ export default function BusinessChainEditorPage() {
setStageGroups((prev) => setStageGoal(prev, stageIdx, goal));
}, []);
// ── 提示词 AI 优化(总控提示词 / 各 Stage 阶段目标)────────────────────────
const [optimizingCoordinator, setOptimizingCoordinator] = useState(false);
const [goalOptimizingIdx, setGoalOptimizingIdx] = useState<number | null>(null);
// 把链条当前结构拼成上下文,让模型贴着「这条链有哪些阶段/席位」来优化。
const buildChainContextText = useCallback((): string => {
const lines: string[] = [];
if (title.trim()) lines.push(`链条标题:${title.trim()}`);
if (description.trim()) lines.push(`链条描述:${description.trim()}`);
stageGroups.forEach((g, i) => {
const seatNames = g.seats.map((s) => s.name || s.agent_id).join("、") || "(空)";
const goal = g.goal?.trim() ? `;阶段目标:${g.goal.trim()}` : "";
lines.push(`Stage ${i + 1}(${g.seats.length > 1 ? "并行" : "串行"}):${seatNames}${goal}`);
});
return lines.join("\n");
}, [title, description, stageGroups]);
const handleOptimizeCoordinatorPrompt = useCallback(async () => {
if (optimizingCoordinator) return;
const text = coordinatorPrompt.trim();
if (!text) {
toast.warning("请先写一版总控提示词,再让 AI 优化");
return;
}
setOptimizingCoordinator(true);
try {
const optimized = await optimizeChainPrompt({
promptText: text,
kind: "coordinator",
context: buildChainContextText(),
});
if (optimized.trim()) {
setCoordinatorPrompt(optimized.trim().slice(0, 4000));
toast.success("总控提示词已优化,可继续手动调整");
} else {
toast.error("优化失败:模型未返回内容");
}
} catch (err) {
toast.error(`优化失败:${err instanceof Error ? err.message : String(err)}`);
} finally {
setOptimizingCoordinator(false);
}
}, [optimizingCoordinator, coordinatorPrompt, buildChainContextText]);
const handleOptimizeStageGoal = useCallback(
async (stageIdx: number) => {
if (goalOptimizingIdx !== null) return;
const goal = stageGroups[stageIdx]?.goal?.trim() ?? "";
if (!goal) {
toast.warning("请先写一版本阶段目标,再让 AI 优化");
return;
}
setGoalOptimizingIdx(stageIdx);
try {
const optimized = await optimizeChainPrompt({
promptText: goal,
kind: "stage_goal",
context: `${buildChainContextText()}\n本次优化的是 Stage ${stageIdx + 1} 的阶段目标。`,
});
if (optimized.trim()) {
setStageGroups((prev) => setStageGoal(prev, stageIdx, optimized.trim().slice(0, 500)));
toast.success(`Stage ${stageIdx + 1} 阶段目标已优化`);
} else {
toast.error("优化失败:模型未返回内容");
}
} catch (err) {
toast.error(`优化失败:${err instanceof Error ? err.message : String(err)}`);
} finally {
setGoalOptimizingIdx(null);
}
},
[goalOptimizingIdx, stageGroups, buildChainContextText],
);
const splitSeat = useCallback((agentId: string) => {
setStageGroups((prev) => splitSeatToNewStage(prev, agentId));
}, []);
@@ -759,9 +835,32 @@ export default function BusinessChainEditorPage() {
>
<div className="flex items-center justify-between border-b border-border bg-muted/50 px-4 py-2">
<span className="select-none text-sm font-medium text-muted-foreground">总控提示词</span>
<span className="select-none text-[10px] text-muted-foreground/70">
写给总控:怎么派活、做什么(仅分层研讨生效,可选)
</span>
<div className="flex items-center gap-2">
<span className="select-none text-[10px] text-muted-foreground/70">
写给总控:怎么派活、做什么(仅分层研讨生效,可选)
</span>
<button
type="button"
onClick={handleOptimizeCoordinatorPrompt}
disabled={optimizingCoordinator || !coordinatorPrompt.trim()}
title={
coordinatorPrompt.trim()
? "让 AI 按链条结构重写这段提示词(更明确的派活方式、交付口径与总结要求)"
: "先写一版提示词,AI 才能在它基础上优化"
}
className={cn(
"inline-flex shrink-0 items-center gap-1 border border-primary/30 bg-primary/5 px-2 py-0.5 text-[10px] font-semibold text-primary transition hover:bg-primary/10 disabled:cursor-not-allowed disabled:opacity-50",
R_CTRL,
)}
>
{optimizingCoordinator ? (
<Loader2 className="size-3 animate-spin" />
) : (
<Wand2 className="size-3" />
)}
{optimizingCoordinator ? "优化中…" : "AI 优化"}
</button>
</div>
</div>
<textarea
value={coordinatorPrompt}
@@ -856,6 +955,8 @@ export default function BusinessChainEditorPage() {
onMergeSeat={mergeSeatToStage}
onSplitSeat={splitSeat}
onStageGoalChange={setGoal}
goalOptimizingIdx={goalOptimizingIdx}
onOptimizeGoal={handleOptimizeStageGoal}
onSeatDetail={showDetail}
/>
</main>
@@ -1049,6 +1150,8 @@ function StagesColumn({
onMergeSeat,
onSplitSeat,
onStageGoalChange,
goalOptimizingIdx,
onOptimizeGoal,
onSeatDetail,
}: {
stageGroups: StageGroups;
@@ -1062,6 +1165,9 @@ function StagesColumn({
onMergeSeat: (agentId: string, targetStageIdx: number) => void;
onSplitSeat: (agentId: string) => void;
onStageGoalChange: (stageIdx: number, goal: string) => void;
/** 正在 AI 优化阶段目标的 stage 下标(null = 没有进行中的优化)。 */
goalOptimizingIdx: number | null;
onOptimizeGoal: (stageIdx: number) => void;
onSeatDetail: (agentId: string, name?: string) => void;
}) {
// 跨 Stage 的全局序号(头像编号):每个 Stage 起始累加。
@@ -1156,6 +1262,9 @@ function StagesColumn({
onMergeSeat={onMergeSeat}
onSplitSeat={onSplitSeat}
onGoalChange={(goal) => onStageGoalChange(stageIdx, goal)}
goalOptimizing={goalOptimizingIdx === stageIdx}
anyGoalOptimizing={goalOptimizingIdx !== null}
onOptimizeGoal={() => onOptimizeGoal(stageIdx)}
onSeatDetail={onSeatDetail}
/>
))}
@@ -1181,6 +1290,9 @@ function StageSection({
onMergeSeat,
onSplitSeat,
onGoalChange,
goalOptimizing,
anyGoalOptimizing,
onOptimizeGoal,
onSeatDetail,
}: {
stageIdx: number;
@@ -1195,6 +1307,11 @@ function StageSection({
onMergeSeat: (agentId: string, targetStageIdx: number) => void;
onSplitSeat: (agentId: string) => void;
onGoalChange: (goal: string) => void;
/** 本 Stage 的阶段目标正在 AI 优化中。 */
goalOptimizing: boolean;
/** 任意 Stage 的优化在途(互斥,避免并发请求互相覆盖)。 */
anyGoalOptimizing: boolean;
onOptimizeGoal: () => void;
onSeatDetail: (agentId: string, name?: string) => void;
}) {
const { setNodeRef, isOver } = useDroppable({ id: STAGE_PREFIX + stageIdx });
@@ -1292,11 +1409,36 @@ function StageSection({
R_CTRL,
)}
/>
{!hasParallelStage && (group.goal?.trim()?.length ?? 0) > 0 && (
<p className="mt-1 text-[10px] text-muted-foreground">
提示:填写本阶段目标后,本链条将按阶段顺序由总控逐阶段派活(分层研讨),并把该目标注入总控。
</p>
)}
<div className="mt-1 flex items-start justify-between gap-2">
{!hasParallelStage && (group.goal?.trim()?.length ?? 0) > 0 ? (
<p className="text-[10px] text-muted-foreground">
提示:填写本阶段目标后,本链条将按阶段顺序由总控逐阶段派活(分层研讨),并把该目标注入总控。
</p>
) : (
<span />
)}
<button
type="button"
onClick={onOptimizeGoal}
disabled={anyGoalOptimizing || !(group.goal?.trim())}
title={
group.goal?.trim()
? "让 AI 重写本阶段目标(明确产出物与对上一阶段的承接)"
: "先写一版阶段目标,AI 才能在它基础上优化"
}
className={cn(
"inline-flex shrink-0 items-center gap-1 border border-primary/30 bg-primary/5 px-2 py-0.5 text-[10px] font-semibold text-primary transition hover:bg-primary/10 disabled:cursor-not-allowed disabled:opacity-50",
R_CTRL,
)}
>
{goalOptimizing ? (
<Loader2 className="size-3 animate-spin" />
) : (
<Wand2 className="size-3" />
)}
{goalOptimizing ? "优化中…" : "AI 优化"}
</button>
</div>
</div>
)}
+1 -1
View File
@@ -36,7 +36,7 @@ export default defineConfig({
"base":"./",
server: {
// 与 scripts/env.sh FRONTEND_PORT=5174、圆桌 wujie 嵌入文档一致。
port: 5174,
port: 5173,
// 无界宿主跨域 fetch 子应用静态资源时,Vite 需回显请求的 Origin。
cors: true,
},
@@ -254,6 +254,15 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA
Proxied through nginx: `/api/langgraph/*` → LangGraph, all other `/api/*` → Gateway.
### AI Writing Built-in Functional Agents (四阶段内置智能体)
The AI-writing graph (`packages/harness/deerflow/agents/ai_writing/`) has four stage "brains" — 素材收集专家 / 大纲规划师 / 作家 / 编辑 — that can each run in one of two modes, switched per-stage by `config.yaml → ai_writing.use_builtin_agents.{researcher,outliner,writer,editor}` (default **false**, mtime hot-reloaded, new sessions pick up changes immediately):
- **Legacy (flag off)**: hard-coded prompts in `prompts/*.py`, direct LLM call (unchanged behavior).
- **Builtin-agent (flag on)**: the node loads the corresponding functional agent from `.deer-flow/agents/ai-writing-<stage>/` — `SOUL.md` becomes the system prompt and `config.yaml`'s `model` overrides the stage model (priority: `agent.model > node explicit fallback (keyword_model/rank_model) > state.model_name`). Admins edit SOUL/model from the agent management page. Runner: `nodes/_agent_runner.py` (`run_writing_agent` / `stream_writing_agent` / `parse_marker_json`). Output contracts (marker + ```json``` blocks, intent.py-style): researcher `[KEYWORDS_READY]` / `[MATERIALS_RANKED]`, outliner `[OUTLINE_READY]`, editor (per-dimension) `[REVIEW_READY]`; writer emits raw section Markdown (keeps per-section streaming) with the legacy `[[SECTION_BLOCKED]]` sentinel in strict mode. **Orchestration stays in the nodes** (search fan-out, per-section loop, 3-dimension review loop, all pause points) — the agent only does single structured generations, and a missing/empty SOUL silently falls back to the legacy path (`WritingAgentUnavailable`).
Seeding mirrors the roundtable pattern: verified `config.yaml` + `SOUL.md` ship in `app/gateway/routers/_ai_writing_seed_assets/<id>/`; `_ai_writing_seed.py::ensure_ai_writing_functional_agents()` copies them to `.deer-flow/agents/` when missing (never overwrites local edits), called from the startup lifespan (before `_sync_legacy_agents`, so the agents appear in `/api/agents` on first boot) and self-heals from `GET /api/ai-writing/sessions` + `GET /api/ai-writing/article-types`. Tests: `tests/test_ai_writing_seed.py`, `tests/test_ai_writing_agent_runner.py`.
### Admin Leaderboard Snapshots (`/api/admin/users/leaderboard*`)
The admin 用户排行榜 is served from **precomputed per-day snapshots** in `admin_leaderboard_daily_stats` (keyed by `(stat_date, settings_hash)`), not by aggregating raw rows on every request. A background scheduler inside the Gateway (`_leaderboard_snapshot_scheduler_loop` in `app/gateway/app.py`, singleton file-locked) ticks every `ADMIN_LEADERBOARD_BACKGROUND_INTERVAL_SECONDS` (default 300s): it prewarms recent days, then builds any `queued`/`error`/stale-`running` day. `GET /leaderboard` merges the per-day snapshots for the requested range. **Today** is rebuilt whenever its snapshot is older than `ADMIN_LEADERBOARD_TODAY_TTL_SECONDS` (default 43200s = half a day, so the leaderboard refreshes ~twice daily); a past day, once `ready`, is treated as fresh forever.
@@ -933,6 +933,20 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"Roundtable functional agent seeding failed at startup (non-fatal)"
)
# AI 写作功能型 agent(researcher / outliner / writer / editor)缺失自动补建。
# 同圆桌:必须在 _sync_legacy_agents 之前,补建目录才能在同一次启动被
# upsert 进 agents 表,admin 启动后即可在智能体管理页编辑四个写作 agent。
try:
from app.gateway.routers._ai_writing_seed import (
ensure_ai_writing_functional_agents,
)
ensure_ai_writing_functional_agents()
except Exception:
logger.exception(
"AI-writing functional agent seeding failed at startup (non-fatal)"
)
# 业务链条管理:幂等补建固定业务行(3Q/6BF/7BF/8BF),agent/chain 初始为空。
try:
business_mapping_store = getattr(app.state, "business_mapping_store", None)
@@ -0,0 +1,117 @@
"""AI 写作功能型 agent 的「调用前自检 + 缺失自动补建」工具。
适用场景
--------
``ai-writing-researcher`` / ``ai-writing-outliner`` / ``ai-writing-writer`` /
``ai-writing-editor`` 是 AI 写作四阶段(素材收集 → 大纲 → 写作 → 审核)的
**功能型**内置 agent。当 ``config.yaml`` 的 ``ai_writing.use_builtin_agents.*``
开关打开时,对应的图节点会从 ``.deer-flow/agents/<id>/`` 加载该 agent 的
SOUL.md(人设+结构化输出契约)与 config.yaml(可配 per-阶段模型)。
仓库根 ``.gitignore`` 把整个 ``.deer-flow/`` 目录排除,新机器 ``git clone``
之后这些目录**不会存在**。本模块在两个地方被调用:
1. **启动 lifespan**(``app/gateway/app.py``):服务起来就把缺失目录补齐,
紧接着的 ``_sync_legacy_agents`` 会把它们 upsert 进 DB,新机器一启动
``/api/agents`` 列表里就有这四个 agent,admin 可直接在智能体管理页编辑
SOUL / 模型。
2. **AI 写作路由入口**(``ai_writing.list_sessions`` / ``list_article_types``,
即用户打开写作页必经的两个 GET):防御性兜底——若管理员在启动后手删了
目录,下一次打开写作页也能自愈。
与 ``_roundtable_seed.py`` 完全同构:资源文件
(``_ai_writing_seed_assets/<id>/``)是发布默认值的唯一可信源,seeder 只在
目录缺失时字节级复制,**绝不覆盖**已存在目录(尊重运维/admin 现场手改)。
线上调优 SOUL 后请把改动写回对应资源 .md,否则新机器补建出来的还是旧版。
注意:graph 节点侧(harness 层)对 agent 目录缺失有独立兜底——加载失败时
自动回退到旧的硬编码 prompt 路径,所以 seeder 失败不会打断写作流程。
"""
from __future__ import annotations
import logging
from collections.abc import Iterable
from pathlib import Path
from deerflow.config.paths import get_paths
logger = logging.getLogger(__name__)
RESEARCHER_AGENT_ID = "ai-writing-researcher"
OUTLINER_AGENT_ID = "ai-writing-outliner"
WRITER_AGENT_ID = "ai-writing-writer"
EDITOR_AGENT_ID = "ai-writing-editor"
# 验证版 SOUL.md + config.yaml 的源码资源目录(随 git 发布)。每个子目录名即 agent id。
_ASSETS_DIR = Path(__file__).parent / "_ai_writing_seed_assets"
# 四个功能型内置 agent,任何部署都应存在;顺序无关紧要(逐个独立补建)。
_FUNCTIONAL_AGENT_IDS: tuple[str, ...] = (
RESEARCHER_AGENT_ID,
OUTLINER_AGENT_ID,
WRITER_AGENT_ID,
EDITOR_AGENT_ID,
)
# seeder 会复制资源目录里的这些文件(存在即复制,缺失则跳过)。
_SEED_FILES: tuple[str, ...] = ("config.yaml", "SOUL.md")
def _ensure_one(agent_id: str) -> bool:
"""Copy the verified SOUL.md + config.yaml for one functional agent into
``.deer-flow/agents/<id>/`` if that directory is missing.
Returns True if the files were just created, False if the directory already
existed (we never overwrite — respect any local edits) or the seed assets
are missing.
"""
agent_dir = get_paths().agent_dir(agent_id)
if agent_dir.exists():
return False
src_dir = _ASSETS_DIR / agent_id
if not src_dir.is_dir():
# Assets should always ship with the code; if they don't, surface a loud
# log instead of silently creating an empty agent dir.
logger.error(
"Missing seed assets for ai-writing agent '%s' (expected at %s)",
agent_id,
src_dir,
)
return False
agent_dir.mkdir(parents=True, exist_ok=True)
for fname in _SEED_FILES:
src = src_dir / fname
if src.exists():
# 字节级复制,保留验证版文件原样(换行/编码),与资源文件 hash 对齐。
(agent_dir / fname).write_bytes(src.read_bytes())
return True
def ensure_ai_writing_functional_agents() -> list[str]:
"""Seed the four ai-writing functional agents if their on-disk directories
are missing. Idempotent — fast no-op when all exist.
Returns the list of agent ids that were just created (empty when nothing
needed seeding). Errors are logged but never raised — a partial failure
must not block the request; the graph node falls back to the legacy
hard-coded prompt path when the agent really cannot be loaded.
"""
created: list[str] = []
for agent_id in _FUNCTIONAL_AGENT_IDS:
try:
if _ensure_one(agent_id):
created.append(agent_id)
except Exception:
logger.exception("Failed to seed ai-writing functional agent '%s'", agent_id)
if created:
logger.info("Seeded missing ai-writing functional agents: %s", ", ".join(created))
return created
def required_agent_ids() -> Iterable[str]:
"""Stable ids of agents this seeder owns — exposed for tests/diagnostics."""
return tuple(_FUNCTIONAL_AGENT_IDS)
@@ -0,0 +1,42 @@
# 编辑
你是 AI 写作流水线的「编辑」,一名资深主编,标准极高,要求严格。你在一家权威媒体担任主编十五年,见过各种质量的文章。
系统每次派给你**一个审查维度**(输入开头会写明:事实准确性 / 逻辑结构 / 语言文字),你只从该维度审查文章,给出该维度的评分(0-100 整数)和具体问题列表。你不能改写文章,只做当前指定维度的审查。综合评分与是否通过由系统汇总判定,不归你管。
---
## 0. 硬规则(最高优先级)
1. **只审当前维度**:输入指定哪个维度就只审哪个维度,不要跨维度挑问题。
2. **marker 后立刻闭嘴**:```json``` 代码块的反引号闭合之后,禁止再输出任何一个字符。
3. **JSON 必须严格合法**:半角双引号 `"`,不要尾随逗号、不要注释。`score` 是 0-100 的整数;每个问题的 `severity` 必须是 `"major"`(必须修改)或 `"minor"`(建议修改);该维度没有问题时 `issues` 填空数组 `[]`。
4. **意见要具体可执行**:`location` 指明位置(如"第二段"),`issue` 说清问题,`suggestion` 给出可操作的修改建议。
---
## 1. 结束协议:`[REVIEW_READY]`
审查完成后,**严格**按以下结构输出,前后不得有其它内容(`[REVIEW_READY]` 单独成行):
```
[REVIEW_READY]
```json
{
"score": 82,
"issues": [
{"severity": "major", "location": "第二段", "issue": "引用的增长数据没有素材支撑", "suggestion": "补充素材 m002 中的具体数据,并注明来源"},
{"severity": "minor", "location": "结尾段", "issue": "结论略显仓促", "suggestion": "增加一句对未来行业趋势的判断"}
]
}
```
```
---
## 2. 反例(请认真避开)
❌ 先输出"这篇文章整体质量不错,下面是我的审查意见:"再给 JSON —— 直接输出 marker + JSON。
❌ marker 后追加总结陈词 —— ```json``` 闭合后什么都不要写。
❌ `severity` 写成 `"high"` / `"严重"` —— 只允许 `"major"` / `"minor"`。
❌ 审「语言文字」维度时挑"数据缺乏素材支撑"的事实性问题 —— 只审当前维度。
@@ -0,0 +1,3 @@
description: AI 写作流程的编辑,从事实/逻辑/语言三个维度逐项审查文章质量,给出评分与具体修改意见
id: ai-writing-editor
name: 编辑
@@ -0,0 +1,80 @@
# 大纲规划师
你是 AI 写作流水线的「大纲规划师」。你有十年内容创作经验,擅长将零散素材组织成有逻辑的文章结构。
你只做一件事:**根据素材和写作意图,产出/调整文章的三级大纲**(章节 → 小节 → 子要点)。你不写正文,只写大纲。大纲须紧扣写作意图,并尽量覆盖可用素材中的核心信息。
系统派给你的任务有两种模式(输入开头会写明):
- **新规划**:根据写作意图 + 素材,从零规划 3-5 个章节的大纲
- **修改模式**:输入里带「当前大纲」JSON + 用户修改要求 → 在当前大纲基础上调整
---
## 0. 硬规则(最高优先级)
1. **严禁思考与表态**:禁止输出任何思考、分析、规划过程,禁止 `<think></think>` 标签及其中内容;禁止输出任何对用户说的话或对任务的评价(如"素材不足""无法规划大纲")——无论素材是否充分,都必须直接产出大纲。
2. **marker 后立刻闭嘴**:```json``` 代码块的反引号闭合之后,禁止再输出任何一个字符。
3. **JSON 必须严格合法**:半角双引号 `"`,不要尾随逗号、不要注释;`material_ids` 中的 ID 必须来自给定素材,没有合适素材时填 `[]`。
4. **修改模式只改用户明确提到的部分**:用户没要求改动的章节、小节、子要点必须原样保留(标题、内容、material_ids 全部不变);章节/小节数量完全由用户要求决定,要加就加、要减就减。
---
## 1. 三级层级语义(决定文章结构)
- Level 1 = `section_title`(章节):渲染为 `##` 二级标题,统领一组内容。高度概括的短语,6-15 字。每篇 3-5 个章节(修改模式数量听用户的)。
- Level 2 = `point_title`(小节):渲染为 `###` 三级标题,下面会有一段 2-3 句的概述正文。名词性短语,15 字以内,不带句末标点。每章节 2-4 个小节。
- Level 3 = `sub_points`(子要点):渲染为 `####` 四级标题,**每个子要点都会展开成一段 3-5 句的独立正文**——不是 bullet 列表,所以要规划成「能独立成段展开」的小主题。名词性短语,≤15 字,不带句末标点。每小节 2-4 条;信息确实少到无法拆分时可留 `[]`。
兼容历史大纲:若「当前大纲」里的 `key_points` 是字符串数组(旧版 2 级结构),保留原值视为小节标题,输出时补成 `{"point_title": "<原 string>", "sub_points": []}`。
---
## 2. 结束协议:`[OUTLINE_READY]`
完成规划后,**严格**按以下结构输出,前后不得有其它内容(`[OUTLINE_READY]` 单独成行):
```
[OUTLINE_READY]
```json
{
"title": "新能源汽车出口为何高速增长",
"sections": [
{
"section_title": "引言:出口数据创新高",
"key_points": [
{"point_title": "出口量同比增幅", "sub_points": ["2024 年累计出口规模", "同比增速对比 2023"]},
{"point_title": "全球市场排名变化", "sub_points": ["对欧出口跃升", "对东盟首次反超日韩"]}
],
"material_ids": ["m001"]
},
{
"section_title": "增长背后的三大动因",
"key_points": [
{"point_title": "产业链成本优势", "sub_points": ["电池原料端议价能力", "整车规模效应"]},
{"point_title": "海外政策利好", "sub_points": ["欧盟碳关税倒逼", "中东购车补贴"]}
],
"material_ids": ["m002", "m003"]
},
{
"section_title": "结论与展望",
"key_points": [
{"point_title": "未来增长挑战", "sub_points": ["欧盟反补贴调查", "海外渠道建设成本"]},
{"point_title": "行业趋势判断", "sub_points": []}
],
"material_ids": ["m004"]
}
]
}
```
```
---
## 3. 反例(请认真避开)
❌ 先输出"好的,我来规划大纲:"再给 JSON —— 直接输出 marker + JSON。
❌ marker 后追加"以上大纲覆盖了……如需调整请告诉我" —— ```json``` 闭合后什么都不要写。
❌ 修改模式下重写了用户没提到的章节标题。
❌ `material_ids` 里出现素材清单中不存在的编号。
❌ 把子要点写成完整句子(如"我们应该分析出口数据的变化趋势。")——应为名词性短语。
@@ -0,0 +1,3 @@
description: AI 写作流程的大纲规划师,根据写作意图与素材规划文章三级大纲(章节→小节→子要点),并按用户反馈调整已有大纲
id: ai-writing-outliner
name: 大纲规划师
@@ -0,0 +1,66 @@
# 素材收集专家
你是 AI 写作流水线的「素材收集专家」。你在一家权威媒体机构任职,熟悉各类信息源的可信度评估。
实际的网络/技能检索由系统执行,**你不联网**。你只承担两类结构化子任务,系统每次只派给你其中一类(输入开头会写明「任务:XXX」):
- **任务 A · 提取检索词**:从写作意图中提炼指定数量的搜索关键词
- **任务 B · 素材排序**:从已检索到的素材列表中,选出与写作意图最相关的素材 ID 并按相关度排序
你不写文章、不评价内容好坏、不输出任何与当前任务无关的内容。
---
## 0. 硬规则(最高优先级)
1. **严禁思考与表态**:这是简单的结构化抽取任务。禁止输出任何思考、分析、推理或规划过程,禁止 `<think></think>` 标签及其中内容,禁止"让我想想""首先"之类过渡语。收到输入后立即输出结果。
2. **marker 后立刻闭嘴**:```json``` 代码块的反引号闭合之后,禁止再输出任何一个字符。
3. **JSON 必须严格合法**:引号一律半角双引号 `"`(不要中文引号),不要尾随逗号,不要注释,后端用程序解析,任何变形都会导致降级。
4. **不要照抄占位符**:示例里的 `"..."`、`关键词1` 等都是占位示意,必须输出依据真实输入提炼的内容。
---
## 1. 任务 A · 提取检索词
输入会给出:写作意图、文章类型、目标读者、要求的关键词数量 N。
要求:
- 关键词必须具体、可搜索,避免过于宽泛的词。
- 数量必须正好 N 个,不多不少。
- 关键词依据写作意图提炼,贴合文章类型与目标读者。
输出格式(严格遵守,`[KEYWORDS_READY]` 单独成行):
```
[KEYWORDS_READY]
```json
{"keywords": ["新能源汽车出口", "汽车出口数据", "新能源汽车海外市场"]}
```
```
## 2. 任务 B · 素材排序
输入会给出:写作意图、素材列表(每条含 ID 和摘要)。
要求:
- 选出与写作意图最相关的素材 ID(最多 8 条),按相关度从高到低排序。
- `ranked_ids` 中的 ID **必须来自素材列表**,绝不编造 ID。
- `summary` 一句话概括检索结果(共搜索到 X 条,精选 Y 条)。
输出格式(严格遵守,`[MATERIALS_RANKED]` 单独成行):
```
[MATERIALS_RANKED]
```json
{"ranked_ids": ["m003", "m001", "m005"], "summary": "共搜索到 8 条素材,精选 3 条最相关的"}
```
```
---
## 3. 反例(请认真避开)
❌ 输出思考过程:"让我分析一下这个写作意图……" —— 直接输出 marker + JSON。
❌ marker 后追加说明:"以上关键词覆盖了……" —— ```json``` 闭合后什么都不要写。
❌ 编造素材 ID:`ranked_ids` 里出现列表中不存在的 `m099`。
❌ 照抄占位符:输出 `{"keywords": ["关键词1", "关键词2"]}`。
@@ -0,0 +1,3 @@
description: AI 写作流程的素材收集专家,负责为写作任务生成检索关键词、并对检索回来的素材按相关度排序筛选
id: ai-writing-researcher
name: 素材收集专家
@@ -0,0 +1,49 @@
# 作家
你是 AI 写作流水线的「作家」。你有十年内容创作经验,文字流畅,善于用事实说话。
系统按大纲**逐章**派活给你:每次输入一个章节(含文章整体结构、当前章节的小节/子要点、相关素材、写作模式),你写出**这一个章节**的正文。你不能修改大纲,不能写其他章节,只写当前这一段。
- 优先采用素材中的事实与数据;素材不足时按输入里的「写作模式」处理(宽松=可结合通用知识,严格=只准用素材)。
- 不要脱离主题杜撰具体的数字、人名、机构名与日期。
---
## 0. 硬规则(最高优先级)
1. **严禁思考与表态**:禁止输出任何思考、分析、规划过程,禁止"让我想想""首先""我需要""根据写作要求"之类过渡语,禁止 `<think></think>` 标签及其中内容。
2. **绝不拒绝、绝不元话语**:禁止输出任何对用户说的话或对任务本身的评价,例如"我无法完成此任务""您提供的素材无法支撑写作""建议您提供……素材"——这类话绝不允许出现在文章里。无论素材是否充分,都按「写作模式」的规则直接产出。
3. **直接输出 Markdown 正文**:不要 JSON、不要解释、不要"以下是正文"之类开场白。收到输入后立即开始写正文(含 `###` 小标题)。
4. **严格模式的唯一例外**:输入标明「写作模式:严格依据素材」且素材确实不足以支撑本章节时,**只输出一行**:`[[SECTION_BLOCKED]] 一句话说明缺什么素材`(例:`[[SECTION_BLOCKED]] 缺少关于XX的具体数据与案例`),此外不输出任何内容。只要素材能支撑哪怕部分要点,就正常写作,不要输出该标记。
---
## 1. 结构要求(三级结构,必须严格遵守)
「章节要点」用层级 bullet 列表给出:
- 主要 bullet(顶格)= 小节标题,对应 Markdown `###` 三级标题
- 缩进 bullet(带前导空格)= 该小节下的子要点,对应 Markdown `####` 四级标题
**每个小节、每个子要点都要有自己的正文段,分两层展开**:
1. `### 小节标题` 下先写一段「概述/引入正文」(2-3 句),说明本小节要讨论什么、几个子要点之间的关系。
2. 然后对每个子要点:`#### 子要点标题` 单起一行,再展开 3-5 句具体论述(结合素材中的事实/数据)。
补充规则:
- 若小节**没有子要点**(缩进 bullet 为空):在 `### 小节标题` 下直接连贯成段论述,**不出现 ####**。
- 即使章节只有一个小节,也要为它写出对应的 `###` 标题。
- 标题层级严格对应:文章 `#` / 章节 `##` 由系统注入,**你不要输出这两级**;你只输出 `###` 小节与 `####` 子要点。
- 不要输出 `#####` 或更深。
- 紧扣本章节主题,不要写「文章整体结构」中其他章节负责的内容,避免重复。
- 充分展开每一层,写满输入要求的篇幅。
---
## 2. 反例(请认真避开)
❌ 开头写"好的,下面是「XX」章节的内容:" —— 直接从 `###` 或正文开始。
❌ 输出 `## 章节标题` —— 章节标题由系统注入,你最高只到 `###`。
❌ 严格模式素材不足时硬凑无关内容、或写"素材与意图不匹配" —— 只输出一行 `[[SECTION_BLOCKED]] 原因`。
❌ 宽松模式下输出"我无法基于现有素材完成" —— 宽松模式永远正常写作,可用通用知识补充。
@@ -0,0 +1,3 @@
description: AI 写作流程的作家,根据大纲与素材逐章撰写文章正文(三级结构 Markdown),严格模式下素材不足时声明阻塞而不杜撰
id: ai-writing-writer
name: 作家
@@ -31,7 +31,7 @@
| # | 章节 | 内容(文字) | 配图建议 |
|---|------|------|----------|
| ① | 报告头 | 标题 + 生成时间占位 | — |
| ② | 一句话结论 + 关键指标卡 | 用一句话点明最终方案/结论 | 4–6 个 KPI 数字卡(共识度、参与席位数、识别风险数、里程碑数、预估周期、预算规模等) |
| ② | 一句话结论 + 关键指标卡 | 用一句话点明最终方案/结论 | 4–6 个 KPI 数字卡(共识度、参与席位数、识别风险数、里程碑数、预估周期、预算规模等);**凡是估算/定性推得的指标,必须在数值旁标注「估算」小角标**,不要让读者把估算当真实数据 |
| ③ | 任务背景与目标 | 2–4 句交代要解决什么、目标与关键约束 | 可选:目标/约束要点列表 |
| ④ | 各席位观点与交付 | **每个席位一段提炼文字**(这位专家关注什么、给出了什么关键结论/建议),用小标题+正文,让读者读懂每方的贡献 | 关键席位视情况配小图(如情报席位的对比、环境席位的承载评估) |
| ⑤ | 综合分析 | 把跨席位的信息串起来分析(风险全景、资源权衡、阶段安排等) | **此处是图表主战场**:风险概率×影响矩阵、资源/预算饼图、方案对比柱状、各席位贡献雷达等 |
@@ -48,8 +48,26 @@
常用图表素材库(按章节按需取用,能画哪些画哪些):
共识达成度 → 环形进度仪表;各席位贡献画像 → 雷达图(完整性/可行性/风险覆盖/创新性/落地性);风险评估 → 概率×影响气泡矩阵;执行路线 → 甘特/时间线;资源预算 → 饼图/环形图;方案权衡 → SWOT 四象限或对比柱状;决策链路 → 流程图(链条模式尤其适用)。
### 4. 视觉风格
- 主色对齐圆桌的**浅红 / 绛红**主题:主色 `#c25555`,浅底 `#fef4f3`,描边 `#ede9e9`,文字 `#1f2937`,辅助灰 `#717b88`;图表配色在主色基础上取同色系/邻近色,保持克制专业;
### 4. 视觉风格(主题随用户消息指定)
用户消息会指明本次绘制的「页面主题」:**浅色(浅红)** 或 **深色(深蓝)**。未指明时默认浅色。两套主题只改配色,版式要求两者通用(见本节末尾)。
#### 4a. 浅色主题(默认 · 浅红)
- 主色对齐圆桌的**浅红 / 绛红**主题:主色 `#c25555`,浅底 `#fef4f3`,描边 `#ede9e9`,文字 `#1f2937`,辅助灰 `#717b88`;图表配色在主色基础上取同色系/邻近色,保持克制专业。
#### 4b. 深色主题(深蓝 · 严格按以下设计令牌取色)
整页深蓝底、浅色字,**禁止**再使用浅红体系(`#c25555` / `#fef4f3`):
- **背景层级**:页面最外层 `#060E24` → 分栏/大区容器 `#0C1836` → 卡片 `#122347`(边框 `1px solid #1A2F5A`,圆角 4px,阴影 `0 2px 8px rgba(22,119,255,.12)`);
- **文字**:大标题 `#FFFFFF`,正文 `#C5CFE0`,次级说明 `#6E88B0`,弱化/禁用 `#4A6491`;
- **主色** `#1677FF`(hover `#0958D9`)、辅色青 `#13C2C2`、链接/操作强调 `#4096FF`;
- **报告头**标题下加发光分割线:`height:1px; background:linear-gradient(90deg, transparent, #1677FF, transparent)`;普通分割线弱 `#2D4473` / 默认 `#4A6491`;
- **KPI 数字卡**:卡底 `#122347`、大数值 `#FFFFFF`(个别可用 `#4096FF` 强调)、标签 `#C5CFE0`、单位说明 `#6E88B0`;
- **图表系列色序**(多系列按顺序取用):`#1677FF` `#13C2C2` `#4096FF` `#36CFC9` `#69B1FF` `#5CDBD3` `#91CAFF` `#87E8DE`;坐标轴/网格线 `#2D4473`,轴刻度文字 `#6E88B0`;
- **语义色**:成功 `#52C41A`、警告/进行中 `#FAAD14`、错误/高风险 `#F5222D`、信息 `#1677FF`、处理中 `#13C2C2`;涨 ▲`#13C2C2`、跌 ▼`#F5222D`、持平 `#6E88B0`;浅底徽标/告警条用前景色 16% 透明度底(如 `rgba(82,196,26,.16)`);
- **圆角与间距**:按钮/Tag 圆角 2px、卡片 4px;间距取 4/8/12/16/24/32px 阶梯。
> **主题与底稿不一致时**:修改/重绘时若底稿 HTML 的配色与本次指定主题不同,必须把整页配色重绘成本次指定的主题,不允许保留旧主题色。
通用版式要求(两主题共用):
- 整体是**研讨汇报**版式:报告头 + 分章节卡片化分区、清晰的章节标题层级、正文行距舒适、留白充足、响应式(`max-width` 容器 + 网格自适应),打印友好;
- 文字排版讲究可读性:段落不要过长,关键结论可用强调色/加粗,列表化呈现要点。
@@ -60,9 +78,16 @@
- **严禁**写 Python / shell / 任何脚本去"生成"HTML,也**严禁**用 bash 跑脚本产出文件——你必须自己把 HTML 文本直接写进 `write_file`;
- **严禁**把 HTML 拆成多个文件或分多次拼接,就一个 `方案总览.html`;
- 控制好体量:聚焦叙述 + 3–5 个图表,别堆砌到写不完。
3. 写完后,用**一两句话**向用户说明「已生成研讨汇报,含 X 个章节、Y 个图表」即可——**不要**把 HTML 源码贴进对话,也**不要**再去读取/校验文件。
3. 写完后,用**一两句话**向用户说明「已生成研讨汇报,含 X 个章节、Y 个图表」即可——**不要**把 HTML 源码贴进对话;**首轮写完后不要**再去读取/校验文件。
> **修改/返工规则**:如果写完后校验发现问题需要修正,请**用 `write_file` 整文件重写**到同一路径,**不要用 `str_replace` 做局部替换**。整文件重写能让右侧预览实时刷新;局部替换不会反映到预览里。
> **修改/返工规则**:同一轮内写完后发现问题要修正,请**用 `write_file` 整文件重写**到同一路径,**不要用 `str_replace` 做局部替换**。整文件重写能让右侧预览实时刷新;局部替换不会反映到预览里。
## 多轮修改(用户会在输入框里继续提修改要求)
报告生成后,用户可能在同一对话里继续发来修改要求(也可能在你绘制中途打断后发来)。处理规则:
1. 把新消息当作对 `方案总览.html` 的**修改要求**:在已有报告基础上做增量修改——只改用户提到的地方,其余内容保持不变;
2. **允许(且建议)先 `read_file /mnt/user-data/outputs/方案总览.html`** 找回当前文件全文,再在其基础上修改(多轮之后上下文可能被压缩,以文件为准);若文件不存在或只写了半截,就按原始要求重新完整绘制一份;
3. 修改后仍**用 `write_file` 一次性整文件重写**到同一路径,保持自包含离线、保持用户消息指定的主题配色;
4. 写完只用一两句话说明本次改了什么;若用户只是提问而非要求修改,就只用文字回答、**不要**改写文件。
## 禁止事项
- ❌ 引用任何外部 CDN / 外链资源(离线必然失败)
@@ -71,7 +96,7 @@
- ❌ 通篇只有图表、没有像样的文字叙述(这是给用户读的汇报,不是纯看板)
- ❌ 通篇只有文字、完全不配图(缺数据也要合理估算出图并标注)
- ❌ 写 Python / shell / 任何脚本去生成 HTML,或用 bash 跑脚本产出文件(必须自己把 HTML 文本直接写进 `write_file`)
- ❌ 把 HTML 拆成多文件或分多次拼接、或写完后再去读取/校验文件
- ❌ 把 HTML 拆成多文件或分多次拼接;首轮写完后再去读取/校验文件(**多轮修改时允许先 `read_file` 底稿**)
- ❌ 用 `str_replace` 局部修改 HTML(返工请整文件 `write_file` 重写)
- ❌ 在报告头或任何位置添加「AI 自动生成」「AI generated」之类的标签、徽章或水印
- ❌ 编造与原料明显矛盾的数据
@@ -14,7 +14,11 @@
1. **任务意图摘要**:目标、约束、关键假设(来自 Step 1);
2. **各席位最终交付**:每个席位的名称 + 它产出的完整 markdown 正文;
3. **总控最终共识结论**;
4. **(可能有)业务链总控编排提示**:用户为这条业务链写给总控(协调)智能体的一段指引。它代表用户对这条业务链产出的期望——**这是最高优先级的口径要求**,请把它作为本次总结的**重点方向、结构与风格的依据**,严格按它强调的关注点来组织你的报告。
4. **(可能有)业务链总控编排提示**:用户为这条业务链写给总控(协调)智能体的一段指引。它代表用户对这条业务链产出的期望——**这是最高优先级的口径要求**。其中往往还藏着用户对**最终总结报告本身**的明确要求,动笔前请先把它们**逐条提取**出来,常见四类:
- **输出结构 / 格式**:如「最后用表格汇总」「按 现状→问题→对策 三段输出」「给我编号清单 / 固定模板」——结构一经用户指定,**完全按用户结构输出、放弃默认章节结构**,不要把用户结构硬塞回默认章节里;
- **重点席位**:如「重点总结方案设计和风险审查的交付」——被点名的席位**前置并显著加大篇幅**(逐条展开其交付要点),未点名的席位压缩成一两句带过即可;
- **口径 / 风格**:如「面向高管汇报」「控制在一页内」「不要技术细节」——全篇遵守;
- **详略 / 取舍**:如「只要结论和执行计划」——未被要求的章节直接省略,不要因为默认结构里有就硬写。
请通读全部原料:提炼每个席位的核心观点 / 交付,串成一份完整的方案总结。
@@ -34,7 +38,7 @@
- 用户既能在对话里直接读到报告,也能在右侧预览 / 下载这个 `.md` 文件——两边内容应当一致。
## 报告结构(叙事型,文字为主线)
按下面的章节顺序组织,缺料的章节可省略或合并。**若有「业务链总控编排提示」,以它的要求优先**:
**下面是默认结构——仅当「业务链总控编排提示」没有指定输出结构时使用。** 用户指定了结构(章节、表格、清单、固定模板等)就完全按用户的来;两者只部分重叠时,以用户要求为骨架、默认章节做补充。按下面的章节顺序组织,缺料的章节可省略或合并:
| # | 章节 | 内容 |
|---|------|------|
@@ -47,6 +51,25 @@
善用 Markdown 表格、有序 / 无序列表,把要点、阶段、里程碑、风险项、优劣势等结构化呈现,提升可读性。
## 结构化流程数据(按需输出 flow-json)
当用户要求「结构化输出 / 结构化数据 / 流程图 / 可视化流程」时——要求可能写在业务链总控编排提示里,也可能是对话中的新消息——除了正常报告(文件 + 对话正文)外,再在**对话回复的最末尾**追加一个 ```flow-json 代码块,前端会据此**实时绘制可交互流程图**(边流式输出边画):
```flow-json
{"title":"方案执行流程","nodes":[
{"id":"n1","label":"环境评估","type":"stage","detail":"点击节点弹窗里显示的一两句详细说明"},
{"id":"n2","label":"编制方案","type":"task","detail":"…","from":["n1"]},
{"id":"n3","label":"方案是否可行","type":"decision","detail":"…","from":["n2"]},
{"id":"n4","label":"实施落地","type":"task","detail":"…","from":[{"id":"n3","label":"可行"}]}
]}
```
硬性规则:
- 每个节点字段:`id`(短唯一串 n1/n2…)、`label`(≤12 字)、`type`、`detail`(一两句说明,弹窗展示)、`from`(**前驱节点列表**,可空;分支边可写成 `{"id":"n3","label":"可行"}` 给边加标签);
- `type` 只能取:`start`(开始)/ `end`(结束)/ `stage`(阶段)/ `task`(任务动作)/ `decision`(判断分支)/ `risk`(风险点)/ `deliverable`(交付物)/ `role`(角色席位)——前端按类型着不同颜色;
- **`nodes` 必须按流程先后顺序排列**,且每个节点的 `from` 只引用**前面已出现**的节点 id——前端解析到一个节点就画一个节点和它的连线;
- 节点 6–20 个为宜;flow-json 块**只放对话回复末尾**,不要写进 .md 报告文件;
- 用户没有要求结构化 / 流程图时,**不要**输出该块。
## 多轮交互(用户会在输入框里继续和你对话)
用户读完报告后,可能在输入框里继续给你发消息。你要**自己判断**意图,分两种情况处理:
@@ -71,3 +94,4 @@
- ❌ 在报告任何位置添加「AI 自动生成」「AI generated」之类的标签、徽章或水印
- ❌ 编造与原料明显矛盾的内容
- ❌ 忽视「业务链总控编排提示」的口径要求(它优先级最高)
- ❌ 编排提示已指定输出结构 / 重点席位,却仍按默认章节平均用力(结构要完全跟用户走,重点席位要明显加权)
@@ -110,6 +110,11 @@ class SessionResponse(BaseModel):
async def list_sessions(request: Request):
"""列出当前用户的 AI 写作历史会话。"""
from app.gateway.deps import get_ai_writing_session_store, get_current_user
from app.gateway.routers._ai_writing_seed import ensure_ai_writing_functional_agents
# 调用前自检:打开写作页必经此接口。若管理员在启动后手删了内置写作 agent
# 目录,这里自愈补建,保证 use_builtin_agents 开启时图节点能加载到 SOUL。
ensure_ai_writing_functional_agents()
repo = get_ai_writing_session_store(request)
if repo is None:
@@ -190,6 +195,10 @@ async def save_transcript(session_id: str, body: SaveTranscriptRequest, request:
async def list_article_types(request: Request):
"""列出所有文章类型,按 sort_order 升序排列。"""
from app.gateway.deps import get_article_type_store
from app.gateway.routers._ai_writing_seed import ensure_ai_writing_functional_agents
# 防御性兜底,与 list_sessions 同理(写作表单加载文章类型时也会经过这里)。
ensure_ai_writing_functional_agents()
store = get_article_type_store(request)
if store is None:
@@ -19,9 +19,13 @@ import logging
from datetime import datetime
from uuid import uuid4
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, Depends, HTTPException, Request
from langchain_core.messages import HumanMessage, SystemMessage
from pydantic import BaseModel, Field
from app.gateway.deps import get_config
from deerflow.config.app_config import AppConfig
from deerflow.models import create_chat_model
from deerflow.runtime.user_context import get_effective_user_id
logger = logging.getLogger(__name__)
@@ -271,3 +275,95 @@ async def delete_chain(request: Request, chain_id: str) -> None:
deleted = await store.delete_chain(chain_id, user_id)
if not deleted:
raise HTTPException(status_code=404, detail="Chain not found")
# ── 提示词优化(总控提示词 / 阶段目标)──────────────────────────────────────
# 链条编辑器的「AI 优化」按钮:把用户当前写的提示词 + 链条结构发来,单次直连
# LLM 重写后返回全文(同 suggestions 路由的直调模式,不走 agent / loopback)。
class OptimizePromptRequest(BaseModel):
prompt_text: str = Field(min_length=1, max_length=8000, description="用户当前写的提示词原文")
kind: str = Field(default="coordinator", description="coordinator=总控提示词 | stage_goal=阶段目标")
context: str = Field(default="", max_length=8000, description="链条结构等上下文(标题 / 各 Stage 席位 / 目标)")
model_name: str | None = Field(default=None, description="可选模型覆盖,缺省用 config models[0]")
class OptimizePromptResponse(BaseModel):
optimized: str
_OPTIMIZE_SYSTEM_COORDINATOR = (
"你是「多智能体业务链条」的提示词优化专家。用户在业务链条编辑器里给总控(协调)智能体写了一段编排提示词——"
"总控会按它逐阶段给各席位智能体派活;第 3 步的方案总结报告也会把它当作最高优先级口径(决定总结的结构与重点)。\n\n"
"请在保留用户原意的前提下重写这段提示词,让它更明确、更可执行:\n"
"1. 明确派活方式:各阶段先后顺序、每阶段的产出目标、对上一阶段结论的承接要求;\n"
"2. 明确交付口径:各席位交付聚焦本职、避免重复劳动,要求显式给出结论而非泛泛而谈;\n"
"3. 若原文流露了对最终总结的期望(输出结构、重点席位、风格详略),把它整理成明确条目;若没有,不要凭空替用户添加;\n"
"4. 语言精炼、命令式、分点表述。\n\n"
"硬性要求:只输出优化后的提示词全文(纯文本,可用换行与编号),不要任何前后缀说明、不要 markdown 代码块;"
"全程简体中文;总长不超过 800 字。"
)
_OPTIMIZE_SYSTEM_STAGE_GOAL = (
"你是「多智能体业务链条」的提示词优化专家。用户在链条编辑器里为某一个 Stage(阶段)写了「本阶段目标」——"
"总控按它给本阶段的席位派活,并要求本阶段交付承接上一阶段产出。\n\n"
"请在保留用户原意的前提下重写这段阶段目标,让它更明确、更可执行:点明本阶段要产出什么(交付物 / 结论形态)、"
"怎么承接上一阶段产出、本阶段各席位分别聚焦什么(如上下文给了席位名单)。语言精炼,两到四句话即可。\n\n"
"硬性要求:只输出优化后的阶段目标全文(纯文本),不要任何前后缀说明、不要 markdown 代码块;"
"全程简体中文;总长不超过 200 字。"
)
def _extract_llm_text(content: object) -> str:
if isinstance(content, str):
return content
if isinstance(content, list):
parts: list[str] = []
for block in content:
if isinstance(block, str):
parts.append(block)
elif isinstance(block, dict) and block.get("type") in {"text", "output_text"}:
text = block.get("text")
if isinstance(text, str):
parts.append(text)
return "\n".join(parts) if parts else ""
if content is None:
return ""
return str(content)
def _strip_code_fence(text: str) -> str:
stripped = text.strip()
if not stripped.startswith("```"):
return stripped
lines = stripped.splitlines()
if len(lines) >= 3 and lines[0].startswith("```") and lines[-1].startswith("```"):
return "\n".join(lines[1:-1]).strip()
return stripped
@router.post("/optimize-prompt", response_model=OptimizePromptResponse)
async def optimize_chain_prompt(
body: OptimizePromptRequest,
config: AppConfig = Depends(get_config),
) -> OptimizePromptResponse:
system = _OPTIMIZE_SYSTEM_STAGE_GOAL if body.kind == "stage_goal" else _OPTIMIZE_SYSTEM_COORDINATOR
user_parts: list[str] = []
if body.context.strip():
user_parts.append(f"【业务链条结构】\n{body.context.strip()}")
user_parts.append(f"【待优化的提示词原文】\n{body.prompt_text.strip()}")
user_parts.append("请输出优化后的提示词全文。")
try:
model = create_chat_model(name=body.model_name, thinking_enabled=False, app_config=config)
response = await model.ainvoke(
[SystemMessage(content=system), HumanMessage(content="\n\n".join(user_parts))],
config={"run_name": "chain_prompt_optimize"},
)
text = _strip_code_fence(_extract_llm_text(response.content))
except Exception as exc: # 模型 / 网络故障 → 502,前端 toast 原样展示
logger.exception("chain prompt optimize failed: %s", exc)
raise HTTPException(status_code=502, detail=f"大模型调用失败:{exc}") from exc
if not text:
raise HTTPException(status_code=502, detail="大模型未返回内容")
return OptimizePromptResponse(optimized=text)
@@ -393,6 +393,15 @@ ai_writing:
keyword_model: deepseek-chat # 检索词生成专用模型,留空回退写作主模型
rank_model: deepseek-chat # 素材排序专用模型,留空回退写作主模型
max_materials: 20 # 合并去重后的素材总量上限,范围 [1, 100],默认 20
# 四阶段「内置功能型智能体」开关(按阶段灰度)。true = 该阶段节点改为加载
# .deer-flow/agents/ai-writing-<stage>/ 的 SOUL.md + config(admin 可在智能体
# 管理页编辑人设/模型),false / 缺失 = 走旧的硬编码 prompt 路径。
# 改完保存即生效(config 按 mtime 热加载),新写作会话立即按新开关运行。
use_builtin_agents:
researcher: false # 素材收集专家(检索词生成 / 素材排序两个子任务)
outliner: false # 大纲规划师(大纲生成 / 按反馈调整)
writer: false # 作家(逐章正文写作,逐章流式与严格模式求助逻辑不变)
editor: false # 编辑(事实/逻辑/语言三维度审查)
log_system:
enabled: true
@@ -0,0 +1,269 @@
"""AI 写作「内置功能型智能体」运行助手。
四个写作阶段(素材收集/大纲/写作/审核)的图节点在 ``config.yaml →
ai_writing.use_builtin_agents.<stage>`` 打开时,不再使用 ``prompts/*.py`` 的
硬编码提示词直调 LLM,而是改为:
1. 从 ``.deer-flow/agents/<agent-id>/`` 加载该内置 agent 的 **SOUL.md**
(人设 + 工作流 + 结构化输出契约)作为 system prompt;
2. 模型按 ``agent config.yaml 的 model > 节点显式回退模型 > state.model_name``
的优先级解析(admin 可在智能体管理页给某阶段单独配模型);
3. 流式调用该模型,chunk 通过调用方传入的回调透传到 LangGraph ``custom``
通道(前端时间线打字机不回归);
4. 按 SOUL 规定的 marker(如 ``[OUTLINE_READY]``)+ ```json``` 块解析结构化
输出,解析复用 ``_utils.parse_json`` 多级容错。
设计取舍(对应 ai-writing-内置智能体改造.md §3.2 / §7):
- **编排控制权留在节点**:writer 的逐章循环、researcher 的检索编排、editor 的
三维度循环都不交给 agent——agent 只承担「单次结构化产出」,这样干预点/逐章
流式/严格模式求助逻辑全部原样保留。
- **不经过 lead agent 运行时**:本模块位于 harness 层,不能 import ``app.*``
(intent.py 的 loopback 机制在 app 层),也无需为单轮结构化调用付出
thread/checkpointer/中间件链的成本。SOUL + per-agent 模型已覆盖本场景的
可配置性诉求;researcher 的「挂载技能」(Phase 3)再考虑接入完整运行时。
- **agent 目录缺失时抛 ``WritingAgentUnavailable``**:由节点捕获后回退旧的
硬编码 prompt 路径,种子器(app 层 ``_ai_writing_seed.py``)失败不阻断写作。
"""
from __future__ import annotations
import logging
import re
from collections.abc import Callable
from langchain_core.messages import HumanMessage, SystemMessage
from langchain_core.runnables import RunnableConfig
from ._utils import ThinkTagParser, _content_to_text, parse_json, reasoning_delta
logger = logging.getLogger(__name__)
# ── 内置写作 agent 的稳定 id(与 app 层种子器 _ai_writing_seed.py 对齐)──────────
RESEARCHER_AGENT_ID = "ai-writing-researcher"
OUTLINER_AGENT_ID = "ai-writing-outliner"
WRITER_AGENT_ID = "ai-writing-writer"
EDITOR_AGENT_ID = "ai-writing-editor"
# ── SOUL 规定的结构化输出 marker(契约见各 agent 的 SOUL.md)─────────────────────
KEYWORDS_READY_MARKER = "[KEYWORDS_READY]"
MATERIALS_RANKED_MARKER = "[MATERIALS_RANKED]"
OUTLINE_READY_MARKER = "[OUTLINE_READY]"
REVIEW_READY_MARKER = "[REVIEW_READY]"
# flag 名(config.yaml ai_writing.use_builtin_agents.<stage>)→ agent id
STAGE_AGENT_IDS = {
"researcher": RESEARCHER_AGENT_ID,
"outliner": OUTLINER_AGENT_ID,
"writer": WRITER_AGENT_ID,
"editor": EDITOR_AGENT_ID,
}
class WritingAgentUnavailable(RuntimeError):
"""内置写作 agent 的 SOUL.md 缺失/为空,无法按 agent 模式运行。
节点捕获后应回退到旧的硬编码 prompt 路径(等价于 flag 关闭),不让写作
流程因 agent 目录被删而中断。
"""
def use_builtin_agent(stage: str) -> bool:
"""读 ``config.yaml → ai_writing.use_builtin_agents.<stage>`` 开关。
缺失/非法一律视为 False(默认走旧节点逻辑)。config 带 mtime 热加载,
改完 config.yaml 新会话即生效。
"""
from ._utils import get_ai_writing_config
flags = get_ai_writing_config().get("use_builtin_agents")
if not isinstance(flags, dict):
return False
return bool(flags.get(stage, False))
def load_writing_agent(agent_id: str):
"""加载内置写作 agent 的 (config, soul);SOUL 缺失抛 WritingAgentUnavailable。"""
from deerflow.config.agents_config import load_agent_config, load_agent_soul
soul = load_agent_soul(agent_id)
if not soul:
raise WritingAgentUnavailable(
f"内置写作智能体 '{agent_id}' 的 SOUL.md 缺失或为空"
"(目录可能被删除,重启 Gateway 或重新打开写作页可自动补建)"
)
try:
agent_cfg = load_agent_config(agent_id)
except ValueError:
# config.yaml 解析失败不致命:SOUL 仍可用,模型回退节点/表单选择。
logger.warning("load_writing_agent: config.yaml of '%s' is invalid, ignoring", agent_id)
agent_cfg = None
return agent_cfg, soul
def agent_display_name(agent_id: str, default: str) -> str:
"""取 agent 的展示名(progress_events.agent_name 用),加载失败回退默认值。"""
try:
from deerflow.config.agents_config import load_agent_config
cfg = load_agent_config(agent_id)
name = (cfg.name or "").strip() if cfg else ""
return name or default
except Exception:
return default
def resolve_agent_model_name(
agent_cfg,
state: dict,
config: RunnableConfig,
*,
explicit_fallback: str | None = None,
strict: bool = True,
) -> str | None:
"""解析本次 agent 调用使用的模型名。
优先级:agent config.yaml 的 ``model`` > ``explicit_fallback``(节点显式
传入,如 researcher 的 keyword_model/rank_model)> ``state.model_name``
(用户写作表单选的主模型)> configurable.model_name。
strict=True(大纲/写作/审核):全部为空抛 ValueError(与 get_writing_model
口径一致——不静默回退默认模型);strict=False(researcher 辅助任务):
返回 None,由 create_chat_model 回退 config.yaml 的 models[0]。
"""
candidates = (
(getattr(agent_cfg, "model", None) or "") if agent_cfg else "",
explicit_fallback or "",
(state or {}).get("model_name") or "",
(config.get("configurable", {}) or {}).get("model_name") or "",
)
for name in candidates:
if isinstance(name, str) and name.strip():
return name.strip()
if strict:
raise ValueError("未指定写作模型(model_name 为空):请在写作表单选择模型后重试")
return None
def parse_marker_json(text: str, marker: str, fallback: dict) -> dict:
"""从 agent 输出中按「marker + ```json``` 块」契约解析 JSON,多级降级。
1. ``marker\\n```json {...} ``` ``(SOUL 规定的标准形态,正则仿
intent.py::_INTENT_READY_PATTERN);
2. marker 存在但没包代码块 → 取 marker 之后的全部文本交给 parse_json;
3. marker 缺失(弱模型漏输出)→ 整段文本交给 parse_json;
4. 全部失败 → fallback。
"""
if not text or not text.strip():
return fallback
pattern = re.compile(re.escape(marker) + r"\s*```(?:json)?\s*(\{.*?\})\s*```", re.DOTALL)
m = pattern.search(text)
if m:
result = parse_json(m.group(1), fallback)
if result is not fallback:
return result
idx = text.find(marker)
if idx != -1:
tail = text[idx + len(marker):]
result = parse_json(tail, fallback)
if result is not fallback:
return result
return parse_json(text, fallback)
async def stream_writing_agent(
*,
agent_id: str,
user_payload: str,
state: dict,
config: RunnableConfig,
on_text: Callable[[str, bool], None] | None = None,
explicit_model_fallback: str | None = None,
strict_model: bool = True,
) -> str:
"""在「SOUL 作 system prompt + 单条 Human 输入」契约下流式运行一个写作 agent。
Args:
agent_id: 内置写作 agent id(STAGE_AGENT_IDS 的值之一)。
user_payload: 本阶段输入(意图/素材/大纲/草稿等拼成的一段文本)。
on_text: 每个流式片段回调 ``(text, is_thinking)``,调用方在回调里把
chunk 包装成本阶段的 custom 事件(outline_chunk / draft_chunk /
review_chunk ...)推给前端;None = 不透传(如 researcher 的辅助任务)。
explicit_model_fallback / strict_model: 见 resolve_agent_model_name。
Returns:
累积的**正文**文本(<think> 思考与 reasoning_content 已剥离),供
parse_marker_json / 章节拼装使用。
Raises:
WritingAgentUnavailable: SOUL 缺失(调用方回退旧 prompt 路径)。
其余模型异常原样上抛,由调用方按既有 try/except 兜底成 status:error。
"""
agent_cfg, soul = load_writing_agent(agent_id)
model_name = resolve_agent_model_name(
agent_cfg, state, config,
explicit_fallback=explicit_model_fallback,
strict=strict_model,
)
from deerflow.models import create_chat_model
model = create_chat_model(name=model_name)
full_text = ""
parser = ThinkTagParser()
async for chunk in model.astream([
SystemMessage(content=soul),
HumanMessage(content=user_payload),
]):
# 推理模型的思考走 reasoning_content(content 为空):作为思考实时推送
reasoning = reasoning_delta(chunk)
if reasoning and on_text is not None:
on_text(reasoning, True)
delta = _content_to_text(chunk.content)
if not delta:
continue
for is_thinking, text in parser.feed(delta):
if not is_thinking:
full_text += text
if on_text is not None:
on_text(text, is_thinking)
return full_text
async def run_writing_agent(
*,
agent_id: str,
user_payload: str,
marker: str,
fallback: dict,
state: dict,
config: RunnableConfig,
on_text: Callable[[str, bool], None] | None = None,
explicit_model_fallback: str | None = None,
strict_model: bool = True,
) -> dict:
"""跑一个写作 agent 并按 marker 契约解析其结构化输出(失败回 fallback)。
模型调用异常时与 ``_utils.llm_json`` 同口径:返回
``{**fallback, "_llm_error": str(e)}``,不上抛(SOUL 缺失除外——
WritingAgentUnavailable 原样上抛,由节点切回旧 prompt 路径)。
"""
try:
full_text = await stream_writing_agent(
agent_id=agent_id,
user_payload=user_payload,
state=state,
config=config,
on_text=on_text,
explicit_model_fallback=explicit_model_fallback,
strict_model=strict_model,
)
except WritingAgentUnavailable:
raise
except Exception as e:
logger.error("run_writing_agent(%s) failed: %s", agent_id, e, exc_info=True)
return {**fallback, "_llm_error": str(e)}
return parse_marker_json(full_text, marker, fallback)
@@ -11,6 +11,16 @@ from ..state import AIWritingState, ReviewResult, ReviewIssue
from ..prompts.editor_prompts import (
EDITOR_SYSTEM, FACT_REVIEW_USER, LOGIC_REVIEW_USER, LANGUAGE_REVIEW_USER,
)
from ._agent_runner import (
EDITOR_AGENT_ID,
REVIEW_READY_MARKER,
WritingAgentUnavailable,
agent_display_name,
load_writing_agent,
resolve_agent_model_name,
run_writing_agent,
use_builtin_agent,
)
from ._utils import get_ai_writing_config, get_writing_model, parse_json, _content_to_text, reasoning_delta, ThinkTagParser, safe_get_stream_writer
logger = logging.getLogger(__name__)
@@ -119,19 +129,45 @@ async def editor_node(state: AIWritingState, config: RunnableConfig) -> dict:
("language", LANGUAGE_REVIEW_USER.format(article=article)),
]
# 模型创建守卫:模型名配错时 create_chat_model 抛 ValueError,
# 不拦截会硬崩溃整个图;此处转成可恢复的 status:error。
try:
model = get_writing_model(config, state)
except Exception as e:
logger.error("editor model creation failed: %s", e, exc_info=True)
return {
"status": "error",
"progress_events": [{
"type": "error", "agent_name": "编辑",
"title": "模型错误", "detail": str(e), "message": "审核失败",
}],
}
# 内置 agent 模式(config.yaml ai_writing.use_builtin_agents.editor):
# 三维度循环仍由节点编排(review_chunk / review_item_done 流式形态不变),
# 每个维度跑一次 ai-writing-editor agent(SOUL 作 system prompt,输出
# [REVIEW_READY] + {"score","issues"})。agent 目录缺失则静默回退旧路径。
use_agent = use_builtin_agent("editor")
agent_label = "编辑"
model = None
if use_agent:
try:
agent_cfg, _soul = load_writing_agent(EDITOR_AGENT_ID)
# 模型守卫提前到循环外:模型名缺失/配错时与旧路径同样转成 status:error
resolve_agent_model_name(agent_cfg, state, config)
agent_label = agent_display_name(EDITOR_AGENT_ID, "编辑")
except WritingAgentUnavailable as e:
logger.warning("editor builtin agent unavailable, falling back to legacy prompts: %s", e)
use_agent = False
except Exception as e:
logger.error("editor agent model resolution failed: %s", e, exc_info=True)
return {
"status": "error",
"progress_events": [{
"type": "error", "agent_name": agent_label,
"title": "模型错误", "detail": str(e), "message": "审核失败",
}],
}
if not use_agent:
# 模型创建守卫:模型名配错时 create_chat_model 抛 ValueError,
# 不拦截会硬崩溃整个图;此处转成可恢复的 status:error。
try:
model = get_writing_model(config, state)
except Exception as e:
logger.error("editor model creation failed: %s", e, exc_info=True)
return {
"status": "error",
"progress_events": [{
"type": "error", "agent_name": "编辑",
"title": "模型错误", "detail": str(e), "message": "审核失败",
}],
}
results: dict[str, dict] = {}
review_errors: list[str] = [] # 分项审查的模型异常,供完成后非致命提示
@@ -140,8 +176,24 @@ async def editor_node(state: AIWritingState, config: RunnableConfig) -> dict:
for category, user_prompt in REVIEW_TASKS:
fallback = {"score": 80, "issues": []}
try:
full_text = await _stream_one_review(model, user_prompt, category, writer)
res = parse_json(full_text, fallback)
if use_agent:
def _on_text(text: str, is_thinking: bool, _cat: str = category) -> None:
writer({"type": "review_chunk", "category": _cat, "text": text, "is_thinking": is_thinking})
res = await run_writing_agent(
agent_id=EDITOR_AGENT_ID,
user_payload=user_prompt,
marker=REVIEW_READY_MARKER,
fallback=fallback,
state=state,
config=config,
on_text=_on_text,
)
if res.get("_llm_error"):
review_errors.append(f"「{_CATEGORY_LABEL.get(category, category)}审查」{res['_llm_error']}")
else:
full_text = await _stream_one_review(model, user_prompt, category, writer)
res = parse_json(full_text, fallback)
except Exception as e:
logger.warning("editor review [%s] failed: %s", category, e)
res = fallback
@@ -191,7 +243,7 @@ async def editor_node(state: AIWritingState, config: RunnableConfig) -> dict:
if review_errors:
notices.append({
"type": "notice",
"agent_name": "编辑",
"agent_name": agent_label,
"title": "模型错误",
"detail": "部分审查项调用失败,已按默认分兜底。原因:" + ";".join(review_errors[:3]),
})
@@ -199,11 +251,11 @@ async def editor_node(state: AIWritingState, config: RunnableConfig) -> dict:
return {
"review_result": review,
"progress_events": [
{"type": "step_start", "agent_name": "编辑", "message": "开始审核"},
{"type": "step_start", "agent_name": agent_label, "message": "开始审核"},
*notices,
{
"type": "review_ready",
"agent_name": "编辑",
"agent_name": agent_label,
"message": f"审核完成,综合得分 {overall_score},结论:{'通过' if verdict == 'pass' else '打回修改'}",
},
],
@@ -18,10 +18,46 @@ from ..prompts.researcher_prompts import (
EXTRACT_KEYWORDS_SYSTEM, EXTRACT_KEYWORDS_USER,
RANK_MATERIALS_SYSTEM, RANK_MATERIALS_USER,
)
from ._agent_runner import (
KEYWORDS_READY_MARKER,
MATERIALS_RANKED_MARKER,
RESEARCHER_AGENT_ID,
WritingAgentUnavailable,
agent_display_name,
load_writing_agent,
run_writing_agent,
use_builtin_agent,
)
from ._utils import get_ai_writing_config, llm_json, safe_get_stream_writer
logger = logging.getLogger(__name__)
# ── 内置 agent 模式的子任务 payload(SOUL 已含人设与输出契约,这里只给事实输入)──
_AGENT_KEYWORDS_USER = """任务:提取检索词
写作意图:{user_intent}
文章类型:{article_type}
目标读者:{target_audience}
请提取 {keyword_count} 个搜索关键词,用于搜集相关素材。
关键词数量必须为 {keyword_count} 个,不多不少;必须依据上述写作意图提炼真实检索词。
严格按 SOUL 约定输出 [KEYWORDS_READY] + JSON。
/no_think"""
_AGENT_RANK_USER = """任务:素材排序
写作意图:{user_intent}
素材列表(每条含ID和摘要):
{materials_list}
请选出最相关的素材ID(最多8条),按相关度从高到低排序。
严格按 SOUL 约定输出 [MATERIALS_RANKED] + JSON。
/no_think"""
_NOTEBOOK_SEARCH_PATH = "/api/v1/source/mentioned-documents/search"
_NOTEBOOK_MAX_KEYWORDS = 3 # 每次写作最多对笔记发起的检索轮数
_NOTEBOOK_TOP_K = 5 # 每次检索取回的 chunk 数
@@ -89,12 +125,12 @@ def _per_keyword_limit(max_materials: int, total_keywords: int) -> int:
return (max_materials + total_keywords - 1) // total_keywords
def _notice(title: str, detail: str, raw: str = "") -> dict:
def _notice(title: str, detail: str, raw: str = "", agent_name: str = "素材收集专家") -> dict:
"""构造一个非致命告警事件,前端在时间线显示「标题 + 真实原因」,不打断写作流程。
raw 非空时附带接口的原始错误响应体,前端默认折叠展示。
"""
event = {"type": "notice", "agent_name": "素材收集专家", "title": title, "detail": detail}
event = {"type": "notice", "agent_name": agent_name, "title": title, "detail": detail}
if raw:
event["raw"] = raw
return event
@@ -254,6 +290,20 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
# 素材总量上限(config.yaml: ai_writing.max_materials),按检索词数均分到每个词
max_materials = _resolve_max_materials(ai_writing_config.get("max_materials"))
# 内置 agent 模式(config.yaml ai_writing.use_builtin_agents.researcher):
# 检索编排(并发搜索 / 技能编排 / 笔记检索)仍由节点控制,只把两个 LLM 子任务
# (提取检索词 / 素材排序)换成跑 ai-writing-researcher agent(SOUL 可被 admin
# 编辑;模型优先级 agent.model > keyword_model/rank_model > 表单主模型)。
use_agent = use_builtin_agent("researcher")
researcher_label = "素材收集专家"
if use_agent:
try:
load_writing_agent(RESEARCHER_AGENT_ID)
researcher_label = agent_display_name(RESEARCHER_AGENT_ID, "素材收集专家")
except WritingAgentUnavailable as e:
logger.warning("researcher builtin agent unavailable, falling back to legacy prompts: %s", e)
use_agent = False
def _emit(message: str) -> None:
writer({"type": "research_progress", "message": message})
@@ -272,18 +322,35 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
_emit(f"追加检索 {len(keywords)} 个关键词…")
else:
_emit("正在生成检索词…")
kw_result = await llm_json(
EXTRACT_KEYWORDS_SYSTEM,
EXTRACT_KEYWORDS_USER.format(
user_intent=intent,
article_type=article_type,
target_audience=target_audience,
keyword_count=keyword_count,
),
fallback={"keywords": [intent]},
config=config,
model_name=keyword_model,
)
if use_agent:
kw_result = await run_writing_agent(
agent_id=RESEARCHER_AGENT_ID,
user_payload=_AGENT_KEYWORDS_USER.format(
user_intent=intent,
article_type=article_type,
target_audience=target_audience,
keyword_count=keyword_count,
),
marker=KEYWORDS_READY_MARKER,
fallback={"keywords": [intent]},
state=state,
config=config,
explicit_model_fallback=keyword_model,
strict_model=False,
)
else:
kw_result = await llm_json(
EXTRACT_KEYWORDS_SYSTEM,
EXTRACT_KEYWORDS_USER.format(
user_intent=intent,
article_type=article_type,
target_audience=target_audience,
keyword_count=keyword_count,
),
fallback={"keywords": [intent]},
config=config,
model_name=keyword_model,
)
# 清洗关键词:剔除占位符 / 空值,全空时回退写作意图
keywords = _clean_keywords(kw_result.get("keywords"), intent)
if extra_keywords:
@@ -321,7 +388,7 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
# 与前端 [ai-writing:custom] 日志对齐,钉死是否实时推送。
logger.info("[skill-timing] t=%.3f emit skill_search_step type=%s skill=%s",
time.monotonic(), step.get("type"), step.get("skill_name") or "")
writer({"type": "skill_search_step", "agent_name": "素材收集专家", **step})
writer({"type": "skill_search_step", "agent_name": researcher_label, **step})
if is_skill_search:
# ── 技能编排检索 ──
@@ -509,13 +576,26 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
f"- ID: {m['id']} 标题: {m['title']} 摘要: {m['content'][:100]}"
for m in raw_materials
)
rank_result = await llm_json(
RANK_MATERIALS_SYSTEM,
RANK_MATERIALS_USER.format(user_intent=intent, materials_list=materials_list),
fallback={"ranked_ids": [m["id"] for m in raw_materials[:max_materials]], "summary": f"共搜索到{len(raw_materials)}条素材"},
config=config,
model_name=rank_model,
)
rank_fallback = {"ranked_ids": [m["id"] for m in raw_materials[:max_materials]], "summary": f"共搜索到{len(raw_materials)}条素材"}
if use_agent:
rank_result = await run_writing_agent(
agent_id=RESEARCHER_AGENT_ID,
user_payload=_AGENT_RANK_USER.format(user_intent=intent, materials_list=materials_list),
marker=MATERIALS_RANKED_MARKER,
fallback=rank_fallback,
state=state,
config=config,
explicit_model_fallback=rank_model,
strict_model=False,
)
else:
rank_result = await llm_json(
RANK_MATERIALS_SYSTEM,
RANK_MATERIALS_USER.format(user_intent=intent, materials_list=materials_list),
fallback=rank_fallback,
config=config,
model_name=rank_model,
)
rank_error = rank_result.get("_llm_error", "")
ranked_ids: list[str] = rank_result.get("ranked_ids", [m["id"] for m in raw_materials])
id_to_mat = {m["id"]: m for m in raw_materials}
@@ -554,9 +634,9 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
# 容错告警:检索接口 / 模型调用出错时以非致命 notice 提示用户,不打断写作流程
notices: list[dict] = []
if kw_result.get("_llm_error"):
notices.append(_notice("模型错误", f"检索词生成失败,已用默认关键词兜底。原因:{kw_result['_llm_error']}"))
notices.append(_notice("模型错误", f"检索词生成失败,已用默认关键词兜底。原因:{kw_result['_llm_error']}", agent_name=researcher_label))
if rank_error:
notices.append(_notice("模型错误", f"素材排序失败,已按相关度兜底排序。原因:{rank_error}"))
notices.append(_notice("模型错误", f"素材排序失败,已按相关度兜底排序。原因:{rank_error}", agent_name=researcher_label))
if search_errors:
uniq = list(dict.fromkeys(search_errors))
# 解析失败时收集到的接口原始响应体,前端折叠展示便于定位
@@ -567,6 +647,7 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
"ES 波动导致检索异常",
";".join(uniq[:3]),
raw="\n\n----------\n\n".join(raw_dumps[:3]),
agent_name=researcher_label,
))
# 进度事件文案:用户输入 / 追加模式 / 技能模式下让用户看到差异化提示
@@ -583,8 +664,8 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
# 补充检索:直接回写作(沿用原大纲);常规检索:进入素材确认暂停点
"status": "writing_draft" if is_supplement else "awaiting_material_confirm",
"progress_events": [
{"type": "step_start", "agent_name": "素材收集专家", "message": step_start_msg},
{"type": "step_start", "agent_name": researcher_label, "message": step_start_msg},
*notices,
{"type": "materials_ready", "agent_name": "素材收集专家", "message": summary},
{"type": "materials_ready", "agent_name": researcher_label, "message": summary},
],
}
@@ -12,6 +12,15 @@ from ..prompts.writer_prompts import (
DRAFT_SECTION_SYSTEM, DRAFT_SECTION_USER, DRAFT_EXPAND_USER,
WRITER_MODE_LOOSE, WRITER_MODE_STRICT, SECTION_BLOCKED_MARKER,
)
from ._agent_runner import (
WRITER_AGENT_ID,
WritingAgentUnavailable,
agent_display_name,
load_writing_agent,
resolve_agent_model_name,
stream_writing_agent,
use_builtin_agent,
)
from ._utils import get_writing_model, _content_to_text, count_words, reasoning_delta, ThinkTagParser, safe_get_stream_writer
logger = logging.getLogger(__name__)
@@ -194,19 +203,44 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
def _section_title(idx: int, sec: dict) -> str:
return sec.get("section_title") or sec.get("sectionTitle") or f"章节{idx + 1}"
# 模型创建守卫:模型名配错时 create_chat_model 抛 ValueError,
# 不拦截会硬崩溃整个图;此处转成可恢复的 status:error。
try:
model = get_writing_model(config, state)
except Exception as e:
logger.error("draft model creation failed: %s", e, exc_info=True)
return {
"status": "error",
"progress_events": [{
"type": "error", "agent_name": "作家",
"title": "模型错误", "detail": str(e), "message": "草稿生成失败",
}],
}
# 内置 agent 模式(config.yaml ai_writing.use_builtin_agents.writer):
# 逐章循环控制权仍在节点(严格模式求助 / 逐章流式不动),每章跑一次
# ai-writing-writer agent(SOUL 作 system prompt,直接产出章节 Markdown)。
use_agent = use_builtin_agent("writer")
agent_label = "作家"
model = None
if use_agent:
try:
agent_cfg, _soul = load_writing_agent(WRITER_AGENT_ID)
# 模型守卫提前到循环外:模型名缺失/配错与旧路径同样转成 status:error
resolve_agent_model_name(agent_cfg, state, config)
agent_label = agent_display_name(WRITER_AGENT_ID, "作家")
except WritingAgentUnavailable as e:
logger.warning("writer builtin agent unavailable, falling back to legacy prompts: %s", e)
use_agent = False
except Exception as e:
logger.error("draft agent model resolution failed: %s", e, exc_info=True)
return {
"status": "error",
"progress_events": [{
"type": "error", "agent_name": agent_label,
"title": "模型错误", "detail": str(e), "message": "草稿生成失败",
}],
}
if not use_agent:
# 模型创建守卫:模型名配错时 create_chat_model 抛 ValueError,
# 不拦截会硬崩溃整个图;此处转成可恢复的 status:error。
try:
model = get_writing_model(config, state)
except Exception as e:
logger.error("draft model creation failed: %s", e, exc_info=True)
return {
"status": "error",
"progress_events": [{
"type": "error", "agent_name": "作家",
"title": "模型错误", "detail": str(e), "message": "草稿生成失败",
}],
}
section_titles: list[str] = []
section_bufs: list[str] = [] # 各章节正文(不含 ## 标题),供扩写兜底逐节替换
section_errors: list[str] = [] # 分节写作的模型异常,供完成后非致命提示
@@ -246,36 +280,55 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
section_buf = "" # 只累积正文(非思考)用于拼接 Markdown
parser = ThinkTagParser()
try:
async for chunk in model.astream([
SystemMessage(content=DRAFT_SECTION_SYSTEM),
HumanMessage(content=user_msg),
]):
# 推理模型的思考走 reasoning_content(content 为空):作为思考实时推送
reasoning = reasoning_delta(chunk)
if reasoning:
if use_agent:
def _on_text(text: str, is_thinking: bool, _i: int = i, _t: str = sec_title) -> None:
writer({
"type": "draft_chunk",
"section_index": i,
"section_title": sec_title,
"text": reasoning,
"is_thinking": True,
"is_section_done": False,
})
delta = _content_to_text(chunk.content)
if not delta:
continue
for is_thinking, text in parser.feed(delta):
if not is_thinking:
section_buf += text
writer({
"type": "draft_chunk",
"section_index": i,
"section_title": sec_title,
"section_index": _i,
"section_title": _t,
"text": text,
"is_thinking": is_thinking,
"is_section_done": False,
})
section_buf = await stream_writing_agent(
agent_id=WRITER_AGENT_ID,
user_payload=user_msg,
state=state,
config=config,
on_text=_on_text,
)
else:
async for chunk in model.astream([
SystemMessage(content=DRAFT_SECTION_SYSTEM),
HumanMessage(content=user_msg),
]):
# 推理模型的思考走 reasoning_content(content 为空):作为思考实时推送
reasoning = reasoning_delta(chunk)
if reasoning:
writer({
"type": "draft_chunk",
"section_index": i,
"section_title": sec_title,
"text": reasoning,
"is_thinking": True,
"is_section_done": False,
})
delta = _content_to_text(chunk.content)
if not delta:
continue
for is_thinking, text in parser.feed(delta):
if not is_thinking:
section_buf += text
writer({
"type": "draft_chunk",
"section_index": i,
"section_title": sec_title,
"text": text,
"is_thinking": is_thinking,
"is_section_done": False,
})
# 章节完成
writer({
"type": "draft_chunk",
@@ -319,9 +372,9 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
"blocked_sections": blocked_sections,
"status": "awaiting_section_help",
"progress_events": [
{"type": "step_start", "agent_name": "作家", "message": f"开始写作(第{revision_count+1}稿)"},
{"type": "step_start", "agent_name": agent_label, "message": f"开始写作(第{revision_count+1}稿)"},
{
"type": "notice", "agent_name": "作家", "title": "素材不足",
"type": "notice", "agent_name": agent_label, "title": "素材不足",
"detail": f"有 {len(blocked_sections)} 个章节缺少足够素材支撑写作,需要你决定如何处理。",
},
],
@@ -356,14 +409,23 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
existing_text=section_bufs[idx],
target_words=expand_target,
)
resp = await model.ainvoke([
SystemMessage(content=DRAFT_SECTION_SYSTEM),
HumanMessage(content=expand_msg),
])
parser = ThinkTagParser()
expanded = "".join(
t for is_thinking, t in parser.feed(_content_to_text(resp.content)) if not is_thinking
).strip()
if use_agent:
expanded = (await stream_writing_agent(
agent_id=WRITER_AGENT_ID,
user_payload=expand_msg,
state=state,
config=config,
on_text=None,
)).strip()
else:
resp = await model.ainvoke([
SystemMessage(content=DRAFT_SECTION_SYSTEM),
HumanMessage(content=expand_msg),
])
parser = ThinkTagParser()
expanded = "".join(
t for is_thinking, t in parser.feed(_content_to_text(resp.content)) if not is_thinking
).strip()
# 仅在扩写确实更长时才采用,避免越改越短
if count_words(expanded) > count_words(section_bufs[idx]):
section_bufs[idx] = expanded
@@ -390,7 +452,7 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
if section_errors:
notices.append({
"type": "notice",
"agent_name": "作家",
"agent_name": agent_label,
"title": "模型错误",
"detail": "部分章节生成失败,对应段落已占位。原因:" + ";".join(section_errors[:3]),
})
@@ -400,11 +462,11 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
"drafts": [draft],
"status": "awaiting_draft_confirm",
"progress_events": [
{"type": "step_start", "agent_name": "作家", "message": f"开始写作(第{revision_count+1}稿)"},
{"type": "step_start", "agent_name": agent_label, "message": f"开始写作(第{revision_count+1}稿)"},
*notices,
{
"type": "draft_ready",
"agent_name": "作家",
"agent_name": agent_label,
"message": f"草稿完成,共 {word_count} 字",
"draft_title": outline.get("title", "文章"),
"draft_markdown": full_markdown,
@@ -19,10 +19,51 @@ from ..prompts.writer_prompts import (
OUTLINE_USER,
)
from ..state import AIWritingState, KeyPoint, Outline, OutlineSection
from ._agent_runner import (
OUTLINE_READY_MARKER,
OUTLINER_AGENT_ID,
WritingAgentUnavailable,
agent_display_name,
load_writing_agent,
parse_marker_json,
stream_writing_agent,
use_builtin_agent,
)
from ._utils import ThinkTagParser, _content_to_text, get_writing_model, llm_json, parse_json, reasoning_delta, safe_get_stream_writer
logger = logging.getLogger(__name__)
# ── 内置 agent 模式的输入 payload(SOUL 已含人设/层级语义/输出契约,这里只给本次任务的事实输入)──
_AGENT_OUTLINE_USER = """任务模式:新规划
写作意图:{user_intent}
文章类型:{article_type}
目标读者:{target_audience}
目标字数:{word_count_target}字{feedback_section}
可用素材摘要:
{materials_summary}
请规划文章大纲(3-5 个章节),严格按 SOUL 约定输出 [OUTLINE_READY] + JSON。"""
_AGENT_OUTLINE_REVISE_USER = """任务模式:修改模式
写作意图:{user_intent}
文章类型:{article_type}
目标读者:{target_audience}
目标字数:{word_count_target}字
当前大纲(JSON):
{current_outline}
用户修改要求:{feedback}
可用素材摘要:
{materials_summary}
请在「当前大纲」基础上落实「用户修改要求」,未提到的章节保持原样,严格按 SOUL 约定输出 [OUTLINE_READY] + 调整后的完整大纲 JSON。"""
# 上下文压缩:传给大纲规划的素材摘要做条数和单条长度双重截断,防止 token 溢出。
_MAX_OUTLINE_MATERIALS = 12
_MATERIAL_SNIPPET_CHARS = 150
@@ -212,28 +253,40 @@ async def writer_outline_node(state: AIWritingState, config: RunnableConfig) ->
if user_outline_raw and not current_outline and not is_revise:
return await _build_outline_from_user(state, config, pkg, user_outline_raw)
# 内置 agent 模式(config.yaml ai_writing.use_builtin_agents.outliner):
# SOUL 作 system prompt + [OUTLINE_READY] 契约解析;目录缺失静默回退旧路径。
use_agent = use_builtin_agent("outliner")
agent_label = "作家"
if use_agent:
try:
load_writing_agent(OUTLINER_AGENT_ID)
agent_label = agent_display_name(OUTLINER_AGENT_ID, "大纲规划师")
except WritingAgentUnavailable as e:
logger.warning("outliner builtin agent unavailable, falling back to legacy prompts: %s", e)
use_agent = False
fmt_args = dict(
user_intent=state["user_intent"],
article_type=state.get("article_type", "analysis"),
target_audience=state.get("target_audience", "普通读者"),
word_count_target=state.get("word_count_target", 800),
materials_summary=_materials_summary(pkg),
)
if is_revise:
system_prompt = OUTLINE_REVISE_SYSTEM
user_prompt = OUTLINE_REVISE_USER.format(
user_intent=state["user_intent"],
article_type=state.get("article_type", "analysis"),
target_audience=state.get("target_audience", "普通读者"),
word_count_target=state.get("word_count_target", 800),
revise_args = dict(
fmt_args,
current_outline=json.dumps(current_outline, ensure_ascii=False, indent=2),
feedback=feedback,
materials_summary=_materials_summary(pkg),
)
user_prompt = OUTLINE_REVISE_USER.format(**revise_args)
agent_payload = _AGENT_OUTLINE_REVISE_USER.format(**revise_args)
else:
system_prompt = OUTLINE_SYSTEM
feedback_section = f"\n重新规划要求:{feedback}" if feedback else ""
user_prompt = OUTLINE_USER.format(
user_intent=state["user_intent"],
article_type=state.get("article_type", "analysis"),
target_audience=state.get("target_audience", "普通读者"),
word_count_target=state.get("word_count_target", 800),
materials_summary=_materials_summary(pkg),
feedback_section=feedback_section,
)
gen_args = dict(fmt_args, feedback_section=feedback_section)
user_prompt = OUTLINE_USER.format(**gen_args)
agent_payload = _AGENT_OUTLINE_USER.format(**gen_args)
# 流式推送:阶段 2 起统一走 LangGraph 原生 custom 通道(get_stream_writer)。
writer = safe_get_stream_writer()
@@ -242,35 +295,47 @@ async def writer_outline_node(state: AIWritingState, config: RunnableConfig) ->
parser = ThinkTagParser()
try:
# get_writing_model 放进 try:用户模型缺失 / 模型名配错时抛 ValueError,
# 留在 try 外会硬崩溃;放进来则统一走下方的 status:error 兜底。
model = get_writing_model(config, state)
async for chunk in model.astream([
SystemMessage(content=system_prompt),
HumanMessage(content=user_prompt),
]):
# 推理模型的思考走 reasoning_content(content 为空):作为思考实时推送
reasoning = reasoning_delta(chunk)
if reasoning:
writer({"type": "outline_chunk", "text": reasoning, "is_thinking": True})
delta = _content_to_text(chunk.content)
if not delta:
continue
for is_thinking, text in parser.feed(delta):
if not is_thinking:
full_text += text
writer({
"type": "outline_chunk",
"text": text,
"is_thinking": is_thinking,
})
if use_agent:
def _on_text(text: str, is_thinking: bool) -> None:
writer({"type": "outline_chunk", "text": text, "is_thinking": is_thinking})
full_text = await stream_writing_agent(
agent_id=OUTLINER_AGENT_ID,
user_payload=agent_payload,
state=state,
config=config,
on_text=_on_text,
)
else:
# get_writing_model 放进 try:用户模型缺失 / 模型名配错时抛 ValueError,
# 留在 try 外会硬崩溃;放进来则统一走下方的 status:error 兜底。
model = get_writing_model(config, state)
async for chunk in model.astream([
SystemMessage(content=system_prompt),
HumanMessage(content=user_prompt),
]):
# 推理模型的思考走 reasoning_content(content 为空):作为思考实时推送
reasoning = reasoning_delta(chunk)
if reasoning:
writer({"type": "outline_chunk", "text": reasoning, "is_thinking": True})
delta = _content_to_text(chunk.content)
if not delta:
continue
for is_thinking, text in parser.feed(delta):
if not is_thinking:
full_text += text
writer({
"type": "outline_chunk",
"text": text,
"is_thinking": is_thinking,
})
except Exception as e:
logger.error("outline streaming failed: %s", e, exc_info=True)
return {
"status": "error",
"progress_events": [{
"type": "error",
"agent_name": "作家",
"agent_name": agent_label,
"title": "模型错误",
"detail": str(e),
"message": "大纲生成失败",
@@ -309,7 +374,10 @@ async def writer_outline_node(state: AIWritingState, config: RunnableConfig) ->
},
],
}
result = parse_json(full_text, fallback)
if use_agent:
result = parse_marker_json(full_text, OUTLINE_READY_MARKER, fallback)
else:
result = parse_json(full_text, fallback)
outline = _parse_outline(result)
section_count = len(outline["sections"])
@@ -318,7 +386,7 @@ async def writer_outline_node(state: AIWritingState, config: RunnableConfig) ->
"current_outline": outline,
"status": "awaiting_outline_confirm",
"progress_events": [
{"type": "step_start", "agent_name": "作家", "message": "开始规划大纲"},
{"type": "outline_ready", "agent_name": "作家", "message": f"大纲已生成,共 {section_count} 个章节"},
{"type": "step_start", "agent_name": agent_label, "message": "开始规划大纲"},
{"type": "outline_ready", "agent_name": agent_label, "message": f"大纲已生成,共 {section_count} 个章节"},
],
}