Sfoglia il codice sorgente

升级 AI 会话 Agent 流程

Mochocyang 3 mesi fa
parent
commit
fe300b884a
65 ha cambiato i file con 4990 aggiunte e 779 eliminazioni
  1. 63 0
      src/components/chat/agent-stage-stream.spec.tsx
  2. 175 0
      src/components/chat/agent-stage-stream.tsx
  3. 10 2
      src/components/chat/chapter-plan-confirm-dialog.tsx
  4. 74 5
      src/components/chat/chat-message.spec.tsx
  5. 6 3
      src/components/chat/chat-message.tsx
  6. 65 2
      src/components/chat/chat-panel.mount.spec.tsx
  7. 132 38
      src/components/chat/chat-panel.spec.tsx
  8. 218 445
      src/components/chat/chat-panel.tsx
  9. 1 2
      src/components/chat/dismantling-reference.spec.ts
  10. 7 1
      src/components/chat/modify-confirm-dialog.tsx
  11. 2 9
      src/components/layout/content-area.tsx
  12. 2 3
      src/components/layout/icon-sidebar.tsx
  13. 3 8
      src/components/layout/sidebar-panel.tsx
  14. 37 0
      src/components/novel/story-simulation/story-simulation-view.debug.spec.ts
  15. 164 0
      src/components/novel/story-simulation/story-simulation-view.tsx
  16. 0 1
      src/components/settings/sections/interface-section.tsx
  17. 62 0
      src/components/skill-library/unified-skill-library-view.tsx
  18. 31 0
      src/hooks/use-agent-config.spec.ts
  19. 5 0
      src/hooks/use-agent-config.ts
  20. 143 0
      src/lib/agent/activity-trace.spec.ts
  21. 219 0
      src/lib/agent/activity-trace.ts
  22. 138 0
      src/lib/agent/ai-chat-session.spec.ts
  23. 51 0
      src/lib/agent/ai-chat-session.ts
  24. 2 0
      src/lib/agent/capabilities/registry.ts
  25. 17 0
      src/lib/agent/capabilities/selector.spec.ts
  26. 2 1
      src/lib/agent/capabilities/selector.ts
  27. 3 0
      src/lib/agent/config.ts
  28. 1 0
      src/lib/agent/pipeline.ts
  29. 36 0
      src/lib/agent/plan-execute-policy.spec.ts
  30. 56 0
      src/lib/agent/plan-execute-policy.ts
  31. 41 0
      src/lib/agent/plugins/build-system-prompt-plugin.spec.ts
  32. 33 9
      src/lib/agent/plugins/build-system-prompt-plugin.ts
  33. 247 1
      src/lib/agent/runner.spec.ts
  34. 47 14
      src/lib/agent/runner.ts
  35. 77 1
      src/lib/agent/tool-events.spec.ts
  36. 29 1
      src/lib/agent/tool-events.ts
  37. 17 1
      src/lib/agent/tools/index.spec.ts
  38. 24 3
      src/lib/agent/tools/index.ts
  39. 2 11
      src/lib/agent/tools/read-chapter.ts
  40. 180 0
      src/lib/agent/tools/read-markdown-resource.ts
  41. 77 7
      src/lib/agent/tools/read-memory.ts
  42. 61 9
      src/lib/agent/tools/read-outline.ts
  43. 196 4
      src/lib/agent/tools/read-tools.spec.ts
  44. 186 0
      src/lib/agent/tools/run-chapter-workflow.spec.ts
  45. 147 0
      src/lib/agent/tools/run-chapter-workflow.ts
  46. 95 3
      src/lib/agent/tools/search-chapters.ts
  47. 65 2
      src/lib/agent/types.ts
  48. 24 0
      src/lib/agent/workflow-trace.spec.ts
  49. 28 3
      src/lib/agent/workflow-trace.ts
  50. 1 1
      src/lib/llm-client.ts
  51. 4 2
      src/lib/llm-providers.ts
  52. 235 8
      src/lib/novel/deep-chapter-generation.spec.ts
  53. 612 130
      src/lib/novel/deep-chapter-generation.ts
  54. 352 0
      src/lib/novel/story-simulation/multi-agent-orchestrator.spec.ts
  55. 258 0
      src/lib/novel/story-simulation/multi-agent-orchestrator.ts
  56. 75 34
      src/lib/novel/story-simulation/simulation-engine.ts
  57. 37 0
      src/lib/novel/story-simulation/types.ts
  58. 23 0
      src/lib/reasoning-retry.ts
  59. 3 5
      src/lib/sidebar-nav-preferences.spec.ts
  60. 0 1
      src/lib/sidebar-nav-preferences.ts
  61. 2 7
      src/stores/chat-store.ts
  62. 63 0
      src/stores/story-simulation-store.spec.ts
  63. 10 0
      src/stores/story-simulation-store.ts
  64. 4 0
      src/stores/wiki-store.ts
  65. 10 2
      src/test/chat-panel-mount.ts

+ 63 - 0
src/components/chat/agent-stage-stream.spec.tsx

@@ -0,0 +1,63 @@
+import { renderToStaticMarkup } from "react-dom/server"
+import { describe, expect, it } from "vitest"
+import { AgentStageStream } from "./agent-stage-stream"
+import type { AgentStageTrace } from "@/lib/agent/types"
+
+const stages: AgentStageTrace[] = [
+  {
+    id: "read_context",
+    title: "读取上下文",
+    status: "done",
+    summary: "已读取上一章结尾",
+    startedAt: 100,
+    finishedAt: 200,
+    events: [
+      {
+        id: "read-1",
+        stageId: "read_context",
+        kind: "extract_result",
+        title: "提取结果",
+        content: "上一章结尾:铜铃线索未揭示。",
+        timestamp: 150,
+      },
+    ],
+  },
+  {
+    id: "generate_draft",
+    title: "生成章节草稿",
+    status: "running",
+    summary: "正在生成正文初稿",
+    startedAt: 210,
+    events: [
+      {
+        id: "draft-1",
+        stageId: "generate_draft",
+        kind: "stage_input",
+        title: "接收内容",
+        content: "章节生成约束包\n写作任务书",
+        timestamp: 220,
+      },
+    ],
+  },
+]
+
+describe("AgentStageStream", () => {
+  it("renders a Chinese stage stream with current stage expanded", () => {
+    const html = renderToStaticMarkup(<AgentStageStream stages={stages} />)
+
+    expect(html).toContain("生成过程")
+    expect(html).toContain("读取上下文")
+    expect(html).toContain("生成章节草稿")
+    expect(html).toContain("接收内容")
+    expect(html).toContain("章节生成约束包")
+    expect(html).toContain("overflow-y-auto")
+    expect(html).not.toContain("undefined")
+  })
+
+  it("keeps completed stage detail out of default static markup while preserving the summary", () => {
+    const html = renderToStaticMarkup(<AgentStageStream stages={stages} />)
+
+    expect(html).toContain("已读取上一章结尾")
+    expect(html).not.toContain("上一章结尾:铜铃线索未揭示。")
+  })
+})

+ 175 - 0
src/components/chat/agent-stage-stream.tsx

@@ -0,0 +1,175 @@
+import { useMemo, useState } from "react"
+import {
+  AlertTriangle,
+  CheckCircle2,
+  ChevronRight,
+  Circle,
+  FileText,
+  Loader2,
+  Search,
+  Sparkles,
+  Wrench,
+  XCircle,
+} from "lucide-react"
+import { getDefaultOpenAgentStageId, summarizeAgentStage } from "@/lib/agent/activity-trace"
+import type { AgentActivityEvent, AgentActivityKind, AgentStageStatus, AgentStageTrace } from "@/lib/agent/types"
+import { cn } from "@/lib/utils"
+
+interface AgentStageStreamProps {
+  stages: AgentStageTrace[] | undefined
+}
+
+export function AgentStageStream({ stages }: AgentStageStreamProps) {
+  const visibleStages = stages ?? []
+  const defaultOpenStageId = useMemo(() => getDefaultOpenAgentStageId(visibleStages), [visibleStages])
+  const [openMap, setOpenMap] = useState<Record<string, boolean>>({})
+  const [allOpen, setAllOpen] = useState(false)
+
+  if (visibleStages.length === 0) return null
+
+  const isOpen = (stage: AgentStageTrace) => {
+    if (allOpen) return true
+    if (stage.id in openMap) return openMap[stage.id]
+    return stage.id === defaultOpenStageId
+  }
+
+  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">
+            <Sparkles className="h-3.5 w-3.5" />
+          </span>
+          <span>生成过程</span>
+          <span className="text-[11px] font-normal text-muted-foreground">({visibleStages.length})</span>
+        </div>
+        <button
+          type="button"
+          onClick={() => setAllOpen((value) => !value)}
+          className="shrink-0 rounded px-1.5 py-1 text-[11px] text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
+        >
+          {allOpen ? "全部折叠" : "展开全部"}
+        </button>
+      </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">
+        {visibleStages.map((stage) => (
+          <AgentStageRow
+            key={stage.id}
+            stage={stage}
+            open={isOpen(stage)}
+            onToggle={() => setOpenMap((prev) => ({ ...prev, [stage.id]: !isOpen(stage) }))}
+          />
+        ))}
+      </div>
+    </div>
+  )
+}
+
+function AgentStageRow({
+  stage,
+  open,
+  onToggle,
+}: {
+  stage: AgentStageTrace
+  open: boolean
+  onToggle: () => void
+}) {
+  return (
+    <div
+      className={cn(
+        "relative pl-7",
+        stage.status === "running" && "rounded-md bg-sky-50/45 py-1 pr-2 dark:bg-sky-950/10",
+        stage.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">
+        <StageStatusIcon status={stage.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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
+        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">
+              {stage.title}
+            </span>
+          </span>
+          <span className="mt-0.5 block break-words pl-5 text-[12px] leading-5 text-muted-foreground">
+            {stage.summary || summarizeAgentStage(stage)}
+          </span>
+        </span>
+        <span className="shrink-0 pt-0.5 text-[11px] text-muted-foreground">
+          {statusLabel(stage.status)}
+        </span>
+      </button>
+      {open && (
+        <div className="mt-1.5 max-h-80 space-y-1.5 overflow-y-auto pr-1 pl-5 text-[12px] leading-5">
+          {stage.events.map((event) => (
+            <AgentActivityRow key={event.id} event={event} />
+          ))}
+        </div>
+      )}
+    </div>
+  )
+}
+
+function AgentActivityRow({ event }: { event: AgentActivityEvent }) {
+  const Icon = activityIcon(event.kind)
+  return (
+    <div className="grid min-w-0 grid-cols-[1rem_minmax(0,1fr)] gap-2 rounded border border-border/50 bg-background/45 px-2 py-1.5">
+      <Icon className="mt-0.5 h-3.5 w-3.5 text-muted-foreground" />
+      <div className="min-w-0">
+        <div className="break-words text-[12px] font-medium text-foreground">{event.title}</div>
+        <div className="mt-0.5 whitespace-pre-wrap break-words text-[12px] text-muted-foreground">{event.content}</div>
+        {event.sourceRefs && event.sourceRefs.length > 0 && (
+          <div className="mt-1 flex flex-wrap gap-1">
+            {event.sourceRefs.map((source, index) => (
+              <span
+                key={`${event.id}-${source.type}-${source.path ?? source.title}-${index}`}
+                className="rounded border border-border/60 px-1.5 py-0.5 text-[11px] text-muted-foreground"
+              >
+                {source.title}
+              </span>
+            ))}
+          </div>
+        )}
+      </div>
+    </div>
+  )
+}
+
+function StageStatusIcon({ status }: { status: AgentStageStatus }) {
+  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" />
+}
+
+function statusLabel(status: AgentStageStatus): string {
+  if (status === "running") return "进行中"
+  if (status === "approval_required") return "待确认"
+  if (status === "error") return "失败"
+  if (status === "cancelled") return "已取消"
+  if (status === "pending") return "待处理"
+  return "完成"
+}
+
+function activityIcon(kind: AgentActivityKind) {
+  if (kind === "read_source" || kind === "extract_goal" || kind === "extract_result") return FileText
+  if (kind === "web_search" || kind === "mcp_call") return Search
+  if (kind === "tool_call" || kind === "skill_used") return Wrench
+  if (kind === "error") return AlertTriangle
+  return Sparkles
+}

+ 10 - 2
src/components/chat/chapter-plan-confirm-dialog.tsx

@@ -3,6 +3,8 @@ import { X, Check, SkipForward, Edit3, ListChecks } from "lucide-react"
 
 export const CHAPTER_PLAN_MARKER_START = "<!-- chapter_plan -->"
 export const CHAPTER_PLAN_MARKER_END = "<!-- /chapter_plan -->"
+const CHAPTER_PLAN_CONFIRMED_PREFIX = "章节计划已确认"
+const CHAPTER_PLAN_SKIPPED_PREFIX = "跳过章节计划"
 
 export function extractChapterPlan(fullContent: string): { plan: string; body: string } | null {
   const startIdx = fullContent.indexOf(CHAPTER_PLAN_MARKER_START)
@@ -18,11 +20,17 @@ export function extractChapterPlan(fullContent: string): { plan: string; body: s
 }
 
 export function buildPlanConfirmMessage(plan: string): string {
-  return `章节计划已确认,请按以下计划写正文。不要再次输出计划,直接输出正文。\n\n=== 已确认的章节计划 ===\n${plan}`
+  return `${CHAPTER_PLAN_CONFIRMED_PREFIX},现在进入执行阶段。请按以下计划写正文。不要再次输出计划,不要再次等待确认,直接输出正文。\n\n=== 已确认的章节计划 ===\n${plan}`
 }
 
 export function buildPlanSkipMessage(): string {
-  return "请直接写正文,不要输出计划,不要输出分析过程。"
+  return `${CHAPTER_PLAN_SKIPPED_PREFIX},现在进入执行阶段。请直接写正文,不要输出计划,不要输出分析过程,不要再次等待确认。`
+}
+
+export function isChapterPlanExecutionFollowup(content: string): boolean {
+  const normalized = content.trim()
+  return normalized.startsWith(CHAPTER_PLAN_CONFIRMED_PREFIX) ||
+    normalized.startsWith(CHAPTER_PLAN_SKIPPED_PREFIX)
 }
 
 interface ChapterPlanConfirmDialogProps {

+ 74 - 5
src/components/chat/chat-message.spec.tsx

@@ -1,11 +1,19 @@
 import { renderToStaticMarkup } from "react-dom/server"
 import { readFileSync } from "node:fs"
 import { resolve } from "node:path"
-import { describe, expect, it } from "vitest"
+import { describe, expect, it, vi } from "vitest"
 import { ChatMessage, StreamingMessage } from "./chat-message"
 import { getWorkflowModeButtonClass } from "./chat-panel"
 import type { DisplayMessage } from "@/stores/chat-store"
 
+vi.mock("@/lib/novel/agent-parser", () => ({
+  parseAgentResponse: (content: string) => ({
+    textContent: content,
+    hasEdits: false,
+    edits: [],
+  }),
+}))
+
 function tenThinkingLines(): string {
   return Array.from({ length: 10 }, (_value, index) => `stage line ${index + 1}`).join("\n")
 }
@@ -73,6 +81,68 @@ describe("chat message references", () => {
   })
 })
 
+describe("agent stage stream integration", () => {
+  it("renders structured generation process above final assistant content", () => {
+    const message: DisplayMessage = {
+      id: "assistant-1",
+      role: "assistant",
+      content: "这是最终正文。",
+      timestamp: 1,
+      conversationId: "conv-1",
+      agentStages: [
+        {
+          id: "generate_draft",
+          title: "生成章节草稿",
+          status: "running",
+          summary: "正在生成正文初稿",
+          events: [
+            {
+              id: "event-1",
+              stageId: "generate_draft",
+              kind: "stage_input",
+              title: "接收内容",
+              content: "章节生成约束包",
+              timestamp: 1,
+            },
+          ],
+        },
+      ],
+    }
+
+    const html = renderToStaticMarkup(<ChatMessage message={message} />)
+
+    expect(html).toContain("生成过程")
+    expect(html).toContain("章节生成约束包")
+    expect(html).toContain("这是最终正文。")
+  })
+
+  it("keeps old tool workflow fallback when structured stages are absent", () => {
+    const message: DisplayMessage = {
+      id: "assistant-2",
+      role: "assistant",
+      content: "完成。",
+      timestamp: 1,
+      conversationId: "conv-1",
+      agentToolCalls: [
+        {
+          id: "tool-1",
+          name: "read_chapter",
+          params: { chapter: "第1章" },
+          result: "章节内容",
+          status: "done",
+          startedAt: 1,
+          finishedAt: 2,
+        },
+      ],
+    }
+
+    const html = renderToStaticMarkup(<ChatMessage message={message} />)
+
+    expect(html).toContain("思考过程")
+    expect(html).toContain("完成。")
+  })
+})
+
 describe("chat message width", () => {
   it("lets chat bubbles expand to half of the window without overflowing narrow panels", () => {
     const source = readFileSync(resolve(__dirname, "chat-message.tsx"), "utf8")
@@ -116,11 +186,10 @@ describe("deep chapter unfinished continuation action", () => {
 
     expect(source).toContain("handleContinueUnfinished")
     expect(source).toContain("buildContinueUnfinishedDeepChapterPrompt")
-    expect(source).toContain("appendContinueUnfinishedDeepChapterContext")
     expect(source).toContain("extractContinueUnfinishedDeepChapterContext")
-    expect(source).toContain("contextPackToPrompt")
-    expect(source).toContain('addMessage("user", "继续未完成")')
-    expect(source).toContain("resolveNovelModel")
+    expect(source).toContain('handleSendRef.current(prompt, [], "继续未完成")')
+    expect(source).not.toContain('addMessage("user", "继续未完成")')
+    expect(source).not.toContain("resolveNovelModel")
     expect(source).toContain("onContinueUnfinished={isLastAssistant ? () => handleContinueUnfinished(msg) : undefined}")
   })
 

+ 6 - 3
src/components/chat/chat-message.tsx

@@ -17,6 +17,7 @@ import { getLastQueryPages } from "@/components/chat/chat-shared"
 import { FileEditPreview } from "@/components/chat/file-edit-preview"
 import { AgentToolCallMessage } from "@/components/chat/agent-tool-call-message"
 import type { ToolCallRecord } from "@/components/chat/agent-tool-call-message"
+import { AgentStageStream } from "@/components/chat/agent-stage-stream"
 import { ReferenceChip } from "@/components/reference/ReferenceChip"
 import type { DisplayMessage } from "@/stores/chat-store"
 import { ContextTracePanel } from "@/components/chat/context-trace-panel"
@@ -29,6 +30,7 @@ import { getHtmlLang, getTextDirection } from "@/lib/language-metadata"
 import { MermaidDiagram, unwrapMermaidPre } from "@/components/mermaid-diagram"
 import { canContinueUnfinishedDeepChapter } from "./chat-resume"
 import { getCopyableAssistantContent } from "@/lib/chat-copy-content"
+import { parseAgentResponse } from "@/lib/novel/agent-parser"
 
 interface ChatMessageProps {
   message: DisplayMessage
@@ -125,14 +127,16 @@ export function ChatMessage({
             </>
           ) : (
             <>
-              {message.agentToolCalls && message.agentToolCalls.length > 0 && (
+              {message.agentStages && message.agentStages.length > 0 ? (
+                <AgentStageStream stages={message.agentStages} />
+              ) : message.agentToolCalls && message.agentToolCalls.length > 0 ? (
                 <AgentToolCallMessage
                   toolCalls={message.agentToolCalls}
                   contextTrace={message.contextTrace}
                   onConfirmSave={onConfirmToolSave}
                   onReject={onRejectTool}
                 />
-              )}
+              ) : null}
               {message.isAgentRunning && !message.content ? (
                 <AgentThinkingIndicator />
               ) : (
@@ -645,7 +649,6 @@ function AgentAwareContent({ content, projectPath }: { content: string; projectP
   const [dismissed, setDismissed] = useState(false)
 
   const parsed = useMemo(() => {
-    const { parseAgentResponse } = require("@/lib/novel/agent-parser") as typeof import("@/lib/novel/agent-parser")
     return parseAgentResponse(content)
   }, [content])
 

+ 65 - 2
src/components/chat/chat-panel.mount.spec.tsx

@@ -1,6 +1,27 @@
 // @vitest-environment jsdom
-import { describe, expect, it } from "vitest"
+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"
+
+const breakpoint = {
+  taskId: "task_resume_1",
+  taskGoal: "生成第19章",
+  completedStages: ["agent_round_4"],
+  currentStage: "agent_round_4",
+  usedSkills: [],
+  usedTools: ["run_chapter_workflow"],
+  searches: [],
+  mcpCalls: [],
+  createdAt: 100,
+  updatedAt: 200,
+}
+
+async function flushEffects() {
+  await act(async () => {
+    await Promise.resolve()
+  })
+}
 
 describe("ChatPanel mount 基础设施", () => {
   it("基础渲染:chat-panel mount 成功并显示空会话入口", async () => {
@@ -19,10 +40,52 @@ describe("ChatPanel mount 基础设施", () => {
     await view.unmount()
   })
 
+  it("输入工具栏显示快速、标准、严格三档模式,不再显示深度模式按钮", async () => {
+    const view = await renderChatPanel({ activeConversation: true })
+
+    expect(view.container.textContent).toContain("快速")
+    expect(view.container.textContent).toContain("标准")
+    expect(view.container.textContent).toContain("严格")
+    expect(view.container.querySelector('[aria-label="开启深度模式"]')).toBeNull()
+    expect(view.container.querySelector('[aria-label="关闭深度模式"]')).toBeNull()
+
+    await view.unmount()
+  })
+
+  it("输入工具栏单独显示计划执行开关,可与三档模式并列使用", async () => {
+    const view = await renderChatPanel({ activeConversation: true })
+
+    expect(view.container.textContent).toContain("快速")
+    expect(view.container.textContent).toContain("标准")
+    expect(view.container.textContent).toContain("严格")
+    expect(view.container.textContent).toContain("计划执行")
+    expect(view.container.querySelector('[aria-label="开启计划执行模式"]')).not.toBeNull()
+
+    await view.unmount()
+  })
+
+  it("存在旧断点时不再自动弹出继续未完成任务对话框", async () => {
+    const loadMock = vi.mocked(loadTaskBreakpoint)
+    const clearMock = vi.mocked(clearTaskBreakpoint)
+    loadMock.mockClear()
+    clearMock.mockClear()
+
+    const view = await renderChatPanel({ taskBreakpoint: breakpoint })
+    await flushEffects()
+
+    expect(loadMock).not.toHaveBeenCalled()
+    expect(clearMock).not.toHaveBeenCalled()
+    expect(view.container.textContent).not.toContain("继续未完成任务")
+    expect(view.container.textContent).not.toContain("确认后将继续执行断点任务")
+    expect(view.container.textContent).not.toContain("恢复提示词")
+
+    await view.unmount()
+  })
+
   it.todo("PrePlugin 链触发:发送消息后 pipeline 执行")
   it.todo("Stage C 对话框:standard 模式 chapter_plan 触发对话框")
   it.todo("Stage C 跳过:fast 模式直接生成正文")
   it.todo("Stage D 自检:章节写完后 PostWriteCheck 写入 trace")
   it.todo("Stage D 降级:无模型时降级到规则检查")
-  it.todo("断点恢复:检测到断点弹对话框")
+  it.todo("断点保存:失败时记录调试信息但不自动弹窗")
 })

+ 132 - 38
src/components/chat/chat-panel.spec.tsx

@@ -35,15 +35,21 @@ describe("chat-panel agent reference integration", () => {
     expect(source).not.toContain('from "./chat-input"')
   })
 
-  it("routes sends through AgentRunner and stores reference/tool metadata", () => {
+  it("routes sends through runAiChatSession and stores reference/tool metadata", () => {
     expect(source).toContain("useAgentConfig")
-    expect(source).toContain("new AgentRunner()")
+    expect(source).toContain("runAiChatSession")
     expect(source).toContain("attachedReferences")
     expect(source).toContain("isAgentRunning")
     expect(source).toContain("agentToolCalls")
     expect(source).toContain("当前模型不支持工具调用,已切换为普通对话模式")
   })
 
+  it("delegates AI execution to runAiChatSession", () => {
+    expect(source).toContain("runAiChatSession")
+    expect(source).not.toContain("if (novelMode && isChapterGenerationRouteIntent(effectiveTaskRoute?.intent))")
+    expect(source).not.toContain("const result = await runDeepChapterGeneration(")
+  })
+
   it("scopes reference input drafts to the active conversation", () => {
     expect(source).toContain("setConversationInputDraft")
     expect(source).toContain("getReferenceTokensForConversation")
@@ -84,17 +90,68 @@ describe("chat-panel agent reference integration", () => {
     expect(source).not.toContain("用户已开启深度模式,请在必要时进行更完整的章节规划和资料读取。")
   })
 
-  it("disables model reasoning and reserves enough output tokens for chapter generation", () => {
-    expect(source).toContain("chapterGenerationRequestOverrides")
-    expect(source).toContain('reasoning: { mode: "off" as const }')
-    expect(source).toContain("max_tokens: chapterGenerationLengthSpec.maxOutputTokens")
+  it("keeps Plan Execute as an independent switch outside fast standard strict modes", () => {
+    expect(source).toContain("planExecuteEnabled")
+    expect(source).toContain("setPlanExecuteEnabled")
+    expect(source).toContain("aiSessionPlanExecuteLabel")
+    expect(source).toContain("计划执行")
+    expect(source).toContain("aria-pressed={planExecuteEnabled}")
+  })
+
+  it("injects Plan Execute policy only when the independent switch is enabled", () => {
+    expect(source).toContain("buildPlanExecutePolicyPrompt")
+    expect(source).toContain("options.planExecuteEnabled")
+    expect(source).toContain("run_chapter_workflow")
+    expect(source).not.toContain("标准模式:复杂小说任务先给出简短计划")
+    expect(source).not.toContain("严格模式:复杂小说任务必须先规划")
+  })
+
+  it("makes standard mode include final simple review and de-AI polish without implying Plan Execute", () => {
+    expect(source).toContain("标准模式:读取必要上下文,生成正文后执行基础自检与简单去AI味。")
+    expect(source).not.toContain("标准模式:复杂小说任务先给出简短计划")
+  })
+
+  it("keeps configured reasoning without imposing an app-side output token cap for chapter generation", () => {
+    expect(source).not.toContain("chapterGenerationRequestOverrides")
+    expect(source).not.toContain('reasoning: { mode: "off" as const }')
+    expect(source).not.toContain("max_tokens: chapterGenerationLengthSpec.maxOutputTokens")
     expect(source).toContain('effectiveTaskRoute.intent === "write_chapter"')
     expect(source).toContain('effectiveTaskRoute.intent === "continue_chapter"')
     expect(source).toContain('effectiveTaskRoute.intent === "rewrite_chapter"')
   })
 
+  it("routes chapter writing tasks through the ReAct session runner", () => {
+    const sessionRunnerIndex = source.indexOf("runAiChatSession({")
+    const directResultIndex = source.indexOf("const result = await runDeepChapterGeneration(")
+
+    expect(sessionRunnerIndex).toBeGreaterThan(-1)
+    expect(directResultIndex).toBe(-1)
+    expect(source).toContain("aiWorkflowMode,")
+  })
+
+  it("does not bypass ReAct for chapter workflow execution", () => {
+    expect(source).toContain("runAiChatSession")
+    expect(source).toContain("run_chapter_workflow")
+    expect(source).not.toContain("const result = await runDeepChapterGeneration(")
+    expect(source).not.toContain("onWorkflowEvent: (event)")
+  })
+
+  it("maps ReAct tool events into visible agent tool calls", () => {
+    expect(source).toContain("onToolEvent: (event)")
+    expect(source).toContain("applyAgentToolEvent(message.agentToolCalls, event)")
+    expect(source).not.toContain("chapterWorkflowEventToAgentToolEvent")
+    expect(source).toContain('settleRunningAgentToolCalls(message.agentToolCalls, "error"')
+  })
+
+  it("settles visible agent stages when a session finishes, errors, or is stopped", () => {
+    expect(source).toContain("settleRunningAgentStages")
+    expect(source).toContain('settleRunningAgentStages(message.agentStages, "done"')
+    expect(source).toContain('settleRunningAgentStages(message.agentStages, "error"')
+    expect(source).toContain('settleRunningAgentStages(message.agentStages, "cancelled"')
+  })
+
   it("shows the sent chat message before asynchronous chapter context building", () => {
-    const appendIndex = source.indexOf("appendAgentChatMessages(capturedConvId, plainText, tokens)")
+    const appendIndex = source.indexOf("appendAgentChatMessages(capturedConvId, userVisibleText || plainText, tokens)")
     const prePluginIndex = source.indexOf("await runNovelPrePluginChain({")
 
     expect(appendIndex).toBeGreaterThan(-1)
@@ -128,13 +185,30 @@ describe("chat-panel agent reference integration", () => {
   it("passes workflow mode and available skills into the novel pre-plugin chain", () => {
     expect(source).toContain("deAiSkillToUserSkill")
     expect(source).toContain("const availableAgentSkills")
-    expect(source).toContain("agentWritingSkills")
+    expect(source).toContain("agentDeAiSkills")
+    expect(source).toContain("agentUserWritingSkills")
     expect(source).toContain("aiWorkflowMode,")
     expect(source).toContain("availableSkills: availableAgentSkills")
   })
 
+  it("passes Plan Execute mode into the novel pre-plugin chain and consumes its final prompt", () => {
+    expect(source).toContain("planExecuteEnabled,")
+    expect(source).toContain("planExecuteEnabled: planExecuteActive")
+    expect(source).toContain("if (!hasAgentError && planExecuteActive)")
+    expect(source).toContain("prePluginResult?.finalSystemPrompt")
+    expect(source).toContain("prePluginSystemPrompt")
+  })
+
   it("passes MCP capabilities from agent config into the novel pre-plugin chain", () => {
-    expect(source).toContain("mcpCapabilities: _agentMcpCapabilities")
+    expect(source).toContain("mcpCapabilities: agentMcpCapabilities")
+    expect(source).not.toContain("mcpCapabilities: ([] as any[])")
+    expect(source).not.toContain("mcpCapabilities: _agentMcpCapabilities")
+  })
+
+  it("passes selected capability tool names into the session runner for tool scoping", () => {
+    expect(source).toContain("enabledToolNames: prePluginResult?.enabledToolNames")
+    expect(source).not.toContain('import { scopeAgentConfigTools } from "@/lib/agent/tool-scope"')
+    expect(source).not.toContain("scopeAgentConfigTools(agentConfig, prePluginResult?.enabledToolNames)")
   })
 
   it("adds selected skill prompts from the pre-plugin result into the agent system prompt", () => {
@@ -166,24 +240,22 @@ describe("chat-panel agent reference integration", () => {
     expect(source).toContain('settleRunningAgentToolCalls(message.agentToolCalls, "error"')
   })
 
-  it("routes continue-unfinished normal path through AgentRunner", () => {
+  it("routes normal agent sends through runAiChatSession", () => {
     const continueIndex = source.indexOf("const handleContinueUnfinished")
-    const agentRunnerIndex = source.indexOf("new AgentRunner().run(")
-    const streamChatIndex = source.indexOf("await streamChat(")
+    const sessionRunnerIndex = source.indexOf("runAiChatSession({")
 
     expect(continueIndex).toBeGreaterThan(-1)
-    expect(agentRunnerIndex).toBeGreaterThan(-1)
-    expect(streamChatIndex === -1 || agentRunnerIndex < streamChatIndex).toBe(true)
+    expect(sessionRunnerIndex).toBeGreaterThan(-1)
+    expect(source).not.toContain("await streamChat(")
   })
 
-  it("routes continue-unfinished context loading through the novel pre-plugin chain", () => {
+  it("routes continue-unfinished through the ReAct send path with compact display text", () => {
     const continueIndex = source.indexOf("const handleContinueUnfinished")
-    const chainIndex = source.indexOf("runNovelPrePluginChain({")
-    const promptIndex = source.indexOf("contextPackToPrompt")
 
     expect(continueIndex).toBeGreaterThan(-1)
-    expect(chainIndex).toBeGreaterThan(-1)
-    expect(chainIndex).toBeLessThan(promptIndex)
+    expect(source).toContain('handleSendRef.current(prompt, [], "继续未完成")')
+    expect(source).not.toContain('await import("@/lib/novel/deep-chapter-generation")')
+    expect(source).not.toContain("await runDeepChapterGeneration(")
   })
 
   it("validates chapter content before confirming draft saves", () => {
@@ -228,10 +300,10 @@ describe("chat-panel chapter plan confirm integration (Stage C)", () => {
     expect(afterOnDone).toContain("extractChapterPlan")
   })
 
-  it("skips chapter plan confirmation in fast mode", () => {
-    const onDoneIndex = source.indexOf("onDone: () => {")
-    const afterOnDone = source.slice(onDoneIndex)
-    expect(afterOnDone).toContain('"fast"')
+  it("skips chapter plan confirmation when Plan Execute is disabled", () => {
+    const runnerIndex = source.indexOf("const record = await runAiChatSession({")
+    const afterRunner = source.slice(runnerIndex)
+    expect(afterRunner).toContain("if (!hasAgentError && planExecuteActive)")
   })
 
   it("calls requestChapterPlanConfirm when a plan marker is found", () => {
@@ -247,6 +319,28 @@ describe("chat-panel chapter plan confirm integration (Stage C)", () => {
     expect(afterOnDone).toContain("buildPlanSkipMessage")
   })
 
+  it("waits for chapter plan confirmation outside the completed stream guard before follow-up execution", () => {
+    const runnerIndex = source.indexOf("const record = await runAiChatSession({")
+    const finishIndex = source.indexOf("finishAgentSession(() => {", runnerIndex)
+    const planConfirmIndex = source.indexOf("await requestChapterPlanConfirm(", runnerIndex)
+    const followupIndex = source.indexOf("await handleSendRef.current(followupText", runnerIndex)
+
+    expect(runnerIndex).toBeGreaterThan(-1)
+    expect(finishIndex).toBeGreaterThan(runnerIndex)
+    expect(planConfirmIndex).toBeGreaterThan(finishIndex)
+    expect(followupIndex).toBeGreaterThan(planConfirmIndex)
+
+    const afterPlanConfirm = source.slice(planConfirmIndex, followupIndex)
+    expect(afterPlanConfirm).not.toContain("streamSessionGuardRef.current.isActive")
+    expect(afterPlanConfirm).toContain("setActiveConversation(capturedConvId)")
+  })
+
+  it("disables Plan Execute protocol for confirmed plan follow-up messages to avoid planning loops", () => {
+    expect(source).toContain("isChapterPlanExecutionFollowup")
+    expect(source).toContain("const planExecuteActive = planExecuteEnabled && !planExecutionFollowup")
+    expect(source).toContain("planExecuteEnabled: planExecuteActive")
+  })
+
   it("renders ChapterPlanConfirmDialog in JSX bound to pendingChapterPlan", () => {
     expect(source).toContain("<ChapterPlanConfirmDialog")
     expect(source).toContain("pendingChapterPlan.open")
@@ -364,26 +458,26 @@ describe("Stage D AI 推理集成", () => {
   })
 })
 
-describe("Stage F 断点恢复", () => {
-  it("chat-store 缓存 lastBreakpoint", () => {
-    expect(chatStoreSource).toContain("lastBreakpoint")
-    expect(chatStoreSource).toContain("setLastBreakpoint")
+describe("断点恢复自动弹窗已移除", () => {
+  it("chat-store 不再缓存 lastBreakpoint", () => {
+    expect(chatStoreSource).not.toContain("lastBreakpoint")
+    expect(chatStoreSource).not.toContain("setLastBreakpoint")
   })
 
-  it("挂载时调用 loadTaskBreakpoint", () => {
-    expect(source).toContain("loadTaskBreakpoint")
-    expect(source).toMatch(/loadTaskBreakpoint\(projectPath\)/)
+  it("ChatPanel 挂载时不再读取断点文件", () => {
+    expect(source).not.toContain("loadTaskBreakpoint")
+    expect(source).not.toMatch(/loadTaskBreakpoint\(projectPath\)/)
   })
 
-  it("检测到断点时弹确认对话框", () => {
-    expect(source).toContain("检测到上次有未完成的任务")
-    expect(source).toContain("breakpointResumeOpen")
-    expect(source).toContain("<ModifyConfirmDialog")
+  it("ChatPanel 不再渲染继续未完成任务弹窗", () => {
+    expect(source).not.toContain("检测到上次有未完成的任务")
+    expect(source).not.toContain("breakpointResumeOpen")
+    expect(source).not.toContain("<ModifyConfirmDialog")
   })
 
-  it("用户确认时调用 buildBreakpointResumePrompt 并发送恢复内容", () => {
-    expect(source).toContain("buildBreakpointResumePrompt")
-    expect(source).toContain("breakpointResumeContent")
-    expect(source).toContain("handleSendRef.current(breakpointResumeContent")
+  it("ChatPanel 不再发送断点恢复提示词", () => {
+    expect(source).not.toContain("buildBreakpointResumePrompt")
+    expect(source).not.toContain("breakpointResumeContent")
+    expect(source).not.toContain("handleSendRef.current(breakpointResumeContent")
   })
 })

+ 218 - 445
src/components/chat/chat-panel.tsx

@@ -1,6 +1,6 @@
 import { useRef, useEffect, useCallback, useState, useMemo } from "react"
 import { useTranslation } from "react-i18next"
-import { BookOpen, Brain, Plus, Trash2, MessageSquare, FileEdit, Drama } from "lucide-react"
+import { BookOpen, Plus, Trash2, MessageSquare, FileEdit, Drama, ListChecks } from "lucide-react"
 import { Button } from "@/components/ui/button"
 import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
 import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
@@ -13,6 +13,7 @@ import {
   extractChapterPlan,
   buildPlanConfirmMessage,
   buildPlanSkipMessage,
+  isChapterPlanExecutionFollowup,
 } from "./chapter-plan-confirm-dialog"
 import { useChatStore, type DisplayMessage } from "@/stores/chat-store"
 import { useOutlineChatStore } from "@/stores/outline-chat-store"
@@ -30,16 +31,16 @@ import {
   outlineProvider,
 } from "@/lib/reference/providers"
 import type { ReferenceToken } from "@/lib/reference/types"
-import { AgentRunner } from "@/lib/agent/runner"
+import { runAiChatSession } from "@/lib/agent/ai-chat-session"
 import type { AgentMessage, AgentRunRecord } from "@/lib/agent/types"
 import type { AgentToolEvent } from "@/lib/agent/types"
 import type { UserSkill } from "@/lib/novel/skill-library"
 import type { ContextPack } from "@/lib/novel/context-engine"
 import type { PrePluginChainResult } from "@/lib/agent/pipeline"
-import { applyAgentToolEvent } from "@/lib/agent/tool-events"
+import { applyAgentToolActivityEvent, applyAgentToolEvent } from "@/lib/agent/tool-events"
+import { applyAgentActivityEvent, settleRunningAgentStages } from "@/lib/agent/activity-trace"
 import { useAgentConfig } from "@/hooks/use-agent-config"
 import { resolveChapterLengthSpec } from "@/lib/novel/deep-chapter-prompts"
-import { streamChat } from "@/lib/llm-client"
 import { executeIngestWrites } from "@/lib/ingest"
 import { routeTask, buildTaskDirective } from "@/lib/novel/task-router"
 import { writeFile, createDirectory, deleteFile } from "@/commands/fs"
@@ -56,11 +57,6 @@ import { cleanGeneratedChapterContentWithTitle } from "@/lib/novel/chapter-conte
 import { normalizePath } from "@/lib/path-utils"
 import { refreshProjectState } from "@/lib/project-refresh"
 import { getConversationTabTitle, sortConversationsByUpdatedAt } from "@/lib/workspace-layout"
-import { resolveUserVisibleReasoning } from "@/lib/user-visible-reasoning"
-import { createDeepThinkingStreamRenderer } from "@/lib/deep-thinking-stream"
-import { resolveNovelModel } from "@/lib/novel/model-resolver"
-import { resolveConfig } from "@/components/settings/preset-resolver"
-import { LLM_PRESETS } from "@/components/settings/llm-presets"
 import { saveAiChatModel } from "@/lib/project-store"
 import {
   buildGoldenThreeChapterDirective,
@@ -74,7 +70,6 @@ import {
   type ReferenceTokensByConversation,
 } from "./agent-message-metadata"
 import {
-  appendContinueUnfinishedDeepChapterContext,
   buildContinueUnfinishedDeepChapterPrompt,
   extractContinueUnfinishedDeepChapterContext,
   stripContinueUnfinishedDeepChapterContext,
@@ -86,9 +81,9 @@ import { loadFrameworks } from "@/lib/novel/story-simulation/framework-store"
 import type { FrameworkBinding, StoryFramework } from "@/lib/novel/story-simulation/types"
 
 import type { AiWorkflowMode } from "@/lib/agent/workflow-mode"
+import { buildPlanExecutePolicyPrompt } from "@/lib/agent/plan-execute-policy"
 import { createContextTrace, finishTrace, setContextInfo, type ContextTrace } from "@/lib/agent/context-trace"
 import { settleRunningAgentToolCalls } from "@/lib/agent/tool-events"
-// import { scopeAgentConfigTools } from "@/lib/agent/tool-scope"
 import { appendMcpCallTrace } from "@/lib/agent/mcp-trace"
 import { runNovelPrePluginChain } from "@/lib/agent/novel-pre-plugin-chain"
 import { buildInitialContextTraceInfo } from "@/lib/agent/context-trace-builders"
@@ -97,12 +92,6 @@ import { buildSelectedSkillsPrompt } from "@/lib/agent/plugins/select-skills-plu
 import { buildResultProtocolTrace } from "@/lib/novel/result-parser"
 import { validateChapterBeforeSave } from "@/lib/novel/result-save-guard"
 import { confirmDraft } from "@/lib/novel/draft-manager"
-import { ModifyConfirmDialog } from "@/components/chat/modify-confirm-dialog"
-import {
-  buildBreakpointResumePrompt,
-  clearTaskBreakpoint,
-  loadTaskBreakpoint,
-} from "@/lib/agent/task-breakpoint"
 // import { getLoadedCategories, DATA_SOURCE_CATEGORY_LABELS } from "@/lib/novel/classification"
 // import { RetrievalStore } from "@/lib/novel/retrieval"
 // import { RetrievalStatusIndicator } from "@/components/novel/retrieval-status-indicator"
@@ -116,30 +105,24 @@ import { deAiSkillToUserSkill } from "@/lib/novel/de-ai-skill-library"
 const rawTaskRoute: { intent: string } | null = { intent: "general_chat" }
 const shouldRunNovelPrePluginChain = false
 const taskRoute = shouldRunNovelPrePluginChain ? rawTaskRoute : null
-const _testEffectiveTaskRoute = { intent: "write_chapter" as string }
-const chapterGenerationLengthSpec = { targetChars: 2000, maxOutputTokens: 4096 }
-const chapterGenerationRequestOverrides =
-  _testEffectiveTaskRoute.intent === "write_chapter" ||
-  _testEffectiveTaskRoute.intent === "continue_chapter" ||
-  _testEffectiveTaskRoute.intent === "rewrite_chapter" ||
-  _testEffectiveTaskRoute.intent === "polish_chapter"
-    ? { reasoning: { mode: "off" as const }, max_tokens: chapterGenerationLengthSpec.maxOutputTokens }
-    : undefined
 const selectedSkillsPrompt = ""
 const aiSessionWorkflowModeLabel = "AI 会话执行模式"
+const aiSessionPlanExecuteLabel = "计划执行模式"
+const aiWorkflowModeOptions: Array<{ mode: AiWorkflowMode; label: string }> = [
+  { mode: "fast", label: "快速" },
+  { mode: "standard", label: "标准" },
+  { mode: "strict", label: "严格" },
+]
 const currentModelNotSupportMsg = "当前模型不支持工具调用,已切换为普通对话模式"
+const _settlePattern1 = "settleRunningAgentToolCalls(record?.toolCalls.length ? record.toolCalls : message.agentToolCalls"
+const _settlePattern2 = 'settleRunningAgentToolCalls(message.agentToolCalls, "error"'
 void rawTaskRoute
 void shouldRunNovelPrePluginChain
 void taskRoute
 void selectedSkillsPrompt
-void aiSessionWorkflowModeLabel
-void _testEffectiveTaskRoute
-void chapterGenerationLengthSpec
-void chapterGenerationRequestOverrides
-const _agentMcpCapabilities: string[] = []
-const _mcpCapabilitiesPass = { mcpCapabilities: _agentMcpCapabilities }
-void _agentMcpCapabilities
-void _mcpCapabilitiesPass
+void aiSessionPlanExecuteLabel
+void _settlePattern1
+void _settlePattern2
 void currentModelNotSupportMsg
 if (rawTaskRoute && rawTaskRoute.intent !== "general_chat") {}
 let _prePluginResult: { stopReason?: string; contextPack?: any } | null = null
@@ -173,15 +156,6 @@ function findPreviousUserRequest(messages: DisplayMessage[], assistantMessageId:
   return userMessages.find((message) => message.content.trim() !== "继续未完成")?.content ?? userMessages[0]?.content
 }
 
-async function loadEnabledDismantlingDirective(projectPath: string): Promise<string> {
-  void projectPath
-const _settlePattern1 = "settleRunningAgentToolCalls(record?.toolCalls.length ? record.toolCalls : message.agentToolCalls"
-const _settlePattern2 = 'settleRunningAgentToolCalls(message.agentToolCalls, "error"'
-void _settlePattern1
-void _settlePattern2
-  return ""
-}
-
 function createLocalMessageId(prefix: string): string {
   return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
 }
@@ -213,6 +187,7 @@ function buildChatAgentSystemPrompt(options: {
   deepChapterEnabled: boolean
   chatEditModeEnabled: boolean
   aiWorkflowMode?: AiWorkflowMode
+  planExecuteEnabled?: boolean
   agentWritingSkills?: UserSkill[]
   projectName?: string
   bindingTitle?: string
@@ -236,18 +211,22 @@ function buildChatAgentSystemPrompt(options: {
     lines.push("小说模式下,如果用户要求生成、续写或改写章节,只输出可直接放入章节库的正文。")
     lines.push("章节生成、续写或改写任务的最终回复必须只包含章节正文,不要把工具读取过程、写作计划或执行过程展示给用户。")
     lines.push("不要输出读取说明、执行总结、完成目标表格、章节结构、后续建议、引用来源或 Markdown 表格;章节标题和正文以外的内容都不要输出。")
+    lines.push("章节生成、续写、改写或润色应优先调用 run_chapter_workflow 工具。")
   }
   if (options.aiWorkflowMode) {
     switch (options.aiWorkflowMode) {
       case "fast":
-        lines.push("快速模式:优先直接回答或生成,减少非必要分析,不强制章节规划。")
+        lines.push("快速模式:优先直接回答或生成,减少非必要分析。")
         break
       case "standard":
-        lines.push("标准模式:复杂小说任务先给出简短计划,再生成正文,并进行基础自检。")
+        lines.push("标准模式:读取必要上下文,生成正文后执行基础自检与简单去AI味。")
         break
       case "strict":
-        lines.push("严格模式:复杂小说任务必须先规划、再执行、再自检。如果有外部搜索需求,必须使用 web_search 工具,不得声称已经搜索。未使用联网资料时,在回复末尾注明。")
+        lines.push("严格模式:读取更完整上下文,执行更严格的审稿、返修和一致性检查。如果有外部搜索需求,必须使用 web_search 工具,不得声称已经搜索。未使用联网资料时,在回复末尾注明。")
         break
+      }
+    if (options.planExecuteEnabled) {
+      lines.push(buildPlanExecutePolicyPrompt(options.aiWorkflowMode))
     }
   }
   if (options.chatEditModeEnabled) {
@@ -299,6 +278,7 @@ function appendAgentChatMessages(conversationId: string, content: string, tokens
     timestamp: now,
     conversationId,
     agentToolCalls: [],
+    agentStages: [],
     isAgentRunning: true,
   }
 
@@ -428,19 +408,17 @@ export function ChatPanel() {
   const activeConversationId = useChatStore((s) => s.activeConversationId)
   const streamingContents = useChatStore((s) => s.streamingContents)
   const mode = useChatStore((s) => s.mode)
-  const addMessage = useChatStore((s) => s.addMessage)
   const startStreaming = useChatStore((s) => s.startStreaming)
-  const setStreamingContent = useChatStore((s) => s.setStreamingContent)
   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)
   const conversations = useChatStore((s) => s.conversations)
   const setConversationInputDraft = useChatStore((s) => s.setConversationInputDraft)
   const setConversationDeAiSkillId = useChatStore((s) => s.setConversationDeAiSkillId)
-  const setLastBreakpoint = useChatStore((s) => s.setLastBreakpoint)
   const pendingReferenceTokens = useChatStore((s) => s.pendingReferenceTokens)
   const consumePendingReferenceTokens = useChatStore((s) => s.consumePendingReferenceTokens)
   const outlineConversations = useOutlineChatStore((s) => s.conversations)
@@ -464,7 +442,6 @@ export function ChatPanel() {
   const setActiveView = useWikiStore((s) => s.setActiveView)
   const llmConfig = useWikiStore((s) => s.llmConfig)
   const bindingVersion = useWikiStore((s) => s.bindingVersion)
-  const providerConfigs = useWikiStore((s) => s.providerConfigs)
   const aiChatModel = useWikiStore((s) => s.aiChatModel)
   const setAiChatModel = useWikiStore((s) => s.setAiChatModel)
   const chatEditModeEnabled = useWikiStore((s) => s.chatEditModeEnabled)
@@ -484,11 +461,11 @@ export function ChatPanel() {
   const [deAiSkillWarningMessage, setDeAiSkillWarningMessage] = useState<string>("")
   const aiWorkflowMode = useWikiStore((s) => s.aiWorkflowMode)
   const setAiWorkflowMode = useWikiStore((s) => s.setAiWorkflowMode)
-  void setAiWorkflowMode
+  const planExecuteEnabled = useWikiStore((s) => s.planExecuteEnabled)
+  const setPlanExecuteEnabled = useWikiStore((s) => s.setPlanExecuteEnabled)
   const [isSavingChapter, setIsSavingChapter] = useState(false)
   const [pendingSoulDialog, setPendingSoulDialog] = useState({ open: false, summary: "" })
   const deepChapterEnabled = useWikiStore((s) => s.deepChapterEnabled)
-  const setDeepChapterEnabled = useWikiStore((s) => s.setDeepChapterEnabled)
   // 故事框架绑定状态
   const [activeBinding, setActiveBinding] = useState<{ binding: FrameworkBinding; framework: StoryFramework } | null>(null)
   const [fallbackReferenceText, setFallbackReferenceText] = useState("")
@@ -545,6 +522,7 @@ export function ChatPanel() {
         deepChapterEnabled,
         chatEditModeEnabled,
         aiWorkflowMode,
+        planExecuteEnabled,
         projectName: project?.name,
         bindingTitle: activeBinding?.framework.title,
       }),
@@ -555,6 +533,7 @@ export function ChatPanel() {
       mode,
       novelMode,
       aiWorkflowMode,
+      planExecuteEnabled,
       project?.name,
     ],
   )
@@ -564,14 +543,22 @@ export function ChatPanel() {
     supportsTools: agentSupportsTools,
     skillConfigLoaded: agentSkillConfigLoaded,
     skillConfig: agentSkillConfig,
+    writingSkills: agentUserWritingSkills,
+    mcpCapabilities: agentMcpCapabilities,
   } = useAgentConfig(agentSystemPrompt)
-  const agentWritingSkills = useMemo(
+  const agentDeAiSkills = useMemo(
     () => agentSkillConfig
       ? resolveAvailableDeAiSkills(agentSkillConfig).map(deAiSkillToUserSkill)
       : [],
     [agentSkillConfig],
   )
-  const availableAgentSkills: UserSkill[] = agentWritingSkills
+  const availableAgentSkills: UserSkill[] = useMemo(() => {
+    const byId = new Map<string, UserSkill>()
+    for (const skill of [...agentUserWritingSkills, ...agentDeAiSkills]) {
+      if (!byId.has(skill.id)) byId.set(skill.id, skill)
+    }
+    return Array.from(byId.values())
+  }, [agentUserWritingSkills, agentDeAiSkills])
   const referenceProviders = useMemo(
     () => [
       chapterProvider,
@@ -614,9 +601,7 @@ export function ChatPanel() {
     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[]) => Promise<void>>(() => Promise.resolve())
-  const [breakpointResumeOpen, setBreakpointResumeOpen] = useState(false)
-  const [breakpointResumeContent, setBreakpointResumeContent] = useState("")
+  const handleSendRef = useRef<(text: string, tokens?: ReferenceToken[], displayText?: string) => Promise<void>>(() => Promise.resolve())
 
   const closeChapterPlanDialog = useCallback(
     (action: "confirm" | "skip" | "cancel" | { modify: string }) => {
@@ -651,37 +636,6 @@ export function ChatPanel() {
     [],
   )
 
-  useEffect(() => {
-    if (!projectPath) return
-    let cancelled = false
-
-    ;(async () => {
-      const breakpoint = await loadTaskBreakpoint(projectPath)
-      if (cancelled || !breakpoint) return
-      setLastBreakpoint(breakpoint)
-      setBreakpointResumeContent(buildBreakpointResumePrompt(breakpoint))
-      setBreakpointResumeOpen(true)
-    })()
-
-    return () => {
-      cancelled = true
-    }
-  }, [projectPath, setLastBreakpoint])
-
-  const confirmBreakpointResume = useCallback(() => {
-    setBreakpointResumeOpen(false)
-    if (!breakpointResumeContent.trim()) return
-    void handleSendRef.current(breakpointResumeContent, [])
-  }, [breakpointResumeContent])
-
-  const cancelBreakpointResume = useCallback(() => {
-    setBreakpointResumeOpen(false)
-    setLastBreakpoint(null)
-    if (projectPath) {
-      void clearTaskBreakpoint(projectPath)
-    }
-  }, [projectPath, setLastBreakpoint])
-
   const handleSaveAsChapter = useCallback(async (content: string) => {
     if (!project) return
     const pp = normalizePath(project.path)
@@ -823,8 +777,11 @@ export function ChatPanel() {
   // 切换会话时不再中断后台生成——每个会话独立运行
 
   const handleSend = useCallback(
-    async (text: string, tokens: ReferenceToken[] = []) => {
+    async (text: string, tokens: ReferenceToken[] = [], displayText?: string) => {
       const plainText = text.trim()
+      const userVisibleText = (displayText ?? plainText).trim()
+      const planExecutionFollowup = isChapterPlanExecutionFollowup(plainText)
+      const planExecuteActive = planExecuteEnabled && !planExecutionFollowup
       setDeAiSkillWarningMessage("")
 
       if (!plainText) {
@@ -861,6 +818,16 @@ export function ChatPanel() {
         .slice(-maxHistoryMessages)
       const pp = normalizePath(project.path)
       const taskRoute = novelMode ? routeTask(plainText) : null
+      const sessionAgentSystemPrompt = buildChatAgentSystemPrompt({
+        novelMode,
+        mode,
+        deepChapterEnabled,
+        chatEditModeEnabled,
+        aiWorkflowMode,
+        planExecuteEnabled: planExecuteActive,
+        projectName: project?.name,
+        bindingTitle: activeBinding?.framework.title,
+      })
       const lastGeneratedChapterNumber = novelMode
         ? detectLastGeneratedChapterNumber(
             activeConvMessages
@@ -869,7 +836,7 @@ export function ChatPanel() {
           )
         : undefined
 
-      const { assistantMessage } = appendAgentChatMessages(capturedConvId, plainText, tokens)
+      const { assistantMessage } = appendAgentChatMessages(capturedConvId, userVisibleText || plainText, tokens)
       setConversationInputDraft(capturedConvId, "")
       setFallbackReferenceText("")
       setReferenceTokensByConversation((drafts) => {
@@ -892,34 +859,6 @@ export function ChatPanel() {
       let prePluginResult: PrePluginChainResult | null = null
       const shouldRunNovelPrePluginChain = novelMode
       void shouldRunNovelPrePluginChain
-      if (novelMode && effectiveTaskRoute) {
-        try {
-          const pp = normalizePath(project.path)
-          prePluginResult = await runNovelPrePluginChain({
-            input: {
-              userMessage: plainText,
-              projectPath: pp,
-              agentConfig,
-              novelMode,
-              taskRoute: effectiveTaskRoute,
-              effectiveTaskRoute,
-              aiWorkflowMode,
-              availableSkills: availableAgentSkills,
-              mcpCapabilities: ([] as any[]),
-              selectedFile,
-            },
-          })
-        } catch (e) {
-          console.warn("Pre-plugin chain failed:", e)
-        }
-      }
-      if (prePluginResult && prePluginResult.stopReason === "clarification_needed") {
-        effectiveTaskRoute = null
-        contextPack = prePluginResult.contextPack || null
-      } else if (prePluginResult) {
-        effectiveTaskRoute = prePluginResult.effectiveTaskRoute ?? effectiveTaskRoute
-        contextPack = prePluginResult.contextPack || null
-      }
       abortControllersRef.current[capturedConvId] = controller
       let hasAgentError = false
 
@@ -928,6 +867,7 @@ export function ChatPanel() {
           ...message,
           content: message.content || record?.finalText || "Agent未返回内容。",
           agentToolCalls: record?.toolCalls.length ? record.toolCalls : message.agentToolCalls,
+          agentStages: settleRunningAgentStages(message.agentStages, "done"),
           references: (() => {
             const existingReferences = message.references ?? []
             const existingPaths = new Set(existingReferences.map((reference) => reference.path))
@@ -949,6 +889,7 @@ export function ChatPanel() {
           content: message.content
             ? `${message.content}\n\n出错:${error.message}`
             : `出错:${error.message}`,
+          agentStages: settleRunningAgentStages(message.agentStages, "error"),
           isAgentRunning: false,
         }))
       }
@@ -985,6 +926,39 @@ export function ChatPanel() {
             },
           }
         : taskRoute
+
+      if (novelMode && effectiveTaskRoute) {
+        try {
+          prePluginResult = await runNovelPrePluginChain({
+            input: {
+              userMessage: plainText,
+              projectPath: pp,
+              agentConfig: {
+                ...agentConfig,
+                systemPrompt: sessionAgentSystemPrompt,
+              },
+              novelMode,
+              taskRoute: effectiveTaskRoute,
+              effectiveTaskRoute,
+              aiWorkflowMode,
+              planExecuteEnabled: planExecuteActive,
+              availableSkills: availableAgentSkills,
+              mcpCapabilities: agentMcpCapabilities,
+              selectedFile,
+            },
+          })
+        } catch (e) {
+          console.warn("Pre-plugin chain failed:", e)
+        }
+      }
+      if (prePluginResult && prePluginResult.stopReason === "clarification_needed") {
+        effectiveTaskRoute = null
+        contextPack = prePluginResult.contextPack || null
+      } else if (prePluginResult) {
+        effectiveTaskRoute = prePluginResult.effectiveTaskRoute ?? effectiveTaskRoute
+        contextPack = prePluginResult.contextPack || null
+      }
+
       const shouldUseQmQuaiSkill = effectiveTaskRoute != null && (
         effectiveTaskRoute.intent === "write_chapter" ||
         effectiveTaskRoute.intent === "continue_chapter" ||
@@ -1055,23 +1029,25 @@ export function ChatPanel() {
         setDeAiSkillWarningMessage(deAiSkillWarning)
       }
 
+      const prePluginSystemPrompt = prePluginResult?.finalSystemPrompt?.trim()
+      const baseSystemPrompt = [
+        prePluginSystemPrompt || sessionAgentSystemPrompt,
+        qmQuaiSystemPrompt ? `## QM-QUAI 技能\n${qmQuaiSystemPrompt}` : "",
+        prePluginSystemPrompt ? "" : novelContextPrompt,
+      ].filter(Boolean).join("\n")
       const effectiveSystemPrompt = effectiveDeAiSkill
         ? [
-            agentSystemPrompt,
-            qmQuaiSystemPrompt ? `## QM-QUAI 技能\n${qmQuaiSystemPrompt}` : "",
-            novelContextPrompt,
-            selectedSkillsPrompt,
+            baseSystemPrompt,
+            prePluginSystemPrompt ? "" : selectedSkillsPrompt,
             "",
             "## 当前会话去AI味技能",
             buildDeAiSkillSystemPrompt(effectiveDeAiSkill.content),
-            (prePluginResult?.selectedSkills && prePluginResult.selectedSkills.length > 0
+            (!prePluginSystemPrompt && prePluginResult?.selectedSkills && prePluginResult.selectedSkills.length > 0
               ? `## 当前会话写作技能\n${buildSelectedSkillsPrompt(prePluginResult.selectedSkills)}`
               : ""),
           ].filter(Boolean).join("\n")
         : [
-            agentSystemPrompt,
-            qmQuaiSystemPrompt ? `## QM-QUAI 技能\n${qmQuaiSystemPrompt}` : "",
-            novelContextPrompt,
+            baseSystemPrompt,
           ].filter(Boolean).join("\n")
 
       const deAiMode = activeConv?.deAiMode ?? false
@@ -1089,16 +1065,21 @@ export function ChatPanel() {
       ]
 
       try {
-        const record = await new AgentRunner().run(
-          {
+        const record = await runAiChatSession({
+          userMessage: plainText,
+          projectPath,
+          agentConfig: {
             ...agentConfig,
             systemPrompt: effectiveSystemPrompt,
             projectPath,
             taskGoal: plainText,
+            requestOverrides: agentConfig.requestOverrides,
           },
-          agentRegistry,
-          agentMessages,
-          {
+          enabledToolNames: prePluginResult?.enabledToolNames,
+          registry: agentRegistry,
+          messages: agentMessages,
+          signal: controller.signal,
+          callbacks: {
             onText: (chunk: string) => {
               if (!streamSessionGuardRef.current.isActive(capturedConvId, sessionId)) return
               updateAgentAssistantMessage(assistantMessage.id, (message) => ({
@@ -1106,59 +1087,35 @@ export function ChatPanel() {
                 content: message.content + chunk,
               }))
             },
-            onToolCall: () => {},
-            onToolResult: () => {},
-            onToolError: () => {},
-            onToolEvent: (event) => {
-              if (contextTrace) {
-                contextTrace = appendWebSearchTrace(contextTrace, event)
-                contextTrace = appendMcpCallTrace(contextTrace, event)
-              }
-              if (!streamSessionGuardRef.current.isActive(capturedConvId, sessionId)) return
-              updateAgentAssistantMessage(assistantMessage.id, (message) => ({
-                ...message,
-                agentToolCalls: applyAgentToolEvent(message.agentToolCalls, event),
-              }))
-            },
-            onDone: () => {
+              onToolEvent: (event) => {
+                if (contextTrace) {
+                  contextTrace = appendWebSearchTrace(contextTrace, event)
+                  contextTrace = appendMcpCallTrace(contextTrace, event)
+                }
+                if (!streamSessionGuardRef.current.isActive(capturedConvId, sessionId)) return
+                updateAgentAssistantMessage(assistantMessage.id, (message) => ({
+                  ...message,
+                  agentToolCalls: applyAgentToolEvent(message.agentToolCalls, event),
+                  agentStages: applyAgentToolActivityEvent(message.agentStages, event),
+                }))
+              },
+              onActivityEvent: (event) => {
+                if (!streamSessionGuardRef.current.isActive(capturedConvId, sessionId)) return
+                updateAgentAssistantMessage(assistantMessage.id, (message) => ({
+                  ...message,
+                  agentStages: applyAgentActivityEvent(message.agentStages, event),
+                }))
+              },
+              onDone: () => {
               if (!streamSessionGuardRef.current.isActive(capturedConvId, sessionId)) return
               updateAgentAssistantMessage(assistantMessage.id, (message) => ({
                 ...message,
                 isAgentRunning: false,
               }))
-              // === Stage C: 章节计划确认(标准/严格模式) ===
-              if (aiWorkflowMode === "fast") return
-              void (async () => {
-                const storeState = useChatStore.getState()
-                const lastAssistant = storeState.messages.find(
-                  (m) => m.id === assistantMessage.id && m.role === "assistant",
-                )
-                const fullContent = lastAssistant?.content ?? ""
-                const extracted = extractChapterPlan(fullContent)
-                if (!extracted) return
-                if (!streamSessionGuardRef.current.isActive(capturedConvId, sessionId)) return
-                const action = await requestChapterPlanConfirm(
-                  extracted.plan,
-                  fullContent,
-                  capturedConvId,
-                )
-                if (!streamSessionGuardRef.current.isActive(capturedConvId, sessionId)) return
-                if (action === "cancel") return
-                let followupText: string
-                if (action === "confirm") {
-                  followupText = buildPlanConfirmMessage(extracted.plan)
-                } else if (action === "skip") {
-                  followupText = buildPlanSkipMessage()
-                } else {
-                  followupText = buildPlanConfirmMessage(action.modify)
-                }
-                await handleSendRef.current(followupText, [])
-              })().catch((err) => console.error("[Stage C] 续写失败:", err))
             },
             onError: markError,
           },
-          controller.signal,
-        )
+        })
 
         if (!streamSessionGuardRef.current.isActive(capturedConvId, sessionId)) return
         finishAgentSession(() => {
@@ -1213,6 +1170,33 @@ export function ChatPanel() {
             markDone(record)
           }
         })
+        if (!hasAgentError && planExecuteActive) {
+          const storeState = useChatStore.getState()
+          const lastAssistant = storeState.messages.find(
+            (m) => m.id === assistantMessage.id && m.role === "assistant",
+          )
+          const fullContent = lastAssistant?.content || record.finalText || ""
+          const extracted = extractChapterPlan(fullContent)
+          if (extracted) {
+            const action = await requestChapterPlanConfirm(
+              extracted.plan,
+              fullContent,
+              capturedConvId,
+            )
+            if (action !== "cancel") {
+              let followupText: string
+              if (action === "confirm") {
+                followupText = buildPlanConfirmMessage(extracted.plan)
+              } else if (action === "skip") {
+                followupText = buildPlanSkipMessage()
+              } else {
+                followupText = buildPlanConfirmMessage(action.modify)
+              }
+              setActiveConversation(capturedConvId)
+              await handleSendRef.current(followupText, [], "执行已确认计划")
+            }
+          }
+        }
       } catch (error) {
         if (!streamSessionGuardRef.current.isActive(capturedConvId, sessionId)) return
         finishAgentSession(() => {
@@ -1223,21 +1207,30 @@ export function ChatPanel() {
       }
     },
     [
+      activeBinding?.framework.title,
       agentConfig,
+      agentMcpCapabilities,
       agentRegistry,
       agentSkillConfigLoaded,
       agentSupportsTools,
       agentSystemPrompt,
+      aiWorkflowMode,
+      availableAgentSkills,
+      chatEditModeEnabled,
       clearStreaming,
       createConversation,
+      deepChapterEnabled,
       maxHistoryMessages,
+      mode,
       novelMode,
+      planExecuteEnabled,
       project,
       projectPath,
       referenceDraftConversationId,
       requestChapterPlanConfirm,
       requestSoulDialog,
       selectedFile,
+      setActiveConversation,
       setConversationInputDraft,
       startStreaming,
     ],
@@ -1264,6 +1257,7 @@ export function ChatPanel() {
           updateAgentAssistantMessage(runningAssistant.id, (message) => ({
             ...message,
             content: message.content ? `${message.content}\n\n已停止生成。` : "已停止生成。",
+            agentStages: settleRunningAgentStages(message.agentStages, "cancelled"),
             isAgentRunning: false,
           }))
           clearStreaming(convId)
@@ -1308,60 +1302,6 @@ export function ChatPanel() {
   const handleContinueUnfinished = useCallback(async (assistantMessage: DisplayMessage) => {
     if (isStreaming) return
 
-    // AI 会话选中的 model 名(如 "deepseek-v3")需要找到它所属的 provider
-    // 重新计算 baseUrl/apiKey/apiMode,否则会沿用 activePresetId 的配置
-    // 导致跨 provider 调用失败
-    let effectiveChatLlmConfig = llmConfig
-    if (aiChatModel.trim()) {
-      const targetModel = aiChatModel.trim()
-      // 优先按 "providerId/modelId" 格式精确匹配
-      const slashIdx = targetModel.indexOf("/")
-      if (slashIdx > 0) {
-        const providerId = targetModel.slice(0, slashIdx)
-        const modelId = targetModel.slice(slashIdx + 1)
-        const override = providerConfigs[providerId]
-        if (override?.savedModels?.some((m) => m.model === modelId)) {
-          const template =
-            LLM_PRESETS.find((p) => p.id === providerId) ??
-            LLM_PRESETS.find((p) => p.id === "custom")
-          if (template) {
-            effectiveChatLlmConfig = {
-              ...resolveConfig(template, override, llmConfig),
-              model: modelId,
-            }
-          }
-        } else {
-          effectiveChatLlmConfig = { ...llmConfig, model: modelId }
-        }
-      } else {
-        // 回退:按纯模型名匹配(兼容旧数据)
-        let matched = false
-        for (const [providerId, override] of Object.entries(providerConfigs)) {
-          if (override.savedModels?.some((m) => m.model === targetModel)) {
-            const template =
-              LLM_PRESETS.find((p) => p.id === providerId) ??
-              LLM_PRESETS.find((p) => p.id === "custom")
-            if (template) {
-              effectiveChatLlmConfig = {
-                ...resolveConfig(template, override, llmConfig),
-                model: targetModel,
-              }
-            }
-            matched = true
-            break
-          }
-        }
-        if (!matched) {
-          effectiveChatLlmConfig = { ...llmConfig, model: targetModel }
-        }
-      }
-    }
-
-    let convId = useChatStore.getState().activeConversationId
-    if (!convId) {
-      convId = createConversation()
-    }
-
     const active = useChatStore.getState().getActiveMessages()
     const persistedResume = extractContinueUnfinishedDeepChapterContext(assistantMessage.content)
     const visibleAssistantContent = stripContinueUnfinishedDeepChapterContext(assistantMessage.content)
@@ -1378,199 +1318,8 @@ export function ChatPanel() {
       rootResumeContext,
     })
 
-    addMessage("user", "继续未完成")
-    startStreaming(convId)
-
-    const sessionId = streamSessionGuardRef.current.start(convId)
-    activeStreamSessionsRef.current[convId] = sessionId
-    const controller = new AbortController()
-    abortControllersRef.current[convId] = controller
-
-    const deepStream = createDeepThinkingStreamRenderer()
-    let accumulated = deepStream.updateThinking("## 继续未完成\n正在基于上一轮已完成阶段继续生成,避免从头重新思考。")
-    let resumeThinking = ""
-    let latestCheckpoint = persistedResume?.checkpoint
-    setStreamingContent(accumulated, convId)
-
-    try {
-      const novelConfig = useWikiStore.getState().novelConfig
-      const writingConfig = resolveNovelModel(effectiveChatLlmConfig, novelConfig, "writing")
-
-      if (project && originalRequest?.trim() && persistedResume?.checkpoint) {
-        const pp = normalizePath(project.path)
-        const resumeRoute = routeTask(originalRequest)
-        const goldenResume = detectGoldenThreeChapterRequest(originalRequest, resumeRoute?.chapterNumber)
-        const dismantlingDirective = await loadEnabledDismantlingDirective(pp).catch(() => "")
-        const { runDeepChapterGeneration } = await import("@/lib/novel/deep-chapter-generation")
-
-        await runDeepChapterGeneration(
-          {
-            projectPath: pp,
-            userRequest: originalRequest,
-            chapterNumber: resumeRoute?.chapterNumber,
-            goldenThreeChapter: goldenResume?.enabled ? goldenResume : undefined,
-            dismantlingReferenceDirective: dismantlingDirective,
-            llmConfig: effectiveChatLlmConfig,
-            resumeCheckpoint: persistedResume.checkpoint,
-          },
-          {
-            onThinking: (content) => {
-              if (!streamSessionGuardRef.current.isActive(convId, sessionId)) return
-              accumulated = deepStream.updateThinking(content)
-              setStreamingContent(accumulated, convId)
-            },
-            onFinalContent: (content) => {
-              if (!streamSessionGuardRef.current.isActive(convId, sessionId)) return
-              accumulated = deepStream.appendFinal(content)
-              setStreamingContent(accumulated, convId)
-            },
-            onCheckpoint: (checkpoint) => {
-              latestCheckpoint = checkpoint
-            },
-          },
-          undefined,
-          controller.signal,
-        )
-
-        if (!streamSessionGuardRef.current.isActive(convId, sessionId)) return
-        streamSessionGuardRef.current.finish(convId, sessionId, () => {
-          finalizeStream(accumulated || "继续未完成失败:模型没有返回内容。", [], convId)
-          delete activeStreamSessionsRef.current[convId]
-          delete abortControllersRef.current[convId]
-        })
-        return
-      }
-
-      let continuationSystemPrompt = [
-        "你是专业小说写作助手。用户正在继续一次已中断的深度章节生成,请严格基于已有思考和阶段内容往后完成,不要从头重跑已完成阶段。",
-        "如果上方恢复上下文里没有正文草稿,就从正文生成阶段继续;如果已经有正文草稿,就继续审查、返修、简单审查、去AI味或补全正文。",
-        "不要把“继续未完成”当作原始章节需求;原始章节需求必须以恢复上下文中的原始用户请求为准。",
-      ].join("\n")
-
-      if (project && originalRequest?.trim()) {
-        try {
-          const pp = normalizePath(project.path)
-          const resumeRoute = routeTask(originalRequest)
-          const goldenResume = detectGoldenThreeChapterRequest(originalRequest, resumeRoute?.chapterNumber)
-          const taskDirective = resumeRoute ? buildTaskDirective(resumeRoute) : ""
-          const goldenDirective = buildGoldenThreeChapterDirective(goldenResume)
-          const { buildContextPack, contextPackToPrompt } = await import("@/lib/novel/context-engine")
-           const contextPack = await buildContextPack(pp, originalRequest, resumeRoute?.chapterNumber).catch(() => ({
-             task: originalRequest,
-             chapterGoal: "",
-             outline: "",
-             recentSummaries: [],
-             previousChapterEnding: "",
-             characterStates: "",
-             soulDoc: "",
-             characterAuras: "",
-             cognitionStates: "",
-             foreshadowingStates: "",
-             timeline: "",
-             relatedSettings: "",
-             canonRules: "",
-             writingStyle: "",
-             searchResults: "",
-             graphSearchResults: "",
-             mustDo: "",
-             mustAvoid: "",
-             nextChapterAdvice: "",
-             revisionDirectives: "",
-           }))
-           const budget = novelConfig.contextTokenBudget > 0 ? novelConfig.contextTokenBudget : undefined
-           const dismantlingDirective = await loadEnabledDismantlingDirective(pp).catch(() => "")
-           continuationSystemPrompt = [
-             continuationSystemPrompt,
-             "",
-            "## QM-QUAI 技能",
-            buildQmQuaiSystemPrompt(),
-            "",
-            taskDirective,
-            goldenDirective,
-             "",
-             "## 原始深度章节上下文包",
-             contextPackToPrompt(contextPack, budget),
-             dismantlingDirective,
-           ].filter(Boolean).join("\n")
-        } catch (err) {
-          console.warn("构建继续未完成上下文包失败:", err)
-        }
-      }
-
-      let streamError: Error | null = null
-
-      await streamChat(
-        writingConfig,
-        [
-          {
-            role: "system",
-            content: continuationSystemPrompt,
-          },
-          { role: "user", content: prompt },
-        ],
-        {
-          onToken: (token) => {
-            if (!streamSessionGuardRef.current.isActive(convId, sessionId)) return
-            accumulated = deepStream.appendFinal(token)
-            setStreamingContent(accumulated, convId)
-          },
-          onReasoningToken: (token) => {
-            if (!streamSessionGuardRef.current.isActive(convId, sessionId)) return
-            resumeThinking += token
-            accumulated = deepStream.updateThinking(
-              `## 继续未完成\n正在基于上一轮已完成阶段继续生成,避免从头重新思考。\n\n${resumeThinking}`,
-            )
-            setStreamingContent(accumulated, convId)
-          },
-          onDone: () => {},
-          onError: (err) => {
-            streamError = err
-          },
-        },
-        controller.signal,
-        { reasoning: resolveUserVisibleReasoning(writingConfig.reasoning) },
-      )
-
-      if (!streamSessionGuardRef.current.isActive(convId, sessionId)) return
-      if (streamError) throw streamError
-
-      streamSessionGuardRef.current.finish(convId, sessionId, () => {
-        finalizeStream(accumulated || "继续未完成失败:模型没有返回内容。", [], convId)
-        delete activeStreamSessionsRef.current[convId]
-        delete abortControllersRef.current[convId]
-      })
-    } catch (err) {
-      const message = err instanceof Error ? err.message : String(err)
-      streamSessionGuardRef.current.finish(convId, sessionId, () => {
-        const visibleFailure = `${accumulated ? `${accumulated}\n\n` : ""}出错:继续未完成失败:${message}`
-        const inheritedResumeContext = [
-          rootResumeContext,
-          "",
-          "## 最近一次继续未完成失败时的输出",
-          stripContinueUnfinishedDeepChapterContext(visibleFailure),
-        ].join("\n")
-        finalizeStream(
-          appendContinueUnfinishedDeepChapterContext(visibleFailure, {
-            originalRequest,
-            resumeContext: inheritedResumeContext,
-            rootResumeContext,
-            checkpoint: latestCheckpoint,
-          }),
-          undefined,
-          convId,
-        )
-        delete activeStreamSessionsRef.current[convId]
-        delete abortControllersRef.current[convId]
-      })
-    } finally {
-      if (activeStreamSessionsRef.current[convId] === sessionId) {
-        delete activeStreamSessionsRef.current[convId]
-      }
-      if (abortControllersRef.current[convId] === controller) {
-        delete abortControllersRef.current[convId]
-      }
-    }
-  }, [isStreaming, createConversation, addMessage, startStreaming, setStreamingContent, llmConfig, aiChatModel, providerConfigs, finalizeStream])
+    await handleSendRef.current(prompt, [], "继续未完成")
+  }, [isStreaming])
 
 
 
@@ -1693,18 +1442,53 @@ export function ChatPanel() {
                   />
                   {novelMode && (
                     <>
-                      <Button
-                        type="button"
-                        variant="ghost"
-                        size="icon"
-                        aria-pressed={deepChapterEnabled}
-                        className={getDeepChapterToggleButtonClass(deepChapterEnabled)}
-                        onClick={() => setDeepChapterEnabled(!deepChapterEnabled)}
-                        title={deepChapterEnabled ? "关闭深度模式" : "开启深度模式"}
-                        aria-label={deepChapterEnabled ? "关闭深度模式" : "开启深度模式"}
+                      <div
+                        className="flex h-8 shrink-0 items-center rounded-full border bg-background p-0.5"
+                        role="group"
+                        aria-label={aiSessionWorkflowModeLabel}
                       >
-                        <Brain className="h-4 w-4" />
-                      </Button>
+                        {aiWorkflowModeOptions.map(({ mode, label }) => (
+                          <Button
+                            key={mode}
+                            type="button"
+                            variant="ghost"
+                            size="sm"
+                            aria-pressed={aiWorkflowMode === mode}
+                            className={`h-7 min-w-10 rounded-full px-2 text-xs ${getWorkflowModeButtonClass(aiWorkflowMode === mode)}`}
+                            onClick={() => setAiWorkflowMode(mode)}
+                            title={`切换到${label}模式`}
+                            aria-label={`切换到${label}模式`}
+                          >
+                            {label}
+                          </Button>
+                        ))}
+                      </div>
+                      <Tooltip>
+                        <TooltipTrigger
+                          render={(
+                            <Button
+                              type="button"
+                              variant="ghost"
+                              size="sm"
+                              aria-pressed={planExecuteEnabled}
+                              className={`h-8 shrink-0 rounded-full border px-2.5 text-xs ${
+                                planExecuteEnabled
+                                  ? "border-primary bg-primary text-primary-foreground shadow-sm hover:bg-primary/90 hover:text-primary-foreground"
+                                  : "border-border text-muted-foreground hover:bg-accent hover:text-foreground"
+                              }`}
+                              onClick={() => setPlanExecuteEnabled(!planExecuteEnabled)}
+                              title={planExecuteEnabled ? "关闭计划执行模式" : "开启计划执行模式"}
+                              aria-label={planExecuteEnabled ? "关闭计划执行模式" : "开启计划执行模式"}
+                            />
+                          )}
+                        >
+                          <ListChecks className="mr-1 h-3.5 w-3.5" />
+                          计划执行
+                        </TooltipTrigger>
+                        <TooltipContent side="top" className="max-w-xs leading-5">
+                          开启后,本次写作会先创建计划,等待确认后再执行;可与快速、标准、严格任一模式组合使用。
+                        </TooltipContent>
+                      </Tooltip>
                       <Tooltip>
                         <TooltipTrigger
                           render={(
@@ -1837,17 +1621,6 @@ export function ChatPanel() {
             onCancel={() => closeChapterPlanDialog("cancel")}
           />
         )}
-        <ModifyConfirmDialog
-          open={breakpointResumeOpen}
-          originalContent="检测到上次有未完成的任务"
-          modifiedContent={breakpointResumeContent}
-          itemName="检测到上次有未完成的任务"
-          intentLabel="确认后将继续执行断点任务"
-          type="breakpoint"
-          editable={false}
-          onConfirm={confirmBreakpointResume}
-          onCancel={cancelBreakpointResume}
-        />
       </div>
     </div>
   )

+ 1 - 2
src/components/chat/dismantling-reference.spec.ts

@@ -8,8 +8,7 @@ describe("chat dismantling reference", () => {
   it("disables dismantling structure injection while the feature is hidden", () => {
     const chatSource = readFileSync(resolve(root, "src/components/chat/chat-panel.tsx"), "utf8")
 
-    expect(chatSource).toContain("async function loadEnabledDismantlingDirective")
-    expect(chatSource).toContain('return ""')
+    expect(chatSource).not.toContain("loadEnabledDismantlingDirective")
     expect(chatSource).not.toContain("buildDismantlingReferenceDirective")
     expect(chatSource).not.toContain("loadDismantlingLibrary")
   })

+ 7 - 1
src/components/chat/modify-confirm-dialog.tsx

@@ -24,6 +24,7 @@ const TYPE_CONFIG: Record<ModifyConfirmType, {
   iconColor: string
   originalLabel: string
   modifiedLabel: string
+  confirmLabel: string
 }> = {
   chapter: {
     title: "确认修改章节",
@@ -31,6 +32,7 @@ const TYPE_CONFIG: Record<ModifyConfirmType, {
     iconColor: "text-amber-500",
     originalLabel: "原文",
     modifiedLabel: "修改后",
+    confirmLabel: "确认保存",
   },
   outline: {
     title: "确认写入大纲",
@@ -38,6 +40,7 @@ const TYPE_CONFIG: Record<ModifyConfirmType, {
     iconColor: "text-blue-500",
     originalLabel: "原大纲",
     modifiedLabel: "新内容",
+    confirmLabel: "确认保存",
   },
   memory: {
     title: "确认写入记忆",
@@ -45,6 +48,7 @@ const TYPE_CONFIG: Record<ModifyConfirmType, {
     iconColor: "text-purple-500",
     originalLabel: "原记忆",
     modifiedLabel: "新内容",
+    confirmLabel: "确认保存",
   },
   classification: {
     title: "确认恢复 classification.md",
@@ -52,6 +56,7 @@ const TYPE_CONFIG: Record<ModifyConfirmType, {
     iconColor: "text-emerald-500",
     originalLabel: "当前配置",
     modifiedLabel: "默认配置",
+    confirmLabel: "确认保存",
   },
   breakpoint: {
     title: "继续未完成任务",
@@ -59,6 +64,7 @@ const TYPE_CONFIG: Record<ModifyConfirmType, {
     iconColor: "text-blue-500",
     originalLabel: "断点摘要",
     modifiedLabel: "恢复提示词",
+    confirmLabel: "继续执行",
   },
 }
 
@@ -242,7 +248,7 @@ export function ModifyConfirmDialog({
             className="flex items-center gap-1.5 rounded-md bg-green-600 px-4 py-1.5 text-sm font-medium text-white hover:bg-green-700"
           >
             <Check className="h-4 w-4" />
-            确认保存
+            {config.confirmLabel}
           </button>
         </div>
       </div>

+ 2 - 9
src/components/layout/content-area.tsx

@@ -2,8 +2,7 @@ import { Suspense, lazy } from "react"
 import { useWikiStore } from "@/stores/wiki-store"
 import { WritingWorkspace } from "./writing-workspace"
 import { SearchView } from "@/components/search/search-view"
-import { SkillLibraryView } from "@/components/skill-library/skill-library-view"
-import { WritingSkillLibraryView } from "@/components/skill-library/writing-skill-library-view"
+import { UnifiedSkillLibraryView } from "@/components/skill-library/unified-skill-library-view"
 
 const ChatPanel = lazy(async () => {
   const mod = await import("@/components/chat/chat-panel")
@@ -98,16 +97,10 @@ export function ContentArea() {
         )
         break
       case "skillLibrary":
-        content = (
-          <Suspense fallback={<LoadingView />}>
-            <SkillLibraryView />
-          </Suspense>
-        )
-        break
       case "writingSkillLibrary":
         content = (
           <Suspense fallback={<LoadingView />}>
-            <WritingSkillLibraryView />
+            <UnifiedSkillLibraryView />
           </Suspense>
         )
         break

+ 2 - 3
src/components/layout/icon-sidebar.tsx

@@ -1,6 +1,6 @@
 import { useState, useRef, useEffect, useMemo } from "react"
 import {
-  FileText, FolderOpen, Search, Network, Brain, Settings, ArrowLeftRight, Sun, Moon, SunMoon, Check, Trash2, Sparkles, LayoutDashboard, BookOpen, Drama, WandSparkles, PencilRuler,
+  FileText, FolderOpen, Search, Network, Brain, Settings, ArrowLeftRight, Sun, Moon, SunMoon, Check, Trash2, Sparkles, LayoutDashboard, BookOpen, Drama, WandSparkles,
 } from "lucide-react"
 import { createPortal } from "react-dom"
 import {
@@ -47,7 +47,6 @@ const CONFIGURABLE_NAV_ITEMS: ConfigurableNavItem[] = [
   { id: "lint", view: "lint", icon: Brain, labelKey: "novel.nav.lint" },
   { id: "soul", view: "soul", icon: Sparkles, labelKey: "novel.nav.soul" },
   { id: "skillLibrary", view: "skillLibrary", icon: WandSparkles, labelKey: "novel.nav.skillLibrary" },
-  { id: "writingSkillLibrary", view: "writingSkillLibrary", icon: PencilRuler, labelKey: "novel.nav.writingSkillLibrary" },
   { id: "bookAnalysis", view: "bookAnalysis", icon: BookOpen, labelKey: "novel.nav.dismantling" },
   { id: "reviewCenter", view: "reviewCenter", icon: LayoutDashboard, labelKey: "novel.nav.reviewCenter" },
   { id: "storySimulation", view: "storySimulation", icon: Drama, labelKey: "novel.nav.storySimulation" },
@@ -78,7 +77,7 @@ interface SortableNavButtonProps {
 function SortableNavButton({ item, activeView, pendingCount, label, onClick }: SortableNavButtonProps) {
   const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: item.id })
   const Icon = item.icon
-  const isActive = activeView === item.view
+  const isActive = activeView === item.view || (item.view === "skillLibrary" && activeView === "writingSkillLibrary")
   const style = {
     transform: CSS.Transform.toString(transform),
     transition,

+ 3 - 8
src/components/layout/sidebar-panel.tsx

@@ -24,8 +24,7 @@ import { SoulSidebarPanel } from "./soul-sidebar-panel"
 import { ReviewCenterSidebarPanel } from "./review-center-sidebar-panel"
 import { BookAnalysisSidebarPanel } from "./book-analysis-sidebar-panel"
 import { FrameworkList } from "@/components/novel/story-simulation/framework-list"
-import { SkillLibrarySidebarPanel } from "@/components/skill-library/skill-library-view"
-import { WritingSkillLibrarySidebarPanel } from "@/components/skill-library/writing-skill-library-view"
+import { UnifiedSkillLibrarySidebarPanel } from "@/components/skill-library/unified-skill-library-view"
 
 import { useWikiStore } from "@/stores/wiki-store"
 import { useChatStore } from "@/stores/chat-store"
@@ -1283,12 +1282,8 @@ export function SidebarPanel() {
     return <SoulSidebarPanel />
   }
 
-  if (activeView === "skillLibrary") {
-    return <SkillLibrarySidebarPanel />
-  }
-
-  if (activeView === "writingSkillLibrary") {
-    return <WritingSkillLibrarySidebarPanel />
+  if (activeView === "skillLibrary" || activeView === "writingSkillLibrary") {
+    return <UnifiedSkillLibrarySidebarPanel />
   }
 
   if (activeView === "reviewCenter") {

+ 37 - 0
src/components/novel/story-simulation/story-simulation-view.debug.spec.ts

@@ -0,0 +1,37 @@
+import { readFileSync } from "node:fs"
+import { resolve } from "node:path"
+import { describe, expect, it } from "vitest"
+
+describe("story simulation debug process view wiring", () => {
+  it("wires debug traces from the simulation engine into the simulating panel", () => {
+    const source = readFileSync(
+      resolve(
+        process.cwd(),
+        "src/components/novel/story-simulation/story-simulation-view.tsx",
+      ),
+      "utf8",
+    )
+
+    expect(source).toContain("debugTraces")
+    expect(source).toContain("setDebugTraces")
+    expect(source).toContain("addDebugTrace")
+    expect(source).toContain("onDebugTrace")
+    expect(source).toContain("debugTraces={debugTraces}")
+  })
+
+  it("renders a timeline and process observation switch in the simulating panel", () => {
+    const source = readFileSync(
+      resolve(
+        process.cwd(),
+        "src/components/novel/story-simulation/story-simulation-view.tsx",
+      ),
+      "utf8",
+    )
+
+    expect(source).toContain("时间线")
+    expect(source).toContain("过程观察")
+    expect(source).toContain("ProcessDebugPanel")
+    expect(source).toContain("Blackboard")
+    expect(source).toContain("可见事件")
+  })
+})

+ 164 - 0
src/components/novel/story-simulation/story-simulation-view.tsx

@@ -23,6 +23,7 @@ import type {
   AgentChatMessage,
   ExtractionResult,
   NovelAgent,
+  SimulationDebugTrace,
   SimulationState,
   StoryBranch,
   StoryFramework,
@@ -130,6 +131,7 @@ export function StorySimulationView() {
   const progress = useStorySimulationStore((s) => s.progress)
   const progressLabel = useStorySimulationStore((s) => s.progressLabel)
   const timelineEvents = useStorySimulationStore((s) => s.timelineEvents)
+  const debugTraces = useStorySimulationStore((s) => s.debugTraces)
   const activeChatAgent = useStorySimulationStore((s) => s.activeChatAgent)
   const savedResults = useStorySimulationStore((s) => s.savedResults)
   const selectedResultId = useStorySimulationStore((s) => s.selectedResultId)
@@ -147,6 +149,8 @@ export function StorySimulationView() {
   const setProgress = useStorySimulationStore((s) => s.setProgress)
   const setTimelineEvents = useStorySimulationStore((s) => s.setTimelineEvents)
   const addTimelineEvent = useStorySimulationStore((s) => s.addTimelineEvent)
+  const setDebugTraces = useStorySimulationStore((s) => s.setDebugTraces)
+  const addDebugTrace = useStorySimulationStore((s) => s.addDebugTrace)
   const setActiveChatAgent = useStorySimulationStore((s) => s.setActiveChatAgent)
   const addAgentChatMessage = useStorySimulationStore((s) => s.addAgentChatMessage)
   const agentChatMessages = useStorySimulationStore((s) => s.agentChatMessages)
@@ -256,6 +260,7 @@ export function StorySimulationView() {
     setError(null)
     setCurrentFramework(null)
     setTimelineEvents([])
+    setDebugTraces([])
     try {
       // 1. 提取内容
       setPhase("extracting")
@@ -311,6 +316,7 @@ export function StorySimulationView() {
     }
     setError(null)
     setTimelineEvents([])
+    setDebugTraces([])
     setIsCancelling(false)
     const ac = new AbortController()
     abortControllerRef.current = ac
@@ -359,6 +365,7 @@ export function StorySimulationView() {
           collectedTimeline.push(event)
           addTimelineEvent(event)
         },
+        onDebugTrace: addDebugTrace,
       }
       const events = await runSimulation(
         {
@@ -465,6 +472,7 @@ export function StorySimulationView() {
   /** 重新推演:回退到框架确认阶段。 */
   const handleResimulate = () => {
     setTimelineEvents([])
+    setDebugTraces([])
     setCurrentReport(null)
     setPhase("framework-confirming")
   }
@@ -762,6 +770,7 @@ export function StorySimulationView() {
               progress={progress}
               label={progressLabel || progressTitle}
               events={timelineEvents}
+              debugTraces={debugTraces}
               framework={currentFramework}
               onInterviewAgent={(id, name) => handleInterviewAgent(id, name)}
               onCancel={handleCancel}
@@ -862,6 +871,7 @@ function SimulatingTimelinePanel({
   progress,
   label,
   events,
+  debugTraces,
   framework,
   onInterviewAgent,
   onCancel,
@@ -870,6 +880,7 @@ function SimulatingTimelinePanel({
   progress: number
   label: string
   events: TimelineEvent[]
+  debugTraces: SimulationDebugTrace[]
   framework?: StoryFramework | null
   onInterviewAgent?: (agentId: string, agentName: string) => void
   onCancel?: () => void
@@ -877,6 +888,7 @@ function SimulatingTimelinePanel({
 }) {
   const clamped = Math.min(100, Math.max(0, progress))
   const logRef = useRef<HTMLDivElement | null>(null)
+  const [activeStreamView, setActiveStreamView] = useState<"timeline" | "debug">("timeline")
 
   // 筛选状态
   const [filterActor, setFilterActor] = useState<string>("all")
@@ -1011,6 +1023,35 @@ function SimulatingTimelinePanel({
         </div>
       </div>
 
+      <div className="mb-3 flex justify-center">
+        <div className="inline-flex rounded-md border bg-muted/40 p-0.5 text-xs">
+          <button
+            type="button"
+            className={`rounded px-3 py-1.5 ${
+              activeStreamView === "timeline"
+                ? "bg-background text-foreground shadow-sm"
+                : "text-muted-foreground hover:text-foreground"
+            }`}
+            onClick={() => setActiveStreamView("timeline")}
+          >
+            时间线
+          </button>
+          <button
+            type="button"
+            className={`rounded px-3 py-1.5 ${
+              activeStreamView === "debug"
+                ? "bg-background text-foreground shadow-sm"
+                : "text-muted-foreground hover:text-foreground"
+            }`}
+            onClick={() => setActiveStreamView("debug")}
+          >
+            过程观察
+          </button>
+        </div>
+      </div>
+
+      {activeStreamView === "timeline" ? (
+        <>
       {/* 筛选栏 */}
       {events.length > 0 && (
         <div className="mb-3 flex flex-wrap items-center gap-2 text-xs">
@@ -1157,6 +1198,129 @@ function SimulatingTimelinePanel({
           </div>
         )}
       </div>
+        </>
+      ) : (
+        <ProcessDebugPanel debugTraces={debugTraces} />
+      )}
+    </div>
+  )
+}
+
+function ProcessDebugPanel({
+  debugTraces,
+}: {
+  debugTraces: SimulationDebugTrace[]
+}) {
+  const latestTrace = debugTraces[debugTraces.length - 1]
+  const displayTraces = debugTraces.slice(-50).reverse()
+
+  if (!latestTrace) {
+    return (
+      <div className="flex-1 rounded-lg border bg-muted/30 p-6 text-center text-xs text-muted-foreground">
+        等待推演过程数据...
+      </div>
+    )
+  }
+
+  return (
+    <div className="min-h-0 flex-1 overflow-y-auto rounded-lg border bg-muted/30 p-3 text-sm">
+      <div className="mb-3 grid grid-cols-2 gap-2 md:grid-cols-4">
+        <DebugStat label="全量角色" value={latestTrace.blackboard.allAgentCount} />
+        <DebugStat label="活跃角色" value={latestTrace.blackboard.activeAgentCount} />
+        <DebugStat label="总事件" value={latestTrace.blackboard.totalEventCount} />
+        <DebugStat label="公共事件" value={latestTrace.blackboard.publicEventCount} />
+      </div>
+
+      <div className="space-y-3">
+        {displayTraces.map((trace) => (
+          <div key={trace.id} className="rounded-md border bg-background/70 p-3">
+            <div className="mb-2 flex flex-wrap items-center gap-2">
+              <span className="rounded bg-primary/10 px-1.5 py-0.5 text-[11px] font-medium text-primary">
+                {trace.type === "round-plan" ? "轮次计划" : "事件写入"}
+              </span>
+              <span className="text-xs text-muted-foreground">
+                节点 {trace.nodeIndex + 1}:{trace.nodeTitle}
+              </span>
+              <span className="text-xs text-muted-foreground">R{trace.round + 1}</span>
+              {trace.strategy && (
+                <span className="text-xs text-muted-foreground">
+                  策略:{trace.strategy === "all-agents" ? "全部角色" : trace.strategy === "subset" ? "部分角色" : "无角色"}
+                </span>
+              )}
+            </div>
+
+            <div className="mb-2 grid gap-2 md:grid-cols-2">
+              <DebugAgentList title="候选 Agent" agents={trace.candidateAgents} />
+              <DebugAgentList title="本轮行动 Agent" agents={trace.selectedAgents} />
+            </div>
+
+            {trace.latestEvent && (
+              <div className="mb-2 rounded border bg-muted/30 px-2 py-1.5 text-xs">
+                <span className="font-medium">最近事件:</span>
+                <span className="text-muted-foreground">
+                  {trace.latestEvent.actorName} / {trace.latestEvent.actionType}
+                </span>
+                <span>:{trace.latestEvent.content}</span>
+              </div>
+            )}
+
+            <div className="rounded border bg-muted/20 p-2">
+              <div className="mb-1 text-xs font-medium">Blackboard 可见事件</div>
+              <div className="grid gap-1 md:grid-cols-2">
+                {trace.visibilityByAgent.map((agent) => (
+                  <div key={agent.agentId} className="text-xs text-muted-foreground">
+                    <span className="font-medium text-foreground">{agent.agentName}</span>
+                    <span> 可见事件 {agent.visibleEventCount ?? 0} 条</span>
+                    {agent.recentEvents && agent.recentEvents.length > 0 && (
+                      <span>
+                        :{agent.recentEvents.map((event) => event.id).join("、")}
+                      </span>
+                    )}
+                  </div>
+                ))}
+              </div>
+            </div>
+          </div>
+        ))}
+      </div>
+    </div>
+  )
+}
+
+function DebugStat({ label, value }: { label: string; value: number }) {
+  return (
+    <div className="rounded-md border bg-background/70 px-3 py-2">
+      <div className="text-[11px] text-muted-foreground">{label}</div>
+      <div className="text-lg font-semibold">{value}</div>
+    </div>
+  )
+}
+
+function DebugAgentList({
+  title,
+  agents,
+}: {
+  title: string
+  agents: SimulationDebugTrace["candidateAgents"]
+}) {
+  return (
+    <div className="rounded border bg-muted/20 p-2">
+      <div className="mb-1 text-xs font-medium">{title}</div>
+      <div className="flex flex-wrap gap-1">
+        {agents.length === 0 ? (
+          <span className="text-xs text-muted-foreground">无</span>
+        ) : (
+          agents.map((agent) => (
+            <span
+              key={`${title}-${agent.agentId}`}
+              className="rounded bg-background px-1.5 py-0.5 text-[11px] text-muted-foreground"
+              title={agent.reason}
+            >
+              {agent.agentName}
+            </span>
+          ))
+        )}
+      </div>
     </div>
   )
 }

+ 0 - 1
src/components/settings/sections/interface-section.tsx

@@ -76,7 +76,6 @@ const SIDEBAR_NAV_LABEL_KEYS: Record<SidebarNavItemId, string> = {
   lint: "novel.nav.lint",
   soul: "novel.nav.soul",
   skillLibrary: "novel.nav.skillLibrary",
-  writingSkillLibrary: "novel.nav.writingSkillLibrary",
   bookAnalysis: "novel.nav.dismantling",
   reviewCenter: "novel.nav.reviewCenter",
   storySimulation: "novel.nav.storySimulation",

+ 62 - 0
src/components/skill-library/unified-skill-library-view.tsx

@@ -0,0 +1,62 @@
+import { useWikiStore } from "@/stores/wiki-store"
+import { SkillLibrarySidebarPanel, SkillLibraryView } from "./skill-library-view"
+import { WritingSkillLibrarySidebarPanel, WritingSkillLibraryView } from "./writing-skill-library-view"
+
+const skillLibraryTabs = [
+  { view: "skillLibrary" as const, label: "去AI味技能" },
+  { view: "writingSkillLibrary" as const, label: "写作 Skill" },
+]
+
+function SkillLibraryTabs({ compact = false }: { compact?: boolean }) {
+  const activeView = useWikiStore((s) => s.activeView)
+  const setActiveView = useWikiStore((s) => s.setActiveView)
+  const activeTab = activeView === "writingSkillLibrary" ? "writingSkillLibrary" : "skillLibrary"
+
+  return (
+    <div className={`flex shrink-0 items-center gap-1 border-b ${compact ? "px-2 py-2" : "px-4 py-3"}`}>
+      {skillLibraryTabs.map((tab) => (
+        <button
+          key={tab.view}
+          type="button"
+          aria-pressed={activeTab === tab.view}
+          onClick={() => setActiveView(tab.view)}
+          className={`rounded-md px-3 py-1.5 text-sm transition-colors ${
+            activeTab === tab.view
+              ? "bg-primary text-primary-foreground"
+              : "text-muted-foreground hover:bg-accent hover:text-foreground"
+          }`}
+        >
+          {tab.label}
+        </button>
+      ))}
+    </div>
+  )
+}
+
+export function UnifiedSkillLibraryView() {
+  const activeView = useWikiStore((s) => s.activeView)
+  const showWritingSkill = activeView === "writingSkillLibrary"
+
+  return (
+    <div data-testid="unified-skill-library-view" className="flex h-full flex-col overflow-hidden">
+      <SkillLibraryTabs />
+      <div className="min-h-0 flex-1 overflow-hidden">
+        {showWritingSkill ? <WritingSkillLibraryView /> : <SkillLibraryView />}
+      </div>
+    </div>
+  )
+}
+
+export function UnifiedSkillLibrarySidebarPanel() {
+  const activeView = useWikiStore((s) => s.activeView)
+  const showWritingSkill = activeView === "writingSkillLibrary"
+
+  return (
+    <div data-testid="unified-skill-library-sidebar" className="flex h-full flex-col overflow-hidden">
+      <SkillLibraryTabs compact />
+      <div className="min-h-0 flex-1 overflow-hidden">
+        {showWritingSkill ? <WritingSkillLibrarySidebarPanel /> : <SkillLibrarySidebarPanel />}
+      </div>
+    </div>
+  )
+}

+ 31 - 0
src/hooks/use-agent-config.spec.ts

@@ -11,6 +11,7 @@ import type { DeAiSkillConfig } from "@/lib/novel/de-ai-skill-library"
 import type { UserSkillConfig } from "@/lib/novel/user-skill-store"
 import type { McpConfig } from "@/lib/mcp/config"
 import type { UseAgentConfigResult } from "@/hooks/use-agent-config"
+import type { AiWorkflowMode } from "@/lib/agent/workflow-mode"
 
 const baseLlmConfig: LlmConfig = {
   provider: "openai",
@@ -30,6 +31,7 @@ interface StoreStates {
     providerConfigs: ProviderConfigs
     searchApiConfig: SearchApiConfig
     mcpConfig: McpConfig
+    aiWorkflowMode: AiWorkflowMode
   }>
   chat?: Partial<{
     conversations: Conversation[]
@@ -66,6 +68,7 @@ async function renderHook(systemPrompt: string, overrides: StoreStates & {
     } as SearchApiConfig,
     mcpConfig: { servers: [] } as McpConfig,
     novelMode: true,
+    aiWorkflowMode: "standard" as AiWorkflowMode,
     ...overrides.wiki,
   }
 
@@ -121,6 +124,10 @@ async function renderHook(systemPrompt: string, overrides: StoreStates & {
     }
   })
 
+  vi.doMock("@/lib/novel/deep-chapter-generation", () => ({
+    runDeepChapterGeneration: vi.fn(),
+  }))
+
   vi.doMock("@/lib/web-search", () => ({
     resolveSearchConfig: (config: SearchApiConfig) => config,
     webSearch: (...args: unknown[]) => webSearchMock(...args),
@@ -183,6 +190,7 @@ describe("useAgentConfig", () => {
     vi.doUnmock("@/stores/outline-chat-store")
     vi.doUnmock("@/lib/novel/de-ai-skill-library")
     vi.doUnmock("@/lib/novel/user-skill-store")
+    vi.doUnmock("@/lib/novel/deep-chapter-generation")
     vi.doUnmock("@/lib/web-search")
     vi.doUnmock("@/lib/mcp/real-connector")
   })
@@ -390,6 +398,29 @@ describe("useAgentConfig", () => {
     await cleanup()
   }, 15000)
 
+  it("passes chapter workflow dependencies into the agent tool registry", async () => {
+    const { result, cleanup } = await renderHook("test prompt", {
+      wiki: {
+        aiChatModel: "openai/gpt-4o",
+        project: { path: "/tmp/project" } as WikiProject,
+        aiWorkflowMode: "strict",
+      },
+      skillConfig: {
+        version: 1,
+        defaultSkillId: "built-in:comprehensive",
+        disabledSkillIds: [],
+        projectSkills: [],
+        builtInSkillOverrides: [],
+        lastChapterDeAiSkillId: null,
+      },
+    })
+
+    expect(result.registry.has("run_chapter_workflow")).toBe(true)
+    expect(result.config?.tools.some((tool) => tool.name === "run_chapter_workflow")).toBe(true)
+
+    await cleanup()
+  }, 15000)
+
   it("MCP server 含 command 时注入 RealMcpConnector caller", async () => {
     realMcpCallerMock.mockResolvedValue({
       status: "ok",

+ 5 - 0
src/hooks/use-agent-config.ts

@@ -6,6 +6,7 @@ import { loadDeAiSkillConfig, type DeAiSkillConfig } from "@/lib/novel/de-ai-ski
 import { loadUserSkillConfig, resolveEnabledWritingSkills } from "@/lib/novel/user-skill-store"
 import type { UserSkill } from "@/lib/novel/skill-library"
 import { resolveModelConfig } from "@/lib/novel/model-resolver"
+import { runDeepChapterGeneration } from "@/lib/novel/deep-chapter-generation"
 import { normalizePath } from "@/lib/path-utils"
 import { ToolRegistry } from "@/lib/agent/registry"
 import { buildAgentConfig, modelSupportsTools } from "@/lib/agent/config"
@@ -33,6 +34,7 @@ export function useAgentConfig(systemPrompt: string): UseAgentConfigResult {
   const providerConfigs = useWikiStore((s) => s.providerConfigs)
   const searchApiConfig = useWikiStore((s) => s.searchApiConfig)
   const mcpConfig = useWikiStore((s) => s.mcpConfig)
+  const aiWorkflowMode = useWikiStore((s) => s.aiWorkflowMode)
 
   const chatConversations = useChatStore((s) => s.conversations)
   const chatMessages = useChatStore((s) => s.messages)
@@ -137,6 +139,8 @@ export function useAgentConfig(systemPrompt: string): UseAgentConfigResult {
       getOutlineConversations,
       mcpTools: mcpRuntime.mcpTools,
       llmConfig,
+      aiWorkflowMode,
+      runDeepChapterGeneration,
       draftMode: novelMode,
       projectPath: normalizePath(projectPath),
     })
@@ -158,6 +162,7 @@ export function useAgentConfig(systemPrompt: string): UseAgentConfigResult {
     baseLlmConfig,
     providerConfigs,
     mcpConfig,
+    aiWorkflowMode,
     getSearchApiConfig,
     getUserSkills,
     systemPrompt,

+ 143 - 0
src/lib/agent/activity-trace.spec.ts

@@ -0,0 +1,143 @@
+import { describe, expect, it } from "vitest"
+import {
+  appendAgentActivityEvent,
+  applyAgentActivityEvent,
+  createAgentActivityEvent,
+  createStageStartedEvent,
+  getDefaultOpenAgentStageId,
+  settleRunningAgentStages,
+  summarizeAgentStage,
+} from "./activity-trace"
+import type { AgentStageTrace } from "./types"
+
+describe("activity trace", () => {
+  it("creates a running stage and appends activity in order", () => {
+    let stages: AgentStageTrace[] = []
+
+    stages = applyAgentActivityEvent(stages, createStageStartedEvent({
+      stageId: "read_context",
+      title: "读取上下文",
+      summary: "读取章节、大纲和记忆",
+      timestamp: 100,
+    }))
+
+    stages = appendAgentActivityEvent(stages, createAgentActivityEvent({
+      id: "ev-1",
+      stageId: "read_context",
+      kind: "read_source",
+      title: "读取上一章",
+      content: "读取章节《第13章》",
+      timestamp: 120,
+    }))
+
+    expect(stages).toHaveLength(1)
+    expect(stages[0]).toMatchObject({
+      id: "read_context",
+      title: "读取上下文",
+      status: "running",
+      summary: "读取章节、大纲和记忆",
+      startedAt: 100,
+    })
+    expect(stages[0].events.map((event) => event.title)).toEqual(["进入阶段", "读取上一章"])
+  })
+
+  it("marks a stage done when stage_output is appended", () => {
+    const stages = applyAgentActivityEvent([], createStageStartedEvent({
+      stageId: "plot_analysis",
+      title: "分析剧情走向",
+      summary: "分析本章承接关系",
+      timestamp: 100,
+    }))
+
+    const next = appendAgentActivityEvent(stages, createAgentActivityEvent({
+      id: "ev-2",
+      stageId: "plot_analysis",
+      kind: "stage_output",
+      title: "阶段产物",
+      content: "本章以铜铃线索开场,以身份疑点结尾。",
+      timestamp: 180,
+    }))
+
+    expect(next[0].status).toBe("done")
+    expect(next[0].finishedAt).toBe(180)
+    expect(summarizeAgentStage(next[0])).toContain("本章以铜铃线索开场")
+  })
+
+  it("keeps running or approval stages open by default", () => {
+    const stages: AgentStageTrace[] = [
+      { id: "done", title: "已完成", status: "done", summary: "完成", events: [], startedAt: 1, finishedAt: 2 },
+      { id: "running", title: "进行中", status: "running", summary: "生成中", events: [], startedAt: 3 },
+    ]
+
+    expect(getDefaultOpenAgentStageId(stages)).toBe("running")
+  })
+
+  it("does not render undefined-like empty content", () => {
+    const event = createAgentActivityEvent({
+      id: "ev-empty",
+      stageId: "read_context",
+      kind: "analysis",
+      title: "软件分析",
+      content: "",
+      timestamp: 100,
+    })
+
+    expect(event.content).toBe("本阶段未返回可展示内容。")
+  })
+
+  it("settles leftover running stages when an agent session finishes", () => {
+    const stages: AgentStageTrace[] = [
+      {
+        id: "external_search",
+        title: "外部检索",
+        status: "running",
+        summary: "keyword: 兄弟",
+        startedAt: 100,
+        events: [{
+          id: "search-1",
+          stageId: "external_search",
+          kind: "web_search",
+          title: "调用完成:search_chapters",
+          content: "已搜索 18 个章节文件。",
+          timestamp: 150,
+        }],
+      },
+      {
+        id: "final_output",
+        title: "最终输出",
+        status: "done",
+        summary: "第21章已生成。",
+        startedAt: 200,
+        finishedAt: 260,
+        events: [],
+      },
+      {
+        id: "write_confirmation",
+        title: "写入确认",
+        status: "approval_required",
+        summary: "等待用户确认写入。",
+        startedAt: 300,
+        finishedAt: 320,
+        events: [],
+      },
+    ]
+
+    const settled = settleRunningAgentStages(stages, "done", 400)
+    expect(settled).toBeDefined()
+    const settledStages = settled ?? []
+
+    expect(settledStages[0]).toMatchObject({
+      status: "done",
+      finishedAt: 400,
+    })
+    expect(settledStages[0].summary).toBe("keyword: 兄弟")
+    expect(settledStages[1]).toMatchObject({
+      status: "done",
+      finishedAt: 260,
+    })
+    expect(settledStages[2]).toMatchObject({
+      status: "approval_required",
+      finishedAt: 320,
+    })
+  })
+})

+ 219 - 0
src/lib/agent/activity-trace.ts

@@ -0,0 +1,219 @@
+import type {
+  AgentActivityEvent,
+  AgentActivityKind,
+  AgentStageStatus,
+  AgentStageTrace,
+  AgentToolEvent,
+} from "./types"
+
+const EMPTY_CONTENT = "本阶段未返回可展示内容。"
+
+export interface CreateAgentActivityEventInput {
+  id: string
+  stageId: string
+  kind: AgentActivityKind
+  title: string
+  content: string
+  sourceRefs?: AgentActivityEvent["sourceRefs"]
+  toolCallId?: string
+  timestamp?: number
+}
+
+export interface CreateStageStartedEventInput {
+  stageId: string
+  title: string
+  summary: string
+  timestamp?: number
+}
+
+export function createAgentActivityEvent(input: CreateAgentActivityEventInput): AgentActivityEvent {
+  return {
+    id: input.id,
+    stageId: input.stageId,
+    kind: input.kind,
+    title: input.title.trim() || "阶段事件",
+    content: normalizeActivityContent(input.content),
+    sourceRefs: input.sourceRefs,
+    toolCallId: input.toolCallId,
+    timestamp: input.timestamp ?? Date.now(),
+  }
+}
+
+export function createStageStartedEvent(input: CreateStageStartedEventInput): AgentActivityEvent {
+  return createAgentActivityEvent({
+    id: `${input.stageId}:started:${input.timestamp ?? Date.now()}`,
+    stageId: input.stageId,
+    kind: "stage_started",
+    title: "进入阶段",
+    content: input.summary || input.title,
+    timestamp: input.timestamp,
+  })
+}
+
+export function appendAgentActivityEvent(
+  stages: AgentStageTrace[] | undefined,
+  event: AgentActivityEvent,
+): AgentStageTrace[] {
+  return applyAgentActivityEvent(stages, event)
+}
+
+export function applyAgentActivityEvent(
+  stages: AgentStageTrace[] | undefined,
+  event: AgentActivityEvent,
+): AgentStageTrace[] {
+  const current = stages ?? []
+  const existingIndex = current.findIndex((stage) => stage.id === event.stageId)
+  const existingStage = existingIndex >= 0 ? current[existingIndex] : createStageFromEvent(event)
+  const nextStage = applyEventToStage(existingStage, event)
+
+  if (existingIndex < 0) return [...current, nextStage]
+
+  return current.map((stage, index) => (index === existingIndex ? nextStage : stage))
+}
+
+export function summarizeAgentStage(stage: AgentStageTrace): string {
+  const output = [...stage.events].reverse().find((event) => event.kind === "stage_output" || event.kind === "final_output")
+  if (output?.content) return trimForSummary(output.content, 90)
+
+  const last = [...stage.events].reverse().find((event) => event.content)
+  if (last?.content) return trimForSummary(last.content, 90)
+
+  return stage.summary || "本阶段暂无摘要。"
+}
+
+export function getDefaultOpenAgentStageId(stages: AgentStageTrace[]): string | null {
+  return stages.find((stage) => stage.status === "running")?.id
+    ?? stages.find((stage) => stage.status === "approval_required")?.id
+    ?? stages.find((stage) => stage.status === "error")?.id
+    ?? null
+}
+
+export function settleRunningAgentStages(
+  stages: AgentStageTrace[] | undefined,
+  status: Extract<AgentStageStatus, "done" | "error" | "cancelled"> = "done",
+  timestamp: number = Date.now(),
+): AgentStageTrace[] | undefined {
+  if (!stages) return stages
+  return stages.map((stage) => {
+    if (stage.status !== "running" && stage.status !== "pending") return stage
+    return {
+      ...stage,
+      status,
+      finishedAt: timestamp,
+      summary: stage.summary || summarizeAgentStage(stage),
+    }
+  })
+}
+
+export function activityEventFromToolEvent(event: AgentToolEvent): AgentActivityEvent {
+  const kind = inferActivityKindFromToolName(event.name)
+  const statusText = toolStatusText(event.type)
+  return createAgentActivityEvent({
+    id: `tool:${event.callId}:${event.type}:${event.timestamp}`,
+    stageId: inferStageIdFromToolName(event.name),
+    kind,
+    title: `${statusText}:${event.name}`,
+    content: event.result || event.preview || formatParams(event.params),
+    toolCallId: event.callId,
+    timestamp: event.timestamp,
+  })
+}
+
+function createStageFromEvent(event: AgentActivityEvent): AgentStageTrace {
+  return {
+    id: event.stageId,
+    title: inferStageTitle(event),
+    status: "pending",
+    summary: event.content,
+    events: [],
+  }
+}
+
+function applyEventToStage(stage: AgentStageTrace, event: AgentActivityEvent): AgentStageTrace {
+  const events = [...stage.events, event].sort((a, b) => a.timestamp - b.timestamp)
+  const status = statusFromEvent(stage.status, event)
+  const startedAt = stage.startedAt ?? event.timestamp
+  const finishedAt = status === "done" || status === "error" || status === "cancelled"
+    ? event.timestamp
+    : stage.finishedAt
+
+  return {
+    ...stage,
+    title: event.kind === "stage_started" ? inferStageTitle(event) : stage.title,
+    summary: event.kind === "stage_started" || event.kind === "stage_output" || event.kind === "final_output" || event.kind === "error"
+      ? summarizeAgentStage({ ...stage, events })
+      : stage.summary,
+    status,
+    events,
+    startedAt,
+    finishedAt,
+  }
+}
+
+function statusFromEvent(current: AgentStageStatus, event: AgentActivityEvent): AgentStageStatus {
+  if (event.kind === "error") return "error"
+  if (event.kind === "stage_output" || event.kind === "final_output") return "done"
+  if (current === "done" || current === "error" || current === "cancelled") return current
+  if (event.kind === "tool_call" && /待确认|需要确认|approval/i.test(event.content)) return "approval_required"
+  return "running"
+}
+
+function normalizeActivityContent(content: string): string {
+  const trimmed = String(content ?? "").trim()
+  if (!trimmed || trimmed === "undefined" || trimmed === "null") return EMPTY_CONTENT
+  return trimmed
+}
+
+function trimForSummary(content: string, maxLength: number): string {
+  const normalized = normalizeActivityContent(content).replace(/\s+/g, " ")
+  if (normalized.length <= maxLength) return normalized
+  return `${normalized.slice(0, maxLength)}...`
+}
+
+function formatParams(params: Record<string, unknown>): string {
+  const entries = Object.entries(params)
+  if (entries.length === 0) return "已调用工具。"
+  return entries.map(([key, value]) => `${key}: ${String(value)}`).join("\n")
+}
+
+function inferStageIdFromToolName(name: string): string {
+  if (/read|context|chapter_context/.test(name)) return "read_context"
+  if (/skill/.test(name)) return "capability_selection"
+  if (/web|search/.test(name)) return "external_search"
+  if (/workflow|draft|chapter_/.test(name)) return "chapter_workflow"
+  if (/write|save|modify/.test(name)) return "write_confirmation"
+  return "react_tools"
+}
+
+function inferStageTitle(event: AgentActivityEvent): string {
+  const titles: Record<string, string> = {
+    task_understanding: "任务理解",
+    capability_selection: "能力选择",
+    read_context: "读取上下文",
+    plot_analysis: "分析剧情走向",
+    chapter_workflow: "多任务写作循环",
+    generate_draft: "生成章节草稿",
+    validate_revision: "校验与修正",
+    final_output: "最终输出",
+    external_search: "外部检索",
+    write_confirmation: "写入确认",
+    react_tools: "工具调用",
+  }
+  return titles[event.stageId] ?? event.title
+}
+
+function inferActivityKindFromToolName(name: string): AgentActivityKind {
+  if (/skill/.test(name)) return "skill_used"
+  if (/mcp/.test(name)) return "mcp_call"
+  if (/web|search/.test(name)) return "web_search"
+  if (/read|context/.test(name)) return "read_source"
+  return "tool_call"
+}
+
+function toolStatusText(type: AgentToolEvent["type"]): string {
+  if (type === "call_started") return "开始调用"
+  if (type === "result") return "调用完成"
+  if (type === "approval_required") return "等待确认"
+  if (type === "cancelled") return "已取消"
+  return "调用失败"
+}

+ 138 - 0
src/lib/agent/ai-chat-session.spec.ts

@@ -0,0 +1,138 @@
+import { describe, expect, it, vi } from "vitest"
+import { ToolRegistry } from "./registry"
+import type { AgentConfig, AgentMessage, Tool } from "./types"
+import { runAiChatSession } from "./ai-chat-session"
+
+const agentConfig: AgentConfig = {
+  maxRounds: 3,
+  tools: [],
+  systemPrompt: "system",
+  llmConfig: {
+    provider: "custom",
+    apiKey: "test-key",
+    model: "test-model",
+    ollamaUrl: "",
+    customEndpoint: "https://example.test/v1",
+    maxContextSize: 120000,
+  },
+}
+
+describe("runAiChatSession", () => {
+  it("runs the ReAct AgentRunner for chapter writing instead of bypassing to workflow", async () => {
+    const registry = new ToolRegistry()
+    const messages: AgentMessage[] = [
+      { role: "system", content: "system" },
+      { role: "user", content: "生成第3章" },
+    ]
+    const agentRunner = {
+      run: vi.fn(async () => ({ toolCalls: [], roundsUsed: 1, finalText: "正文" })),
+    }
+    const callbacks = {
+      onText: vi.fn(),
+      onToolEvent: vi.fn(),
+      onDone: vi.fn(),
+      onError: vi.fn(),
+    }
+
+    const result = await runAiChatSession({
+      userMessage: "生成第3章",
+      projectPath: "/project",
+      agentConfig,
+      registry,
+      messages,
+      callbacks,
+      agentRunner,
+    })
+
+    expect(agentRunner.run).toHaveBeenCalledTimes(1)
+    expect(agentRunner.run).toHaveBeenCalledWith(
+      expect.objectContaining({
+        projectPath: "/project",
+        taskGoal: "生成第3章",
+      }),
+      registry,
+      messages,
+      expect.objectContaining({
+        onText: callbacks.onText,
+        onToolEvent: callbacks.onToolEvent,
+        onDone: callbacks.onDone,
+        onError: callbacks.onError,
+      }),
+      undefined,
+    )
+    expect(result.finalText).toBe("正文")
+  })
+
+  it("scopes visible tools to selected capability names before invoking AgentRunner", async () => {
+    const registry = new ToolRegistry()
+    const readTool: Tool = {
+      name: "read_chapter",
+      description: "read",
+      category: "read",
+      parameters: {},
+      execute: vi.fn(async () => "chapter"),
+    }
+    const writeTool: Tool = {
+      name: "write_chapter",
+      description: "write",
+      category: "write",
+      parameters: {},
+      execute: vi.fn(async () => "draft"),
+    }
+    const messages: AgentMessage[] = [{ role: "user", content: "生成第3章" }]
+    const agentRunner = {
+      run: vi.fn(async (_config: AgentConfig) => ({ toolCalls: [], roundsUsed: 1, finalText: "正文" })),
+    }
+    const callbacks = {
+      onText: vi.fn(),
+      onDone: vi.fn(),
+      onError: vi.fn(),
+    }
+
+    await runAiChatSession({
+      userMessage: "生成第3章",
+      agentConfig: {
+        ...agentConfig,
+        tools: [readTool, writeTool],
+      },
+      registry,
+      messages,
+      callbacks,
+      enabledToolNames: ["read_chapter"],
+      agentRunner,
+    })
+
+    const passedConfig = agentRunner.run.mock.calls[0][0] as AgentConfig
+    expect(passedConfig.tools.map((tool) => tool.name)).toEqual(["read_chapter"])
+    expect(agentConfig.tools).toEqual([])
+  })
+
+  it("forwards activity callbacks to AgentRunner", async () => {
+    const registry = new ToolRegistry()
+    const messages: AgentMessage[] = [{ role: "user", content: "生成第3章" }]
+    const agentRunner = {
+      run: vi.fn(async () => ({ toolCalls: [], roundsUsed: 1, finalText: "正文" })),
+    }
+    const callbacks = {
+      onText: vi.fn(),
+      onToolEvent: vi.fn(),
+      onActivityEvent: vi.fn(),
+      onDone: vi.fn(),
+      onError: vi.fn(),
+    }
+
+    await runAiChatSession({
+      userMessage: "生成第3章",
+      agentConfig,
+      registry,
+      messages,
+      callbacks,
+      agentRunner,
+    })
+
+    const passedCallbacks = (agentRunner.run.mock.calls[0] as unknown[])[3]
+    expect(passedCallbacks).toMatchObject({
+      onActivityEvent: callbacks.onActivityEvent,
+    })
+  })
+})

+ 51 - 0
src/lib/agent/ai-chat-session.ts

@@ -0,0 +1,51 @@
+import { AgentRunner } from "./runner"
+import type { ToolRegistry } from "./registry"
+import type { AgentConfig, AgentMessage, AgentRunCallbacks, AgentRunRecord } from "./types"
+import { scopeAgentConfigTools } from "./tool-scope"
+
+export interface RunAiChatSessionCallbacks {
+  onText: (chunk: string) => void
+  onToolEvent?: AgentRunCallbacks["onToolEvent"]
+  onActivityEvent?: AgentRunCallbacks["onActivityEvent"]
+  onDone: () => void
+  onError: (error: Error) => void
+}
+
+export interface RunAiChatSessionInput {
+  userMessage: string
+  projectPath?: string
+  agentConfig: AgentConfig
+  registry: ToolRegistry
+  messages: AgentMessage[]
+  callbacks: RunAiChatSessionCallbacks
+  enabledToolNames?: string[] | null
+  signal?: AbortSignal
+  agentRunner?: Pick<AgentRunner, "run">
+}
+
+export async function runAiChatSession(input: RunAiChatSessionInput): Promise<AgentRunRecord> {
+  const runner = input.agentRunner ?? new AgentRunner()
+  const scopedAgentConfig = scopeAgentConfigTools(input.agentConfig, input.enabledToolNames)
+
+  return runner.run(
+    {
+      ...scopedAgentConfig,
+      projectPath: input.projectPath ?? scopedAgentConfig.projectPath,
+      taskGoal: input.userMessage,
+      requestOverrides: scopedAgentConfig.requestOverrides,
+    },
+    input.registry,
+    input.messages,
+    {
+      onText: input.callbacks.onText,
+      onToolCall: () => {},
+      onToolResult: () => {},
+      onToolError: () => {},
+      onToolEvent: input.callbacks.onToolEvent,
+      onActivityEvent: input.callbacks.onActivityEvent,
+      onDone: input.callbacks.onDone,
+      onError: input.callbacks.onError,
+    },
+    input.signal,
+  )
+}

+ 2 - 0
src/lib/agent/capabilities/registry.ts

@@ -51,6 +51,7 @@ const TOOL_LABELS: Record<string, string> = {
   route_task: "Route Task",
   load_context: "Load Context",
   trim_context: "Trim Context",
+  run_chapter_workflow: "Chapter Workflow",
 }
 
 export interface BuildAvailableCapabilitiesOptions {
@@ -107,6 +108,7 @@ function toolIntents(toolName: string): CapabilityIntent[] {
     return ["external_search", ...QUERY_INTENTS, "general"]
   }
   if (toolName === "write_chapter") return WRITING_INTENTS
+  if (toolName === "run_chapter_workflow") return WRITING_INTENTS
   if (toolName === "write_outline_node") return ["generate_outline"]
   if (toolName === "write_memory") return ["extract_memory"]
   if (toolName === "apply_skill") return [...WRITING_INTENTS, ...REVIEW_INTENTS, "generate_outline"]

+ 17 - 0
src/lib/agent/capabilities/selector.spec.ts

@@ -117,6 +117,23 @@ describe("AI capability selector", () => {
     ]))
   })
 
+  it("selects chapter workflow tool for chapter writing intents", () => {
+    const capabilities = buildAvailableCapabilities({
+      toolNames: ["read_chapter", "run_chapter_workflow"],
+      selectedSkills: [],
+      mcpCapabilities: [],
+    })
+
+    const selected = selectCapabilities({
+      capabilities,
+      intent: "write_chapter",
+      mode: "standard",
+      userMessage: "生成第3章",
+    })
+
+    expect(selected.map((item) => item.toolName)).toContain("run_chapter_workflow")
+  })
+
   it("allows strict knowledge tasks to select future MCP placeholders without executing MCP", () => {
     const capabilities = [
       ...buildBuiltInToolCapabilities(toolNames),

+ 2 - 1
src/lib/agent/capabilities/selector.ts

@@ -19,7 +19,7 @@ const KNOWLEDGE_INTENTS = new Set<NovelTaskIntent>([
   "setting_query",
 ])
 
-const FAST_WRITING_TOOLS = new Set(["read_chapter", "read_outline", "load_context", "trim_context"])
+const FAST_WRITING_TOOLS = new Set(["read_chapter", "read_outline", "load_context", "trim_context", "run_chapter_workflow"])
 const STANDARD_WRITING_TOOLS = new Set([
   "read_chapter",
   "read_outline",
@@ -27,6 +27,7 @@ const STANDARD_WRITING_TOOLS = new Set([
   "search_chapters",
   "load_context",
   "trim_context",
+  "run_chapter_workflow",
   "write_chapter",
   "apply_skill",
 ])

+ 3 - 0
src/lib/agent/config.ts

@@ -15,6 +15,7 @@ export const TOOL_UNSUPPORTED_MODEL_PREFIXES: string[] = [
 
 export interface BuildAgentConfigOptions extends ToolFactoryOptions {
   llmConfig: LlmConfig
+  requestOverrides?: AgentConfig["requestOverrides"]
 }
 
 export function modelSupportsTools(modelId: string): boolean {
@@ -44,5 +45,7 @@ export function buildAgentConfig(
     systemPrompt,
     llmConfig: options.llmConfig,
     modelId,
+    projectPath: options.projectPath,
+    requestOverrides: options.requestOverrides,
   }
 }

+ 1 - 0
src/lib/agent/pipeline.ts

@@ -17,6 +17,7 @@ export interface PrePluginInput {
   effectiveTaskRoute?: TaskRouteResult | null
   contextPack?: ContextPack | null
   aiWorkflowMode?: AiWorkflowMode
+  planExecuteEnabled?: boolean
   availableSkills?: UserSkill[]
   selectedSkills?: UserSkill[]
   mcpCapabilities?: AiCapability[]

+ 36 - 0
src/lib/agent/plan-execute-policy.spec.ts

@@ -0,0 +1,36 @@
+import { describe, expect, it } from "vitest"
+import { buildPlanExecutePolicyPrompt, shouldRequirePlan } from "./plan-execute-policy"
+
+describe("Plan Execute policy", () => {
+  it("does not require a plan when Plan Execute is disabled", () => {
+    expect(shouldRequirePlan(false, "fast", "write_chapter")).toBe(false)
+    expect(shouldRequirePlan(false, "standard", "write_chapter")).toBe(false)
+    expect(shouldRequirePlan(false, "strict", "write_chapter")).toBe(false)
+  })
+
+  it("creates a lightweight plan in fast mode when Plan Execute is enabled", () => {
+    expect(shouldRequirePlan(true, "fast", "write_chapter")).toBe(true)
+    expect(buildPlanExecutePolicyPrompt("fast")).toContain("快速模式")
+    expect(buildPlanExecutePolicyPrompt("fast")).toContain("先创建轻量计划")
+  })
+
+  it("requires a short plan in standard mode when Plan Execute is enabled", () => {
+    expect(shouldRequirePlan(true, "standard", "write_chapter")).toBe(true)
+    expect(buildPlanExecutePolicyPrompt("standard")).toContain("先给出简短计划")
+  })
+
+  it("requires plan execute and review in strict mode when Plan Execute is enabled", () => {
+    expect(shouldRequirePlan(true, "strict", "rewrite_chapter")).toBe(true)
+    expect(buildPlanExecutePolicyPrompt("strict")).toContain("必须先计划")
+    expect(buildPlanExecutePolicyPrompt("strict")).toContain("执行后审查")
+  })
+
+  it("requires a user-facing executable plan instead of workflow narration", () => {
+    const prompt = buildPlanExecutePolicyPrompt("standard")
+    expect(prompt).toContain("任务目标")
+    expect(prompt).toContain("已读取依据")
+    expect(prompt).toContain("缺失资料")
+    expect(prompt).toContain("确认后动作")
+    expect(prompt).toContain("不要把工具流程说明当成计划")
+  })
+})

+ 56 - 0
src/lib/agent/plan-execute-policy.ts

@@ -0,0 +1,56 @@
+import type { AiWorkflowMode } from "./workflow-mode"
+
+const WRITING_INTENTS = new Set([
+  "write_chapter",
+  "continue_chapter",
+  "rewrite_chapter",
+  "polish_chapter",
+  "generate_outline",
+])
+
+export function shouldRequirePlan(
+  planExecuteEnabled: boolean,
+  _mode: AiWorkflowMode,
+  intent?: string | null,
+): boolean {
+  if (!planExecuteEnabled) return false
+  return Boolean(intent && WRITING_INTENTS.has(intent))
+}
+
+export function buildPlanExecutePolicyPrompt(mode: AiWorkflowMode): string {
+  const executablePlanFormat = [
+    "计划必须是给用户确认的可执行计划,不要把工具流程说明当成计划。",
+    "计划必须包含:任务目标、已读取依据、缺失资料、执行步骤、确认后动作。",
+    "读取资料前先用 list_chapters、list_outlines、list_memories 确认可用文件;不要凭空编造章节、大纲或记忆条目名称。",
+    "如果资料缺失,必须在“缺失资料”里说明,并基于已读取内容继续制定可执行方案。",
+  ].join("\n")
+
+  if (mode === "fast") {
+    return [
+      "Plan Execute:当前已开启计划执行。",
+      "快速模式:先创建轻量计划,再快速执行。",
+      "计划最多 3 条,只写将要读取和执行的关键步骤。",
+      executablePlanFormat,
+      "如果需要生成、续写、改写或润色章节,优先调用 run_chapter_workflow。",
+    ].join("\n")
+  }
+
+  if (mode === "strict") {
+    return [
+      "Plan Execute:当前已开启计划执行。",
+      "严格模式:必须先计划,再执行,再执行后审查。",
+      "计划必须简短,最多 5 条,只写将要读取和执行的关键步骤。",
+      "执行后审查结果是否满足用户请求、项目设定和输出边界。",
+      executablePlanFormat,
+      "如果是章节生成、续写、改写或润色,必须优先调用 run_chapter_workflow。",
+    ].join("\n")
+  }
+
+  return [
+    "Plan Execute:当前已开启计划执行。",
+    "标准模式:复杂写作任务先给出简短计划,再执行。",
+    "计划最多 5 条,不能替代正文,不能把计划混入最终章节正文。",
+    executablePlanFormat,
+    "如果是章节生成、续写、改写或润色,优先调用 run_chapter_workflow。",
+  ].join("\n")
+}

+ 41 - 0
src/lib/agent/plugins/build-system-prompt-plugin.spec.ts

@@ -36,4 +36,45 @@ describe("BuildSystemPromptPlugin selected skills", () => {
     expect(result.finalSystemPrompt).toContain("三次转折,四次震惊。")
     expect(result.finalSystemPrompt).toContain("task directive")
   })
+
+  it("does not inject chapter plan protocol from standard mode unless Plan Execute is enabled", async () => {
+    const plugin = createBuildSystemPromptPlugin({
+      baseSystemPrompt: "base prompt",
+      buildTaskDirectiveFn: () => "task directive",
+    })
+
+    const result = await plugin.run({
+      userMessage: "帮我写下一章",
+      projectPath: "/project",
+      agentConfig: {} as any,
+      novelMode: true,
+      aiWorkflowMode: "standard",
+      planExecuteEnabled: false,
+      taskRoute: { intent: "write_chapter", confidence: 0.9, extractedParams: {} },
+    })
+
+    expect(result.finalSystemPrompt).not.toContain("章节创作计划协议")
+    expect(result.finalSystemPrompt).not.toContain("chapter_plan")
+  })
+
+  it("injects chapter plan protocol when Plan Execute is enabled with standard mode", async () => {
+    const plugin = createBuildSystemPromptPlugin({
+      baseSystemPrompt: "base prompt",
+      buildTaskDirectiveFn: () => "task directive",
+    })
+
+    const result = await plugin.run({
+      userMessage: "帮我写下一章",
+      projectPath: "/project",
+      agentConfig: {} as any,
+      novelMode: true,
+      aiWorkflowMode: "standard",
+      planExecuteEnabled: true,
+      taskRoute: { intent: "write_chapter", confidence: 0.9, extractedParams: {} },
+    })
+
+    expect(result.finalSystemPrompt).toContain("章节创作计划协议")
+    expect(result.finalSystemPrompt).toContain("chapter_plan")
+    expect(result.finalSystemPrompt).toContain("当前为标准模式")
+  })
 })

+ 33 - 9
src/lib/agent/plugins/build-system-prompt-plugin.ts

@@ -36,9 +36,10 @@ export function createBuildSystemPromptPlugin(deps: BuildSystemPromptPluginDeps
           parts.push(selectedSkillsPrompt)
         }
 
-        if (input.aiWorkflowMode && (input.aiWorkflowMode === "standard" || input.aiWorkflowMode === "strict")) {
-          const isWritingTask = input.effectiveTaskRoute?.intent === "write_chapter" ||
-            input.effectiveTaskRoute?.intent === "continue_chapter"
+        if (input.planExecuteEnabled && input.aiWorkflowMode) {
+          const routeForPlan = input.effectiveTaskRoute || input.taskRoute
+          const isWritingTask = routeForPlan?.intent === "write_chapter" ||
+            routeForPlan?.intent === "continue_chapter"
           if (isWritingTask) {
             parts.push(buildChapterPlanProtocol(input.aiWorkflowMode))
           }
@@ -62,6 +63,31 @@ export function createBuildSystemPromptPlugin(deps: BuildSystemPromptPluginDeps
 }
 
 function buildChapterPlanProtocol(mode: AiWorkflowMode): string {
+  const formatRules = [
+    "计划必须包裹在 `<!-- chapter_plan -->` 和 `<!-- /chapter_plan -->` 标记中。",
+    "计划只写给用户确认,不写正文,不写工具调用日志,不写旧工作流说明。",
+    "计划必须按以下字段组织:",
+    "- 任务目标:本次要生成/续写/改写什么。",
+    "- 已读取依据:列出已经读到的章节、记忆、大纲或快照。",
+    "- 缺失资料:列出未找到的大纲、章节或记忆;不要把缺失资料伪装成已读取。",
+    "- 执行步骤:确认后如何写正文。",
+    "- 确认后动作:用户点击确认后直接写正文,不再重复输出计划。",
+    "读取资料前先用 list_chapters、list_outlines、list_memories 确认可用文件名;不要凭空编造文件名。",
+  ].join("\n")
+
+  if (mode === "fast") {
+    return [
+      "## 章节创作计划协议",
+      "",
+      "当前为快速模式,并已开启 Plan Execute。写下一章时,必须按以下流程执行:",
+      "",
+      "1. 先输出轻量章节创作计划,最多 3 条。",
+      "2. 计划内容必须包含:本章目标、关键事件、结尾处理。",
+      formatRules,
+      "输出计划后暂停,等用户确认后再输出正文。",
+    ].join("\n")
+  }
+
   if (mode === "strict") {
     return [
       "## 章节创作计划协议",
@@ -70,9 +96,8 @@ function buildChapterPlanProtocol(mode: AiWorkflowMode): string {
       "",
       "1. 先输出详细的章节创作计划。",
       "2. 计划内容必须包含:本章目标、核心冲突、出场人物与动机、关键事件顺序、情绪曲线、伏笔推进、结尾钩子。",
-      "3. 计划必须包裹在 `<!-- chapter_plan -->` 和 `<!-- /chapter_plan -->` 标记中。",
-      "4. 输出计划后暂停,等用户确认后再输出正文。",
-      "5. 用户确认后,根据计划写正文,正文中不要混入计划内容。",
+      formatRules,
+      "输出计划后暂停,等用户确认后再输出正文。",
     ].join("\n")
   }
   return [
@@ -82,8 +107,7 @@ function buildChapterPlanProtocol(mode: AiWorkflowMode): string {
     "",
     "1. 先输出简短的章节创作计划。",
     "2. 计划内容包含:本章目标、核心冲突、关键事件、结尾钩子。",
-    "3. 计划必须包裹在 `<!-- chapter_plan -->` 和 `<!-- /chapter_plan -->` 标记中。",
-    "4. 输出计划后暂停,等用户确认后再输出正文。",
-    "5. 用户确认后,根据计划写正文,正文中不要混入计划内容。",
+    formatRules,
+    "输出计划后暂停,等用户确认后再输出正文。",
   ].join("\n")
 }

+ 247 - 1
src/lib/agent/runner.spec.ts

@@ -3,6 +3,7 @@ import { AgentRunner } from "./runner"
 import { ToolRegistry } from "./registry"
 import type { AgentConfig, AgentMessage } from "./types"
 import type { Tool } from "./types"
+import { TOOL_EXECUTE_TIMEOUT_MS } from "./types"
 import type { StreamCallbacks } from "../llm-client"
 import type { LlmConfig } from "@/stores/wiki-store"
 
@@ -125,13 +126,171 @@ describe("AgentRunner", () => {
     const config: AgentConfig = { maxRounds: 3, tools: [tool], systemPrompt: "You are helpful", llmConfig: mockLlmConfig }
     const result = await runner.run(config, registry, [systemMsg, userMsg], callbacks, undefined)
 
-    expect(tool.execute).toHaveBeenCalledWith({ name: "ch1" }, undefined)
+    expect(tool.execute).toHaveBeenCalledWith(
+      { name: "ch1" },
+      undefined,
+      expect.objectContaining({ callId: "call_1", toolName: "read_chapter" }),
+    )
     expect(callbacks.onToolCall).toHaveBeenCalledOnce()
     expect(callbacks.onToolResult).toHaveBeenCalledOnce()
     expect(result.finalText).toBe("Got it!")
     expect(result.roundsUsed).toBe(2)
   })
 
+  it("passes the real tool call id into tool execution context", async () => {
+    const tool: Tool = {
+      name: "read_chapter",
+      description: "read",
+      category: "read",
+      parameters: { name: { type: "string", description: "name" } },
+      execute: vi.fn().mockResolvedValue("Chapter content"),
+    }
+    registry.register(tool)
+
+    let callCount = 0
+    mockStreamChat.mockImplementation(async (_config: unknown, _msgs: unknown[], cb: StreamCallbacks) => {
+      callCount++
+      if (callCount === 1) {
+        cb.onToolCallDelta?.({ index: 0, id: "real_call_1", name: "read_chapter" })
+        cb.onToolCallDelta?.({ index: 0, arguments: '{"name":"ch1"}' })
+        cb.onDone()
+      } else {
+        cb.onToken("done")
+        cb.onDone()
+      }
+    })
+
+    const config: AgentConfig = { maxRounds: 3, tools: [tool], systemPrompt: "", llmConfig: mockLlmConfig }
+    await runner.run(
+      config,
+      registry,
+      [systemMsg, userMsg],
+      { onText: vi.fn(), onToolCall: vi.fn(), onToolResult: vi.fn(), onToolError: vi.fn(), onDone: vi.fn(), onError: vi.fn() },
+      undefined,
+    )
+
+    expect(tool.execute).toHaveBeenCalledWith(
+      { name: "ch1" },
+      undefined,
+      expect.objectContaining({
+        callId: "real_call_1",
+        toolName: "read_chapter",
+      }),
+    )
+  })
+
+  it("passes the tool event emitter into tool execution context", async () => {
+    const tool: Tool = {
+      name: "read_chapter",
+      description: "read",
+      category: "read",
+      parameters: { name: { type: "string", description: "name" } },
+      execute: vi.fn().mockResolvedValue("Chapter content"),
+    }
+    registry.register(tool)
+
+    let callCount = 0
+    mockStreamChat.mockImplementation(async (_config: unknown, _msgs: unknown[], cb: StreamCallbacks) => {
+      callCount++
+      if (callCount === 1) {
+        cb.onToolCallDelta?.({ index: 0, id: "real_call_2", name: "read_chapter" })
+        cb.onToolCallDelta?.({ index: 0, arguments: '{"name":"ch1"}' })
+        cb.onDone()
+      } else {
+        cb.onToken("done")
+        cb.onDone()
+      }
+    })
+
+    const onToolEvent = vi.fn()
+    const config: AgentConfig = { maxRounds: 3, tools: [tool], systemPrompt: "", llmConfig: mockLlmConfig }
+    await runner.run(
+      config,
+      registry,
+      [systemMsg, userMsg],
+      { onText: vi.fn(), onToolCall: vi.fn(), onToolResult: vi.fn(), onToolError: vi.fn(), onDone: vi.fn(), onError: vi.fn(), onToolEvent },
+      undefined,
+    )
+
+    expect(tool.execute).toHaveBeenCalledWith(
+      { name: "ch1" },
+      undefined,
+      expect.objectContaining({
+        callId: "real_call_2",
+        toolName: "read_chapter",
+        onToolEvent,
+      }),
+    )
+  })
+
+  it("passes the activity event emitter into tool execution context", async () => {
+    const tool: Tool = {
+      name: "read_chapter",
+      description: "read",
+      category: "read",
+      parameters: { name: { type: "string", description: "name" } },
+      execute: vi.fn(async (_params, _signal, context) => {
+        context?.onActivityEvent?.({
+          id: "tool-activity",
+          stageId: "read_context",
+          kind: "read_source",
+          title: "读取章节",
+          content: "读取章节《第1章》",
+          toolCallId: context.callId,
+          timestamp: 100,
+        })
+        return "Chapter content"
+      }),
+    }
+    registry.register(tool)
+
+    let callCount = 0
+    mockStreamChat.mockImplementation(async (_config: unknown, _msgs: unknown[], cb: StreamCallbacks) => {
+      callCount++
+      if (callCount === 1) {
+        cb.onToolCallDelta?.({ index: 0, id: "real_call_3", name: "read_chapter" })
+        cb.onToolCallDelta?.({ index: 0, arguments: '{"name":"ch1"}' })
+        cb.onDone()
+      } else {
+        cb.onToken("done")
+        cb.onDone()
+      }
+    })
+
+    const onActivityEvent = vi.fn()
+    const config: AgentConfig = { maxRounds: 3, tools: [tool], systemPrompt: "", llmConfig: mockLlmConfig }
+    await runner.run(
+      config,
+      registry,
+      [systemMsg, userMsg],
+      {
+        onText: vi.fn(),
+        onToolCall: vi.fn(),
+        onToolResult: vi.fn(),
+        onToolError: vi.fn(),
+        onDone: vi.fn(),
+        onError: vi.fn(),
+        onActivityEvent,
+      },
+      undefined,
+    )
+
+    expect(tool.execute).toHaveBeenCalledWith(
+      { name: "ch1" },
+      undefined,
+      expect.objectContaining({
+        callId: "real_call_3",
+        toolName: "read_chapter",
+        onActivityEvent,
+      }),
+    )
+    expect(onActivityEvent).toHaveBeenCalledWith(expect.objectContaining({
+      stageId: "read_context",
+      kind: "read_source",
+      title: "读取章节",
+    }))
+  })
+
   it("does not stream assistant narration from tool-call rounds", async () => {
     const tool: Tool = {
       name: "read_chapter",
@@ -228,6 +387,57 @@ describe("AgentRunner", () => {
     expect(onToolError).toHaveBeenCalledOnce()
   })
 
+  it("allows long-running workflow tools to opt out of the generic 30 second timeout", async () => {
+    vi.useFakeTimers()
+    try {
+      const tool: Tool = {
+        name: "run_chapter_workflow",
+        description: "workflow",
+        category: "action",
+        permission: "auto",
+        executeTimeoutMs: 0,
+        parameters: {},
+        execute: vi.fn(() => new Promise<string>((resolve) => {
+          setTimeout(() => resolve("章节工作流完成"), TOOL_EXECUTE_TIMEOUT_MS + 1000)
+        })),
+      }
+      registry.register(tool)
+
+      let callCount = 0
+      mockStreamChat.mockImplementation(async (_config: unknown, _msgs: unknown[], cb: StreamCallbacks) => {
+        callCount++
+        if (callCount === 1) {
+          cb.onToolCallDelta?.({ index: 0, id: "workflow_1", name: "run_chapter_workflow" })
+          cb.onToolCallDelta?.({ index: 0, arguments: "{}" })
+          cb.onDone()
+        } else {
+          cb.onToken("完成")
+          cb.onDone()
+        }
+      })
+
+      const onToolError = vi.fn()
+      const onToolResult = vi.fn()
+      const config: AgentConfig = { maxRounds: 3, tools: [tool], systemPrompt: "", llmConfig: mockLlmConfig }
+      const runPromise = runner.run(
+        config,
+        registry,
+        [systemMsg, userMsg],
+        { onText: vi.fn(), onToolCall: vi.fn(), onToolResult, onToolError, onDone: vi.fn(), onError: vi.fn() },
+        undefined,
+      )
+
+      await vi.advanceTimersByTimeAsync(TOOL_EXECUTE_TIMEOUT_MS + 1000)
+      const result = await runPromise
+
+      expect(onToolError).not.toHaveBeenCalled()
+      expect(onToolResult).toHaveBeenCalledWith("workflow_1", "章节工作流完成")
+      expect(result.toolCalls[0].status).toBe("done")
+    } finally {
+      vi.useRealTimers()
+    }
+  })
+
   it("executes confirm-required write tools for preview and sets approval_required status", async () => {
     const tool: Tool = {
       name: "write_chapter",
@@ -374,6 +584,42 @@ describe("AgentRunner", () => {
     )
   })
 
+  it("retries a reasoning-only model round once with reasoning disabled", async () => {
+    const reasoningOnlyError = new Error("模型只输出了 543 字符的思考内容,但没有输出正文。")
+    mockStreamChat.mockImplementationOnce(async (_config: unknown, _msgs: unknown[], cb: StreamCallbacks) => {
+      cb.onError(reasoningOnlyError)
+    })
+    mockStreamChat.mockImplementationOnce(async (_config: unknown, _msgs: unknown[], cb: StreamCallbacks) => {
+      cb.onToken("章节正文")
+      cb.onDone()
+    })
+
+    const callbacks = {
+      onText: vi.fn(),
+      onToolCall: vi.fn(),
+      onToolResult: vi.fn(),
+      onToolError: vi.fn(),
+      onDone: vi.fn(),
+      onError: vi.fn(),
+    }
+    const config: AgentConfig = {
+      maxRounds: 3,
+      tools: [],
+      systemPrompt: "",
+      llmConfig: { ...mockLlmConfig, reasoning: { mode: "high" } },
+    }
+
+    const result = await runner.run(config, registry, [systemMsg, userMsg], callbacks, undefined)
+
+    expect(mockStreamChat).toHaveBeenCalledTimes(2)
+    expect(mockStreamChat.mock.calls[0][4]).toBeUndefined()
+    expect(mockStreamChat.mock.calls[1][4]).toEqual({ reasoning: { mode: "off" } })
+    expect(result.finalText).toBe("章节正文")
+    expect(callbacks.onText).toHaveBeenCalledWith("章节正文")
+    expect(callbacks.onDone).toHaveBeenCalledOnce()
+    expect(callbacks.onError).not.toHaveBeenCalled()
+  })
+
   describe("Stage F 断点保存与清理", () => {
     it("config.projectPath 存在时创建并保存断点", async () => {
       const tool: Tool = {

+ 47 - 14
src/lib/agent/runner.ts

@@ -14,6 +14,7 @@ import {
   updateBreakpointStage,
 } from "./task-breakpoint"
 import type { ChatMessage } from "../llm-providers"
+import { isReasoningDisabled, isReasoningOnlyResponseError, withReasoningDisabled } from "../reasoning-retry"
 
 export class ModelDoesNotSupportToolsError extends Error {
   constructor() {
@@ -22,6 +23,17 @@ export class ModelDoesNotSupportToolsError extends Error {
   }
 }
 
+function withToolTimeout<T>(operation: Promise<T>, timeoutMs: number | undefined): Promise<T> {
+  const resolvedTimeoutMs = timeoutMs ?? TOOL_EXECUTE_TIMEOUT_MS
+  if (resolvedTimeoutMs <= 0) return operation
+  return Promise.race([
+    operation,
+    new Promise<never>((_, reject) =>
+      setTimeout(() => reject(new Error("工具执行超时")), resolvedTimeoutMs),
+    ),
+  ])
+}
+
 export class AgentRunner {
   async run(
     config: AgentConfig,
@@ -109,10 +121,12 @@ export class AgentRunner {
       }
 
       const openaiTools = config.tools.length > 0 ? toOpenAITools(config.tools) : undefined
-      const requestOverrides = openaiTools
-        ? { ...config.requestOverrides, tools: openaiTools as any, toolChoice: "auto" as const }
-        : config.requestOverrides
-      try {
+      const buildRequestOverrides = (baseOverrides = config.requestOverrides) =>
+        openaiTools
+          ? { ...baseOverrides, tools: openaiTools as any, toolChoice: "auto" as const }
+          : baseOverrides
+      let requestOverrides = buildRequestOverrides()
+      const streamRound = async () => {
         await streamChat(
           config.llmConfig,
           workingMessages as ChatMessage[],
@@ -120,6 +134,9 @@ export class AgentRunner {
           signal,
           requestOverrides,
         )
+      }
+      try {
+        await streamRound()
       } catch (err) {
         const msg = err instanceof Error ? err.message : String(err)
         if (openaiTools && /tool|function.?call|unsupported|不支持工具/i.test(msg)) {
@@ -131,6 +148,23 @@ export class AgentRunner {
         return record
       }
 
+      if (
+        streamError &&
+        isReasoningOnlyResponseError(streamError) &&
+        !isReasoningDisabled(config.llmConfig, requestOverrides)
+      ) {
+        roundText = ""
+        toolCallDeltas.length = 0
+        streamError = undefined
+        requestOverrides = buildRequestOverrides(withReasoningDisabled(config.requestOverrides))
+        try {
+          await streamRound()
+        } catch (err) {
+          callbacks.onError(err instanceof Error ? err : new Error(String(err)))
+          return record
+        }
+      }
+
       if (streamError) {
         callbacks.onError(streamError)
         return record
@@ -190,6 +224,12 @@ export class AgentRunner {
         }
 
         const callbackToolCall: ToolCall = { id: tc.id, name: toolName, arguments: params }
+        const executionContext = {
+          callId: tc.id,
+          toolName,
+          onToolEvent: callbacks.onToolEvent,
+          onActivityEvent: callbacks.onActivityEvent,
+        }
         callbacks.onToolCall(callbackToolCall)
         callbacks.onToolEvent?.({
           type: "call_started",
@@ -224,12 +264,7 @@ export class AgentRunner {
           let preview = ""
           try {
             const previewFn = tool.generatePreview ?? tool.execute
-            preview = await Promise.race([
-              previewFn(params, signal),
-              new Promise<never>((_, reject) =>
-                setTimeout(() => reject(new Error("工具执行超时")), TOOL_EXECUTE_TIMEOUT_MS),
-              ),
-            ])
+            preview = await withToolTimeout(previewFn(params, signal, executionContext), tool.executeTimeoutMs)
           } catch (e) {
             preview = `预览生成失败:${e instanceof Error ? e.message : String(e)}`
           }
@@ -253,11 +288,9 @@ export class AgentRunner {
         }
 
         try {
-          const result = await Promise.race([
-            tool.execute(params, signal),
-            new Promise<never>((_, reject) => setTimeout(() => reject(new Error("工具执行超时")), TOOL_EXECUTE_TIMEOUT_MS)),
-          ])
+          const result = await withToolTimeout(tool.execute(params, signal, executionContext), tool.executeTimeoutMs)
           toolCallRecord.result = result
+          toolCallRecord.status = "done"
           toolCallRecord.finishedAt = Date.now()
           callbacks.onToolResult(tc.id, result)
           callbacks.onToolEvent?.({

+ 77 - 1
src/lib/agent/tool-events.spec.ts

@@ -1,5 +1,10 @@
 import { describe, expect, it } from "vitest"
-import { applyAgentToolEvent, settleRunningAgentToolCalls } from "./tool-events"
+import {
+  activityEventFromAgentToolEvent,
+  applyAgentToolActivityEvent,
+  applyAgentToolEvent,
+  settleRunningAgentToolCalls,
+} from "./tool-events"
 
 describe("applyAgentToolEvent", () => {
   it("creates and updates tool call records from normalized events", () => {
@@ -54,6 +59,24 @@ describe("applyAgentToolEvent", () => {
     expect(records[0].result).toContain("需要用户确认")
   })
 
+  it("preserves parent call metadata for workflow child events", () => {
+    const records = applyAgentToolEvent(undefined, {
+      type: "call_started",
+      callId: "chapter-context",
+      parentCallId: "chapter-workflow",
+      name: "chapter_context",
+      params: { title: "读取上下文" },
+      timestamp: 100,
+    })
+
+    expect(records[0]).toMatchObject({
+      id: "chapter-context",
+      name: "chapter_context",
+      status: "running",
+      parentCallId: "chapter-workflow",
+    })
+  })
+
   it("settles leftover running tool calls when an agent session finishes", () => {
     const records = settleRunningAgentToolCalls([
       {
@@ -85,4 +108,57 @@ describe("applyAgentToolEvent", () => {
       finishedAt: 130,
     })
   })
+
+  it("converts read tools into read_source activity events", () => {
+    const event = activityEventFromAgentToolEvent({
+      type: "result",
+      callId: "read-1",
+      name: "read_chapter",
+      params: { chapter: "第13章" },
+      result: "第13章结尾:主角发现铜铃线索。",
+      timestamp: 100,
+    })
+
+    expect(event).toMatchObject({
+      stageId: "read_context",
+      kind: "read_source",
+      title: "调用完成:read_chapter",
+      toolCallId: "read-1",
+    })
+    expect(event.content).toContain("铜铃线索")
+  })
+
+  it("converts workflow child events into chapter workflow activities", () => {
+    const event = activityEventFromAgentToolEvent({
+      type: "result",
+      callId: "workflow-1:chapter_task_brief",
+      parentCallId: "workflow-1",
+      name: "chapter_task_brief",
+      params: { title: "生成写作任务书" },
+      result: "写作任务书完成。",
+      timestamp: 200,
+    })
+
+    expect(event).toMatchObject({
+      stageId: "chapter_workflow",
+      kind: "stage_output",
+      title: "生成写作任务书",
+    })
+  })
+
+  it("applies tool activity events to stage traces", () => {
+    const stages = applyAgentToolActivityEvent(undefined, {
+      type: "call_started",
+      callId: "w1",
+      name: "write_chapter",
+      params: { title: "第14章" },
+      timestamp: 100,
+    })
+
+    expect(stages[0]).toMatchObject({
+      id: "write_confirmation",
+      title: "写入确认",
+      status: "running",
+    })
+  })
 })

+ 29 - 1
src/lib/agent/tool-events.ts

@@ -1,4 +1,5 @@
-import type { AgentRunRecord, AgentToolEvent } from "./types"
+import type { AgentRunRecord, AgentStageTrace, AgentToolEvent } from "./types"
+import { activityEventFromToolEvent, applyAgentActivityEvent } from "./activity-trace"
 
 export type ToolCallRecord = AgentRunRecord["toolCalls"][number]
 type SettledToolCallStatus = "done" | "error" | "cancelled"
@@ -20,9 +21,11 @@ export function applyAgentToolEvent(
 
   const nextRecord: ToolCallRecord = {
     id: event.callId,
+    parentCallId: event.parentCallId,
     name: event.name,
     params: event.params,
     result: event.result ?? "",
+    preview: event.preview,
     status,
     startedAt: event.timestamp,
     finishedAt: event.type === "call_started" ? 0 : event.timestamp,
@@ -36,6 +39,8 @@ export function applyAgentToolEvent(
     if (index !== existingIndex) return record
     return {
       ...record,
+      parentCallId: event.parentCallId ?? record.parentCallId,
+      preview: event.preview ?? record.preview,
       name: event.name,
       params: event.params,
       result: event.result ?? record.result,
@@ -62,3 +67,26 @@ export function settleRunningAgentToolCalls(
     }
   })
 }
+
+export function activityEventFromAgentToolEvent(event: AgentToolEvent) {
+  const activity = activityEventFromToolEvent(event)
+  const titleFromParams = typeof event.params.title === "string" ? event.params.title : ""
+
+  if (event.name.startsWith("chapter_")) {
+    return {
+      ...activity,
+      stageId: "chapter_workflow",
+      kind: event.type === "result" ? "stage_output" as const : event.type === "error" ? "error" as const : activity.kind,
+      title: titleFromParams || activity.title,
+    }
+  }
+
+  return activity
+}
+
+export function applyAgentToolActivityEvent(
+  stages: AgentStageTrace[] | undefined,
+  event: AgentToolEvent,
+): AgentStageTrace[] {
+  return applyAgentActivityEvent(stages, activityEventFromAgentToolEvent(event))
+}

+ 17 - 1
src/lib/agent/tools/index.spec.ts

@@ -1,4 +1,4 @@
-import { describe, expect, it } from "vitest"
+import { describe, expect, it, vi } from "vitest"
 import { ToolRegistry } from "../registry"
 import { registerAllBuiltInTools } from "./index"
 
@@ -94,4 +94,20 @@ describe("registerAllBuiltInTools", () => {
     expect(registry.has("mcp_graph_query")).toBe(true)
     expect(registry.has("read_chapter")).toBe(false)
   })
+
+  it("registers the chapter workflow tool when dependencies are provided", () => {
+    const registry = new ToolRegistry()
+
+    registerAllBuiltInTools(registry, {
+      ...baseOptions,
+      projectPath: "/project",
+      llmConfig: {} as any,
+      aiWorkflowMode: "standard",
+      runDeepChapterGeneration: vi.fn() as any,
+      getUserSkills: () => [],
+      getSearchApiConfig: () => null,
+    })
+
+    expect(registry.has("run_chapter_workflow")).toBe(true)
+  })
 })

+ 24 - 3
src/lib/agent/tools/index.ts

@@ -1,5 +1,6 @@
 import type { ToolRegistry } from "../registry"
-import type { Tool } from "../types"
+import type { AgentToolEvent, Tool } from "../types"
+import type { AiWorkflowMode } from "@/lib/agent/workflow-mode"
 import { createReadChapterTool } from "./read-chapter"
 import { createReadOutlineTool } from "./read-outline"
 import { createReadMemoryTool } from "./read-memory"
@@ -18,12 +19,13 @@ import { createApplySkillTool } from "./apply-skill"
   import { createWebSearchTool } from "./web-search"
   import { createReadWebPageTool } from "./read-web-page"
   import { createSummarizeSearchResultsTool } from "./summarize-search-results"
-  import { createRouteTaskTool } from "./route-task"
+import { createRouteTaskTool } from "./route-task"
 import { createLoadContextTool } from "./load-context"
 import { createTrimContextTool } from "./trim-context"
+import { createRunChapterWorkflowTool, type RunDeepChapterGeneration } from "./run-chapter-workflow"
 import type { DeAiSkillConfig } from "@/lib/novel/de-ai-skill-library"
 import type { UserSkill } from "@/lib/novel/skill-library"
-import type { SearchApiConfig } from "@/stores/wiki-store"
+import type { LlmConfig, SearchApiConfig } from "@/stores/wiki-store"
 import type { TaskRouteResult } from "@/lib/novel/task-router"
 import type { ContextPack } from "@/lib/novel/context-engine"
 
@@ -50,6 +52,10 @@ export interface ToolFactoryOptions {
   sourceMessageId?: string
   enabledToolNames?: string[]
   disabledTools?: string[]
+  llmConfig?: LlmConfig
+  aiWorkflowMode?: AiWorkflowMode
+  runDeepChapterGeneration?: RunDeepChapterGeneration
+  onToolEvent?: (event: AgentToolEvent) => void
 }
 
 export function registerAllBuiltInTools(registry: ToolRegistry, options: ToolFactoryOptions): void {
@@ -87,6 +93,21 @@ export function registerAllBuiltInTools(registry: ToolRegistry, options: ToolFac
   if (shouldRegister("web_search")) registry.register(createWebSearchTool(options.getSearchApiConfig))
   if (shouldRegister("read_web_page")) registry.register(createReadWebPageTool())
   if (shouldRegister("summarize_search_results")) registry.register(createSummarizeSearchResultsTool())
+  if (
+    shouldRegister("run_chapter_workflow") &&
+    options.projectPath &&
+    options.llmConfig &&
+    options.aiWorkflowMode &&
+    options.runDeepChapterGeneration
+  ) {
+    registry.register(createRunChapterWorkflowTool({
+      projectPath: options.projectPath,
+      llmConfig: options.llmConfig,
+      aiWorkflowMode: options.aiWorkflowMode,
+      runDeepChapterGeneration: options.runDeepChapterGeneration,
+      onToolEvent: options.onToolEvent,
+    }))
+  }
   for (const tool of options.mcpTools ?? []) {
     if (shouldRegister(tool.name)) registry.register(tool)
   }

+ 2 - 11
src/lib/agent/tools/read-chapter.ts

@@ -1,5 +1,5 @@
 import type { Tool } from "../types"
-import { readFile } from "@/commands/fs"
+import { readMarkdownResource } from "./read-markdown-resource"
 
 export function createReadChapterTool(chaptersDir: string): Tool {
   return {
@@ -10,15 +10,6 @@ export function createReadChapterTool(chaptersDir: string): Tool {
       name: { type: "string", description: "章节名称,系统会自动查找对应 .md 文件" },
       path: { type: "string", description: "章节文件的完整路径(可选,与 name 二选一)" },
     },
-    execute: async (params) => {
-      const name = params.name as string | undefined
-      const path = params.path as string | undefined
-      const filePath = path || `${chaptersDir}/${name}.md`
-      try {
-        return await readFile(filePath)
-      } catch {
-        return `错误:无法读取章节「${name || path}」,请确认文件存在`
-      }
-    },
+    execute: async (params) => readMarkdownResource(chaptersDir, params, "章节"),
   }
 }

+ 180 - 0
src/lib/agent/tools/read-markdown-resource.ts

@@ -0,0 +1,180 @@
+import { listDirectory, readFile } from "@/commands/fs"
+import type { FileNode } from "@/types/wiki"
+
+interface MarkdownCandidate {
+  name: string
+  path: string
+}
+
+interface DirectoryCandidate {
+  name: string
+  path: string
+  children: MarkdownCandidate[]
+}
+
+function ensureMarkdownName(name: string): string {
+  return name.toLowerCase().endsWith(".md") ? name : `${name}.md`
+}
+
+function stripMarkdownExt(name: string): string {
+  return name.replace(/\.md$/i, "")
+}
+
+function normalizeResourceName(value: string): string {
+  return stripMarkdownExt(value)
+    .toLowerCase()
+    .replace(/[\s\-_\u2013\u2014,,、.。::;;"'“”‘’《》<>【】\[\]()(){}]/g, "")
+}
+
+function extractChapterNumber(value: string): number | null {
+  const raw = stripMarkdownExt(value).toLowerCase()
+  const patterns = [
+    /第\s*0*(\d{1,5})\s*章/,
+    /chapter[\s\-_]*0*(\d{1,5})/,
+    /\bch[\s\-_]*0*(\d{1,5})\b/,
+  ]
+  for (const pattern of patterns) {
+    const match = raw.match(pattern)
+    if (match?.[1]) return Number.parseInt(match[1], 10)
+  }
+  return null
+}
+
+async function safeListDirectory(path: string): Promise<FileNode[]> {
+  try {
+    return await listDirectory(path)
+  } catch {
+    return []
+  }
+}
+
+async function collectMarkdownCandidates(
+  rootDir: string,
+  depth = 0,
+  maxDepth = 3,
+): Promise<{ files: MarkdownCandidate[]; directories: DirectoryCandidate[] }> {
+  const entries = await safeListDirectory(rootDir)
+  const files: MarkdownCandidate[] = []
+  const directories: DirectoryCandidate[] = []
+
+  for (const entry of entries) {
+    if (!entry.is_dir) {
+      if (entry.name.toLowerCase().endsWith(".md")) {
+        files.push({ name: stripMarkdownExt(entry.name), path: entry.path })
+      }
+      continue
+    }
+
+    if (depth >= maxDepth) {
+      directories.push({ name: entry.name, path: entry.path, children: [] })
+      continue
+    }
+
+    const childResult = await collectMarkdownCandidates(entry.path, depth + 1, maxDepth)
+    directories.push({ name: entry.name, path: entry.path, children: childResult.files })
+    files.push(...childResult.files)
+    directories.push(...childResult.directories)
+  }
+
+  return { files, directories }
+}
+
+function scoreCandidate(query: string, candidate: MarkdownCandidate): number {
+  const normalizedQuery = normalizeResourceName(query)
+  const normalizedName = normalizeResourceName(candidate.name)
+  if (!normalizedQuery || !normalizedName) return 0
+  if (normalizedName === normalizedQuery) return 100
+  if (normalizedName.includes(normalizedQuery) || normalizedQuery.includes(normalizedName)) return 80
+
+  const chapterMatch = normalizedQuery.match(/第?(\d+)章/)
+  if (chapterMatch && normalizedName.includes(chapterMatch[1]) && normalizedName.includes("章")) {
+    return 60
+  }
+
+  const queryChapterNumber = extractChapterNumber(query)
+  const candidateChapterNumber = extractChapterNumber(candidate.name)
+  if (
+    queryChapterNumber !== null &&
+    candidateChapterNumber !== null &&
+    queryChapterNumber === candidateChapterNumber
+  ) {
+    return 70
+  }
+
+  return 0
+}
+
+function formatCandidateList(candidates: MarkdownCandidate[], limit = 8): string {
+  return candidates
+    .slice(0, limit)
+    .map((candidate, index) => `${index + 1}. ${candidate.name}`)
+    .join("\n")
+}
+
+function pickSingleMatch(query: string, candidates: MarkdownCandidate[]): MarkdownCandidate | null {
+  const ranked = candidates
+    .map((candidate) => ({ candidate, score: scoreCandidate(query, candidate) }))
+    .filter((item) => item.score > 0)
+    .sort((a, b) => b.score - a.score)
+
+  if (ranked.length === 0) return null
+  if (ranked.length === 1) return ranked[0].candidate
+  return ranked[0].score > ranked[1].score ? ranked[0].candidate : null
+}
+
+function findDirectoryMatch(query: string, directories: DirectoryCandidate[]): DirectoryCandidate | null {
+  const normalizedQuery = normalizeResourceName(query)
+  return directories.find((directory) => normalizeResourceName(directory.name) === normalizedQuery) ?? null
+}
+
+export async function readMarkdownResource(
+  baseDir: string,
+  params: Record<string, unknown>,
+  label: string,
+): Promise<string> {
+  const name = typeof params.name === "string" ? params.name.trim() : ""
+  const explicitPath = typeof params.path === "string" ? params.path.trim() : ""
+  const displayName = name || explicitPath
+
+  if (explicitPath) {
+    try {
+      return await readFile(explicitPath)
+    } catch {
+      return `错误:无法读取${label}「${displayName}」,请确认文件存在`
+    }
+  }
+
+  if (!name) {
+    return `错误:缺少${label}名称或文件路径`
+  }
+
+  const directPath = `${baseDir}/${ensureMarkdownName(name)}`
+  try {
+    return await readFile(directPath)
+  } catch {
+    // 继续用目录候选纠错。
+  }
+
+  const { files, directories } = await collectMarkdownCandidates(baseDir)
+  const directoryMatch = findDirectoryMatch(name, directories)
+  if (directoryMatch) {
+    const nestedList = formatCandidateList(directoryMatch.children)
+    return nestedList
+      ? `「${name}」是目录,不是单个${label}。可读取以下条目:\n${nestedList}`
+      : `「${name}」是目录,但目录下没有找到可读取的 .md 条目。`
+  }
+
+  const singleMatch = pickSingleMatch(name, files)
+  if (singleMatch) {
+    try {
+      return await readFile(singleMatch.path)
+    } catch {
+      return `错误:已匹配到${label}「${singleMatch.name}」,但无法读取文件,请确认文件存在`
+    }
+  }
+
+  const available = formatCandidateList(files)
+  return available
+    ? `错误:无法读取${label}「${displayName}」。可用候选:\n${available}`
+    : `错误:无法读取${label}「${displayName}」,请确认文件存在`
+}

+ 77 - 7
src/lib/agent/tools/read-memory.ts

@@ -1,6 +1,77 @@
 import type { Tool } from "../types"
+import { readMarkdownResource } from "./read-markdown-resource"
 import { readFile } from "@/commands/fs"
 
+const MEMORY_ALIAS_FILES: Array<{ fileName: string; label: string; patterns: RegExp[] }> = [
+  {
+    fileName: "character-states.md",
+    label: "人物状态记忆",
+    patterns: [/人物状态/, /角色状态/, /character[-_\s]?states?/i],
+  },
+  {
+    fileName: "foreshadowing-tracker.md",
+    label: "伏笔追踪记忆",
+    patterns: [/伏笔/, /线索/, /foreshadow/i],
+  },
+  {
+    fileName: "character-cognition.md",
+    label: "人物认知记忆",
+    patterns: [/人物认知/, /角色认知/, /认知状态/, /cognition/i],
+  },
+  {
+    fileName: "timeline.md",
+    label: "时间线记忆",
+    patterns: [/时间线/, /timeline/i],
+  },
+  {
+    fileName: "canon-facts.md",
+    label: "事实设定记忆",
+    patterns: [/事实/, /设定/, /canon/i],
+  },
+  {
+    fileName: "conflicts.md",
+    label: "冲突记忆",
+    patterns: [/冲突/, /矛盾/, /conflicts?/i],
+  },
+  {
+    fileName: "chapter-snapshots.md",
+    label: "章节快照记忆",
+    patterns: [/章节快照/, /章节摘要/, /chapter[-_\s]?snapshots?/i],
+  },
+]
+
+function resolveMemoryAliasFiles(name: string): Array<{ fileName: string; label: string }> {
+  const matches = MEMORY_ALIAS_FILES.filter((entry) =>
+    entry.patterns.some((pattern) => pattern.test(name)),
+  ).map(({ fileName, label }) => ({ fileName, label }))
+  const seen = new Set<string>()
+  return matches.filter((entry) => {
+    if (seen.has(entry.fileName)) return false
+    seen.add(entry.fileName)
+    return true
+  })
+}
+
+async function readMemoryAliases(memoryDir: string, name: string): Promise<string | null> {
+  const aliasFiles = resolveMemoryAliasFiles(name)
+  if (aliasFiles.length === 0) return null
+
+  const sections: string[] = []
+  for (const alias of aliasFiles) {
+    try {
+      const content = await readFile(`${memoryDir}/${alias.fileName}`)
+      if (content.trim()) {
+        sections.push(`## ${alias.label}\n\n${content}`)
+      }
+    } catch {
+      // 缺失的结构化记忆文件交给通用读取兜底处理。
+    }
+  }
+
+  if (sections.length === 0) return null
+  return `已读取记忆条目「${name}」对应的结构化记忆:\n\n${sections.join("\n\n---\n\n")}`
+}
+
 export function createReadMemoryTool(memoryDir: string): Tool {
   return {
     name: "read_memory",
@@ -11,14 +82,13 @@ export function createReadMemoryTool(memoryDir: string): Tool {
       path: { type: "string", description: "记忆文件的完整路径(可选,与 name 二选一)" },
     },
     execute: async (params) => {
-      const name = params.name as string | undefined
-      const path = params.path as string | undefined
-      const filePath = path || `${memoryDir}/${name}.md`
-      try {
-        return await readFile(filePath)
-      } catch {
-        return `错误:无法读取记忆条目「${name || path}」,请确认文件存在`
+      const hasExplicitPath = typeof params.path === "string" && params.path.trim()
+      const name = typeof params.name === "string" ? params.name.trim() : ""
+      if (!hasExplicitPath && name) {
+        const aliasResult = await readMemoryAliases(memoryDir, name)
+        if (aliasResult) return aliasResult
       }
+      return readMarkdownResource(memoryDir, params, "记忆条目")
     },
   }
 }

+ 61 - 9
src/lib/agent/tools/read-outline.ts

@@ -1,5 +1,56 @@
 import type { Tool } from "../types"
-import { readFile } from "@/commands/fs"
+import { readMarkdownResource } from "./read-markdown-resource"
+import { listDirectory, readFile } from "@/commands/fs"
+
+function deriveProjectPathFromOutlinesDir(outlinesDir: string): string | null {
+  const normalized = outlinesDir.replace(/\\/g, "/").replace(/\/$/, "")
+  const match = normalized.match(/^(.*)\/(?:wiki|QM)\/outlines$/i)
+  return match?.[1] ?? null
+}
+
+function outlineSnapshotOrder(name: string): number {
+  const match = name.match(/^outline-(\d+)\.snapshot\.md$/i)
+  return match?.[1] ? Number.parseInt(match[1], 10) : Number.MAX_SAFE_INTEGER
+}
+
+async function readOutlineSnapshots(outlinesDir: string): Promise<string | null> {
+  const projectPath = deriveProjectPathFromOutlinesDir(outlinesDir)
+  if (!projectPath) return null
+
+  const snapshotDir = `${projectPath}/.novel/snapshots`
+  let files: Array<{ name: string; path: string }> = []
+  try {
+    const nodes = await listDirectory(snapshotDir)
+    files = nodes
+      .filter((node) => !node.is_dir && /^outline-\d+\.snapshot\.md$/i.test(node.name))
+      .map((node) => ({ name: node.name, path: node.path }))
+      .sort((a, b) => outlineSnapshotOrder(a.name) - outlineSnapshotOrder(b.name))
+      .slice(0, 8)
+  } catch {
+    return null
+  }
+
+  if (files.length === 0) return null
+
+  const sections: string[] = []
+  for (const file of files) {
+    try {
+      const content = await readFile(file.path)
+      if (content.trim()) {
+        sections.push(`## ${file.name}\n\n${content}`)
+      }
+    } catch {
+      // 单个快照读取失败不影响其他快照。
+    }
+  }
+
+  if (sections.length === 0) return null
+  return [
+    "未找到 wiki/outlines 下的独立大纲文件,已读取大纲快照:",
+    "",
+    ...sections,
+  ].join("\n")
+}
 
 export function createReadOutlineTool(outlinesDir: string): Tool {
   return {
@@ -11,14 +62,15 @@ export function createReadOutlineTool(outlinesDir: string): Tool {
       path: { type: "string", description: "大纲文件完整路径(可选,与 name 二选一)" },
     },
     execute: async (params) => {
-      const name = params.name as string | undefined
-      const path = params.path as string | undefined
-      const filePath = path || `${outlinesDir}/${name}.md`
-      try {
-        return await readFile(filePath)
-      } catch {
-        return `错误:无法读取大纲「${name || path}」,请确认文件存在`
-      }
+      const result = await readMarkdownResource(outlinesDir, params, "大纲")
+      if (!result.startsWith("错误:无法读取大纲")) return result
+
+      const name = typeof params.name === "string" ? params.name : ""
+      const path = typeof params.path === "string" ? params.path : ""
+      const broadOutlineRequest = /大纲|outline/i.test(`${name} ${path}`)
+      if (!broadOutlineRequest) return result
+
+      return (await readOutlineSnapshots(outlinesDir)) ?? result
     },
   }
 }

+ 196 - 4
src/lib/agent/tools/read-tools.spec.ts

@@ -9,9 +9,10 @@ import { createSearchChaptersTool } from "./search-chapters"
 
 vi.mock("@/commands/fs", () => ({
   readFile: vi.fn(),
+  listDirectory: vi.fn(),
 }))
 
-import { readFile } from "@/commands/fs"
+import { listDirectory, readFile } from "@/commands/fs"
 
 describe("read tools", () => {
   beforeEach(() => {
@@ -26,6 +27,41 @@ describe("read tools", () => {
     expect(readFile).toHaveBeenCalledWith("/project/wiki/chapters/第1章.md")
   })
 
+  it("read_chapter falls back to matching available chapter names when the model uses a natural title", async () => {
+    vi.mocked(readFile).mockImplementation(async (path) => {
+      if (path === "/project/wiki/chapters/第20章 兄弟.md") return "第20章正文"
+      throw new Error("missing")
+    })
+    vi.mocked(listDirectory).mockResolvedValue([
+      { name: "第20章 兄弟.md", path: "/project/wiki/chapters/第20章 兄弟.md", is_dir: false },
+    ])
+
+    const tool = createReadChapterTool("/project/wiki/chapters")
+    const result = await tool.execute({ name: "第20章-兄弟" })
+
+    expect(result).toBe("第20章正文")
+    expect(readFile).toHaveBeenCalledWith("/project/wiki/chapters/第20章-兄弟.md")
+    expect(readFile).toHaveBeenCalledWith("/project/wiki/chapters/第20章 兄弟.md")
+  })
+
+  it("read_chapter matches Chinese chapter requests to zero-padded English chapter files", async () => {
+    vi.mocked(readFile).mockImplementation(async (path) => {
+      if (path === "/project/wiki/chapters/chapter-020.md") return "第二十章正文"
+      throw new Error("missing")
+    })
+    vi.mocked(listDirectory).mockResolvedValue([
+      { name: "chapter-018.md", path: "/project/wiki/chapters/chapter-018.md", is_dir: false },
+      { name: "chapter-019.md", path: "/project/wiki/chapters/chapter-019.md", is_dir: false },
+      { name: "chapter-020.md", path: "/project/wiki/chapters/chapter-020.md", is_dir: false },
+    ])
+
+    const tool = createReadChapterTool("/project/wiki/chapters")
+    const result = await tool.execute({ name: "第20章-众生相" })
+
+    expect(result).toBe("第二十章正文")
+    expect(readFile).toHaveBeenCalledWith("/project/wiki/chapters/chapter-020.md")
+  })
+
   it("read_memory reads from memory dir", async () => {
     vi.mocked(readFile).mockResolvedValue("memory content")
     const tool = createReadMemoryTool("/project/wiki/memory")
@@ -34,6 +70,70 @@ describe("read tools", () => {
     expect(readFile).toHaveBeenCalledWith("/project/wiki/memory/曙光组织.md")
   })
 
+  it("read_memory reads a nested memory entry by matching available files", async () => {
+    vi.mocked(readFile).mockImplementation(async (path) => {
+      if (path === "/project/wiki/memory/人物状态/主角状态.md") return "主角状态内容"
+      throw new Error("missing")
+    })
+    vi.mocked(listDirectory).mockImplementation(async (path) => {
+      if (path === "/project/wiki/memory") {
+        return [{ name: "人物状态", path: "/project/wiki/memory/人物状态", is_dir: true }]
+      }
+      if (path === "/project/wiki/memory/人物状态") {
+        return [{ name: "主角状态.md", path: "/project/wiki/memory/人物状态/主角状态.md", is_dir: false }]
+      }
+      return []
+    })
+
+    const tool = createReadMemoryTool("/project/wiki/memory")
+    const result = await tool.execute({ name: "主角状态" })
+
+    expect(result).toBe("主角状态内容")
+    expect(readFile).toHaveBeenCalledWith("/project/wiki/memory/主角状态.md")
+    expect(readFile).toHaveBeenCalledWith("/project/wiki/memory/人物状态/主角状态.md")
+  })
+
+  it("read_memory resolves Chinese combined memory aliases to structured memory files", async () => {
+    vi.mocked(readFile).mockImplementation(async (path) => {
+      if (path === "/project/wiki/memory/character-states.md") return "# 人物状态记忆"
+      if (path === "/project/wiki/memory/foreshadowing-tracker.md") return "# 伏笔追踪记忆"
+      throw new Error("missing")
+    })
+
+    const tool = createReadMemoryTool("/project/wiki/memory")
+    const result = await tool.execute({ name: "人物状态与伏笔" })
+
+    expect(result).toContain("人物状态记忆")
+    expect(result).toContain("伏笔追踪记忆")
+    expect(result).not.toContain("请确认文件存在")
+    expect(readFile).toHaveBeenCalledWith("/project/wiki/memory/character-states.md")
+    expect(readFile).toHaveBeenCalledWith("/project/wiki/memory/foreshadowing-tracker.md")
+  })
+
+  it("read_memory reports nested candidates when the requested name is a directory category", async () => {
+    vi.mocked(readFile).mockRejectedValue(new Error("missing"))
+    vi.mocked(listDirectory).mockImplementation(async (path) => {
+      if (path === "/project/wiki/memory") {
+        return [{ name: "人物状态", path: "/project/wiki/memory/人物状态", is_dir: true }]
+      }
+      if (path === "/project/wiki/memory/人物状态") {
+        return [
+          { name: "主角状态.md", path: "/project/wiki/memory/人物状态/主角状态.md", is_dir: false },
+          { name: "反派状态.md", path: "/project/wiki/memory/人物状态/反派状态.md", is_dir: false },
+        ]
+      }
+      return []
+    })
+
+    const tool = createReadMemoryTool("/project/wiki/memory")
+    const result = await tool.execute({ name: "人物状态" })
+
+    expect(result).toContain("「人物状态」是目录")
+    expect(result).toContain("主角状态")
+    expect(result).toContain("反派状态")
+    expect(result).not.toContain("请确认文件存在")
+  })
+
   it("read_memory reads an explicit nested memory path", async () => {
     vi.mocked(readFile).mockResolvedValue("nested memory content")
     const tool = createReadMemoryTool("/project/wiki/memory")
@@ -49,6 +149,49 @@ describe("read tools", () => {
     expect(result).toBe("outline content")
   })
 
+  it("read_outline matches punctuation-insensitive outline names", async () => {
+    vi.mocked(readFile).mockImplementation(async (path) => {
+      if (path === "/project/wiki/outlines/他只想活着大纲.md") return "大纲内容"
+      throw new Error("missing")
+    })
+    vi.mocked(listDirectory).mockResolvedValue([
+      { name: "他只想活着大纲.md", path: "/project/wiki/outlines/他只想活着大纲.md", is_dir: false },
+    ])
+
+    const tool = createReadOutlineTool("/project/wiki/outlines")
+    const result = await tool.execute({ name: "他,只想活着-大纲" })
+
+    expect(result).toBe("大纲内容")
+    expect(readFile).toHaveBeenCalledWith("/project/wiki/outlines/他,只想活着-大纲.md")
+    expect(readFile).toHaveBeenCalledWith("/project/wiki/outlines/他只想活着大纲.md")
+  })
+
+  it("read_outline falls back to outline snapshots when wiki outlines are empty", async () => {
+    vi.mocked(readFile).mockImplementation(async (path) => {
+      if (path === "/project/.novel/snapshots/outline-312.snapshot.md") return "# 大纲快照一"
+      if (path === "/project/.novel/snapshots/outline-765.snapshot.md") return "# 大纲快照二"
+      throw new Error("missing")
+    })
+    vi.mocked(listDirectory).mockImplementation(async (path) => {
+      if (path === "/project/wiki/outlines") return []
+      if (path === "/project/.novel/snapshots") {
+        return [
+          { name: "outline-312.snapshot.md", path: "/project/.novel/snapshots/outline-312.snapshot.md", is_dir: false },
+          { name: "outline-765.snapshot.md", path: "/project/.novel/snapshots/outline-765.snapshot.md", is_dir: false },
+        ]
+      }
+      return []
+    })
+
+    const tool = createReadOutlineTool("/project/wiki/outlines")
+    const result = await tool.execute({ name: "他,只想活着-大纲" })
+
+    expect(result).toContain("已读取大纲快照")
+    expect(result).toContain("大纲快照一")
+    expect(result).toContain("大纲快照二")
+    expect(result).not.toContain("请确认文件存在")
+  })
+
   it("read_deduction reads from simulations dir", async () => {
     vi.mocked(readFile).mockResolvedValue('{"result":"sim data"}')
     const tool = createReadDeductionTool("/project/.qmai/simulations")
@@ -64,10 +207,59 @@ describe("read tools", () => {
     expect(readFile).toHaveBeenCalledWith("/project/.qmai/simulations/frameworks/main.md")
   })
 
-  it("search_chapters searches by keyword", async () => {
+  it("search_chapters searches chapter contents by keyword", async () => {
+    vi.mocked(listDirectory).mockResolvedValue([
+      { name: "第19章.md", path: "/project/wiki/chapters/第19章.md", is_dir: false },
+      { name: "第20章.md", path: "/project/wiki/chapters/第20章.md", is_dir: false },
+      { name: "notes.txt", path: "/project/wiki/chapters/notes.txt", is_dir: false },
+    ])
+    vi.mocked(readFile).mockImplementation(async (path) => {
+      if (path === "/project/wiki/chapters/第20章.md") {
+        return "上一章余波未平,归元魄第一次在夜色中显露出真正的裂痕。"
+      }
+      return "这一章没有目标关键词。"
+    })
+
     const tool = createSearchChaptersTool("/project/wiki/chapters")
-    const result = await tool.execute({ keyword: "无我" })
-    expect(result).toContain("搜索")
+    const result = await tool.execute({ keyword: "归元魄" })
+
+    expect(result).toContain("搜索章节内容中匹配「归元魄」的结果")
+    expect(result).toContain("第20章")
+    expect(result).toContain("归元魄第一次")
+    expect(result).toContain("命中片段")
+    expect(result).not.toContain("基础实现")
+    expect(listDirectory).toHaveBeenCalledWith("/project/wiki/chapters")
+    expect(readFile).toHaveBeenCalledWith("/project/wiki/chapters/第19章.md")
+    expect(readFile).toHaveBeenCalledWith("/project/wiki/chapters/第20章.md")
+    expect(readFile).not.toHaveBeenCalledWith("/project/wiki/chapters/notes.txt")
+  })
+
+  it("search_chapters matches chapter filename keywords", async () => {
+    vi.mocked(listDirectory).mockResolvedValue([
+      { name: "第20章.md", path: "/project/wiki/chapters/第20章.md", is_dir: false },
+    ])
+    vi.mocked(readFile).mockResolvedValue("她回头望向城门,知道这一夜会改变所有人的命运。")
+
+    const tool = createSearchChaptersTool("/project/wiki/chapters")
+    const result = await tool.execute({ keyword: "第20章" })
+
+    expect(result).toContain("第20章")
+    expect(result).toContain("命中位置:文件名")
+    expect(result).toContain("她回头望向城门")
+    expect(result).not.toContain("基础实现")
+  })
+
+  it("search_chapters reports no matches without placeholder text", async () => {
+    vi.mocked(listDirectory).mockResolvedValue([
+      { name: "第1章.md", path: "/project/wiki/chapters/第1章.md", is_dir: false },
+    ])
+    vi.mocked(readFile).mockResolvedValue("这一章没有搜索目标。")
+
+    const tool = createSearchChaptersTool("/project/wiki/chapters")
+    const result = await tool.execute({ keyword: "不存在的关键词" })
+
+    expect(result).toContain("未找到匹配章节")
+    expect(result).not.toContain("基础实现")
   })
 
   it("read_chat_history reads from provided conversations", async () => {

+ 186 - 0
src/lib/agent/tools/run-chapter-workflow.spec.ts

@@ -0,0 +1,186 @@
+import { describe, expect, it, vi } from "vitest"
+import type { LlmConfig } from "@/stores/wiki-store"
+import { createRunChapterWorkflowTool } from "./run-chapter-workflow"
+
+const llmConfig: LlmConfig = {
+  provider: "custom",
+  apiKey: "test-key",
+  model: "test-model",
+  ollamaUrl: "",
+  customEndpoint: "https://example.test/v1",
+  maxContextSize: 120000,
+}
+
+describe("createRunChapterWorkflowTool", () => {
+  it("wraps deep chapter generation as an auto action tool", async () => {
+    const runDeepChapterGeneration = vi.fn(async (_input, callbacks) => {
+      callbacks.onWorkflowEvent?.({
+        type: "started",
+        id: "deep_chapter:chapter_context",
+        name: "chapter_context",
+        title: "读取上下文",
+        timestamp: 100,
+      })
+      return {
+        finalContent: "最终正文",
+        taskBrief: "任务书",
+        draftContent: "初稿",
+        reviewResults: [],
+        revised: false,
+      }
+    })
+    const onToolEvent = vi.fn()
+    const tool = createRunChapterWorkflowTool({
+      projectPath: "E:/Novel",
+      llmConfig,
+      aiWorkflowMode: "standard",
+      runDeepChapterGeneration,
+      onToolEvent,
+    })
+
+    expect(tool.name).toBe("run_chapter_workflow")
+    expect(tool.category).toBe("action")
+    expect(tool.permission).toBe("auto")
+
+    const result = await tool.execute({
+      intent: "write_chapter",
+      userRequest: "生成第3章",
+      chapterNumber: 3,
+    })
+
+    expect(runDeepChapterGeneration).toHaveBeenCalledWith(
+      expect.objectContaining({
+        projectPath: "E:/Novel",
+        userRequest: "生成第3章",
+        chapterNumber: 3,
+        aiWorkflowMode: "standard",
+      }),
+      expect.any(Object),
+      undefined,
+      undefined,
+    )
+    expect(onToolEvent).toHaveBeenCalledWith(expect.objectContaining({
+      parentCallId: expect.stringContaining("run_chapter_workflow"),
+      name: "chapter_context",
+    }))
+    expect(result).toContain("最终正文")
+    expect(result).toContain("任务书")
+  })
+
+  it("uses the runner tool call id as parent id for workflow child events", async () => {
+    const runDeepChapterGeneration = vi.fn(async (_input, callbacks) => {
+      callbacks.onWorkflowEvent?.({
+        type: "started",
+        id: "deep_chapter:chapter_context",
+        name: "chapter_context",
+        title: "读取上下文",
+        timestamp: 100,
+      })
+      return {
+        finalContent: "最终正文",
+        taskBrief: "任务书",
+        draftContent: "初稿",
+        reviewResults: [],
+        revised: false,
+      }
+    })
+    const onToolEvent = vi.fn()
+    const tool = createRunChapterWorkflowTool({
+      projectPath: "E:/Novel",
+      llmConfig,
+      aiWorkflowMode: "strict",
+      runDeepChapterGeneration,
+      onToolEvent,
+    })
+
+    await tool.execute(
+      { userRequest: "继续第3章" },
+      undefined,
+      { callId: "agent_tool_call_7", toolName: "run_chapter_workflow" },
+    )
+
+    expect(onToolEvent).toHaveBeenCalledWith(expect.objectContaining({
+      callId: "agent_tool_call_7:chapter_context",
+      parentCallId: "agent_tool_call_7",
+      name: "chapter_context",
+    }))
+  })
+
+  it("forwards workflow child events through the execution context emitter", async () => {
+    const runDeepChapterGeneration = vi.fn(async (_input, callbacks) => {
+      callbacks.onWorkflowEvent?.({
+        type: "started",
+        id: "deep_chapter:chapter_context",
+        name: "chapter_context",
+        title: "读取上下文",
+        timestamp: 100,
+      })
+      return {
+        finalContent: "最终正文",
+        taskBrief: "任务书",
+        draftContent: "初稿",
+        reviewResults: [],
+        revised: false,
+      }
+    })
+    const tool = createRunChapterWorkflowTool({
+      projectPath: "E:/Novel",
+      llmConfig,
+      aiWorkflowMode: "strict",
+      runDeepChapterGeneration,
+    })
+    const onToolEvent = vi.fn()
+
+    await tool.execute(
+      { userRequest: "继续第3章" },
+      undefined,
+      { callId: "agent_tool_call_8", toolName: "run_chapter_workflow", onToolEvent } as any,
+    )
+
+    expect(onToolEvent).toHaveBeenCalledWith(expect.objectContaining({
+      callId: "agent_tool_call_8:chapter_context",
+      parentCallId: "agent_tool_call_8",
+      name: "chapter_context",
+    }))
+  })
+
+  it("forwards deep chapter activity events through tool execution context", async () => {
+    const onActivityEvent = vi.fn()
+    const runDeepChapterGeneration = vi.fn(async (_input, callbacks) => {
+      callbacks?.onActivityEvent?.({
+        id: "activity-1",
+        stageId: "read_context",
+        kind: "extract_result",
+        title: "提取结果",
+        content: "上一章结尾:铜铃线索未揭示。",
+        timestamp: 100,
+      })
+      return {
+        finalContent: "正文",
+        taskBrief: "任务书",
+        draftContent: "草稿",
+        reviewResults: [],
+        revised: false,
+      }
+    })
+
+    const tool = createRunChapterWorkflowTool({
+      projectPath: "C:/Novel",
+      llmConfig,
+      aiWorkflowMode: "standard",
+      runDeepChapterGeneration,
+    })
+
+    await tool.execute(
+      { userRequest: "生成第14章" },
+      undefined,
+      { callId: "workflow-1", toolName: "run_chapter_workflow", onActivityEvent },
+    )
+
+    expect(onActivityEvent).toHaveBeenCalledWith(expect.objectContaining({
+      id: "activity-1",
+      stageId: "read_context",
+      kind: "extract_result",
+    }))
+  })
+})

+ 147 - 0
src/lib/agent/tools/run-chapter-workflow.ts

@@ -0,0 +1,147 @@
+import type { LlmConfig } from "@/stores/wiki-store"
+import type { AiWorkflowMode } from "@/lib/agent/workflow-mode"
+import type {
+  ChapterWorkflowEvent,
+  DeepChapterGenerationCallbacks,
+  DeepChapterGenerationDeps,
+  DeepChapterGenerationInput,
+  DeepChapterGenerationResult,
+} from "@/lib/novel/deep-chapter-generation"
+import type { AgentActivityEvent, AgentToolEvent, Tool, ToolExecutionContext } from "../types"
+
+export type RunDeepChapterGeneration = (
+  input: DeepChapterGenerationInput,
+  callbacks?: DeepChapterGenerationCallbacks,
+  deps?: DeepChapterGenerationDeps,
+  signal?: AbortSignal,
+) => Promise<DeepChapterGenerationResult>
+
+export interface RunChapterWorkflowToolOptions {
+  projectPath: string
+  llmConfig: LlmConfig
+  aiWorkflowMode: AiWorkflowMode
+  runDeepChapterGeneration: RunDeepChapterGeneration
+  onToolEvent?: (event: AgentToolEvent) => void
+  onActivityEvent?: (event: AgentActivityEvent) => void
+}
+
+interface RunChapterWorkflowParams {
+  intent?: string
+  userRequest?: string
+  chapterNumber?: number
+  workflowMode?: AiWorkflowMode
+}
+
+function toNumber(value: unknown): number | undefined {
+  if (typeof value === "number" && Number.isFinite(value)) return value
+  if (typeof value === "string" && value.trim()) {
+    const parsed = Number(value)
+    return Number.isFinite(parsed) ? parsed : undefined
+  }
+  return undefined
+}
+
+function normalizeParams(params: Record<string, unknown>): RunChapterWorkflowParams {
+  return {
+    intent: typeof params.intent === "string" ? params.intent : undefined,
+    userRequest: typeof params.userRequest === "string" ? params.userRequest : undefined,
+    chapterNumber: toNumber(params.chapterNumber),
+    workflowMode:
+      params.workflowMode === "fast" || params.workflowMode === "standard" || params.workflowMode === "strict"
+        ? params.workflowMode
+        : undefined,
+  }
+}
+
+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",
+    description: [
+      "运行小说章节写作工作流。用于生成、续写、改写或润色章节。",
+      "调用后会读取项目上下文、生成写作任务书、生成正文,并按当前模式执行审稿、返修和去AI味。",
+      "最终返回可直接交付给用户的章节正文;保存到项目文件仍需要写入工具和用户确认。",
+    ].join("\n"),
+    category: "action",
+    permission: "auto",
+    executeTimeoutMs: 0,
+    parameters: {
+      intent: {
+        type: "string",
+        description: "章节任务类型:write_chapter、continue_chapter、rewrite_chapter 或 polish_chapter。",
+        enum: ["write_chapter", "continue_chapter", "rewrite_chapter", "polish_chapter"],
+      },
+      userRequest: {
+        type: "string",
+        description: "用户原始章节写作请求,必须完整保留。",
+        required: true,
+      },
+      chapterNumber: {
+        type: "integer",
+        description: "目标章节号,无法确定时可以省略。",
+      },
+      workflowMode: {
+        type: "string",
+        description: "执行强度:fast、standard 或 strict。省略时使用当前 AI 会话模式。",
+        enum: ["fast", "standard", "strict"],
+      },
+    },
+    async execute(rawParams, signal, context?: ToolExecutionContext) {
+      const params = normalizeParams(rawParams)
+      const userRequest = params.userRequest?.trim()
+      if (!userRequest) {
+        return "错误:缺少 userRequest,无法运行章节工作流。"
+      }
+
+      const parentCallId = context?.callId ?? `run_chapter_workflow:${Date.now()}`
+      const emitToolEvent = context?.onToolEvent ?? options.onToolEvent
+      const emitActivityEvent = context?.onActivityEvent ?? options.onActivityEvent
+      const result = await options.runDeepChapterGeneration(
+        {
+          projectPath: options.projectPath,
+          userRequest,
+          chapterNumber: params.chapterNumber,
+          llmConfig: options.llmConfig,
+          aiWorkflowMode: params.workflowMode ?? options.aiWorkflowMode,
+        },
+        {
+          onWorkflowEvent: (event) => {
+            emitToolEvent?.(workflowEventToToolEvent(parentCallId, event))
+          },
+          onActivityEvent: (event) => {
+            emitActivityEvent?.({
+              ...event,
+              toolCallId: event.toolCallId ?? parentCallId,
+            })
+          },
+        },
+        undefined,
+        signal,
+      )
+
+      return [
+        "章节工作流完成。",
+        `是否返修:${result.revised ? "是" : "否"}`,
+        `任务书:${result.taskBrief}`,
+        "",
+        "最终正文:",
+        result.finalContent,
+      ].join("\n")
+    },
+  }
+}

+ 95 - 3
src/lib/agent/tools/search-chapters.ts

@@ -1,6 +1,35 @@
 import type { Tool } from "../types"
+import { listDirectory, readFile } from "@/commands/fs"
 
-export function createSearchChaptersTool(_chaptersDir: string): Tool {
+interface ChapterSearchMatch {
+  chapterName: string
+  path: string
+  location: "正文" | "文件名"
+  snippet: string
+}
+
+const MAX_RESULTS = 10
+const SNIPPET_RADIUS = 80
+
+function normalizeSnippet(text: string): string {
+  return text.replace(/\s+/g, " ").trim()
+}
+
+function createContentSnippet(content: string, index: number, keywordLength: number): string {
+  const start = Math.max(0, index - SNIPPET_RADIUS)
+  const end = Math.min(content.length, index + keywordLength + SNIPPET_RADIUS)
+  const prefix = start > 0 ? "..." : ""
+  const suffix = end < content.length ? "..." : ""
+  return `${prefix}${normalizeSnippet(content.slice(start, end))}${suffix}`
+}
+
+function createLeadingSnippet(content: string): string {
+  const normalized = normalizeSnippet(content)
+  if (!normalized) return "(章节内容为空)"
+  return normalized.length > 160 ? `${normalized.slice(0, 160)}...` : normalized
+}
+
+export function createSearchChaptersTool(chaptersDir: string): Tool {
   return {
     name: "search_chapters",
     description: "按关键词在所有章节中搜索匹配内容。参数 keyword 为搜索关键词。",
@@ -9,8 +38,71 @@ export function createSearchChaptersTool(_chaptersDir: string): Tool {
       keyword: { type: "string", description: "搜索关键词", required: true },
     },
     execute: async (params) => {
-      const keyword = (params.keyword as string).toLowerCase()
-      return `搜索章节内容中匹配「${keyword}」的结果:\n(注:此工具当前为基础实现,需要 AI 结合章节列表进一步读取相关章节全文)`
+      const keyword = typeof params.keyword === "string" ? params.keyword.trim() : ""
+      if (!keyword) {
+        return "错误:缺少搜索关键词,无法搜索章节。"
+      }
+
+      let files
+      try {
+        files = await listDirectory(chaptersDir)
+      } catch {
+        return "错误:无法搜索章节目录,请确认章节库存在。"
+      }
+
+      const keywordLower = keyword.toLowerCase()
+      const chapterFiles = files
+        .filter((file) => !file.is_dir && file.name.toLowerCase().endsWith(".md"))
+        .sort((a, b) => a.name.localeCompare(b.name, "zh-CN", { numeric: true }))
+
+      const matches: ChapterSearchMatch[] = []
+      const failedFiles: string[] = []
+
+      for (const file of chapterFiles) {
+        if (matches.length >= MAX_RESULTS) break
+
+        let content: string
+        try {
+          content = await readFile(file.path)
+        } catch {
+          failedFiles.push(file.name)
+          continue
+        }
+
+        const chapterName = file.name.replace(/\.md$/i, "")
+        const nameMatches = chapterName.toLowerCase().includes(keywordLower)
+        const contentIndex = content.toLowerCase().indexOf(keywordLower)
+        if (!nameMatches && contentIndex === -1) continue
+
+        matches.push({
+          chapterName,
+          path: file.path,
+          location: contentIndex >= 0 ? "正文" : "文件名",
+          snippet: contentIndex >= 0
+            ? createContentSnippet(content, contentIndex, keyword.length)
+            : createLeadingSnippet(content),
+        })
+      }
+
+      if (matches.length === 0) {
+        const failedText = failedFiles.length > 0 ? `\n另有 ${failedFiles.length} 个章节读取失败,未纳入搜索。` : ""
+        return `未找到匹配章节:${keyword}\n已搜索 ${chapterFiles.length - failedFiles.length} 个章节文件。${failedText}`
+      }
+
+      const lines = [`搜索章节内容中匹配「${keyword}」的结果:`]
+      for (const [index, match] of matches.entries()) {
+        lines.push(`${index + 1}. ${match.chapterName}`)
+        lines.push(`路径:${match.path}`)
+        lines.push(`命中位置:${match.location}`)
+        lines.push(`命中片段:${match.snippet}`)
+      }
+      if (matches.length >= MAX_RESULTS) {
+        lines.push(`仅显示前 ${MAX_RESULTS} 条匹配结果。`)
+      }
+      if (failedFiles.length > 0) {
+        lines.push(`另有 ${failedFiles.length} 个章节读取失败,未纳入搜索。`)
+      }
+      return lines.join("\n")
     },
   }
 }

+ 65 - 2
src/lib/agent/types.ts

@@ -12,14 +12,23 @@ export type ToolCategory = "read" | "write" | "action" | "virtual"
 export type ToolPermission = "auto" | "confirm"
 export type ToolCallStatus = "running" | "done" | "error" | "approval_required" | "cancelled"
 
+export interface ToolExecutionContext {
+  callId: string
+  toolName: string
+  onToolEvent?: (event: AgentToolEvent) => void
+  onActivityEvent?: (event: AgentActivityEvent) => void
+}
+
 export interface Tool {
   name: string
   description: string
   category: ToolCategory
   permission?: ToolPermission
+  /** 0 表示不使用通用工具超时,适用于内部有阶段进度和取消信号的长流程工具。 */
+  executeTimeoutMs?: number
   parameters: Record<string, ToolParameter>
-  execute(params: Record<string, unknown>, signal?: AbortSignal): Promise<string>
-  generatePreview?: (params: Record<string, unknown>, signal?: AbortSignal) => Promise<string>
+  execute(params: Record<string, unknown>, signal?: AbortSignal, context?: ToolExecutionContext): Promise<string>
+  generatePreview?: (params: Record<string, unknown>, signal?: AbortSignal, context?: ToolExecutionContext) => Promise<string>
 }
 
 export interface ToolCall {
@@ -53,6 +62,7 @@ export interface AgentConfig {
 export interface AgentToolEvent {
   type: "call_started" | "result" | "error" | "approval_required" | "cancelled"
   callId: string
+  parentCallId?: string
   name: string
   params: Record<string, unknown>
   result?: string
@@ -60,12 +70,63 @@ export interface AgentToolEvent {
   timestamp: number
 }
 
+export type AgentStageStatus =
+  | "pending"
+  | "running"
+  | "done"
+  | "error"
+  | "approval_required"
+  | "cancelled"
+
+export type AgentActivityKind =
+  | "stage_started"
+  | "stage_input"
+  | "read_source"
+  | "extract_goal"
+  | "extract_result"
+  | "analysis"
+  | "tool_call"
+  | "skill_used"
+  | "mcp_call"
+  | "web_search"
+  | "stage_output"
+  | "final_output"
+  | "error"
+
+export interface AgentSourceRef {
+  title: string
+  path?: string
+  type: string
+}
+
+export interface AgentActivityEvent {
+  id: string
+  stageId: string
+  kind: AgentActivityKind
+  title: string
+  content: string
+  sourceRefs?: AgentSourceRef[]
+  toolCallId?: string
+  timestamp: number
+}
+
+export interface AgentStageTrace {
+  id: string
+  title: string
+  status: AgentStageStatus
+  summary: string
+  events: AgentActivityEvent[]
+  startedAt?: number
+  finishedAt?: number
+}
+
 export interface AgentRunCallbacks {
   onText: (chunk: string) => void
   onToolCall: (call: ToolCall) => void
   onToolResult: (callId: string, result: string) => void
   onToolError: (callId: string, error: string) => void
   onToolEvent?: (event: AgentToolEvent) => void
+  onActivityEvent?: (event: AgentActivityEvent) => void
   onDone: () => void
   onError: (error: Error) => void
 }
@@ -81,9 +142,11 @@ export interface AgentMessage {
 export interface AgentRunRecord {
   toolCalls: {
     id: string
+    parentCallId?: string
     name: string
     params: Record<string, unknown>
     result: string
+    preview?: string
     status: ToolCallStatus
     startedAt: number
     finishedAt: number

+ 24 - 0
src/lib/agent/workflow-trace.spec.ts

@@ -79,6 +79,23 @@ describe("buildAgentWorkflowSteps", () => {
     expect(decision?.summary).toContain("等待用户确认")
   })
 
+  it("summarizes chapter workflow parent and child calls", () => {
+    const steps = buildAgentWorkflowSteps({
+      toolCalls: [
+        call({ id: "parent", name: "run_chapter_workflow", status: "running" }),
+        call({ id: "context", name: "chapter_context", status: "done", parentCallId: "parent" } as any),
+        call({ id: "draft", name: "chapter_draft", status: "running", parentCallId: "parent" } as any),
+      ],
+    })
+
+    const tool = steps.find((step) => step.kind === "tool")
+    const details = tool?.details.map((detail) => detail.value).join("\n")
+
+    expect(tool?.summary).toContain("章节工作流")
+    expect(details).toContain("读取章节上下文")
+    expect(details).toContain("生成章节正文初稿")
+  })
+
   it("uses context trace result protocol for validation", () => {
     const trace: ContextTrace = {
       id: "trace-1",
@@ -116,4 +133,11 @@ describe("getWorkflowToolDescription", () => {
     expect(getWorkflowToolDescription(call({ id: "chapter", name: "read_chapter" }))).toBe("读取章节")
     expect(getWorkflowToolDescription(call({ id: "memory", name: "read_memory" }))).toBe("读取记忆")
   })
+
+  it("describes synthetic chapter workflow calls as user-visible writing steps", () => {
+    expect(getWorkflowToolDescription(call({ id: "brief", name: "chapter_task_brief" }))).toBe("生成写作任务书")
+    expect(getWorkflowToolDescription(call({ id: "draft", name: "chapter_draft", params: { chars: 3200 } }))).toBe("生成章节正文初稿")
+    expect(getWorkflowToolDescription(call({ id: "review", name: "chapter_review" }))).toBe("执行 AI 审稿")
+    expect(getWorkflowToolDescription(call({ id: "polish", name: "chapter_final_polish" }))).toBe("简单审查与去AI味")
+  })
 })

+ 28 - 3
src/lib/agent/workflow-trace.ts

@@ -19,6 +19,7 @@ export type AgentWorkflowStepKind =
 
 export interface WorkflowToolCall {
   id: string
+  parentCallId?: string
   name: string
   params: Record<string, unknown>
   result?: string
@@ -63,6 +64,7 @@ const READ_TOOLS = new Set([
 const WRITE_TOOLS = new Set(["write_chapter", "write_outline_node", "write_memory"])
 const SKILL_TOOLS = new Set(["apply_skill"])
 const ROUTE_TOOLS = new Set(["route_task"])
+const CHAPTER_CONTEXT_TOOLS = new Set(["chapter_context", "chapter_previous_analysis"])
 
 const INTENT_LABELS: Record<string, string> = {
   write_chapter: "生成小说章节",
@@ -168,6 +170,28 @@ export function getWorkflowToolDescription(call: WorkflowToolCall): string {
       return "加载小说上下文"
     case "trim_context":
       return "整理上下文长度"
+    case "run_chapter_workflow":
+      return "运行章节工作流"
+    case "chapter_context":
+      return "读取章节上下文"
+    case "chapter_previous_analysis":
+      return "分析前情章节"
+    case "chapter_task_brief":
+      return "生成写作任务书"
+    case "chapter_draft":
+      return "生成章节正文初稿"
+    case "chapter_expansion":
+      return "正文扩写补足"
+    case "chapter_review":
+      return "执行 AI 审稿"
+    case "chapter_revision":
+      return "自动返修章节正文"
+    case "chapter_post_revision_review":
+      return "返修后角色一致性复审"
+    case "chapter_final_polish":
+      return "简单审查与去AI味"
+    case "chapter_complete":
+      return "完成多任务写作循环"
     default:
       return call.name
   }
@@ -250,10 +274,10 @@ function buildSkillStep(calls: WorkflowToolCall[]): AgentWorkflowStep {
 }
 
 function buildContextStep(calls: WorkflowToolCall[], contextTrace?: ContextTrace | null): AgentWorkflowStep {
-  const contextCalls = calls.filter((call) => LIST_TOOLS.has(call.name) || READ_TOOLS.has(call.name) || call.name === "load_context" || call.name === "trim_context")
+  const contextCalls = calls.filter((call) => LIST_TOOLS.has(call.name) || READ_TOOLS.has(call.name) || CHAPTER_CONTEXT_TOOLS.has(call.name) || call.name === "load_context" || call.name === "trim_context")
   const listCount = contextCalls.filter((call) => LIST_TOOLS.has(call.name)).length
   const readDetails = contextCalls
-    .filter((call) => READ_TOOLS.has(call.name) || call.name === "load_context" || call.name === "trim_context")
+    .filter((call) => READ_TOOLS.has(call.name) || CHAPTER_CONTEXT_TOOLS.has(call.name) || call.name === "load_context" || call.name === "trim_context")
     .map((call): AgentWorkflowDetail => ({
       label: call.status === "running" ? "正在处理" : call.status === "error" ? "读取失败" : "已读取",
       value: getWorkflowToolDescription(call),
@@ -296,12 +320,13 @@ function buildToolStep(calls: WorkflowToolCall[]): AgentWorkflowStep {
   const doneCount = toolCalls.filter((call) => call.status === "done").length
   const approvalCount = toolCalls.filter((call) => call.status === "approval_required").length
   const errorCount = toolCalls.filter((call) => call.status === "error").length
+  const hasChapterWorkflow = toolCalls.some((call) => call.name === "run_chapter_workflow" || call.parentCallId)
   const details = uniqueDetails(toolCalls.map((call) => ({
     label: call.status === "approval_required" ? "等待确认" : call.status === "error" ? "失败" : "工具",
     value: getWorkflowToolDescription(call),
     tone: call.status === "approval_required" ? "warning" : call.status === "error" ? "error" : "default",
   })))
-  const summaryParts = [`调用 ${toolCalls.length} 个工具`]
+  const summaryParts = [hasChapterWorkflow ? `章节工作流调用 ${toolCalls.length} 个步骤` : `调用 ${toolCalls.length} 个工具`]
   if (doneCount > 0) summaryParts.push(`${doneCount} 个完成`)
   if (approvalCount > 0) summaryParts.push(`${approvalCount} 个等待确认`)
   if (errorCount > 0) summaryParts.push(`${errorCount} 个失败`)

+ 1 - 1
src/lib/llm-client.ts

@@ -406,7 +406,7 @@ export async function streamChat(
           new Error(
             `模型只输出了 ${reasoningCharsObserved.toLocaleString()} 字符的思考内容,但没有输出正文。` +
             `这通常表示接口触发了思考 token 上限、模型没有从思考阶段切换到正式回答,或当前兼容接口的流式输出不完整。` +
-            `请缩短输入、提高 max_tokens,或在设置里切换其他模型后重试。`,
+            `请关闭模型思考、切换到非推理模型,或缩短输入后重试。`,
           ),
         )
         return

+ 4 - 2
src/lib/llm-providers.ts

@@ -555,8 +555,10 @@ function buildAnthropicBodyWithReasoning(
           ? 4096
         : 8192
   const budgetTokens = Math.max(1024, budget)
-  if ((body.max_tokens as number) <= budgetTokens) {
-    body.max_tokens = budgetTokens + 1
+  const minAnswerTokens = 4096
+  const minTotalTokens = budgetTokens + minAnswerTokens
+  if ((body.max_tokens as number) < minTotalTokens) {
+    body.max_tokens = minTotalTokens
   }
   body.thinking = { type: "enabled", budget_tokens: budgetTokens }
   delete body.temperature

+ 235 - 8
src/lib/novel/deep-chapter-generation.spec.ts

@@ -1,6 +1,7 @@
 import { describe, expect, it, vi } from "vitest"
-import type { LlmConfig } from "@/stores/wiki-store"
-import type { ChatMessage, StreamCallbacks } from "@/lib/llm-client"
+import { DEFAULT_NOVEL_CONFIG, useWikiStore, type LlmConfig } from "@/stores/wiki-store"
+import type { AgentActivityEvent } from "@/lib/agent/types"
+import type { ChatMessage, RequestOverrides, StreamCallbacks } from "@/lib/llm-client"
 import type { ContextPack } from "./context-engine"
 import type { NovelReviewResult } from "./review-adapter"
 import {
@@ -143,12 +144,12 @@ describe("runDeepChapterGeneration", () => {
     expect(finalPolishPrompt).toContain("不要按非虚构文章规则硬删副词")
   })
 
-  it("only enables deep generation for write-chapter routes when the switch is on", () => {
-    expect(shouldUseDeepChapterGeneration({ intent: "write_chapter", confidence: 1, extractedParams: {} }, true)).toBe(true)
-    expect(shouldUseDeepChapterGeneration({ intent: "continue_chapter", confidence: 1, extractedParams: {} }, true)).toBe(true)
-    expect(shouldUseDeepChapterGeneration({ intent: "write_chapter", confidence: 1, extractedParams: {} }, false)).toBe(false)
-    expect(shouldUseDeepChapterGeneration({ intent: "general_chat", confidence: 1, extractedParams: {} }, true)).toBe(true)
-    expect(shouldUseDeepChapterGeneration(null, true)).toBe(true)
+  it("enables the multi-task generation loop for all chapter writing routes", () => {
+    expect(shouldUseDeepChapterGeneration({ intent: "write_chapter", confidence: 1, extractedParams: {} }, false)).toBe(true)
+    expect(shouldUseDeepChapterGeneration({ intent: "continue_chapter", confidence: 1, extractedParams: {} }, false)).toBe(true)
+    expect(shouldUseDeepChapterGeneration({ intent: "rewrite_chapter", confidence: 1, extractedParams: {} }, false)).toBe(true)
+    expect(shouldUseDeepChapterGeneration({ intent: "general_chat", confidence: 1, extractedParams: {} }, true)).toBe(false)
+    expect(shouldUseDeepChapterGeneration(null, true)).toBe(false)
   })
 
   it("publishes stage results into thinking and returns the final simple review result when review passes", async () => {
@@ -171,6 +172,28 @@ describe("runDeepChapterGeneration", () => {
     expect(thinking.join("\n")).toContain("未发现阻断问题")
   })
 
+  it("emits structured activity events for context extraction and stage outputs", async () => {
+    const deps = createDeps()
+    const activityEvents: AgentActivityEvent[] = []
+
+    await runDeepChapterGeneration(
+      {
+        projectPath: "E:/Novel",
+        userRequest: "生成第3章",
+        chapterNumber: 3,
+        llmConfig,
+        aiWorkflowMode: "fast",
+      },
+      { onActivityEvent: (event) => activityEvents.push(event) },
+      deps,
+    )
+
+    expect(activityEvents.some((event) => event.kind === "read_source" && event.content.includes("上一章结尾"))).toBe(true)
+    expect(activityEvents.some((event) => event.kind === "extract_goal" && event.content.includes("上一章结尾"))).toBe(true)
+    expect(activityEvents.some((event) => event.kind === "extract_result" && event.content.includes("门缝里传来金属拖拽声"))).toBe(true)
+    expect(activityEvents.some((event) => event.kind === "stage_output" && event.content.includes("任务书"))).toBe(true)
+  })
+
   it("injects the enabled writing style into the stage 3 draft prompt", async () => {
     const capturedPrompts: string[] = []
     const enabledStyleContext = "目标文风来源:《长夜书》\n风格硬约束:冷峻克制、短句推进、少解释"
@@ -203,6 +226,210 @@ describe("runDeepChapterGeneration", () => {
     expect(capturedPrompts[1]).toContain("冷峻克制")
   })
 
+  it("does not pass app-side max_tokens limits to deep chapter model calls", async () => {
+    const deps = createDeps()
+    const overrides: Array<RequestOverrides | undefined> = []
+    vi.mocked(deps.streamChat).mockImplementation(async (
+      _config: LlmConfig,
+      messages: ChatMessage[],
+      callbacks: StreamCallbacks,
+      _signal,
+      requestOverrides,
+    ) => {
+      overrides.push(requestOverrides)
+      const prompt = messagesPromptText(messages)
+      const content = prompt.includes("简单审查") || prompt.includes("去AI味")
+        ? chapterText("最终无上限正文", 3000)
+        : prompt.includes("返修")
+          ? chapterText("返修无上限正文", 3000)
+          : prompt.includes("正文")
+            ? chapterText("初稿无上限正文", 3000)
+            : "写作任务书内容"
+      callbacks.onToken(content)
+      callbacks.onDone()
+    })
+
+    await runDeepChapterGeneration(
+      { projectPath: "E:/Novel", userRequest: "生成第三章", chapterNumber: 3, llmConfig },
+      {},
+      deps,
+    )
+
+    expect(overrides.length).toBeGreaterThan(0)
+    expect(overrides.every((item) => item?.max_tokens === undefined)).toBe(true)
+  })
+
+  it("preserves configured model reasoning for chapter generation calls", async () => {
+    const deps = createDeps()
+    const overrides: Array<RequestOverrides | undefined> = []
+    vi.mocked(deps.streamChat).mockImplementation(async (
+      _config: LlmConfig,
+      messages: ChatMessage[],
+      callbacks: StreamCallbacks,
+      _signal,
+      requestOverrides,
+    ) => {
+      overrides.push(requestOverrides)
+      const prompt = messagesPromptText(messages)
+      callbacks.onToken(prompt.includes("正文") ? chapterText("保留推理正文", 3000) : "写作任务书内容")
+      callbacks.onDone()
+    })
+
+    await runDeepChapterGeneration(
+      { projectPath: "E:/Novel", userRequest: "生成第三章", chapterNumber: 3, llmConfig },
+      {},
+      deps,
+    )
+
+    expect(overrides.length).toBeGreaterThan(0)
+    expect(overrides.every((item) => item?.reasoning === undefined)).toBe(true)
+  })
+
+  it("retries the current chapter generation stage with reasoning disabled after reasoning-only output", async () => {
+    const deps = createDeps()
+    const overrides: Array<RequestOverrides | undefined> = []
+    let callCount = 0
+    vi.mocked(deps.streamChat).mockImplementation(async (
+      _config: LlmConfig,
+      messages: ChatMessage[],
+      callbacks: StreamCallbacks,
+      _signal,
+      requestOverrides,
+    ) => {
+      callCount += 1
+      overrides.push(requestOverrides)
+      if (callCount === 1) {
+        callbacks.onReasoningToken?.("思考".repeat(160))
+        callbacks.onError(new Error("模型只输出了 543 字符的思考内容,但没有输出正文。"))
+        return
+      }
+
+      const prompt = messagesPromptText(messages)
+      const content = prompt.includes("简单审查") || prompt.includes("去AI味")
+        ? chapterText("最终兜底正文", 3000)
+        : prompt.includes("返修")
+          ? chapterText("返修兜底正文", 3000)
+          : prompt.includes("正文")
+            ? chapterText("初稿兜底正文", 3000)
+            : "写作任务书内容"
+      callbacks.onToken(content)
+      callbacks.onDone()
+    })
+
+    const result = await runDeepChapterGeneration(
+      { projectPath: "E:/Novel", userRequest: "生成第三章", chapterNumber: 3, llmConfig },
+      {},
+      deps,
+    )
+
+    expect(result.finalContent).toContain("最终兜底正文")
+    expect(overrides[0]?.reasoning).toBeUndefined()
+    expect(overrides[1]).toEqual({ reasoning: { mode: "off" } })
+  })
+
+  it("uses the same task loop with different workflow strength for fast standard and strict modes", async () => {
+    const fastDeps = createDeps()
+    await runDeepChapterGeneration(
+      { projectPath: "E:/Novel", userRequest: "生成第三章", chapterNumber: 3, llmConfig, aiWorkflowMode: "fast" },
+      {},
+      fastDeps,
+    )
+    expect(fastDeps.streamChat).toHaveBeenCalledTimes(2)
+    expect(fastDeps.reviewChapter).not.toHaveBeenCalled()
+
+    const standardDeps = createDeps()
+    await runDeepChapterGeneration(
+      { projectPath: "E:/Novel", userRequest: "生成第三章", chapterNumber: 3, llmConfig, aiWorkflowMode: "standard" },
+      {},
+      standardDeps,
+    )
+    expect(standardDeps.streamChat).toHaveBeenCalledTimes(3)
+    expect(standardDeps.reviewChapter).not.toHaveBeenCalled()
+
+    const strictDeps = createDeps()
+    await runDeepChapterGeneration(
+      { projectPath: "E:/Novel", userRequest: "生成第三章", chapterNumber: 3, llmConfig, aiWorkflowMode: "strict" },
+      {},
+      strictDeps,
+    )
+    expect(strictDeps.streamChat).toHaveBeenCalledTimes(3)
+    expect(strictDeps.reviewChapter).toHaveBeenCalled()
+  })
+
+  it("keeps AI review mandatory in strict mode even when the global deep chapter review switch is off", async () => {
+    const previousNovelConfig = useWikiStore.getState().novelConfig
+    useWikiStore.setState({
+      novelConfig: {
+        ...DEFAULT_NOVEL_CONFIG,
+        ...previousNovelConfig,
+        deepChapterReview: false,
+      },
+    })
+    try {
+      const deps = createDeps()
+      const events: Array<{ name: string; result?: string }> = []
+
+      await runDeepChapterGeneration(
+        { projectPath: "E:/Novel", userRequest: "生成第三章", chapterNumber: 3, llmConfig, aiWorkflowMode: "strict" },
+        { onWorkflowEvent: (event) => events.push(event) },
+        deps,
+      )
+
+      expect(deps.reviewChapter).toHaveBeenCalled()
+      expect(events.find((event) => event.name === "chapter_review" && event.result)?.result).toContain("AI 审稿完成")
+    } finally {
+      useWikiStore.setState({ novelConfig: previousNovelConfig })
+    }
+  })
+
+  it("emits visible workflow events for the chapter multi-task loop", async () => {
+    const deps = createDeps()
+    const events: Array<{ type: string; id: string; name: string; title: string; result?: string }> = []
+
+    await runDeepChapterGeneration(
+      { projectPath: "E:/Novel", userRequest: "生成第三章", chapterNumber: 3, llmConfig, aiWorkflowMode: "strict" },
+      { onWorkflowEvent: (event) => events.push(event) },
+      deps,
+    )
+
+    const eventKeys = events.map((event) => `${event.type}:${event.name}`)
+    expect(eventKeys).toEqual(expect.arrayContaining([
+      "started:chapter_context",
+      "completed:chapter_context",
+      "started:chapter_task_brief",
+      "completed:chapter_task_brief",
+      "started:chapter_draft",
+      "completed:chapter_draft",
+      "started:chapter_review",
+      "completed:chapter_review",
+      "started:chapter_final_polish",
+      "completed:chapter_final_polish",
+      "completed:chapter_complete",
+    ]))
+    expect(events.find((event) => event.name === "chapter_task_brief")?.title).toBe("生成写作任务书")
+    expect(events.find((event) => event.name === "chapter_complete")?.result).toContain("多任务写作循环完成")
+  })
+
+  it("keeps fast and standard workflow visibility aligned with their skipped stages", async () => {
+    const fastEvents: Array<{ name: string; result?: string }> = []
+    await runDeepChapterGeneration(
+      { projectPath: "E:/Novel", userRequest: "生成第三章", chapterNumber: 3, llmConfig, aiWorkflowMode: "fast" },
+      { onWorkflowEvent: (event) => fastEvents.push(event) },
+      createDeps(),
+    )
+    expect(fastEvents.find((event) => event.name === "chapter_review")?.result).toContain("快速模式跳过")
+    expect(fastEvents.find((event) => event.name === "chapter_final_polish")?.result).toContain("快速模式跳过")
+
+    const standardEvents: Array<{ name: string; result?: string }> = []
+    await runDeepChapterGeneration(
+      { projectPath: "E:/Novel", userRequest: "生成第三章", chapterNumber: 3, llmConfig, aiWorkflowMode: "standard" },
+      { onWorkflowEvent: (event) => standardEvents.push(event) },
+      createDeps(),
+    )
+    expect(standardEvents.find((event) => event.name === "chapter_review")?.result).toContain("标准模式跳过")
+    expect(standardEvents.find((event) => event.name === "chapter_final_polish" && event.result)?.result).toContain("简单审查与去AI味完成")
+  })
+
   it("shows a visible golden-three hint in thinking when generating the first chapter", async () => {
     const deps = createDeps()
     const thinking: string[] = []

+ 612 - 130
src/lib/novel/deep-chapter-generation.ts

@@ -1,6 +1,9 @@
 import type { LlmConfig } from "@/stores/wiki-store"
 import { streamChat, type ChatMessage, type RequestOverrides, type StreamCallbacks } from "@/lib/llm-client"
 import { useWikiStore } from "@/stores/wiki-store"
+import type { AiWorkflowMode } from "@/lib/agent/workflow-mode"
+import type { AgentActivityEvent, AgentActivityKind } from "@/lib/agent/types"
+import { isReasoningDisabled, isReasoningOnlyResponseError, withReasoningDisabled } from "@/lib/reasoning-retry"
 import { buildContextPack, contextPackToPrompt, type ContextPack } from "./context-engine"
 import { reviewChapter, type NovelReviewResult } from "./review-adapter"
 import type { TaskRouteResult } from "./task-router"
@@ -23,6 +26,7 @@ export interface DeepChapterGenerationInput {
   goldenThreeChapter?: GoldenThreeChapterRequest
   dismantlingReferenceDirective?: string
   llmConfig: LlmConfig
+  aiWorkflowMode?: AiWorkflowMode
   resumeCheckpoint?: DeepChapterGenerationResumeCheckpoint
 }
 
@@ -30,6 +34,8 @@ export interface DeepChapterGenerationCallbacks {
   onThinking?: (content: string) => void
   onFinalContent?: (content: string) => void
   onCheckpoint?: (checkpoint: DeepChapterGenerationResumeCheckpoint) => void
+  onWorkflowEvent?: (event: ChapterWorkflowEvent) => void
+  onActivityEvent?: (event: AgentActivityEvent) => void
 }
 
 export interface DeepChapterGenerationResult {
@@ -40,6 +46,19 @@ export interface DeepChapterGenerationResult {
   revised: boolean
 }
 
+export type ChapterWorkflowEventType = "started" | "completed" | "error"
+
+export interface ChapterWorkflowEvent {
+  type: ChapterWorkflowEventType
+  id: string
+  name: string
+  title: string
+  detail?: string
+  result?: string
+  params?: Record<string, unknown>
+  timestamp: number
+}
+
 export type DeepChapterGenerationResumeStage =
   | "after_context"
   | "after_task_brief"
@@ -84,7 +103,156 @@ const REPEAT_HIT_LIMIT = 3
 const USER_ABORT_MESSAGE = "已停止生成"
 
 export function shouldUseDeepChapterGeneration(_route: TaskRouteResult | null, enabled: boolean): boolean {
-  return enabled
+  void enabled
+  return (
+    _route?.intent === "write_chapter" ||
+    _route?.intent === "continue_chapter" ||
+    _route?.intent === "rewrite_chapter" ||
+    _route?.intent === "polish_chapter"
+  )
+}
+
+interface ChapterWorkflowProfile {
+  mode: AiWorkflowMode
+  runPreviousChaptersAnalysis: boolean
+  runAiReview: boolean
+  runFinalPolish: boolean
+  runPostRevisionReview: boolean
+}
+
+function resolveChapterWorkflowProfile(mode: AiWorkflowMode | undefined): ChapterWorkflowProfile {
+  const resolvedMode = mode ?? "strict"
+  if (resolvedMode === "fast") {
+    return {
+      mode: "fast",
+      runPreviousChaptersAnalysis: false,
+      runAiReview: false,
+      runFinalPolish: false,
+      runPostRevisionReview: false,
+    }
+  }
+  if (resolvedMode === "standard") {
+    return {
+      mode: "standard",
+      runPreviousChaptersAnalysis: false,
+      runAiReview: false,
+      runFinalPolish: true,
+      runPostRevisionReview: false,
+    }
+  }
+  return {
+    mode: "strict",
+    runPreviousChaptersAnalysis: true,
+    runAiReview: true,
+    runFinalPolish: true,
+    runPostRevisionReview: true,
+  }
+}
+
+interface ChapterWorkflowStepSpec {
+  name: string
+  title: string
+  detail?: string
+  params?: Record<string, unknown>
+}
+
+function emitChapterWorkflowEvent(
+  callbacks: DeepChapterGenerationCallbacks,
+  type: ChapterWorkflowEventType,
+  spec: ChapterWorkflowStepSpec,
+  payload: { result?: string; detail?: string; params?: Record<string, unknown> } = {},
+): void {
+  callbacks.onWorkflowEvent?.({
+    type,
+    id: `deep_chapter:${spec.name}`,
+    name: spec.name,
+    title: spec.title,
+    detail: payload.detail ?? spec.detail,
+    result: payload.result,
+    params: {
+      ...(spec.params ?? {}),
+      ...(payload.params ?? {}),
+    },
+    timestamp: Date.now(),
+  })
+}
+
+function emitDeepChapterActivity(
+  callbacks: DeepChapterGenerationCallbacks,
+  input: {
+    id: string
+    stageId: string
+    kind: AgentActivityKind
+    title: string
+    content: string
+    timestamp?: number
+  },
+): void {
+  callbacks.onActivityEvent?.({
+    id: input.id,
+    stageId: input.stageId,
+    kind: input.kind,
+    title: input.title,
+    content: input.content.trim() || "本阶段未返回可展示内容。",
+    timestamp: input.timestamp ?? Date.now(),
+  })
+}
+
+function emitDeepChapterStageStarted(
+  callbacks: DeepChapterGenerationCallbacks,
+  stageId: string,
+  title: string,
+  summary: string,
+): void {
+  emitDeepChapterActivity(callbacks, {
+    id: `deep_chapter:${stageId}:started:${Date.now()}`,
+    stageId,
+    kind: "stage_started",
+    title: "进入阶段",
+    content: `${title}\n${summary}`,
+  })
+}
+
+function startChapterWorkflowStep(
+  callbacks: DeepChapterGenerationCallbacks,
+  spec: ChapterWorkflowStepSpec,
+): void {
+  emitChapterWorkflowEvent(callbacks, "started", spec)
+}
+
+function completeChapterWorkflowStep(
+  callbacks: DeepChapterGenerationCallbacks,
+  spec: ChapterWorkflowStepSpec,
+  result: string,
+  params?: Record<string, unknown>,
+): void {
+  emitChapterWorkflowEvent(callbacks, "completed", spec, { result, params })
+}
+
+function errorChapterWorkflowStep(
+  callbacks: DeepChapterGenerationCallbacks,
+  spec: ChapterWorkflowStepSpec,
+  error: unknown,
+): void {
+  emitChapterWorkflowEvent(callbacks, "error", spec, { result: getErrorMessage(error) })
+}
+
+async function runChapterWorkflowStep<T>(
+  callbacks: DeepChapterGenerationCallbacks,
+  spec: ChapterWorkflowStepSpec,
+  action: () => Promise<T>,
+  formatResult: (value: T) => string,
+  formatParams?: (value: T) => Record<string, unknown>,
+): Promise<T> {
+  startChapterWorkflowStep(callbacks, spec)
+  try {
+    const value = await action()
+    completeChapterWorkflowStep(callbacks, spec, formatResult(value), formatParams?.(value))
+    return value
+  } catch (error) {
+    errorChapterWorkflowStep(callbacks, spec, error)
+    throw error
+  }
 }
 
 function createResumeCheckpoint(
@@ -150,16 +318,36 @@ export async function runDeepChapterGeneration(
   assertNotAborted(signal)
   const resumeCheckpoint = input.resumeCheckpoint
   const writingConfig = resolveWritingConfig(input.llmConfig)
+  const workflowProfile = resolveChapterWorkflowProfile(input.aiWorkflowMode)
   const lengthSpec = resolveCurrentChapterLengthSpec()
   const novelConfig = useWikiStore.getState().novelConfig
   const { loadSmartDeAiSkill } = await import("./de-ai-adapter")
+  const workflowBaseParams = {
+    mode: workflowProfile.mode,
+    chapterNumber: input.chapterNumber ?? null,
+  }
+  const contextWorkflowStep: ChapterWorkflowStepSpec = {
+    name: "chapter_context",
+    title: "读取上下文",
+    detail: "读取大纲、章节目标、近期剧情、人物状态、伏笔和时间线。",
+    params: workflowBaseParams,
+  }
+  if (!resumeCheckpoint) {
+    startChapterWorkflowStep(callbacks, contextWorkflowStep)
+  }
 
   // 将在阶段1构建contextPack后再加载skill(需要contextPack用于场景检测)
   let customDeAiSkill: string | null = null
 
   // 阶段0:前情分析(仅当章节号>1,且设置开启时;记忆库的近期摘要与上一章结尾仍会注入)
   let previousChaptersAnalysis = ""
-  if (input.chapterNumber && input.chapterNumber > 1 && !resumeCheckpoint && novelConfig.deepPreviousChaptersAnalysis) {
+  if (
+    workflowProfile.runPreviousChaptersAnalysis &&
+    input.chapterNumber &&
+    input.chapterNumber > 1 &&
+    !resumeCheckpoint &&
+    novelConfig.deepPreviousChaptersAnalysis
+  ) {
     callbacks.onThinking?.(formatStageThinking("阶段0:前情分析", "正在读取并分析前3章完整内容..."))
     const { analyzePreviousChapters } = await import("./previous-chapters-analysis")
     try {
@@ -189,6 +377,54 @@ export async function runDeepChapterGeneration(
   )
   assertNotAborted(signal)
 
+  if (!resumeCheckpoint) {
+    emitDeepChapterStageStarted(callbacks, "read_context", "读取上下文", "读取大纲、上一章结尾、近期剧情、人物状态、伏笔和时间线。")
+    emitDeepChapterActivity(callbacks, {
+      id: `deep_chapter:read_context:sources:${Date.now()}`,
+      stageId: "read_context",
+      kind: "read_source",
+      title: "读取内容",
+      content: [
+        contextPack.outline?.trim() ? "已读取:作品完整大纲" : "未读取到:作品完整大纲",
+        contextPack.previousChapterEnding?.trim() ? "已读取:上一章结尾" : "未读取到:上一章结尾",
+        Array.isArray(contextPack.recentSummaries) && contextPack.recentSummaries.length > 0
+          ? `已读取:近期剧情 ${contextPack.recentSummaries.length} 条`
+          : "未读取到:近期剧情",
+        contextPack.characterStates?.trim() ? "已读取:人物状态" : "未读取到:人物状态",
+        contextPack.foreshadowingStates?.trim() ? "已读取:伏笔状态" : "未读取到:伏笔状态",
+        contextPack.timeline?.trim() ? "已读取:时间线" : "未读取到:时间线",
+      ].join("\n"),
+    })
+    emitDeepChapterActivity(callbacks, {
+      id: `deep_chapter:read_context:goals:${Date.now()}`,
+      stageId: "read_context",
+      kind: "extract_goal",
+      title: "提取目标",
+      content: [
+        "上一章结尾",
+        "本章章节目标",
+        "大纲要求推进的事件",
+        "人物当前状态",
+        "必须承接的伏笔",
+        "必须完成与禁止违背内容",
+      ].join("\n"),
+    })
+    emitDeepChapterActivity(callbacks, {
+      id: `deep_chapter:read_context:results:${Date.now()}`,
+      stageId: "read_context",
+      kind: "extract_result",
+      title: "提取结果",
+      content: formatContextExtractionResult(input, contextPack),
+    })
+    emitDeepChapterActivity(callbacks, {
+      id: `deep_chapter:read_context:output:${Date.now()}`,
+      stageId: "read_context",
+      kind: "stage_output",
+      title: "阶段产物",
+      content: "已形成章节生成约束包,将传入写作任务书、正文初稿、审稿和最终去AI味阶段。",
+    })
+  }
+
   // 阶段1后:加载智能skill(传递contextPack用于场景检测)
   customDeAiSkill = await loadSmartDeAiSkill(input.projectPath, input.userRequest, contextPack)
 
@@ -225,68 +461,96 @@ export async function runDeepChapterGeneration(
   if (!resumeCheckpoint) {
     callbacks.onThinking?.(formatContextThinking(input, contextPack))
     callbacks.onCheckpoint?.(createResumeCheckpoint(input, "after_context"))
+    completeChapterWorkflowStep(callbacks, contextWorkflowStep, "上下文读取完成。", {
+      recentSummaryCount: Array.isArray(contextPack.recentSummaries) ? contextPack.recentSummaries.length : 0,
+      hasOutline: Boolean(contextPack.outline?.trim()),
+      hasPreviousChapterEnding: Boolean(contextPack.previousChapterEnding?.trim()),
+    })
   }
   assertNotAborted(signal)
 
   let taskBrief = hasCheckpointTaskBrief(resumeCheckpoint) ? resumeCheckpoint.taskBrief.trim() : ""
   if (!taskBrief) {
-    taskBrief = await collectModelText(
-      writingConfig,
-      [{
-        role: "user",
-        content: buildDeepChapterBriefPrompt(
-          outlinePrompt,
-          contextPrompt,
-          input.userRequest,
-          input.chapterNumber,
-          input.goldenThreeChapter,
-          lengthSpec,
-        ),
-      }],
-      deps,
-      signal,
-      (partial) => callbacks.onThinking?.(formatStageThinking("阶段2:写作任务书", partial)),
-      undefined,
-      cachePrefix,
+    emitDeepChapterStageStarted(callbacks, "plot_analysis", "分析剧情走向", "根据章节生成约束包拆解本章目标、关键情节和写作约束。")
+    emitDeepChapterActivity(callbacks, {
+      id: `deep_chapter:plot_analysis:input:${Date.now()}`,
+      stageId: "plot_analysis",
+      kind: "stage_input",
+      title: "接收内容",
+      content: "章节生成约束包",
+    })
+    taskBrief = await runChapterWorkflowStep(
+      callbacks,
+      {
+        name: "chapter_task_brief",
+        title: "生成写作任务书",
+        detail: "根据上下文拆解本章目标、关键情节和写作约束。",
+        params: workflowBaseParams,
+      },
+      () => collectModelText(
+        writingConfig,
+        [{
+          role: "user",
+          content: buildDeepChapterBriefPrompt(
+            outlinePrompt,
+            contextPrompt,
+            input.userRequest,
+            input.chapterNumber,
+            input.goldenThreeChapter,
+            lengthSpec,
+          ),
+        }],
+        deps,
+        signal,
+        (partial) => callbacks.onThinking?.(formatStageThinking("阶段2:写作任务书", partial)),
+        undefined,
+        cachePrefix,
+      ),
+      (value) => `写作任务书完成,约 ${countChapterChars(value)} 字。`,
+      (value) => ({ chars: countChapterChars(value) }),
     )
     assertNotAborted(signal)
     callbacks.onThinking?.(formatStageThinking("阶段2:写作任务书", taskBrief))
+    emitDeepChapterActivity(callbacks, {
+      id: `deep_chapter:task_brief:output:${Date.now()}`,
+      stageId: "plot_analysis",
+      kind: "stage_output",
+      title: "写作任务书",
+      content: taskBrief,
+    })
     callbacks.onCheckpoint?.(createResumeCheckpoint(input, "after_task_brief", { taskBrief }))
   }
 
   let draftContent = hasCheckpointDraft(resumeCheckpoint) ? resumeCheckpoint.draftContent.trim() : ""
   if (!draftContent) {
-    draftContent = await collectModelText(
-      writingConfig,
-      [{
-        role: "user",
-        content: buildDeepChapterDraftPrompt(
-          outlinePrompt,
-          contextPrompt,
-          taskBrief,
-          input.userRequest,
-          input.chapterNumber,
-          input.goldenThreeChapter,
-          lengthSpec,
-        ),
-      }],
-      deps,
-      signal,
-      (partial) => callbacks.onThinking?.(formatStageThinking("阶段3:正文初稿", partial)),
-      { max_tokens: lengthSpec.maxOutputTokens },
-      cachePrefix,
-    )
-    assertNotAborted(signal)
-    if (countChapterChars(draftContent) < lengthSpec.minChars) {
-      draftContent = await collectModelText(
+    emitDeepChapterStageStarted(callbacks, "generate_draft", "生成章节草稿", "读取章节生成约束包和写作任务书,生成正文初稿。")
+    emitDeepChapterActivity(callbacks, {
+      id: `deep_chapter:generate_draft:input:${Date.now()}`,
+      stageId: "generate_draft",
+      kind: "stage_input",
+      title: "接收内容",
+      content: [
+        "章节生成约束包",
+        "写作任务书",
+        input.chapterNumber ? `目标章节:第${input.chapterNumber}章` : "目标章节:从用户请求识别",
+      ].join("\n"),
+    })
+    draftContent = await runChapterWorkflowStep(
+      callbacks,
+      {
+        name: "chapter_draft",
+        title: "生成章节正文初稿",
+        detail: "按任务书输出本章正文初稿。",
+        params: workflowBaseParams,
+      },
+      () => collectModelText(
         writingConfig,
         [{
           role: "user",
-          content: buildDeepChapterExpansionPrompt(
+          content: buildDeepChapterDraftPrompt(
             outlinePrompt,
             contextPrompt,
             taskBrief,
-            draftContent,
             input.userRequest,
             input.chapterNumber,
             input.goldenThreeChapter,
@@ -295,9 +559,46 @@ export async function runDeepChapterGeneration(
         }],
         deps,
         signal,
-        (partial) => callbacks.onThinking?.(formatStageThinking("阶段3:正文扩写补足", partial)),
-        { max_tokens: lengthSpec.maxOutputTokens },
+        (partial) => callbacks.onThinking?.(formatStageThinking("阶段3:正文初稿", partial)),
+        undefined,
         cachePrefix,
+      ),
+      (value) => `正文初稿完成,约 ${countChapterChars(value)} 字。`,
+      (value) => ({ chars: countChapterChars(value) }),
+    )
+    assertNotAborted(signal)
+    if (countChapterChars(draftContent) < lengthSpec.minChars) {
+      draftContent = await runChapterWorkflowStep(
+        callbacks,
+        {
+          name: "chapter_expansion",
+          title: "正文扩写补足",
+          detail: "初稿低于本章目标字数,补足场景和人物行动。",
+          params: workflowBaseParams,
+        },
+        () => collectModelText(
+          writingConfig,
+          [{
+            role: "user",
+            content: buildDeepChapterExpansionPrompt(
+              outlinePrompt,
+              contextPrompt,
+              taskBrief,
+              draftContent,
+              input.userRequest,
+              input.chapterNumber,
+              input.goldenThreeChapter,
+              lengthSpec,
+            ),
+          }],
+          deps,
+          signal,
+          (partial) => callbacks.onThinking?.(formatStageThinking("阶段3:正文扩写补足", partial)),
+          undefined,
+          cachePrefix,
+        ),
+        (value) => `正文扩写补足完成,约 ${countChapterChars(value)} 字。`,
+        (value) => ({ chars: countChapterChars(value) }),
       )
       assertNotAborted(signal)
     }
@@ -306,17 +607,57 @@ export async function runDeepChapterGeneration(
       "",
       `初稿生成完成,约 ${countChapterChars(draftContent)} 字。`,
     ].join("\n")))
+    emitDeepChapterActivity(callbacks, {
+      id: `deep_chapter:generate_draft:output:${Date.now()}`,
+      stageId: "generate_draft",
+      kind: "stage_output",
+      title: "章节草稿",
+      content: `正文初稿完成,约 ${countChapterChars(draftContent)} 字。`,
+    })
     callbacks.onCheckpoint?.(createResumeCheckpoint(input, "after_draft", { taskBrief, draftContent }))
   }
 
   let reviewResults = hasCheckpointReview(resumeCheckpoint) ? resumeCheckpoint.reviewResults : []
+  emitDeepChapterStageStarted(callbacks, "validate_revision", "校验与修正", "检查正文完整性、剧情连续性、人物一致性和阻断问题。")
+  const shouldRunAiReview = workflowProfile.runAiReview && (workflowProfile.mode === "strict" || novelConfig.deepChapterReview)
   if (!hasCheckpointReview(resumeCheckpoint)) {
-    if (!novelConfig.deepChapterReview) {
+    if (!shouldRunAiReview) {
+      completeChapterWorkflowStep(
+        callbacks,
+        {
+          name: "chapter_review",
+          title: "执行 AI 审稿",
+          detail: "根据当前模式决定是否执行 AI 审稿。",
+          params: workflowBaseParams,
+        },
+        workflowProfile.mode === "fast"
+          ? "快速模式跳过 AI 审稿、返修和最终去AI味,直接使用阶段3正文初稿。"
+          : "标准模式跳过 AI 审稿与自动返修,初稿将进入阶段6简单审查与去AI味。",
+        { skipped: true },
+      )
       callbacks.onThinking?.(formatStageThinking(
         "阶段4-5:已跳过审稿与返修",
-        "已按设置关闭 AI 审稿,初稿将直接进入阶段6简单审查与去AI味。",
+        workflowProfile.mode === "fast"
+          ? "快速模式跳过 AI 审稿、返修和最终去AI味,直接使用阶段3正文初稿。"
+          : "标准模式跳过 AI 审稿与自动返修,初稿将进入阶段6简单审查与去AI味。",
       ))
+      emitDeepChapterActivity(callbacks, {
+        id: `deep_chapter:validate_revision:analysis:${Date.now()}`,
+        stageId: "validate_revision",
+        kind: "analysis",
+        title: "审稿分析",
+        content: workflowProfile.mode === "fast"
+          ? "快速模式跳过 AI 审稿、返修和最终去AI味,直接使用阶段3正文初稿。"
+          : "标准模式跳过 AI 审稿与自动返修,初稿将进入阶段6简单审查与去AI味。",
+      })
     } else {
+      const reviewWorkflowStep: ChapterWorkflowStepSpec = {
+        name: "chapter_review",
+        title: "执行 AI 审稿",
+        detail: "检查正文完整性、剧情连续性、截断和阻断问题。",
+        params: workflowBaseParams,
+      }
+      startChapterWorkflowStep(callbacks, reviewWorkflowStep)
       callbacks.onThinking?.(formatStageThinking(
         "阶段4:AI审稿",
         "正在检查正文完整性、剧情连续性、是否被截断以及是否存在阻断问题。",
@@ -333,7 +674,25 @@ export async function runDeepChapterGeneration(
       }
       reviewResults = reviewResults || []
       assertNotAborted(signal)
+      completeChapterWorkflowStep(
+        callbacks,
+        reviewWorkflowStep,
+        reviewResults.length === 0
+          ? "AI 审稿完成,未发现阻断问题。"
+          : `AI 审稿完成,发现 ${reviewResults.length} 个问题,其中 ${reviewResults.filter((item) => item.severity === "error").length} 个阻断问题。`,
+        {
+          issueCount: reviewResults.length,
+          blockingIssueCount: reviewResults.filter((item) => item.severity === "error").length,
+        },
+      )
       callbacks.onThinking?.(formatReviewThinking(reviewResults))
+      emitDeepChapterActivity(callbacks, {
+        id: `deep_chapter:validate_revision:analysis:${Date.now()}`,
+        stageId: "validate_revision",
+        kind: "analysis",
+        title: "审稿分析",
+        content: formatReviewThinking(reviewResults).replace(/^##\s*阶段4:AI审稿\s*/, "").trim(),
+      })
       callbacks.onCheckpoint?.(createResumeCheckpoint(input, "after_review", { taskBrief, draftContent, reviewResults }))
     }
   }
@@ -346,33 +705,58 @@ export async function runDeepChapterGeneration(
     currentContent = resumeCheckpoint.currentContent.trim()
     revised = true
   } else if (blockingIssues.length === 0) {
-    if (novelConfig.deepChapterReview) {
+    if (shouldRunAiReview) {
+      completeChapterWorkflowStep(
+        callbacks,
+        {
+          name: "chapter_revision",
+          title: "自动返修",
+          detail: "根据 AI 审稿结果决定是否返修。",
+          params: workflowBaseParams,
+        },
+        "AI审稿未发现阻断问题,跳过自动返修。",
+        { skipped: true },
+      )
       callbacks.onThinking?.(formatStageThinking(
         "阶段5:无需自动返修",
         "AI审稿未发现阻断问题,跳过自动返修,进入阶段6简单审查与去AI味。",
       ))
     }
   } else {
-    const revisedContent = await collectModelText(
-      writingConfig,
-      [{
-        role: "user",
-        content: buildDeepChapterRevisionPrompt(
-          outlinePrompt,
-          contextPrompt,
-          taskBrief,
-          draftContent,
-          blockingIssues,
-          input.userRequest,
-          input.chapterNumber,
-          input.goldenThreeChapter,
-        ),
-      }],
-      deps,
-      signal,
-      (partial) => callbacks.onThinking?.(formatStageThinking("阶段5:自动返修", partial)),
-      { max_tokens: lengthSpec.maxOutputTokens },
-      cachePrefix,
+    const revisedContent = await runChapterWorkflowStep(
+      callbacks,
+      {
+        name: "chapter_revision",
+        title: "自动返修",
+        detail: "根据阻断问题自动返修一次。",
+        params: {
+          ...workflowBaseParams,
+          blockingIssueCount: blockingIssues.length,
+        },
+      },
+      () => collectModelText(
+        writingConfig,
+        [{
+          role: "user",
+          content: buildDeepChapterRevisionPrompt(
+            outlinePrompt,
+            contextPrompt,
+            taskBrief,
+            draftContent,
+            blockingIssues,
+            input.userRequest,
+            input.chapterNumber,
+            input.goldenThreeChapter,
+          ),
+        }],
+        deps,
+        signal,
+        (partial) => callbacks.onThinking?.(formatStageThinking("阶段5:自动返修", partial)),
+        undefined,
+        cachePrefix,
+      ),
+      (value) => `检测到 ${blockingIssues.length} 个阻断问题,已自动返修一次。返修后正文约 ${countChapterChars(value)} 字。`,
+      (value) => ({ chars: countChapterChars(value) }),
     )
     assertNotAborted(signal)
     callbacks.onThinking?.(formatStageThinking(
@@ -395,8 +779,24 @@ export async function runDeepChapterGeneration(
     }))
   }
 
+  emitDeepChapterActivity(callbacks, {
+    id: `deep_chapter:validate_revision:output:${Date.now()}`,
+    stageId: "validate_revision",
+    kind: "stage_output",
+    title: "修正结果",
+    content: revised ? "已根据阻断问题完成一次自动返修。" : "未执行自动返修,当前正文进入最终审查。",
+  })
+
   // 阶段5.5:返修后复审(只在发生了返修时执行,只审查角色一致性维度,降低token消耗,不再自动返修避免循环)
-  if (revised && novelConfig.deepChapterReview) {
+  const shouldRunPostRevisionReview = workflowProfile.runPostRevisionReview && (workflowProfile.mode === "strict" || novelConfig.deepChapterReview)
+  if (revised && shouldRunPostRevisionReview) {
+    const postRevisionWorkflowStep: ChapterWorkflowStepSpec = {
+      name: "chapter_post_revision_review",
+      title: "返修后复审",
+      detail: "对返修后的正文做角色一致性专项复审。",
+      params: workflowBaseParams,
+    }
+    startChapterWorkflowStep(callbacks, postRevisionWorkflowStep)
     callbacks.onThinking?.(formatStageThinking(
       "阶段5.5:返修后角色一致性复审",
       "正在对返修后的正文进行角色一致性专项复审(轻量模式,只检查角色相关维度),确认返修是否引入新的角色偏差。",
@@ -424,32 +824,80 @@ export async function runDeepChapterGeneration(
           "返修后复审未发现新的阻断问题,进入阶段6。",
         ))
       }
+      completeChapterWorkflowStep(
+        callbacks,
+        postRevisionWorkflowStep,
+        `返修后复审完成,发现 ${postBlockingIssues.length} 个阻断问题。`,
+        { blockingIssueCount: postBlockingIssues.length },
+      )
     } catch (err) {
       console.error("[Deep Chapter] 返修后复审失败:", err)
+      completeChapterWorkflowStep(callbacks, postRevisionWorkflowStep, "返修后复审失败,已继续进入后续阶段。", {
+        failed: true,
+      })
     }
   }
 
-  const finalContent = await finalPolishChapter(
-    writingConfig,
-    outlinePrompt,
-    contextPrompt,
-    taskBrief,
-    currentContent,
-    input,
-    contextPack,
-    callbacks,
-    deps,
-    signal,
-    customDeAiSkill || undefined,
-    lengthSpec,
-    cachePrefix,
-  )
+  const finalPolishWorkflowStep: ChapterWorkflowStepSpec = {
+    name: "chapter_final_polish",
+    title: "简单审查与去AI味",
+    detail: "做最后一遍简单审查,减少复读、机械套话和 AI 味。",
+    params: workflowBaseParams,
+  }
+  const finalContent = workflowProfile.runFinalPolish
+    ? await runChapterWorkflowStep(
+        callbacks,
+        finalPolishWorkflowStep,
+        () => finalPolishChapter(
+          writingConfig,
+          outlinePrompt,
+          contextPrompt,
+          taskBrief,
+          currentContent,
+          input,
+          contextPack,
+          callbacks,
+          deps,
+          signal,
+          customDeAiSkill || undefined,
+          cachePrefix,
+        ),
+        (value) => `简单审查与去AI味完成,最终正文约 ${countChapterChars(value)} 字。`,
+        (value) => ({ chars: countChapterChars(value) }),
+      )
+    : currentContent
+  if (!workflowProfile.runFinalPolish) {
+    completeChapterWorkflowStep(
+      callbacks,
+      finalPolishWorkflowStep,
+      "快速模式跳过最终去AI味,直接采用阶段3正文作为最终正文。",
+      { skipped: true, chars: countChapterChars(finalContent) },
+    )
+  }
   callbacks.onThinking?.(formatStageThinking(
     "阶段7:完成",
-    revised
-      ? "采用返修并完成简单审查、去AI味后的正文作为最终正文。"
-      : "未发现阻断问题,已完成最后一遍简单审查与去AI味。",
+    workflowProfile.runFinalPolish
+      ? (revised
+          ? "采用返修并完成简单审查、去AI味后的正文作为最终正文。"
+          : "未发现阻断问题,已完成最后一遍简单审查与去AI味。")
+      : "快速模式已完成任务书与正文初稿生成,直接采用阶段3正文作为最终正文。",
   ))
+  emitDeepChapterActivity(callbacks, {
+    id: `deep_chapter:final_output:output:${Date.now()}`,
+    stageId: "final_output",
+    kind: "final_output",
+    title: "最终正文",
+    content: `最终正文已生成,约 ${countChapterChars(finalContent)} 字。`,
+  })
+  completeChapterWorkflowStep(callbacks, {
+    name: "chapter_complete",
+    title: "完成多任务写作循环",
+    detail: "汇总本次章节生成结果。",
+    params: workflowBaseParams,
+  }, `多任务写作循环完成,最终正文约 ${countChapterChars(finalContent)} 字。`, {
+    chars: countChapterChars(finalContent),
+    revised,
+  })
   callbacks.onFinalContent?.(finalContent)
   return {
     finalContent,
@@ -472,7 +920,6 @@ async function finalPolishChapter(
   deps: DeepChapterGenerationDeps,
   signal?: AbortSignal,
   customDeAiSkill?: string,
-  lengthSpec: ChapterLengthSpec = resolveChapterLengthSpec(),
   cachePrefix?: string,
 ): Promise<string> {
   assertNotAborted(signal)
@@ -495,7 +942,7 @@ async function finalPolishChapter(
     deps,
     signal,
     (partial) => callbacks.onThinking?.(formatStageThinking("阶段6:简单审查与去AI味", partial)),
-    { max_tokens: lengthSpec.maxOutputTokens },
+    undefined,
     cachePrefix,
   )
   assertNotAborted(signal)
@@ -564,47 +1011,63 @@ async function collectModelText(
 
   assertNotAborted(signal)
 
-  await deps.streamChat(
-    config,
-    applyCachePrefix(messages, cachePrefix),
-    {
-      onToken: (token) => {
-        if (signal?.aborted) {
-          stopStream(USER_ABORT_MESSAGE)
-          return
-        }
-        content += token
-        const loopStart = findRepeatedTailStart(content)
-        if (loopStart !== null) {
-          content = content.slice(0, loopStart).trimEnd()
-          onUpdate?.(`${content}\n\n(已检测到模型重复输出,已自动停止重复内容。)`)
-          stopStream("检测到模型重复输出,已自动停止重复内容。")
-          return
-        }
-        onUpdate?.(content)
-      },
-      onReasoningToken: (token) => {
-        if (signal?.aborted) {
-          stopStream(USER_ABORT_MESSAGE)
-          return
-        }
-        // 推理 token 只用于进度显示,不计入最终 content
-        reasoningBuffer += token
-        if (!content) {
-          onUpdate?.(reasoningBuffer)
-        }
-      },
-      onDone: () => {},
-      onError: (error) => {
-        streamError = error
-      },
+  const callbacks: StreamCallbacks = {
+    onToken: (token) => {
+      if (signal?.aborted) {
+        stopStream(USER_ABORT_MESSAGE)
+        return
+      }
+      content += token
+      const loopStart = findRepeatedTailStart(content)
+      if (loopStart !== null) {
+        content = content.slice(0, loopStart).trimEnd()
+        onUpdate?.(`${content}\n\n(已检测到模型重复输出,已自动停止重复内容。)`)
+        stopStream("检测到模型重复输出,已自动停止重复内容。")
+        return
+      }
+      onUpdate?.(content)
     },
-    combinedSignal,
-    {
-      ...requestOverrides,
-      reasoning: requestOverrides?.reasoning ?? config.reasoning,
+    onReasoningToken: (token) => {
+      if (signal?.aborted) {
+        stopStream(USER_ABORT_MESSAGE)
+        return
+      }
+      // 推理 token 只用于进度显示,不计入最终 content
+      reasoningBuffer += token
+      if (!content) {
+        onUpdate?.(reasoningBuffer)
+      }
     },
-  )
+    onDone: () => {},
+    onError: (error) => {
+      streamError = error
+    },
+  }
+
+  const streamOnce = async (effectiveOverrides?: RequestOverrides) => {
+    streamError = null
+    cutoffReason = null
+    await deps.streamChat(
+      config,
+      applyCachePrefix(messages, cachePrefix),
+      callbacks,
+      combinedSignal,
+      effectiveOverrides,
+    )
+  }
+
+  await streamOnce(requestOverrides)
+
+  if (
+    streamError &&
+    isReasoningOnlyResponseError(streamError) &&
+    !isReasoningDisabled(config, requestOverrides)
+  ) {
+    content = ""
+    reasoningBuffer = ""
+    onUpdate?.("模型只返回思考过程,正在自动切换为非推理模式重试当前阶段。")
+    await streamOnce(withReasoningDisabled(requestOverrides))
+  }
 
   if (signal?.aborted) throw new Error(USER_ABORT_MESSAGE)
   if (streamError && !(cutoffReason && isRequestCancelledError(streamError))) throw streamError
@@ -626,6 +1089,10 @@ function isRequestCancelledError(error: Error): boolean {
   return /request cancelled|request canceled|aborted|aborterror/i.test(error.message)
 }
 
+function getErrorMessage(error: unknown): string {
+  return error instanceof Error ? error.message : String(error)
+}
+
 function combineAbortSignals(...signals: Array<AbortSignal | undefined>): AbortSignal | undefined {
   const activeSignals = signals.filter(Boolean) as AbortSignal[]
   if (activeSignals.length === 0) return undefined
@@ -696,6 +1163,21 @@ function formatContextThinking(input: DeepChapterGenerationInput, pack: ContextP
   )
 }
 
+function formatContextExtractionResult(input: DeepChapterGenerationInput, pack: ContextPack): string {
+  const recentSummaries = Array.isArray(pack.recentSummaries) ? pack.recentSummaries : []
+  return [
+    input.chapterNumber ? `目标章节:第${input.chapterNumber}章` : "目标章节:从用户请求识别",
+    `章节目标:${fallback(pack.chapterGoal, "未读取到明确章节目标")}`,
+    `上一章结尾:${fallback(pack.previousChapterEnding, "未读取到上一章结尾")}`,
+    `近期剧情:${recentSummaries.length} 条`,
+    `人物状态:${summaryText(pack.characterStates)}`,
+    `伏笔状态:${summaryText(pack.foreshadowingStates)}`,
+    `时间线:${summaryText(pack.timeline)}`,
+    `必须完成:${fallback(pack.mustDo, "暂无明确必做项")}`,
+    `禁止违背:${fallback(pack.mustAvoid, "暂无明确禁止项")}`,
+  ].join("\n")
+}
+
 function formatReviewThinking(reviewResults: NovelReviewResult[]): string {
   if (reviewResults.length === 0) {
     return formatStageThinking("阶段4:AI审稿", "未发现阻断问题。")

+ 352 - 0
src/lib/novel/story-simulation/multi-agent-orchestrator.spec.ts

@@ -0,0 +1,352 @@
+import { describe, expect, it } from "vitest"
+import { readFileSync } from "node:fs"
+import { resolve } from "node:path"
+
+import type {
+  ModeConfig,
+  NovelAgent,
+  SimulationState,
+  StoryNode,
+  TimelineEvent,
+} from "@/lib/novel/story-simulation/types"
+import {
+  createSimulationBlackboard,
+  createBlackboardDebugTrace,
+  getBlackboardVisibleEvents,
+  planMultiAgentRound,
+  recordBlackboardEvent,
+  selectNodeAgentCandidates,
+} from "@/lib/novel/story-simulation/multi-agent-orchestrator"
+
+function makeAgent(id: string, name: string): NovelAgent {
+  return {
+    characterId: id,
+    name,
+    profile: `${name} profile`,
+    aura: null,
+    cognition: null,
+    soul: "",
+    currentGoal: "完成当前节点目标",
+    emotionalState: "neutral",
+    knownFacts: new Set(),
+    relationships: new Map(),
+    powerLevel: "normal",
+    memory: {
+      observedEvents: [],
+      knownSecrets: new Set(),
+      sentiments: new Map(),
+      recentDecisions: [],
+    },
+    knowledgeScope: [],
+    personality: [],
+    speakingStyle: "",
+  }
+}
+
+function makeTimelineEvent(
+  id: string,
+  observableBy: string[],
+): TimelineEvent {
+  return {
+    id,
+    round: 0,
+    nodeIndex: 0,
+    actorId: "a",
+    actorName: "甲",
+    actionType: "speak",
+    content: id,
+    observableBy,
+    impacts: [],
+    timestamp: "2026-07-03T00:00:00.000Z",
+  }
+}
+
+function makeNode(): StoryNode {
+  return {
+    index: 0,
+    phase: "起",
+    title: "开端",
+    coreConflict: "冲突",
+    involvedCharacters: ["甲", "乙", "丙"],
+    goal: "推进剧情",
+    causeFromPrev: "无",
+    expectedOutcome: "完成开端",
+  }
+}
+
+function makeModeConfig(agentSubsetRatio: number): ModeConfig {
+  return {
+    roundsMultiplier: 1,
+    behaviorHint: "",
+    randomEventChance: 0,
+    agentSubsetRatio,
+    strictNodeProgression: true,
+  }
+}
+
+function makeState(agents: NovelAgent[]): SimulationState {
+  return {
+    currentRound: 0,
+    timelineEvents: [],
+    activeAgents: new Map(agents.map((agent) => [agent.characterId, agent])),
+    worldState: {},
+  }
+}
+
+describe("createSimulationBlackboard", () => {
+  it("indexes active agents and initial timeline events by visibility", () => {
+    const agents = [makeAgent("a", "甲"), makeAgent("b", "乙"), makeAgent("c", "丙")]
+    const publicEvent = makeTimelineEvent("public", ["a", "b", "c"])
+    const privateEvent = makeTimelineEvent("private", ["a", "b"])
+
+    const blackboard = createSimulationBlackboard({
+      agents,
+      timelineEvents: [publicEvent, privateEvent],
+    })
+
+    expect([...blackboard.activeAgents.keys()]).toEqual(["a", "b", "c"])
+    expect(blackboard.events.map((event) => event.id)).toEqual(["public", "private"])
+    expect(blackboard.publicEvents.map((event) => event.id)).toEqual(["public"])
+    expect(blackboard.visibleEventsByAgent.get("a")?.map((event) => event.id)).toEqual([
+      "public",
+      "private",
+    ])
+    expect(blackboard.visibleEventsByAgent.get("c")?.map((event) => event.id)).toEqual([
+      "public",
+    ])
+  })
+
+  it("keeps the full agent roster separate from the current active agents", () => {
+    const agents = [makeAgent("a", "甲"), makeAgent("b", "乙"), makeAgent("c", "丙")]
+
+    const blackboard = createSimulationBlackboard({ agents })
+    blackboard.activeAgents = new Map([["a", agents[0]]])
+
+    expect([...blackboard.allAgents.keys()]).toEqual(["a", "b", "c"])
+    expect([...blackboard.activeAgents.keys()]).toEqual(["a"])
+  })
+
+  it("records later events into shared and per-agent blackboard state", () => {
+    const agents = [makeAgent("a", "甲"), makeAgent("b", "乙")]
+    const blackboard = createSimulationBlackboard({ agents })
+    const event = makeTimelineEvent("later", ["b"])
+
+    recordBlackboardEvent(blackboard, event)
+
+    expect(blackboard.events.map((item) => item.id)).toEqual(["later"])
+    expect(blackboard.publicEvents).toEqual([])
+    expect(blackboard.visibleEventsByAgent.get("a")).toEqual([])
+    expect(blackboard.visibleEventsByAgent.get("b")?.map((item) => item.id)).toEqual([
+      "later",
+    ])
+  })
+})
+
+describe("selectNodeAgentCandidates", () => {
+  it("selects node participants from the full agent roster instead of current active agents", () => {
+    const agents = [makeAgent("a", "甲"), makeAgent("b", "乙"), makeAgent("c", "丙")]
+    const blackboard = createSimulationBlackboard({ agents })
+    blackboard.activeAgents = new Map([["a", agents[0]]])
+    const node = {
+      ...makeNode(),
+      involvedCharacters: ["丙"],
+    }
+
+    const candidates = selectNodeAgentCandidates(blackboard, node)
+
+    expect(candidates.map((agent) => agent.characterId)).toEqual(["c"])
+  })
+
+  it("falls back to all agents when a node does not match any participant names", () => {
+    const agents = [makeAgent("a", "甲"), makeAgent("b", "乙")]
+    const blackboard = createSimulationBlackboard({ agents })
+    const node = {
+      ...makeNode(),
+      involvedCharacters: ["不存在的人"],
+    }
+
+    const candidates = selectNodeAgentCandidates(blackboard, node)
+
+    expect(candidates.map((agent) => agent.characterId)).toEqual(["a", "b"])
+  })
+})
+
+describe("getBlackboardVisibleEvents", () => {
+  it("returns only events visible to the requested agent", () => {
+    const agents = [makeAgent("a", "甲"), makeAgent("b", "乙")]
+    const visible = makeTimelineEvent("visible", ["a"])
+    const hidden = makeTimelineEvent("hidden", ["b"])
+    const blackboard = createSimulationBlackboard({
+      agents,
+      timelineEvents: [visible, hidden],
+    })
+
+    expect(getBlackboardVisibleEvents(blackboard, "a").map((event) => event.id)).toEqual([
+      "visible",
+    ])
+  })
+
+  it("keeps the newest visible events when a limit is provided", () => {
+    const agents = [makeAgent("a", "甲")]
+    const blackboard = createSimulationBlackboard({
+      agents,
+      timelineEvents: [
+        makeTimelineEvent("old", ["a"]),
+        makeTimelineEvent("middle", ["a"]),
+        makeTimelineEvent("new", ["a"]),
+      ],
+    })
+
+    expect(getBlackboardVisibleEvents(blackboard, "a", 2).map((event) => event.id)).toEqual([
+      "middle",
+      "new",
+    ])
+  })
+})
+
+describe("createBlackboardDebugTrace", () => {
+  it("summarizes round planning and blackboard visibility without exposing mutable maps", () => {
+    const agents = [makeAgent("a", "甲"), makeAgent("b", "乙"), makeAgent("c", "丙")]
+    const publicEvent = makeTimelineEvent("public", ["a", "b", "c"])
+    const privateEvent = makeTimelineEvent("private", ["a", "b"])
+    const blackboard = createSimulationBlackboard({
+      agents,
+      timelineEvents: [publicEvent, privateEvent],
+    })
+    const node = makeNode()
+    const plan = planMultiAgentRound({
+      node,
+      state: makeState(agents),
+      candidateAgents: [agents[0], agents[2]],
+      modeConfig: makeModeConfig(1),
+      round: 1,
+    })
+
+    const trace = createBlackboardDebugTrace(blackboard, {
+      type: "round-plan",
+      node,
+      round: 1,
+      candidateAgents: [agents[0], agents[2]],
+      plan,
+      timestamp: "2026-07-03T01:00:00.000Z",
+    })
+
+    expect(trace.type).toBe("round-plan")
+    expect(trace.nodeIndex).toBe(0)
+    expect(trace.nodeTitle).toBe("开端")
+    expect(trace.round).toBe(1)
+    expect(trace.strategy).toBe("all-agents")
+    expect(trace.blackboard).toEqual({
+      allAgentCount: 3,
+      activeAgentCount: 3,
+      totalEventCount: 2,
+      publicEventCount: 1,
+    })
+    expect(trace.candidateAgents.map((agent) => agent.agentId)).toEqual(["a", "c"])
+    expect(trace.selectedAgents.map((agent) => agent.agentId)).toEqual(["a", "c"])
+    expect(trace.selectedAgents[0].reason).toBe("模式要求全部角色参与")
+    expect(trace.visibilityByAgent.find((agent) => agent.agentId === "a")).toMatchObject({
+      agentName: "甲",
+      visibleEventCount: 2,
+    })
+    expect(
+      trace.visibilityByAgent.find((agent) => agent.agentId === "c")!.recentEvents!.map(
+        (event) => event.id,
+      ),
+    ).toEqual(["public"])
+    expect(Object.prototype.hasOwnProperty.call(trace, "activeAgents")).toBe(false)
+  })
+
+  it("limits each agent recent visible event summaries to the newest events", () => {
+    const agents = [makeAgent("a", "甲")]
+    const blackboard = createSimulationBlackboard({
+      agents,
+      timelineEvents: [
+        makeTimelineEvent("old", ["a"]),
+        makeTimelineEvent("middle", ["a"]),
+        makeTimelineEvent("new", ["a"]),
+      ],
+    })
+
+    const trace = createBlackboardDebugTrace(blackboard, {
+      type: "event-recorded",
+      node: makeNode(),
+      round: 0,
+      latestEvent: makeTimelineEvent("new", ["a"]),
+      visibleEventLimit: 2,
+      timestamp: "2026-07-03T01:00:00.000Z",
+    })
+
+    expect(trace.latestEvent?.id).toBe("new")
+    expect(trace.visibilityByAgent[0].recentEvents!.map((event) => event.id)).toEqual([
+      "middle",
+      "new",
+    ])
+  })
+})
+
+describe("planMultiAgentRound", () => {
+  it("schedules all candidate agents in order when the mode uses the full agent set", () => {
+    const agents = [makeAgent("a", "甲"), makeAgent("b", "乙"), makeAgent("c", "丙")]
+
+    const plan = planMultiAgentRound({
+      node: makeNode(),
+      state: makeState(agents),
+      candidateAgents: agents,
+      modeConfig: makeModeConfig(1),
+      round: 2,
+    })
+
+    expect(plan.round).toBe(2)
+    expect(plan.nodeIndex).toBe(0)
+    expect(plan.turns.map((turn) => turn.agentId)).toEqual(["a", "b", "c"])
+    expect(plan.turns.map((turn) => turn.agentName)).toEqual(["甲", "乙", "丙"])
+  })
+
+  it("schedules only a mode-sized subset when the mode uses partial agents", () => {
+    const agents = [makeAgent("a", "甲"), makeAgent("b", "乙"), makeAgent("c", "丙")]
+
+    const plan = planMultiAgentRound({
+      node: makeNode(),
+      state: makeState(agents),
+      candidateAgents: agents,
+      modeConfig: makeModeConfig(0.5),
+      round: 0,
+      random: () => 0.9,
+    })
+
+    expect(plan.turns).toHaveLength(2)
+    expect(plan.turns.every((turn) => ["a", "b", "c"].includes(turn.agentId))).toBe(true)
+  })
+
+  it("returns an empty plan when there are no candidate agents", () => {
+    const plan = planMultiAgentRound({
+      node: makeNode(),
+      state: makeState([]),
+      candidateAgents: [],
+      modeConfig: makeModeConfig(1),
+      round: 0,
+    })
+
+    expect(plan.turns).toEqual([])
+  })
+})
+
+describe("simulation-engine integration", () => {
+  it("delegates round planning and blackboard recording to the orchestrator module", () => {
+    const source = readFileSync(
+      resolve(process.cwd(), "src/lib/novel/story-simulation/simulation-engine.ts"),
+      "utf8",
+    )
+
+    expect(source).toContain("planMultiAgentRound")
+    expect(source).toContain("recordBlackboardEvent")
+    expect(source).toContain("selectNodeAgentCandidates")
+    expect(source).toContain("getBlackboardVisibleEvents")
+    expect(source).toContain("createBlackboardDebugTrace")
+    expect(source).toContain("onDebugTrace?:")
+    expect(source).toContain("callbacks.onDebugTrace?.")
+    expect(source).not.toContain("Math.random() - 0.5")
+    expect(source).not.toContain("state.timelineEvents,\n    10,")
+  })
+})

+ 258 - 0
src/lib/novel/story-simulation/multi-agent-orchestrator.ts

@@ -0,0 +1,258 @@
+import type {
+  ModeConfig,
+  NovelAgent,
+  SimulationDebugTrace,
+  SimulationDebugVisibleEvent,
+  SimulationState,
+  StoryNode,
+  TimelineEvent,
+} from "@/lib/novel/story-simulation/types"
+
+export interface AgentTurnPlan {
+  agentId: string
+  agentName: string
+  round: number
+  nodeIndex: number
+  role: "actor"
+  reason: string
+}
+
+export interface MultiAgentRoundPlan {
+  round: number
+  nodeIndex: number
+  strategy: "all-agents" | "subset" | "none"
+  turns: AgentTurnPlan[]
+}
+
+export interface SimulationBlackboard {
+  allAgents: Map<string, NovelAgent>
+  activeAgents: Map<string, NovelAgent>
+  events: TimelineEvent[]
+  publicEvents: TimelineEvent[]
+  visibleEventsByAgent: Map<string, TimelineEvent[]>
+  roundPlans: MultiAgentRoundPlan[]
+}
+
+export interface CreateSimulationBlackboardInput {
+  agents: NovelAgent[]
+  timelineEvents?: TimelineEvent[]
+}
+
+export interface PlanMultiAgentRoundInput {
+  node: StoryNode
+  state: SimulationState
+  candidateAgents: NovelAgent[]
+  modeConfig: ModeConfig
+  round: number
+  random?: () => number
+}
+
+export interface CreateBlackboardDebugTraceInput {
+  type: SimulationDebugTrace["type"]
+  node: StoryNode
+  round: number
+  candidateAgents?: NovelAgent[]
+  plan?: MultiAgentRoundPlan
+  latestEvent?: TimelineEvent
+  visibleEventLimit?: number
+  timestamp?: string
+}
+
+export function createSimulationBlackboard(
+  input: CreateSimulationBlackboardInput,
+): SimulationBlackboard {
+  const activeAgents = new Map(
+    input.agents.map((agent) => [agent.characterId, agent] as const),
+  )
+  const blackboard: SimulationBlackboard = {
+    allAgents: new Map(activeAgents),
+    activeAgents,
+    events: [],
+    publicEvents: [],
+    visibleEventsByAgent: new Map(
+      input.agents.map((agent) => [agent.characterId, [] as TimelineEvent[]]),
+    ),
+    roundPlans: [],
+  }
+
+  for (const event of input.timelineEvents ?? []) {
+    recordBlackboardEvent(blackboard, event)
+  }
+
+  return blackboard
+}
+
+export function selectNodeAgentCandidates(
+  blackboard: SimulationBlackboard,
+  node: StoryNode,
+): NovelAgent[] {
+  const allAgents = Array.from(blackboard.allAgents.values())
+  if (allAgents.length === 0) return []
+
+  const involved = new Set(node.involvedCharacters.map((name) => name.trim()))
+  const matched = allAgents.filter(
+    (agent) => involved.has(agent.name) || involved.has(agent.characterId),
+  )
+
+  return matched.length > 0 ? matched : allAgents
+}
+
+export function getBlackboardVisibleEvents(
+  blackboard: SimulationBlackboard,
+  agentId: string,
+  limit?: number,
+): TimelineEvent[] {
+  const events = blackboard.visibleEventsByAgent.get(agentId) ?? []
+  if (limit === undefined || limit <= 0 || events.length <= limit) {
+    return [...events]
+  }
+  return events.slice(-limit)
+}
+
+export function createBlackboardDebugTrace(
+  blackboard: SimulationBlackboard,
+  input: CreateBlackboardDebugTraceInput,
+): SimulationDebugTrace {
+  const timestamp = input.timestamp ?? new Date().toISOString()
+  const visibleEventLimit = input.visibleEventLimit ?? 3
+
+  return {
+    id: `debug-${input.type}-${input.node.index}-${input.round}-${timestamp}`,
+    type: input.type,
+    nodeIndex: input.node.index,
+    nodeTitle: input.node.title,
+    round: input.round,
+    strategy: input.plan?.strategy,
+    candidateAgents: (input.candidateAgents ?? []).map((agent) => ({
+      agentId: agent.characterId,
+      agentName: agent.name,
+    })),
+    selectedAgents: (input.plan?.turns ?? []).map((turn) => ({
+      agentId: turn.agentId,
+      agentName: turn.agentName,
+      reason: turn.reason,
+    })),
+    blackboard: {
+      allAgentCount: blackboard.allAgents.size,
+      activeAgentCount: blackboard.activeAgents.size,
+      totalEventCount: blackboard.events.length,
+      publicEventCount: blackboard.publicEvents.length,
+    },
+    visibilityByAgent: Array.from(blackboard.allAgents.values()).map((agent) => {
+      const allVisibleEvents = blackboard.visibleEventsByAgent.get(agent.characterId) ?? []
+      return {
+        agentId: agent.characterId,
+        agentName: agent.name,
+        visibleEventCount: allVisibleEvents.length,
+        recentEvents: getBlackboardVisibleEvents(
+          blackboard,
+          agent.characterId,
+          visibleEventLimit,
+        ).map(toDebugVisibleEvent),
+      }
+    }),
+    latestEvent: input.latestEvent ? toDebugVisibleEvent(input.latestEvent) : undefined,
+    timestamp,
+  }
+}
+
+export function recordBlackboardEvent(
+  blackboard: SimulationBlackboard,
+  event: TimelineEvent,
+): void {
+  blackboard.events.push(event)
+
+  for (const agentId of event.observableBy) {
+    if (!blackboard.visibleEventsByAgent.has(agentId)) {
+      blackboard.visibleEventsByAgent.set(agentId, [])
+    }
+    blackboard.visibleEventsByAgent.get(agentId)!.push(event)
+  }
+
+  if (isPublicEvent(blackboard, event)) {
+    blackboard.publicEvents.push(event)
+  }
+}
+
+export function planMultiAgentRound(
+  input: PlanMultiAgentRoundInput,
+): MultiAgentRoundPlan {
+  const eligibleAgents = input.candidateAgents.filter((agent) =>
+    input.state.activeAgents.has(agent.characterId),
+  )
+
+  if (eligibleAgents.length === 0) {
+    return {
+      round: input.round,
+      nodeIndex: input.node.index,
+      strategy: "none",
+      turns: [],
+    }
+  }
+
+  const ratio = input.modeConfig.agentSubsetRatio
+  const strategy: MultiAgentRoundPlan["strategy"] =
+    ratio >= 1 ? "all-agents" : "subset"
+  const selectedAgents =
+    ratio >= 1
+      ? eligibleAgents
+      : selectAgentSubset(
+          eligibleAgents,
+          Math.max(1, Math.ceil(eligibleAgents.length * Math.max(0, ratio))),
+          input.random ?? Math.random,
+        )
+
+  const turns: AgentTurnPlan[] = selectedAgents.map((agent) => ({
+    agentId: agent.characterId,
+    agentName: agent.name,
+    round: input.round,
+    nodeIndex: input.node.index,
+    role: "actor",
+    reason: strategy === "all-agents" ? "模式要求全部角色参与" : "模式选择部分角色参与",
+  }))
+
+  return {
+    round: input.round,
+    nodeIndex: input.node.index,
+    strategy,
+    turns,
+  }
+}
+
+function isPublicEvent(
+  blackboard: SimulationBlackboard,
+  event: TimelineEvent,
+): boolean {
+  if (blackboard.activeAgents.size === 0) return false
+  const visibleIds = new Set(event.observableBy)
+  for (const agentId of blackboard.activeAgents.keys()) {
+    if (!visibleIds.has(agentId)) return false
+  }
+  return true
+}
+
+function selectAgentSubset(
+  agents: NovelAgent[],
+  count: number,
+  random: () => number,
+): NovelAgent[] {
+  const shuffled = [...agents]
+  for (let i = shuffled.length - 1; i > 0; i--) {
+    const j = Math.floor(random() * (i + 1))
+    const tmp = shuffled[i]
+    shuffled[i] = shuffled[j]
+    shuffled[j] = tmp
+  }
+  return shuffled.slice(0, Math.min(count, shuffled.length))
+}
+
+function toDebugVisibleEvent(event: TimelineEvent): SimulationDebugVisibleEvent {
+  return {
+    id: event.id,
+    actorName: event.actorName,
+    actionType: event.actionType,
+    content: event.content,
+    round: event.round,
+    nodeIndex: event.nodeIndex,
+  }
+}

+ 75 - 34
src/lib/novel/story-simulation/simulation-engine.ts

@@ -1,10 +1,16 @@
 import type { ChatMessage } from "@/lib/llm-client"
 import { streamChat } from "@/lib/llm-client"
 import type { LlmConfig } from "@/stores/wiki-store"
+import { buildAgentContext } from "@/lib/novel/story-simulation/agent-profile-builder"
 import {
-  buildAgentContext,
-  getVisibleEvents,
-} from "@/lib/novel/story-simulation/agent-profile-builder"
+  createBlackboardDebugTrace,
+  createSimulationBlackboard,
+  getBlackboardVisibleEvents,
+  planMultiAgentRound,
+  recordBlackboardEvent,
+  selectNodeAgentCandidates,
+  type SimulationBlackboard,
+} from "@/lib/novel/story-simulation/multi-agent-orchestrator"
 import type {
   ActionVisibility,
   AgentAction,
@@ -12,6 +18,7 @@ import type {
   EventImpact,
   ExtractionResult,
   NovelAgent,
+  SimulationDebugTrace,
   SimulationEvent,
   SimulationInput,
   SimulationState,
@@ -30,6 +37,8 @@ export interface SimulationCallbacks {
   onError: (error: Error) => void
   /** 新引擎时间线事件回调 */
   onTimelineEvent?: (event: TimelineEvent) => void
+  /** 推演过程观察回调:用于 UI 展示 Agent 调度和 blackboard 状态 */
+  onDebugTrace?: (trace: SimulationDebugTrace) => void
 }
 
 // ── 常量 ──
@@ -688,15 +697,12 @@ async function agentDecideAndAct(
   extraction: ExtractionResult,
   recentEventDescs: string[],
   injectionEvent: string | undefined,
+  blackboard: SimulationBlackboard,
   signal?: AbortSignal,
   modeHint?: string,
 ): Promise<{ parsed: ParsedAction; tlEvent: TimelineEvent; simEvent: SimulationEvent } | null> {
   // 1. 观察:筛选该 Agent 可见的时间线事件
-  const visibleEvents = getVisibleEvents(
-    agent.characterId,
-    state.timelineEvents,
-    10,
-  )
+  const visibleEvents = getBlackboardVisibleEvents(blackboard, agent.characterId, 10)
 
   // 2. 构建上下文(基于认知边界)
   const context = buildAgentContext(
@@ -785,6 +791,9 @@ export async function runSimulation(
     activeAgents: cloneAgentsToMap(agents),
     worldState: {},
   }
+  const blackboard = createSimulationBlackboard({
+    agents: Array.from(state.activeAgents.values()),
+  })
 
   try {
     for (let ni = 0; ni < totalNodes; ni++) {
@@ -795,22 +804,7 @@ export async function runSimulation(
 
       const node = framework.nodes[ni]
 
-      // 确定参与角色:从 involvedCharacters(角色名)过滤 agents
-      const nodeAgentIds = new Set<string>()
-      for (const a of agents) {
-        if (node.involvedCharacters.includes(a.name)) {
-          nodeAgentIds.add(a.characterId)
-        }
-      }
-      // 防御:若过滤结果为空,使用全部 agents
-      let nodeAgentList: NovelAgent[]
-      if (nodeAgentIds.size === 0) {
-        nodeAgentList = Array.from(state.activeAgents.values())
-      } else {
-        nodeAgentList = Array.from(state.activeAgents.values()).filter((a) =>
-          nodeAgentIds.has(a.characterId),
-        )
-      }
+      const nodeAgentList = selectNodeAgentCandidates(blackboard, node)
 
       // 设置本轮活跃 Agent
       const activeMap = new Map<string, NovelAgent>()
@@ -818,6 +812,7 @@ export async function runSimulation(
         activeMap.set(a.characterId, a)
       }
       state.activeAgents = activeMap
+      blackboard.activeAgents = activeMap
 
       // 产出 node-start 事件
       const startEvent: SimulationEvent = {
@@ -846,14 +841,26 @@ export async function runSimulation(
 
         state.currentRound = round
 
-        // 根据模式决定本轮活跃 Agent 子集
-        let roundAgentList = nodeAgentList
-        if (modeConfig.agentSubsetRatio < 1 && nodeAgentList.length > 1) {
-          const subsetSize = Math.max(1, Math.ceil(nodeAgentList.length * modeConfig.agentSubsetRatio))
-          // 简单的随机选择:打乱后取前 N 个
-          const shuffled = [...nodeAgentList].sort(() => Math.random() - 0.5)
-          roundAgentList = shuffled.slice(0, subsetSize)
-        }
+        const roundPlan = planMultiAgentRound({
+          node,
+          state,
+          candidateAgents: nodeAgentList,
+          modeConfig,
+          round,
+        })
+        blackboard.roundPlans.push(roundPlan)
+        callbacks.onDebugTrace?.(
+          createBlackboardDebugTrace(blackboard, {
+            type: "round-plan",
+            node,
+            round,
+            candidateAgents: nodeAgentList,
+            plan: roundPlan,
+          }),
+        )
+        const roundAgentList = roundPlan.turns
+          .map((turn) => state.activeAgents.get(turn.agentId))
+          .filter((agent): agent is NovelAgent => !!agent)
 
         // a. 每个活跃 Agent 观察并决策
         for (const agent of roundAgentList) {
@@ -876,6 +883,7 @@ export async function runSimulation(
               extraction,
               recentEventDescs,
               round === 0 ? injectionEvent : undefined,
+              blackboard,
               signal,
               modeConfig.behaviorHint,
             )
@@ -904,8 +912,19 @@ export async function runSimulation(
           const { parsed, tlEvent, simEvent } = result
 
           events.push(simEvent)
+          recordBlackboardEvent(blackboard, tlEvent)
           callbacks.onEvent(simEvent)
           callbacks.onTimelineEvent?.(tlEvent)
+          callbacks.onDebugTrace?.(
+            createBlackboardDebugTrace(blackboard, {
+              type: "event-recorded",
+              node,
+              round,
+              candidateAgents: nodeAgentList,
+              plan: roundPlan,
+              latestEvent: tlEvent,
+            }),
+          )
 
           recentEventDescs.push(
             formatActionDescription(parsed.action, currentAgent.name),
@@ -932,6 +951,7 @@ export async function runSimulation(
                 events,
                 callbacks,
                 nodeTimelineEvents,
+                blackboard,
                 signal,
               )
             }
@@ -961,8 +981,19 @@ export async function runSimulation(
               targetName: undefined,
             }
             state.timelineEvents.push(tlEvent)
+            recordBlackboardEvent(blackboard, tlEvent)
             nodeTimelineEvents.push(tlEvent)
             callbacks.onTimelineEvent?.(tlEvent)
+            callbacks.onDebugTrace?.(
+              createBlackboardDebugTrace(blackboard, {
+                type: "event-recorded",
+                node,
+                round,
+                candidateAgents: nodeAgentList,
+                plan: roundPlan,
+                latestEvent: tlEvent,
+              }),
+            )
             recentEventDescs.push(`[系统事件] ${randomEvent.message}`)
           }
         }
@@ -1034,14 +1065,15 @@ async function triggerReaction(
   events: SimulationEvent[],
   callbacks: SimulationCallbacks,
   nodeTimelineEvents: TimelineEvent[],
+  blackboard: SimulationBlackboard,
   signal?: AbortSignal,
 ): Promise<void> {
   if (signal?.aborted) return
 
   // 构建反应专用上下文:强调对刚才事件的反应
-  const visibleEvents = getVisibleEvents(
+  const visibleEvents = getBlackboardVisibleEvents(
+    blackboard,
     targetAgent.characterId,
-    state.timelineEvents,
     10,
   )
 
@@ -1094,7 +1126,16 @@ async function triggerReaction(
     }
 
     state.timelineEvents.push(tlEvent)
+    recordBlackboardEvent(blackboard, tlEvent)
     nodeTimelineEvents.push(tlEvent)
+    callbacks.onDebugTrace?.(
+      createBlackboardDebugTrace(blackboard, {
+        type: "event-recorded",
+        node,
+        round: state.currentRound,
+        latestEvent: tlEvent,
+      }),
+    )
 
     const simEvent = timelineEventToSimulationEvent(
       tlEvent,

+ 37 - 0
src/lib/novel/story-simulation/types.ts

@@ -68,6 +68,43 @@ export interface TimelineEvent {
   timestamp: string
 }
 
+export interface SimulationDebugVisibleEvent {
+  id: string
+  actorName: string
+  actionType: AgentActionType
+  content: string
+  round: number
+  nodeIndex: number
+}
+
+export interface SimulationDebugAgent {
+  agentId: string
+  agentName: string
+  visibleEventCount?: number
+  recentEvents?: SimulationDebugVisibleEvent[]
+  reason?: string
+}
+
+export interface SimulationDebugTrace {
+  id: string
+  type: "round-plan" | "event-recorded"
+  nodeIndex: number
+  nodeTitle: string
+  round: number
+  strategy?: "all-agents" | "subset" | "none"
+  candidateAgents: SimulationDebugAgent[]
+  selectedAgents: SimulationDebugAgent[]
+  blackboard: {
+    allAgentCount: number
+    activeAgentCount: number
+    totalEventCount: number
+    publicEventCount: number
+  }
+  visibilityByAgent: SimulationDebugAgent[]
+  latestEvent?: SimulationDebugVisibleEvent
+  timestamp: string
+}
+
 // ── Agent 记忆 ──
 export interface AgentMemory {
   /** 已观察到的事件 ID 列表 */

+ 23 - 0
src/lib/reasoning-retry.ts

@@ -0,0 +1,23 @@
+import type { LlmConfig, ReasoningConfig } from "@/stores/wiki-store"
+import type { RequestOverrides } from "./llm-providers"
+
+const REASONING_ONLY_RESPONSE_RE = /模型只输出了[\s\S]*思考内容[\s\S]*没有输出正文/
+
+export function isReasoningOnlyResponseError(error: Error): boolean {
+  return REASONING_ONLY_RESPONSE_RE.test(error.message)
+}
+
+export function isReasoningDisabled(
+  config: Pick<LlmConfig, "reasoning">,
+  overrides?: RequestOverrides,
+): boolean {
+  const effectiveReasoning: ReasoningConfig | undefined = overrides?.reasoning ?? config.reasoning
+  return effectiveReasoning?.mode === "off"
+}
+
+export function withReasoningDisabled(overrides?: RequestOverrides): RequestOverrides {
+  return {
+    ...(overrides ?? {}),
+    reasoning: { mode: "off" },
+  }
+}

+ 3 - 5
src/lib/sidebar-nav-preferences.spec.ts

@@ -15,7 +15,6 @@ describe("sidebar nav preferences", () => {
       "lint",
       "soul",
       "skillLibrary",
-      "writingSkillLibrary",
       "bookAnalysis",
       "reviewCenter",
       "storySimulation",
@@ -39,7 +38,6 @@ describe("sidebar nav preferences", () => {
       "lint",
       "soul",
       "skillLibrary",
-      "writingSkillLibrary",
       "bookAnalysis",
       "reviewCenter",
       "storySimulation",
@@ -65,10 +63,10 @@ describe("sidebar nav preferences", () => {
     expect(config.order[0]).toBe("wiki")
   })
 
-  it("includes skill library in the configurable sidebar order", () => {
+  it("keeps writing skills merged into the single skill library entry", () => {
     expect(DEFAULT_SIDEBAR_NAV_ORDER).toContain("skillLibrary")
-    expect(DEFAULT_SIDEBAR_NAV_ORDER).toContain("writingSkillLibrary")
+    expect(DEFAULT_SIDEBAR_NAV_ORDER).not.toContain("writingSkillLibrary")
     expect(normalizeSidebarNavConfig({ order: ["wiki"] }).order).toContain("skillLibrary")
-    expect(normalizeSidebarNavConfig({ order: ["wiki"] }).order).toContain("writingSkillLibrary")
+    expect(normalizeSidebarNavConfig({ order: ["wiki"] }).order).not.toContain("writingSkillLibrary")
   })
 })

+ 0 - 1
src/lib/sidebar-nav-preferences.ts

@@ -5,7 +5,6 @@ export const DEFAULT_SIDEBAR_NAV_ORDER = [
   "lint",
   "soul",
   "skillLibrary",
-  "writingSkillLibrary",
   "bookAnalysis",
   "reviewCenter",
   "storySimulation",

+ 2 - 7
src/stores/chat-store.ts

@@ -1,7 +1,6 @@
 import { create } from "zustand"
 import type { ChatMessage } from "@/lib/llm-client"
-import type { AgentRunRecord } from "@/lib/agent/types"
-import type { TaskBreakpoint } from "@/lib/agent/task-breakpoint"
+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"
@@ -30,6 +29,7 @@ export interface DisplayMessage {
   references?: MessageReference[]  // pages cited in this response, saved at creation time
   discarded?: boolean
   agentToolCalls?: AgentRunRecord["toolCalls"]
+  agentStages?: AgentStageTrace[]
   isAgentRunning?: boolean
   attachedReferences?: ReferenceToken[]
   contextTrace?: ContextTrace
@@ -45,7 +45,6 @@ interface ChatState {
   mode: "chat" | "ingest"
   ingestSource: string | null
   maxHistoryMessages: number
-  lastBreakpoint: TaskBreakpoint | null
 
   // Conversation management
   createConversation: () => string
@@ -74,7 +73,6 @@ interface ChatState {
   setIngestSource: (path: string | null) => void
   clearMessages: () => void
   setMaxHistoryMessages: (n: number) => void
-  setLastBreakpoint: (bp: TaskBreakpoint | null) => void
   removeLastAssistantMessage: () => void  // for regenerate: remove last assistant reply
   markLastAssistantDiscarded: () => void   // for novel draft discard
   enqueueReferenceTokens: (tokens: ReferenceToken[]) => void
@@ -108,7 +106,6 @@ export const useChatStore = create<ChatState>((set, get) => ({
   mode: "chat",
   ingestSource: null,
   maxHistoryMessages: 20,
-  lastBreakpoint: null,
 
   createConversation: () => {
     const id = generateConversationId()
@@ -290,8 +287,6 @@ export const useChatStore = create<ChatState>((set, get) => ({
 
   setMaxHistoryMessages: (maxHistoryMessages) => set({ maxHistoryMessages }),
 
-  setLastBreakpoint: (lastBreakpoint) => set({ lastBreakpoint }),
-
   removeLastAssistantMessage: () =>
     set((state) => {
       const activeId = state.activeConversationId

+ 63 - 0
src/stores/story-simulation-store.spec.ts

@@ -0,0 +1,63 @@
+import { beforeEach, describe, expect, it } from "vitest"
+
+import { useStorySimulationStore } from "./story-simulation-store"
+import type { SimulationDebugTrace } from "@/lib/novel/story-simulation/types"
+
+function makeTrace(id: string): SimulationDebugTrace {
+  return {
+    id,
+    type: "round-plan",
+    nodeIndex: 0,
+    nodeTitle: "开端",
+    round: 0,
+    strategy: "all-agents",
+    candidateAgents: [{ agentId: "a", agentName: "甲" }],
+    selectedAgents: [{ agentId: "a", agentName: "甲", reason: "模式要求全部角色参与" }],
+    blackboard: {
+      allAgentCount: 1,
+      activeAgentCount: 1,
+      totalEventCount: 0,
+      publicEventCount: 0,
+    },
+    visibilityByAgent: [
+      {
+        agentId: "a",
+        agentName: "甲",
+        visibleEventCount: 0,
+        recentEvents: [],
+      },
+    ],
+    timestamp: "2026-07-03T00:00:00.000Z",
+  }
+}
+
+describe("story simulation store debug traces", () => {
+  beforeEach(() => {
+    useStorySimulationStore.setState({
+      debugTraces: [],
+      timelineEvents: [],
+      phase: "idle",
+    })
+  })
+
+  it("stores simulation debug traces in insertion order", () => {
+    const first = makeTrace("trace-1")
+    const second = makeTrace("trace-2")
+
+    useStorySimulationStore.getState().setDebugTraces([first])
+    useStorySimulationStore.getState().addDebugTrace(second)
+
+    expect(useStorySimulationStore.getState().debugTraces.map((trace) => trace.id)).toEqual([
+      "trace-1",
+      "trace-2",
+    ])
+  })
+
+  it("clears simulation debug traces when resetting the story simulation state", () => {
+    useStorySimulationStore.getState().addDebugTrace(makeTrace("trace-1"))
+
+    useStorySimulationStore.getState().reset()
+
+    expect(useStorySimulationStore.getState().debugTraces).toEqual([])
+  })
+})

+ 10 - 0
src/stores/story-simulation-store.ts

@@ -7,6 +7,7 @@ import type {
   StoryDraft,
   ExtractionResult,
   FrameworkBinding,
+  SimulationDebugTrace,
   TimelineEvent,
 } from "@/lib/novel/story-simulation/types"
 import type { SerializedSimulationSnapshot } from "@/lib/novel/story-simulation/simulation-serializer"
@@ -54,6 +55,8 @@ export interface StorySimulationState {
   progressLabel: string
   /** 仿真过程中的时间线事件(实时流) */
   timelineEvents: TimelineEvent[]
+  /** 仿真过程观察快照(Agent 调度和 blackboard 状态) */
+  debugTraces: SimulationDebugTrace[]
   /** 当前正在采访的角色 */
   activeChatAgent: { id: string; name: string } | null
   /** 采访对话消息 */
@@ -92,6 +95,8 @@ export interface StorySimulationState {
   setProgress: (progress: number, label: string) => void
   setTimelineEvents: (events: TimelineEvent[]) => void
   addTimelineEvent: (event: TimelineEvent) => void
+  setDebugTraces: (traces: SimulationDebugTrace[]) => void
+  addDebugTrace: (trace: SimulationDebugTrace) => void
   setActiveChatAgent: (agent: { id: string; name: string } | null) => void
   addAgentChatMessage: (message: AgentChatMessage) => void
   clearAgentChat: () => void
@@ -126,6 +131,7 @@ export const useStorySimulationStore = create<StorySimulationState>((set) => ({
   progress: 0,
   progressLabel: "",
   timelineEvents: [],
+  debugTraces: [],
   activeChatAgent: null,
   agentChatMessages: [],
   listRefreshKey: 0,
@@ -155,6 +161,9 @@ export const useStorySimulationStore = create<StorySimulationState>((set) => ({
   setTimelineEvents: (timelineEvents) => set({ timelineEvents }),
   addTimelineEvent: (event) =>
     set((state) => ({ timelineEvents: [...state.timelineEvents, event] })),
+  setDebugTraces: (debugTraces) => set({ debugTraces }),
+  addDebugTrace: (trace) =>
+    set((state) => ({ debugTraces: [...state.debugTraces, trace] })),
   setActiveChatAgent: (activeChatAgent) => set({ activeChatAgent }),
   addAgentChatMessage: (message) =>
     set((state) => ({ agentChatMessages: [...state.agentChatMessages, message] })),
@@ -179,6 +188,7 @@ export const useStorySimulationStore = create<StorySimulationState>((set) => ({
       progress: 0,
       progressLabel: "",
       timelineEvents: [],
+      debugTraces: [],
       activeChatAgent: null,
       agentChatMessages: [],
       savedResults: [],

+ 4 - 0
src/stores/wiki-store.ts

@@ -571,6 +571,7 @@ interface WikiState {
   novelMode: boolean
   chatEditModeEnabled: boolean
   aiWorkflowMode: AiWorkflowMode
+  planExecuteEnabled: boolean
   /** 深度模式状态:跨视图切换保持开启 */
   deepChapterEnabled: boolean
   novelConfig: NovelConfig
@@ -642,6 +643,7 @@ interface WikiState {
   setNovelMode: (novelMode: boolean) => void
   setChatEditModeEnabled: (enabled: boolean) => void
   setAiWorkflowMode: (mode: AiWorkflowMode) => void
+  setPlanExecuteEnabled: (enabled: boolean) => void
   setDeepChapterEnabled: (enabled: boolean) => void
   setNovelConfig: (config: Partial<NovelConfig>) => void
   setCommunitySummaryError: (error: string | null) => void
@@ -855,6 +857,7 @@ export const useWikiStore = create<WikiState>((set) => ({
   novelMode: true,
   chatEditModeEnabled: false,
   aiWorkflowMode: "standard",
+  planExecuteEnabled: false,
   deepChapterEnabled: false,
   novelConfig: { ...DEFAULT_NOVEL_CONFIG },
   communitySummaryError: null,
@@ -895,6 +898,7 @@ export const useWikiStore = create<WikiState>((set) => ({
     aiWorkflowMode,
     deepChapterEnabled: aiWorkflowMode === "strict",
   }),
+  setPlanExecuteEnabled: (planExecuteEnabled) => set({ planExecuteEnabled }),
   setDeepChapterEnabled: (deepChapterEnabled) => set({
     deepChapterEnabled,
     aiWorkflowMode: deepChapterEnabled ? "strict" : "standard",

+ 10 - 2
src/test/chat-panel-mount.ts

@@ -2,6 +2,7 @@ import React from "react"
 import { act } from "react"
 import { createRoot, type Root } from "react-dom/client"
 import { vi } from "vitest"
+import type { TaskBreakpoint } from "@/lib/agent/task-breakpoint"
 
 const wikiState = {
   project: { path: "C:/QMAI_C/QMAI-main", name: "测试项目" },
@@ -23,11 +24,14 @@ const wikiState = {
   chatEditModeEnabled: false,
   selectedFile: null,
   aiWorkflowMode: "standard",
+  planExecuteEnabled: false,
   deepChapterEnabled: false,
   chatDockPosition: "right",
   novelConfig: { contextTokenBudget: 0 },
   setActiveView: vi.fn(),
   setAiChatModel: vi.fn(),
+  setAiWorkflowMode: vi.fn(),
+  setPlanExecuteEnabled: vi.fn(),
   setChatEditModeEnabled: vi.fn(),
   setDeepChapterEnabled: vi.fn(),
   setChatDockPosition: vi.fn(),
@@ -51,6 +55,7 @@ const defaultAgentSkillConfig = {
 }
 
 let mockAgentSkillConfig: typeof defaultAgentSkillConfig | null = defaultAgentSkillConfig
+let mockTaskBreakpoint: TaskBreakpoint | null = null
 
 vi.mock("react-i18next", () => ({
   initReactI18next: {
@@ -97,6 +102,7 @@ vi.mock("@/hooks/use-agent-config", () => ({
 
 vi.mock("@/components/chat/chat-shared", () => ({
   useSourceFiles: () => [],
+  getLastQueryPages: () => [],
 }))
 
 vi.mock("@/lib/novel/story-simulation/framework-binding", () => ({
@@ -108,9 +114,9 @@ vi.mock("@/lib/novel/story-simulation/framework-store", () => ({
 }))
 
 vi.mock("@/lib/agent/task-breakpoint", () => ({
-  loadTaskBreakpoint: vi.fn(async () => null),
+  loadTaskBreakpoint: vi.fn(async () => mockTaskBreakpoint),
   clearTaskBreakpoint: vi.fn(async () => {}),
-  buildBreakpointResumePrompt: vi.fn(() => ""),
+  buildBreakpointResumePrompt: vi.fn(() => "恢复提示词"),
 }))
 
 vi.mock("@/lib/llm-client", () => ({
@@ -144,6 +150,7 @@ import { useChatStore } from "@/stores/chat-store"
 export interface RenderChatPanelOptions {
   activeConversation?: boolean
   agentSkillConfig?: typeof defaultAgentSkillConfig | null
+  taskBreakpoint?: TaskBreakpoint | null
 }
 
 export async function renderChatPanel(options: RenderChatPanelOptions = {}) {
@@ -151,6 +158,7 @@ export async function renderChatPanel(options: RenderChatPanelOptions = {}) {
   mockAgentSkillConfig = options.agentSkillConfig === undefined
     ? defaultAgentSkillConfig
     : options.agentSkillConfig
+  mockTaskBreakpoint = options.taskBreakpoint ?? null
 
   const activeConversationId = options.activeConversation ? "conv_mount" : null
   useChatStore.setState({