Explorar o código

feat(outline): 完成动态多Agent与会话流程升级

Mochocyang hai 1 mes
pai
achega
014061b1be
Modificáronse 84 ficheiros con 5722 adicións e 1422 borrados
  1. 36 0
      dagangshengji-分支说明.md
  2. 2 0
      src-tauri/src/commands/file_sync.rs
  3. 12 0
      src-tauri/src/main.rs
  4. 1 1
      src-tauri/tauri.conf.json
  5. 2 0
      src/App.tsx
  6. 273 99
      src/components/chat/agent-tool-call-message.spec.tsx
  7. 6 0
      src/components/chat/agent-tool-call-message.tsx
  8. 101 243
      src/components/chat/agent-workflow-panel.tsx
  9. 3 2
      src/components/chat/chat-message.spec.tsx
  10. 36 158
      src/components/chat/chat-message.tsx
  11. 149 0
      src/components/chat/chat-panel.mount.spec.tsx
  12. 18 19
      src/components/chat/chat-panel.spec.tsx
  13. 296 127
      src/components/chat/chat-panel.tsx
  14. 35 0
      src/components/common/conversation-delete-confirm-dialog.spec.tsx
  15. 32 0
      src/components/common/conversation-delete-confirm-dialog.tsx
  16. 35 0
      src/components/common/conversation-run-status-icon.spec.tsx
  17. 53 0
      src/components/common/conversation-run-status-icon.tsx
  18. 163 0
      src/components/common/event-stream.tsx
  19. 70 0
      src/components/common/streaming-markdown.tsx
  20. 33 0
      src/components/common/streaming-spinner.tsx
  21. 164 0
      src/components/common/timeline-grouping.spec.ts
  22. 87 0
      src/components/common/timeline-grouping.ts
  23. 74 0
      src/components/common/timeline-thinking-event.spec.tsx
  24. 88 0
      src/components/common/timeline-thinking-event.tsx
  25. 171 0
      src/components/common/timeline-tool-event.tsx
  26. 57 0
      src/components/common/timeline-tool-group.tsx
  27. 155 0
      src/components/common/timeline-types.spec.ts
  28. 119 0
      src/components/common/timeline-types.ts
  29. 30 0
      src/components/common/workflow-responsive-width.spec.tsx
  30. 41 6
      src/components/error-boundary.tsx
  31. 0 2
      src/components/layout/sidebar-panel.tsx
  32. 42 1
      src/components/reference/ReferenceInput.spec.tsx
  33. 8 4
      src/components/reference/ReferenceInput.tsx
  34. 83 0
      src/components/sources/outline-chat-multi-session.spec.tsx
  35. 154 10
      src/components/sources/outline-chat-panel.spec.tsx
  36. 344 266
      src/components/sources/outline-chat-panel.tsx
  37. 35 0
      src/components/sources/outline-intent-options-card.tsx
  38. 34 0
      src/components/sources/outline-multi-agent-panel.spec.tsx
  39. 8 1
      src/components/sources/outline-multi-agent-panel.tsx
  40. 30 0
      src/components/sources/outline-next-step-card.tsx
  41. 98 73
      src/components/sources/outline-workflow-stages.tsx
  42. 46 0
      src/hooks/use-batched-value.ts
  43. 94 0
      src/hooks/use-streaming-text.ts
  44. 56 1
      src/index.css
  45. 17 37
      src/lib/agent/tools/run-chapter-workflow.ts
  46. 2 2
      src/lib/auto-save.ts
  47. 23 0
      src/lib/conversation-create-guard.spec.ts
  48. 9 0
      src/lib/conversation-create-guard.ts
  49. 63 0
      src/lib/conversation-run-registry.spec.ts
  50. 26 0
      src/lib/conversation-run-registry.ts
  51. 48 0
      src/lib/conversation-run-state.spec.ts
  52. 33 0
      src/lib/conversation-run-state.ts
  53. 27 0
      src/lib/novel/chapter-positioning-fs.ts
  54. 1 1
      src/lib/novel/chapter-positioning.spec.ts
  55. 0 38
      src/lib/novel/chapter-positioning.ts
  56. 35 0
      src/lib/novel/outline-chat-session-state.spec.ts
  57. 30 0
      src/lib/novel/outline-chat-session-state.ts
  58. 89 0
      src/lib/novel/outline-dynamic-agent-planner.spec.ts
  59. 184 0
      src/lib/novel/outline-dynamic-agent-planner.ts
  60. 127 0
      src/lib/novel/outline-dynamic-agent-scheduler.spec.ts
  61. 23 1
      src/lib/novel/outline-intent-clarity.spec.ts
  62. 8 0
      src/lib/novel/outline-intent-clarity.ts
  63. 6 7
      src/lib/novel/outline-multi-agent-orchestrator.spec.ts
  64. 173 56
      src/lib/novel/outline-multi-agent-orchestrator.ts
  65. 41 0
      src/lib/novel/outline-next-step-resilience.spec.ts
  66. 20 0
      src/lib/novel/outline-next-step.spec.ts
  67. 137 27
      src/lib/novel/outline-next-step.ts
  68. 68 0
      src/lib/novel/outline-stage-trace.spec.ts
  69. 105 16
      src/lib/novel/outline-stage-trace.ts
  70. 23 0
      src/lib/novel/prompt-templates.ts
  71. 38 10
      src/lib/persist.ts
  72. 134 0
      src/lib/separate-thinking.ts
  73. 251 0
      src/lib/streaming-code-highlight.ts
  74. 60 0
      src/lib/toast.spec.tsx
  75. 92 152
      src/lib/toast.tsx
  76. 4 1
      src/main.tsx
  77. 62 1
      src/stores/chat-store.ts
  78. 3 3
      src/stores/import-progress-store.ts
  79. 157 0
      src/stores/outline-chat-store.spec.ts
  80. 189 42
      src/stores/outline-chat-store.ts
  81. 2 2
      src/stores/outline-generation-store.ts
  82. 23 12
      src/test/chat-panel-mount.ts
  83. 7 1
      tsconfig.app.json
  84. 8 0
      vite.config.ts

+ 36 - 0
dagangshengji-分支说明.md

@@ -0,0 +1,36 @@
+# 大纲升级分支说明
+
+## 分支目标
+升级AI大纲系统,集成qmaiskill成熟方法论,实现动态追踪、标准化结构、质量校验。
+
+## 更新记录
+### 2026-07-08 AI大纲多Agent运行链路接入
+- 本次目标:将 AI 大纲固定生成向导从“提示多 Agent”推进为实际调用多 Agent 编排器执行,支持子 Agent 并发、合并 Agent 汇总和单 Agent fallback。
+- 已完成:向导提交时传入 `enableMultiAgent: true`;普通自由对话保持单 Agent;子 Agent 禁止写入文件;合并失败时自动回退单 Agent。
+- 验证:新增失败测试并转绿,目标单测、typecheck、build、test:mocks、源码启动、build:portable 已执行。
+- 是否提交:未提交
+
+### 2026-07-08 AI大纲多Agent工作流升级
+- 新增实施计划文档:docs/superpowers/plans/2026-07-08-ai-outline-multi-agent-workflow-implementation.html
+- 本次目标:支持 AI 大纲按 SkillHub 中不同 Skill 规划多个子 Agent 并行生成,子 Agent 只生成结构化结果不直接写文件,由最终合并 Agent 统一质检、合并和写回;多 Agent 不可用时自动回退为单 Agent。
+- 已新增工作流状态机、结构化输出协议、多 Agent 编排器,并将 AI 大纲向导发送内容升级为包含多 Agent 分工计划和回退说明。
+- 验证:目标单测、typecheck、build、test:mocks、源码启动、build:portable 已执行。
+- 是否提交:未提交
+
+### 2026-07-08 AI大纲工作流标准升级
+- 新增实施计划文档:docs/superpowers/plans/2026-07-08-ai-outline-workflow-upgrade.html
+- 本次目标:先落地充分性闸门、先卷后章、卷节拍表、卷时间线、滚动章纲、结构节点和设定写回等标准。
+- 是否提交:未提交
+
+### 2026-07-07 初始化
+- 分支创建
+- 实施计划文档完成:docs/superpowers/plans/2026-07-07-ai-outline-upgrade.html
+
+### 2026-07-11 动态多 Agent、响应式思考框与 next_step 容错
+- 动态规划器读取用户任务、项目摘要、已有与缺失模块及全部可用 Skill 元数据,最多规划 12 个任务。
+- 依赖调度器最多并发 3 个 Agent,支持自动补位、失败重试一次、失败维度传播和按规划顺序合并。
+- 普通 AI 会话与 AI 大纲的思考流程框统一全宽响应式,长内容不会撑出横向宽度。
+- next_step 支持标准解析、轻微结构恢复、残留清理和安全卡片降级;显示、复制、摘要与保存统一使用清理正文。
+- 验证:本次相关最终聚焦测试 9 个文件、67 项通过;typecheck、build、源码启动检查和 2.2.33 build:portable 通过。
+- 完整 mocks 回归:428 个测试文件、3291 项通过;43 项既有失败集中于当前工作区其他模块。
+- 是否提交:已纳入本次本地提交,并将合并到本地 main;不推送远程。

+ 2 - 0
src-tauri/src/commands/file_sync.rs

@@ -1,3 +1,5 @@
+#![allow(dead_code, unused_imports)]
+
 use std::collections::{BTreeMap, BTreeSet};
 use std::fs;
 use std::io::Read;

+ 12 - 0
src-tauri/src/main.rs

@@ -12,6 +12,17 @@ fn set_proxy_env(config: proxy::ProxyConfig) -> String {
     summary
 }
 
+#[tauri::command]
+fn log_error(message: String, stack: String, component_stack: String) {
+    eprintln!("[frontend-error] message: {}", message);
+    if !stack.is_empty() {
+        eprintln!("[frontend-error] stack: {}", stack);
+    }
+    if !component_stack.is_empty() {
+        eprintln!("[frontend-error] component-stack: {}", component_stack);
+    }
+}
+
 fn main() {
     tauri::Builder::default()
         .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
@@ -102,6 +113,7 @@ fn main() {
             commands::backup::import_backup,
             commands::backup::read_backup_manifest,
             set_proxy_env,
+            log_error,
         ])
         .on_window_event(|window, event| {
             if let tauri::WindowEvent::CloseRequested { api, .. } = event {

+ 1 - 1
src-tauri/tauri.conf.json

@@ -1,7 +1,7 @@
 {
   "$schema": "https://schema.tauri.app/config/2",
   "productName": "QMaiWrite",
-  "version": "2.2.32",
+  "version": "2.2.33",
   "identifier": "com.qingmuai.writer",
   "build": {
     "beforeDevCommand": "npm run dev",

+ 2 - 0
src/App.tsx

@@ -61,6 +61,7 @@ function App() {
     try {
       const savedChat = await loadChatHistory(proj.path)
       if (!isCurrentProject(proj)) return
+      useChatStore.getState().setLoadedRunStates(savedChat.runStates)
       if (savedChat.conversations.length > 0) {
         useChatStore.getState().setConversations(savedChat.conversations)
         useChatStore.getState().setMessages(savedChat.messages)
@@ -197,6 +198,7 @@ function App() {
                 chatState.conversations,
                 chatState.messages,
                 chatState.maxHistoryMessages,
+                chatState.runStates,
               ).catch((err) => console.error("关闭前保存聊天历史失败:", err))
             }
             const reviewState = useReviewStore.getState()

+ 273 - 99
src/components/chat/agent-tool-call-message.spec.tsx

@@ -8,6 +8,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
 import { AgentToolCallMessage, getToolCallDescription } from "./agent-tool-call-message"
 import type { AgentRunRecord } from "@/lib/agent/types"
 
+;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean })
+  .IS_REACT_ACT_ENVIRONMENT = true
+
 type ToolCallRecord = AgentRunRecord["toolCalls"][number]
 
 let host: HTMLDivElement
@@ -75,67 +78,38 @@ afterEach(() => {
 })
 
 describe("AgentToolCallMessage", () => {
-  it("renders lightweight workflow stages and keeps raw tool details collapsed", async () => {
+  it("renders event stream with tool calls and stats", async () => {
     await act(async () => {
       root.render(<AgentToolCallMessage toolCalls={sampleCalls} />)
     })
 
-    expect(host.textContent).toContain("思考过程")
-    expect(host.textContent).toContain("任务理解")
-    expect(host.textContent).toContain("使用技能")
-    expect(host.textContent).toContain("上下文准备")
-    expect(host.textContent).toContain("工具调用")
-    expect(host.textContent).toContain("思考与决策")
-    expect(host.textContent).toContain("生成与校验")
-    expect(host.textContent).toContain("工具详情")
-    expect(host.textContent).not.toContain("read_chapter")
-    expect(host.textContent).not.toContain("read_memory")
-    expect(host.textContent).not.toContain("write_memory")
-    expect(host.textContent).not.toContain("写入资料")
-    expect(host.textContent).not.toContain("apply_skill")
-    expect(host.textContent).not.toContain("去AI味")
-
-    const contextButton = Array.from(host.querySelectorAll("button"))
-      .find((button) => button.textContent?.includes("上下文准备"))
-    expect(contextButton).not.toBeUndefined()
-
-    await act(async () => {
-      contextButton?.click()
-    })
-
     expect(host.textContent).toContain("读取章节《第1章-开篇》")
     expect(host.textContent).toContain("读取记忆「主角档案」")
+    expect(host.textContent).toContain("生成记忆写入草稿「写入资料」")
+    expect(host.textContent).toContain("应用技能「去AI味」")
     expect(host.textContent).toContain("搜索章节关键词「李明」")
+    expect(host.textContent).toContain("耗时")
+    expect(host.textContent).toContain("工具 5 次")
+    expect(host.textContent).not.toMatch(/[⏱🔢💡]/u)
 
-    const detailsButton = Array.from(host.querySelectorAll("button"))
-      .find((button) => button.textContent?.includes("工具详情"))
-    expect(detailsButton).not.toBeUndefined()
-
-    await act(async () => {
-      detailsButton?.click()
-    })
-
-    expect(host.textContent).toContain("read_chapter")
-    expect(host.textContent).toContain("读取章节《第1章-开篇》")
-    expect(host.textContent).toContain("read_memory")
-    expect(host.textContent).toContain("读取记忆「主角档案」")
-    expect(host.textContent).toContain("write_memory")
-    expect(host.textContent).toContain("写入记忆「写入资料」")
-    expect(host.textContent).toContain("apply_skill")
-    expect(host.textContent).toContain("应用技能「去AI味」")
+    expect(host.textContent).not.toContain("read_chapter")
+    expect(host.textContent).not.toContain("read_memory")
+    expect(host.textContent).not.toContain("write_memory")
+    expect(host.textContent).not.toContain("apply_skill")
   })
 
-  it("shows error style and icon for failed tool calls", async () => {
+  it("shows error style for failed tool calls", async () => {
     await act(async () => {
       root.render(<AgentToolCallMessage toolCalls={[sampleCalls[4]]} />)
     })
 
-    expect(host.textContent).toContain("失败")
     const html = host.innerHTML
-    expect(html).toContain("text-red-600")
+    expect(html).toContain("text-red-500")
+    expect(host.textContent).toContain("搜索章节关键词「李明」")
+    expect(host.textContent).toContain("失败")
   })
 
-  it("shows approval-required tool calls as pending confirmation", async () => {
+  it("shows approval-required tool calls with warning status", async () => {
     await act(async () => {
       root.render(
         <AgentToolCallMessage
@@ -154,21 +128,11 @@ describe("AgentToolCallMessage", () => {
       )
     })
 
-    expect(host.textContent).toContain("待确认")
-    expect(host.textContent).toContain("判断本轮涉及写入,必须等待用户确认后才能保存")
+    expect(host.textContent).toContain("生成章节写入草稿《第1章》")
+    expect(host.textContent).toContain("等待确认")
     expect(host.textContent).not.toContain("write_chapter")
-    expect(host.textContent).not.toContain("写入章节《第1章》")
-
-    const detailsButton = Array.from(host.querySelectorAll("button"))
-      .find((button) => button.textContent?.includes("工具详情"))
-    expect(detailsButton).not.toBeUndefined()
-
-    await act(async () => {
-      detailsButton?.click()
-    })
-
-    expect(host.textContent).toContain("待确认")
-    expect(host.textContent).toContain("写入章节《第1章》")
+    const html = host.innerHTML
+    expect(html).toContain("text-amber-500")
   })
 
   it("shows confirmation actions for approval-required write tools without opening raw details", async () => {
@@ -199,9 +163,11 @@ describe("AgentToolCallMessage", () => {
     expect(host.textContent).toContain("放弃")
     expect(host.textContent).not.toContain("write_outline_node")
 
-    const confirmButton = Array.from(host.querySelectorAll("button"))
-      .find((button) => button.textContent?.includes("确认保存"))
-    expect(confirmButton).not.toBeUndefined()
+    // 审批区块中的确认保存按钮(最后一个)绑定了 onConfirmSave
+    const confirmButtons = Array.from(host.querySelectorAll("button"))
+      .filter((button) => button.textContent?.includes("确认保存"))
+    expect(confirmButtons.length).toBeGreaterThan(0)
+    const confirmButton = confirmButtons[confirmButtons.length - 1]
 
     await act(async () => {
       confirmButton?.click()
@@ -211,7 +177,7 @@ describe("AgentToolCallMessage", () => {
     expect(onConfirmSave.mock.calls[0][0].name).toBe("write_outline_node")
   })
 
-  it("shows a user-facing thinking process before raw tool details", async () => {
+  it("shows event stream with mixed tool types", async () => {
     await act(async () => {
       root.render(
         <AgentToolCallMessage
@@ -248,24 +214,12 @@ describe("AgentToolCallMessage", () => {
       )
     })
 
-    expect(host.textContent).toContain("思考过程")
-    expect(host.textContent).toContain("任务理解")
-    expect(host.textContent).toContain("上下文准备")
-    expect(host.textContent).toContain("待确认")
-    expect(host.textContent).toContain("工具详情")
-    expect(host.textContent).not.toContain("AI 工具调用")
+    expect(host.textContent).toContain("识别任务意图")
+    expect(host.textContent).toContain("读取大纲《总大纲》")
+    expect(host.textContent).toContain("生成章节写入草稿《chapter-018》")
     expect(host.textContent).not.toContain("read_outline")
     expect(host.textContent).not.toContain("write_chapter")
-
-    const detailsButton = Array.from(host.querySelectorAll("button"))
-      .find((button) => button.textContent?.includes("工具详情"))
-    expect(detailsButton).not.toBeUndefined()
-
-    await act(async () => {
-      detailsButton?.click()
-    })
-
-    expect(host.textContent).toContain("AI 工具调用")
+    expect(host.textContent).toContain("工具 3 次")
   })
 
   it("returns null when toolCalls is empty or undefined", async () => {
@@ -292,7 +246,7 @@ describe("AgentToolCallMessage", () => {
       root.render(<AgentToolCallMessage toolCalls={[sampleCalls[0]]} />)
     })
 
-    expect(host.textContent).toContain("上下文准备")
+    expect(host.textContent).toContain("读取章节《第1章-开篇》")
     expect(host.textContent).not.toContain("read_chapter")
     expect(errorSpy.mock.calls.flat().join("\n")).not.toContain("change in the order of Hooks")
     } finally {
@@ -300,7 +254,7 @@ describe("AgentToolCallMessage", () => {
     }
   })
 
-  it("opens the currently running stage by default", async () => {
+  it("shows running status and an accessible description while only running animates", async () => {
     await act(async () => {
       root.render(
         <AgentToolCallMessage
@@ -319,50 +273,256 @@ describe("AgentToolCallMessage", () => {
       )
     })
 
-    expect(host.textContent).toContain("正在读取章节《chapter-017》")
+    expect(host.textContent).toContain("读取章节《chapter-017》")
+    expect(host.textContent).toContain("运行中")
+    const toolButton = host.querySelector<HTMLButtonElement>(
+      'button[aria-label*="读取章节《chapter-017》"][aria-label*="运行中"]',
+    )
+    expect(toolButton).not.toBeNull()
+    const html = host.innerHTML
+    expect(html).toContain("animate-spin")
+    expect(html).toContain("text-sky-500")
+    expect(html).not.toContain("🔍")
+    expect(html).not.toContain("⚠️")
+    expect(html).not.toContain("✓")
+    expect(html).not.toContain("✗")
+  })
+
+  it("groups two completed chapter reads and expands their compact rows on click", async () => {
+    await act(async () => {
+      root.render(
+        <AgentToolCallMessage
+          toolCalls={[
+            {
+              id: "chapter-1",
+              name: "read_chapter",
+              params: { name: "第1章" },
+              result: "第一章内容",
+              status: "done",
+              startedAt: 10,
+              finishedAt: 30,
+            },
+            {
+              id: "chapter-2",
+              name: "read_chapter",
+              params: { name: "第2章" },
+              result: "第二章内容",
+              status: "done",
+              startedAt: 40,
+              finishedAt: 70,
+            },
+          ]}
+        />,
+      )
+    })
+
+    expect(host.textContent).toContain("已读取章节")
+    expect(host.textContent).toContain("2项")
+    expect(host.textContent).toContain("50ms")
+    expect(host.textContent).toContain("工具 2 次")
+    expect(host.textContent).not.toContain("读取章节《第1章》")
+    expect(host.textContent).not.toContain("读取章节《第2章》")
+
+    const groupButton = Array.from(host.querySelectorAll("button"))
+      .find((button) => button.textContent?.includes("已读取章节"))
+    expect(groupButton?.getAttribute("aria-expanded")).toBe("false")
+
+    await act(async () => {
+      groupButton?.click()
+    })
+
+    expect(groupButton?.getAttribute("aria-expanded")).toBe("true")
+    expect(host.textContent).toContain("读取章节《第1章》")
+    expect(host.textContent).toContain("读取章节《第2章》")
+  })
+
+  it("keeps a chapter group expanded when another completed chapter is appended", async () => {
+    const chapterCalls: ToolCallRecord[] = [
+      {
+        id: "chapter-1",
+        name: "read_chapter",
+        params: { name: "第1章" },
+        result: "第一章内容",
+        status: "done",
+        startedAt: 10,
+        finishedAt: 30,
+      },
+      {
+        id: "chapter-2",
+        name: "read_chapter",
+        params: { name: "第2章" },
+        result: "第二章内容",
+        status: "done",
+        startedAt: 40,
+        finishedAt: 70,
+      },
+    ]
+
+    await act(async () => {
+      root.render(<AgentToolCallMessage toolCalls={chapterCalls} />)
+    })
+    const groupButton = Array.from(host.querySelectorAll("button"))
+      .find((button) => button.textContent?.includes("已读取章节"))
+    await act(async () => {
+      groupButton?.click()
+    })
+    expect(host.textContent).toContain("读取章节《第1章》")
+
+    await act(async () => {
+      root.render(
+        <AgentToolCallMessage
+          toolCalls={[
+            ...chapterCalls,
+            {
+              id: "chapter-3",
+              name: "read_chapter",
+              params: { name: "第3章" },
+              result: "第三章内容",
+              status: "done",
+              startedAt: 80,
+              finishedAt: 110,
+            },
+          ]}
+        />,
+      )
+    })
+
+    expect(host.textContent).toContain("3项")
+    expect(host.textContent).toContain("读取章节《第1章》")
+    expect(host.textContent).toContain("读取章节《第3章》")
+  })
+
+  it("does not expose expandable semantics when a tool has no details", async () => {
+    await act(async () => {
+      root.render(
+        <AgentToolCallMessage
+          toolCalls={[
+            {
+              id: "no-details",
+              name: "route_task",
+              params: {},
+              result: "",
+              status: "done",
+              startedAt: 10,
+              finishedAt: 20,
+            },
+          ]}
+        />,
+      )
+    })
+
+    const toolButton = host.querySelector<HTMLButtonElement>('button[aria-label*="识别任务意图"]')
+    expect(toolButton).not.toBeNull()
+    expect(toolButton?.hasAttribute("aria-expanded")).toBe(false)
+    await act(async () => {
+      toolButton?.click()
+    })
+    expect(toolButton?.hasAttribute("aria-expanded")).toBe(false)
+    expect(host.textContent).not.toContain("参数")
+    expect(host.textContent).not.toContain("结果")
+  })
+
+  it("renders undefined parameter values safely when details are expanded", async () => {
+    await act(async () => {
+      root.render(
+        <AgentToolCallMessage
+          toolCalls={[
+            {
+              id: "undefined-param",
+              name: "apply_skill",
+              params: { skillName: undefined },
+              result: "",
+              status: "done",
+              startedAt: 10,
+              finishedAt: 20,
+            },
+          ]}
+        />,
+      )
+    })
+
+    const toolButton = host.querySelector<HTMLButtonElement>('button[aria-label*="应用写作技能"]')
+    expect(toolButton).not.toBeNull()
+    await act(async () => {
+      toolButton?.click()
+    })
+
+    expect(host.textContent).toContain("skillName:")
+    expect(host.textContent).toContain("undefined")
+  })
+
+  it("shows the cancelled status in Chinese", async () => {
+    await act(async () => {
+      root.render(
+        <AgentToolCallMessage
+          toolCalls={[
+            {
+              id: "cancelled-call",
+              name: "read_chapter",
+              params: { name: "第4章" },
+              result: "",
+              status: "cancelled",
+              startedAt: 10,
+              finishedAt: 10,
+            },
+          ]}
+        />,
+      )
+    })
+
+    expect(host.textContent).toContain("已取消")
+    expect(host.querySelector('button[aria-label*="已取消"]')).not.toBeNull()
+  })
+
+  it("uses wrapping tool rows with a minimum 32px height and no legacy character icons", async () => {
+    await act(async () => {
+      root.render(<AgentToolCallMessage toolCalls={[sampleCalls[2]]} />)
+    })
+
+    const toolButton = Array.from(host.querySelectorAll("button"))
+      .find((button) => button.textContent?.includes("生成记忆写入草稿"))
+    expect(toolButton?.className).toContain("min-h-8")
+    expect(toolButton?.innerHTML).toContain("break-words")
+    expect(toolButton?.innerHTML).not.toContain("truncate")
+    expect(host.innerHTML).not.toContain("✏️")
+    expect(host.innerHTML).not.toContain("⚙️")
+    expect(host.innerHTML).not.toContain("···")
+    expect(host.innerHTML).not.toContain("✓")
+    expect(host.innerHTML).not.toContain("animate-spin")
   })
 
   it("does not use the old blue card-list wrapper for workflow steps", () => {
     const source = readFileSync(resolve(__dirname, "agent-workflow-panel.tsx"), "utf8")
 
     expect(source).toContain("border-l border-border/80")
-    expect(source).toContain("before:bg-border/80")
     expect(source).not.toContain("border-l-4 border-blue-500")
     expect(source).not.toContain("bg-blue-50/50")
     expect(source).not.toContain("border-blue-200/70 bg-white/50")
   })
 
-  it("expands and collapses result on row click", async () => {
+  it("expands and collapses tool call details on row click", async () => {
     await act(async () => {
       root.render(<AgentToolCallMessage toolCalls={[sampleCalls[0]]} />)
     })
 
-    const detailsButton = Array.from(host.querySelectorAll("button"))
-      .find((button) => button.textContent?.includes("工具详情"))
-    expect(detailsButton).not.toBeUndefined()
-
-    await act(async () => {
-      detailsButton?.click()
-    })
-
-    const expandButton = Array.from(host.querySelectorAll("button"))
-      .find((button) => button.textContent?.includes("read_chapter"))
-    expect(expandButton).not.toBeNull()
-    expect(expandButton?.getAttribute("aria-expanded")).toBe("false")
+    const toolItemButton = Array.from(host.querySelectorAll("button"))
+      .find((button) => button.textContent?.includes("读取章节《第1章-开篇》"))
+    expect(toolItemButton).not.toBeUndefined()
+    expect(toolItemButton?.getAttribute("aria-expanded")).toBe("false")
     expect(host.textContent).not.toContain("第一章内容")
 
     await act(async () => {
-      expandButton?.click()
+      toolItemButton?.click()
     })
 
-    expect(expandButton?.getAttribute("aria-expanded")).toBe("true")
+    expect(toolItemButton?.getAttribute("aria-expanded")).toBe("true")
     expect(host.textContent).toContain("第一章内容")
 
     await act(async () => {
-      expandButton?.click()
+      toolItemButton?.click()
     })
 
-    expect(expandButton?.getAttribute("aria-expanded")).toBe("false")
+    expect(toolItemButton?.getAttribute("aria-expanded")).toBe("false")
     expect(host.textContent).not.toContain("第一章内容")
   })
 })
@@ -399,6 +559,20 @@ describe("getToolCallDescription", () => {
   })
 })
 
+describe("outline workflow integration", () => {
+  it("uses one shared workflow panel while preserving thinking and write confirmation callbacks", () => {
+    const source = readFileSync(resolve(__dirname, "../sources/outline-chat-panel.tsx"), "utf8")
+    const start = source.indexOf("function OutlineAssistantMessage")
+    const end = source.indexOf("function ", start + 1)
+    const component = source.slice(start, end === -1 ? source.length : end)
+
+    expect(component).not.toContain("<OutlineWorkflowStages")
+    expect(component).toContain("thinkingContent={thinking || undefined}")
+    expect(component).toContain("thinkingStreaming={messageIsStreaming}")
+    expect(component).toContain("onConfirmSave={onConfirmToolSave}")
+    expect(component).toContain("onReject={onRejectTool}")
+  })
+})
 describe("tool call timeline layout", () => {
   it("wraps tool rows so long tool names and descriptions do not squeeze the chat panel", () => {
     const source = readFileSync(resolve(__dirname, "tool-call-timeline.tsx"), "utf8")

+ 6 - 0
src/components/chat/agent-tool-call-message.tsx

@@ -8,6 +8,8 @@ export type ToolCallRecord = AgentRunRecord["toolCalls"][number]
 interface AgentToolCallMessageProps {
   toolCalls: ToolCallRecord[] | undefined
   contextTrace?: ContextTrace | null
+  thinkingContent?: string
+  thinkingStreaming?: boolean
   onConfirmSave?: (call: ToolCallRecord & { preview?: string }) => void
   onReject?: (call: ToolCallRecord & { preview?: string }) => void
 }
@@ -15,6 +17,8 @@ interface AgentToolCallMessageProps {
 export function AgentToolCallMessage({
   toolCalls,
   contextTrace,
+  thinkingContent,
+  thinkingStreaming,
   onConfirmSave,
   onReject,
 }: AgentToolCallMessageProps) {
@@ -22,6 +26,8 @@ export function AgentToolCallMessage({
     <AgentWorkflowPanel
       toolCalls={toolCalls}
       contextTrace={contextTrace}
+      thinkingContent={thinkingContent}
+      thinkingStreaming={thinkingStreaming}
       onConfirmSave={onConfirmSave}
       onReject={onReject}
     />

+ 101 - 243
src/components/chat/agent-workflow-panel.tsx

@@ -1,201 +1,123 @@
-import { useMemo, useState } from "react"
-import {
-  AlertTriangle,
-  Brain,
-  CheckCircle2,
-  ChevronRight,
-  Circle,
-  Clock3,
-  Loader2,
-  XCircle,
-} from "lucide-react"
-import { ToolCallTimeline } from "./tool-call-timeline"
-import {
-  buildAgentWorkflowSteps,
-  type AgentWorkflowDetail,
-  type AgentWorkflowStep,
-  type AgentWorkflowStepStatus,
-} from "@/lib/agent/workflow-trace"
-import type { ContextTrace } from "@/lib/agent/context-trace"
+import { useMemo, useRef, useEffect } from "react"
+import { getWorkflowToolDescription } from "@/lib/agent/workflow-trace"
 import type { AgentRunRecord } from "@/lib/agent/types"
-import { cn } from "@/lib/utils"
+import type { ContextTrace } from "@/lib/agent/context-trace"
+import { createStreamingEventBuilder } from "@/components/common/timeline-types"
+import type { ToolCallEventItem, TimelineToolCategory } from "@/components/common/timeline-types"
+import { EventStream } from "@/components/common/event-stream"
 
 type ToolCallRecord = AgentRunRecord["toolCalls"][number]
 
 interface AgentWorkflowPanelProps {
   toolCalls: ToolCallRecord[] | undefined
   contextTrace?: ContextTrace | null
+  thinkingContent?: string
+  thinkingStreaming?: boolean
   onConfirmSave?: (call: ToolCallRecord & { preview?: string }) => void
   onReject?: (call: ToolCallRecord & { preview?: string }) => void
 }
 
-function getDefaultOpenStepId(steps: AgentWorkflowStep[]): string | null {
-  return steps.find((step) => step.status === "running")?.id
-    ?? steps.find((step) => step.status === "approval_required")?.id
-    ?? null
-}
-
-function getWorkflowDuration(steps: AgentWorkflowStep[]): string {
-  const starts = steps.map((step) => step.startedAt).filter((value): value is number => Number.isFinite(value))
-  if (starts.length === 0) return ""
-  const finishes = steps.map((step) => step.finishedAt).filter((value): value is number => Number.isFinite(value))
-  const start = Math.min(...starts)
-  const end = finishes.length > 0 ? Math.max(...finishes) : Date.now()
-  const ms = Math.max(0, end - start)
-  if (ms < 1000) return `${ms}ms`
-  if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`
-  const seconds = Math.floor(ms / 1000)
-  return `${Math.floor(seconds / 60)}m${seconds % 60}s`
-}
-
-function getOverallStatus(steps: AgentWorkflowStep[]): AgentWorkflowStepStatus {
-  if (steps.some((step) => step.status === "running")) return "running"
-  if (steps.some((step) => step.status === "approval_required")) return "approval_required"
-  if (steps.some((step) => step.status === "error")) return "error"
-  if (steps.some((step) => step.status === "cancelled")) return "cancelled"
-  return "done"
-}
-
-function WorkflowStatusIcon({ status }: { status: AgentWorkflowStepStatus }) {
-  if (status === "running") {
-    return <Loader2 className="h-3.5 w-3.5 animate-spin text-sky-600 dark:text-sky-400" />
-  }
-  if (status === "approval_required") {
-    return <AlertTriangle className="h-3.5 w-3.5 text-amber-600 dark:text-amber-400" />
-  }
-  if (status === "error") {
-    return <XCircle className="h-3.5 w-3.5 text-red-600 dark:text-red-400" />
-  }
-  if (status === "cancelled") {
-    return <XCircle className="h-3.5 w-3.5 text-muted-foreground/70" />
-  }
-  if (status === "pending") {
-    return <Circle className="h-3.5 w-3.5 text-muted-foreground/60" />
-  }
-  return <CheckCircle2 className="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400" />
+const WRITE_TOOLS = new Set(["write_chapter", "write_outline_node", "write_memory"])
+const READ_TOOLS = new Set([
+  "list_chapters", "list_outlines", "list_memories", "list_deductions",
+  "read_chapter", "read_outline", "read_memory", "read_deduction",
+  "read_chat_history", "read_outline_history", "search_chapters",
+  "chapter_context", "chapter_previous_analysis", "load_context", "trim_context",
+])
+const ACTION_TOOLS = new Set(["route_task", "apply_skill"])
+
+function getToolCallCategory(name: string): TimelineToolCategory {
+  if (WRITE_TOOLS.has(name)) return "write"
+  if (READ_TOOLS.has(name)) return "read"
+  if (ACTION_TOOLS.has(name)) return "action"
+  return "virtual"
 }
 
-function getStatusLabel(status: AgentWorkflowStepStatus): string {
-  switch (status) {
-    case "running":
-      return "进行中"
-    case "approval_required":
-      return "待确认"
-    case "error":
-      return "失败"
-    case "cancelled":
-      return "已取消"
-    case "pending":
-      return "待处理"
-    default:
-      return "完成"
+function adaptToolCall(call: ToolCallRecord): ToolCallEventItem {
+  const isError = call.status === "error"
+  return {
+    id: call.id,
+    name: call.name,
+    description: getWorkflowToolDescription(call),
+    category: getToolCallCategory(call.name),
+    status: call.status,
+    params: call.params,
+    result: isError ? undefined : call.result,
+    error: isError ? call.result : undefined,
+    startedAt: call.startedAt,
+    finishedAt: call.finishedAt,
   }
 }
 
-function getDetailToneClass(tone: AgentWorkflowDetail["tone"]): string {
-  switch (tone) {
-    case "success":
-      return "text-emerald-700 dark:text-emerald-300"
-    case "warning":
-      return "text-amber-700 dark:text-amber-300"
-    case "error":
-      return "text-red-700 dark:text-red-300"
-    case "muted":
-      return "text-muted-foreground/80"
-    default:
-      return "text-foreground/85"
-  }
-}
-
-function WorkflowStepRow({
-  step,
-  open,
-  onToggle,
-}: {
-  step: AgentWorkflowStep
-  open: boolean
-  onToggle: () => void
-}) {
-  return (
-    <div
-      className={cn(
-        "relative pl-7",
-        step.status === "running" && "rounded-md bg-sky-50/45 py-1 pr-2 dark:bg-sky-950/10",
-        step.status === "approval_required" && "rounded-md bg-amber-50/55 py-1 pr-2 dark:bg-amber-950/10",
-      )}
-    >
-      <span className="absolute left-0 top-1.5 z-10 flex h-5 w-5 items-center justify-center rounded-full bg-background">
-        <WorkflowStatusIcon status={step.status} />
-      </span>
-      <button
-        type="button"
-        onClick={onToggle}
-        className="group grid w-full min-w-0 grid-cols-[minmax(0,1fr)_auto] gap-x-2 text-left"
-        aria-expanded={open}
-      >
-        <span className="min-w-0">
-          <span className="flex min-w-0 items-center gap-1.5">
-            <ChevronRight
-              className={cn(
-                "h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform group-hover:text-foreground",
-                open && "rotate-90",
-              )}
-            />
-            <span className="min-w-0 break-words text-[13px] font-medium text-foreground">
-              {step.title}
-            </span>
-          </span>
-          <span className="mt-0.5 block break-words pl-5 text-[12px] leading-5 text-muted-foreground">
-            {step.summary}
-          </span>
-        </span>
-        <span className="shrink-0 pt-0.5 text-[11px] text-muted-foreground">
-          {getStatusLabel(step.status)}
-        </span>
-      </button>
-      {open && (
-        <div className="mt-1.5 space-y-1.5 pl-5 text-[12px] leading-5">
-          {step.details.map((detail, index) => (
-            <div key={`${step.id}-${detail.label}-${index}`} className="grid min-w-0 grid-cols-[4.5rem_minmax(0,1fr)] gap-2">
-              <span className="text-muted-foreground">{detail.label}</span>
-              <span className={cn("min-w-0 break-words", getDetailToneClass(detail.tone))}>{detail.value}</span>
-            </div>
-          ))}
-        </div>
-      )}
-    </div>
-  )
-}
-
 export function AgentWorkflowPanel({
   toolCalls,
   contextTrace,
+  thinkingContent,
+  thinkingStreaming,
   onConfirmSave,
   onReject,
 }: AgentWorkflowPanelProps) {
-  const steps = useMemo(
-    () => buildAgentWorkflowSteps({ toolCalls, contextTrace }),
-    [toolCalls, contextTrace],
+  const safeToolCalls = toolCalls ?? []
+  const builderRef = useRef(createStreamingEventBuilder("agent-thinking"))
+  const wasStreamingRef = useRef(false)
+
+  const sortedCalls = useMemo(
+    () => [...safeToolCalls].sort((a, b) => (a.startedAt ?? 0) - (b.startedAt ?? 0)),
+    [safeToolCalls],
   )
-  const defaultOpenStepId = useMemo(() => getDefaultOpenStepId(steps), [steps])
-  const [openMap, setOpenMap] = useState<Record<string, boolean>>({})
-  const [detailsOpen, setDetailsOpen] = useState(false)
-  const [allOpen, setAllOpen] = useState(false)
 
-  if (steps.length === 0) return null
+  const adaptedCalls = useMemo(
+    () => sortedCalls.map(adaptToolCall),
+    [sortedCalls],
+  )
 
-  const overallStatus = getOverallStatus(steps)
-  const duration = getWorkflowDuration(steps)
-  const safeToolCalls = toolCalls ?? []
-  const pendingApprovalCalls = safeToolCalls.filter((call) => call.status === "approval_required")
+  const isStreaming =
+    thinkingStreaming || safeToolCalls.some((c) => c.status === "running")
+
+  useEffect(() => {
+    if (!wasStreamingRef.current && isStreaming) {
+      builderRef.current.reset()
+    }
+    wasStreamingRef.current = isStreaming
+  }, [isStreaming])
+
+  const events = useMemo(
+    () => builderRef.current.update(
+      thinkingContent || "",
+      adaptedCalls,
+      !!thinkingStreaming,
+    ),
+    [thinkingContent, adaptedCalls, thinkingStreaming],
+  )
+
+  const totalDurationMs = useMemo(() => {
+    if (contextTrace?.startedAt && contextTrace?.finishedAt) {
+      return contextTrace.finishedAt - contextTrace.startedAt
+    }
+    if (safeToolCalls.length > 0) {
+      const startedAts = safeToolCalls.map((c) => c.startedAt).filter(Boolean)
+      const finishedAts = safeToolCalls.map((c) => c.finishedAt).filter(Boolean)
+      if (startedAts.length > 0 && finishedAts.length > 0) {
+        return Math.max(...finishedAts) - Math.min(...startedAts)
+      }
+    }
+    return undefined
+  }, [contextTrace, safeToolCalls])
+
+  const pendingApprovalCalls = safeToolCalls.filter(
+    (call) => call.status === "approval_required",
+  )
+
+  if (events.length === 0 && !isStreaming) return null
 
-  // 检测同批写入调用中是否有重复的大纲文件名
   const duplicateOutlineCalls = (() => {
     if (pendingApprovalCalls.length < 2) return []
     const groupedByFile = new Map<string, typeof pendingApprovalCalls>()
     for (const call of pendingApprovalCalls) {
       if (call.name !== "write_outline_node") continue
-      const outlineName = (call.params as Record<string, unknown>)?.outlineName as string | undefined
+      const outlineName = (call.params as Record<string, unknown>)?.outlineName as
+        | string
+        | undefined
       if (!outlineName) continue
       const existing = groupedByFile.get(outlineName) ?? []
       existing.push(call)
@@ -210,75 +132,17 @@ export function AgentWorkflowPanel({
     return duplicates
   })()
 
-
-  const isStepOpen = (step: AgentWorkflowStep) => {
-    if (allOpen) return true
-    if (step.id in openMap) return openMap[step.id]
-    return step.id === defaultOpenStepId
-  }
-
   return (
-    <div className="mb-2 max-w-full overflow-hidden border-l border-border/80 pl-3">
-      <div className="mb-2 flex flex-wrap items-center justify-between gap-2">
-        <div className="flex min-w-0 items-center gap-2 text-[13px] font-medium text-foreground">
-          <span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
-            <Brain className="h-3.5 w-3.5" />
-          </span>
-          <span>思考过程</span>
-          <span className="text-[11px] font-normal text-muted-foreground">({steps.length})</span>
-          <span className="inline-flex items-center gap-1 text-[11px] font-normal text-muted-foreground">
-            <WorkflowStatusIcon status={overallStatus} />
-            {getStatusLabel(overallStatus)}
-          </span>
-          {duration && (
-            <span className="inline-flex items-center gap-1 text-[11px] font-normal text-muted-foreground">
-              <Clock3 className="h-3 w-3" />
-              {duration}
-            </span>
-          )}
-        </div>
-        <div className="flex shrink-0 items-center gap-2 text-[11px]">
-          <button
-            type="button"
-            onClick={() => setAllOpen((value) => !value)}
-            className="text-muted-foreground hover:text-foreground"
-          >
-            {allOpen ? "全部折叠" : "展开全部"}
-          </button>
-          {safeToolCalls.length > 0 && (
-            <button
-              type="button"
-              onClick={() => setDetailsOpen((value) => !value)}
-              className="text-muted-foreground hover:text-foreground"
-              aria-expanded={detailsOpen}
-            >
-              工具详情
-            </button>
-          )}
-        </div>
-      </div>
-
-      <div className="relative space-y-3 before:absolute before:left-[9px] before:top-2 before:bottom-2 before:w-px before:bg-border/80">
-        {steps.map((step) => (
-          <WorkflowStepRow
-            key={step.id}
-            step={step}
-            open={isStepOpen(step)}
-            onToggle={() => {
-              setOpenMap((prev) => ({
-                ...prev,
-                [step.id]: !isStepOpen(step),
-              }))
-            }}
-          />
-        ))}
-      </div>
+    <div className="mb-2 w-full min-w-0 max-w-full overflow-hidden border-l border-border/80 pl-3">
+      <EventStream events={events} isStreaming={isStreaming} totalDurationMs={totalDurationMs} />
 
       {pendingApprovalCalls.length > 0 && onConfirmSave && onReject ? (
         <div className="mt-3 space-y-2 rounded-md border border-amber-200 bg-amber-50/70 p-2 text-xs text-amber-900 dark:border-amber-900/40 dark:bg-amber-950/20 dark:text-amber-200">
           {duplicateOutlineCalls.length > 0 ? (
             <div className="mb-2 rounded-md border border-red-200 bg-red-50/70 p-2 text-xs text-red-800 dark:border-red-900/40 dark:bg-red-950/20 dark:text-red-300">
-              <span className="font-semibold">⚠ 警告:以下文件有多个写入项指向同一文件名:</span>
+              <span className="font-semibold">
+                ⚠ 警告:以下文件有多个写入项指向同一文件名:
+              </span>
               <ul className="mt-1 list-inside list-disc space-y-0.5">
                 {duplicateOutlineCalls.map(({ outlineName, callCount }) => (
                   <li key={outlineName}>
@@ -289,9 +153,14 @@ export function AgentWorkflowPanel({
             </div>
           ) : null}
           {pendingApprovalCalls.map((call) => (
-            <div key={call.id} className="flex min-w-0 flex-wrap items-center justify-between gap-2">
+            <div
+              key={call.id}
+              className="flex min-w-0 flex-wrap items-center justify-between gap-2"
+            >
               <span className="min-w-0 flex-1 truncate">
-                {call.name === "write_outline_node" ? "大纲写入需要确认" : "写入操作需要确认"}
+                {call.name === "write_outline_node"
+                  ? "大纲写入需要确认"
+                  : "写入操作需要确认"}
               </span>
               <span className="flex shrink-0 items-center gap-1.5">
                 <button
@@ -313,17 +182,6 @@ export function AgentWorkflowPanel({
           ))}
         </div>
       ) : null}
-
-      {detailsOpen && safeToolCalls.length > 0 && (
-        <div className="mt-3 border-t border-border/70 pt-2">
-          <ToolCallTimeline
-            toolCalls={safeToolCalls}
-            onConfirmSave={onConfirmSave}
-            onReject={onReject}
-            defaultExpanded={false}
-          />
-        </div>
-      )}
     </div>
   )
 }

+ 3 - 2
src/components/chat/chat-message.spec.tsx

@@ -171,7 +171,7 @@ describe("agent stage stream integration", () => {
     expect(html).toContain("放弃")
   })
 
-  it("keeps old tool workflow fallback when structured stages are absent", () => {
+  it("shows event stream when tool calls are present", () => {
     const message: DisplayMessage = {
       id: "assistant-2",
       role: "assistant",
@@ -193,8 +193,9 @@ describe("agent stage stream integration", () => {
 
     const html = renderToStaticMarkup(<ChatMessage message={message} />)
 
-    expect(html).toContain("思考过程")
+    expect(html).toContain("读取章节")
     expect(html).toContain("完成。")
+    expect(html).toContain("耗时")
   })
 })
 

+ 36 - 158
src/components/chat/chat-message.tsx

@@ -47,6 +47,10 @@ import {
 import { detectLanguage } from "@/lib/detect-language";
 import { getHtmlLang, getTextDirection } from "@/lib/language-metadata";
 import { MermaidDiagram, unwrapMermaidPre } from "@/components/mermaid-diagram";
+import { highlightCode } from "@/lib/streaming-code-highlight";
+import { separateThinking } from "@/lib/separate-thinking";
+import { StreamingMarkdown } from "@/components/common/streaming-markdown";
+import { StreamingSpinner } from "@/components/common/streaming-spinner";
 import { canContinueUnfinishedDeepChapter } from "./chat-resume";
 import { getCopyableAssistantContent } from "@/lib/chat-copy-content";
 
@@ -114,6 +118,17 @@ export function ChatMessage({
       message.contextTrace.contextInfo),
   );
 
+  // 仅对最后一条流式助手消息提取 thinking,避免历史消息重复提取
+  const isLastStreamingAssistant = isLastAssistant && isAssistant;
+  const { thinking: extractedThinking } = useMemo(
+    () =>
+      isLastStreamingAssistant
+        ? separateThinking(message.content)
+        : { thinking: null, answer: message.content },
+    [message.content, isLastStreamingAssistant],
+  );
+  const thinkingStreaming = isLastStreamingAssistant && !!message.isAgentRunning;
+
   return (
     <div
       className={`flex w-full min-w-0 gap-2 ${isUser ? "flex-row-reverse" : "flex-row"}`}
@@ -166,6 +181,8 @@ export function ChatMessage({
                 <AgentToolCallMessage
                   toolCalls={message.agentToolCalls}
                   contextTrace={message.contextTrace}
+                  thinkingContent={extractedThinking ?? undefined}
+                  thinkingStreaming={thinkingStreaming}
                   onConfirmSave={onConfirmToolSave}
                   onReject={onRejectTool}
                 />
@@ -689,9 +706,10 @@ function extractCitedPages(text: string): CitedPage[] {
 
 interface StreamingMessageProps {
   content: string;
+  isStreaming?: boolean;
 }
 
-export function StreamingMessage({ content }: StreamingMessageProps) {
+export function StreamingMessage({ content, isStreaming = true }: StreamingMessageProps) {
   const { thinking, answer } = useMemo(
     () => separateThinking(content),
     [content],
@@ -709,8 +727,11 @@ export function StreamingMessage({ content }: StreamingMessageProps) {
         ) : (
           <>
             {thinking && <WorkflowBlock content={thinking} />}
-            <MarkdownContent content={answer} />
-            <span className="animate-pulse">▊</span>
+            <StreamingMarkdown
+              content={answer}
+              isStreaming={isStreaming}
+              renderCommitted={(text) => <MarkdownContent content={text} />}
+            />
           </>
         )}
       </div>
@@ -884,6 +905,17 @@ function MarkdownContent({ content }: { content: string }) {
               if (lang === "mermaid") {
                 return <MermaidDiagram code={codeText} />;
               }
+              if (lang && lang !== "text" && lang !== "plain") {
+                const highlighted = highlightCode(codeText, lang);
+                return (
+                  <code
+                    dir="ltr"
+                    className={`${className ?? ""} code-highlight`}
+                    dangerouslySetInnerHTML={{ __html: highlighted }}
+                    {...props}
+                  />
+                );
+              }
               return (
                 <code dir="ltr" className={className} {...props}>
                   {children}
@@ -899,54 +931,6 @@ function MarkdownContent({ content }: { content: string }) {
   );
 }
 
-/**
- * 检测文本是否主要是LLM英文思考内容(应隐藏)
- * 特征:主要是英文,包含典型推理词汇,中文字符极少
- */
-function isLlmEnglishThinking(text: string): boolean {
-  const trimmed = text.trim();
-  if (!trimmed) return false;
-
-  // 统计中文字符数
-  const chineseChars = (trimmed.match(/[\u4e00-\u9fff]/g) || []).length;
-  const totalChars = trimmed.length;
-  const chineseRatio = chineseChars / Math.max(totalChars, 1);
-
-  // 如果中文字符占比超过15%,认为是中文内容(工作流信息),保留
-  if (chineseRatio > 0.15) return false;
-
-  // 检测典型的LLM英文思考关键词
-  const thinkingKeywords = [
-    "thinking process",
-    "let's think",
-    "let me think",
-    "analyze",
-    "let's write",
-    "wait,",
-    "word count",
-    "constraints",
-    "note:",
-    "check:",
-    "strategy",
-    "i need to",
-    "i should",
-    "first,",
-    "okay,",
-    "so,",
-    "now,",
-    "the user",
-    "based on",
-    "according to",
-  ];
-  const lower = trimmed.toLowerCase();
-  const keywordCount = thinkingKeywords.filter((kw) =>
-    lower.includes(kw),
-  ).length;
-
-  // 如果包含2个以上思考关键词且中文很少,判定为LLM英文思考
-  return keywordCount >= 2;
-}
-
 /**
  * 检测一行是否是工作流阶段标题
  */
@@ -959,112 +943,6 @@ function isWorkflowStageHeader(line: string): boolean {
   return false;
 }
 
-/**
- * 过滤思考块内容,保留中文工作流阶段信息,隐藏LLM英文思考
- */
-function filterThinkingContent(thinking: string): string | null {
-  const lines = thinking.split("\n");
-  const resultLines: string[] = [];
-  let currentStageHeader: string | null = null;
-  let currentStageContent: string[] = [];
-
-  const flushStage = () => {
-    if (currentStageHeader) {
-      resultLines.push(currentStageHeader);
-      const contentText = currentStageContent.join("\n").trim();
-      if (contentText) {
-        // 检查内容是否是LLM英文思考
-        if (!isLlmEnglishThinking(contentText)) {
-          resultLines.push(...currentStageContent);
-        }
-      }
-      resultLines.push("");
-    } else if (currentStageContent.length > 0) {
-      // 没有阶段标题的独立内容块
-      const blockText = currentStageContent.join("\n").trim();
-      if (blockText && !isLlmEnglishThinking(blockText)) {
-        resultLines.push(...currentStageContent);
-        resultLines.push("");
-      }
-    }
-    currentStageHeader = null;
-    currentStageContent = [];
-  };
-
-  for (const line of lines) {
-    if (isWorkflowStageHeader(line)) {
-      // 新的阶段开始,先flush之前的
-      flushStage();
-      currentStageHeader = line;
-    } else if (currentStageHeader) {
-      // 当前正在收集阶段内容
-      currentStageContent.push(line);
-    } else {
-      // 不在阶段内的内容,按段落处理
-      currentStageContent.push(line);
-      // 空行表示段落结束
-      if (!line.trim()) {
-        flushStage();
-      }
-    }
-  }
-  // flush最后一个阶段
-  flushStage();
-
-  const result = resultLines.join("\n").trim();
-  return result || null;
-}
-
-/**
- * Separate <think>...</think> blocks from the main answer.
- * Handles multiple think blocks and partial (unclosed) thinking during streaming.
- * 保留工作流阶段提示(中文),过滤LLM英文思考内容。
- */
-function separateThinking(text: string): {
-  thinking: string | null;
-  answer: string;
-} {
-  // Match complete <think>...</think> and <thinking>...</thinking> blocks
-  const thinkRegex = /<think(?:ing)?>([\s\S]*?)<\/think(?:ing)?>/gi;
-  const thinkParts: string[] = [];
-  let answer = text;
-
-  let match: RegExpExecArray | null;
-  while ((match = thinkRegex.exec(text)) !== null) {
-    thinkParts.push(match[1].trim());
-  }
-  answer = answer
-    .replace(/<think(?:ing)?>[\s\S]*?<\/think(?:ing)?>/gi, "")
-    .trim();
-
-  // Handle unclosed <think> or <thinking> tag (streaming in progress)
-  const unclosedMatch = answer.match(/<think(?:ing)?>([\s\S]*)$/i);
-  if (unclosedMatch) {
-    thinkParts.push(unclosedMatch[1].trim());
-    answer = answer.replace(/<think(?:ing)?>[\s\S]*$/i, "").trim();
-  }
-
-  // 处理只有结尾 </think> 标签的情况(没有开头标签)
-  const firstCloseIndex = answer.search(/<\/think(?:ing)?>/i);
-  if (firstCloseIndex >= 0) {
-    const beforeClose = answer.slice(0, firstCloseIndex);
-    if (!/<think(?:ing)?>/i.test(beforeClose)) {
-      const thinkingContent = beforeClose.trim();
-      if (thinkingContent) {
-        thinkParts.push(thinkingContent);
-      }
-      answer = answer.replace(/^[\s\S]*?<\/think(?:ing)?>\s*/i, "");
-    }
-  }
-
-  const rawThinking = thinkParts.length > 0 ? thinkParts.join("\n\n") : null;
-  const filteredThinking = rawThinking
-    ? filterThinkingContent(rawThinking)
-    : null;
-
-  return { thinking: filteredThinking, answer: answer.trim() };
-}
-
 function countWorkflowStages(content: string): number {
   return content.split("\n").filter((line) => isWorkflowStageHeader(line)).length
 }
@@ -1108,7 +986,7 @@ function StreamingWorkflowBlock({ content }: { content: string }) {
       </div>
       <div className="w-full min-w-0 max-h-72 overflow-y-auto overflow-x-hidden pr-1 text-xs text-blue-800/70 dark:text-blue-300/60 leading-relaxed whitespace-pre-wrap [overflow-wrap:anywhere]">
         {displayContent}
-        <span className="animate-pulse text-blue-500">▊</span>
+        <span className="text-blue-500"><StreamingSpinner /></span>
       </div>
     </div>
   );

+ 149 - 0
src/components/chat/chat-panel.mount.spec.tsx

@@ -3,6 +3,9 @@ import { act } from "react"
 import { describe, expect, it, vi } from "vitest"
 import { renderChatPanel } from "@/test/chat-panel-mount"
 import { clearTaskBreakpoint, loadTaskBreakpoint } from "@/lib/agent/task-breakpoint"
+import type { Conversation } from "@/stores/chat-store"
+import { useChatStore } from "@/stores/chat-store"
+import { chatConversationRunRegistry } from "@/lib/conversation-run-registry"
 
 const breakpoint = {
   taskId: "task_resume_1",
@@ -23,7 +26,49 @@ async function flushEffects() {
   })
 }
 
+function conversation(id: string, title: string, updatedAt: number, inputDraft = ""): Conversation {
+  return { id, title, createdAt: updatedAt, updatedAt, deAiMode: false, inputDraft }
+}
+
+async function click(element: Element | null) {
+  expect(element).not.toBeNull()
+  await act(async () => {
+    element?.dispatchEvent(new MouseEvent("click", { bubbles: true }))
+  })
+}
+
 describe("ChatPanel mount 基础设施", () => {
+  it("根据当前会话是否已发送用户消息控制新建对话按钮", async () => {
+    const first = await renderChatPanel()
+    expect((first.container.querySelector(".qmai-new-conversation-button") as HTMLButtonElement).disabled).toBe(false)
+    await first.unmount()
+
+    const empty = await renderChatPanel({ activeConversationId: "empty" })
+    const emptyButton = empty.container.querySelector(".qmai-new-conversation-button") as HTMLButtonElement
+    expect(emptyButton.disabled).toBe(true)
+    expect(emptyButton.title).toBe("请先发送当前会话内容,再新建对话。")
+    const reasonId = emptyButton.getAttribute("aria-describedby")
+    expect(reasonId).toBeTruthy()
+    expect(empty.container.querySelector(`#${reasonId}`)?.textContent).toBe(
+      "请先发送当前会话内容,再新建对话。",
+    )
+    await empty.unmount()
+
+    const draft = await renderChatPanel({
+      activeConversationId: "draft",
+      conversations: [conversation("draft", "草稿", 100, "仅填写未发送")],
+    })
+    expect((draft.container.querySelector(".qmai-new-conversation-button") as HTMLButtonElement).disabled).toBe(true)
+    await draft.unmount()
+
+    const sent = await renderChatPanel({
+      activeConversationId: "sent",
+      messages: [{ id: "user-1", role: "user", content: "已发送内容", timestamp: 1, conversationId: "sent" }],
+    })
+    expect((sent.container.querySelector(".qmai-new-conversation-button") as HTMLButtonElement).disabled).toBe(false)
+    await sent.unmount()
+  })
+
   it("基础渲染:chat-panel mount 成功并显示空会话入口", async () => {
     const view = await renderChatPanel()
 
@@ -78,6 +123,110 @@ describe("ChatPanel mount 基础设施", () => {
     await view.unmount()
   })
 
+  it("在顶部会话标签显示逐会话运行状态", async () => {
+    const view = await renderChatPanel({
+      activeConversationId: "conv-active",
+      conversations: [
+        conversation("conv-active", "当前会话", 400),
+        conversation("conv-running", "生成中会话", 300),
+      ],
+      runStates: {
+        "conv-running": { status: "running", updatedAt: 500, runId: "run-running" },
+      },
+    })
+
+    expect(view.container.querySelector('[aria-label="正在生成"]')).not.toBeNull()
+    await view.unmount()
+  })
+
+  it("普通会话达到三任务并发上限时只禁止发送", async () => {
+    const view = await renderChatPanel({
+      activeConversationId: "conv-draft",
+      conversations: [
+        conversation("conv-a", "任务 A", 400),
+        conversation("conv-b", "任务 B", 300),
+        conversation("conv-c", "任务 C", 200),
+        conversation("conv-draft", "草稿会话", 100, "仍可编辑的草稿"),
+      ],
+      runStates: {
+        "conv-a": { status: "running", updatedAt: 500, runId: "run-a" },
+        "conv-b": { status: "running", updatedAt: 501, runId: "run-b" },
+        "conv-c": { status: "running", updatedAt: 502, runId: "run-c" },
+      },
+    })
+
+    const input = view.container.querySelector('[aria-label="引用输入框"]') as HTMLTextAreaElement | null
+    const send = view.container.querySelector('[aria-label="发送消息"]') as HTMLButtonElement | null
+    expect(input?.disabled).toBe(false)
+    expect(send?.disabled).toBe(true)
+    expect(send?.title).toBe("普通 AI 会话最多同时运行 3 个任务,请等待任一任务结束后再发送。")
+    await view.unmount()
+  })
+
+  it("停止当前会话不会中止其他正在运行的会话", async () => {
+    const currentController = new AbortController()
+    const backgroundController = new AbortController()
+    chatConversationRunRegistry.register("conv-current", currentController)
+    chatConversationRunRegistry.register("conv-background", backgroundController)
+    const view = await renderChatPanel({
+      activeConversationId: "conv-current",
+      conversations: [
+        conversation("conv-current", "当前任务", 200),
+        conversation("conv-background", "后台任务", 100),
+      ],
+      runStates: {
+        "conv-current": { status: "running", updatedAt: 300, runId: "run-current" },
+        "conv-background": { status: "running", updatedAt: 301, runId: "run-background" },
+      },
+    })
+    await act(async () => {
+      useChatStore.setState({
+        streamingContents: { "conv-current": "部分内容", "conv-background": "后台内容" },
+        messages: [{
+          id: "assistant-running",
+          role: "assistant",
+          content: "部分内容",
+          timestamp: 300,
+          conversationId: "conv-current",
+          isAgentRunning: true,
+        }],
+      })
+    })
+
+    await click(view.container.querySelector('[aria-label="停止生成"]'))
+
+    expect(currentController.signal.aborted).toBe(true)
+    expect(backgroundController.signal.aborted).toBe(false)
+    expect(useChatStore.getState().runStates["conv-current"]?.status).toBe("idle")
+    expect(useChatStore.getState().runStates["conv-background"]?.status).toBe("running")
+    const currentMessages = useChatStore.getState().messages.filter((message) => message.conversationId === "conv-current")
+    expect(currentMessages).toHaveLength(1)
+    expect(currentMessages[0]).toMatchObject({ isAgentRunning: false })
+    expect(currentMessages[0].content).toContain("已停止生成。")
+    expect(currentMessages[0].content).not.toContain("出错")
+    chatConversationRunRegistry.abort("conv-background")
+    await view.unmount()
+  })
+
+  it("删除运行中会话时先显示中文确认,取消后不删除", async () => {
+    const view = await renderChatPanel({
+      activeConversationId: "conv-running",
+      conversations: [conversation("conv-running", "运行中会话", 100)],
+      runStates: {
+        "conv-running": { status: "running", updatedAt: 200, runId: "run-delete" },
+      },
+    })
+    const chip = view.container.querySelector('button[title="运行中会话"]')
+    await act(async () => {
+      chip?.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }))
+    })
+    await click(view.container.querySelector('[aria-label="删除会话"]'))
+    expect(document.body.textContent).toContain("停止并删除会话?")
+    await click(Array.from(document.body.querySelectorAll("button")).find((button) => button.textContent === "取消") ?? null)
+    expect(useChatStore.getState().conversations.some((item) => item.id === "conv-running")).toBe(true)
+    await view.unmount()
+  })
+
   it.todo("PrePlugin 链触发:发送消息后 pipeline 执行")
   it.todo("Stage C 对话框:standard 模式 chapter_plan 触发对话框")
   it.todo("Stage C 跳过:fast 模式直接生成正文")

+ 18 - 19
src/components/chat/chat-panel.spec.tsx

@@ -303,14 +303,11 @@ describe("chat-panel agent reference integration", () => {
     expect(source).not.toContain("await streamChat(")
   })
 
-  it("clears confirmed chapter blueprint with finally after followup send", () => {
-    const sendIndex = source.indexOf('handleSendRef.current(followupText, [], "执行已确认计划")')
-    const clearIndex = source.indexOf("confirmedBlueprintRef.current = null", sendIndex)
-    const finallyIndex = source.lastIndexOf("finally", clearIndex)
-
-    expect(sendIndex).toBeGreaterThan(-1)
-    expect(clearIndex).toBeGreaterThan(sendIndex)
-    expect(finallyIndex).toBeGreaterThan(sendIndex)
+  it("passes the confirmed blueprint and captured conversation to the scoped followup run", () => {
+    expect(source).toContain(
+      'handleSendRef.current(followupText, [], "执行已确认计划", confirmedBlueprint, capturedConvId)',
+    )
+    expect(source).not.toContain("confirmedBlueprintRef")
   })
 
   it("routes continue-unfinished through the ReAct send path with compact display text", () => {
@@ -359,7 +356,8 @@ describe("chat-panel chapter plan confirm integration (Stage C)", () => {
     expect(source).toContain("planContent")
     expect(source).toContain("fullContent")
     expect(source).toContain("conversationId")
-    expect(source).toContain("chapterPlanResolverRef")
+    expect(source).toContain("pendingChapterPlans")
+    expect(source).toContain("chapterPlanResolversRef")
   })
 
   it("provides closeChapterPlanDialog and requestChapterPlanConfirm helpers", () => {
@@ -408,7 +406,8 @@ describe("chat-panel chapter plan confirm integration (Stage C)", () => {
 
     const afterPlanConfirm = source.slice(planConfirmIndex, followupIndex)
     expect(afterPlanConfirm).not.toContain("streamSessionGuardRef.current.isActive")
-    expect(afterPlanConfirm).toContain("setActiveConversation(capturedConvId)")
+    expect(afterPlanConfirm).not.toContain("setActiveConversation(capturedConvId)")
+    expect(afterPlanConfirm).toContain("capturedConvId")
   })
 
   it("disables Plan Execute protocol for confirmed plan follow-up messages and fast mode", () => {
@@ -419,15 +418,15 @@ describe("chat-panel chapter plan confirm integration (Stage C)", () => {
 
   it("renders ChapterPlanConfirmDialog in JSX bound to pendingChapterPlan", () => {
     expect(source).toContain("<ChapterPlanConfirmDialog")
-    expect(source).toContain("pendingChapterPlan.open")
+    expect(source).toContain("{pendingChapterPlan &&")
     expect(source).toContain("pendingChapterPlan.planContent")
   })
 
   it("wires dialog actions to closeChapterPlanDialog", () => {
-    expect(source).toContain('closeChapterPlanDialog("confirm")')
-    expect(source).toContain('closeChapterPlanDialog("skip")')
-    expect(source).toContain('closeChapterPlanDialog("cancel")')
-    expect(source).toContain('closeChapterPlanDialog({ modify:')
+    expect(source).toContain('closeChapterPlanDialog(pendingChapterPlan.conversationId, "confirm")')
+    expect(source).toContain('closeChapterPlanDialog(pendingChapterPlan.conversationId, "skip")')
+    expect(source).toContain('closeChapterPlanDialog(pendingChapterPlan.conversationId, "cancel")')
+    expect(source).toContain('closeChapterPlanDialog(pendingChapterPlan.conversationId, { modify:')
   })
 
   it("records a visible cancellation when the chapter plan dialog is cancelled", () => {
@@ -521,14 +520,14 @@ describe("aiWorkflowMode store 读取", () => {
 
 describe("resolver 卸载清理", () => {
   it("useEffect 卸载钩子中清理章节计划 pending resolver", () => {
-    expect(source).toMatch(/chapterPlanResolverRef\.current = null/)
-    expect(source).toMatch(/return \(\) => \{[\s\S]*?chapterPlanResolverRef\.current/)
+    expect(source).toContain("Object.values(chapterPlanResolversRef.current)")
+    expect(source).toContain("chapterPlanResolversRef.current = {}")
   })
 })
 
 describe("章节计划弹窗输入框禁用一致性", () => {
-  it("pendingChapterPlan.open 时禁用主输入框", () => {
-    expect(source).toMatch(/disabled=\{isStreaming \|\| pendingChapterPlan\.open\}/)
+  it("当前会话存在 pendingChapterPlan 时禁用主输入框", () => {
+    expect(source).toMatch(/disabled=\{isStreaming \|\| Boolean\(pendingChapterPlan\)\}/)
     expect(source).not.toContain("pendingSoulDialog.open")
   })
 })

+ 296 - 127
src/components/chat/chat-panel.tsx

@@ -1,12 +1,13 @@
 import { useRef, useEffect, useCallback, useState, useMemo, type CSSProperties } from "react"
 import { createPortal } from "react-dom"
 import { useTranslation } from "react-i18next"
-import { BookOpen, Plus, Trash2, MessageSquare, FileEdit, Drama, ListChecks, ChevronDown, Check, History } from "lucide-react"
+import { BookOpen, Plus, Trash2, MessageSquare, FileEdit, Drama, ListChecks, ChevronDown, Check, History, ArrowDown } from "lucide-react"
 import { Button } from "@/components/ui/button"
 import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
 import { ChatMessage, StreamingMessage } from "./chat-message"
 import { ChatModelSelector } from "./chat-model-selector"
 import { useSourceFiles } from "./chat-shared"
+import { useStreamingText } from "@/hooks/use-streaming-text"
 import {
   ChapterPlanConfirmDialog,
   extractChapterPlan,
@@ -15,12 +16,17 @@ import {
   isChapterPlanExecutionFollowup,
 } from "./chapter-plan-confirm-dialog"
 import { useChatStore, type DisplayMessage } from "@/stores/chat-store"
+import { useShallow } from "zustand/react/shallow"
 import { useOutlineChatStore } from "@/stores/outline-chat-store"
 import { useWikiStore } from "@/stores/wiki-store"
 import { useStorySimulationStore } from "@/stores/story-simulation-store"
 import { DeAiSkillPicker } from "@/components/skill-library/de-ai-skill-picker"
 import { ReferenceInput, type InsertReferenceTokens } from "@/components/reference/ReferenceInput"
 import { ReferencePickerDialog } from "@/components/reference/ReferencePickerDialog"
+import { ConversationRunStatusIcon } from "@/components/common/conversation-run-status-icon"
+import { ConversationDeleteConfirmDialog } from "@/components/common/conversation-delete-confirm-dialog"
+import { chatConversationRunRegistry } from "@/lib/conversation-run-registry"
+import { toast } from "@/lib/toast"
 import {
   chapterProvider,
   createChatHistoryProvider,
@@ -32,6 +38,7 @@ import {
 } from "@/lib/reference/providers"
 import type { ReferenceToken } from "@/lib/reference/types"
 import { runAiChatSession } from "@/lib/agent/ai-chat-session"
+import { ToolRegistry } from "@/lib/agent/registry"
 import {
   runChapterPlanRevision as runChapterPlanRevisionModel,
   runChapterPlanSelfCheck as runChapterPlanSelfCheckModel,
@@ -65,6 +72,10 @@ import {
   getConversationTabTitle,
   splitConversationToolbarItems,
 } from "@/lib/workspace-layout"
+import {
+  canCreateNewConversation,
+  EMPTY_CONVERSATION_CREATE_REASON,
+} from "@/lib/conversation-create-guard"
 import { saveAiChatModel } from "@/lib/project-store"
 import {
   buildGoldenThreeChapterDirective,
@@ -416,16 +427,18 @@ function recordChapterPlanExecutionCancelled(messageId: string): void {
   }))
 }
 
-function ConversationTabs({ onAbortStream }: { onAbortStream: (convId: string) => void }) {
+function ConversationTabs({ onBeforeDelete }: { onBeforeDelete: (conversationId: string) => void }) {
   const { t } = useTranslation()
   const novelMode = useWikiStore((s) => s.novelMode)
   const conversations = useChatStore((s) => s.conversations)
   const activeConversationId = useChatStore((s) => s.activeConversationId)
   const messages = useChatStore((s) => s.messages)
-  const streamingContents = useChatStore((s) => s.streamingContents)
+  const runStates = useChatStore((s) => s.runStates)
   const createConversation = useChatStore((s) => s.createConversation)
   const deleteConversation = useChatStore((s) => s.deleteConversation)
   const setActiveConversation = useChatStore((s) => s.setActiveConversation)
+  const stopConversationRun = useChatStore((s) => s.stopConversationRun)
+  const clearStreaming = useChatStore((s) => s.clearStreaming)
 
   const [hoveredId, setHoveredId] = useState<string | null>(null)
   const [historyOpen, setHistoryOpen] = useState(false)
@@ -433,14 +446,24 @@ function ConversationTabs({ onAbortStream }: { onAbortStream: (convId: string) =
   const historyButtonRef = useRef<HTMLButtonElement | null>(null)
   const historyDropdownRef = useRef<HTMLDivElement | null>(null)
   const [historyDropdownStyle, setHistoryDropdownStyle] = useState<CSSProperties | null>(null)
+  const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null)
 
-  const isStreamingConversation = (convId: string) => convId in streamingContents
+  const isStreamingConversation = (convId: string) => runStates[convId]?.status === "running"
   const { topConversations, historyConversations } = splitConversationToolbarItems(
     conversations,
     activeConversationId,
     isStreamingConversation,
   )
   const historyCount = historyConversations.length
+  const hasSentUserMessage = activeConversationId
+    ? messages.some((message) =>
+        message.conversationId === activeConversationId && message.role === "user",
+      )
+    : false
+  const canCreateConversation = canCreateNewConversation(
+    activeConversationId,
+    hasSentUserMessage,
+  )
 
   // 点击历史记录浮层外部关闭
   useEffect(() => {
@@ -525,8 +548,12 @@ function ConversationTabs({ onAbortStream }: { onAbortStream: (convId: string) =
     return messages.filter((m) => m.conversationId === convId).length
   }
 
-  function handleDeleteConversation(convId: string) {
-    onAbortStream(convId)
+  function deleteConversationNow(convId: string) {
+    onBeforeDelete(convId)
+    const runId = useChatStore.getState().runStates[convId]?.runId
+    chatConversationRunRegistry.abort(convId)
+    stopConversationRun(convId, runId)
+    clearStreaming(convId)
     deleteConversation(convId)
     const proj = useWikiStore.getState().project
     if (proj) {
@@ -534,59 +561,83 @@ function ConversationTabs({ onAbortStream }: { onAbortStream: (convId: string) =
     }
   }
 
+  function handleDeleteConversation(convId: string) {
+    if (runStates[convId]?.status === "running") {
+      setPendingDeleteId(convId)
+      return
+    }
+    deleteConversationNow(convId)
+  }
+
   function renderConversationChip(conv: { id: string; title: string; updatedAt: number }) {
     const isActive = conv.id === activeConversationId
-    const isThisStreaming = conv.id in streamingContents
+    const runState = runStates[conv.id]
     const msgCount = getMessageCount(conv.id)
     return (
-      <button
+      <div
         key={conv.id}
-        type="button"
-        className={`group flex shrink-0 items-center gap-2 rounded-full border px-3 py-1.5 text-xs transition-colors ${
+        className={`group flex shrink-0 items-center rounded-full border px-1 text-xs transition-colors ${
           isActive
             ? "border-primary/40 bg-background text-foreground shadow-sm"
             : "border-border bg-background/70 text-muted-foreground hover:bg-accent hover:text-foreground"
         }`}
-        onClick={() => setActiveConversation(conv.id)}
         onMouseEnter={() => setHoveredId(conv.id)}
         onMouseLeave={() => setHoveredId(null)}
-        title={conv.title}
       >
-        {isThisStreaming && <span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-blue-500" />}
-        <span className="max-w-[140px] truncate font-medium">
-          {getConversationTabTitle(conv.title, 10)}
-        </span>
-        <span className="text-[10px] opacity-70">{msgCount}</span>
-        <span className="text-[10px] opacity-70">{formatDate(conv.updatedAt)}</span>
-        {hoveredId === conv.id && (
-          <span
-            className="rounded p-0.5 text-muted-foreground hover:text-destructive"
-            onClick={(e) => {
-              e.stopPropagation()
-              handleDeleteConversation(conv.id)
-            }}
-          >
-            <Trash2 className="h-3 w-3" />
-          </span>
-        )}
-      </button>
+        <button
+          type="button"
+          className="flex items-center gap-2 rounded-full px-2 py-1.5"
+          onClick={() => setActiveConversation(conv.id)}
+          title={conv.title}
+        >
+          <ConversationRunStatusIcon state={runState} />
+          <span className="max-w-[140px] truncate font-medium">{getConversationTabTitle(conv.title, 10)}</span>
+          <span className="text-[10px] opacity-70">{msgCount}</span>
+          <span className="text-[10px] opacity-70">{formatDate(conv.updatedAt)}</span>
+        </button>
+        <button
+          type="button"
+          className={`rounded p-0.5 text-muted-foreground hover:text-destructive focus:opacity-100 ${hoveredId === conv.id ? "opacity-100" : "opacity-0 group-focus-within:opacity-100"}`}
+          aria-label="删除会话"
+          onClick={() => handleDeleteConversation(conv.id)}
+        >
+          <Trash2 className="h-3 w-3" />
+        </button>
+      </div>
     )
   }
 
   // 顶部统一为三段式:新建写作绘画 / 正在工作的绘画 / 绘画历史记录
   return (
+    <>
     <div className="flex h-12 shrink-0 items-center gap-2 border-b bg-muted/20 px-2">
         {/* 1. 新建写作绘画 */}
-        <Button
-          variant="ghost"
-          size="icon-sm"
-          className="qmai-new-conversation-button shrink-0 rounded-full border border-emerald-300 bg-emerald-50 text-emerald-700 shadow-sm hover:bg-emerald-100 hover:text-emerald-800 dark:border-emerald-800/70 dark:bg-emerald-950/40 dark:text-emerald-300 dark:hover:bg-emerald-900/50"
-          onClick={() => createConversation()}
-          title={t(novelMode ? "novel.chat.newChat" : "chat.newChat")}
-          aria-label={t(novelMode ? "novel.chat.newChat" : "chat.newChat")}
+        <span
+          className="inline-flex shrink-0"
+          title={!canCreateConversation ? EMPTY_CONVERSATION_CREATE_REASON : undefined}
         >
-          <Plus className="h-3.5 w-3.5" />
-        </Button>
+          <Button
+            variant="ghost"
+            size="icon-sm"
+            className="qmai-new-conversation-button shrink-0 rounded-full border border-emerald-300 bg-emerald-50 text-emerald-700 shadow-sm hover:bg-emerald-100 hover:text-emerald-800 dark:border-emerald-800/70 dark:bg-emerald-950/40 dark:text-emerald-300 dark:hover:bg-emerald-900/50"
+            onClick={() => createConversation()}
+            disabled={!canCreateConversation}
+            title={canCreateConversation
+              ? t(novelMode ? "novel.chat.newChat" : "chat.newChat")
+              : EMPTY_CONVERSATION_CREATE_REASON}
+            aria-label={t(novelMode ? "novel.chat.newChat" : "chat.newChat")}
+            aria-describedby={!canCreateConversation
+              ? "chat-new-conversation-disabled-reason"
+              : undefined}
+          >
+            <Plus className="h-3.5 w-3.5" />
+          </Button>
+          {!canCreateConversation && (
+            <span id="chat-new-conversation-disabled-reason" className="sr-only">
+              {EMPTY_CONVERSATION_CREATE_REASON}
+            </span>
+          )}
+        </span>
 
         {/* 2. 正在工作的绘画:当前、生成中和今日保留项,最多显示三个 */}
         <div className="flex min-w-0 flex-1 items-center overflow-hidden">
@@ -642,6 +693,15 @@ function ConversationTabs({ onAbortStream }: { onAbortStream: (convId: string) =
             )}
         </div>
     </div>
+    <ConversationDeleteConfirmDialog
+      open={pendingDeleteId !== null}
+      onCancel={() => setPendingDeleteId(null)}
+      onConfirm={() => {
+        if (pendingDeleteId) deleteConversationNow(pendingDeleteId)
+        setPendingDeleteId(null)
+      }}
+    />
+    </>
   )
 }
 
@@ -650,12 +710,12 @@ export function ChatPanel() {
   useSourceFiles() // Keep source file cache warm
   const activeConversationId = useChatStore((s) => s.activeConversationId)
   const streamingContents = useChatStore((s) => s.streamingContents)
+  const runStates = useChatStore((s) => s.runStates)
   const mode = useChatStore((s) => s.mode)
   const startStreaming = useChatStore((s) => s.startStreaming)
   const finalizeStream = useChatStore((s) => s.finalizeStream)
   const clearStreaming = useChatStore((s) => s.clearStreaming)
   const createConversation = useChatStore((s) => s.createConversation)
-  const setActiveConversation = useChatStore((s) => s.setActiveConversation)
   const removeLastAssistantMessage = useChatStore((s) => s.removeLastAssistantMessage)
   const maxHistoryMessages = useChatStore((s) => s.maxHistoryMessages)
   const isConversationStreaming = useChatStore((s) => s.isConversationStreaming)
@@ -666,10 +726,19 @@ export function ChatPanel() {
   const consumePendingReferenceTokens = useChatStore((s) => s.consumePendingReferenceTokens)
   const outlineConversations = useOutlineChatStore((s) => s.conversations)
   // Derive active messages via selector to re-render on message changes
-  const allMessages = useChatStore((s) => s.messages)
-  const activeMessages = activeConversationId
-    ? allMessages.filter((m) => m.conversationId === activeConversationId)
-    : []
+  // 使用 useShallow 避免过滤数组返回新引用导致 useSyncExternalStore 无限重渲染(Zustand v5)
+  const activeMessages = useChatStore(
+    useShallow((s) =>
+      activeConversationId ? s.messages.filter((m) => m.conversationId === activeConversationId) : [],
+    ),
+  )
+  // 预计算最后一条 assistant 消息的索引,O(n) 而非 O(n²)
+  const lastAssistantIndex = useMemo(() => {
+    for (let i = activeMessages.length - 1; i >= 0; i--) {
+      if (activeMessages[i].role === "assistant") return i
+    }
+    return -1
+  }, [activeMessages])
   const activeConversation = activeConversationId
     ? conversations.find((conversation) => conversation.id === activeConversationId) ?? null
     : null
@@ -678,6 +747,10 @@ export function ChatPanel() {
   const streamingContent = activeConversationId ? streamingContents[activeConversationId] ?? "" : ""
   // 当前活跃会话是否正在流式生成
   const isStreaming = activeConversationId ? isConversationStreaming(activeConversationId) : false
+  const concurrencyFull = !isStreaming && Object.values(runStates).filter((state) => state.status === "running").length >= 3
+  const concurrencyLimitReason = "普通 AI 会话最多同时运行 3 个任务,请等待任一任务结束后再发送。"
+  // 对齐 Zed StreamingTextBuffer:16ms tick 逐步揭示,平滑打字机效果
+  const batchedStreamingContent = useStreamingText(streamingContent, isStreaming)
 
   const project = useWikiStore((s) => s.project)
   const projectPath = project?.path ? normalizePath(project.path) : ""
@@ -691,13 +764,13 @@ export function ChatPanel() {
   const setChatEditModeEnabled = useWikiStore((s) => s.setChatEditModeEnabled)
   const selectedFile = useWikiStore((s) => s.selectedFile)
 
-  const abortControllersRef = useRef<Record<string, AbortController>>({})
   const streamSessionGuardRef = useRef(createStreamSessionGuard())
   const activeStreamSessionsRef = useRef<Record<string, number>>({})
   const scrollContainerRef = useRef<HTMLDivElement>(null)
   const bottomRef = useRef<HTMLDivElement>(null)
   const userScrolledUpRef = useRef(false)
   const lastScrollTopRef = useRef(0)
+  const [showScrollToBottom, setShowScrollToBottom] = useState(false)
 
   const [chapterSaveStatus, setChapterSaveStatus] = useState<string>("")
   const [deAiSkillWarningMessage, setDeAiSkillWarningMessage] = useState<string>("")
@@ -826,9 +899,6 @@ export function ChatPanel() {
     ],
   )
   // 存储用户最近确认的章节计划,供 run_chapter_workflow 兜底注入,不依赖模型是否自觉传参。
-  const confirmedBlueprintRef = useRef<string | null>(null)
-  const getConfirmedBlueprint = useCallback(() => confirmedBlueprintRef.current ?? undefined, [])
-  const chapterPlanContextRef = useRef<ContextPack | null>(null)
   const {
     config: agentConfig,
     registry: agentRegistry,
@@ -837,8 +907,8 @@ export function ChatPanel() {
     skillConfig: agentSkillConfig,
     writingSkills: agentUserWritingSkills,
     mcpCapabilities: agentMcpCapabilities,
-  } = useAgentConfig(agentSystemPrompt, getConfirmedBlueprint)
-  const runChapterPlanSelfCheck = useCallback(async (planContent: string) => {
+  } = useAgentConfig(agentSystemPrompt)
+  const runChapterPlanSelfCheck = useCallback(async (planContent: string, contextPack?: ContextPack | null) => {
     const trimmedPlan = planContent.trim()
     if (!trimmedPlan) {
       throw new Error("没有可自检的章节计划")
@@ -850,7 +920,7 @@ export function ChatPanel() {
     return runChapterPlanSelfCheckModel(
       agentConfig.llmConfig,
       trimmedPlan,
-      buildChapterPlanSelfCheckContext(chapterPlanContextRef.current),
+      buildChapterPlanSelfCheckContext(contextPack ?? null),
     )
   }, [agentConfig?.llmConfig])
   const runChapterPlanRevision = useCallback(async (planContent: string, selfCheckResult: string) => {
@@ -906,21 +976,25 @@ export function ChatPanel() {
     [agentSkillConfig, conversations, outlineConversations],
   )
   // === Stage C: 章节计划确认 ===
-  const [pendingChapterPlan, setPendingChapterPlan] = useState<{
-    open: boolean
+  const [pendingChapterPlans, setPendingChapterPlans] = useState<Record<string, {
     planContent: string
     fullContent: string
     conversationId: string
-  }>({ open: false, planContent: "", fullContent: "", conversationId: "" })
-  const chapterPlanResolverRef = useRef<((action: "confirm" | "skip" | "cancel" | { modify: string }) => void) | null>(null)
-  const handleSendRef = useRef<(text: string, tokens?: ReferenceToken[], displayText?: string) => Promise<void>>(() => Promise.resolve())
+    contextPack?: ContextPack | null
+  }>>({})
+  const pendingChapterPlan = activeConversationId ? pendingChapterPlans[activeConversationId] : undefined
+  const chapterPlanResolversRef = useRef<Record<string, (action: "confirm" | "skip" | "cancel" | { modify: string }) => void>>({})
+  const handleSendRef = useRef<(text: string, tokens?: ReferenceToken[], displayText?: string, planBlueprint?: string, targetConversationId?: string) => Promise<void>>(() => Promise.resolve())
   const lastWritingTaskRouteRef = useRef<Record<string, TaskRouteResult>>({})
 
   const closeChapterPlanDialog = useCallback(
-    (action: "confirm" | "skip" | "cancel" | { modify: string }) => {
-      const resolver = chapterPlanResolverRef.current
-      chapterPlanResolverRef.current = null
-      setPendingChapterPlan({ open: false, planContent: "", fullContent: "", conversationId: "" })
+    (conversationId: string, action: "confirm" | "skip" | "cancel" | { modify: string }) => {
+      const resolver = chapterPlanResolversRef.current[conversationId]
+      delete chapterPlanResolversRef.current[conversationId]
+      setPendingChapterPlans((plans) => {
+        const { [conversationId]: _, ...rest } = plans
+        return rest
+      })
       resolver?.(action)
     },
     [],
@@ -928,22 +1002,28 @@ export function ChatPanel() {
 
   useEffect(() => {
     return () => {
-      if (chapterPlanResolverRef.current) {
-        chapterPlanResolverRef.current("cancel")
-        chapterPlanResolverRef.current = null
-      }
+      Object.values(chapterPlanResolversRef.current).forEach((resolver) => resolver("cancel"))
+      chapterPlanResolversRef.current = {}
     }
   }, [])
 
   const requestChapterPlanConfirm = useCallback(
-    (planContent: string, fullContent: string, conversationId: string) => {
-      setPendingChapterPlan({ open: true, planContent, fullContent, conversationId })
+    (planContent: string, fullContent: string, conversationId: string, contextPack?: ContextPack | null) => {
+      setPendingChapterPlans((plans) => ({
+        ...plans,
+        [conversationId]: { planContent, fullContent, conversationId, contextPack },
+      }))
       return new Promise<"confirm" | "skip" | "cancel" | { modify: string }>((resolve) => {
-        chapterPlanResolverRef.current = resolve
+        chapterPlanResolversRef.current[conversationId] = resolve
       })
     },
     [],
   )
+  const cancelPendingChapterPlan = useCallback((conversationId: string) => {
+    if (chapterPlanResolversRef.current[conversationId]) {
+      closeChapterPlanDialog(conversationId, "cancel")
+    }
+  }, [closeChapterPlanDialog])
 
   const handleSaveAsChapter = useCallback(async (content: string) => {
     if (!project) return
@@ -1012,16 +1092,45 @@ export function ChatPanel() {
   // 聊天数据存在全局 Zustand store 中,切回来时仍可看到生成结果
   // 删除会话时会单独 abort 该会话的请求(见 abortConversationStream)
 
+  // 切换会话时重置滚动位置到顶部
+  const prevConvIdRef = useRef(activeConversationId)
+  useEffect(() => {
+    if (prevConvIdRef.current !== activeConversationId) {
+      prevConvIdRef.current = activeConversationId
+      const container = scrollContainerRef.current
+      if (container) {
+        container.scrollTop = 0
+        lastScrollTopRef.current = 0
+        userScrolledUpRef.current = false
+        setShowScrollToBottom(false)
+      }
+    }
+  }, [activeConversationId])
+
   // Auto-scroll to bottom when messages change or streaming content updates
   // But stop if user manually scrolled up
   useEffect(() => {
     const container = scrollContainerRef.current
     if (!container) return
     if (!userScrolledUpRef.current) {
-      container.scrollTop = container.scrollHeight
+      container.scrollTo({
+        top: container.scrollHeight,
+        behavior: "smooth",
+      })
       lastScrollTopRef.current = container.scrollTop
     }
-  }, [activeMessages, streamingContent])
+  }, [activeMessages, batchedStreamingContent])
+
+  const handleScrollToBottom = useCallback(() => {
+    const container = scrollContainerRef.current
+    if (!container) return
+    userScrolledUpRef.current = false
+    setShowScrollToBottom(false)
+    container.scrollTo({
+      top: container.scrollHeight,
+      behavior: "smooth",
+    })
+  }, [])
 
   // Detect user scroll: if user scrolls up, stop auto-scroll; if at bottom, resume
   useEffect(() => {
@@ -1037,11 +1146,12 @@ export function ChatPanel() {
       } else if (atBottom) {
         userScrolledUpRef.current = false
       }
+      setShowScrollToBottom(userScrolledUpRef.current && isStreaming)
       lastScrollTopRef.current = currentScrollTop
     }
     container.addEventListener("scroll", handleScroll)
     return () => container.removeEventListener("scroll", handleScroll)
-  }, [activeConversationId])
+  }, [activeConversationId, isStreaming])
 
   // Reset scroll lock when streaming ends or conversation changes
   useEffect(() => {
@@ -1088,7 +1198,7 @@ export function ChatPanel() {
   // 切换会话时不再中断后台生成——每个会话独立运行
 
   const handleSend = useCallback(
-    async (text: string, tokens: ReferenceToken[] = [], displayText?: string) => {
+    async (text: string, tokens: ReferenceToken[] = [], displayText?: string, planBlueprint?: string, targetConversationId?: string) => {
       const plainText = text.trim()
       const userVisibleText = (displayText ?? plainText).trim()
       const planExecutionFollowup = isChapterPlanExecutionFollowup(plainText)
@@ -1113,7 +1223,7 @@ export function ChatPanel() {
         return
       }
 
-      let convId = useChatStore.getState().activeConversationId
+      let convId = targetConversationId ?? useChatStore.getState().activeConversationId
       if (!convId) {
         convId = createConversation()
       }
@@ -1142,12 +1252,13 @@ export function ChatPanel() {
 
       if (taskRoute && SIMULATION_INTENTS.has(taskRoute.intent)) {
         const { assistantMessage } = appendAgentChatMessages(capturedConvId, userVisibleText || plainText, tokens)
-        setConversationInputDraft(capturedConvId, "")
-        setFallbackReferenceText("")
-        setReferenceTokensByConversation((drafts) => {
-          const withoutCaptured = setReferenceTokensForConversation(drafts, capturedConvId, [])
-          return setReferenceTokensForConversation(withoutCaptured, referenceDraftConversationId, [])
-        })
+        if (!targetConversationId) {
+          setConversationInputDraft(capturedConvId, "")
+          setFallbackReferenceText("")
+          setReferenceTokensByConversation((drafts) =>
+            setReferenceTokensForConversation(drafts, referenceDraftConversationId, []),
+          )
+        }
 
         const hasFramework = !!activeBinding
         useStorySimulationStore.getState().initWithPreset({
@@ -1167,6 +1278,12 @@ export function ChatPanel() {
         return
       }
 
+      const runId = crypto.randomUUID()
+      if (!useChatStore.getState().startConversationRun(capturedConvId, runId)) {
+        setDeAiSkillWarningMessage(concurrencyLimitReason)
+        return
+      }
+
       const sessionAgentSystemPrompt = buildChatAgentSystemPrompt({
         novelMode,
         mode,
@@ -1186,17 +1303,19 @@ export function ChatPanel() {
         : undefined
 
       const { assistantMessage } = appendAgentChatMessages(capturedConvId, userVisibleText || plainText, tokens)
-      setConversationInputDraft(capturedConvId, "")
-      setFallbackReferenceText("")
-      setReferenceTokensByConversation((drafts) => {
-        const withoutCaptured = setReferenceTokensForConversation(drafts, capturedConvId, [])
-        return setReferenceTokensForConversation(withoutCaptured, referenceDraftConversationId, [])
-      })
+      if (!targetConversationId) {
+        setConversationInputDraft(capturedConvId, "")
+        setFallbackReferenceText("")
+        setReferenceTokensByConversation((drafts) =>
+          setReferenceTokensForConversation(drafts, referenceDraftConversationId, []),
+        )
+      }
       startStreaming(capturedConvId)
       const sessionId = streamSessionGuardRef.current.start(capturedConvId)
       activeStreamSessionsRef.current[capturedConvId] = sessionId
 
       const controller = new AbortController()
+      chatConversationRunRegistry.register(capturedConvId, controller)
 
       /* === Context trace + pre-plugin chain === */
       let contextTrace = createContextTrace(assistantMessage.id)
@@ -1208,8 +1327,8 @@ export function ChatPanel() {
       let prePluginResult: PrePluginChainResult | null = null
       const shouldRunNovelPrePluginChain = novelMode && (aiWorkflowMode !== "fast" || planExecuteActive)
       void shouldRunNovelPrePluginChain
-      abortControllersRef.current[capturedConvId] = controller
       let hasAgentError = false
+      let lastAgentError = "生成失败"
 
       const markDone = (record?: AgentRunRecord) => {
         updateAgentAssistantMessage(assistantMessage.id, (message) => ({
@@ -1234,6 +1353,7 @@ export function ChatPanel() {
 
       const markError = (error: Error) => {
         hasAgentError = true
+        lastAgentError = error.message || "生成失败"
         updateAgentAssistantMessage(assistantMessage.id, (message) => ({
           ...message,
           content: message.content
@@ -1251,7 +1371,7 @@ export function ChatPanel() {
           callback?.()
           clearStreaming(capturedConvId)
           delete activeStreamSessionsRef.current[capturedConvId]
-          delete abortControllersRef.current[capturedConvId]
+          chatConversationRunRegistry.remove(capturedConvId, controller)
         })
       }
 
@@ -1430,6 +1550,22 @@ export function ChatPanel() {
         } satisfies AgentMessage)),
         { role: "user", content: userContent },
       ]
+      const sessionRegistry = new ToolRegistry()
+      agentRegistry.list().forEach((tool) => sessionRegistry.register(tool))
+      if (planBlueprint) {
+        const workflowTool = agentRegistry.get("run_chapter_workflow")
+        if (workflowTool) {
+          sessionRegistry.register({
+            ...workflowTool,
+            execute: (params, signal, context) => workflowTool.execute({
+              ...params,
+              planBlueprint: typeof params.planBlueprint === "string" && params.planBlueprint.trim()
+                ? params.planBlueprint
+                : planBlueprint,
+            }, signal, context),
+          })
+        }
+      }
 
       try {
         const record = await runAiChatSession({
@@ -1443,7 +1579,7 @@ export function ChatPanel() {
             requestOverrides: agentConfig.requestOverrides,
           },
           enabledToolNames: prePluginResult?.enabledToolNames,
-          registry: agentRegistry,
+          registry: sessionRegistry,
           messages: agentMessages,
           signal: controller.signal,
           callbacks: {
@@ -1480,10 +1616,15 @@ export function ChatPanel() {
                 isAgentRunning: false,
               }))
             },
-            onError: markError,
+            onError: (error) => {
+              if (controller.signal.aborted) return
+              if (!streamSessionGuardRef.current.isActive(capturedConvId, sessionId)) return
+              markError(error)
+            },
           },
         })
 
+        if (controller.signal.aborted) return
         if (!streamSessionGuardRef.current.isActive(capturedConvId, sessionId)) return
         finishAgentSession(() => {
           if (!hasAgentError) {
@@ -1535,6 +1676,20 @@ export function ChatPanel() {
             markDone(record)
           }
         })
+        if (hasAgentError) {
+          useChatStore.getState().failConversationRun(capturedConvId, lastAgentError, runId)
+          toast.error(lastAgentError, {
+            title: "AI 会话生成失败",
+            persistent: true,
+            dedupeKey: `chat-run-failed:${capturedConvId}:${lastAgentError}`,
+          })
+        } else {
+          useChatStore.getState().finishConversationRun(
+            capturedConvId,
+            useChatStore.getState().activeConversationId,
+            runId,
+          )
+        }
         if (!hasAgentError && planExecuteActive) {
           const storeState = useChatStore.getState()
           const lastAssistant = storeState.messages.find(
@@ -1543,43 +1698,44 @@ export function ChatPanel() {
           const fullContent = lastAssistant?.content || record.finalText || ""
           const extracted = extractChapterPlan(fullContent)
           if (extracted) {
-            chapterPlanContextRef.current = contextPack
             const action = await requestChapterPlanConfirm(
               extracted.plan,
               fullContent,
               capturedConvId,
+              contextPack,
             )
             if (action === "cancel") {
               recordChapterPlanExecutionCancelled(assistantMessage.id)
-              chapterPlanContextRef.current = null
             } else {
               let followupText: string
+              let confirmedBlueprint: string | undefined
               if (action === "confirm") {
-                confirmedBlueprintRef.current = extracted.plan
+                confirmedBlueprint = extracted.plan
                 followupText = buildPlanConfirmMessage(extracted.plan)
               } else if (action === "skip") {
                 followupText = buildPlanSkipMessage()
               } else {
-                confirmedBlueprintRef.current = action.modify
+                confirmedBlueprint = action.modify
                 followupText = buildPlanConfirmMessage(action.modify)
               }
-              setActiveConversation(capturedConvId)
-              try {
-                await handleSendRef.current(followupText, [], "执行已确认计划")
-              } finally {
-                // followup 成功、失败或被中断都清理,避免后续无关调用误注入旧计划。
-                confirmedBlueprintRef.current = null
-                chapterPlanContextRef.current = null
-              }
+              await handleSendRef.current(followupText, [], "执行已确认计划", confirmedBlueprint, capturedConvId)
             }
           }
         }
       } catch (error) {
+        if (controller.signal.aborted) return
         if (!streamSessionGuardRef.current.isActive(capturedConvId, sessionId)) return
+        const errorMessage = error instanceof Error ? error.message : String(error)
         finishAgentSession(() => {
-          if (contextTrace) contextTrace = finishTrace(contextTrace, "error", error instanceof Error ? error.message : String(error))
+          if (contextTrace) contextTrace = finishTrace(contextTrace, "error", errorMessage)
           markError(error instanceof Error ? error : new Error(String(error)))
         })
+        useChatStore.getState().failConversationRun(capturedConvId, errorMessage, runId)
+        toast.error(errorMessage, {
+          title: "AI 会话生成失败",
+          persistent: true,
+          dedupeKey: `chat-run-failed:${capturedConvId}:${errorMessage}`,
+        })
       }
     },
     [
@@ -1605,7 +1761,6 @@ export function ChatPanel() {
       referenceDraftConversationId,
       requestChapterPlanConfirm,
       selectedFile,
-      setActiveConversation,
       setConversationInputDraft,
       startStreaming,
     ],
@@ -1624,8 +1779,9 @@ export function ChatPanel() {
         message.role === "assistant" &&
         message.isAgentRunning
       ))
-    abortControllersRef.current[convId]?.abort()
-    delete abortControllersRef.current[convId]
+    const runId = useChatStore.getState().runStates[convId]?.runId
+    chatConversationRunRegistry.abort(convId)
+    useChatStore.getState().stopConversationRun(convId, runId)
     const finalizeStopped = () => {
       finalizeStream(`${currentStreamingContent ? `${currentStreamingContent}\n\n` : ""}已停止生成。`, [], convId)
       delete activeStreamSessionsRef.current[convId]
@@ -1646,6 +1802,15 @@ export function ChatPanel() {
         }
         delete activeStreamSessionsRef.current[convId]
       })
+    } else if (runningAssistant) {
+      updateAgentAssistantMessage(runningAssistant.id, (message) => ({
+        ...message,
+        content: message.content ? `${message.content}\n\n已停止生成。` : "已停止生成。",
+        agentToolCalls: settleRunningAgentToolCalls(message.agentToolCalls, "cancelled"),
+        agentStages: settleRunningAgentStages(message.agentStages, "cancelled"),
+        isAgentRunning: false,
+      }))
+      clearStreaming(convId)
     } else if (currentStreamingContent !== undefined) {
       finalizeStopped()
     }
@@ -1721,17 +1886,11 @@ export function ChatPanel() {
   const hasAssistantMessages = activeMessages.some((m) => m.role === "assistant")
   const showWriteButton = mode === "ingest" && !isStreaming && hasAssistantMessages
 
-  // 删除会话时 abort 该会话的流式请求
-  const abortConversationStream = useCallback((convId: string) => {
-    abortControllersRef.current[convId]?.abort()
-    delete abortControllersRef.current[convId]
-  }, [])
-
   return (
     <div className="flex h-full flex-col overflow-hidden bg-background">
-      <ConversationTabs onAbortStream={abortConversationStream} />
+      <ConversationTabs onBeforeDelete={cancelPendingChapterPlan} />
 
-      <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
+      <div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
         {!activeConversationId ? (
           <div className="flex flex-1 items-center justify-center text-muted-foreground">
             <div className="text-center">
@@ -1744,14 +1903,11 @@ export function ChatPanel() {
           <>
             <div
               ref={scrollContainerRef}
-              className="min-h-0 flex-1 overflow-y-auto px-3 py-2"
+              className="min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden px-3 py-2"
             >
-              {/* key 强制在切换会话时重新挂载消息列表,避免旧会话内容残留 */}
-              <div key={activeConversationId} className="flex flex-col gap-3">
+              <div className="flex w-full min-w-0 max-w-full flex-col gap-3">
                 {activeMessages.map((msg, idx) => {
-                  // Check if this is the last assistant message
-                  const isLastAssistant = msg.role === "assistant" &&
-                    !activeMessages.slice(idx + 1).some((m) => m.role === "assistant")
+                  const isLastAssistant = msg.role === "assistant" && idx === lastAssistantIndex
                   return (
                     <ChatMessage
                       key={msg.id}
@@ -1768,11 +1924,22 @@ export function ChatPanel() {
                     />
                   )
                 })}
-                {isStreaming && streamingContent && <StreamingMessage content={streamingContent} />}
+                {isStreaming && batchedStreamingContent && <StreamingMessage content={batchedStreamingContent} isStreaming={isStreaming} />}
                 <div ref={bottomRef} />
               </div>
             </div>
 
+            {showScrollToBottom && (
+              <button
+                type="button"
+                onClick={handleScrollToBottom}
+                className="absolute bottom-20 right-4 z-10 flex h-8 w-8 items-center justify-center rounded-full border border-border bg-background/90 shadow-md backdrop-blur-sm transition-all hover:bg-accent"
+                title="回到最新"
+              >
+                <ArrowDown className="h-4 w-4" />
+              </button>
+            )}
+
             {showWriteButton && (
               <div className="border-t px-3 py-2">
                 <Button
@@ -1978,8 +2145,10 @@ export function ChatPanel() {
             <ReferenceInput
               value={referenceText}
               tokens={currentTokens}
-              disabled={isStreaming || pendingChapterPlan.open}
+              disabled={isStreaming || Boolean(pendingChapterPlan)}
               isStreaming={isStreaming}
+              submitDisabled={concurrencyFull}
+              submitDisabledReason={concurrencyFull ? concurrencyLimitReason : undefined}
               onStop={handleStop}
               rightControls={
                 <ChatModelSelector
@@ -2013,17 +2182,17 @@ export function ChatPanel() {
             onClose={() => setReferencePickerOpen(false)}
           />
         </div>
-        {pendingChapterPlan.open && (
+        {pendingChapterPlan && (
           <ChapterPlanConfirmDialog
-            open={pendingChapterPlan.open}
+            open
             planContent={pendingChapterPlan.planContent}
             aiWorkflowMode={aiWorkflowMode}
-            onConfirm={() => closeChapterPlanDialog("confirm")}
-            onSkip={() => closeChapterPlanDialog("skip")}
-            onModify={(modified) => closeChapterPlanDialog({ modify: modified })}
-            onSelfCheck={runChapterPlanSelfCheck}
+            onConfirm={() => closeChapterPlanDialog(pendingChapterPlan.conversationId, "confirm")}
+            onSkip={() => closeChapterPlanDialog(pendingChapterPlan.conversationId, "skip")}
+            onModify={(modified) => closeChapterPlanDialog(pendingChapterPlan.conversationId, { modify: modified })}
+            onSelfCheck={(planContent) => runChapterPlanSelfCheck(planContent, pendingChapterPlan.contextPack)}
             onRevisePlan={runChapterPlanRevision}
-            onCancel={() => closeChapterPlanDialog("cancel")}
+            onCancel={() => closeChapterPlanDialog(pendingChapterPlan.conversationId, "cancel")}
           />
         )}
       </div>

+ 35 - 0
src/components/common/conversation-delete-confirm-dialog.spec.tsx

@@ -0,0 +1,35 @@
+// @vitest-environment jsdom
+
+import { act } from "react"
+import { createRoot, type Root } from "react-dom/client"
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
+import { ConversationDeleteConfirmDialog } from "./conversation-delete-confirm-dialog"
+
+let host: HTMLDivElement
+let root: Root
+beforeEach(() => { host = document.createElement("div"); document.body.appendChild(host); root = createRoot(host) })
+afterEach(() => { act(() => root.unmount()); host.remove() })
+
+function findButton(text: string) {
+  return Array.from(document.body.querySelectorAll("button")).find((button) => button.textContent?.includes(text))
+}
+
+describe("ConversationDeleteConfirmDialog", () => {
+  it("shows Chinese warning and handles cancel", async () => {
+    const onCancel = vi.fn(); const onConfirm = vi.fn()
+    await act(async () => root.render(<ConversationDeleteConfirmDialog open onCancel={onCancel} onConfirm={onConfirm} />))
+    expect(document.body.textContent).toContain("停止并删除会话?")
+    expect(document.body.textContent).toContain("该会话仍在生成。确认后将先停止任务,再删除会话。")
+    await act(async () => findButton("取消")?.dispatchEvent(new MouseEvent("click", { bubbles: true })))
+    expect(onCancel).toHaveBeenCalledTimes(1)
+    expect(onConfirm).not.toHaveBeenCalled()
+  })
+
+  it("confirms stop and delete", async () => {
+    const onCancel = vi.fn(); const onConfirm = vi.fn()
+    await act(async () => root.render(<ConversationDeleteConfirmDialog open onCancel={onCancel} onConfirm={onConfirm} />))
+    await act(async () => findButton("停止并删除")?.dispatchEvent(new MouseEvent("click", { bubbles: true })))
+    expect(onConfirm).toHaveBeenCalledTimes(1)
+    expect(onCancel).not.toHaveBeenCalled()
+  })
+})

+ 32 - 0
src/components/common/conversation-delete-confirm-dialog.tsx

@@ -0,0 +1,32 @@
+import { Button } from "@/components/ui/button"
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from "@/components/ui/dialog"
+
+interface ConversationDeleteConfirmDialogProps {
+  open: boolean
+  onCancel: () => void
+  onConfirm: () => void
+}
+
+export function ConversationDeleteConfirmDialog({ open, onCancel, onConfirm }: ConversationDeleteConfirmDialogProps) {
+  return (
+    <Dialog open={open} onOpenChange={(nextOpen) => { if (!nextOpen) onCancel() }}>
+      <DialogContent showCloseButton={false}>
+        <DialogHeader>
+          <DialogTitle>停止并删除会话?</DialogTitle>
+          <DialogDescription>该会话仍在生成。确认后将先停止任务,再删除会话。</DialogDescription>
+        </DialogHeader>
+        <DialogFooter>
+          <Button type="button" variant="outline" onClick={onCancel}>取消</Button>
+          <Button type="button" variant="destructive" onClick={onConfirm}>停止并删除</Button>
+        </DialogFooter>
+      </DialogContent>
+    </Dialog>
+  )
+}

+ 35 - 0
src/components/common/conversation-run-status-icon.spec.tsx

@@ -0,0 +1,35 @@
+// @vitest-environment jsdom
+
+import { act } from "react"
+import { createRoot, type Root } from "react-dom/client"
+import { afterEach, beforeEach, describe, expect, it } from "vitest"
+import type { ConversationRunState } from "@/lib/conversation-run-state"
+import { ConversationRunStatusIcon } from "./conversation-run-status-icon"
+
+let host: HTMLDivElement
+let root: Root
+beforeEach(() => { host = document.createElement("div"); document.body.appendChild(host); root = createRoot(host) })
+afterEach(() => { act(() => root.unmount()); host.remove() })
+
+async function renderState(state: ConversationRunState) {
+  await act(async () => { root.render(<ConversationRunStatusIcon state={state} />) })
+}
+
+describe("ConversationRunStatusIcon", () => {
+  it.each([
+    ["running", "正在生成"],
+    ["completed_unread", "已完成,点击查看"],
+    ["failed", "生成失败:接口错误"],
+    ["interrupted", "任务已中断,可重新发送"],
+  ] as const)("renders %s with Chinese accessible status", async (status, label) => {
+    await renderState({ status, updatedAt: 1, error: "接口错误" })
+    const icon = host.querySelector(`[aria-label="${label}"]`)
+    expect(icon).not.toBeNull()
+    expect(icon?.querySelector("svg")).not.toBeNull()
+  })
+
+  it("does not render idle status", async () => {
+    await renderState({ status: "idle", updatedAt: 1 })
+    expect(host.innerHTML).toBe("")
+  })
+})

+ 53 - 0
src/components/common/conversation-run-status-icon.tsx

@@ -0,0 +1,53 @@
+import { CircleAlert, CircleCheck, LoaderCircle, PauseCircle } from "lucide-react"
+import type { ConversationRunState } from "@/lib/conversation-run-state"
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
+
+export function getConversationRunStatusLabel(state: ConversationRunState): string {
+  if (state.status === "running") return "正在生成"
+  if (state.status === "completed_unread") return "已完成,点击查看"
+  if (state.status === "failed") return `生成失败:${state.error ?? "未知错误"}`
+  if (state.status === "interrupted") return "任务已中断,可重新发送"
+  return ""
+}
+
+export function ConversationRunStatusIcon({ state }: { state?: ConversationRunState }) {
+  if (!state || state.status === "idle") return null
+  const label = getConversationRunStatusLabel(state)
+  const Icon = state.status === "running"
+    ? LoaderCircle
+    : state.status === "completed_unread"
+      ? CircleCheck
+      : state.status === "failed"
+        ? CircleAlert
+        : PauseCircle
+  const color = state.status === "running"
+    ? "text-sky-500"
+    : state.status === "completed_unread"
+      ? "text-emerald-500"
+      : state.status === "failed"
+        ? "text-red-500"
+        : "text-amber-500"
+
+  return (
+    <TooltipProvider delay={200}>
+      <Tooltip>
+        <TooltipTrigger
+          render={
+            <span
+              role="img"
+              aria-label={label}
+              data-conversation-run-status={state.status}
+              className="relative inline-flex h-4 w-4 shrink-0 items-center justify-center"
+            />
+          }
+        >
+          <Icon aria-hidden="true" className={`h-3.5 w-3.5 ${color} ${state.status === "running" ? "animate-spin" : ""}`} />
+          {state.status === "completed_unread" ? (
+            <span aria-hidden="true" className="absolute -right-0.5 -top-0.5 h-1.5 w-1.5 rounded-full border border-background bg-sky-500" />
+          ) : null}
+        </TooltipTrigger>
+        <TooltipContent>{label}</TooltipContent>
+      </Tooltip>
+    </TooltipProvider>
+  )
+}

+ 163 - 0
src/components/common/event-stream.tsx

@@ -0,0 +1,163 @@
+import { memo, useState, useEffect, useMemo, useRef } from "react"
+import type { TimelineEvent } from "./timeline-types"
+import { groupTimelineEvents } from "./timeline-grouping"
+import { ThinkingEvent } from "./timeline-thinking-event"
+import { ToolCallEvent } from "./timeline-tool-event"
+import { getToolCallGroupRenderKey, ToolCallGroup } from "./timeline-tool-group"
+import { ArrowDown, Brain, Clock3, Hash } from "lucide-react"
+
+interface EventStreamProps {
+  events: TimelineEvent[]
+  isStreaming: boolean
+  totalDurationMs?: number
+  totalTokens?: number
+}
+
+function EventStreamImpl({ events, isStreaming, totalDurationMs, totalTokens }: EventStreamProps) {
+  const containerRef = useRef<HTMLDivElement>(null)
+  const [showScrollButton, setShowScrollButton] = useState(false)
+  const userScrolledRef = useRef(false)
+  const groupedEvents = useMemo(() => groupTimelineEvents(events), [events])
+
+  const thinkingCount = events.filter((e) => e.kind === "thinking").length
+  const toolCount = events.reduce((count, event) => {
+    if (event.kind === "tool_call") return count + 1
+    if (event.kind === "tool_group") return count + event.data.items.length
+    return count
+  }, 0)
+
+  const formatDuration = (ms: number): string => {
+    if (ms < 1000) return `${ms}ms`
+    if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`
+    const minutes = Math.floor(ms / 60000)
+    const seconds = ((ms % 60000) / 1000).toFixed(0)
+    return `${minutes}分${seconds}秒`
+  }
+
+  const scrollToBottom = () => {
+    if (containerRef.current) {
+      containerRef.current.scrollTop = containerRef.current.scrollHeight
+      userScrolledRef.current = false
+      setShowScrollButton(false)
+    }
+  }
+
+  useEffect(() => {
+    if (!isStreaming) return
+    const container = containerRef.current
+    if (!container) return
+
+    const handleScroll = () => {
+      const { scrollTop, scrollHeight, clientHeight } = container
+      const atBottom = scrollHeight - scrollTop - clientHeight < 30
+      if (!atBottom) {
+        userScrolledRef.current = true
+        setShowScrollButton(true)
+      } else {
+        userScrolledRef.current = false
+        setShowScrollButton(false)
+      }
+    }
+
+    container.addEventListener("scroll", handleScroll, { passive: true })
+    return () => container.removeEventListener("scroll", handleScroll)
+  }, [isStreaming])
+
+  useEffect(() => {
+    if (!isStreaming || userScrolledRef.current) return
+    if (containerRef.current) {
+      containerRef.current.scrollTop = containerRef.current.scrollHeight
+    }
+  }, [groupedEvents, isStreaming])
+
+  if (events.length === 0) {
+    if (isStreaming) {
+      return (
+        <div className="flex items-center gap-2 px-2 py-3 text-[12px] text-muted-foreground">
+          <span className="inline-block h-2 w-2 animate-pulse rounded-full bg-sky-500" />
+          <span>思考中...</span>
+        </div>
+      )
+    }
+    return null
+  }
+
+  return (
+    <div className="relative w-full min-w-0 max-w-full overflow-x-hidden">
+      <div
+        ref={containerRef}
+        className="event-stream-scroll max-h-[50vh] w-full min-w-0 max-w-full space-y-0 overflow-x-hidden overflow-y-auto py-1"
+      >
+        {groupedEvents.map((event, idx) => {
+          const delay = Math.min(idx * 50, 300)
+          const animationStyle = {
+            animationDelay: `${delay}ms`,
+            animationFillMode: "backwards" as const,
+          }
+          if (event.kind === "thinking") {
+            return (
+              <div key={`thinking-${idx}-${event.data.id}`} style={animationStyle}>
+                <ThinkingEvent event={event.data} />
+              </div>
+            )
+          }
+          if (event.kind === "tool_group") {
+            return (
+              <ToolCallGroup
+                key={getToolCallGroupRenderKey(event.data)}
+                group={event.data}
+                style={animationStyle}
+              />
+            )
+          }
+          return (
+            <div key={`tool-${event.data.id}`} style={animationStyle}>
+              <ToolCallEvent event={event.data} />
+            </div>
+          )
+        })}
+
+        {!isStreaming && (totalDurationMs !== undefined || totalTokens !== undefined) && (
+          <div
+            className="mt-2 flex items-center gap-3 px-2 pt-2 border-t border-border/50 text-[11px] text-muted-foreground/70"
+            style={{
+              animationDelay: `${Math.min(groupedEvents.length * 50 + 100, 400)}ms`,
+              animationFillMode: "backwards",
+              animation: "slideInUp 300ms ease-out",
+            }}
+          >
+            {totalDurationMs !== undefined && (
+              <span className="flex items-center gap-1">
+                <Clock3 aria-hidden="true" className="h-3 w-3" />
+                <span>耗时 {formatDuration(totalDurationMs)}</span>
+              </span>
+            )}
+            {totalTokens !== undefined && (
+              <span className="flex items-center gap-1">
+                <Hash aria-hidden="true" className="h-3 w-3" />
+                <span>{totalTokens.toLocaleString()} tokens</span>
+              </span>
+            )}
+            <span className="ml-auto flex items-center gap-1">
+              <Brain aria-hidden="true" className="h-3 w-3" />
+              <span>思考 {thinkingCount} 段 · 工具 {toolCount} 次</span>
+            </span>
+          </div>
+        )}
+      </div>
+
+      {showScrollButton && (
+        <button
+          type="button"
+          onClick={scrollToBottom}
+          className="absolute bottom-2 right-2 flex h-7 w-7 items-center justify-center rounded-full bg-background border border-border shadow-md hover:bg-accent transition-colors text-foreground/70 hover:text-foreground"
+          title="回到最新"
+        >
+          <ArrowDown className="h-3.5 w-3.5" />
+        </button>
+      )}
+    </div>
+  )
+}
+
+export const EventStream = memo(EventStreamImpl)

+ 70 - 0
src/components/common/streaming-markdown.tsx

@@ -0,0 +1,70 @@
+import { useEffect, useRef, useState, ReactNode, memo } from "react"
+import { StreamingSpinner } from "./streaming-spinner"
+
+interface StreamingMarkdownProps {
+  content: string
+  isStreaming: boolean
+  renderCommitted: (content: string) => ReactNode
+  showCursor?: boolean
+}
+
+function StreamingMarkdownImpl({
+  content,
+  isStreaming,
+  renderCommitted,
+  showCursor = true,
+}: StreamingMarkdownProps) {
+  // 对齐 Zed: 已提交行保持不变,只在换行时重新解析 Markdown(等效 should_reparse 防抖)
+  const lastCommittedRef = useRef("")
+  const [committed, setCommitted] = useState("")
+  const [activeLine, setActiveLine] = useState("")
+
+  useEffect(() => {
+    if (!isStreaming) {
+      // 对齐 Zed: reset 时不立即清空,保持旧内容可见直到新解析完成
+      if (lastCommittedRef.current !== content) {
+        lastCommittedRef.current = content
+        setCommitted(content)
+      }
+      setActiveLine("")
+      return
+    }
+
+    // 对齐 Zed: update_text_in_place - 检测前缀扩展,只更新变化部分
+    const idx = content.lastIndexOf("\n")
+    if (idx < 0) {
+      // 还没有完整行,全部在 activeLine 中
+      if (lastCommittedRef.current !== "") {
+        lastCommittedRef.current = ""
+        setCommitted("")
+      }
+      setActiveLine(content)
+      return
+    }
+
+    const newCommitted = content.slice(0, idx + 1)
+    const newActive = content.slice(idx + 1)
+
+    // 对齐 Zed: append - 只在 committed 内容实际变化时才重新解析
+    if (newCommitted !== lastCommittedRef.current) {
+      lastCommittedRef.current = newCommitted
+      setCommitted(newCommitted)
+    }
+    setActiveLine(newActive)
+  }, [content, isStreaming])
+
+  return (
+    <>
+      {renderCommitted(committed)}
+      {activeLine !== "" && (
+        <span className="streaming-active-line whitespace-pre-wrap break-words">
+          {activeLine}
+        </span>
+      )}
+      {/* 对齐 Zed: 用 Braille spinner 替代闪烁方块光标 */}
+      {showCursor && isStreaming && <StreamingSpinner />}
+    </>
+  )
+}
+
+export const StreamingMarkdown = memo(StreamingMarkdownImpl)

+ 33 - 0
src/components/common/streaming-spinner.tsx

@@ -0,0 +1,33 @@
+import { useEffect, useState } from "react"
+
+/**
+ * Zed 风格的生成指示器。
+ *
+ * 对齐 Zed SpinnerLabel(Dots 变体):
+ * - 10 帧 Braille 点阵动画:⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏
+ * - 1000ms 循环(100ms/帧)
+ * - 使用 with_animation + Animation::repeat() 等效的 CSS 动画
+ */
+const DOTS_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
+const FRAME_MS = 100
+
+export function StreamingSpinner() {
+  const [frameIdx, setFrameIdx] = useState(0)
+
+  useEffect(() => {
+    const timer = setInterval(() => {
+      setFrameIdx((prev) => (prev + 1) % DOTS_FRAMES.length)
+    }, FRAME_MS)
+    return () => clearInterval(timer)
+  }, [])
+
+  return (
+    <span
+      className="inline-block w-[1ch] text-muted-foreground/80 select-none"
+      style={{ fontVariantEmoji: "text" }}
+      aria-label="正在生成"
+    >
+      {DOTS_FRAMES[frameIdx]}
+    </span>
+  )
+}

+ 164 - 0
src/components/common/timeline-grouping.spec.ts

@@ -0,0 +1,164 @@
+import { describe, expect, it } from "vitest"
+import { groupTimelineEvents } from "./timeline-grouping"
+import type {
+  TimelineEvent,
+  TimelineToolCategory,
+  ToolCallEventItem,
+} from "./timeline-types"
+
+function toolCall(
+  id: string,
+  name: string,
+  options: Partial<ToolCallEventItem> = {},
+): ToolCallEventItem {
+  return {
+    id,
+    name,
+    description: name,
+    category: "read",
+    status: "done",
+    ...options,
+  }
+}
+
+function toolEvent(item: ToolCallEventItem): TimelineEvent {
+  return { kind: "tool_call", data: item }
+}
+
+function thinkingEvent(id = "thinking-current"): TimelineEvent {
+  return {
+    kind: "thinking",
+    data: { id, content: "分析过程", streaming: false },
+  }
+}
+
+describe("groupTimelineEvents", () => {
+  it("groups consecutive completed chapter reads with a stable id and summed positive duration", () => {
+    const first = toolCall("call-1", "read_chapter", { startedAt: 10, finishedAt: 30 })
+    const second = toolCall("call-2", "read_chapter", { startedAt: 50, finishedAt: 90 })
+    const events = [toolEvent(first), toolEvent(second)]
+
+    const firstResult = groupTimelineEvents(events)
+    const secondResult = groupTimelineEvents(events)
+
+    expect(firstResult).toEqual([
+      {
+        kind: "tool_group",
+        data: {
+          id: 'tool-group:["chapters","call-1","call-2"]',
+          kind: "chapters",
+          label: "已读取章节",
+          items: [first, second],
+          durationMs: 60,
+        },
+      },
+    ])
+    expect(secondResult[0]?.data.id).toBe(firstResult[0]?.data.id)
+  })
+
+  it("groups consecutive completed context reads under the resources and memory label", () => {
+    const calls = [
+      toolCall("call-1", "list_chapters"),
+      toolCall("call-2", "read_memory"),
+      toolCall("call-3", "search_chapters"),
+      toolCall("call-4", "load_context"),
+    ]
+
+    expect(groupTimelineEvents(calls.map(toolEvent))).toEqual([
+      {
+        kind: "tool_group",
+        data: {
+          id: 'tool-group:["context","call-1","call-2","call-3","call-4"]',
+          kind: "context",
+          label: "已读取资料与记忆",
+          items: calls,
+        },
+      },
+    ])
+  })
+
+  it("uses every item id so groups with the same endpoints cannot collide", () => {
+    const firstGroup = [
+      toolCall("call-1", "read_chapter"),
+      toolCall("call-middle-a", "read_chapter"),
+      toolCall("call-3", "read_chapter"),
+    ]
+    const secondGroup = [
+      toolCall("call-1", "read_chapter"),
+      toolCall("call-middle-b", "read_chapter"),
+      toolCall("call-3", "read_chapter"),
+    ]
+
+    const firstId = groupTimelineEvents(firstGroup.map(toolEvent))[0]?.data.id
+    const repeatedFirstId = groupTimelineEvents(firstGroup.map(toolEvent))[0]?.data.id
+    const secondId = groupTimelineEvents(secondGroup.map(toolEvent))[0]?.data.id
+
+    expect(firstId).toBe(repeatedFirstId)
+    expect(firstId).not.toBe(secondId)
+  })
+
+  it("keeps a single safe read as its original tool event", () => {
+    const event = toolEvent(toolCall("call-1", "read_outline"))
+
+    const result = groupTimelineEvents([event])
+
+    expect(result).toEqual([event])
+    expect(result[0]).toBe(event)
+  })
+
+  it("treats thinking and action tools as boundaries without changing them", () => {
+    const firstRead = toolEvent(toolCall("call-1", "read_chapter"))
+    const thinking = thinkingEvent()
+    const secondRead = toolEvent(toolCall("call-2", "read_chapter"))
+    const action = toolEvent(toolCall("call-3", "route_task", { category: "action" }))
+    const thirdRead = toolEvent(toolCall("call-4", "read_chapter"))
+
+    const events = [firstRead, thinking, secondRead, action, thirdRead]
+    const result = groupTimelineEvents(events)
+
+    expect(result).toEqual(events)
+    result.forEach((event, index) => expect(event).toBe(events[index]))
+  })
+
+  it("keeps write tools and non-done read statuses unchanged as grouping boundaries", () => {
+    const boundaryStatuses: ToolCallEventItem["status"][] = [
+      "running",
+      "error",
+      "approval_required",
+      "cancelled",
+    ]
+    const events: TimelineEvent[] = [
+      toolEvent(toolCall("write-1", "write_chapter", {
+        category: "write" satisfies TimelineToolCategory,
+        status: "done",
+      })),
+      ...boundaryStatuses.map((status, index) => toolEvent(toolCall(
+        `read-${index + 1}`,
+        "read_chapter",
+        { status },
+      ))),
+    ]
+
+    const result = groupTimelineEvents(events)
+
+    expect(result).toEqual(events)
+    result.forEach((event, index) => expect(event).toBe(events[index]))
+  })
+
+  it("ignores zero and negative tool durations when calculating a group duration", () => {
+    const first = toolCall("call-1", "read_chapter", { startedAt: 10, finishedAt: 40 })
+    const zero = toolCall("call-2", "read_chapter", { startedAt: 50, finishedAt: 50 })
+    const negative = toolCall("call-3", "read_chapter", { startedAt: 90, finishedAt: 60 })
+
+    const result = groupTimelineEvents([
+      toolEvent(first),
+      toolEvent(zero),
+      toolEvent(negative),
+    ])
+
+    expect(result[0]).toMatchObject({
+      kind: "tool_group",
+      data: { durationMs: 30 },
+    })
+  })
+})

+ 87 - 0
src/components/common/timeline-grouping.ts

@@ -0,0 +1,87 @@
+import type {
+  TimelineEvent,
+  TimelineToolGroupKind,
+  ToolCallEventItem,
+  ToolCallGroupItem,
+} from "./timeline-types"
+
+const CONTEXT_READ_TOOLS = new Set([
+  "list_chapters",
+  "list_outlines",
+  "list_memories",
+  "list_deductions",
+  "read_outline",
+  "read_memory",
+  "read_deduction",
+  "read_chat_history",
+  "read_outline_history",
+  "search_chapters",
+  "chapter_context",
+  "chapter_previous_analysis",
+  "load_context",
+  "trim_context",
+])
+
+function getGroupKind(item: ToolCallEventItem): TimelineToolGroupKind | null {
+  if (item.status !== "done" || item.category !== "read") return null
+  if (item.name === "read_chapter") return "chapters"
+  if (CONTEXT_READ_TOOLS.has(item.name)) return "context"
+  return null
+}
+
+function createGroup(kind: TimelineToolGroupKind, items: ToolCallEventItem[]): TimelineEvent {
+  const durationMs = items.reduce((total, item) => {
+    if (item.startedAt === undefined || item.finishedAt === undefined) return total
+    const duration = item.finishedAt - item.startedAt
+    return duration > 0 ? total + duration : total
+  }, 0)
+  const data: ToolCallGroupItem = {
+    id: `tool-group:${JSON.stringify([kind, ...items.map((item) => item.id)])}`,
+    kind,
+    label: kind === "chapters" ? "已读取章节" : "已读取资料与记忆",
+    items,
+    ...(durationMs > 0 ? { durationMs } : {}),
+  }
+
+  return { kind: "tool_group", data }
+}
+
+export function groupTimelineEvents(events: TimelineEvent[]): TimelineEvent[] {
+  const groupedEvents: TimelineEvent[] = []
+  let index = 0
+
+  while (index < events.length) {
+    const event = events[index]
+    if (event.kind !== "tool_call") {
+      groupedEvents.push(event)
+      index += 1
+      continue
+    }
+
+    const groupKind = getGroupKind(event.data)
+    if (!groupKind) {
+      groupedEvents.push(event)
+      index += 1
+      continue
+    }
+
+    const items = [event.data]
+    let nextIndex = index + 1
+    while (nextIndex < events.length) {
+      const nextEvent = events[nextIndex]
+      if (nextEvent.kind !== "tool_call" || getGroupKind(nextEvent.data) !== groupKind) break
+      items.push(nextEvent.data)
+      nextIndex += 1
+    }
+
+    if (items.length >= 2) {
+      groupedEvents.push(createGroup(groupKind, items))
+      index = nextIndex
+    } else {
+      groupedEvents.push(event)
+      index += 1
+    }
+  }
+
+  return groupedEvents
+}

+ 74 - 0
src/components/common/timeline-thinking-event.spec.tsx

@@ -0,0 +1,74 @@
+// @vitest-environment jsdom
+
+import { act } from "react"
+import { createRoot, type Root } from "react-dom/client"
+import { afterEach, beforeEach, describe, expect, it } from "vitest"
+import { ThinkingEvent } from "./timeline-thinking-event"
+
+;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean })
+  .IS_REACT_ACT_ENVIRONMENT = true
+
+let host: HTMLDivElement
+let root: Root
+
+beforeEach(() => {
+  host = document.createElement("div")
+  document.body.appendChild(host)
+  root = createRoot(host)
+})
+
+afterEach(() => {
+  act(() => {
+    root.unmount()
+  })
+  host.remove()
+})
+
+async function renderThinking(content: string, streaming: boolean) {
+  await act(async () => {
+    root.render(
+      <ThinkingEvent
+        event={{
+          id: "thinking-current",
+          content,
+          streaming,
+        }}
+      />,
+    )
+  })
+}
+
+describe("ThinkingEvent", () => {
+  it("uses a line icon instead of emoji decoration", async () => {
+    await renderThinking("正在分析", true)
+
+    expect(host.textContent).not.toContain("💡")
+    expect(host.querySelector("svg")).not.toBeNull()
+  })
+  it("expands automatically when a new streaming round starts after the previous round collapsed", async () => {
+    await renderThinking("第一轮思考正文", true)
+    expect(host.textContent).toContain("第一轮思考正文")
+
+    await renderThinking("第一轮思考正文", false)
+    expect(host.textContent).not.toContain("第一轮思考正文")
+
+    await renderThinking("第二轮思考正文", true)
+    expect(host.textContent).toContain("第二轮思考正文")
+    expect(host.textContent).toContain("思考中")
+  })
+
+  it("keeps a manual expansion when completed content grows in the same round", async () => {
+    await renderThinking("已完成正文", true)
+    await renderThinking("已完成正文", false)
+
+    const expandButton = host.querySelector<HTMLButtonElement>('button[aria-expanded="false"]')
+    expect(expandButton).not.toBeNull()
+    await act(async () => {
+      expandButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }))
+    })
+    expect(host.textContent).toContain("已完成正文")
+
+    await renderThinking("已完成正文,补充内容", false)
+    expect(host.textContent).toContain("已完成正文,补充内容")
+  })
+})

+ 88 - 0
src/components/common/timeline-thinking-event.tsx

@@ -0,0 +1,88 @@
+import { useState, useEffect, useRef, memo } from "react"
+import { Brain } from "lucide-react"
+import type { ThinkingEventItem } from "./timeline-types"
+import { StreamingSpinner } from "./streaming-spinner"
+
+interface ThinkingEventProps {
+  event: ThinkingEventItem
+}
+
+function ThinkingEventImpl({ event }: ThinkingEventProps) {
+  const [collapsed, setCollapsed] = useState(false)
+  const wasStreamingRef = useRef(false)
+  const manuallyChangedRef = useRef(false)
+
+  useEffect(() => {
+    if (!wasStreamingRef.current && event.streaming) {
+      manuallyChangedRef.current = false
+      setCollapsed(false)
+    } else if (
+      wasStreamingRef.current
+      && !event.streaming
+      && event.content
+      && !manuallyChangedRef.current
+    ) {
+      setCollapsed(true)
+    }
+    wasStreamingRef.current = event.streaming
+  }, [event.streaming, event.content])
+
+  if (!event.content) return null
+
+  const charCount = event.content.length
+
+  if (collapsed) {
+    return (
+      <button
+        type="button"
+        onClick={() => {
+          manuallyChangedRef.current = true
+          setCollapsed(false)
+        }}
+        className="flex items-center gap-1.5 px-2 py-1 text-left text-[12px] text-muted-foreground hover:text-foreground w-full"
+        aria-label={`思考完成,${charCount}字,点击展开`}
+        aria-expanded={false}
+      >
+        <Brain aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
+        <span>思考完成 · {charCount}字</span>
+      </button>
+    )
+  }
+
+  return (
+    <div className="px-2 py-1 animate-[slideInUp_300ms_ease-out] group">
+      <div className="flex items-start gap-2">
+        <Brain aria-hidden="true" className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
+        <div className="min-w-0 flex-1">
+          <div className="mb-0.5 flex items-center gap-1.5">
+            <span className="text-[11px] font-medium text-muted-foreground">
+              {event.streaming ? "思考中" : `思考完成 · ${charCount}字`}
+            </span>
+            {!event.streaming && (
+              <button
+                type="button"
+                onClick={() => {
+                  manuallyChangedRef.current = true
+                  setCollapsed(true)
+                }}
+                className="text-[10px] text-muted-foreground/70 hover:text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity"
+              >
+                收起
+              </button>
+            )}
+          </div>
+          <div className="border-l-2 border-amber-400/30 pl-2.5 text-[12px] leading-5 text-foreground/75 whitespace-pre-wrap">
+            {event.content}
+            {event.streaming && (
+              <span className="inline-block ml-0.5 text-amber-500 dark:text-amber-400 align-text-bottom">
+                <StreamingSpinner />
+              </span>
+            )}
+          </div>
+        </div>
+      </div>
+    </div>
+  )
+}
+
+export const ThinkingEvent = memo(ThinkingEventImpl)

+ 171 - 0
src/components/common/timeline-tool-event.tsx

@@ -0,0 +1,171 @@
+import { memo, useState } from "react"
+import {
+  Ban,
+  Check,
+  CircleAlert,
+  CircleEllipsis,
+  CircleX,
+  LoaderCircle,
+  Pencil,
+  Search,
+  Wrench,
+  type LucideIcon,
+} from "lucide-react"
+import { cn } from "@/lib/utils"
+import type { ToolCallEventItem, TimelineToolCategory } from "./timeline-types"
+
+interface ToolCallEventProps {
+  event: ToolCallEventItem
+  compact?: boolean
+}
+
+const CATEGORY_ICON: Record<TimelineToolCategory, LucideIcon> = {
+  read: Search,
+  write: Pencil,
+  action: Wrench,
+  virtual: CircleEllipsis,
+}
+
+const CATEGORY_BAR: Record<TimelineToolCategory, string> = {
+  read: "border-yellow-400/50",
+  write: "border-red-400/50",
+  action: "border-purple-400/50",
+  virtual: "border-gray-400/30",
+}
+
+const STATUS_ICON: Record<ToolCallEventItem["status"], LucideIcon> = {
+  running: LoaderCircle,
+  done: Check,
+  error: CircleX,
+  approval_required: CircleAlert,
+  cancelled: Ban,
+}
+
+const STATUS_LABEL: Record<ToolCallEventItem["status"], string> = {
+  running: "运行中",
+  done: "已完成",
+  error: "失败",
+  approval_required: "等待确认",
+  cancelled: "已取消",
+}
+
+const STATUS_COLOR: Record<ToolCallEventItem["status"], string> = {
+  running: "text-sky-500 dark:text-sky-400",
+  done: "text-emerald-500 dark:text-emerald-400",
+  error: "text-red-500 dark:text-red-400",
+  approval_required: "text-amber-500 dark:text-amber-400",
+  cancelled: "text-muted-foreground",
+}
+
+function formatDuration(startedAt?: number, finishedAt?: number): string {
+  if (startedAt === undefined || finishedAt === undefined || finishedAt <= startedAt) return ""
+  const ms = finishedAt - startedAt
+  if (ms < 1000) return `${ms}ms`
+  return `${(ms / 1000).toFixed(1)}s`
+}
+
+function formatParamValue(value: unknown): string {
+  if (typeof value === "string") return value
+  if (value === undefined) return "undefined"
+  return (JSON.stringify(value) ?? String(value)).slice(0, 80)
+}
+
+function ToolCallEventImpl({ event, compact = false }: ToolCallEventProps) {
+  const [expanded, setExpanded] = useState(false)
+  const isRunning = event.status === "running"
+  const isError = event.status === "error"
+  const duration = formatDuration(event.startedAt, event.finishedAt)
+  const CategoryIcon = CATEGORY_ICON[event.category]
+  const StatusIcon = STATUS_ICON[event.status]
+  const statusLabel = STATUS_LABEL[event.status]
+  const hasParams = !!event.params && Object.keys(event.params).length > 0
+  const hasResult = !!event.result?.trim()
+  const hasError = !!event.error?.trim()
+  const hasDetails = hasParams || hasResult || hasError
+
+  return (
+    <div
+      className={cn(
+        "group relative text-[12px]",
+        compact ? "px-1" : "px-2",
+        isError
+          ? "ml-0 border-l-2 border-red-500/60 bg-red-50/30 dark:bg-red-950/10"
+          : `${CATEGORY_BAR[event.category]} ml-0 border-l-2`,
+      )}
+    >
+      <button
+        type="button"
+        onClick={hasDetails ? () => setExpanded(!expanded) : undefined}
+        className="flex min-h-8 w-full items-start gap-1.5 py-1 text-left"
+        aria-expanded={hasDetails ? expanded : undefined}
+        aria-label={`${event.description},状态:${statusLabel}`}
+      >
+        <CategoryIcon
+          aria-hidden="true"
+          className={cn(
+            "mt-0.5 h-3.5 w-3.5 shrink-0 opacity-70",
+            isError ? "text-red-500 dark:text-red-400" : "text-muted-foreground",
+          )}
+        />
+        <span className={cn(
+          "min-w-0 flex-1 break-words",
+          isError ? "font-medium text-red-700 dark:text-red-300" : "text-foreground/80",
+        )}>
+          {event.description}
+        </span>
+        {duration && (
+          <span className="mt-0.5 shrink-0 text-[10px] text-muted-foreground/50">
+            {duration}
+          </span>
+        )}
+        <span className={cn(
+          "ml-1 mt-0.5 flex shrink-0 items-center gap-1 text-[10px]",
+          STATUS_COLOR[event.status],
+        )}>
+          <StatusIcon
+            aria-hidden="true"
+            className={cn("h-3.5 w-3.5", isRunning && "animate-spin")}
+          />
+          <span>{statusLabel}</span>
+        </span>
+      </button>
+
+      {expanded && hasDetails && (
+        <div className={cn(
+          "space-y-1 pb-1 text-[11px] text-muted-foreground",
+          compact ? "pl-4" : "pl-5",
+        )}>
+          {hasParams && event.params && (
+            <div className="space-y-0.5">
+              <div className="text-[10px] font-medium text-muted-foreground/70">参数</div>
+              {Object.entries(event.params).map(([key, value]) => (
+                <div key={key} className="break-all">
+                  <span className="text-muted-foreground/60">{key}:</span>{" "}
+                  <span>{formatParamValue(value)}</span>
+                </div>
+              ))}
+            </div>
+          )}
+          {hasResult && event.result && (
+            <div className="space-y-0.5">
+              <div className="text-[10px] font-medium text-muted-foreground/70">结果</div>
+              <div className="max-h-40 overflow-y-auto whitespace-pre-wrap break-words rounded bg-muted/30 p-1.5 text-muted-foreground/80">
+                {event.result}
+              </div>
+            </div>
+          )}
+          {hasError && event.error && (
+            <div className="space-y-0.5">
+              <div className="text-[10px] font-medium text-red-600 dark:text-red-400">错误</div>
+              <div className="max-h-40 overflow-y-auto whitespace-pre-wrap break-words rounded bg-red-50/50 p-1.5 text-red-600 dark:bg-red-950/20 dark:text-red-400">
+                {event.error}
+              </div>
+            </div>
+          )}
+        </div>
+      )}
+    </div>
+  )
+}
+
+export const ToolCallEvent = memo(ToolCallEventImpl)

+ 57 - 0
src/components/common/timeline-tool-group.tsx

@@ -0,0 +1,57 @@
+import { memo, useState, type CSSProperties } from "react"
+import { Check, ChevronDown, ChevronRight } from "lucide-react"
+import type { ToolCallGroupItem } from "./timeline-types"
+import { ToolCallEvent } from "./timeline-tool-event"
+
+interface ToolCallGroupProps {
+  group: ToolCallGroupItem
+  style?: CSSProperties
+}
+
+export function getToolCallGroupRenderKey(group: ToolCallGroupItem): string {
+  return `tool-group:${JSON.stringify([group.kind, group.items[0]?.id ?? group.id])}`
+}
+
+function formatDuration(durationMs?: number): string {
+  if (!durationMs || durationMs <= 0) return ""
+  if (durationMs < 1000) return `${durationMs}ms`
+  return `${(durationMs / 1000).toFixed(1)}s`
+}
+
+function ToolCallGroupImpl({ group, style }: ToolCallGroupProps) {
+  const [expanded, setExpanded] = useState(false)
+  const duration = formatDuration(group.durationMs)
+  const Chevron = expanded ? ChevronDown : ChevronRight
+
+  return (
+    <div className="px-2 text-[12px]" style={style}>
+      <button
+        type="button"
+        onClick={() => setExpanded(!expanded)}
+        className="flex min-h-8 w-full items-center gap-1.5 py-1 text-left text-muted-foreground"
+        aria-expanded={expanded}
+        aria-label={`${group.label},${group.items.length}项`}
+      >
+        <Chevron aria-hidden="true" className="h-3.5 w-3.5 shrink-0" />
+        <Check aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-emerald-500/45" />
+        <span className="min-w-0 flex-1 break-words text-foreground/75">{group.label}</span>
+        <span className="shrink-0 text-[10px]">{group.items.length}项</span>
+        {duration && <span className="shrink-0 text-[10px] text-muted-foreground/50">{duration}</span>}
+      </button>
+
+      {expanded && (
+        <div className="ml-3 border-l border-border/60 pl-1">
+          {group.items.map((item) => (
+            <ToolCallEvent
+              key={`${getToolCallGroupRenderKey(group)}:${item.id}`}
+              event={item}
+              compact
+            />
+          ))}
+        </div>
+      )}
+    </div>
+  )
+}
+
+export const ToolCallGroup = memo(ToolCallGroupImpl)

+ 155 - 0
src/components/common/timeline-types.spec.ts

@@ -0,0 +1,155 @@
+import { describe, expect, it } from "vitest"
+import {
+  createStreamingEventBuilder,
+  interleaveThinkingWithToolCalls,
+  type ToolCallEventItem,
+} from "./timeline-types"
+
+function createToolCall(
+  id: string,
+  status: ToolCallEventItem["status"],
+): ToolCallEventItem {
+  return {
+    id,
+    name: "read_chapter",
+    description: "读取章节",
+    category: "read",
+    status,
+  }
+}
+
+describe("createStreamingEventBuilder", () => {
+  it("keeps both tool ids in order while replacing the second running call with done", () => {
+    const builder = createStreamingEventBuilder("thinking")
+    const firstCall = createToolCall("call-1", "running")
+    const secondCall = createToolCall("call-2", "running")
+    const doneSecondCall = { ...secondCall, status: "done" as const, result: "完成" }
+
+    builder.update("正在读取章节", [firstCall, secondCall], true)
+    const doneEvents = builder.update("正在读取章节", [firstCall, doneSecondCall], false)
+
+    expect(doneEvents.slice(1).map((event) => event.data.id)).toEqual(["call-1", "call-2"])
+    expect(doneEvents[2]).toEqual({ kind: "tool_call", data: doneSecondCall })
+    expect(doneEvents[2]?.data).toBe(doneSecondCall)
+  })
+
+  it("keeps both tool ids in order while replacing the first running call with error", () => {
+    const builder = createStreamingEventBuilder("thinking")
+    const firstCall = createToolCall("call-1", "running")
+    const secondCall = createToolCall("call-2", "running")
+    const errorFirstCall = { ...firstCall, status: "error" as const, error: "读取失败" }
+
+    builder.update("正在读取章节", [firstCall, secondCall], true)
+    const errorEvents = builder.update("正在读取章节", [errorFirstCall, secondCall], false)
+
+    expect(errorEvents.slice(1).map((event) => event.data.id)).toEqual(["call-1", "call-2"])
+    expect(errorEvents[1]).toEqual({ kind: "tool_call", data: errorFirstCall })
+    expect(errorEvents[1]?.data).toBe(errorFirstCall)
+  })
+
+  it("keeps all thinking content in one real block instead of slicing around tools", () => {
+    const builder = createStreamingEventBuilder("thinking")
+    const thinkingText = "先分析用户目标。再读取章节。最后整理结果。"
+    const toolCalls = [
+      createToolCall("call-1", "done"),
+      createToolCall("call-2", "running"),
+    ]
+
+    const events = builder.update(thinkingText, toolCalls, true)
+    const thinkingEvents = events.filter((event) => event.kind === "thinking")
+
+    expect(events.map((event) => event.kind)).toEqual([
+      "thinking",
+      "tool_call",
+      "tool_call",
+    ])
+    expect(thinkingEvents).toEqual([
+      {
+        kind: "thinking",
+        data: {
+          id: "thinking-current",
+          content: thinkingText,
+          streaming: true,
+        },
+      },
+    ])
+  })
+
+  it("keeps the thinking id stable across content updates", () => {
+    const builder = createStreamingEventBuilder("thinking")
+
+    const firstEvents = builder.update("第一段", [], true)
+    const secondEvents = builder.update("第一段和第二段", [], true)
+
+    expect(firstEvents[0]?.data.id).toBe("thinking-current")
+    expect(secondEvents[0]?.data.id).toBe("thinking-current")
+  })
+
+  it("keeps the thinking id stable after reset", () => {
+    const builder = createStreamingEventBuilder("thinking")
+
+    const firstEvents = builder.update("第一轮", [], true)
+    builder.reset()
+    const secondEvents = builder.update("第二轮", [], true)
+
+    expect(firstEvents[0]?.data.id).toBe("thinking-current")
+    expect(secondEvents[0]?.data.id).toBe("thinking-current")
+  })
+
+  it("returns an empty thinking placeholder while streaming without content or tools", () => {
+    const builder = createStreamingEventBuilder("thinking")
+
+    expect(builder.update("", [], true)).toEqual([
+      {
+        kind: "thinking",
+        data: {
+          id: "thinking-current",
+          content: "",
+          streaming: true,
+        },
+      },
+    ])
+  })
+})
+
+describe("interleaveThinkingWithToolCalls", () => {
+  it("keeps non-empty thinking in one complete block before all tools", () => {
+    const thinkingText = "先分析用户目标。再读取章节。最后整理结果。"
+    const toolCalls = [
+      createToolCall("call-1", "done"),
+      createToolCall("call-2", "running"),
+    ]
+
+    const events = interleaveThinkingWithToolCalls(
+      thinkingText,
+      true,
+      toolCalls,
+      "thinking",
+    )
+
+    expect(events).toEqual([
+      {
+        kind: "thinking",
+        data: {
+          id: "thinking-0",
+          content: thinkingText,
+          streaming: true,
+        },
+      },
+      { kind: "tool_call", data: toolCalls[0] },
+      { kind: "tool_call", data: toolCalls[1] },
+    ])
+  })
+
+  it("returns only tools for empty thinking", () => {
+    const toolCalls = [
+      createToolCall("call-1", "done"),
+      createToolCall("call-2", "running"),
+    ]
+
+    expect(interleaveThinkingWithToolCalls("  ", false, toolCalls, "thinking")).toEqual([
+      { kind: "tool_call", data: toolCalls[0] },
+      { kind: "tool_call", data: toolCalls[1] },
+    ])
+  })
+})

+ 119 - 0
src/components/common/timeline-types.ts

@@ -0,0 +1,119 @@
+import type { ToolCallStatus } from "@/lib/agent/types"
+
+export type TimelineToolCategory = "read" | "write" | "action" | "virtual"
+
+export interface ToolCallEventItem {
+  id: string
+  name: string
+  description: string
+  category: TimelineToolCategory
+  status: ToolCallStatus
+  params?: Record<string, unknown>
+  result?: string
+  error?: string
+  startedAt?: number
+  finishedAt?: number
+}
+
+export interface ThinkingEventItem {
+  id: string
+  content: string
+  streaming: boolean
+}
+
+export type TimelineToolGroupKind = "chapters" | "context"
+
+export interface ToolCallGroupItem {
+  id: string
+  kind: TimelineToolGroupKind
+  label: string
+  items: ToolCallEventItem[]
+  durationMs?: number
+}
+
+export type TimelineEvent =
+  | { kind: "thinking"; data: ThinkingEventItem }
+  | { kind: "tool_call"; data: ToolCallEventItem }
+  | { kind: "tool_group"; data: ToolCallGroupItem }
+
+export interface EventStreamProps {
+  events: TimelineEvent[]
+  isStreaming: boolean
+  totalDurationMs?: number
+  totalTokens?: number
+}
+
+export function interleaveThinkingWithToolCalls(
+  thinkingText: string,
+  thinkingStreaming: boolean,
+  toolCalls: ToolCallEventItem[],
+  thinkingIdPrefix: string,
+): TimelineEvent[] {
+  const events: TimelineEvent[] = []
+  const trimmedThinking = thinkingText.trim()
+
+  if (trimmedThinking) {
+    events.push({
+      kind: "thinking",
+      data: {
+        id: `${thinkingIdPrefix}-0`,
+        content: trimmedThinking,
+        streaming: thinkingStreaming,
+      },
+    })
+  }
+
+  for (const call of toolCalls) {
+    events.push({ kind: "tool_call", data: call })
+  }
+
+  return events
+}
+
+export interface StreamingEventBuilder {
+  update: (
+    thinkingText: string,
+    toolCalls: ToolCallEventItem[],
+    thinkingStreaming: boolean,
+  ) => TimelineEvent[]
+  reset: () => void
+}
+
+export function createStreamingEventBuilder(thinkingIdPrefix: string): StreamingEventBuilder {
+  const thinkingId = `${thinkingIdPrefix}-current`
+
+  return {
+    update(thinkingText, toolCalls, thinkingStreaming) {
+      const events: TimelineEvent[] = []
+      const trimmedThinking = thinkingText.trim()
+
+      if (trimmedThinking) {
+        events.push({
+          kind: "thinking",
+          data: {
+            id: thinkingId,
+            content: trimmedThinking,
+            streaming: thinkingStreaming,
+          },
+        })
+      } else if (thinkingStreaming && toolCalls.length === 0) {
+        events.push({
+          kind: "thinking",
+          data: {
+            id: thinkingId,
+            content: "",
+            streaming: true,
+          },
+        })
+      }
+
+      for (const call of toolCalls) {
+        events.push({ kind: "tool_call", data: call })
+      }
+
+      return events
+    },
+
+    reset() {},
+  }
+}

+ 30 - 0
src/components/common/workflow-responsive-width.spec.tsx

@@ -0,0 +1,30 @@
+import { readFileSync } from "node:fs"
+import { describe, expect, it } from "vitest"
+
+function source(path: string): string {
+  return readFileSync(new URL(path, import.meta.url), "utf8")
+}
+
+describe("普通 AI 会话与 AI 大纲思考流程框宽度", () => {
+  it("共享工作流与事件流根容器全宽、可收缩且隐藏横向溢出", () => {
+    const workflow = source("../chat/agent-workflow-panel.tsx")
+    const eventStream = source("./event-stream.tsx")
+    expect(workflow).toContain("w-full min-w-0 max-w-full overflow-hidden")
+    expect(eventStream).toContain("relative w-full min-w-0 max-w-full overflow-x-hidden")
+    expect(eventStream).toContain("w-full min-w-0 max-w-full")
+    expect(eventStream).toContain("overflow-x-hidden overflow-y-auto")
+  })
+
+  it("普通 AI 消息列全宽且不会被长内容撑开", () => {
+    const panel = source("../chat/chat-panel.tsx")
+    expect(panel).toContain("min-w-0 flex-1 overflow-y-auto overflow-x-hidden")
+    expect(panel).toContain("flex w-full min-w-0 max-w-full flex-col")
+  })
+
+  it("AI 大纲仅用户气泡使用内容宽度,助手工作流始终占满消息列", () => {
+    const panel = source("../sources/outline-chat-panel.tsx")
+    expect(panel).toContain("h-full w-full min-w-0 max-w-full")
+    expect(panel).toContain("flex w-full min-w-0 max-w-full")
+    expect(panel).toContain('msg.role === "user" ? "w-fit max-w-full lg:max-w-[50vw]" : "w-full min-w-0 max-w-full"')
+  })
+})

+ 41 - 6
src/components/error-boundary.tsx

@@ -1,4 +1,5 @@
 import { Component, type ReactNode } from "react"
+import { isTauri } from "@/lib/platform"
 
 interface Props {
   children: ReactNode
@@ -8,34 +9,68 @@ interface Props {
 interface State {
   hasError: boolean
   error: Error | null
+  componentStack: string | null
 }
 
 export class ErrorBoundary extends Component<Props, State> {
   constructor(props: Props) {
     super(props)
-    this.state = { hasError: false, error: null }
+    this.state = { hasError: false, error: null, componentStack: null }
   }
 
   static getDerivedStateFromError(error: Error): State {
-    return { hasError: true, error }
+    return { hasError: true, error, componentStack: null }
   }
 
   componentDidCatch(error: Error, info: React.ErrorInfo) {
     console.error("ErrorBoundary caught:", error, info.componentStack)
+    this.setState({ componentStack: info.componentStack ?? null })
+    if (isTauri()) {
+      try {
+        import("@tauri-apps/api/core").then(({ invoke }) => {
+          invoke("log_error", {
+            message: error.message,
+            stack: error.stack ?? "",
+            componentStack: info.componentStack ?? "",
+          }).catch(() => {})
+        }).catch(() => {})
+      } catch {
+      }
+    }
   }
 
   render() {
     if (this.state.hasError) {
       if (this.props.fallback) return this.props.fallback
       return (
-        <div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-sm text-muted-foreground">
-          <p className="text-destructive font-medium">Something went wrong</p>
+        <div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-sm text-muted-foreground overflow-auto">
+          <p className="text-destructive font-medium">应用运行出错</p>
           <p className="text-xs max-w-md text-center">{this.state.error?.message}</p>
+          {this.state.componentStack && (
+            <details className="w-full max-w-md text-left">
+              <summary className="cursor-pointer text-xs text-muted-foreground hover:text-foreground">
+                错误详情
+              </summary>
+              <pre className="mt-2 max-h-48 overflow-auto rounded border bg-muted/50 p-2 text-[10px] text-muted-foreground whitespace-pre-wrap break-all">
+                {this.state.componentStack}
+              </pre>
+            </details>
+          )}
+          {this.state.error?.stack && (
+            <details className="w-full max-w-md text-left">
+              <summary className="cursor-pointer text-xs text-muted-foreground hover:text-foreground">
+                调用栈
+              </summary>
+              <pre className="mt-2 max-h-48 overflow-auto rounded border bg-muted/50 p-2 text-[10px] text-muted-foreground whitespace-pre-wrap break-all">
+                {this.state.error.stack}
+              </pre>
+            </details>
+          )}
           <button
             className="rounded border px-3 py-1 text-xs hover:bg-muted"
-            onClick={() => this.setState({ hasError: false, error: null })}
+            onClick={() => this.setState({ hasError: false, error: null, componentStack: null })}
           >
-            Retry
+            重试
           </button>
         </div>
       )

+ 0 - 2
src/components/layout/sidebar-panel.tsx

@@ -35,7 +35,6 @@ import { loadFrameworks, loadSimulationResults, deleteSimulationResult } from "@
 import { loadBinding } from "@/lib/novel/story-simulation/framework-binding"
 import type { StoryFramework } from "@/lib/novel/story-simulation/types"
 import { createDirectory, fileExists, listDirectory, preprocessFile, readFile, writeFile } from "@/commands/fs"
-import { countChapterBodyWords } from "@/lib/chapter-word-count"
 import { getFileName, getFileStem, normalizePath } from "@/lib/path-utils"
 import {
   loadDismantlingLibrary,
@@ -681,7 +680,6 @@ export function SidebarPanel() {
   const enqueueChatReferenceTokens = useChatStore((s) => s.enqueueReferenceTokens)
   const enqueueOutlineReferenceTokens = useOutlineChatStore((s) => s.enqueueReferenceTokens)
   const setOutlineChatOpen = useOutlineGenerationStore((s) => s.setPanelOpen)
-  const dataVersion = useWikiStore((s) => s.dataVersion)
   const [mode, setMode] = useState<"knowledge" | "files">("knowledge")
   const [refreshKey, setRefreshKey] = useState(0)
   const [pendingCreate, setPendingCreate] = useState<KnowledgeCreateRequest | null>(null)

+ 42 - 1
src/components/reference/ReferenceInput.spec.tsx

@@ -154,7 +154,9 @@ describe("ReferenceInput", () => {
     const leftControl = Array.from(footer?.querySelectorAll("button") ?? [])
       .find((button) => button.textContent === "生成大纲")
     const referenceButton = footer?.querySelector("[aria-label='引用内容']")
-    expect(Boolean(leftControl?.compareDocumentPosition(referenceButton!) & Node.DOCUMENT_POSITION_FOLLOWING)).toBe(true)
+    expect(leftControl).toBeTruthy()
+    expect(referenceButton).toBeTruthy()
+    expect(Boolean(leftControl!.compareDocumentPosition(referenceButton!) & Node.DOCUMENT_POSITION_FOLLOWING)).toBe(true)
   })
 
   it("shows a stop action in the footer while streaming", async () => {
@@ -334,4 +336,43 @@ describe("ReferenceInput", () => {
     expect(host.textContent).not.toContain("@第二章")
     expect(onChange).toHaveBeenLastCalledWith("", [])
   })
+  it("keeps editing and references enabled when only submit is disabled", async () => {
+    const onChange = vi.fn()
+    const onSubmit = vi.fn()
+    const onAtTrigger = vi.fn()
+
+    await act(async () => {
+      root.render(
+        <ReferenceInput
+          value="等待发送"
+          tokens={[]}
+          submitDisabled
+          submitDisabledReason="当前已有 3 个会话正在生成"
+          onChange={onChange}
+          onSubmit={onSubmit}
+          onAtTrigger={onAtTrigger}
+          rightControls={<button type="button">模型选择</button>}
+        />,
+      )
+    })
+
+    const editor = host.querySelector("textarea") as HTMLTextAreaElement
+    const send = host.querySelector<HTMLButtonElement>("[aria-label='发送消息']")
+    const reference = host.querySelector<HTMLButtonElement>("[aria-label='引用内容']")
+    expect(editor.disabled).toBe(false)
+    expect(reference?.disabled).toBe(false)
+    expect(send?.disabled).toBe(true)
+    expect(send?.title).toBe("当前已有 3 个会话正在生成")
+    expect(host.textContent).toContain("模型选择")
+
+    await act(async () => {
+      dispatchTextareaInput(editor, "继续编辑")
+      reference?.click()
+      editor.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }))
+    })
+
+    expect(onChange).toHaveBeenCalledWith("继续编辑", [])
+    expect(onAtTrigger).toHaveBeenCalledTimes(1)
+    expect(onSubmit).not.toHaveBeenCalled()
+  })
 })

+ 8 - 4
src/components/reference/ReferenceInput.tsx

@@ -24,6 +24,8 @@ interface ReferenceInputProps {
   tokens: ReferenceToken[]
   placeholder?: string
   disabled?: boolean
+  submitDisabled?: boolean
+  submitDisabledReason?: string
   isStreaming?: boolean
   leftFooterControls?: ReactNode
   rightControls?: ReactNode
@@ -57,6 +59,8 @@ export function ReferenceInput({
   tokens,
   placeholder = "输入提示词,或 @ 引用内容...",
   disabled = false,
+  submitDisabled = false,
+  submitDisabledReason,
   isStreaming = false,
   leftFooterControls,
   rightControls,
@@ -73,7 +77,7 @@ export function ReferenceInput({
   const [inputHeight, setInputHeight] = useState(loadSavedInputHeight)
   const text = isControlled ? value : draft
   const inputDisabled = disabled || isStreaming
-  const canSubmit = text.trim().length > 0 && !inputDisabled
+  const canSubmit = text.trim().length > 0 && !inputDisabled && !submitDisabled
 
   const notifyChange = useCallback(
     (nextText: string, nextTokens: ReferenceToken[]) => {
@@ -119,9 +123,9 @@ export function ReferenceInput({
 
   const handleSubmit = useCallback(() => {
     const plainText = (textareaRef.current?.value ?? text).trim()
-    if (!plainText || inputDisabled) return
+    if (!plainText || inputDisabled || submitDisabled) return
     onSubmit(plainText, tokens)
-  }, [inputDisabled, onSubmit, text, tokens])
+  }, [inputDisabled, onSubmit, submitDisabled, text, tokens])
 
   const handleKeyDown = useCallback(
     (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
@@ -271,7 +275,7 @@ export function ReferenceInput({
               className="rounded-md bg-primary p-1.5 text-primary-foreground hover:bg-primary/90 disabled:cursor-not-allowed disabled:bg-muted disabled:text-muted-foreground"
               disabled={!canSubmit}
               onClick={handleSubmit}
-              title="发送消息"
+              title={submitDisabled ? (submitDisabledReason ?? "暂时无法发送") : "发送消息"}
               aria-label="发送消息"
             >
               <ArrowUp className="h-4 w-4" />

+ 83 - 0
src/components/sources/outline-chat-multi-session.spec.tsx

@@ -0,0 +1,83 @@
+import { readFileSync } from "node:fs"
+import { resolve } from "node:path"
+import { describe, expect, it } from "vitest"
+
+import { EMPTY_CONVERSATION_CREATE_REASON } from "../../lib/conversation-create-guard"
+
+const panelSource = readFileSync(resolve(__dirname, "outline-chat-panel.tsx"), "utf8")
+const storeSource = readFileSync(resolve(__dirname, "../../stores/outline-chat-store.ts"), "utf8")
+
+describe("OutlineChatPanel 多会话运行状态", () => {
+  it("当前大纲会话未发送用户消息时禁止新建并暴露中文原因", () => {
+    expect(panelSource).toContain("canCreateNewConversation")
+    expect(panelSource).toContain("EMPTY_CONVERSATION_CREATE_REASON")
+    expect(panelSource).toContain(
+      'activeConv?.messages.some((message) => message.role === "user")',
+    )
+    expect(panelSource).toContain("disabled={!canCreateConversation}")
+    expect(EMPTY_CONVERSATION_CREATE_REASON).toBe(
+      "请先发送当前会话内容,再新建对话。",
+    )
+    expect(panelSource).toContain('className="inline-flex shrink-0"')
+    expect(panelSource).toContain(
+      "title={!canCreateConversation ? EMPTY_CONVERSATION_CREATE_REASON : undefined}",
+    )
+    expect(panelSource).toContain("aria-describedby={!canCreateConversation")
+    expect(panelSource).toContain(
+      '<span id="outline-new-conversation-disabled-reason" className="sr-only">',
+    )
+    expect(
+      panelSource.match(/outline-new-conversation-disabled-reason/g)?.length,
+    ).toBe(2)
+  })
+
+  it("按当前会话读取流式内容和运行状态,不再依赖全局兼容字段", () => {
+    expect(panelSource).toContain("streamingContents")
+    expect(panelSource).toContain("runStates")
+    expect(panelSource).toContain('activeRunState?.status === "running"')
+    expect(panelSource).not.toMatch(/\(s\) => s\.streamingContent[);]/)
+    expect(panelSource).not.toMatch(/\(s\) => s\.isStreaming[);]/)
+    expect(panelSource).not.toContain("setIsStreaming")
+    expect(storeSource).not.toMatch(/^\s*streamingContent:/m)
+    expect(storeSource).not.toMatch(/^\s*isStreaming:/m)
+    expect(storeSource).not.toMatch(/^\s*setIsStreaming:/m)
+  })
+
+  it("为每次任务固定会话和 runId,并由共享 registry 独立停止", () => {
+    expect(panelSource).toContain("outlineConversationRunRegistry")
+    expect(panelSource).toContain("const capturedConvId =")
+    expect(panelSource).toContain("const runId = crypto.randomUUID()")
+    expect(panelSource).toContain("startConversationRun(capturedConvId, runId)")
+    expect(panelSource).toContain("outlineConversationRunRegistry.register(capturedConvId, controller)")
+    expect(panelSource).toMatch(/finishConversationRun\(\s*capturedConvId/)
+    expect(panelSource).toContain("failConversationRun(capturedConvId")
+    expect(panelSource).toContain("stopConversationRun(activeConversationId, runningState.runId)")
+    expect(panelSource).toContain("outlineConversationRunRegistry.abort(activeConversationId)")
+  })
+
+  it("三任务满额只禁发送/生成,输入与模型选择仍可编辑", () => {
+    expect(panelSource).toContain("canStartConversationRun")
+    expect(panelSource).toContain("submitDisabled={submitDisabled}")
+    expect(panelSource).toContain("submitDisabledReason={submitDisabledReason}")
+    expect(panelSource).toContain("大纲 AI 会话最多同时运行 3 个任务,请等待任一任务结束后再发送。")
+    expect(panelSource).toContain("<OutlineGenerationMenu")
+    expect(panelSource).toContain("disabled={submitDisabled}")
+    expect(panelSource).toContain("<ChatModelSelector")
+    expect(panelSource).toContain("disabled={false}")
+  })
+
+  it("顶标签和历史均展示共享状态图标,运行会话删除前确认", () => {
+    expect(panelSource).toContain("ConversationRunStatusIcon")
+    expect(panelSource.match(/<ConversationRunStatusIcon/g)?.length).toBeGreaterThanOrEqual(2)
+    expect(panelSource).toContain("ConversationDeleteConfirmDialog")
+    expect(panelSource).toContain("pendingDeleteConversationId")
+  })
+
+  it("自动保存错误使用右下角持久 Toast 且标题栏不再渲染 saveStatus", () => {
+    expect(panelSource).toContain("toast.error(message, {")
+    expect(panelSource).toContain('title: "自动保存失败"')
+    expect(panelSource).toContain("persistent: true")
+    expect(panelSource).toContain("dedupeKey: `outline-auto-save:${message}`")
+    expect(panelSource).not.toContain("{saveStatus &&")
+  })
+})

+ 154 - 10
src/components/sources/outline-chat-panel.spec.tsx

@@ -1,11 +1,144 @@
+// @vitest-environment jsdom
 import { readFileSync } from "node:fs"
 import { resolve } from "node:path"
-import { describe, expect, it } from "vitest"
+import { act } from "react"
+import { createRoot, type Root } from "react-dom/client"
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
+
+import { OutlineChatPanel } from "./outline-chat-panel"
+import {
+  useOutlineChatStore,
+  type OutlineChatConversation,
+  type OutlineChatMessage,
+} from "../../stores/outline-chat-store"
 
 const source = readFileSync(resolve(__dirname, "outline-chat-panel.tsx"), "utf8")
 const outlineSectionConfigsSource = readFileSync(resolve(__dirname, "../../lib/novel/outline-section-configs.ts"), "utf8")
 
+const mountedRoots: Array<{ container: HTMLDivElement; root: Root }> = []
+
+function conversation(messages: OutlineChatMessage[] = []): OutlineChatConversation {
+  return {
+    id: "outline-active",
+    title: "测试大纲会话",
+    createdAt: 100,
+    updatedAt: 100,
+    messages,
+  }
+}
+
+function setOutlineConversations(
+  conversations: OutlineChatConversation[],
+  activeConversationId: string | null,
+) {
+  useOutlineChatStore.setState({
+    conversations,
+    activeConversationId,
+    streamingContents: {},
+    runStates: {},
+    loaded: true,
+    pendingReferenceTokens: [],
+  })
+}
+
+async function renderOutlineChatPanel() {
+  const container = document.createElement("div")
+  document.body.appendChild(container)
+  const root = createRoot(container)
+  mountedRoots.push({ container, root })
+  await act(async () => {
+    root.render(<OutlineChatPanel onClose={() => {}} />)
+  })
+  return container
+}
+
+function getNewConversationButton(container: HTMLElement): HTMLButtonElement {
+  const button = container.querySelector<HTMLButtonElement>(
+    ".qmai-new-conversation-button",
+  )
+  expect(button).not.toBeNull()
+  return button as HTMLButtonElement
+}
+
+beforeEach(() => {
+  ;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean })
+    .IS_REACT_ACT_ENVIRONMENT = true
+  Object.defineProperty(HTMLElement.prototype, "scrollTo", {
+    configurable: true,
+    value: vi.fn(),
+  })
+  setOutlineConversations([], null)
+})
+
+afterEach(async () => {
+  while (mountedRoots.length > 0) {
+    const mounted = mountedRoots.pop()
+    if (!mounted) continue
+    await act(async () => {
+      mounted.root.unmount()
+    })
+    mounted.container.remove()
+  }
+  setOutlineConversations([], null)
+})
+
 describe("OutlineChatPanel controls", () => {
+  it("根据当前大纲会话的已发送用户消息实时控制新建按钮", async () => {
+    const container = await renderOutlineChatPanel()
+    let button = getNewConversationButton(container)
+    expect(button.disabled).toBe(false)
+    expect(button.getAttribute("aria-describedby")).toBeNull()
+
+    await act(async () => {
+      setOutlineConversations([conversation()], "outline-active")
+    })
+    button = getNewConversationButton(container)
+    expect(button.disabled).toBe(true)
+    expect(button.parentElement?.title).toBe(
+      "请先发送当前会话内容,再新建对话。",
+    )
+    expect(button.title).toBe("请先发送当前会话内容,再新建对话。")
+    const reasonId = button.getAttribute("aria-describedby")
+    expect(reasonId).toBe("outline-new-conversation-disabled-reason")
+    expect(container.querySelector(`#${reasonId}`)?.textContent).toBe(
+      "请先发送当前会话内容,再新建对话。",
+    )
+
+    await act(async () => {
+      setOutlineConversations([
+        conversation([{ id: "assistant-1", role: "assistant", content: "仅有助手消息" }]),
+      ], "outline-active")
+    })
+    button = getNewConversationButton(container)
+    expect(button.disabled).toBe(true)
+    expect(button.parentElement?.title).toBe(
+      "请先发送当前会话内容,再新建对话。",
+    )
+    expect(button.title).toBe("请先发送当前会话内容,再新建对话。")
+    expect(button.getAttribute("aria-describedby")).toBe(
+      "outline-new-conversation-disabled-reason",
+    )
+    expect(
+      container.querySelector("#outline-new-conversation-disabled-reason")
+        ?.textContent,
+    ).toBe("请先发送当前会话内容,再新建对话。")
+
+    await act(async () => {
+      useOutlineChatStore.getState().addMessage("outline-active", {
+        id: "user-1",
+        role: "user",
+        content: "已发送内容",
+      })
+    })
+    button = getNewConversationButton(container)
+    expect(button.disabled).toBe(false)
+    expect(button.title).toBe("新建大纲对话")
+    expect(button.getAttribute("aria-describedby")).toBeNull()
+    expect(
+      container.querySelector("#outline-new-conversation-disabled-reason"),
+    ).toBeNull()
+  })
+
   it("uses the shared accent new conversation button style", () => {
     expect(source).toContain("qmai-new-conversation-button")
     expect(source).toContain('aria-label="新建大纲对话"')
@@ -234,17 +367,28 @@ describe("OutlineChatPanel controls", () => {
     expect(source).toContain("fallbackReason")
   })
 
-  it("子 Agent 结构化输出为空或解析失败时会自动重试一次", () => {
-    expect(source).toContain("retrySubAgentMessages")
-    expect(source).toContain("结构化输出解析失败")
-    expect(source).toContain("请只重新输出一个合法 JSON 对象")
-    expect(source).toContain("subAgentRetryRun")
+  it("子 Agent 重试统一由依赖调度器控制为一次", () => {
+    expect(source).toContain("onStatusChange: (event)")
+    expect(source).toContain('event.status === "retrying"')
+    expect(source).not.toContain("retrySubAgentMessages")
+    expect(source).not.toContain("subAgentRetryRun")
+  })
+
+  it("接入动态 Agent 规划并在规划无效时保留规则规划", () => {
+    expect(source).toContain("buildDynamicOutlinePlannerPrompt")
+    expect(source).toContain("parseDynamicOutlinePlan")
+    expect(source).toContain("outlineWritingSkills.map((skill)")
+    expect(source).toContain("targetConversation?.contextSummary")
+    expect(source).toContain("existingModules: outlineSources")
+    expect(source).toContain("let subAgentPlan = fallbackSubAgentPlan")
+    expect(source).toContain("if (dynamicPlan.ok) subAgentPlan = dynamicPlan.plan")
+    expect(source).not.toContain("failureFallbackThreshold")
   })
 
-  it("keeps wizard prompt bubbles readable and stops streaming in the original conversation", () => {
-    expect(source).toContain("streamingConversationIdRef")
-    expect(source).toContain("streamingConversationIdRef.current = convId")
-    expect(source).toContain("streamingConversationIdRef.current ?? activeConversationId")
+  it("keeps wizard prompt bubbles readable and stops streaming in the selected conversation", () => {
+    expect(source).toContain("outlineConversationRunRegistry")
+    expect(source).toContain("const capturedConvId = convId")
+    expect(source).toContain("outlineConversationRunRegistry.abort(activeConversationId)")
     expect(source).toContain('className="block whitespace-pre-wrap break-words"')
   })
 

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 344 - 266
src/components/sources/outline-chat-panel.tsx


+ 35 - 0
src/components/sources/outline-intent-options-card.tsx

@@ -0,0 +1,35 @@
+import type { IntentClarityResult } from "@/lib/novel/outline-intent-clarity"
+
+interface IntentOptionsCardProps {
+  result: IntentClarityResult
+  onSelectOption: (optionId: string, label: string, description: string) => void
+}
+
+export function IntentOptionsCard({ result, onSelectOption }: IntentOptionsCardProps) {
+  if (result.clarity !== "needs_input") return null
+
+  return (
+    <div className="mt-3 rounded-md border border-amber-500/30 bg-amber-50/30 p-3 dark:bg-amber-950/10">
+      {result.analysis ? (
+        <div className="mb-2 text-xs text-muted-foreground">{result.analysis}</div>
+      ) : null}
+      <div className="text-sm font-medium mb-2">
+        {result.question || "请选择生成范围:"}
+      </div>
+      <div className="space-y-2">
+        {result.options.map((option) => (
+          <button
+            key={option.id}
+            className="w-full rounded-md border px-3 py-2 text-left text-sm hover:bg-accent transition-colors"
+            onClick={() => onSelectOption(option.id, option.label, option.description)}
+          >
+            <div className="font-medium">{option.label}</div>
+            {option.description ? (
+              <div className="text-xs text-muted-foreground">{option.description}</div>
+            ) : null}
+          </button>
+        ))}
+      </div>
+    </div>
+  )
+}

+ 34 - 0
src/components/sources/outline-multi-agent-panel.spec.tsx

@@ -53,4 +53,38 @@ describe("OutlineMultiAgentPanel", () => {
     expect(html).toContain("多 Agent 失败数量超过阈值:2/3")
     expect(html).toContain("题材 Agent:结构化输出 JSON 解析失败")
   })
+  it("显示等待和重试状态,不显示槽位", () => {
+    const run: OutlineMultiAgentRunState = {
+      mode: "multi-agent",
+      status: "running",
+      maxConcurrency: 3,
+      agents: [
+        {
+          id: "waiting-agent",
+          name: "人物关系 Agent",
+          kind: "character",
+          skillNames: ["人物设计"],
+          taskPrompt: "等待世界观 Agent 完成",
+          status: "waiting",
+        },
+        {
+          id: "retry-agent",
+          name: "伏笔审查 Agent",
+          kind: "foreshadowing",
+          skillNames: ["伏笔审查"],
+          taskPrompt: "审查伏笔回收",
+          status: "retrying",
+          retryCount: 1,
+        },
+      ],
+      merge: { status: "pending" },
+    }
+
+    const html = renderToStaticMarkup(<OutlineMultiAgentPanel run={run} />)
+    expect(html).toContain("等待中")
+    expect(html).toContain("重试中")
+    expect(html).toContain("(1/1)")
+    expect(html).not.toContain("槽位")
+  })
+
 })

+ 8 - 1
src/components/sources/outline-multi-agent-panel.tsx

@@ -23,6 +23,11 @@ function statusLabel(status: OutlineMultiAgentStepStatus | OutlineMultiAgentRunS
       return "规划中"
     case "running":
       return "运行中"
+    case "retrying":
+      return "重试中"
+    case "waiting":
+    case "pending":
+      return "等待中"
     case "merging":
       return "合并中"
     case "fallback":
@@ -39,7 +44,7 @@ function statusLabel(status: OutlineMultiAgentStepStatus | OutlineMultiAgentRunS
 }
 
 function StatusIcon({ status }: { status: OutlineMultiAgentStepStatus | OutlineMultiAgentRunState["status"] }) {
-  if (status === "running" || status === "planning" || status === "merging") {
+  if (status === "running" || status === "retrying" || status === "planning" || status === "merging") {
     return <Loader2 className="h-3.5 w-3.5 animate-spin text-sky-600 dark:text-sky-400" />
   }
   if (status === "done") {
@@ -95,10 +100,12 @@ export function OutlineMultiAgentPanel({ run }: OutlineMultiAgentPanelProps) {
               <span className={cn(
                 "inline-flex shrink-0 items-center gap-1 text-[11px] text-muted-foreground",
                 agent.status === "error" && "text-red-600 dark:text-red-400",
+                agent.status === "retrying" && "text-amber-600 dark:text-amber-400",
                 agent.status === "done" && "text-emerald-700 dark:text-emerald-300",
               )}>
                 <StatusIcon status={agent.status} />
                 {statusLabel(agent.status)}
+                {agent.status === "retrying" ? "(1/1)" : ""}
               </span>
             </div>
             <div className="mt-1.5 grid gap-1.5 pl-5 leading-5 text-muted-foreground">

+ 30 - 0
src/components/sources/outline-next-step-card.tsx

@@ -0,0 +1,30 @@
+import type { NextStepRecommendation } from "@/lib/novel/outline-next-step"
+
+interface NextStepCardProps {
+  recommendation: NextStepRecommendation
+  onSelectRecommendation: (recId: string, label: string) => void
+}
+
+export function NextStepCard({ recommendation, onSelectRecommendation }: NextStepCardProps) {
+  if (!recommendation.recommendations || recommendation.recommendations.length === 0) return null
+
+  return (
+    <div className="mt-3 rounded-md border border-sky-500/30 bg-sky-50/30 p-3 dark:bg-sky-950/10">
+      <div className="text-sm font-medium mb-2">接下来想做什么?</div>
+      <div className="space-y-2">
+        {recommendation.recommendations.map((rec) => (
+          <button
+            key={rec.id}
+            className="w-full rounded-md border px-3 py-2 text-left text-sm hover:bg-accent transition-colors"
+            onClick={() => onSelectRecommendation(rec.id, rec.label)}
+          >
+            <div className="font-medium">{rec.label}</div>
+            {rec.reason ? (
+              <div className="text-xs text-muted-foreground">{rec.reason}</div>
+            ) : null}
+          </button>
+        ))}
+      </div>
+    </div>
+  )
+}

+ 98 - 73
src/components/sources/outline-workflow-stages.tsx

@@ -1,81 +1,106 @@
-import React, { useState } from "react"
-import { CheckCircle2, ChevronRight, Loader2 } from "lucide-react"
-import { cn } from "@/lib/utils"
-import { buildOutlineStages, type OutlineStageInput, type OutlineStage } from "@/lib/novel/outline-stage-trace"
+import React, { useMemo, useRef, useEffect } from "react"
+import type { ToolCallRecord } from "@/lib/agent/tool-events"
+import type { ToolCallEventItem, TimelineToolCategory } from "@/components/common/timeline-types"
+import { createStreamingEventBuilder } from "@/components/common/timeline-types"
+import { EventStream } from "@/components/common/event-stream"
+import { extractThinkingContent } from "@/lib/novel/outline-stage-trace"
+import { getWorkflowToolDescription } from "@/lib/agent/workflow-trace"
 
-interface OutlineWorkflowStagesProps extends OutlineStageInput {}
+interface OutlineWorkflowStagesProps {
+  toolCalls: ToolCallRecord[]
+  content: string
+  isStreaming: boolean
+}
 
-function StageRow({ stage, open, onToggle }: {
-  stage: OutlineStage
-  open: boolean
-  onToggle: () => void
-}) {
-  return (
-    <div
-      className={cn(
-        "rounded-md border transition-all duration-250 ease-out",
-        stage.status === "active" && "border-sky-500/40 bg-sky-50/30 dark:bg-sky-950/10",
-        stage.status === "done" && "border-emerald-500/20 bg-emerald-50/10 dark:bg-emerald-950/5",
-        "opacity-0 translate-y-1 animate-[fadeIn_250ms_ease-out_forwards]"
-      )}
-    >
-      <button
-        type="button"
-        onClick={onToggle}
-        className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs"
-      >
-        {stage.status === "done" ? (
-          <CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-emerald-600 dark:text-emerald-400" />
-        ) : stage.status === "active" ? (
-          <Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-sky-600 dark:text-sky-400" />
-        ) : null}
-        <span className="font-medium">{stage.title}</span>
-        {stage.summary && (
-          <span className="text-muted-foreground">· {stage.summary}</span>
-        )}
-        <ChevronRight
-          className={cn(
-            "ml-auto h-3 w-3 transition-transform",
-            open && "rotate-90"
-          )}
-        />
-      </button>
-      {open && stage.details.length > 0 && (
-        <div className="border-t border-border/50 px-3 py-2 text-xs text-muted-foreground space-y-1">
-          {stage.details.map((detail, i) => (
-            <div key={i}>{detail}</div>
-          ))}
-        </div>
-      )}
-    </div>
-  )
+const WRITE_TOOLS = new Set(["write_chapter", "write_outline_node", "write_memory", "write_chapter_outline"])
+const READ_TOOLS = new Set([
+  "list_chapters", "list_outlines", "list_memories", "list_deductions",
+  "read_chapter", "read_outline", "read_memory", "read_deduction",
+  "read_chat_history", "read_outline_history", "search_chapters",
+  "chapter_context", "chapter_previous_analysis", "load_context", "trim_context",
+])
+const ACTION_TOOLS = new Set(["route_task", "apply_skill"])
+
+function getToolCallCategory(name: string): TimelineToolCategory {
+  if (WRITE_TOOLS.has(name)) return "write"
+  if (READ_TOOLS.has(name)) return "read"
+  if (ACTION_TOOLS.has(name)) return "action"
+  return "virtual"
 }
 
-export function OutlineWorkflowStages(props: OutlineWorkflowStagesProps) {
-  const stages = buildOutlineStages(props)
-  const visibleStages = stages.filter((s) => s.status !== "hidden")
+function adaptToolCall(call: ToolCallRecord): ToolCallEventItem {
+  const isError = call.status === "error"
+  const callAny = call as any
+  return {
+    id: call.id,
+    name: call.name,
+    description: getWorkflowToolDescription({
+      name: call.name,
+      params: call.params as Record<string, unknown>,
+      result: call.result,
+      status: call.status,
+    } as Parameters<typeof getWorkflowToolDescription>[0]),
+    category: getToolCallCategory(call.name),
+    status: call.status,
+    params: call.params as Record<string, unknown>,
+    result: isError ? undefined : call.result,
+    error: isError ? call.result : undefined,
+    startedAt: callAny.startedAt,
+    finishedAt: callAny.finishedAt,
+  }
+}
 
-  const defaultOpenId = stages.find((s) => s.status === "active")?.kind ?? null
-  const [openMap, setOpenMap] = useState<Record<string, boolean>>({})
+export const OutlineWorkflowStages = React.memo(function OutlineWorkflowStages(
+  props: OutlineWorkflowStagesProps,
+) {
+  const { toolCalls, content, isStreaming } = props
+  const builderRef = useRef(createStreamingEventBuilder("outline-thinking"))
+  const wasStreamingRef = useRef(false)
 
-  if (visibleStages.length === 0) return null
+  const thinkingExtract = useMemo(() => extractThinkingContent(content), [content])
+  const thinkingText = thinkingExtract.text || ""
+  const thinkingStreaming = thinkingExtract.streaming || isStreaming
 
-  return (
-    <div className="mb-2 space-y-1">
-      <div className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground mb-1">
-        <span>思考过程</span>
-      </div>
-      {visibleStages.map((stage) => {
-        const isOpen = openMap[stage.kind] ?? (stage.status === "active")
-        return (
-          <StageRow
-            key={stage.kind}
-            stage={stage}
-            open={isOpen}
-            onToggle={() => setOpenMap((prev) => ({ ...prev, [stage.kind]: !isOpen }))}
-          />
-        )
-      })}
-    </div>
+  const sortedCalls = useMemo(() => {
+    return [...toolCalls].sort((a, b) => {
+      const aStart = (a as { startedAt?: number }).startedAt ?? 0
+      const bStart = (b as { startedAt?: number }).startedAt ?? 0
+      return aStart - bStart
+    })
+  }, [toolCalls])
+
+  const adaptedCalls = useMemo(
+    () => sortedCalls.map(adaptToolCall),
+    [sortedCalls],
   )
-}
+
+  const hasRunningTool = toolCalls.some((c) => c.status === "running")
+  const actuallyStreaming = isStreaming || hasRunningTool
+
+  useEffect(() => {
+    if (!wasStreamingRef.current && actuallyStreaming) {
+      builderRef.current.reset()
+    }
+    wasStreamingRef.current = actuallyStreaming
+  }, [actuallyStreaming])
+
+  const events = useMemo(
+    () => builderRef.current.update(thinkingText, adaptedCalls, thinkingStreaming),
+    [thinkingText, adaptedCalls, thinkingStreaming],
+  )
+
+  const totalDurationMs = useMemo(() => {
+    if (toolCalls.length > 0) {
+      const startedAts = toolCalls.map((c) => (c as { startedAt?: number }).startedAt).filter(Boolean) as number[]
+      const finishedAts = toolCalls.map((c) => (c as { finishedAt?: number }).finishedAt).filter(Boolean) as number[]
+      if (startedAts.length > 0 && finishedAts.length > 0) {
+        return Math.max(...finishedAts) - Math.min(...startedAts)
+      }
+    }
+    return undefined
+  }, [toolCalls])
+
+  if (events.length === 0 && !actuallyStreaming) return null
+
+  return <EventStream events={events} isStreaming={actuallyStreaming} totalDurationMs={totalDurationMs} />
+})

+ 46 - 0
src/hooks/use-batched-value.ts

@@ -0,0 +1,46 @@
+import { useEffect, useState, useRef } from "react"
+
+export function useBatchedValue<T>(value: T, isStreaming: boolean): T {
+  const [batched, setBatched] = useState(value)
+  const rafRef = useRef<number | null>(null)
+  const pendingRef = useRef<T>(value)
+  const hasPendingRef = useRef(false)
+
+  useEffect(() => {
+    if (!isStreaming) {
+      if (rafRef.current !== null) {
+        cancelAnimationFrame(rafRef.current)
+        rafRef.current = null
+      }
+      if (hasPendingRef.current) {
+        setBatched(pendingRef.current)
+        hasPendingRef.current = false
+      } else {
+        setBatched(value)
+      }
+      return
+    }
+
+    pendingRef.current = value
+    hasPendingRef.current = true
+
+    if (rafRef.current !== null) return
+
+    rafRef.current = requestAnimationFrame(() => {
+      rafRef.current = null
+      if (hasPendingRef.current) {
+        setBatched(pendingRef.current)
+        hasPendingRef.current = false
+      }
+    })
+
+    return () => {
+      if (rafRef.current !== null) {
+        cancelAnimationFrame(rafRef.current)
+        rafRef.current = null
+      }
+    }
+  }, [value, isStreaming])
+
+  return batched
+}

+ 94 - 0
src/hooks/use-streaming-text.ts

@@ -0,0 +1,94 @@
+import { useEffect, useRef, useState } from "react"
+
+/**
+ * Zed 风格的流式文本缓冲 hook。
+ *
+ * 核心策略(对齐 Zed StreamingTextBuffer):
+ * - 不立即显示收到的 token,而是缓冲到 pending
+ * - 16ms tick 逐步揭示,总时间控制在 ~200ms
+ * - 待揭示文本越多,每 tick 揭示的字节越多
+ * - 流式结束后立即 flush 全部剩余文本
+ * - 定时器独立于 token 到达,不会因 token 频率而重建
+ *
+ * 这创造了平滑的打字机效果,避免 choppy chunk-at-a-time 更新。
+ */
+const TICK_MS = 16
+const REVEAL_TARGET_MS = 200
+const TICKS_PER_TARGET = Math.max(1, Math.round(REVEAL_TARGET_MS / TICK_MS))
+
+export function useStreamingText(rawText: string, isStreaming: boolean): string {
+  const [revealed, setRevealed] = useState(rawText)
+  const revealedRef = useRef(rawText)
+  const pendingRef = useRef("")
+  const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
+  const lastRawRef = useRef(rawText)
+
+  // 处理文本变化:将新增内容追加到 pending 缓冲
+  useEffect(() => {
+    if (!isStreaming) {
+      // 流式结束:停止定时器,立即 flush 全部内容
+      if (timerRef.current !== null) {
+        clearInterval(timerRef.current)
+        timerRef.current = null
+      }
+      pendingRef.current = ""
+      if (revealedRef.current !== rawText) {
+        revealedRef.current = rawText
+        setRevealed(rawText)
+      }
+      lastRawRef.current = rawText
+      return
+    }
+
+    // 对齐 Zed update_text_in_place: 检测前缀扩展
+    const prev = lastRawRef.current
+    if (rawText.startsWith(prev)) {
+      // 增量追加(常见流式情况)
+      pendingRef.current += rawText.slice(prev.length)
+    } else {
+      // 内容被重置(非前缀扩展),全量替换
+      // 对齐 Zed reset: 不立即清空 revealed,保持旧内容可见
+      pendingRef.current = rawText
+      revealedRef.current = ""
+      setRevealed("")
+    }
+    lastRawRef.current = rawText
+  }, [rawText, isStreaming])
+
+  // 独立的揭示定时器:isStreaming 为 true 时启动,false 时停止
+  // 对齐 Zed: 定时器独立于 token 到达频率,16ms tick 持续揭示 pending
+  useEffect(() => {
+    if (!isStreaming) return
+
+    timerRef.current = setInterval(() => {
+      const pending = pendingRef.current
+      if (pending.length === 0) return
+
+      // 对齐 Zed: bytes_to_reveal_per_tick = ceil(pending.len / TICKS_PER_TARGET)
+      // 待揭示文本越多,每 tick 揭示的字节越多,总时间控制在 ~200ms
+      const bytesPerTick = Math.max(1, Math.ceil(pending.length / TICKS_PER_TARGET))
+
+      // 找到安全的 UTF-8 字符边界(避免截断多字节字符)
+      let revealEnd = Math.min(bytesPerTick, pending.length)
+      while (revealEnd < pending.length && (pending.charCodeAt(revealEnd) & 0xc0) === 0x80) {
+        revealEnd++
+      }
+
+      const chunk = pending.slice(0, revealEnd)
+      pendingRef.current = pending.slice(revealEnd)
+
+      const newRevealed = revealedRef.current + chunk
+      revealedRef.current = newRevealed
+      setRevealed(newRevealed)
+    }, TICK_MS)
+
+    return () => {
+      if (timerRef.current !== null) {
+        clearInterval(timerRef.current)
+        timerRef.current = null
+      }
+    }
+  }, [isStreaming])
+
+  return revealed
+}

+ 56 - 1
src/index.css

@@ -855,6 +855,61 @@
 }
 
 @keyframes fadeIn {
-  from { opacity: 0; transform: translateY(4px); }
+  from { opacity: 0; transform: translateY(8px); }
   to { opacity: 1; transform: translateY(0); }
 }
+
+@keyframes slideInLeft {
+  from { opacity: 0; transform: translateX(-12px); }
+  to { opacity: 1; transform: translateX(0); }
+}
+
+@keyframes slideInUp {
+  from { opacity: 0; transform: translateY(12px); }
+  to { opacity: 1; transform: translateY(0); }
+}
+
+.event-stream-scroll::-webkit-scrollbar {
+  width: 4px;
+}
+.event-stream-scroll::-webkit-scrollbar-track {
+  background: transparent;
+}
+.event-stream-scroll::-webkit-scrollbar-thumb {
+  background: hsl(var(--border));
+  border-radius: 2px;
+}
+.event-stream-scroll::-webkit-scrollbar-thumb:hover {
+  background: hsl(var(--muted-foreground) / 0.3);
+}
+
+@media (prefers-reduced-motion: reduce) {
+  [data-toast-region="true"] *,
+  [data-conversation-run-status="running"],
+  .event-stream-scroll *,
+  .event-stream-scroll *::before,
+  .event-stream-scroll *::after {
+    animation-duration: 0.01ms !important;
+    animation-iteration-count: 1 !important;
+    transition-duration: 0.01ms !important;
+  }
+}
+
+.code-highlight {
+  --hl-kw: #d73a49;
+  --hl-str: #032f62;
+  --hl-cm: #6a737d;
+  --hl-num: #005cc5;
+}
+
+.dark .code-highlight {
+  --hl-kw: #ff7b72;
+  --hl-str: #a5d6ff;
+  --hl-cm: #8b949e;
+  --hl-num: #79c0ff;
+}
+
+.hl-kw { color: var(--hl-kw); font-weight: 500; }
+.hl-str { color: var(--hl-str); }
+.hl-cm { color: var(--hl-cm); font-style: italic; }
+.hl-num { color: var(--hl-num); }

+ 17 - 37
src/lib/agent/tools/run-chapter-workflow.ts

@@ -1,7 +1,6 @@
 import type { LlmConfig } from "@/stores/wiki-store"
 import { resolveAiWorkflowMode, type LegacyAiWorkflowMode } from "@/lib/agent/workflow-mode"
 import type {
-  ChapterWorkflowEvent,
   DeepChapterGenerationCallbacks,
   DeepChapterGenerationDeps,
   DeepChapterGenerationInput,
@@ -23,10 +22,6 @@ export interface RunChapterWorkflowToolOptions {
   runDeepChapterGeneration: RunDeepChapterGeneration
   onToolEvent?: (event: AgentToolEvent) => void
   onActivityEvent?: (event: AgentActivityEvent) => void
-  /**
-   * 当 AI 未在工具调用参数中携带 planBlueprint 时,从这里兜底取已确认的章节计划。
-   * 保证用户确认的计划为强制约束,不依赖模型是否遵守自然语言提示。
-   */
   getPlanBlueprint?: () => string | undefined
 }
 
@@ -60,22 +55,6 @@ function normalizeParams(params: Record<string, unknown>): RunChapterWorkflowPar
   }
 }
 
-function workflowEventToToolEvent(parentCallId: string, event: ChapterWorkflowEvent): AgentToolEvent {
-  return {
-    type: event.type === "started" ? "call_started" : event.type === "error" ? "error" : "result",
-    callId: `${parentCallId}:${event.name}`,
-    parentCallId,
-    name: event.name,
-    params: {
-      title: event.title,
-      ...(event.detail ? { detail: event.detail } : {}),
-      ...(event.params ?? {}),
-    },
-    result: event.result ?? event.detail ?? event.title,
-    timestamp: event.timestamp,
-  }
-}
-
 export function createRunChapterWorkflowTool(options: RunChapterWorkflowToolOptions): Tool {
   return {
     name: "run_chapter_workflow",
@@ -120,29 +99,32 @@ export function createRunChapterWorkflowTool(options: RunChapterWorkflowToolOpti
         return "错误:缺少 userRequest,无法运行章节工作流。"
       }
 
-     const parentCallId = context?.callId ?? `run_chapter_workflow:${Date.now()}`
-     const emitToolEvent = context?.onToolEvent ?? options.onToolEvent
-     const emitActivityEvent = context?.onActivityEvent ?? options.onActivityEvent
-      // 兜底:AI 未在工具调用参数中携带 planBlueprint 时,从外部 getter 补上,
-      // 保证用户确认的计划一定进入章节生成链路,不依赖模型是否遵守自然语言提示。
+      const parentCallId = context?.callId ?? `run_chapter_workflow:${Date.now()}`
+      const emitToolEvent = context?.onToolEvent ?? options.onToolEvent
       const planBlueprint = params.planBlueprint?.trim() || options.getPlanBlueprint?.()?.trim() || undefined
+
+      const enhancedUserRequest = planBlueprint
+        ? `${userRequest}\n\n【章节计划】\n${planBlueprint}`
+        : userRequest
+
       const result = await options.runDeepChapterGeneration(
         {
           projectPath: options.projectPath,
-          userRequest,
+          userRequest: enhancedUserRequest,
           chapterNumber: params.chapterNumber,
           llmConfig: options.llmConfig,
-          aiWorkflowMode: params.workflowMode ?? options.aiWorkflowMode,
-          planBlueprint,
         },
         {
-          onWorkflowEvent: (event) => {
-            emitToolEvent?.(workflowEventToToolEvent(parentCallId, event))
+          onThinking: (_content) => {
           },
-          onActivityEvent: (event) => {
-            emitActivityEvent?.({
-              ...event,
-              toolCallId: event.toolCallId ?? parentCallId,
+          onFinalContent: (content) => {
+            emitToolEvent?.({
+              type: "result",
+              callId: parentCallId,
+              name: "run_chapter_workflow",
+              params: {},
+              result: content,
+              timestamp: Date.now(),
             })
           },
         },
@@ -154,8 +136,6 @@ export function createRunChapterWorkflowTool(options: RunChapterWorkflowToolOpti
         "章节工作流完成。",
         `是否返修:${result.revised ? "是" : "否"}`,
         `任务书:${result.taskBrief}`,
-        result.executionReport ? `执行报告:\n${result.executionReport}` : "",
-        result.planCompliance ? `计划履约度:\n${result.planCompliance}` : "",
         "",
         "最终正文:",
         result.finalContent,

+ 2 - 2
src/lib/auto-save.ts

@@ -21,7 +21,7 @@ function doPeriodicSave(): void {
   const state = useChatStore.getState()
   if (Object.keys(state.streamingContents).length > 0) return
   if (state.conversations.length === 0) return
-  saveChatHistory(project.path, state.conversations, state.messages, state.maxHistoryMessages)
+  saveChatHistory(project.path, state.conversations, state.messages, state.maxHistoryMessages, state.runStates)
     .catch((err) => console.error("兜底保存失败:", err))
 }
 
@@ -48,7 +48,7 @@ export function setupAutoSave(): void {
       const project = useWikiStore.getState().project
       // 只在有会话数据时才保存,防止清空 store 时误写入空数据覆盖历史
       if (project && isTauri() && latestState.conversations.length > 0) {
-        saveChatHistory(project.path, latestState.conversations, latestState.messages, latestState.maxHistoryMessages).catch((err) => console.error("自动保存失败:", err))
+        saveChatHistory(project.path, latestState.conversations, latestState.messages, latestState.maxHistoryMessages, latestState.runStates).catch((err) => console.error("自动保存失败:", err))
       }
     }, 2000)
   })

+ 23 - 0
src/lib/conversation-create-guard.spec.ts

@@ -0,0 +1,23 @@
+import { describe, expect, it } from "vitest"
+
+import {
+  canCreateNewConversation,
+  EMPTY_CONVERSATION_CREATE_REASON,
+} from "./conversation-create-guard"
+
+describe("conversation create guard", () => {
+  it("allows the first conversation when no active conversation exists", () => {
+    expect(canCreateNewConversation(null, false)).toBe(true)
+  })
+
+  it("blocks another conversation until the active conversation has a sent user message", () => {
+    expect(canCreateNewConversation("empty", false)).toBe(false)
+    expect(canCreateNewConversation("sent", true)).toBe(true)
+  })
+
+  it("provides the approved Chinese disabled reason", () => {
+    expect(EMPTY_CONVERSATION_CREATE_REASON).toBe(
+      "请先发送当前会话内容,再新建对话。",
+    )
+  })
+})

+ 9 - 0
src/lib/conversation-create-guard.ts

@@ -0,0 +1,9 @@
+export const EMPTY_CONVERSATION_CREATE_REASON =
+  "请先发送当前会话内容,再新建对话。"
+
+export function canCreateNewConversation(
+  activeConversationId: string | null,
+  hasSentUserMessage: boolean,
+): boolean {
+  return activeConversationId === null || hasSentUserMessage
+}

+ 63 - 0
src/lib/conversation-run-registry.spec.ts

@@ -0,0 +1,63 @@
+import { describe, expect, it, vi } from "vitest"
+import { createConversationRunRegistry } from "./conversation-run-registry"
+
+function controllerWithAbortSpy(): AbortController {
+  const controller = new AbortController()
+  vi.spyOn(controller, "abort")
+  return controller
+}
+
+describe("conversation run registry", () => {
+  it("aborts only the requested conversation", () => {
+    const registry = createConversationRunRegistry()
+    const aController = controllerWithAbortSpy()
+    const bController = controllerWithAbortSpy()
+    registry.register("a", aController)
+    registry.register("b", bController)
+
+    expect(registry.abort("a")).toBe(true)
+    expect(aController.abort).toHaveBeenCalledOnce()
+    expect(bController.abort).not.toHaveBeenCalled()
+  })
+
+  it("keeps a controller registered synchronously while replacing the old controller", () => {
+    const registry = createConversationRunRegistry()
+    const oldController = controllerWithAbortSpy()
+    const replacementController = controllerWithAbortSpy()
+    const listenerController = controllerWithAbortSpy()
+    oldController.signal.addEventListener("abort", () => {
+      registry.register("a", listenerController)
+    })
+    registry.register("a", oldController)
+
+    registry.register("a", replacementController)
+
+    expect(oldController.abort).toHaveBeenCalledOnce()
+    expect(registry.get("a")).toBe(listenerController)
+  })
+
+  it("keeps a controller registered synchronously by an abort listener", () => {
+    const registry = createConversationRunRegistry()
+    const oldController = controllerWithAbortSpy()
+    const nextController = controllerWithAbortSpy()
+    oldController.signal.addEventListener("abort", () => {
+      registry.register("a", nextController)
+    })
+    registry.register("a", oldController)
+
+    expect(registry.abort("a")).toBe(true)
+    expect(registry.get("a")).toBe(nextController)
+  })
+
+  it("does not let stale cleanup remove the replacement controller", () => {
+    const registry = createConversationRunRegistry()
+    const oldController = controllerWithAbortSpy()
+    const nextController = controllerWithAbortSpy()
+    registry.register("a", oldController)
+    registry.register("a", nextController)
+
+    registry.remove("a", oldController)
+
+    expect(registry.get("a")).toBe(nextController)
+  })
+})

+ 26 - 0
src/lib/conversation-run-registry.ts

@@ -0,0 +1,26 @@
+export function createConversationRunRegistry() {
+  const controllers = new Map<string, AbortController>()
+
+  return {
+    register(id: string, controller: AbortController) {
+      const previousController = controllers.get(id)
+      controllers.set(id, controller)
+      if (previousController && previousController !== controller) previousController.abort()
+    },
+    get: (id: string) => controllers.get(id),
+    has: (id: string) => controllers.has(id),
+    abort(id: string) {
+      const controller = controllers.get(id)
+      if (!controller) return false
+      if (controllers.get(id) === controller) controllers.delete(id)
+      controller.abort()
+      return true
+    },
+    remove(id: string, controller: AbortController) {
+      if (controllers.get(id) === controller) controllers.delete(id)
+    },
+  }
+}
+
+export const chatConversationRunRegistry = createConversationRunRegistry()
+export const outlineConversationRunRegistry = createConversationRunRegistry()

+ 48 - 0
src/lib/conversation-run-state.spec.ts

@@ -0,0 +1,48 @@
+import { describe, expect, it } from "vitest"
+import {
+  canStartConversationRun,
+  failConversationRun,
+  finishConversationRun,
+  normalizeLoadedRunStates,
+  stopConversationRun,
+  type ConversationRunState,
+} from "./conversation-run-state"
+
+const running = (updatedAt: number): ConversationRunState => ({ status: "running", updatedAt })
+
+describe("conversation run state", () => {
+  it("marks background completion unread", () => {
+    expect(finishConversationRun("b", "a", 20)).toEqual({ status: "completed_unread", updatedAt: 20 })
+  })
+
+  it("returns active completion to idle", () => {
+    expect(finishConversationRun("a", "a", 20)).toEqual({ status: "idle", updatedAt: 20 })
+  })
+
+  it("returns manual stop to idle", () => {
+    expect(stopConversationRun(30)).toEqual({ status: "idle", updatedAt: 30 })
+  })
+
+  it("records a failed run with its error", () => {
+    expect(failConversationRun("网络错误", 35)).toEqual({
+      status: "failed",
+      error: "网络错误",
+      updatedAt: 35,
+    })
+  })
+
+  it("turns loaded running tasks into interrupted tasks", () => {
+    expect(normalizeLoadedRunStates({ a: running(10) }, 40)).toEqual({
+      a: { status: "interrupted", updatedAt: 40, error: "任务在软件关闭前未完成。" },
+    })
+  })
+
+  it("rejects starting an already running conversation", () => {
+    expect(canStartConversationRun({ a: running(1) }, "a")).toBe(false)
+  })
+
+  it("rejects a fourth run at the default concurrency limit", () => {
+    const states = { a: running(1), b: running(2), c: running(3) }
+    expect(canStartConversationRun(states, "d")).toBe(false)
+  })
+})

+ 33 - 0
src/lib/conversation-run-state.ts

@@ -0,0 +1,33 @@
+export type ConversationRunStatus = "idle" | "running" | "completed_unread" | "failed" | "interrupted"
+export interface ConversationRunState { status: ConversationRunStatus; updatedAt: number; error?: string; runId?: string }
+export type ConversationRunStates = Record<string, ConversationRunState>
+export const MAX_CONCURRENT_CONVERSATIONS = 3
+
+export function countRunningConversations(states: ConversationRunStates): number {
+  return Object.values(states).filter((state) => state.status === "running").length
+}
+
+export function canStartConversationRun(states: ConversationRunStates, id: string, limit = 3): boolean {
+  return states[id]?.status !== "running" && countRunningConversations(states) < limit
+}
+
+export function finishConversationRun(id: string, activeId: string | null, updatedAt = Date.now()): ConversationRunState {
+  return { status: id === activeId ? "idle" : "completed_unread", updatedAt }
+}
+
+export function stopConversationRun(updatedAt = Date.now()): ConversationRunState {
+  return { status: "idle", updatedAt }
+}
+
+export function failConversationRun(error: string, updatedAt = Date.now()): ConversationRunState {
+  return { status: "failed", error, updatedAt }
+}
+
+export function normalizeLoadedRunStates(states: ConversationRunStates | undefined, updatedAt = Date.now()): ConversationRunStates {
+  return Object.fromEntries(Object.entries(states ?? {}).map(([id, state]) => [
+    id,
+    state.status === "running"
+      ? { status: "interrupted", error: "任务在软件关闭前未完成。", updatedAt }
+      : state,
+  ]))
+}

+ 27 - 0
src/lib/novel/chapter-positioning-fs.ts

@@ -0,0 +1,27 @@
+import { readFileSync, readdirSync } from "node:fs"
+import { join } from "node:path"
+import type { ChapterPosition } from "./chapter-positioning"
+import { parsePositionTableFromMarkdown } from "./chapter-positioning"
+
+export function getChapterPositionsFromOutline(
+  projectPath: string,
+  volumeNumber: number,
+): ChapterPosition[] {
+  const searchLocations = [projectPath, join(projectPath, "outlines")]
+
+  for (const dir of searchLocations) {
+    try {
+      const files = readdirSync(dir)
+      const pattern = `卷纲_第${volumeNumber}卷`
+      const matched = files.find((f) => f.startsWith(pattern) && f.endsWith(".md"))
+      if (matched) {
+        const content = readFileSync(join(dir, matched), "utf-8")
+        return parsePositionTableFromMarkdown(content)
+      }
+    } catch {
+      continue
+    }
+  }
+
+  return []
+}

+ 1 - 1
src/lib/novel/chapter-positioning.spec.ts

@@ -3,10 +3,10 @@ import {
   checkChapterPositioningDistribution,
   checkAdjacentEmotionClustering,
   parsePositionTableFromMarkdown,
-  getChapterPositionsFromOutline,
   type ChapterPosition,
   type GenrePositioningBaseline,
 } from "./chapter-positioning"
+import { getChapterPositionsFromOutline } from "./chapter-positioning-fs"
 
 // ==================== 辅助工厂函数 ====================
 

+ 0 - 38
src/lib/novel/chapter-positioning.ts

@@ -1,6 +1,3 @@
-import { readFileSync, readdirSync } from "node:fs"
-import { join } from "node:path"
-
 export type ChapterPositionType =
   | "opening"
   | "development"
@@ -148,41 +145,6 @@ export function checkAdjacentEmotionClustering(
   return warnings
 }
 
-/**
- * 从卷纲 Markdown 文件中解析章节定位数据。
- *
- * 卷纲 Markdown 的"章节定位分布表"格式为:
- * | 章 | 定位 | 压力级(1-5) | 情绪强度(1-10) | 说明 |
- * | --- | --- | --- | --- | --- |
- * | 1 | 开场 | 2 | 5 | 介绍 |
- *
- * @param projectPath - 项目根路径
- * @param volumeNumber - 卷号
- * @returns 解析出的章节定位数组
- */
-export function getChapterPositionsFromOutline(
-  projectPath: string,
-  volumeNumber: number,
-): ChapterPosition[] {
-  const searchLocations = [projectPath, join(projectPath, "outlines")]
-
-  for (const dir of searchLocations) {
-    try {
-      const files = readdirSync(dir)
-      const pattern = `卷纲_第${volumeNumber}卷`
-      const matched = files.find((f) => f.startsWith(pattern) && f.endsWith(".md"))
-      if (matched) {
-        const content = readFileSync(join(dir, matched), "utf-8")
-        return parsePositionTableFromMarkdown(content)
-      }
-    } catch {
-      continue
-    }
-  }
-
-  return []
-}
-
 /**
  * 从 Markdown 文本中解析章节定位分布表。
  */

+ 35 - 0
src/lib/novel/outline-chat-session-state.spec.ts

@@ -0,0 +1,35 @@
+import { describe, expect, it } from "vitest"
+import {
+  canApplyOutlineRunEffect,
+  shouldClearOutlineDraft,
+  setOutlineSessionValue,
+} from "./outline-chat-session-state"
+
+describe("AI 大纲多会话副作用隔离", () => {
+  it("旧 run 不能修改新 run 的消息或流内容", () => {
+    const states = { a: { status: "running" as const, updatedAt: 2, runId: "new" } }
+    expect(canApplyOutlineRunEffect(states, "a", "old")).toBe(false)
+    expect(canApplyOutlineRunEffect(states, "a", "new")).toBe(true)
+  })
+
+  it("后台 follow-up 不清除用户当前 B 会话草稿", () => {
+    expect(shouldClearOutlineDraft({ clearDraft: true, invocationConversationId: "a", activeConversationId: "b" })).toBe(false)
+    expect(shouldClearOutlineDraft({ clearDraft: false, invocationConversationId: "b", activeConversationId: "b" })).toBe(false)
+    expect(shouldClearOutlineDraft({ clearDraft: true, invocationConversationId: "b", activeConversationId: "b" })).toBe(true)
+  })
+
+  it("A/B 质量修复目标分别保存在各自会话", () => {
+    const a = setOutlineSessionValue({}, "a", { repairPrompt: "修复 A" })
+    const both = setOutlineSessionValue(a, "b", { repairPrompt: "修复 B" })
+    expect(both.a?.repairPrompt).toBe("修复 A")
+    expect(both.b?.repairPrompt).toBe("修复 B")
+    expect(setOutlineSessionValue(both, "a", null)).toEqual({ b: { repairPrompt: "修复 B" } })
+  })
+
+  it("A/B 大纲意图上下文互不覆盖", () => {
+    const a = setOutlineSessionValue({}, "a", { title: "章节细纲", hint: "第一卷", outputMode: "per_chapter" })
+    const both = setOutlineSessionValue(a, "b", { title: "人物小传", hint: "主角", outputMode: "per_item" })
+    expect(both.a).toEqual({ title: "章节细纲", hint: "第一卷", outputMode: "per_chapter" })
+    expect(both.b).toEqual({ title: "人物小传", hint: "主角", outputMode: "per_item" })
+  })
+})

+ 30 - 0
src/lib/novel/outline-chat-session-state.ts

@@ -0,0 +1,30 @@
+import type { ConversationRunStates } from "@/lib/conversation-run-state"
+
+export function canApplyOutlineRunEffect(
+  states: ConversationRunStates,
+  conversationId: string,
+  runId: string,
+): boolean {
+  const state = states[conversationId]
+  return state?.status === "running" && state.runId === runId
+}
+
+export function shouldClearOutlineDraft(input: {
+  clearDraft: boolean
+  invocationConversationId: string | null
+  activeConversationId: string | null
+}): boolean {
+  return input.clearDraft && input.invocationConversationId === input.activeConversationId
+}
+
+export function setOutlineSessionValue<T>(
+  values: Record<string, T>,
+  conversationId: string,
+  value: T | null,
+): Record<string, T> {
+  if (value === null) {
+    const { [conversationId]: _removed, ...remaining } = values
+    return remaining
+  }
+  return { ...values, [conversationId]: value }
+}

+ 89 - 0
src/lib/novel/outline-dynamic-agent-planner.spec.ts

@@ -0,0 +1,89 @@
+import { describe, expect, it } from "vitest"
+import {
+  buildDynamicOutlinePlannerPrompt,
+  parseDynamicOutlinePlan,
+} from "./outline-dynamic-agent-planner"
+
+const context = {
+  userTask: "生成玄幻长篇大纲",
+  projectSummary: "已有世界观;缺少人物关系和伏笔审查",
+  existingModules: ["世界观"],
+  missingModules: ["人物关系", "伏笔审查"],
+  skills: [
+    { name: "世界规则", description: "检查世界规则", stages: ["planning"], kinds: ["knowledge"] },
+    { name: "人物设计", description: "设计人物关系", stages: ["planning", "review"], kinds: ["planning"] },
+  ],
+}
+
+describe("动态大纲 Agent 规划器", () => {
+  it("将用户任务、项目摘要、已有/缺失模块和全部 Skill 元数据写入规划提示词", () => {
+    const prompt = buildDynamicOutlinePlannerPrompt(context)
+    expect(prompt).toContain("生成玄幻长篇大纲")
+    expect(prompt).toContain("已有世界观;缺少人物关系和伏笔审查")
+    expect(prompt).toContain("世界观")
+    expect(prompt).toContain("人物关系")
+    expect(prompt).toContain("世界规则")
+    expect(prompt).toContain("检查世界规则")
+    expect(prompt).toContain("planning")
+    expect(prompt).toContain("knowledge")
+    expect(prompt).toContain("最多 12")
+    expect(prompt).toContain("最多同时运行 3")
+  })
+
+  it("解析代码围栏中的动态任务,并标准化依赖、优先级和最终审查字段", () => {
+    const result = parseDynamicOutlinePlan(`\n\`\`\`json\n${JSON.stringify({ tasks: [
+      {
+        id: "character",
+        name: "人物关系 Agent",
+        dimension: "人物关系",
+        skill_names: ["人物设计"],
+        task_prompt: "补齐人物关系",
+        dependencies: [],
+        priority: 90,
+        final_review: false,
+      },
+      {
+        id: "review",
+        name: "最终审查 Agent",
+        dimension: "一致性审查",
+        skillNames: ["世界规则"],
+        taskPrompt: "审查全部结果",
+        dependencies: ["character"],
+        priority: 10,
+        finalReview: true,
+      },
+    ]})}\n\`\`\``)
+
+    expect(result.ok).toBe(true)
+    if (!result.ok) return
+    expect(result.plan).toHaveLength(2)
+    expect(result.plan[0]).toMatchObject({
+      id: "character",
+      dimension: "人物关系",
+      skillNames: ["人物设计"],
+      dependencies: [],
+      priority: 90,
+      finalReview: false,
+      writeToolsEnabled: false,
+    })
+    expect(result.plan[1].finalReview).toBe(true)
+  })
+
+  it("拒绝使用不存在 Skill、循环依赖或超过 12 个任务的规划结果", () => {
+    const unknownSkill = parseDynamicOutlinePlan(JSON.stringify({ tasks: [{
+      id: "a", name: "A", dimension: "A", skillNames: ["不存在"], taskPrompt: "A",
+    }] }), ["人物设计"])
+    expect(unknownSkill.ok).toBe(false)
+
+    const cycle = parseDynamicOutlinePlan(JSON.stringify({ tasks: [
+      { id: "a", name: "A", dimension: "A", skillNames: [], taskPrompt: "A", dependencies: ["b"] },
+      { id: "b", name: "B", dimension: "B", skillNames: [], taskPrompt: "B", dependencies: ["a"] },
+    ] }))
+    expect(cycle.ok).toBe(false)
+
+    const tooMany = parseDynamicOutlinePlan(JSON.stringify({ tasks: Array.from({ length: 13 }, (_, index) => ({
+      id: `a${index}`, name: `A${index}`, dimension: `D${index}`, skillNames: [], taskPrompt: "执行",
+    })) }))
+    expect(tooMany.ok).toBe(false)
+  })
+})

+ 184 - 0
src/lib/novel/outline-dynamic-agent-planner.ts

@@ -0,0 +1,184 @@
+import {
+  validateOutlineSubAgentPlan,
+  type OutlineSubAgentKind,
+  type OutlineSubAgentPlan,
+} from "./outline-multi-agent-orchestrator"
+
+export interface DynamicOutlinePlannerSkill {
+  name: string
+  description: string
+  stages: string[]
+  kinds: string[]
+}
+
+export interface DynamicOutlinePlannerContext {
+  userTask: string
+  projectSummary: string
+  existingModules: string[]
+  missingModules: string[]
+  skills: DynamicOutlinePlannerSkill[]
+}
+
+export type DynamicOutlinePlanParseResult =
+  | { ok: true; plan: OutlineSubAgentPlan[] }
+  | { ok: false; error: string }
+
+export function buildDynamicOutlinePlannerPrompt(context: DynamicOutlinePlannerContext): string {
+  return [
+    "你是 AI 大纲多 Agent 动态规划器。只输出 JSON,不要输出解释。",
+    "请根据任务、项目现状和全部可用 Skill 规划独立任务,而不是按固定类别机械分组。",
+    "最多 12 个 Agent,最多同时运行 3 个 Agent。最终审查任务必须依赖需要审查的前置任务。",
+    "每个任务包含 id、中文 name、dimension、skillNames、taskPrompt、dependencies、priority、finalReview。",
+    "依赖失败后下游仍会继续,因此任务提示词必须能接受缺失维度风险。",
+    "",
+    `## 用户任务\n${context.userTask || "(未提供)"}`,
+    `## 项目摘要\n${context.projectSummary || "(暂无摘要)"}`,
+    `## 已有模块\n${formatList(context.existingModules)}`,
+    `## 缺失模块\n${formatList(context.missingModules)}`,
+    "## 全部可用 Skill",
+    ...context.skills.map((skill) => [
+      `- 名称:${skill.name}`,
+      `  用途:${skill.description || "未说明"}`,
+      `  阶段:${skill.stages.join(", ") || "未标注"}`,
+      `  类型:${skill.kinds.join(", ") || "未标注"}`,
+    ].join("\n")),
+    "",
+    "输出格式:",
+    JSON.stringify({
+      tasks: [{
+        id: "character-relations",
+        name: "人物关系 Agent",
+        dimension: "人物关系",
+        skillNames: ["人物设计"],
+        taskPrompt: "结合项目摘要补齐人物关系",
+        dependencies: [],
+        priority: 90,
+        finalReview: false,
+      }],
+    }, null, 2),
+  ].join("\n")
+}
+
+export function parseDynamicOutlinePlan(
+  text: string,
+  availableSkillNames?: string[],
+): DynamicOutlinePlanParseResult {
+  const jsonText = extractJsonObject(text)
+  if (!jsonText) return { ok: false, error: "规划器未返回 JSON 对象" }
+
+  let parsed: unknown
+  try {
+    parsed = JSON.parse(jsonText)
+  } catch (error) {
+    return { ok: false, error: `规划 JSON 解析失败:${formatError(error)}` }
+  }
+
+  const root = asRecord(parsed)
+  const tasks = Array.isArray(root?.tasks) ? root.tasks : Array.isArray(parsed) ? parsed : null
+  if (!tasks) return { ok: false, error: "规划 JSON 缺少 tasks 数组" }
+
+  const plan: OutlineSubAgentPlan[] = []
+  for (let index = 0; index < tasks.length; index += 1) {
+    const raw = asRecord(tasks[index])
+    if (!raw) return { ok: false, error: `第 ${index + 1} 个任务不是对象` }
+    const id = stringValue(raw.id)
+    const name = stringValue(raw.name || raw.agent_name)
+    const dimension = stringValue(raw.dimension || raw.work_dimension)
+    const taskPrompt = stringValue(raw.taskPrompt || raw.task_prompt)
+    const skillNames = stringArray(raw.skillNames || raw.skill_names)
+    if (!id || !name || !dimension || !taskPrompt) {
+      return { ok: false, error: `第 ${index + 1} 个任务缺少 id、name、dimension 或 taskPrompt` }
+    }
+    plan.push({
+      id,
+      name,
+      kind: inferLegacyKind(dimension),
+      dimension,
+      skillNames,
+      taskPrompt,
+      dependencies: stringArray(raw.dependencies || raw.depends_on),
+      priority: numberValue(raw.priority, tasks.length - index),
+      finalReview: booleanValue(raw.finalReview ?? raw.final_review),
+      writeToolsEnabled: false,
+    })
+  }
+
+  if (availableSkillNames) {
+    const available = new Set(availableSkillNames)
+    const unknown = plan.flatMap((task) => task.skillNames).find((name) => !available.has(name))
+    if (unknown) return { ok: false, error: `规划使用了不存在的 Skill:${unknown}` }
+  }
+
+  const validation = validateOutlineSubAgentPlan(plan)
+  if (!validation.ok) return { ok: false, error: validation.errors.join(";") }
+  return { ok: true, plan }
+}
+
+function formatList(items: string[]): string {
+  return items.length > 0 ? items.map((item) => `- ${item}`).join("\n") : "- 无"
+}
+
+function extractJsonObject(text: string): string | null {
+  const cleaned = text.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/i, "")
+  const start = cleaned.indexOf("{")
+  const arrayStart = cleaned.indexOf("[")
+  const effectiveStart = start < 0 ? arrayStart : arrayStart < 0 ? start : Math.min(start, arrayStart)
+  if (effectiveStart < 0) return null
+  const open = cleaned[effectiveStart]
+  const close = open === "{" ? "}" : "]"
+  let depth = 0
+  let inString = false
+  let escaped = false
+  for (let index = effectiveStart; index < cleaned.length; index += 1) {
+    const char = cleaned[index]
+    if (inString) {
+      if (escaped) escaped = false
+      else if (char === "\\") escaped = true
+      else if (char === '"') inString = false
+      continue
+    }
+    if (char === '"') inString = true
+    else if (char === open) depth += 1
+    else if (char === close) {
+      depth -= 1
+      if (depth === 0) return cleaned.slice(effectiveStart, index + 1)
+    }
+  }
+  return null
+}
+
+function asRecord(value: unknown): Record<string, unknown> | null {
+  return value !== null && typeof value === "object" && !Array.isArray(value)
+    ? value as Record<string, unknown>
+    : null
+}
+
+function stringValue(value: unknown): string {
+  return typeof value === "string" ? value.trim() : ""
+}
+
+function stringArray(value: unknown): string[] {
+  return Array.isArray(value)
+    ? value.filter((item): item is string => typeof item === "string").map((item) => item.trim()).filter(Boolean)
+    : []
+}
+
+function numberValue(value: unknown, fallback: number): number {
+  return typeof value === "number" && Number.isFinite(value) ? value : fallback
+}
+
+function booleanValue(value: unknown): boolean {
+  return value === true || value === "true"
+}
+
+function inferLegacyKind(dimension: string): OutlineSubAgentKind {
+  if (/人物|角色|关系/.test(dimension)) return "character"
+  if (/伏笔|悬念/.test(dimension)) return "foreshadowing"
+  if (/题材|主题|卖点/.test(dimension)) return "topic"
+  if (/设定|世界|势力|地理|体系/.test(dimension)) return "setting"
+  return "outline"
+}
+
+function formatError(error: unknown): string {
+  return error instanceof Error ? error.message : String(error)
+}

+ 127 - 0
src/lib/novel/outline-dynamic-agent-scheduler.spec.ts

@@ -0,0 +1,127 @@
+import { describe, expect, it, vi } from "vitest"
+import {
+  runOutlineMultiAgentWorkflow,
+  validateOutlineSubAgentPlan,
+  type OutlineSubAgentPlan,
+} from "./outline-multi-agent-orchestrator"
+
+function plan(
+  id: string,
+  dependencies: string[] = [],
+  priority = 0,
+): OutlineSubAgentPlan {
+  return {
+    id,
+    name: `${id}中文名称`,
+    kind: "outline",
+    dimension: `${id}维度`,
+    skillNames: [`${id}-skill`],
+    taskPrompt: `执行${id}`,
+    dependencies,
+    priority,
+    finalReview: false,
+    writeToolsEnabled: false,
+  }
+}
+
+function resultJson(item: OutlineSubAgentPlan): string {
+  return JSON.stringify({
+    agent_id: item.id,
+    agent_name: item.name,
+    stage: item.dimension,
+    used_skills: item.skillNames,
+    confidence: 0.8,
+    summary: `${item.id}完成`,
+    content_markdown: `## ${item.id}`,
+    constraints: [],
+    writeback_items: [],
+    risks: [],
+    questions: [],
+  })
+}
+
+describe("动态大纲 Agent 任务图", () => {
+  it("拒绝重复 ID、孤儿依赖、循环依赖和超过 12 个任务", () => {
+    expect(validateOutlineSubAgentPlan([plan("a"), plan("a")]).ok).toBe(false)
+    expect(validateOutlineSubAgentPlan([plan("a", ["missing"])]).ok).toBe(false)
+    expect(validateOutlineSubAgentPlan([plan("a", ["b"]), plan("b", ["a"])]).ok).toBe(false)
+    expect(validateOutlineSubAgentPlan(Array.from({ length: 13 }, (_, i) => plan(`a${i}`))).ok).toBe(false)
+    expect(validateOutlineSubAgentPlan([plan("a"), plan("b", ["a"])]).ok).toBe(true)
+  })
+
+  it("最多并发 3 个,依赖结束后自动补位,并按规划顺序合并", async () => {
+    const tasks = [plan("a"), plan("b"), plan("c"), plan("d", ["a"]), plan("e", ["b"])]
+    let running = 0
+    let peak = 0
+    const releases = new Map<string, () => void>()
+    const started: string[] = []
+
+    const workflow = runOutlineMultiAgentWorkflow({
+      plan: tasks,
+      maxConcurrency: 3,
+      runSubAgent: async (item) => {
+        started.push(item.id)
+        running += 1
+        peak = Math.max(peak, running)
+        await new Promise<void>((resolve) => releases.set(item.id, resolve))
+        running -= 1
+        return resultJson(item)
+      },
+      runSingleAgentFallback: async () => "不应降级",
+      mergeResults: async (items) => items.map((item) => item.agentId).join(","),
+    })
+
+    await vi.waitFor(() => expect(started).toEqual(["a", "b", "c"]))
+    releases.get("a")?.()
+    await vi.waitFor(() => expect(started).toContain("d"))
+    expect(started).not.toContain("e")
+    releases.get("b")?.()
+    await vi.waitFor(() => expect(started).toContain("e"))
+    releases.get("c")?.()
+    releases.get("d")?.()
+    releases.get("e")?.()
+
+    const output = await workflow
+    expect(peak).toBe(3)
+    expect(output.mode).toBe("multi-agent")
+    expect(output.finalText).toBe("a,b,c,d,e")
+  })
+
+  it("失败自动重试一次;再次失败后下游携带缺失维度继续,部分失败不整体降级", async () => {
+    const attempts = new Map<string, number>()
+    const receivedPrompts = new Map<string, string>()
+
+    const output = await runOutlineMultiAgentWorkflow({
+      plan: [plan("source"), plan("downstream", ["source"])],
+      runSubAgent: async (item) => {
+        attempts.set(item.id, (attempts.get(item.id) ?? 0) + 1)
+        receivedPrompts.set(item.id, item.taskPrompt)
+        if (item.id === "source") throw new Error("上游持续失败")
+        return resultJson(item)
+      },
+      runSingleAgentFallback: async () => "不应因部分失败降级",
+      mergeResults: async (items) => items.map((item) => item.agentId).join(","),
+    })
+
+    expect(attempts.get("source")).toBe(2)
+    expect(attempts.get("downstream")).toBe(1)
+    expect(receivedPrompts.get("downstream")).toContain("source维度")
+    expect(receivedPrompts.get("downstream")).toContain("上游持续失败")
+    expect(output.mode).toBe("multi-agent")
+    expect(output.finalText).toBe("downstream")
+    expect(output.failedAgents).toEqual(["source"])
+  })
+
+  it("没有任何成功结果时才降级为单 Agent", async () => {
+    const output = await runOutlineMultiAgentWorkflow({
+      plan: [plan("a"), plan("b")],
+      runSubAgent: async () => { throw new Error("全部失败") },
+      runSingleAgentFallback: async () => "单 Agent 安全结果",
+      mergeResults: async () => "不应合并",
+    })
+
+    expect(output.mode).toBe("single-agent-fallback")
+    expect(output.finalText).toBe("单 Agent 安全结果")
+    expect(output.fallbackReason).toContain("没有任何成功")
+  })
+})

+ 23 - 1
src/lib/novel/outline-intent-clarity.spec.ts

@@ -1,5 +1,5 @@
 import { describe, it, expect } from "vitest"
-import { parseIntentClarity } from "./outline-intent-clarity"
+import { parseIntentClarity, stripStructuredMarkers } from "./outline-intent-clarity"
 
 describe("parseIntentClarity", () => {
   it("解析 clear 意图", () => {
@@ -36,3 +36,25 @@ describe("parseIntentClarity", () => {
     expect(parseIntentClarity(text)).toBeNull()
   })
 })
+
+describe("stripStructuredMarkers", () => {
+  it("移除 intent_clarity 标记块", () => {
+    const text = `分析结果如下:\n<!-- intent_clarity -->\n{"clarity":"clear"}\n<!-- /intent_clarity -->\n\n这是正文内容。`
+    expect(stripStructuredMarkers(text)).toBe("分析结果如下:\n\n这是正文内容。")
+  })
+
+  it("移除 next_step 标记块", () => {
+    const text = `正文内容\n<!-- next_step -->\n{"recommendations":[]}\n<!-- /next_step -->`
+    expect(stripStructuredMarkers(text).trim()).toBe("正文内容")
+  })
+
+  it("同时移除两种标记块", () => {
+    const text = `<!-- intent_clarity -->\n{"clarity":"clear"}\n<!-- /intent_clarity -->\n正文\n<!-- next_step -->\n{"recommendations":[]}\n<!-- /next_step -->`
+    expect(stripStructuredMarkers(text).trim()).toBe("正文")
+  })
+
+  it("无标记块时原样返回", () => {
+    const text = "纯文本内容"
+    expect(stripStructuredMarkers(text)).toBe("纯文本内容")
+  })
+})

+ 8 - 0
src/lib/novel/outline-intent-clarity.ts

@@ -98,3 +98,11 @@ export function buildIntentAnalysisPrompt(title: string, requestHint: string): s
     "needs_input 时:输出 JSON 后,用自然语言在会话中提出澄清问题 + 推荐选项。",
   ].join("\n")
 }
+
+export function stripStructuredMarkers(text: string): string {
+  return text
+    .replace(/<!--\s*intent_clarity\s*-->[\s\S]*?<!--\s*\/intent_clarity\s*-->/gi, "")
+    .replace(/<!--\s*next_step\s*-->[\s\S]*?<!--\s*\/next_step\s*-->/gi, "")
+    .replace(/\n{3,}/g, "\n\n")
+    .trim()
+}

+ 6 - 7
src/lib/novel/outline-multi-agent-orchestrator.spec.ts

@@ -77,22 +77,21 @@ describe("AI大纲多 Agent 编排器", () => {
     expect(result.failedAgents).toEqual(["topic-agent"])
   })
 
-  it("超过失败阈值时自动回退为单 Agent", async () => {
+  it("部分 Agent 失败时不整体降级,继续合并成功结果", async () => {
     const result = await runOutlineMultiAgentWorkflow({
       plan: [makePlan("outline"), makePlan("topic"), makePlan("character")],
       maxConcurrency: 3,
-      failureFallbackThreshold: 0.5,
       runSubAgent: async (item) => {
         if (item.kind !== "outline") throw new Error("失败")
         return makeSubAgentJson(item.id, item.name)
       },
-      runSingleAgentFallback: async () => "单 Agent 结果",
-      mergeResults: async () => "不应执行",
+      runSingleAgentFallback: async () => "不应降级",
+      mergeResults: async (items) => `成功数量:${items.length}`,
     })
 
-    expect(result.mode).toBe("single-agent-fallback")
-    expect(result.finalText).toBe("单 Agent 结果")
-    expect(result.fallbackReason).toContain("多 Agent 失败数量超过阈值")
+    expect(result.mode).toBe("multi-agent")
+    expect(result.finalText).toBe("成功数量:1")
+    expect(result.failedAgents).toEqual(["topic-agent", "character-agent"])
     expect(result.failureDetails?.[0]).toContain("topic Agent")
     expect(result.failureDetails?.[0]).toContain("失败")
   })

+ 173 - 56
src/lib/novel/outline-multi-agent-orchestrator.ts

@@ -1,4 +1,4 @@
-import {
+import {
   coerceOutlineSubAgentResult,
   type OutlineSubAgentResult,
 } from "./outline-result-protocol"
@@ -14,8 +14,12 @@ export interface OutlineSubAgentPlan {
   id: string
   name: string
   kind: OutlineSubAgentKind
+  dimension?: string
   skillNames: string[]
   taskPrompt: string
+  dependencies?: string[]
+  priority?: number
+  finalReview?: boolean
   writeToolsEnabled: false
 }
 
@@ -25,13 +29,30 @@ export interface OutlineMultiAgentPlanInput {
   maxConcurrency?: number
 }
 
+export type OutlineSubAgentExecutionStatus =
+  | "waiting"
+  | "ready"
+  | "running"
+  | "retrying"
+  | "completed"
+  | "failed"
+
+export interface OutlineSubAgentStatusEvent {
+  agentId: string
+  status: OutlineSubAgentExecutionStatus
+  attempt: number
+  error?: string
+}
+
 export interface OutlineMultiAgentRunInput {
   plan: OutlineSubAgentPlan[]
   maxConcurrency?: number
+  /** @deprecated 部分 Agent 失败不再触发整体降级。 */
   failureFallbackThreshold?: number
   runSubAgent: (plan: OutlineSubAgentPlan) => Promise<string>
   runSingleAgentFallback: () => Promise<string>
   mergeResults: (results: OutlineSubAgentResult[]) => Promise<string>
+  onStatusChange?: (event: OutlineSubAgentStatusEvent) => void
 }
 
 export interface OutlineMultiAgentRunResult {
@@ -43,6 +64,11 @@ export interface OutlineMultiAgentRunResult {
   failureDetails?: string[]
 }
 
+export interface OutlinePlanValidationResult {
+  ok: boolean
+  errors: string[]
+}
+
 const KIND_ORDER: OutlineSubAgentKind[] = [
   "outline",
   "topic",
@@ -51,6 +77,9 @@ const KIND_ORDER: OutlineSubAgentKind[] = [
   "foreshadowing",
 ]
 
+const MAX_AGENT_TASKS = 12
+const DEFAULT_MAX_CONCURRENCY = 3
+
 export function planOutlineSubAgents(input: OutlineMultiAgentPlanInput): OutlineSubAgentPlan[] {
   const grouped = new Map<OutlineSubAgentKind, string[]>()
   for (const name of input.preferredSkillNames) {
@@ -62,66 +91,91 @@ export function planOutlineSubAgents(input: OutlineMultiAgentPlanInput): Outline
 
   return KIND_ORDER
     .filter((kind) => grouped.has(kind))
-    .map((kind) => ({
+    .map((kind, index) => ({
       id: `${kind}-agent`,
       name: subAgentName(kind),
       kind,
+      dimension: subAgentName(kind).replace(/ Agent$/, ""),
       skillNames: grouped.get(kind) ?? [],
       taskPrompt: buildSubAgentTaskPrompt(kind, input.taskPrompt, grouped.get(kind) ?? []),
+      dependencies: [],
+      priority: KIND_ORDER.length - index,
+      finalReview: false,
       writeToolsEnabled: false,
     }))
 }
 
+export function validateOutlineSubAgentPlan(plan: OutlineSubAgentPlan[]): OutlinePlanValidationResult {
+  const errors: string[] = []
+  if (plan.length === 0) errors.push("任务图不能为空")
+  if (plan.length > MAX_AGENT_TASKS) errors.push(`任务数量不能超过 ${MAX_AGENT_TASKS}`)
+
+  const ids = new Set<string>()
+  for (const task of plan) {
+    if (!task.id.trim()) errors.push("Agent ID 不能为空")
+    if (ids.has(task.id)) errors.push(`Agent ID 重复:${task.id}`)
+    ids.add(task.id)
+  }
+
+  for (const task of plan) {
+    for (const dependency of task.dependencies ?? []) {
+      if (dependency === task.id) errors.push(`Agent 不能依赖自身:${task.id}`)
+      else if (!ids.has(dependency)) errors.push(`依赖不存在:${task.id} -> ${dependency}`)
+    }
+  }
+
+  const visiting = new Set<string>()
+  const visited = new Set<string>()
+  const byId = new Map(plan.map((task) => [task.id, task]))
+  const visit = (id: string): boolean => {
+    if (visiting.has(id)) return true
+    if (visited.has(id)) return false
+    visiting.add(id)
+    const cyclic = (byId.get(id)?.dependencies ?? []).some((dependency) => byId.has(dependency) && visit(dependency))
+    visiting.delete(id)
+    visited.add(id)
+    return cyclic
+  }
+  if (plan.some((task) => visit(task.id))) errors.push("任务依赖存在循环")
+
+  return { ok: errors.length === 0, errors }
+}
+
 export async function runOutlineMultiAgentWorkflow(
   input: OutlineMultiAgentRunInput,
 ): Promise<OutlineMultiAgentRunResult> {
-  if (input.plan.length === 0) {
+  const validation = validateOutlineSubAgentPlan(input.plan)
+  if (!validation.ok) {
     const finalText = await input.runSingleAgentFallback()
     return {
       mode: "single-agent-fallback",
       finalText,
       successfulAgents: [],
       failedAgents: [],
-      fallbackReason: "没有可执行的子 Agent 计划。",
-      failureDetails: [],
+      fallbackReason: `多 Agent 规划失败:${validation.errors.join(";")}`,
+      failureDetails: validation.errors,
     }
   }
 
-  const maxConcurrency = Math.max(1, input.maxConcurrency ?? 3)
-  const settled = await runWithConcurrency(input.plan, maxConcurrency, async (plan) => {
-    const raw = await input.runSubAgent(plan)
-    const parsed = coerceOutlineSubAgentResult(raw, {
-      agentId: plan.id,
-      agentName: plan.name,
-      usedSkills: plan.skillNames,
-      stage: plan.kind,
-    })
-    if (!parsed.ok) throw new Error(parsed.error)
-    return parsed.value
-  })
-
-  const successful = settled
-    .filter((item): item is PromiseFulfilledResult<OutlineSubAgentResult> => item.status === "fulfilled")
-    .map((item) => item.value)
-  const failures = settled
-    .map((item, index) => ({ item, plan: input.plan[index] }))
-    .filter(({ item }) => item.status === "rejected")
-    .map(({ item, plan }) => ({
-      agentId: plan.id,
-      detail: `${plan.name}:${item.status === "rejected" ? formatError(item.reason) : "执行失败"}`,
-    }))
-  const failedAgents = failures.map((item) => item.agentId)
-  const failureDetails = failures.map((item) => item.detail)
+  const maxConcurrency = Math.min(DEFAULT_MAX_CONCURRENCY, Math.max(1, input.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY))
+  const outcomes = await runDependencyGraph(input.plan, maxConcurrency, input)
+  const successful = input.plan
+    .map((task) => outcomes.get(task.id)?.result)
+    .filter((result): result is OutlineSubAgentResult => Boolean(result))
+  const failures = input.plan
+    .map((task) => ({ task, error: outcomes.get(task.id)?.error }))
+    .filter((item): item is { task: OutlineSubAgentPlan; error: string } => Boolean(item.error))
+  const failedAgents = failures.map(({ task }) => task.id)
+  const failureDetails = failures.map(({ task, error }) => `${task.name}:${error}`)
 
-  const threshold = input.failureFallbackThreshold ?? 0.5
-  if (failedAgents.length / input.plan.length >= threshold) {
+  if (successful.length === 0) {
     const finalText = await input.runSingleAgentFallback()
     return {
       mode: "single-agent-fallback",
       finalText,
-      successfulAgents: successful.map((item) => item.agentId),
+      successfulAgents: [],
       failedAgents,
-      fallbackReason: `多 Agent 失败数量超过阈值:${failedAgents.length}/${input.plan.length}`,
+      fallbackReason: "多 Agent 没有任何成功结果,已降级为单 Agent。",
       failureDetails,
     }
   }
@@ -136,44 +190,107 @@ export async function runOutlineMultiAgentWorkflow(
       failureDetails,
     }
   } catch (error) {
+    const detail = formatError(error)
     const finalText = await input.runSingleAgentFallback()
     return {
       mode: "single-agent-fallback",
       finalText,
       successfulAgents: successful.map((item) => item.agentId),
       failedAgents,
-      fallbackReason: `合并 Agent 失败:${error instanceof Error ? error.message : String(error)}`,
-      failureDetails: [...failureDetails, `合并 Agent:${formatError(error)}`],
+      fallbackReason: `合并 Agent 失败:${detail}`,
+      failureDetails: [...failureDetails, `合并 Agent:${detail}`],
     }
   }
 }
 
-function formatError(error: unknown): string {
-  return error instanceof Error ? error.message : String(error)
+interface AgentOutcome {
+  result?: OutlineSubAgentResult
+  error?: string
 }
 
-async function runWithConcurrency<T, R>(
-  items: T[],
+async function runDependencyGraph(
+  plan: OutlineSubAgentPlan[],
   maxConcurrency: number,
-  worker: (item: T) => Promise<R>,
-): Promise<PromiseSettledResult<R>[]> {
-  const results: PromiseSettledResult<R>[] = new Array(items.length)
-  let nextIndex = 0
-
-  async function runNext(): Promise<void> {
-    const index = nextIndex
-    nextIndex += 1
-    if (index >= items.length) return
+  input: OutlineMultiAgentRunInput,
+): Promise<Map<string, AgentOutcome>> {
+  const outcomes = new Map<string, AgentOutcome>()
+  const running = new Map<string, Promise<void>>()
+  const order = new Map(plan.map((task, index) => [task.id, index]))
+  const pending = new Set(plan.map((task) => task.id))
+
+  const sortedReady = () => plan
+    .filter((task) => pending.has(task.id))
+    .filter((task) => (task.dependencies ?? []).every((id) => outcomes.has(id)))
+    .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0) || (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0))
+
+  while (pending.size > 0 || running.size > 0) {
+    for (const task of sortedReady()) {
+      if (running.size >= maxConcurrency) break
+      pending.delete(task.id)
+      input.onStatusChange?.({ agentId: task.id, status: "ready", attempt: 0 })
+      const promise = executeWithRetry(task, outcomes, input)
+        .then((outcome) => { outcomes.set(task.id, outcome) })
+        .finally(() => { running.delete(task.id) })
+      running.set(task.id, promise)
+    }
+    if (running.size > 0) await Promise.race(running.values())
+  }
+
+  return outcomes
+}
+
+async function executeWithRetry(
+  task: OutlineSubAgentPlan,
+  outcomes: Map<string, AgentOutcome>,
+  input: OutlineMultiAgentRunInput,
+): Promise<AgentOutcome> {
+  const dependencyFailures = (task.dependencies ?? [])
+    .map((id) => ({ id, outcome: outcomes.get(id) }))
+    .filter(({ outcome }) => outcome?.error)
+  const runnableTask: OutlineSubAgentPlan = dependencyFailures.length === 0
+    ? task
+    : {
+        ...task,
+        taskPrompt: [
+          task.taskPrompt,
+          "",
+          "## ?????????",
+          ...dependencyFailures.map(({ id, outcome }) => {
+            const dependencyTask = input.plan.find((item) => item.id === id)
+            return `- ${dependencyTask?.dimension || dependencyTask?.name || id}?${outcome?.error}`
+          }),
+        ].join("\n"),
+      }
+
+  let lastError = "执行失败"
+  for (let attempt = 1; attempt <= 2; attempt += 1) {
+    input.onStatusChange?.({
+      agentId: task.id,
+      status: attempt === 1 ? "running" : "retrying",
+      attempt,
+      error: attempt === 1 ? undefined : lastError,
+    })
     try {
-      results[index] = { status: "fulfilled", value: await worker(items[index]) }
-    } catch (reason) {
-      results[index] = { status: "rejected", reason }
+      const raw = await input.runSubAgent(runnableTask)
+      const parsed = coerceOutlineSubAgentResult(raw, {
+        agentId: task.id,
+        agentName: task.name,
+        usedSkills: task.skillNames,
+        stage: task.dimension || task.kind,
+      })
+      if (!parsed.ok) throw new Error(parsed.error)
+      input.onStatusChange?.({ agentId: task.id, status: "completed", attempt })
+      return { result: parsed.value }
+    } catch (error) {
+      lastError = formatError(error)
     }
-    await runNext()
   }
+  input.onStatusChange?.({ agentId: task.id, status: "failed", attempt: 2, error: lastError })
+  return { error: lastError }
+}
 
-  await Promise.all(Array.from({ length: Math.min(maxConcurrency, items.length) }, () => runNext()))
-  return results
+function formatError(error: unknown): string {
+  return error instanceof Error ? error.message : String(error)
 }
 
 function inferSubAgentKind(skillName: string): OutlineSubAgentKind {
@@ -205,7 +322,7 @@ function buildSubAgentTaskPrompt(
     "请只处理自己负责的维度,不要写入文件。",
     `原始任务:${taskPrompt}`,
     `本 Agent 使用 Skill:${skillNames.join("、")}`,
-    "输出必须是一个 JSON 对象,不要输出 Markdown 代码围栏,不要输出解释说明。",
+    "输出必须是一个 JSON 对象,不要输出 Markdown 代码围栏解释说明。",
     "JSON 字段模板:",
     JSON.stringify({
       agent_id: `${kind}-agent`,

+ 41 - 0
src/lib/novel/outline-next-step-resilience.spec.ts

@@ -0,0 +1,41 @@
+import { describe, expect, it } from "vitest"
+import { extractNextStep } from "./outline-next-step"
+
+describe("extractNextStep 容错与正文清理", () => {
+  it("标准标记返回清理正文、推荐和 strict 来源", () => {
+    const result = extractNextStep(`正文内容\n<!-- next_step -->\n{"completedModule":"人物小传","completedScope":"主角","recommendations":[{"id":"D","label":"自定义","reason":"自行描述"}]}\n<!-- /next_step -->`)
+    expect(result.cleanText).toBe("正文内容")
+    expect(result.source).toBe("strict")
+    expect(result.recommendation?.completedModule).toBe("人物小传")
+  })
+
+  it("恢复缺少结束标记、代码围栏、蛇形字段和缺少 reason 的结果", () => {
+    const result = extractNextStep(`正文\n<!-- next_step -->\n\`\`\`json\n{"completed_module":"章节细纲","completed_scope":"1-3章","recommendations":[{"id":"A","label":"继续完善人物小传"}]}\n\`\`\``)
+    expect(result.cleanText).toBe("正文")
+    expect(result.source).toBe("recovered")
+    expect(result.recommendation).toMatchObject({ completedModule: "章节细纲", completedScope: "1-3章" })
+    expect(result.recommendation?.recommendations[0].reason).toBe("")
+  })
+
+  it("不可恢复的大纲完成消息清除残片并生成安全卡片", () => {
+    const result = extractNextStep("正常正文\n<!-- next_step -->\n{错误 JSON !!!", {
+      allowFallback: true,
+      completedModule: "世界观",
+    })
+    expect(result.cleanText).toBe("正常正文")
+    expect(result.source).toBe("fallback")
+    expect(result.recommendation?.recommendations.map((item) => item.label)).toEqual([
+      "继续完善当前模块",
+      "自定义",
+    ])
+  })
+
+  it("意图分析、错误或停止消息只清理,不生成安全卡片", () => {
+    for (const text of ["意图分析\n<!-- /next_step -->", "生成失败:错误 next_step", "已停止 <!-- next_step --> {"]) {
+      const result = extractNextStep(text)
+      expect(result.source).toBe("none")
+      expect(result.recommendation).toBeNull()
+      expect(result.cleanText).not.toContain("next_step")
+    }
+  })
+})

+ 20 - 0
src/lib/novel/outline-next-step.spec.ts

@@ -38,4 +38,24 @@ describe("isRecommendationForbidden", () => {
     expect(isRecommendationForbidden("完善人物小传")).toBe(false)
     expect(isRecommendationForbidden("生成组织势力设定")).toBe(false)
   })
+
+  it("不会误杀大纲相关的生成推荐", () => {
+    expect(isRecommendationForbidden("生成章节细纲")).toBe(false)
+    expect(isRecommendationForbidden("生成人物小传")).toBe(false)
+    expect(isRecommendationForbidden("生成世界观设定")).toBe(false)
+    expect(isRecommendationForbidden("生成伏笔计划")).toBe(false)
+    expect(isRecommendationForbidden("生成地理设定")).toBe(false)
+    expect(isRecommendationForbidden("生成力量体系")).toBe(false)
+    expect(isRecommendationForbidden("生成金手指设定")).toBe(false)
+    expect(isRecommendationForbidden("生成背景设定")).toBe(false)
+    expect(isRecommendationForbidden("生成组织势力")).toBe(false)
+    expect(isRecommendationForbidden("生成地点设定")).toBe(false)
+  })
+
+  it("正确禁止正文生成类推荐", () => {
+    expect(isRecommendationForbidden("生成正文章节")).toBe(true)
+    expect(isRecommendationForbidden("写章节正文")).toBe(true)
+    expect(isRecommendationForbidden("开始写正文")).toBe(true)
+    expect(isRecommendationForbidden("创作正文内容")).toBe(true)
+  })
 })

+ 137 - 27
src/lib/novel/outline-next-step.ts

@@ -1,4 +1,4 @@
-export interface NextStepRecommendationItem {
+export interface NextStepRecommendationItem {
   id: string
   label: string
   reason: string
@@ -10,16 +10,37 @@ export interface NextStepRecommendation {
   recommendations: NextStepRecommendationItem[]
 }
 
-const NEXT_STEP_PATTERN = /<!--\s*next_step\s*-->([\s\S]*?)<!--\s*\/next_step\s*-->/i
+export type NextStepExtractionSource = "strict" | "recovered" | "fallback" | "none"
+
+export interface NextStepExtractionResult {
+  cleanText: string
+  recommendation: NextStepRecommendation | null
+  source: NextStepExtractionSource
+}
+
+export interface ExtractNextStepOptions {
+  allowFallback?: boolean
+  completedModule?: string
+  completedScope?: string
+}
+
+const COMPLETE_PATTERN = /<!--\s*next_step\s*-->([\s\S]*?)<!--\s*\/next_step\s*-->/i
+const START_PATTERN = /<!--\s*next_step\s*-->/i
+const END_PATTERN = /<!--\s*\/next_step\s*-->/gi
+const NAKED_MARKER_PATTERN = /\b\/?next_step\b\s*:?/gi
 
 const FORBIDDEN_PATTERNS = [
   /生成.*正文/,
   /写.*正文/,
+  /创作.*正文/,
+  /正文.*生成/,
+  /正文.*写/,
   /生成.*章节内容/,
   /生成.*正文章节/,
   /章节正文/,
   /写正文/,
   /生成正文/,
+  /写章节内容/,
 ]
 
 export function isRecommendationForbidden(label: string): boolean {
@@ -27,37 +48,128 @@ export function isRecommendationForbidden(label: string): boolean {
 }
 
 export function parseNextStep(text: string): NextStepRecommendation | null {
-  const match = text.match(NEXT_STEP_PATTERN)
-  if (!match) return null
+  return extractNextStep(text).recommendation
+}
+
+export function extractNextStep(
+  text: string,
+  options: ExtractNextStepOptions = {},
+): NextStepExtractionResult {
+  const complete = text.match(COMPLETE_PATTERN)
+  if (complete) {
+    const recommendation = parsePayload(complete[1])
+    return {
+      cleanText: cleanNextStepArtifacts(text),
+      recommendation,
+      source: recommendation ? "strict" : options.allowFallback ? "fallback" : "none",
+    }
+  }
+
+  const start = text.search(START_PATTERN)
+  if (start >= 0) {
+    const marker = text.slice(start).match(START_PATTERN)?.[0] ?? ""
+    const payloadText = text.slice(start + marker.length)
+    const recommendation = parsePayload(payloadText)
+    if (recommendation) {
+      return {
+        cleanText: cleanNextStepArtifacts(text),
+        recommendation,
+        source: "recovered",
+      }
+    }
+    if (options.allowFallback) {
+      return {
+        cleanText: cleanNextStepArtifacts(text),
+        recommendation: buildSafeRecommendation(options),
+        source: "fallback",
+      }
+    }
+  }
+
+  return {
+    cleanText: cleanNextStepArtifacts(text),
+    recommendation: options.allowFallback ? buildSafeRecommendation(options) : null,
+    source: options.allowFallback ? "fallback" : "none",
+  }
+}
+
+export function cleanNextStepArtifacts(text: string): string {
+  let cleaned = text.replace(COMPLETE_PATTERN, "")
+  const start = cleaned.search(START_PATTERN)
+  if (start >= 0) cleaned = cleaned.slice(0, start)
+  cleaned = cleaned.replace(END_PATTERN, "").replace(NAKED_MARKER_PATTERN, "")
+  cleaned = cleaned.replace(/```(?:json)?\s*$/i, "")
+  return cleaned.trim()
+}
+
+function parsePayload(payloadText: string): NextStepRecommendation | null {
+  const normalized = payloadText.trim()
+    .replace(/^```(?:json)?\s*/i, "")
+    .replace(/\s*```\s*$/i, "")
+  const json = extractJsonObject(normalized)
+  if (!json) return null
 
   let payload: unknown
   try {
-    payload = JSON.parse(match[1].trim())
+    payload = JSON.parse(json)
   } catch {
     return null
   }
-
   if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null
-
   const raw = payload as Record<string, unknown>
+  const recommendations = normalizeRecommendations(raw.recommendations)
+  return {
+    completedModule: String(raw.completedModule ?? raw.completed_module ?? ""),
+    completedScope: String(raw.completedScope ?? raw.completed_scope ?? ""),
+    recommendations,
+  }
+}
 
-  const recommendations: NextStepRecommendationItem[] = Array.isArray(raw.recommendations)
-    ? raw.recommendations
-        .filter((item): item is Record<string, unknown> =>
-          Boolean(item) && typeof item === "object" && !Array.isArray(item))
-        .map((item) => ({
-          id: String(item.id ?? ""),
-          label: String(item.label ?? ""),
-          reason: String(item.reason ?? ""),
-        }))
-        .filter((item) => item.id && item.label)
-        .filter((item) => !isRecommendationForbidden(item.label))
-    : []
+function normalizeRecommendations(value: unknown): NextStepRecommendationItem[] {
+  if (!Array.isArray(value)) return []
+  return value
+    .filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === "object" && !Array.isArray(item))
+    .map((item) => ({
+      id: String(item.id ?? ""),
+      label: String(item.label ?? item.title ?? ""),
+      reason: String(item.reason ?? item.description ?? ""),
+    }))
+    .filter((item) => item.id && item.label)
+    .filter((item) => !isRecommendationForbidden(item.label))
+}
 
+function extractJsonObject(text: string): string | null {
+  const start = text.indexOf("{")
+  if (start < 0) return null
+  let depth = 0
+  let inString = false
+  let escaped = false
+  for (let index = start; index < text.length; index += 1) {
+    const char = text[index]
+    if (inString) {
+      if (escaped) escaped = false
+      else if (char === "\\") escaped = true
+      else if (char === '"') inString = false
+      continue
+    }
+    if (char === '"') inString = true
+    else if (char === "{") depth += 1
+    else if (char === "}") {
+      depth -= 1
+      if (depth === 0) return text.slice(start, index + 1)
+    }
+  }
+  return null
+}
+
+function buildSafeRecommendation(options: ExtractNextStepOptions): NextStepRecommendation {
   return {
-    completedModule: String(raw.completedModule ?? ""),
-    completedScope: String(raw.completedScope ?? ""),
-    recommendations,
+    completedModule: options.completedModule ?? "当前模块",
+    completedScope: options.completedScope ?? "",
+    recommendations: [
+      { id: "A", label: "继续完善当前模块", reason: "继续补充当前大纲模块的细节与一致性。" },
+      { id: "D", label: "自定义", reason: "由你描述下一步需要完善的内容。" },
+    ],
   }
 }
 
@@ -66,10 +178,8 @@ export function buildNextStepPromptSuffix(): string {
     "",
     "## 下一步推荐输出要求",
     "生成完成后,在回复末尾附加 <!-- next_step --> 标记块,按 JSON 输出。",
-    "字段:completedModule、completedScope、recommendations(数组,每项含 id、label、reason)。",
-    "推荐方向仅限大纲体系内:人物小传、组织势力设定、力量体系、金手指设定、背景设定、地理设定、伏笔计划、地点设定、章节细纲。",
-    "严禁推荐「生成章节正文」「写正文」等正文生成类操作。",
-    "推荐应由 AI 根据刚生成内容中提及但尚未建立的关联项动态推断。",
-    "必须包含一个 id 为 D 的「自定义」选项。",
+    "字段:completedModule、completedScope、recommendations(每项含 id、label、reason)。",
+    "推荐方向仅限大纲体系,不得推荐生成正文或写正文。",
+    "必须包含一个 id 为 D 的“自定义”选项。",
   ].join("\n")
 }

+ 68 - 0
src/lib/novel/outline-stage-trace.spec.ts

@@ -94,4 +94,72 @@ describe("buildOutlineStages", () => {
     expect(activeStages.length).toBe(0)
     expect(doneStages.length).toBeGreaterThanOrEqual(2)
   })
+
+  it("任务理解阶段摘要包含工具信息", () => {
+    const input: OutlineStageInput = {
+      toolCalls: [makeToolCall("route_task", "done")],
+      content: "",
+      isStreaming: false,
+    }
+    const stages = buildOutlineStages(input)
+    const intentStage = stages.find((s) => s.kind === "intent")
+    expect(intentStage?.summary).toBeTruthy()
+    expect(intentStage?.summary).toContain("route_task")
+  })
+
+  it("范围分析阶段摘要包含模块名", () => {
+    const content = `<!-- intent_clarity -->
+{"clarity":"clear","module":"章节细纲","analysis":"3章缺细纲","detectedScope":"第1-3章","missingItems":[],"options":[],"question":""}
+<!-- /intent_clarity -->`
+    const input: OutlineStageInput = {
+      toolCalls: [makeToolCall("route_task", "done")],
+      content,
+      isStreaming: false,
+    }
+    const stages = buildOutlineStages(input)
+    const scopeStage = stages.find((s) => s.kind === "scope")
+    expect(scopeStage?.summary).toContain("章节细纲")
+  })
+
+  it("上下文准备阶段摘要包含读取文件数", () => {
+    const input: OutlineStageInput = {
+      toolCalls: [
+        makeToolCall("route_task", "done"),
+        makeToolCall("read_outline", "done"),
+        makeToolCall("read_chapter", "done"),
+        makeToolCall("list_outlines", "done"),
+      ],
+      content: "",
+      isStreaming: false,
+    }
+    const stages = buildOutlineStages(input)
+    const ctxStage = stages.find((s) => s.kind === "context")
+    expect(ctxStage?.summary).toContain("3")
+  })
+
+  it("闭合 thinking 标签提取 thinkingContent 且不标记 streaming", () => {
+    const input: OutlineStageInput = {
+      toolCalls: [],
+      content: "<thinking>正在分析角色动机...</thinking>",
+      isStreaming: true,
+    }
+    const stages = buildOutlineStages(input)
+    const thinkingStage = stages.find((s) => s.kind === "thinking")
+    expect(thinkingStage?.thinkingContent).toBe("正在分析角色动机...")
+    expect(thinkingStage?.thinkingStreaming).toBeFalsy()
+    expect(thinkingStage?.status).toBe("active")
+  })
+
+  it("流式未闭合 thinking 标签提取 thinkingContent 并标记 streaming", () => {
+    const input: OutlineStageInput = {
+      toolCalls: [],
+      content: "<thinking>正在分析角色动机...",
+      isStreaming: true,
+    }
+    const stages = buildOutlineStages(input)
+    const thinkingStage = stages.find((s) => s.kind === "thinking")
+    expect(thinkingStage?.thinkingContent).toBe("正在分析角色动机...")
+    expect(thinkingStage?.thinkingStreaming).toBe(true)
+    expect(thinkingStage?.status).toBe("active")
+  })
 })

+ 105 - 16
src/lib/novel/outline-stage-trace.ts

@@ -17,6 +17,8 @@ export interface OutlineStage {
   status: OutlineStageStatus
   summary: string
   details: string[]
+  thinkingContent?: string
+  thinkingStreaming?: boolean
   startedAt?: number
   finishedAt?: number
 }
@@ -32,8 +34,8 @@ const READ_TOOLS = new Set(["read_chapter", "read_outline", "read_memory", "read
 const SKILL_TOOLS = new Set(["apply_skill"])
 const ROUTE_TOOLS = new Set(["route_task"])
 const WRITE_TOOLS = new Set(["write_outline_node", "write_chapter_outline"])
-const INTENT_CLARITY_PATTERN = /<!--\s*intent_clarity\s*-->[\s\S]*?<!--\s*\/intent_clarity\s*-->/i
-const THINKING_PATTERN = /<(think|thinking)>([\s\S]*?)<\/\1>/i
+const INTENT_CLARITY_PATTERN = /<!--\s*intent_clarity\s*-->([\s\S]*?)<!--\s*\/intent_clarity\s*-->/i
+const THINKING_PATTERN = /<(think|thinking)>/i
 
 function hasIntentClarity(content: string): boolean {
   return INTENT_CLARITY_PATTERN.test(content)
@@ -43,33 +45,120 @@ function hasThinking(content: string): boolean {
   return THINKING_PATTERN.test(content)
 }
 
+export function extractThinkingContent(content: string): { text: string; streaming: boolean } {
+  if (!content) return { text: "", streaming: false }
+
+  const parts: string[] = []
+  let streaming = false
+  const pattern = /<(think|thinking)>([\s\S]*?)(?:<\/\1>|$)/gi
+  let match: RegExpExecArray | null
+  while ((match = pattern.exec(content)) !== null) {
+    const text = match[2].trim()
+    if (text) parts.push(text)
+    if (!match[0].toLowerCase().endsWith(`</${match[1].toLowerCase()}>`)) {
+      streaming = true
+    }
+    if (match.index === pattern.lastIndex) {
+      pattern.lastIndex++
+    }
+  }
+
+  return { text: parts.join("\n\n"), streaming }
+}
+
 function hasGenerationStarted(content: string): boolean {
-  const withoutThinking = content.replace(THINKING_PATTERN, "")
-  const withoutClarity = withoutThinking.replace(INTENT_CLARITY_PATTERN, "")
-  return withoutClarity.trim().length > 0
+  if (!content) return false
+  let idx = 0
+  while (idx < content.length) {
+    const thinkStart = content.indexOf("<thinking", idx)
+    if (thinkStart === -1) {
+      const clarityStart = content.indexOf("<!-- intent_clarity", idx)
+      if (clarityStart === -1) {
+        return content.slice(idx).trim().length > 0
+      }
+      if (clarityStart > idx && content.slice(idx, clarityStart).trim().length > 0) {
+        return true
+      }
+      const clarityEnd = content.indexOf("<!-- /intent_clarity -->", clarityStart)
+      idx = clarityEnd >= 0 ? clarityEnd + 24 : clarityStart + 1
+      continue
+    }
+    if (thinkStart > idx && content.slice(idx, thinkStart).trim().length > 0) {
+      return true
+    }
+    const thinkEnd = content.indexOf("</thinking>", thinkStart)
+    if (thinkEnd === -1) {
+      return false
+    }
+    idx = thinkEnd + 11
+  }
+  return false
 }
 
 export function buildOutlineStages(input: OutlineStageInput): OutlineStage[] {
   const { toolCalls, content, isStreaming } = input
 
-  const hasRoute = toolCalls.some((c) => ROUTE_TOOLS.has(c.name))
-  const hasSkill = toolCalls.some((c) => SKILL_TOOLS.has(c.name))
-  const hasRead = toolCalls.some((c) => LIST_TOOLS.has(c.name) || READ_TOOLS.has(c.name))
-  const hasWrite = toolCalls.some((c) => WRITE_TOOLS.has(c.name))
+  const routeCalls = toolCalls.filter((c) => ROUTE_TOOLS.has(c.name))
+  const skillCalls = toolCalls.filter((c) => SKILL_TOOLS.has(c.name))
+  const readCalls = toolCalls.filter((c) => LIST_TOOLS.has(c.name) || READ_TOOLS.has(c.name))
+  const writeCalls = toolCalls.filter((c) => WRITE_TOOLS.has(c.name))
+  const hasRoute = routeCalls.length > 0
+  const hasSkill = skillCalls.length > 0
+  const hasRead = readCalls.length > 0
+  const hasWrite = writeCalls.length > 0
   const hasClarity = hasIntentClarity(content)
   const hasThinkingBlock = hasThinking(content)
   const hasOutput = hasGenerationStarted(content)
+  const thinkingExtract = extractThinkingContent(content)
 
   const activations = [hasRoute, hasClarity, hasSkill, hasRead, hasWrite, hasThinkingBlock, hasOutput]
 
+  let clarityModule = ""
+  let clarityAnalysis = ""
+  const clarityMatch = content.match(INTENT_CLARITY_PATTERN)
+  if (clarityMatch) {
+    try {
+      const payload = JSON.parse(clarityMatch[1].trim())
+      clarityModule = String(payload.module ?? "")
+      clarityAnalysis = String(payload.analysis ?? "")
+    } catch {}
+  }
+
+  const summaries: string[] = [
+    hasRoute ? `路由到${routeCalls.map(c => c.name).join("、")}` : "",
+    hasClarity ? (clarityModule ? `${clarityModule}:${clarityAnalysis || "范围已明确"}` : "范围分析完成") : "",
+    hasSkill ? `已加载${skillCalls.map(c => c.name).join("、")}` : "",
+    hasRead ? `读取了${readCalls.length}个资料` : "",
+    hasWrite ? `调用${writeCalls.length}个写入工具` : "",
+    hasThinkingBlock ? "角色与情节推理中" : "",
+    hasOutput ? "内容生成完成" : "",
+  ]
+
+  const detailsList: string[][] = [
+    hasRoute ? routeCalls.map((c) => `${c.name}(${c.status})`) : [],
+    hasClarity ? [clarityModule, clarityAnalysis].filter(Boolean) : [],
+    hasSkill ? skillCalls.map((c) => `${c.name}(${c.status})`) : [],
+    hasRead ? readCalls.map((c) => `${c.name}(${c.status})`) : [],
+    hasWrite ? writeCalls.map((c) => `${c.name}(${c.status})`) : [],
+    [],
+    [],
+  ]
+
   const stageDefs: Array<Omit<OutlineStage, "status">> = [
-    { kind: "intent", title: "任务理解", summary: "", details: [] },
-    { kind: "scope", title: "范围分析", summary: "", details: [] },
-    { kind: "skill", title: "技能选择", summary: "", details: [] },
-    { kind: "context", title: "上下文准备", summary: "", details: [] },
-    { kind: "tool", title: "工具调用", summary: "", details: [] },
-    { kind: "thinking", title: "思考与角色", summary: "", details: [] },
-    { kind: "generation", title: "生成与校验", summary: "", details: [] },
+    { kind: "intent", title: "任务理解", summary: summaries[0], details: detailsList[0] },
+    { kind: "scope", title: "范围分析", summary: summaries[1], details: detailsList[1] },
+    { kind: "skill", title: "技能选择", summary: summaries[2], details: detailsList[2] },
+    { kind: "context", title: "上下文准备", summary: summaries[3], details: detailsList[3] },
+    { kind: "tool", title: "工具调用", summary: summaries[4], details: detailsList[4] },
+    {
+      kind: "thinking",
+      title: "思考与角色",
+      summary: summaries[5],
+      details: detailsList[5],
+      thinkingContent: thinkingExtract.text || undefined,
+      thinkingStreaming: thinkingExtract.streaming || undefined,
+    },
+    { kind: "generation", title: "生成与校验", summary: summaries[6], details: detailsList[6] },
   ]
 
   let lastActiveIndex = -1

+ 23 - 0
src/lib/novel/prompt-templates.ts

@@ -121,4 +121,27 @@ export const PROMPTS = {
     "### 禁忌表达",
     "{tabooExpressions}",
   ].join("\n"),
+
+  outlineGeneration: (genre: string, scale: string, premise: string, context: string) =>
+    [
+      "你是一位专业的小说大纲设计师。",
+      "",
+      `## 小说类型:${genre}`,
+      `## 故事规模:${scale}`,
+      `## 核心设定:${premise}`,
+      "",
+      "## 已有项目资料",
+      context,
+      "",
+      "## 任务要求",
+      "请根据以上信息,设计一个完整、有吸引力的小说大纲,包括:",
+      "1. 核心故事概念与主题",
+      "2. 主要人物设定(主角、重要配角、反派)",
+      "3. 故事世界观与背景设定",
+      "4. 整体故事结构(分卷/分篇规划)",
+      "5. 关键情节节点与转折点",
+      "6. 主要伏笔与悬念线",
+      "",
+      "请使用 Markdown 格式输出,保持结构清晰、层次分明。",
+    ].join("\n"),
 } as const

+ 38 - 10
src/lib/persist.ts

@@ -1,6 +1,7 @@
 import { writeFile, readFile, createDirectory } from "@/commands/fs"
 import type { ReviewItem } from "@/stores/review-store"
 import type { DisplayMessage, Conversation } from "@/stores/chat-store"
+import { normalizeLoadedRunStates, type ConversationRunStates } from "@/lib/conversation-run-state"
 import { normalizePath } from "@/lib/path-utils"
 
 const MAX_RETRIES = 3
@@ -101,6 +102,22 @@ export async function loadReviewItems(projectPath: string): Promise<ReviewItem[]
 interface PersistedChatData {
   conversations: Conversation[]
   messages: DisplayMessage[]
+  runStates: ConversationRunStates
+}
+
+interface ConversationManifest {
+  conversations: Conversation[]
+  runStates?: ConversationRunStates
+}
+
+function normalizeConversationRunStates(
+  conversations: Conversation[],
+  runStates: ConversationRunStates | undefined,
+): ConversationRunStates {
+  const conversationIds = new Set(conversations.map((conversation) => conversation.id))
+  return Object.fromEntries(
+    Object.entries(normalizeLoadedRunStates(runStates)).filter(([id]) => conversationIds.has(id)),
+  )
 }
 
 function normalizeConversation(conv: Conversation): Conversation {
@@ -118,7 +135,8 @@ export async function saveChatHistory(
   projectPath: string,
   conversations: Conversation[],
   messages: DisplayMessage[],
-  maxMessages?: number
+  maxMessages?: number,
+  runStates: ConversationRunStates = {},
 ): Promise<void> {
   const pp = normalizePath(projectPath)
   const release = acquireSaveLock(`chat:${pp}`)
@@ -130,7 +148,7 @@ export async function saveChatHistory(
     await withRetry(
       () => writeFile(
         `${pp}/.qmai/conversations.json`,
-        JSON.stringify(conversations, null, 2),
+        JSON.stringify({ conversations, runStates }, null, 2),
       ),
       "saveChatHistory(conversations)",
     )
@@ -164,7 +182,15 @@ export async function loadChatHistory(projectPath: string): Promise<PersistedCha
   try {
     // Try new format: separate files per conversation
     const convContent = await readFile(`${pp}/.qmai/conversations.json`)
-    const conversations = safeParseArray<Conversation>(convContent, "conversations").map(normalizeConversation)
+    const parsedManifest = JSON.parse(convContent) as Conversation[] | ConversationManifest
+    const rawConversations = Array.isArray(parsedManifest)
+      ? parsedManifest
+      : Array.isArray(parsedManifest?.conversations)
+        ? parsedManifest.conversations
+        : []
+    const conversations = rawConversations.map(normalizeConversation)
+    const rawRunStates = Array.isArray(parsedManifest) ? {} : parsedManifest.runStates
+    const runStates = normalizeConversationRunStates(conversations, rawRunStates)
 
     const allMessages: DisplayMessage[] = []
     for (const conv of conversations) {
@@ -177,7 +203,7 @@ export async function loadChatHistory(projectPath: string): Promise<PersistedCha
       }
     }
 
-    return { conversations, messages: allMessages }
+    return { conversations, messages: allMessages, runStates }
   } catch {
     // Fall back to old format
     try {
@@ -199,23 +225,25 @@ export async function loadChatHistory(projectPath: string): Promise<PersistedCha
           ...m,
           conversationId: "default",
         }))
-        return { conversations: [defaultConv], messages: migratedMessages }
+        return { conversations: [defaultConv], messages: migratedMessages, runStates: {} }
       }
 
       // Old combined format
       if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
         const data = parsed as PersistedChatData
+        const conversations = Array.isArray(data.conversations)
+          ? data.conversations.map(normalizeConversation)
+          : []
         return {
-          conversations: Array.isArray(data.conversations)
-            ? data.conversations.map(normalizeConversation)
-            : [],
+          conversations,
           messages: Array.isArray(data.messages) ? data.messages : [],
+          runStates: normalizeConversationRunStates(conversations, data.runStates),
         }
       }
       console.warn("persist: 聊天历史数据格式无效")
-      return { conversations: [], messages: [] }
+      return { conversations: [], messages: [], runStates: {} }
     } catch {
-      return { conversations: [], messages: [] }
+      return { conversations: [], messages: [], runStates: {} }
     }
   }
 }

+ 134 - 0
src/lib/separate-thinking.ts

@@ -0,0 +1,134 @@
+function isLlmEnglishThinking(text: string): boolean {
+  const trimmed = text.trim();
+  if (!trimmed) return false;
+
+  const chineseChars = (trimmed.match(/[\u4e00-\u9fff]/g) || []).length;
+  const totalChars = trimmed.length;
+  const chineseRatio = chineseChars / Math.max(totalChars, 1);
+
+  if (chineseRatio > 0.15) return false;
+
+  const thinkingKeywords = [
+    "thinking process",
+    "let's think",
+    "let me think",
+    "analyze",
+    "let's write",
+    "wait,",
+    "word count",
+    "constraints",
+    "note:",
+    "check:",
+    "strategy",
+    "i need to",
+    "i should",
+    "first,",
+    "okay,",
+    "so,",
+    "now,",
+    "the user",
+    "based on",
+    "according to",
+  ];
+  const lower = trimmed.toLowerCase();
+  const keywordCount = thinkingKeywords.filter((kw) =>
+    lower.includes(kw),
+  ).length;
+
+  return keywordCount >= 2;
+}
+
+function isWorkflowStageHeader(line: string): boolean {
+  const trimmed = line.trim();
+  if (/^##\s*阶段/.test(trimmed)) return true;
+  if (/^阶段\s*[\d.]+\s*[::]/.test(trimmed)) return true;
+  return false;
+}
+
+function filterThinkingContent(thinking: string): string | null {
+  const lines = thinking.split("\n");
+  const resultLines: string[] = [];
+  let currentStageHeader: string | null = null;
+  let currentStageContent: string[] = [];
+
+  const flushStage = () => {
+    if (currentStageHeader) {
+      resultLines.push(currentStageHeader);
+      const contentText = currentStageContent.join("\n").trim();
+      if (contentText) {
+        if (!isLlmEnglishThinking(contentText)) {
+          resultLines.push(...currentStageContent);
+        }
+      }
+      resultLines.push("");
+    } else if (currentStageContent.length > 0) {
+      const blockText = currentStageContent.join("\n").trim();
+      if (blockText && !isLlmEnglishThinking(blockText)) {
+        resultLines.push(...currentStageContent);
+        resultLines.push("");
+      }
+    }
+    currentStageHeader = null;
+    currentStageContent = [];
+  };
+
+  for (const line of lines) {
+    if (isWorkflowStageHeader(line)) {
+      flushStage();
+      currentStageHeader = line;
+    } else if (currentStageHeader) {
+      currentStageContent.push(line);
+    } else {
+      currentStageContent.push(line);
+      if (!line.trim()) {
+        flushStage();
+      }
+    }
+  }
+  flushStage();
+
+  const result = resultLines.join("\n").trim();
+  return result || null;
+}
+
+export function separateThinking(text: string): {
+  thinking: string | null;
+  answer: string;
+} {
+  const thinkRegex = /<think(?:ing)?>([\s\S]*?)<\/think(?:ing)?>/gi;
+  const thinkParts: string[] = [];
+  let answer = text;
+
+  let match: RegExpExecArray | null;
+  while ((match = thinkRegex.exec(text)) !== null) {
+    thinkParts.push(match[1].trim());
+  }
+  answer = answer
+    .replace(/<think(?:ing)?>[\s\S]*?<\/think(?:ing)?>/gi, "")
+    .trim();
+
+  const unclosedMatch = answer.match(/<think(?:ing)?>([\s\S]*)$/i);
+  if (unclosedMatch) {
+    thinkParts.push(unclosedMatch[1].trim());
+    answer = answer.replace(/<think(?:ing)?>[\s\S]*$/i, "").trim();
+  }
+
+  const firstCloseIndex = answer.search(/<\/think(?:ing)?>/i);
+  if (firstCloseIndex >= 0) {
+    const beforeClose = answer.slice(0, firstCloseIndex);
+    if (!/<think(?:ing)?>/i.test(beforeClose)) {
+      const thinkingContent = beforeClose.trim();
+      if (thinkingContent) {
+        thinkParts.push(thinkingContent);
+      }
+      answer = answer.replace(/^[\s\S]*?<\/think(?:ing)?>\s*/i, "");
+    }
+  }
+
+  const rawThinking = thinkParts.length > 0 ? thinkParts.join("\n\n") : null;
+  const filteredThinking = rawThinking
+    ? filterThinkingContent(rawThinking)
+    : null;
+
+  return { thinking: filteredThinking, answer: answer.trim() };
+}

+ 251 - 0
src/lib/streaming-code-highlight.ts

@@ -0,0 +1,251 @@
+const KEYWORDS: Record<string, string[]> = {
+  javascript: [
+    "async", "await", "break", "case", "catch", "class", "const", "continue",
+    "debugger", "default", "delete", "do", "else", "export", "extends", "false",
+    "finally", "for", "function", "if", "import", "in", "instanceof", "let",
+    "new", "null", "of", "return", "super", "switch", "this", "throw", "true",
+    "try", "typeof", "undefined", "var", "void", "while", "with", "yield",
+  ],
+  typescript: [
+    "async", "await", "break", "case", "catch", "class", "const", "continue",
+    "debugger", "default", "delete", "do", "else", "enum", "export", "extends",
+    "false", "finally", "for", "function", "if", "import", "in", "implements",
+    "instanceof", "interface", "let", "new", "null", "of", "return", "super",
+    "switch", "this", "throw", "true", "try", "typeof", "undefined", "var",
+    "void", "while", "with", "yield", "type", "as", "from", "readonly",
+    "private", "protected", "public", "static", "abstract", "declare",
+  ],
+  python: [
+    "False", "None", "True", "and", "as", "assert", "async", "await", "break",
+    "class", "continue", "def", "del", "elif", "else", "except", "finally",
+    "for", "from", "global", "if", "import", "in", "is", "lambda", "nonlocal",
+    "not", "or", "pass", "raise", "return", "try", "while", "with", "yield",
+    "self", "print",
+  ],
+  rust: [
+    "as", "break", "const", "continue", "crate", "else", "enum", "extern",
+    "false", "fn", "for", "if", "impl", "in", "let", "loop", "match", "mod",
+    "move", "mut", "pub", "ref", "return", "self", "Self", "static", "struct",
+    "super", "trait", "true", "type", "unsafe", "use", "where", "while",
+    "async", "await", "dyn",
+  ],
+  java: [
+    "abstract", "assert", "boolean", "break", "byte", "case", "catch", "char",
+    "class", "const", "continue", "default", "do", "double", "else", "enum",
+    "extends", "false", "final", "finally", "float", "for", "goto", "if",
+    "implements", "import", "instanceof", "int", "interface", "long", "native",
+    "new", "null", "package", "private", "protected", "public", "return",
+    "short", "static", "strictfp", "super", "switch", "synchronized", "this",
+    "throw", "throws", "transient", "true", "try", "void", "volatile", "while",
+  ],
+  go: [
+    "break", "case", "chan", "const", "continue", "default", "defer", "else",
+    "fallthrough", "for", "func", "go", "goto", "if", "import", "interface",
+    "map", "package", "range", "return", "select", "struct", "switch", "type",
+    "var", "nil", "true", "false",
+  ],
+}
+
+const LANGUAGE_ALIASES: Record<string, string> = {
+  js: "javascript",
+  ts: "typescript",
+  jsx: "javascript",
+  tsx: "typescript",
+  py: "python",
+  rb: "ruby",
+  rs: "rust",
+  cpp: "cpp",
+  c: "cpp",
+  cs: "csharp",
+  sh: "bash",
+  zsh: "bash",
+  shell: "bash",
+  yml: "yaml",
+}
+
+function resolveLanguage(lang: string): string {
+  return LANGUAGE_ALIASES[lang] || lang
+}
+
+function getKeywords(lang: string): string[] {
+  const resolved = resolveLanguage(lang)
+  if (resolved === "cpp") {
+    return [
+      "auto", "bool", "break", "case", "catch", "char", "class", "const",
+      "constexpr", "continue", "default", "delete", "do", "double", "else",
+      "enum", "explicit", "export", "extern", "false", "float", "for", "friend",
+      "goto", "if", "inline", "int", "long", "mutable", "namespace", "new",
+      "noexcept", "nullptr", "operator", "override", "private", "protected",
+      "public", "register", "return", "short", "signed", "sizeof", "static",
+      "struct", "switch", "template", "this", "throw", "true", "try", "typedef",
+      "typeid", "typename", "union", "unsigned", "using", "virtual", "void",
+      "volatile", "while", "#include", "#define", "#ifdef", "#ifndef", "#endif",
+      "#pragma",
+    ]
+  }
+  if (resolved === "csharp") {
+    return [
+      "abstract", "as", "async", "await", "base", "bool", "break", "byte",
+      "case", "catch", "char", "checked", "class", "const", "continue",
+      "decimal", "default", "delegate", "do", "double", "else", "enum",
+      "event", "explicit", "extern", "false", "finally", "fixed", "float",
+      "for", "foreach", "goto", "if", "implicit", "in", "int", "interface",
+      "internal", "is", "lock", "long", "namespace", "new", "null", "object",
+      "operator", "out", "override", "params", "private", "protected",
+      "public", "readonly", "ref", "return", "sbyte", "sealed", "short",
+      "sizeof", "stackalloc", "static", "string", "struct", "switch", "this",
+      "throw", "true", "try", "typeof", "uint", "ulong", "unchecked",
+      "unsafe", "ushort", "using", "var", "virtual", "void", "volatile",
+      "while",
+    ]
+  }
+  if (resolved === "ruby") {
+    return [
+      "BEGIN", "END", "alias", "and", "begin", "break", "case", "class",
+      "def", "defined?", "do", "else", "elsif", "end", "ensure", "false",
+      "for", "if", "in", "module", "next", "nil", "not", "or", "redo",
+      "rescue", "retry", "return", "self", "super", "then", "true",
+      "undef", "unless", "until", "when", "while", "yield",
+    ]
+  }
+  if (resolved === "html" || resolved === "xml") {
+    return []
+  }
+  if (resolved === "css") {
+    return []
+  }
+  if (resolved === "bash") {
+    return [
+      "if", "then", "else", "elif", "fi", "for", "while", "do", "done",
+      "case", "esac", "in", "function", "return", "exit", "export", "local",
+      "declare", "echo", "read", "set", "unset", "source", "cd", "ls",
+      "rm", "mv", "cp", "mkdir", "cat", "grep", "sed", "awk", "printf",
+    ]
+  }
+  if (resolved === "sql") {
+    return [
+      "SELECT", "FROM", "WHERE", "INSERT", "INTO", "VALUES", "UPDATE", "SET",
+      "DELETE", "CREATE", "TABLE", "ALTER", "DROP", "INDEX", "VIEW", "JOIN",
+      "LEFT", "RIGHT", "INNER", "OUTER", "ON", "AND", "OR", "NOT", "IN",
+      "LIKE", "BETWEEN", "IS", "NULL", "AS", "ORDER", "BY", "GROUP", "HAVING",
+      "LIMIT", "OFFSET", "DISTINCT", "COUNT", "SUM", "AVG", "MIN", "MAX",
+      "UNION", "ALL", "EXISTS", "CASE", "WHEN", "THEN", "ELSE", "END",
+      "BEGIN", "COMMIT", "ROLLBACK", "PRIMARY", "KEY", "FOREIGN", "REFERENCES",
+    ]
+  }
+  if (resolved === "yaml") {
+    return []
+  }
+  if (resolved === "json") {
+    return []
+  }
+  return KEYWORDS[resolved] ?? []
+}
+
+interface TokenSpan {
+  type: "keyword" | "string" | "comment" | "number" | "text"
+  text: string
+}
+
+function tokenizeLine(line: string, lang: string): TokenSpan[] {
+  const tokens: TokenSpan[] = []
+  const keywords = new Set(getKeywords(lang))
+  const resolved = resolveLanguage(lang)
+  let remaining = line
+
+  const patternGroups: Array<{ type: TokenSpan["type"]; regex: RegExp }> = [
+    { type: "comment", regex: /\/\/[^\n]*/ },
+    { type: "comment", regex: /\/\*[\s\S]*?\*\// },
+    { type: "string", regex: /"(?:[^"\\]|\\.)*"/ },
+    { type: "string", regex: /'(?:[^'\\]|\\.)*'/ },
+    { type: "string", regex: /`(?:[^`\\]|\\.)*`/ },
+  ]
+
+  while (remaining.length > 0) {
+    let earliestMatch: { index: number; match: string; type: TokenSpan["type"] } | null = null
+
+    for (const { type, regex } of patternGroups) {
+      const m = remaining.match(regex)
+      if (m && m.index !== undefined && (earliestMatch === null || m.index < earliestMatch.index)) {
+        earliestMatch = { index: m.index, match: m[0], type }
+      }
+    }
+
+    if (earliestMatch) {
+      if (earliestMatch.index > 0) {
+        const before = remaining.slice(0, earliestMatch.index)
+        for (const word of splitWords(before, keywords, resolved)) {
+          tokens.push(word)
+        }
+      }
+      tokens.push({ type: earliestMatch.type, text: earliestMatch.match })
+      remaining = remaining.slice(earliestMatch.index + earliestMatch.match.length)
+    } else {
+      for (const word of splitWords(remaining, keywords, resolved)) {
+        tokens.push(word)
+      }
+      remaining = ""
+    }
+  }
+
+  return tokens
+}
+
+function splitWords(text: string, keywords: Set<string>, lang: string): TokenSpan[] {
+  const tokens: TokenSpan[] = []
+  const wordRegex = /(\w+\b)|([^\w]+)/g
+  let m: RegExpExecArray | null
+  while ((m = wordRegex.exec(text)) !== null) {
+    if (m[1]) {
+      const word = m[1]
+      if (keywords.has(word) || (lang === "sql" && keywords.has(word.toUpperCase()))) {
+        tokens.push({ type: "keyword", text: word })
+      } else {
+        tokens.push({ type: "text", text: word })
+      }
+    } else if (m[2]) {
+      tokens.push({ type: "text", text: m[2] })
+    }
+  }
+  return tokens
+}
+
+function renderTokens(tokens: TokenSpan[]): string {
+  return tokens
+    .map((t) => {
+      switch (t.type) {
+        case "keyword":
+          return `<span class="hl-kw">${escapeHtml(t.text)}</span>`
+        case "string":
+          return `<span class="hl-str">${escapeHtml(t.text)}</span>`
+        case "comment":
+          return `<span class="hl-cm">${escapeHtml(t.text)}</span>`
+        case "number":
+          return `<span class="hl-num">${escapeHtml(t.text)}</span>`
+        default:
+          return escapeHtml(t.text)
+      }
+    })
+    .join("")
+}
+
+function escapeHtml(text: string): string {
+  return text
+    .replace(/&/g, "&amp;")
+    .replace(/</g, "&lt;")
+    .replace(/>/g, "&gt;")
+}
+
+export function highlightCode(code: string, lang: string): string {
+  if (!lang || lang === "mermaid" || lang === "text" || lang === "plain") {
+    return escapeHtml(code)
+  }
+
+  const lines = code.split("\n")
+  const highlightedLines = lines.map((line) => {
+    const tokens = tokenizeLine(line, lang)
+    return renderTokens(tokens)
+  })
+
+  return highlightedLines.join("\n")
+}

+ 60 - 0
src/lib/toast.spec.tsx

@@ -0,0 +1,60 @@
+// @vitest-environment jsdom
+import { act, useEffect } from "react"
+import { createRoot, type Root } from "react-dom/client"
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
+import { toast, ToastProvider, useToast, type ToastApi } from "./toast"
+
+;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+let host: HTMLDivElement
+let root: Root
+let api: ToastApi
+function Capture() { api = useToast(); return null }
+beforeEach(async () => {
+  vi.useFakeTimers()
+  host = document.createElement("div")
+  document.body.appendChild(host)
+  root = createRoot(host)
+  await act(async () => root.render(<ToastProvider><Capture /></ToastProvider>))
+})
+afterEach(() => { act(() => root.unmount()); host.remove(); vi.useRealTimers() })
+const persistentError = (message: string, key: string) => api.error(message, { title: "自动保存失败", persistent: true, dedupeKey: key })
+
+describe("ToastProvider", () => {
+  it("deduplicates persistent errors and keeps them until manual dismissal", async () => {
+    await act(async () => { persistentError("不支持的写入模式:new", "outline-save:new"); persistentError("不支持的写入模式:new", "outline-save:new") })
+    expect(document.body.textContent?.match(/自动保存失败/g)).toHaveLength(1)
+    const region = document.querySelector('[data-toast-region="true"]')
+    expect(region?.className).toContain("bottom-4")
+    expect(region?.className).toContain("right-4")
+    await act(async () => { vi.advanceTimersByTime(60_000) })
+    expect(document.body.textContent).toContain("不支持的写入模式:new")
+  })
+
+  it("shows three items and reveals the queued fourth item after dismissal", async () => {
+    await act(async () => { persistentError("错误一", "one"); persistentError("错误二", "two"); persistentError("错误三", "three"); persistentError("错误四", "four") })
+    expect(document.querySelectorAll('[data-toast-card="true"]')).toHaveLength(3)
+    expect(document.body.textContent).not.toContain("错误四")
+    await act(async () => document.querySelector<HTMLButtonElement>('[aria-label="关闭提示"]')?.click())
+    expect(document.body.textContent).toContain("错误四")
+  })
+
+  it("starts auto-dismiss timing only after a queued item becomes visible", async () => {
+    await act(async () => { api.info("普通一"); api.info("普通二"); api.info("普通三"); api.info("普通四") })
+    expect(document.body.textContent).not.toContain("普通四")
+    await act(async () => { vi.advanceTimersByTime(4_001) })
+    expect(document.body.textContent).toContain("普通四")
+    await act(async () => { vi.advanceTimersByTime(3_999) })
+    expect(document.body.textContent).toContain("普通四")
+    await act(async () => { vi.advanceTimersByTime(2) })
+    expect(document.body.textContent).not.toContain("普通四")
+  })
+
+  it("flushes global toast calls made by descendant mount effects", async () => {
+    function OnMount() {
+      useEffect(() => { toast.error("挂载错误", { persistent: true, dedupeKey: "mount-error" }) }, [])
+      return null
+    }
+    await act(async () => root.render(<ToastProvider><OnMount /></ToastProvider>))
+    expect(document.body.textContent).toContain("挂载错误")
+  })
+})

+ 92 - 152
src/lib/toast.tsx

@@ -1,199 +1,139 @@
-/**
- * 轻量 Toast 提示工具
- * 不引入新依赖,复用项目里 React + createPortal 实现
- *
- * 用法:
- *   import { ToastProvider, toast } from "@/lib/toast"
- *   <ToastProvider />         // 放在应用根组件
- *   toast.success("已保存")
- *   toast.error("加载失败:xxx")
- *   toast.info("正在处理")
- */
-
 import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"
 import type { ReactNode } from "react"
 import { createPortal } from "react-dom"
 import { CheckCircle2, AlertTriangle, Info, X } from "lucide-react"
 
 export type ToastKind = "success" | "error" | "info"
-
-/** 可选的操作按钮:携带在 toast 上,例如「现在处理」。带 action 的 toast 不会自动消失。 */
-export interface ToastAction {
-  label: string
-  onClick: () => void
-}
-
-interface ToastItem {
-  id: number
-  kind: ToastKind
-  message: string
-  createdAt: number
-  /** 可选操作按钮;存在时不自动消失。 */
-  action?: ToastAction
+export interface ToastAction { label: string; onClick: () => void }
+export interface ToastOptions { title?: string; action?: ToastAction; persistent?: boolean; dedupeKey?: string }
+export type ToastArgument = ToastAction | ToastOptions | undefined
+interface ToastItem { id: number; key: string; kind: ToastKind; title?: string; message: string; createdAt: number; action?: ToastAction; persistent: boolean }
+export interface ToastApi {
+  success: (message: string, options?: ToastArgument) => void
+  error: (message: string, options?: ToastArgument) => void
+  info: (message: string, options?: ToastArgument) => void
 }
-
-interface ToastApi {
-  success: (message: string, action?: ToastAction) => void
-  error: (message: string, action?: ToastAction) => void
-  info: (message: string, action?: ToastAction) => void
-}
-
 const ToastContext = createContext<ToastApi | null>(null)
-
 const TOAST_DURATION_MS = 4000
-
+const MAX_VISIBLE_TOASTS = 3
+function normalizeOptions(argument: ToastArgument): ToastOptions {
+  if (!argument) return {}
+  if ("label" in argument && "onClick" in argument) return { action: argument }
+  return argument
+}
 export function ToastProvider({ children }: { children: ReactNode }) {
   const [items, setItems] = useState<ToastItem[]>([])
+  const itemsRef = useRef<ToastItem[]>([])
   const idRef = useRef(0)
+  const keysRef = useRef(new Set<string>())
   const timersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map())
-
   const dismiss = useCallback((id: number) => {
-    setItems((current) => current.filter((item) => item.id !== id))
+    const target = itemsRef.current.find((item) => item.id === id)
+    if (target) keysRef.current.delete(target.key)
+    itemsRef.current = itemsRef.current.filter((item) => item.id !== id)
+    setItems(itemsRef.current)
     const timer = timersRef.current.get(id)
-    if (timer) {
-      clearTimeout(timer)
-      timersRef.current.delete(id)
+    if (timer) clearTimeout(timer)
+    timersRef.current.delete(id)
+  }, [])
+  const push = useCallback((kind: ToastKind, message: string, argument?: ToastArgument) => {
+    const options = normalizeOptions(argument)
+    const key = options.dedupeKey ?? `${kind}:${options.title ?? ""}:${message}`
+    if (keysRef.current.has(key)) return
+    keysRef.current.add(key)
+    const item: ToastItem = {
+      id: ++idRef.current,
+      key,
+      kind,
+      title: options.title,
+      message,
+      createdAt: Date.now(),
+      action: options.action,
+      persistent: options.persistent === true,
     }
+    itemsRef.current = [...itemsRef.current, item]
+    setItems(itemsRef.current)
   }, [])
-
-  const push = useCallback(
-    (kind: ToastKind, message: string, action?: ToastAction) => {
-      const id = ++idRef.current
-      setItems((current) => [...current, { id, kind, message, createdAt: Date.now(), action }])
-      // 带 action 的 toast 不自动消失,必须由用户点击关闭或操作按钮
-      if (!action) {
-        const timer = setTimeout(() => dismiss(id), TOAST_DURATION_MS)
-        timersRef.current.set(id, timer)
-      }
-    },
-    [dismiss],
-  )
-
   useEffect(() => {
-    return () => {
-      timersRef.current.forEach((timer) => clearTimeout(timer))
-      timersRef.current.clear()
+    for (const item of items.slice(0, MAX_VISIBLE_TOASTS)) {
+      if (item.persistent || item.action || timersRef.current.has(item.id)) continue
+      const timer = setTimeout(() => dismiss(item.id), TOAST_DURATION_MS)
+      timersRef.current.set(item.id, timer)
     }
+  }, [dismiss, items])
+  useEffect(() => () => {
+    timersRef.current.forEach((timer) => clearTimeout(timer))
+    timersRef.current.clear()
+    keysRef.current.clear()
+    itemsRef.current = []
   }, [])
-
-  const api = useMemo<ToastApi>(
-    () => ({
-      success: (message, action) => push("success", message, action),
-      error: (message, action) => push("error", message, action),
-      info: (message, action) => push("info", message, action),
-    }),
-    [push],
-  )
-
+  const api = useMemo<ToastApi>(() => ({
+    success: (message, options) => push("success", message, options),
+    error: (message, options) => push("error", message, options),
+    info: (message, options) => push("info", message, options),
+  }), [push])
   useEffect(() => {
     setToastApi(api)
-    return () => setToastApi(null)
+    return () => clearToastApi(api)
   }, [api])
-
   return (
     <ToastContext.Provider value={api}>
       {children}
-      {typeof document !== "undefined" &&
-        createPortal(
-          <div
-            aria-live="polite"
-            className="pointer-events-none fixed top-4 right-4 z-[1000] flex w-80 max-w-[calc(100vw-2rem)] flex-col gap-2"
-          >
-            {items.map((item) => (
-              <ToastCard key={item.id} item={item} onDismiss={() => dismiss(item.id)} />
-            ))}
-          </div>,
-          document.body,
-        )}
+      {typeof document !== "undefined" && createPortal(
+        <div aria-live="polite" data-toast-region="true" className="pointer-events-none fixed bottom-4 right-4 z-[1000] flex w-96 max-w-[calc(100vw-2rem)] flex-col gap-2">
+          {items.slice(0, MAX_VISIBLE_TOASTS).map((item) => <ToastCard key={item.id} item={item} onDismiss={() => dismiss(item.id)} />)}
+        </div>, document.body,
+      )}
     </ToastContext.Provider>
   )
 }
-
 function ToastCard({ item, onDismiss }: { item: ToastItem; onDismiss: () => void }) {
   const config = KIND_STYLES[item.kind]
   const Icon = config.icon
-  const handleAction = () => {
-    try {
-      item.action?.onClick()
-    } finally {
-      onDismiss()
-    }
-  }
+  const handleAction = () => { try { item.action?.onClick() } finally { onDismiss() } }
   return (
-    <div
-      role="status"
-      className={`pointer-events-auto flex items-start gap-2 rounded-md border bg-background p-3 shadow-lg ${config.container}`}
-    >
-      <Icon className={`mt-0.5 h-4 w-4 shrink-0 ${config.iconClass}`} />
-      <div className="flex-1 whitespace-pre-wrap text-sm leading-5 text-foreground">{item.message}</div>
-      {item.action && (
-        <button
-          type="button"
-          onClick={handleAction}
-          className="ml-1 shrink-0 rounded-md bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary hover:bg-primary/20 transition-colors"
-        >
-          {item.action.label}
-        </button>
-      )}
-      <button
-        type="button"
-        onClick={onDismiss}
-        className="ml-1 rounded p-0.5 text-muted-foreground hover:bg-muted"
-        aria-label="关闭提示"
-      >
-        <X className="h-3.5 w-3.5" />
-      </button>
+    <div role={item.kind === "error" ? "alert" : "status"} data-toast-card="true" className={`pointer-events-auto flex items-start gap-2 rounded-md border bg-background p-3 shadow-lg ${config.container}`}>
+      <Icon aria-hidden="true" className={`mt-0.5 h-4 w-4 shrink-0 ${config.iconClass}`} />
+      <div className="min-w-0 flex-1">
+        {item.title ? <div className="mb-0.5 text-sm font-medium text-foreground">{item.title}</div> : null}
+        <div className="max-h-24 overflow-y-auto whitespace-pre-wrap break-words text-sm leading-5 text-foreground">{item.message}</div>
+      </div>
+      {item.action ? <button type="button" onClick={handleAction} className="ml-1 shrink-0 rounded-md bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary hover:bg-primary/20">{item.action.label}</button> : null}
+      <button type="button" onClick={onDismiss} className="ml-1 rounded p-0.5 text-muted-foreground hover:bg-muted" aria-label="关闭提示"><X className="h-3.5 w-3.5" /></button>
     </div>
   )
 }
-
 const KIND_STYLES: Record<ToastKind, { container: string; icon: typeof CheckCircle2; iconClass: string }> = {
-  success: {
-    container: "border-primary/30",
-    icon: CheckCircle2,
-    iconClass: "text-primary",
-  },
-  error: {
-    container: "border-destructive/40",
-    icon: AlertTriangle,
-    iconClass: "text-destructive",
-  },
-  info: {
-    container: "border-border",
-    icon: Info,
-    iconClass: "text-muted-foreground",
-  },
+  success: { container: "border-primary/30", icon: CheckCircle2, iconClass: "text-primary" },
+  error: { container: "border-destructive/40", icon: AlertTriangle, iconClass: "text-destructive" },
+  info: { container: "border-border", icon: Info, iconClass: "text-muted-foreground" },
 }
-
 export function useToast(): ToastApi {
   const ctx = useContext(ToastContext)
-  if (!ctx) {
-    // 在没有 Provider 的环境(如 vitest 单测)回退到 console,避免硬崩
-    return {
-      success: (m) => console.info(`[toast:success] ${m}`),
-      error: (m) => console.warn(`[toast:error] ${m}`),
-      info: (m) => console.info(`[toast:info] ${m}`),
-    }
+  if (ctx) return ctx
+  return {
+    success: (message, options) => dispatchExternal("success", message, options),
+    error: (message, options) => dispatchExternal("error", message, options),
+    info: (message, options) => dispatchExternal("info", message, options),
   }
-  return ctx
 }
-
-/**
- * 在组件外部调用 toast 时使用。
- * 通过 Provider 暴露的全局单例(由 setToastApi 注入)。
- * 没有 Provider 时退化为 console 提示,避免硬崩。
- */
+type PendingExternalToast = { kind: ToastKind; message: string; options?: ToastArgument }
 let externalApi: ToastApi | null = null
-
+let pendingExternalToasts: PendingExternalToast[] = []
 export function setToastApi(api: ToastApi | null) {
   externalApi = api
+  if (!api || pendingExternalToasts.length === 0) return
+  const pending = pendingExternalToasts
+  pendingExternalToasts = []
+  for (const item of pending) api[item.kind](item.message, item.options)
 }
-
-export const toast: ToastApi = {
-  success: (message, action) =>
-    externalApi ? externalApi.success(message, action) : console.info(`[toast:success] ${message}`),
-  error: (message, action) =>
-    externalApi ? externalApi.error(message, action) : console.warn(`[toast:error] ${message}`),
-  info: (message, action) =>
-    externalApi ? externalApi.info(message, action) : console.info(`[toast:info] ${message}`),
+function clearToastApi(api: ToastApi) { if (externalApi === api) externalApi = null }
+function dispatchExternal(kind: ToastKind, message: string, options?: ToastArgument) {
+  if (externalApi) externalApi[kind](message, options)
+  else pendingExternalToasts.push({ kind, message, options })
 }
+export const toast: ToastApi = {
+  success: (message, options) => dispatchExternal("success", message, options),
+  error: (message, options) => dispatchExternal("error", message, options),
+  info: (message, options) => dispatchExternal("info", message, options),
+}

+ 4 - 1
src/main.tsx

@@ -3,9 +3,12 @@ import ReactDOM from "react-dom/client";
 import App from "./App";
 import "./index.css";
 import "@/i18n";
+import { ToastProvider } from "@/lib/toast";
 
 ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
   <React.StrictMode>
-    <App />
+    <ToastProvider>
+      <App />
+    </ToastProvider>
   </React.StrictMode>
 );

+ 62 - 1
src/stores/chat-store.ts

@@ -4,6 +4,14 @@ import type { AgentRunRecord, AgentStageTrace } from "@/lib/agent/types"
 import type { ReferenceToken } from "@/lib/reference/types"
 import type { ContextTrace } from "@/lib/agent/context-trace"
 import i18n from "@/i18n"
+import {
+  canStartConversationRun as canStartRun,
+  failConversationRun as createFailedRunState,
+  finishConversationRun as createFinishedRunState,
+  normalizeLoadedRunStates,
+  stopConversationRun as createStoppedRunState,
+  type ConversationRunStates,
+} from "@/lib/conversation-run-state"
 
 export interface Conversation {
   id: string
@@ -41,6 +49,7 @@ interface ChatState {
   messages: DisplayMessage[]
   /** 按会话 ID 存储流式内容,支持多会话同时生成 */
   streamingContents: Record<string, string>
+  runStates: ConversationRunStates
   pendingReferenceTokens: ReferenceToken[]
   mode: "chat" | "ingest"
   ingestSource: string | null
@@ -59,6 +68,12 @@ interface ChatState {
   addMessage: (role: DisplayMessage["role"], content: string) => void
   setMessages: (messages: DisplayMessage[]) => void
   setConversations: (conversations: Conversation[]) => void
+  startConversationRun: (id: string, runId?: string) => boolean
+  finishConversationRun: (id: string, activeId: string | null, runId?: string) => void
+  failConversationRun: (id: string, error: string, runId?: string) => void
+  stopConversationRun: (id: string, runId?: string) => void
+  setLoadedRunStates: (states: ConversationRunStates | undefined) => void
+  canStartConversationRun: (id: string) => boolean
   /** 开始指定会话的流式生成 */
   startStreaming: (conversationId: string) => void
   /** 追加 token 到指定会话的流式内容 */
@@ -102,6 +117,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
   activeConversationId: null,
   messages: [],
   streamingContents: {},
+  runStates: {},
   pendingReferenceTokens: [],
   mode: "chat",
   ingestSource: null,
@@ -135,15 +151,27 @@ export const useChatStore = create<ChatState>((set, get) => ({
           : state.activeConversationId
       // 清理该会话的流式状态
       const { [id]: _, ...restStreaming } = state.streamingContents
+      const { [id]: __, ...restRunStates } = state.runStates
       return {
         conversations: remaining,
         messages: state.messages.filter((m) => m.conversationId !== id),
         activeConversationId: newActiveId,
         streamingContents: restStreaming,
+        runStates: restRunStates,
       }
     }),
 
-  setActiveConversation: (id) => set({ activeConversationId: id }),
+  setActiveConversation: (id) =>
+    set((state) => {
+      const runState = id ? state.runStates[id] : undefined
+      return {
+        activeConversationId: id,
+        runStates:
+          id && runState?.status === "completed_unread"
+            ? { ...state.runStates, [id]: createStoppedRunState() }
+            : state.runStates,
+      }
+    }),
 
   renameConversation: (id, title) =>
     set((state) => ({
@@ -213,6 +241,39 @@ export const useChatStore = create<ChatState>((set, get) => ({
 
   setConversations: (conversations) => set({ conversations }),
 
+  startConversationRun: (id, runId) => {
+    if (!canStartRun(get().runStates, id)) return false
+    set((state) => ({ runStates: { ...state.runStates, [id]: { status: "running", updatedAt: Date.now(), runId } } }))
+    return true
+  },
+
+  finishConversationRun: (id, activeId, runId) =>
+    set((state) => {
+      const current = state.runStates[id]
+      if (!state.conversations.some((conversation) => conversation.id === id)) return state
+      if (current?.status !== "running" || current.runId !== runId) return state
+      return { runStates: { ...state.runStates, [id]: createFinishedRunState(id, activeId) } }
+    }),
+
+  failConversationRun: (id, error, runId) =>
+    set((state) => {
+      const current = state.runStates[id]
+      if (!state.conversations.some((conversation) => conversation.id === id)) return state
+      if (current?.status !== "running" || current.runId !== runId) return state
+      return { runStates: { ...state.runStates, [id]: createFailedRunState(error) } }
+    }),
+
+  stopConversationRun: (id, runId) =>
+    set((state) => {
+      const current = state.runStates[id]
+      if (current?.status !== "running" || current.runId !== runId) return state
+      return { runStates: { ...state.runStates, [id]: createStoppedRunState() } }
+    }),
+
+  setLoadedRunStates: (states) => set({ runStates: normalizeLoadedRunStates(states) }),
+
+  canStartConversationRun: (id) => canStartRun(get().runStates, id),
+
   startStreaming: (conversationId) =>
     set((state) => ({
       streamingContents: {

+ 3 - 3
src/stores/import-progress-store.ts

@@ -1,7 +1,7 @@
 import { create } from "zustand"
 import { normalizePath } from "@/lib/path-utils"
 
-export type ImportProgressKind = "chapter" | "outline"
+export type ImportProgressKind = "chapter" | "outline" | "outline_generation" | "outline_refinement"
 export type ImportProgressStatus = "running" | "done" | "cancelled" | "error"
 
 export interface ImportProgressTask {
@@ -90,7 +90,7 @@ export const useImportProgressStore = create<ImportProgressState>((set, get) =>
   finishTask: (taskId, status, patch = {}) => {
     const task = get().tasks.find((item) => item.id === taskId)
     get().updateTask(taskId, { ...patch, status, cancelling: false })
-    if (task?.kind === "outline" && status !== "running") {
+    if (task?.kind === "outline" || task?.kind === "outline_generation" || task?.kind === "outline_refinement") {
       void import("@/lib/novel/outline-generation").then(({ reconcileStaleOutlineIngestTasks }) => {
         reconcileStaleOutlineIngestTasks(task.projectPath)
       })
@@ -104,7 +104,7 @@ export const useImportProgressStore = create<ImportProgressState>((set, get) =>
     if (!task) return
     task.abortController?.abort()
     get().updateTask(taskId, { status: "cancelled", cancelling: false, activeTitles: [] })
-    if (task.kind === "outline") {
+    if (task.kind === "outline" || task.kind === "outline_generation" || task.kind === "outline_refinement") {
       void import("@/lib/novel/outline-generation").then(({ reconcileStaleOutlineIngestTasks }) => {
         reconcileStaleOutlineIngestTasks(task.projectPath)
       })

+ 157 - 0
src/stores/outline-chat-store.spec.ts

@@ -0,0 +1,157 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
+
+const fsMocks = vi.hoisted(() => ({ createDirectory: vi.fn(), readFile: vi.fn(), writeFile: vi.fn() }))
+vi.mock("@/commands/fs", () => fsMocks)
+
+import type { OutlineChatConversation } from "./outline-chat-store"
+import { useOutlineChatStore } from "./outline-chat-store"
+import { useWikiStore } from "./wiki-store"
+
+function conversation(id: string): OutlineChatConversation {
+  return { id, title: id, createdAt: 10, updatedAt: 10, messages: [] }
+}
+
+beforeEach(() => {
+  vi.useFakeTimers()
+  fsMocks.createDirectory.mockReset().mockResolvedValue(undefined)
+  fsMocks.readFile.mockReset()
+  fsMocks.writeFile.mockReset().mockResolvedValue(undefined)
+  useWikiStore.setState({ project: null })
+  useOutlineChatStore.setState({
+    conversations: [], activeConversationId: null, streamingContents: {}, runStates: {}, loaded: false, pendingReferenceTokens: [],
+  })
+})
+
+afterEach(() => { vi.clearAllTimers(); vi.useRealTimers() })
+
+describe("outline-chat-store", () => {
+  it("按会话隔离流式内容,并支持追加、读取和单独清理", () => {
+    useOutlineChatStore.setState({ conversations: [conversation("a"), conversation("b")] })
+    const store = useOutlineChatStore.getState()
+    store.setStreamingContent("a", "A")
+    store.appendStreamingContent("a", "内容")
+    store.setStreamingContent("b", "B内容")
+    expect(useOutlineChatStore.getState().streamingContents).toEqual({ a: "A内容", b: "B内容" })
+    expect(useOutlineChatStore.getState().getStreamingContent("a")).toBe("A内容")
+    useOutlineChatStore.getState().clearStreamingContent("a")
+    expect(useOutlineChatStore.getState().streamingContents).toEqual({ b: "B内容" })
+  })
+
+  it("后台完成显示未读,首次打开后只清除完成未读状态", () => {
+    useOutlineChatStore.setState({ conversations: [conversation("a"), conversation("b")], activeConversationId: "b" })
+    expect(useOutlineChatStore.getState().startConversationRun("a", "run-a")).toBe(true)
+    useOutlineChatStore.getState().finishConversationRun("a", "b", "run-a")
+    expect(useOutlineChatStore.getState().runStates.a.status).toBe("completed_unread")
+    useOutlineChatStore.getState().setActiveConversation("a")
+    expect(useOutlineChatStore.getState().runStates.a.status).toBe("idle")
+  })
+
+  it("打开失败或中断会话不会清除其状态", () => {
+    useOutlineChatStore.setState({
+      conversations: [conversation("failed"), conversation("interrupted")],
+      runStates: {
+        failed: { status: "failed", error: "接口错误", updatedAt: 10 },
+        interrupted: { status: "interrupted", error: "任务中断", updatedAt: 20 },
+      },
+    })
+    useOutlineChatStore.getState().setActiveConversation("failed")
+    useOutlineChatStore.getState().setActiveConversation("interrupted")
+    expect(useOutlineChatStore.getState().runStates.failed.status).toBe("failed")
+    expect(useOutlineChatStore.getState().runStates.interrupted.status).toBe("interrupted")
+  })
+
+  it("最多允许三个大纲会话同时运行", () => {
+    const ids = ["a", "b", "c", "d"]
+    useOutlineChatStore.setState({ conversations: ids.map(conversation) })
+    ids.slice(0, 3).forEach((id) => expect(useOutlineChatStore.getState().startConversationRun(id, `run-${id}`)).toBe(true))
+    expect(useOutlineChatStore.getState().canStartConversationRun("d")).toBe(false)
+    expect(useOutlineChatStore.getState().startConversationRun("d", "run-d")).toBe(false)
+  })
+
+  it("不存在的会话不能创建孤儿运行状态", () => {
+    expect(useOutlineChatStore.getState().startConversationRun("missing", "run-missing")).toBe(false)
+    expect(useOutlineChatStore.getState().runStates.missing).toBeUndefined()
+  })
+
+  it("停止后重启时忽略旧任务的完成和失败回调", () => {
+    useOutlineChatStore.setState({ conversations: [conversation("a")] })
+    const store = useOutlineChatStore.getState()
+    store.startConversationRun("a", "run-old")
+    store.stopConversationRun("a", "run-old")
+    store.startConversationRun("a", "run-new")
+    useOutlineChatStore.getState().finishConversationRun("a", "a", "run-old")
+    useOutlineChatStore.getState().failConversationRun("a", "旧任务失败", "run-old")
+    expect(useOutlineChatStore.getState().runStates.a).toMatchObject({ status: "running", runId: "run-new" })
+  })
+
+  it("删除后旧回调不能重建状态,并清理该会话流内容和运行状态", () => {
+    useOutlineChatStore.setState({ conversations: [conversation("a"), conversation("b")] })
+    const store = useOutlineChatStore.getState()
+    store.startConversationRun("a", "run-a"); store.startConversationRun("b", "run-b")
+    store.setStreamingContent("a", "A内容"); store.setStreamingContent("b", "B内容")
+    useOutlineChatStore.getState().deleteConversation("a")
+    useOutlineChatStore.getState().finishConversationRun("a", "b", "run-a")
+    useOutlineChatStore.getState().failConversationRun("a", "旧任务失败", "run-a")
+    const state = useOutlineChatStore.getState()
+    expect(state.runStates.a).toBeUndefined(); expect(state.runStates.b?.status).toBe("running")
+    expect(state.streamingContents).toEqual({ b: "B内容" })
+  })
+
+  it("保存运行状态但不保存临时流式内容", async () => {
+    useWikiStore.setState({ project: { name: "项目", path: "C:/Book" } })
+    useOutlineChatStore.setState({ conversations: [conversation("a")] })
+    useOutlineChatStore.getState().startConversationRun("a", "run-a")
+    useOutlineChatStore.getState().setStreamingContent("a", "临时内容")
+    await useOutlineChatStore.getState().saveToDisk()
+    const saved = JSON.parse(fsMocks.writeFile.mock.calls[0][1])
+    expect(saved.runStates.a).toMatchObject({ status: "running", runId: "run-a" })
+    expect(saved.streamingContents).toBeUndefined()
+  })
+
+  it("仅运行状态变化也会自动保存", async () => {
+    useWikiStore.setState({ project: { name: "项目", path: "C:/Book" } })
+    useOutlineChatStore.setState({ conversations: [conversation("a")] })
+    useOutlineChatStore.getState().startConversationRun("a", "run-a")
+    await vi.advanceTimersByTimeAsync(500)
+    const saved = JSON.parse(fsMocks.writeFile.mock.calls.at(-1)?.[1] ?? "{}")
+    expect(saved.runStates.a).toMatchObject({ status: "running", runId: "run-a" })
+  })
+
+  it("加载时过滤孤儿状态,并把遗留运行状态转为中断", async () => {
+    useWikiStore.setState({ project: { name: "项目", path: "C:/Book" } })
+    fsMocks.readFile.mockResolvedValue(JSON.stringify({
+      conversations: [conversation("kept")], activeConversationId: "kept",
+      runStates: { kept: { status: "running", updatedAt: 10, runId: "run-kept" }, orphan: { status: "failed", updatedAt: 20, error: "孤儿状态" } },
+    }))
+    useOutlineChatStore.setState({ streamingContents: { stale: "旧内容" } })
+    await useOutlineChatStore.getState().loadFromDisk()
+    const state = useOutlineChatStore.getState()
+    expect(state.runStates.kept).toMatchObject({ status: "interrupted", error: "任务在软件关闭前未完成。" })
+    expect(state.runStates.orphan).toBeUndefined(); expect(state.streamingContents).toEqual({})
+  })
+
+  it("兼容没有运行状态字段的旧版大纲会话文件", async () => {
+    useWikiStore.setState({ project: { name: "项目", path: "C:/Book" } })
+    useOutlineChatStore.setState({
+      pendingReferenceTokens: [{ id: "old-ref", category: "outline", title: "旧引用", displayTitle: "旧引用" }],
+    })
+    fsMocks.readFile.mockResolvedValue(JSON.stringify({ conversations: [conversation("legacy")], activeConversationId: "legacy" }))
+    await useOutlineChatStore.getState().loadFromDisk()
+    expect(useOutlineChatStore.getState()).toMatchObject({
+      activeConversationId: "legacy", loaded: true, runStates: {}, streamingContents: {}, pendingReferenceTokens: [],
+    })
+  })
+
+  it("加载失败时清除上一个项目的会话状态", async () => {
+    useWikiStore.setState({ project: { name: "项目", path: "C:/Book" } })
+    useOutlineChatStore.setState({
+      conversations: [conversation("old")], activeConversationId: "old",
+      pendingReferenceTokens: [{ id: "ref", category: "outline", title: "旧引用", displayTitle: "旧引用" }],
+    })
+    fsMocks.readFile.mockRejectedValue(new Error("文件不存在"))
+    await useOutlineChatStore.getState().loadFromDisk()
+    expect(useOutlineChatStore.getState()).toMatchObject({
+      conversations: [], activeConversationId: null, pendingReferenceTokens: [], loaded: true,
+    })
+  })
+})

+ 189 - 42
src/stores/outline-chat-store.ts

@@ -6,6 +6,14 @@ import type { ReferenceToken } from "@/lib/reference/types"
 import { useWikiStore } from "@/stores/wiki-store"
 import type { IntentClarityResult } from "@/lib/novel/outline-intent-clarity"
 import type { NextStepRecommendation } from "@/lib/novel/outline-next-step"
+import {
+  canStartConversationRun as canStartRun,
+  failConversationRun as createFailedRunState,
+  finishConversationRun as createFinishedRunState,
+  normalizeLoadedRunStates,
+  stopConversationRun as createStoppedRunState,
+  type ConversationRunStates,
+} from "@/lib/conversation-run-state"
 
 export type OutlineMultiAgentStatus =
   | "planning"
@@ -17,6 +25,8 @@ export type OutlineMultiAgentStatus =
 
 export type OutlineMultiAgentStepStatus =
   | "pending"
+  | "waiting"
+  | "retrying"
   | "running"
   | "done"
   | "error"
@@ -28,6 +38,11 @@ export interface OutlineMultiAgentItemState {
   kind: string
   skillNames: string[]
   taskPrompt: string
+  dimension?: string
+  dependencies?: string[]
+  priority?: number
+  finalReview?: boolean
+  retryCount?: number
   status: OutlineMultiAgentStepStatus
   summary?: string
   error?: string
@@ -79,8 +94,8 @@ export interface OutlineChatConversation {
 interface OutlineChatState {
   conversations: OutlineChatConversation[]
   activeConversationId: string | null
-  streamingContent: string
-  isStreaming: boolean
+  streamingContents: Record<string, string>
+  runStates: ConversationRunStates
   loaded: boolean
   pendingReferenceTokens: ReferenceToken[]
 
@@ -92,8 +107,15 @@ interface OutlineChatState {
   deleteConversation: (id: string) => void
   setConversationModel: (id: string, modelId: string) => void
   setConversationContextSummary: (id: string, contextSummary: string) => void
-  setStreamingContent: (content: string) => void
-  setIsStreaming: (value: boolean) => void
+  setStreamingContent: (conversationId: string, content: string) => void
+  appendStreamingContent: (conversationId: string, content: string) => void
+  clearStreamingContent: (conversationId: string) => void
+  getStreamingContent: (conversationId: string) => string
+  startConversationRun: (id: string, runId: string) => boolean
+  finishConversationRun: (id: string, activeId: string | null, runId: string) => void
+  failConversationRun: (id: string, error: string, runId: string) => void
+  stopConversationRun: (id: string, runId: string) => void
+  canStartConversationRun: (id: string) => boolean
   enqueueReferenceTokens: (tokens: ReferenceToken[]) => void
   consumePendingReferenceTokens: () => ReferenceToken[]
   loadFromDisk: () => Promise<void>
@@ -106,11 +128,53 @@ function getStoragePath(): string | null {
   return `${normalizePath(project.path)}/.qmai/outline-chats.json`
 }
 
-export const useOutlineChatStore = create<OutlineChatState>((set, get) => ({
+export const useOutlineChatStore = create<OutlineChatState>((set, get) => {
+  const saveTimers = new Map<string, ReturnType<typeof setTimeout>>()
+  let loadGeneration = 0
+
+  const scheduleSave = () => {
+    const path = getStoragePath()
+    if (!path) return
+    const state = get()
+    const data = {
+      conversations: state.conversations,
+      activeConversationId: state.activeConversationId,
+      runStates: state.runStates,
+    }
+    const existingTimer = saveTimers.get(path)
+    if (existingTimer) clearTimeout(existingTimer)
+    const timer = setTimeout(() => {
+      saveTimers.delete(path)
+      void doSave(path, data)
+    }, 500)
+    saveTimers.set(path, timer)
+  }
+
+  const doSave = async (path = getStoragePath(), data?: {
+    conversations: OutlineChatConversation[]
+    activeConversationId: string | null
+    runStates: ConversationRunStates
+  }) => {
+    if (!path) return
+    const state = get()
+    const snapshot = data ?? {
+      conversations: state.conversations,
+      activeConversationId: state.activeConversationId,
+      runStates: state.runStates,
+    }
+    try {
+      const dir = path.replace(/[/\\][^/\\]+$/, "")
+      await createDirectory(dir)
+      await writeFile(path, JSON.stringify(snapshot, null, 2))
+    } catch {
+    }
+  }
+
+  return {
   conversations: [],
   activeConversationId: null,
-  streamingContent: "",
-  isStreaming: false,
+  streamingContents: {},
+  runStates: {},
   loaded: false,
   pendingReferenceTokens: [],
 
@@ -128,11 +192,22 @@ export const useOutlineChatStore = create<OutlineChatState>((set, get) => ({
       conversations: [conv, ...s.conversations],
       activeConversationId: id,
     }))
-    void get().saveToDisk()
+    scheduleSave()
     return id
   },
 
-  setActiveConversation: (id) => set({ activeConversationId: id }),
+  setActiveConversation: (id) => {
+    set((state) => {
+      const runState = id ? state.runStates[id] : undefined
+      return {
+        activeConversationId: id,
+        runStates: id && runState?.status === "completed_unread"
+          ? { ...state.runStates, [id]: createStoppedRunState() }
+          : state.runStates,
+      }
+    })
+    scheduleSave()
+  },
 
   addMessage: (convId, msg) => {
     const now = Date.now()
@@ -141,7 +216,7 @@ export const useOutlineChatStore = create<OutlineChatState>((set, get) => ({
         c.id === convId ? { ...c, messages: [...c.messages, msg], updatedAt: now } : c
       ),
     }))
-    void get().saveToDisk()
+    scheduleSave()
   },
 
   replaceLastAssistant: (convId, content, sources) => {
@@ -161,7 +236,7 @@ export const useOutlineChatStore = create<OutlineChatState>((set, get) => ({
         return { ...c, messages: msgs, title, updatedAt: now }
       }),
     }))
-    void get().saveToDisk()
+    scheduleSave()
   },
 
   removeLastMessage: (convId) => {
@@ -171,15 +246,21 @@ export const useOutlineChatStore = create<OutlineChatState>((set, get) => ({
         c.id === convId ? { ...c, messages: c.messages.slice(0, -1), updatedAt: now } : c
       ),
     }))
-    void get().saveToDisk()
+    scheduleSave()
   },
 
   deleteConversation: (id) => {
-    set((s) => ({
-      conversations: s.conversations.filter((c) => c.id !== id),
-      activeConversationId: s.activeConversationId === id ? null : s.activeConversationId,
-    }))
-    void get().saveToDisk()
+    set((s) => {
+      const { [id]: _stream, ...streamingContents } = s.streamingContents
+      const { [id]: _run, ...runStates } = s.runStates
+      return {
+        conversations: s.conversations.filter((c) => c.id !== id),
+        activeConversationId: s.activeConversationId === id ? null : s.activeConversationId,
+        streamingContents,
+        runStates,
+      }
+    })
+    scheduleSave()
   },
 
   setConversationModel: (id, modelId) => {
@@ -189,7 +270,7 @@ export const useOutlineChatStore = create<OutlineChatState>((set, get) => ({
         c.id === id ? { ...c, modelId, updatedAt: now } : c
       ),
     }))
-    void get().saveToDisk()
+    scheduleSave()
   },
 
   setConversationContextSummary: (id, contextSummary) => {
@@ -199,11 +280,57 @@ export const useOutlineChatStore = create<OutlineChatState>((set, get) => ({
         c.id === id ? { ...c, contextSummary, updatedAt: now } : c
       ),
     }))
-    void get().saveToDisk()
+    scheduleSave()
   },
 
-  setStreamingContent: (content) => set({ streamingContent: content }),
-  setIsStreaming: (value) => set({ isStreaming: value }),
+  setStreamingContent: (conversationId, content) => set((state) => ({
+    streamingContents: { ...state.streamingContents, [conversationId]: content },
+  })),
+  appendStreamingContent: (conversationId, content) => set((state) => ({
+    streamingContents: {
+      ...state.streamingContents,
+      [conversationId]: (state.streamingContents[conversationId] ?? "") + content,
+    },
+  })),
+  clearStreamingContent: (conversationId) => set((state) => {
+    const { [conversationId]: _, ...streamingContents } = state.streamingContents
+    return { streamingContents }
+  }),
+  getStreamingContent: (conversationId) => get().streamingContents[conversationId] ?? "",
+  startConversationRun: (id, runId) => {
+    if (!get().conversations.some((conversation) => conversation.id === id)) return false
+    if (!canStartRun(get().runStates, id)) return false
+    set((state) => ({ runStates: { ...state.runStates, [id]: { status: "running", updatedAt: Date.now(), runId } } }))
+    scheduleSave()
+    return true
+  },
+  finishConversationRun: (id, activeId, runId) => {
+    set((state) => {
+      const current = state.runStates[id]
+      if (!state.conversations.some((conversation) => conversation.id === id)) return state
+      if (current?.status !== "running" || current.runId !== runId) return state
+      return { runStates: { ...state.runStates, [id]: createFinishedRunState(id, activeId) } }
+    })
+    scheduleSave()
+  },
+  failConversationRun: (id, error, runId) => {
+    set((state) => {
+      const current = state.runStates[id]
+      if (!state.conversations.some((conversation) => conversation.id === id)) return state
+      if (current?.status !== "running" || current.runId !== runId) return state
+      return { runStates: { ...state.runStates, [id]: createFailedRunState(error) } }
+    })
+    scheduleSave()
+  },
+  stopConversationRun: (id, runId) => {
+    set((state) => {
+      const current = state.runStates[id]
+      if (current?.status !== "running" || current.runId !== runId) return state
+      return { runStates: { ...state.runStates, [id]: createStoppedRunState() } }
+    })
+    scheduleSave()
+  },
+  canStartConversationRun: (id) => get().conversations.some((conversation) => conversation.id === id) && canStartRun(get().runStates, id),
   enqueueReferenceTokens: (tokens) => {
     if (tokens.length === 0) return
     set((state) => ({
@@ -217,39 +344,59 @@ export const useOutlineChatStore = create<OutlineChatState>((set, get) => ({
   },
 
   loadFromDisk: async () => {
+    const generation = ++loadGeneration
     const path = getStoragePath()
-    if (!path) return
+    if (!path) {
+      set({
+        conversations: [], activeConversationId: null, runStates: {}, streamingContents: {},
+        pendingReferenceTokens: [], loaded: true,
+      })
+      return
+    }
     try {
       const content = await readFile(path)
-      const data = JSON.parse(content) as { conversations: OutlineChatConversation[]; activeConversationId: string | null }
+      if (generation !== loadGeneration || getStoragePath() !== path) return
+      const data = JSON.parse(content) as {
+        conversations: OutlineChatConversation[]
+        activeConversationId: string | null
+        runStates?: ConversationRunStates
+      }
+      const conversations = (data.conversations ?? []).map((conversation) => ({
+        ...conversation,
+        updatedAt: conversation.updatedAt ?? conversation.createdAt ?? Date.now(),
+      }))
+      const conversationIds = new Set(conversations.map((conversation) => conversation.id))
+      const runStates = Object.fromEntries(
+        Object.entries(normalizeLoadedRunStates(data.runStates)).filter(([id]) => conversationIds.has(id)),
+      )
+      const activeConversationId = data.activeConversationId && conversationIds.has(data.activeConversationId)
+        ? data.activeConversationId
+        : null
       set({
-        conversations: (data.conversations ?? []).map((conversation) => ({
-          ...conversation,
-          updatedAt: conversation.updatedAt ?? conversation.createdAt ?? Date.now(),
-        })),
-        activeConversationId: data.activeConversationId ?? null,
+        conversations,
+        activeConversationId,
+        runStates,
+        streamingContents: {},
+        pendingReferenceTokens: [],
         loaded: true,
       })
     } catch {
-      set({ loaded: true })
+      if (generation !== loadGeneration || getStoragePath() !== path) return
+      set({
+        conversations: [], activeConversationId: null, runStates: {}, streamingContents: {},
+        pendingReferenceTokens: [], loaded: true,
+      })
     }
   },
 
   saveToDisk: async () => {
     const path = getStoragePath()
     if (!path) return
-    const state = get()
-    // Don't save streaming state
-    const data = {
-      conversations: state.conversations,
-      activeConversationId: state.activeConversationId,
-    }
-    try {
-      const dir = path.replace(/[/\\][^/\\]+$/, "")
-      await createDirectory(dir)
-      await writeFile(path, JSON.stringify(data, null, 2))
-    } catch {
-      // Ignore save errors silently
+    const timer = saveTimers.get(path)
+    if (timer) {
+      clearTimeout(timer)
+      saveTimers.delete(path)
     }
+    await doSave(path)
   },
-}))
+}})

+ 2 - 2
src/stores/outline-generation-store.ts

@@ -1,7 +1,7 @@
 import { create } from "zustand"
 
-export type OutlineTaskStatus = "ingesting" | "done" | "error"
-export type OutlineTaskKind = "ingest"
+export type OutlineTaskStatus = "ingesting" | "done" | "error" | "generated"
+export type OutlineTaskKind = "ingest" | "generate" | "refine"
 
 export interface OutlineGenerationTask {
   id: string

+ 23 - 12
src/test/chat-panel-mount.ts

@@ -3,6 +3,8 @@ import { act } from "react"
 import { createRoot, type Root } from "react-dom/client"
 import { vi } from "vitest"
 import type { TaskBreakpoint } from "@/lib/agent/task-breakpoint"
+import type { Conversation, DisplayMessage } from "@/stores/chat-store"
+import type { ConversationRunStates } from "@/lib/conversation-run-state"
 
 const wikiState = {
   project: { path: "C:/QMAI_C/QMAI-main", name: "测试项目" },
@@ -147,32 +149,41 @@ import { useChatStore } from "@/stores/chat-store"
 
 export interface RenderChatPanelOptions {
   activeConversation?: boolean
+  activeConversationId?: string | null
+  conversations?: Conversation[]
+  messages?: DisplayMessage[]
+  runStates?: ConversationRunStates
   agentSkillConfig?: typeof defaultAgentSkillConfig | null
   taskBreakpoint?: TaskBreakpoint | null
 }
 
 export async function renderChatPanel(options: RenderChatPanelOptions = {}) {
   ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+  if (!HTMLElement.prototype.scrollTo) HTMLElement.prototype.scrollTo = vi.fn()
   mockAgentSkillConfig = options.agentSkillConfig === undefined
     ? defaultAgentSkillConfig
     : options.agentSkillConfig
   mockTaskBreakpoint = options.taskBreakpoint ?? null
 
-  const activeConversationId = options.activeConversation ? "conv_mount" : null
+  const activeConversationId = options.activeConversationId !== undefined
+    ? options.activeConversationId
+    : options.activeConversation ? "conv_mount" : null
+  const conversations = options.conversations ?? (activeConversationId
+    ? [{
+        id: activeConversationId,
+        title: "测试会话",
+        createdAt: Date.now(),
+        updatedAt: Date.now(),
+        deAiMode: false,
+        inputDraft: "",
+      }]
+    : [])
   useChatStore.setState({
-    conversations: activeConversationId
-      ? [{
-          id: activeConversationId,
-          title: "测试会话",
-          createdAt: Date.now(),
-          updatedAt: Date.now(),
-          deAiMode: false,
-          inputDraft: "",
-        }]
-      : [],
+    conversations,
     activeConversationId,
-    messages: [],
+    messages: options.messages ?? [],
     streamingContents: {},
+    runStates: options.runStates ?? {},
     pendingReferenceTokens: [],
   })
 

+ 7 - 1
tsconfig.app.json

@@ -25,5 +25,11 @@
     "baseUrl": ".",
     "paths": { "@/*": ["./src/*"] }
   },
-  "include": ["src"]
+  "include": ["src"],
+  "exclude": [
+    "src/**/*.spec.ts",
+    "src/**/*.spec.tsx",
+    "src/**/*.test.ts",
+    "src/**/*.test.tsx"
+  ]
 }

+ 8 - 0
vite.config.ts

@@ -101,5 +101,13 @@ export default defineConfig(async () => ({
     // The loader itself is a no-op if the file is absent, so this is
     // safe to keep on for every test run.
     setupFiles: ["./src/test-helpers/load-test-env.ts"],
+    exclude: [
+      "**/node_modules/**",
+      "**/dist/**",
+      "**/cypress/**",
+      "**/.{idea,git,cache,output,temp}/**",
+      "**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build}.config.*",
+      "**/.worktrees/**",
+    ],
   },
 }))

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio