diff --git a/frontend-web/docs/ai-writing-内置智能体改造.md b/frontend-web/docs/ai-writing-内置智能体改造.md new file mode 100644 index 00000000..2cb9dc0b --- /dev/null +++ b/frontend-web/docs/ai-writing-内置智能体改造.md @@ -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//` + - `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//` + 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//` 重新种子,或后续给种子器加「内置功能 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 文案),改造前即失败,待另行清理。 diff --git a/frontend-web/docs/ai-writing-右侧编辑器-滑词与段落菜单.md b/frontend-web/docs/ai-writing-右侧编辑器-滑词与段落菜单.md deleted file mode 100644 index ea9c4d50..00000000 --- a/frontend-web/docs/ai-writing-右侧编辑器-滑词与段落菜单.md +++ /dev/null @@ -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
(流式只读 Markdown)"] - TextArtifactRenderer --> WritingBubbleMenu - TextArtifactRenderer --> WritingParagraphMenu - TextArtifactRenderer --> TiptapEditor - TextArtifactRenderer --> WritingAiMenu -``` - -| 阶段 | 右侧展示 | 滑词 / 段落菜单 | -|------|----------|-----------------| -| 草稿流式生成中(`writing_draft` / `revising` 且有 section 流) | `DraftStreamingPreview`(ReactMarkdown 只读) | **不可用** | -| 成稿可编辑(`isDraftEditable === true`) | `TextArtifactRenderer` + Tiptap | **可用** | -| 流式结束但尚未 editable | `TextArtifactRenderer` 且 `isStreaming={true}` | **不可用**(`editable: false`) | - -相关状态由 [CanvasUIContext.tsx](../src/open-canvas/contexts/CanvasUIContext.tsx) 的 `isDraftStreaming`、`isDraftEditable` 控制;注释中「禁用编辑/润色/滑词」即指生成完成前不进入可编辑渲染器。 - -入口文件: - -- [AIWritingDraftPanel.tsx](../src/open-canvas/components/ai-writing/AIWritingDraftPanel.tsx) — 决定流式预览 vs `TextArtifactRenderer` -- [TextArtifactRenderer.tsx](../src/open-canvas/components/artifact/TextArtifactRenderer.tsx) — 挂载编辑器与两套浮层菜单 - ---- - -## 三、技术栈:编辑器 vs 浮层 - -### 3.1 Tiptap 编辑器(有使用) - -| 包 | 用途 | -|----|------| -| `@tiptap/react` | `useEditor`、`EditorContent` | -| `@tiptap/starter-kit` | 段落、标题、加粗、斜体、下划线等 | -| `@tiptap/extension-placeholder` | 空文档占位符 | -| `tiptap-markdown` | Markdown ↔ 文档互转 | -| 自研 `AiHighlightExtension` | AI 改写前高亮选区(ProseMirror Decoration) | - -扩展注册见 [TiptapExtensions.ts](../src/open-canvas/editor/TiptapExtensions.ts);实例创建见 [useWritingEditor.ts](../src/open-canvas/hooks/useWritingEditor.ts)。 - -### 3.2 浮层菜单(自研,非 Tiptap 官方插件) - -| 包 | 是否用于滑词/段落菜单 | -|----|----------------------| -| `@tiptap/extension-bubble-menu` | **未使用**(`package.json` 中有依赖,代码未 import) | -| `@tiptap/react` 的 `` | **未使用** | - -两套菜单均采用: - -1. 监听 Tiptap / DOM 事件更新位置与选区; -2. `createPortal(..., document.body)` 挂到 `document.body`,避免被右侧滚动容器 `overflow` 裁切; -3. `position: fixed` + `getBoundingClientRect()` 定位。 - -命名上的 `WritingBubbleMenu` 仅表示「气泡式选区工具条」,实现上与 Tiptap 官方 Bubble Menu 扩展无关。 - ---- - -## 四、核心文件索引 - -| 文件 | 职责 | -|------|------| -| [TiptapEditor.tsx](../src/open-canvas/editor/TiptapEditor.tsx) | `EditorContent` 渲染容器 | -| [TiptapExtensions.ts](../src/open-canvas/editor/TiptapExtensions.ts) | StarterKit、Placeholder、Markdown、AiHighlight | -| [AiHighlightExtension.ts](../src/open-canvas/editor/AiHighlightExtension.ts) | `setAiHighlight` / `clearAiHighlight` 命令 | -| [useWritingEditor.ts](../src/open-canvas/hooks/useWritingEditor.ts) | 创建 editor、同步外部 markdown、editable | -| [WritingBubbleMenu.tsx](../src/open-canvas/components/artifact/WritingBubbleMenu.tsx) | **滑词** 选区浮层 | -| [WritingParagraphMenu.tsx](../src/open-canvas/components/artifact/WritingParagraphMenu.tsx) | **段落** 左侧 ✨ 按钮 | -| [WritingAiMenu.tsx](../src/open-canvas/components/artifact/WritingAiMenu.tsx) | AI 改写操作菜单 | -| [WritingRewritePreview.tsx](../src/open-canvas/components/artifact/WritingRewritePreview.tsx) | 改写结果预览 | -| [useWritingRewrite.ts](../src/open-canvas/hooks/useWritingRewrite.ts) | 调用润色 API、写回编辑器 | -| [apply-rewrite.ts](../src/open-canvas/utils/apply-rewrite.ts) | 将改写结果应用到 ProseMirror 选区 | -| [CanvasUIContext.tsx](../src/open-canvas/contexts/CanvasUIContext.tsx) | `writingSelection` 全局状态 | -| [selection.ts](../src/open-canvas/types/selection.ts) | `WritingSelection` 类型定义 | - ---- - -## 五、滑词:`WritingBubbleMenu` - -### 5.1 行为 - -- 用户在编辑器内拖选非空文本时显示浮层;选区为空或位于 `codeBlock` 内时隐藏。 -- 浮层按钮:`AI 改写`、`B` / `I` / `U`、`H1`、复制。 -- 按钮 `onMouseDown` 使用 `e.preventDefault()`,避免点击时编辑器失焦导致选区丢失。 - -### 5.2 实现要点 - -```text -editor.on("selectionUpdate") - → 读取 editor.state.selection (from, to, empty) - → window.getSelection() + Range.getBoundingClientRect() 算屏幕坐标 - → setWritingSelection({ from, to, text }) 写入 CanvasUIContext - → createPortal 渲染工具条,useLayoutEffect 做贴边 / 上方空间不足时翻到选区下方 -``` - -关键代码位置:[WritingBubbleMenu.tsx](../src/open-canvas/components/artifact/WritingBubbleMenu.tsx) 中 `useEffect` 监听 `selectionUpdate` 与 `blur`。 - -点击「AI 改写」时: - -1. `editor.commands.setAiHighlight(from, to)` 高亮选区; -2. 调用父组件传入的 `onAiRewrite()`,由 `TextArtifactRenderer` 打开 `WritingAiMenu`。 - ---- - -## 六、段落菜单:`WritingParagraphMenu` - -### 6.1 行为 - -- 鼠标在编辑器内移动时,检测光标下的**块级节点**; -- 仅对 `paragraph`、`heading`、`blockquote` 且文本非空时,在块左侧显示 ✨ 按钮; -- 鼠标离开编辑器或移出可改写块后,延迟约 **380ms** 隐藏(`HIDE_DELAY`),并在按钮与正文之间留有 hover 桥接区,避免移向按钮时闪烁消失。 - -### 6.2 实现要点 - -```text -editor.view.dom mousemove - → editor.view.posAtCoords({ left, top }) - → doc.resolve(pos).before(depth) 定位块起点 - → editor.view.nodeDOM(nodeStart) 取 DOM 矩形 - → createPortal 固定定位按钮在块左侧 (BTN_SIZE=24, BTN_GAP=6) -``` - -点击 ✨ 时: - -1. `setTextSelection` 选中整段 ProseMirror 范围(`nodeFrom` ~ `nodeTo`); -2. `setAiHighlight` + `setWritingSelection`; -3. 触发与滑词相同的 `onAiRewrite()` 流程。 - -关键代码位置:[WritingParagraphMenu.tsx](../src/open-canvas/components/artifact/WritingParagraphMenu.tsx)。 - ---- - -## 七、AI 改写数据流 - -```mermaid -sequenceDiagram - participant User - participant Menu as BubbleMenu / ParagraphMenu - participant Ctx as CanvasUIContext - participant Renderer as TextArtifactRenderer - participant API as rewrite API - - User->>Menu: 选中文本或点段落 ✨ - Menu->>Ctx: setWritingSelection - Menu->>Renderer: onAiRewrite() - Renderer->>Renderer: phase = menu, WritingAiMenu - User->>Renderer: 选择润色/扩写等 - Renderer->>API: useWritingRewrite.rewrite - API-->>Renderer: 流式结果 - Renderer->>Renderer: WritingRewritePreview - User->>Renderer: 接受改写 - Renderer->>Renderer: applyReplace / applyInsert -``` - -- 选区信息类型:`WritingSelection`(`from`、`to`、`text`),见 [selection.ts](../src/open-canvas/types/selection.ts)。 -- 与画布聊天侧选区 `CanvasTextSelection` 分离;后者用于快捷操作栏等,不用于 Tiptap 改写。 - ---- - -## 八、与顶部工具栏、目录栏的关系 - -同一 `TextArtifactRenderer` 编辑模式下还包含: - -| 组件 | 作用 | -|------|------| -| [WritingToolbar.tsx](../src/open-canvas/components/artifact/WritingToolbar.tsx) | 顶部固定格式栏、导出等 | -| [DraftOutlineSidebar.tsx](../src/open-canvas/components/artifact/DraftOutlineSidebar.tsx) | 左侧标题目录,点击滚动到对应 heading | - -滑词 / 段落菜单与工具栏互不替代:工具栏面向全局格式;浮层面向**当前选区或当前段落**的快捷操作与 AI 改写入口。 - ---- - -## 九、开发与改造建议 - -### 9.1 若要改用官方 Bubble Menu - -1. 在 [TiptapExtensions.ts](../src/open-canvas/editor/TiptapExtensions.ts) 或组件层引入 `@tiptap/react` 的 `BubbleMenu`(或注册 `@tiptap/extension-bubble-menu`); -2. 将 [WritingBubbleMenu.tsx](../src/open-canvas/components/artifact/WritingBubbleMenu.tsx) 内手写 `selectionUpdate` + Portal 逻辑替换为官方组件的 `shouldShow` / `tippyOptions`(v3 API 以官方文档为准); -3. **段落左侧按钮**仍建议保留自研 `WritingParagraphMenu`(官方 Bubble Menu 只覆盖选区,不覆盖 hover 段落场景)。 - -### 9.2 新增浮层按钮 - -- **滑词**:在 `WritingBubbleMenu` 的 `BubbleButton` 列表中增加,使用 `editor.chain().focus()....run()`。 -- **段落**:在 `WritingParagraphMenu` 的 `handleClick` 或拆出多按钮布局;注意 `REWRITABLE_TYPES` 与 hover 延迟逻辑。 - -### 9.3 流式阶段禁止交互 - -不要在 `DraftStreamingPreview` 中挂载 Tiptap 菜单:流式阶段 token 高频更新,挂载 `useWritingEditor` 易引发更新风暴(见 [DraftStreamingPreview.tsx](../src/open-canvas/components/ai-writing/DraftStreamingPreview.tsx) 文件头注释)。应等 `isDraftEditable` 后再走 `TextArtifactRenderer`。 - ---- - -## 十、相关文档 - -- [前端开发.md](./前端开发.md) §25.4 — Canvas / AI 写作路由与组件总表 -- [ai-writing-用户自带大纲-测试用例.md](./ai-writing-用户自带大纲-测试用例.md) — AI 写作业务流程测试(含右侧草稿层级校验) - ---- - -## 十一、依赖版本(摘录) - -以 [package.json](../package.json) 为准,编辑器相关主要为: - -- `@tiptap/core`、`@tiptap/react`、`@tiptap/starter-kit`:`^3.23.4` -- `@tiptap/extension-bubble-menu`:已安装但未接入滑词实现 -- `tiptap-markdown`:Markdown 扩展 - -升级 Tiptap 大版本前,请对照官方迁移指南,并重点回归:`WritingBubbleMenu` 选区坐标、`WritingParagraphMenu` 的 `posAtCoords`、以及 `AiHighlightExtension` 与 StarterKit 扩展名冲突(勿重复注册 `underline`)。 diff --git a/frontend-web/docs/ai-writing-用户自带大纲-测试用例.md b/frontend-web/docs/ai-writing-用户自带大纲-测试用例.md deleted file mode 100644 index da73fbeb..00000000 --- a/frontend-web/docs/ai-writing-用户自带大纲-测试用例.md +++ /dev/null @@ -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`(自带大纲分支 + 素材关联)。 diff --git a/frontend-web/src/open-canvas/components/ai-writing/TimelineStep.tsx b/frontend-web/src/open-canvas/components/ai-writing/TimelineStep.tsx index 4a92a95b..b1289ca6 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/TimelineStep.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/TimelineStep.tsx @@ -22,6 +22,8 @@ function statusOf(event: ProgressEvent, isLast: boolean): StepStatus { const AGENT_COLOR: Record = { 素材收集专家: 'text-blue-500', 作家: 'text-purple-500', + // 内置智能体模式(ai_writing.use_builtin_agents.outliner)下大纲阶段的默认 agent 名 + 大纲规划师: 'text-violet-500', 编辑: 'text-orange-500', 系统: 'text-muted-foreground', }; diff --git a/frontend-web/src/roundtable-planning/api/chains.ts b/frontend-web/src/roundtable-planning/api/chains.ts index dce9b6ca..85d6a8ac 100644 --- a/frontend-web/src/roundtable-planning/api/chains.ts +++ b/frontend-web/src/roundtable-planning/api/chains.ts @@ -174,3 +174,34 @@ export async function deleteChain(id: string): Promise { 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 { + 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 ?? ""; +} diff --git a/frontend-web/src/roundtable-planning/api/drafts.ts b/frontend-web/src/roundtable-planning/api/drafts.ts index 1906a07b..9ce15956 100644 --- a/frontend-web/src/roundtable-planning/api/drafts.ts +++ b/frontend-web/src/roundtable-planning/api/drafts.ts @@ -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 diff --git a/frontend-web/src/roundtable-planning/components/FlowGraphPanel.tsx b/frontend-web/src/roundtable-planning/components/FlowGraphPanel.tsx new file mode 100644 index 00000000..25ec4815 --- /dev/null +++ b/frontend-web/src/roundtable-planning/components/FlowGraphPanel.tsx @@ -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 ( +
+ +
+ + + {meta.label} + +
+
{node.label}
+ +
+ ); +} + +// 必须模块级常量:每次 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(null); + // 用户手动拖过的节点位置 —— 数据增量更新重新布局时保留,不被布局覆盖。 + const manualPosRef = useRef>(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(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 ( +
+ {/* 头部:标题 + 工具栏 */} +
+
+ + + {data.title || "方案流程图"} + + + {data.nodes.length} 节点 · {data.edges.length} 连线 + + {streaming && ( + + + 绘制中 + + )} +
+
+ void rf.zoomIn({ duration: 150 })}> + + + void rf.zoomOut({ duration: 150 })}> + + + void rf.fitView({ duration: 250, padding: 0.18 })} + > + + + + {fullscreen ? : } + + + + +
+
+ + {/* 画布 */} +
+ setSelectedId(n.id)} + fitView + fitViewOptions={{ padding: 0.18 }} + minZoom={0.2} + maxZoom={2.5} + panOnDrag + zoomOnScroll + nodesConnectable={false} + deleteKeyCode={null} + proOptions={{ hideAttribution: true }} + > + + + + {/* 图例 */} +
+ {Object.entries(FLOW_NODE_TYPE_META).map(([type, meta]) => ( + + + {meta.label} + + ))} +
+
+ + {/* 节点详情弹窗 */} + {selectedNode && ( + setSelectedId(null)} + onJump={(id) => setSelectedId(id)} + /> + )} +
+ ); +} + +function ToolButton({ + title, + onClick, + children, +}: { + title: string; + onClick: () => void; + children: React.ReactNode; +}) { + return ( + + ); +} + +// ── 节点详情弹窗(类型 / 说明 / 上下游连接)───────────────────────────────── + +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 ( +
+
e.stopPropagation()} + > +
+
+ + + {meta.label} + +

+ {node.label} +

+
+ +
+
+
+
+ 详细说明 +
+

+ {node.detail?.trim() || "(该节点未提供详细说明)"} +

+
+ {incoming.length > 0 && ( + ({ id: e.from, label: labelOf(e.from), edgeLabel: e.label }))} onJump={onJump} /> + )} + {outgoing.length > 0 && ( + ({ id: e.to, label: labelOf(e.to), edgeLabel: e.label }))} onJump={onJump} /> + )} +
+
+
+ ); +} + +function ConnectionList({ + title, + entries, + onJump, +}: { + title: string; + entries: Array<{ id: string; label: string; edgeLabel?: string }>; + onJump: (id: string) => void; +}) { + return ( +
+
+ {title} +
+
+ {entries.map((entry) => ( + + ))} +
+
+ ); +} + +// ── 外壳:覆盖右栏 / 全屏切换 ─────────────────────────────────────────────── + +export function FlowGraphPanel({ data, streaming, onClose }: FlowGraphPanelProps) { + const [fullscreen, setFullscreen] = useState(false); + return ( +
+ + setFullscreen((f) => !f)} + onClose={onClose} + /> + +
+ ); +} diff --git a/frontend-web/src/roundtable-planning/components/Step3ReportMode.tsx b/frontend-web/src/roundtable-planning/components/Step3ReportMode.tsx index 65abc900..29e63db9 100644 --- a/frontend-web/src/roundtable-planning/components/Step3ReportMode.tsx +++ b/frontend-web/src/roundtable-planning/components/Step3ReportMode.tsx @@ -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 (
@@ -196,6 +202,17 @@ export function Step3ReportMode({
+ {(phase === "creating" || phase === "streaming") && ( + + )}
)} + {phase === "paused" && ( +
+ + 已暂停。可在下方输入修改要求继续,或点右上角「重新绘制」。 +
+ )} + {phase === "done" && (
已生成可视化方案总览,右侧可查看 / 全屏 / 下载。
)} + + {themeMismatch && ( +
+ + 界面主题已切换,这份报告还是旧主题的配色。 + + +
+ )} {!atBottom && }
+ )} + + {/* 流程图画布(覆盖右栏;全屏在组件内部切换) */} + {flowGraph && flowView && ( + setFlowView(false)} + /> + )} diff --git a/frontend-web/src/roundtable-planning/hooks/useStep3Report.ts b/frontend-web/src/roundtable-planning/hooks/useStep3Report.ts index b6102430..f8bbebda 100644 --- a/frontend-web/src/roundtable-planning/hooks/useStep3Report.ts +++ b/frontend-web/src/roundtable-planning/hooks/useStep3Report.ts @@ -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>({}); const runningRef = useRef(false); const aborterRef = useRef(null); + // 报告线程是否仍「存活」(本会话里跑过 run/interject 建的真线程)。存活时插话 + // 直接续线程(免带原料与旧 HTML);草稿恢复的合成线程不算存活。 + const liveThreadRef = useRef(null); + const agentNameRef = useRef("roundtable-report"); + // 当前展示的报告是用哪个主题绘制的(null = 未知,旧草稿没存该字段)。 + const [reportTheme, setReportTheme] = useState<"light" | "dark" | null>(null); // 当前报告气泡 id —— 流式 delta / 工具步骤 / finalize 都按它定位(绝不用数组下标)。 const bubbleIdRef = useRef(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, diff --git a/frontend-web/src/roundtable-planning/hooks/useStep3Summary.ts b/frontend-web/src/roundtable-planning/hooks/useStep3Summary.ts index 30e19df9..8ae53352 100644 --- a/frontend-web/src/roundtable-planning/hooks/useStep3Summary.ts +++ b/frontend-web/src/roundtable-planning/hooks/useStep3Summary.ts @@ -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"); } diff --git a/frontend-web/src/roundtable-planning/lib/flow-graph.test.ts b/frontend-web/src/roundtable-planning/lib/flow-graph.test.ts new file mode 100644 index 00000000..4886d5ec --- /dev/null +++ b/frontend-web/src/roundtable-planning/lib/flow-graph.test.ts @@ -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))); +}); diff --git a/frontend-web/src/roundtable-planning/lib/flow-graph.ts b/frontend-web/src/roundtable-planning/lib/flow-graph.ts new file mode 100644 index 00000000..b921134d --- /dev/null +++ b/frontend-web/src/roundtable-planning/lib/flow-graph.ts @@ -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(Object.keys(FLOW_NODE_TYPE_META)); + +export const FLOW_FENCE_OPEN = "```flow-json"; + +/** + * 平衡括号扫描:从 `key`(如 `"nodes"`)后面的 `[` 开始,把数组里**已完整闭合**的 + * 顶层 `{…}` 逐个 JSON.parse 收集;跳过字符串内的括号与转义;数组未闭合(流式半截) + * 时收集到哪算哪,截断的尾对象忽略。 + */ +function collectObjects(text: string, key: string): Record[] { + const keyIdx = text.indexOf(key); + if (keyIdx < 0) return []; + const arrStart = text.indexOf("[", keyIdx); + if (arrStart < 0) return []; + const out: Record[] = []; + 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); + } + } 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): 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, 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; + 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): 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(); + 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(); + 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(); + 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(); + const visiting = new Set(); + 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(); + 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(); + 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)! })); +} diff --git a/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx b/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx index 9be1df56..283338e8 100644 --- a/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx @@ -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(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() { >
总控提示词 - - 写给总控:怎么派活、做什么(仅分层研讨生效,可选) - +
+ + 写给总控:怎么派活、做什么(仅分层研讨生效,可选) + + +