Просмотр исходного кода

Merge branch 'juqingtuiyanshi-duozhinengti'

Mochocyang 2 месяцев назад
Родитель
Сommit
965bee8913
100 измененных файлов с 16821 добавлено и 1302 удалено
  1. 32 0
      agent-diji-分支说明.md
  2. 138 0
      agent-duihua-分支说明.md
  3. 338 0
      docs/superpowers/specs/2026-07-02-agent-workflow-thought-process-design.md
  4. 689 0
      docs/superpowers/specs/2026-07-02-stage-C-D-optimization-design.md
  5. 977 0
      gongjudiaoyongyouhua-分支说明.md
  6. 0 371
      src-tauri/src/commands/file_sync.rs
  7. 168 0
      src-tauri/src/commands/mcp_stdio.rs
  8. 1 0
      src-tauri/src/commands/mod.rs
  9. 5 0
      src-tauri/src/lib.rs
  10. 5 0
      src-tauri/src/main.rs
  11. 3 1
      src/App.tsx
  12. 76 0
      src/components/chat/agent-message-metadata.spec.ts
  13. 92 0
      src/components/chat/agent-message-metadata.ts
  14. 63 0
      src/components/chat/agent-stage-stream.spec.tsx
  15. 175 0
      src/components/chat/agent-stage-stream.tsx
  16. 375 0
      src/components/chat/agent-tool-call-message.spec.tsx
  17. 31 0
      src/components/chat/agent-tool-call-message.tsx
  18. 266 0
      src/components/chat/agent-workflow-panel.tsx
  19. 190 0
      src/components/chat/chapter-plan-confirm-dialog.spec.tsx
  20. 151 0
      src/components/chat/chapter-plan-confirm-dialog.tsx
  21. 121 13
      src/components/chat/chat-message.spec.tsx
  22. 99 7
      src/components/chat/chat-message.tsx
  23. 91 0
      src/components/chat/chat-panel.mount.spec.tsx
  24. 457 0
      src/components/chat/chat-panel.spec.tsx
  25. 841 714
      src/components/chat/chat-panel.tsx
  26. 278 0
      src/components/chat/context-trace-panel.spec.tsx
  27. 1124 0
      src/components/chat/context-trace-panel.tsx
  28. 1 2
      src/components/chat/dismantling-reference.spec.ts
  29. 149 0
      src/components/chat/intent-clarification-dialog.tsx
  30. 257 0
      src/components/chat/modify-confirm-dialog.tsx
  31. 543 0
      src/components/chat/tool-call-timeline.tsx
  32. 3 2
      src/components/layout/content-area.tsx
  33. 1 1
      src/components/layout/icon-sidebar.tsx
  34. 52 1
      src/components/layout/knowledge-tree.tsx
  35. 13 3
      src/components/layout/sidebar-panel.tsx
  36. 169 0
      src/components/novel/retrieval-status-indicator.tsx
  37. 37 0
      src/components/novel/story-simulation/story-simulation-view.debug.spec.ts
  38. 164 0
      src/components/novel/story-simulation/story-simulation-view.tsx
  39. 64 0
      src/components/reference/ReferenceChip.spec.tsx
  40. 51 0
      src/components/reference/ReferenceChip.tsx
  41. 315 0
      src/components/reference/ReferenceInput.spec.tsx
  42. 277 0
      src/components/reference/ReferenceInput.tsx
  43. 211 0
      src/components/reference/ReferencePickerDialog.spec.tsx
  44. 225 0
      src/components/reference/ReferencePickerDialog.tsx
  45. 39 0
      src/components/settings/mcp-section.spec.tsx
  46. 44 0
      src/components/settings/sections/classification-section.spec.tsx
  47. 288 0
      src/components/settings/sections/classification-section.tsx
  48. 216 0
      src/components/settings/sections/mcp-section.tsx
  49. 12 1
      src/components/settings/settings-view.tsx
  50. 3 0
      src/components/skill-library/skill-library-view.spec.tsx
  51. 16 0
      src/components/skill-library/skill-library-view.tsx
  52. 62 0
      src/components/skill-library/unified-skill-library-view.tsx
  53. 172 0
      src/components/skill-library/writing-skill-library-view.spec.tsx
  54. 528 0
      src/components/skill-library/writing-skill-library-view.tsx
  55. 86 3
      src/components/sources/outline-chat-panel.spec.tsx
  56. 472 165
      src/components/sources/outline-chat-panel.tsx
  57. 482 0
      src/hooks/use-agent-config.spec.ts
  58. 174 0
      src/hooks/use-agent-config.ts
  59. 62 9
      src/i18n/en.json
  60. 62 9
      src/i18n/zh.json
  61. 143 0
      src/lib/agent/activity-trace.spec.ts
  62. 219 0
      src/lib/agent/activity-trace.ts
  63. 138 0
      src/lib/agent/ai-chat-session.spec.ts
  64. 51 0
      src/lib/agent/ai-chat-session.ts
  65. 226 0
      src/lib/agent/ai-chat-workflow-convergence.spec.ts
  66. 137 0
      src/lib/agent/capabilities/registry.ts
  67. 196 0
      src/lib/agent/capabilities/selector.spec.ts
  68. 160 0
      src/lib/agent/capabilities/selector.ts
  69. 37 0
      src/lib/agent/capabilities/types.ts
  70. 51 0
      src/lib/agent/config.ts
  71. 161 0
      src/lib/agent/context-trace-builders.spec.ts
  72. 42 0
      src/lib/agent/context-trace-builders.ts
  73. 187 0
      src/lib/agent/context-trace.ts
  74. 55 0
      src/lib/agent/mcp-trace.spec.ts
  75. 75 0
      src/lib/agent/mcp-trace.ts
  76. 199 0
      src/lib/agent/novel-pre-plugin-chain.spec.ts
  77. 122 0
      src/lib/agent/novel-pre-plugin-chain.ts
  78. 100 0
      src/lib/agent/pipeline.ts
  79. 36 0
      src/lib/agent/plan-execute-policy.spec.ts
  80. 56 0
      src/lib/agent/plan-execute-policy.ts
  81. 108 0
      src/lib/agent/plugin-config.spec.ts
  82. 106 0
      src/lib/agent/plugin-scenarios.spec.ts
  83. 19 0
      src/lib/agent/plugin-scenarios.ts
  84. 279 0
      src/lib/agent/plugins/build-context-pack-plugin.spec.ts
  85. 202 0
      src/lib/agent/plugins/build-context-pack-plugin.ts
  86. 80 0
      src/lib/agent/plugins/build-system-prompt-plugin.spec.ts
  87. 113 0
      src/lib/agent/plugins/build-system-prompt-plugin.ts
  88. 217 0
      src/lib/agent/plugins/confidence-gate-plugin.spec.ts
  89. 102 0
      src/lib/agent/plugins/confidence-gate-plugin.ts
  90. 171 0
      src/lib/agent/plugins/post-write-check-ai.spec.ts
  91. 180 0
      src/lib/agent/plugins/post-write-check-ai.ts
  92. 120 0
      src/lib/agent/plugins/post-write-check-plugin.spec.ts
  93. 103 0
      src/lib/agent/plugins/post-write-check-plugin.ts
  94. 45 0
      src/lib/agent/plugins/resolve-chapter-plugin.ts
  95. 38 0
      src/lib/agent/plugins/route-task-plugin.ts
  96. 94 0
      src/lib/agent/plugins/select-capabilities-plugin.spec.ts
  97. 37 0
      src/lib/agent/plugins/select-capabilities-plugin.ts
  98. 155 0
      src/lib/agent/plugins/select-skills-plugin.spec.ts
  99. 186 0
      src/lib/agent/plugins/select-skills-plugin.ts
  100. 39 0
      src/lib/agent/plugins/soul-dialog-plugin.ts

+ 32 - 0
agent-diji-分支说明.md

@@ -0,0 +1,32 @@
+# agent-diji 分支说明
+
+## 分支用途
+
+本分支用于开发 Agent 工具调用框架的核心基础设施,包括:
+
+- Agent 核心类型定义
+- ToolRegistry 工具注册中心
+- OpenAI tools schema 转换
+- LLM 层 tool_calls 扩展
+- AgentRunner 多轮调用循环
+- 17 个内置工具(读/写/行动)
+- Agent UI 组件(AgentToolCallMessage)
+- 聊天消息 DisplayMessage 字段扩展
+
+## 使用要求
+
+- 本分支仅实现 Agent 框架本身,不直接改动 chat-panel / outline-chat-panel 的业务逻辑。
+- chat-panel 和 outline-chat-panel 的接入放在后续 `agent-duihua`、`agent-dagang` 分支处理。
+- 所有改动必须可测试、可 typecheck、可打包。
+- 不删除、不修改已有 `ChatInput` 组件。
+
+## 更新记录
+
+### 2026-06-30
+
+- 创建分支 agent-diji
+- 待开始实现 Task 1.1 ~ Task 1.14
+
+## 提交状态
+
+- 当前未提交

+ 138 - 0
agent-duihua-分支说明.md

@@ -0,0 +1,138 @@
+# agent-duihua 分支说明
+
+## 分支目标
+
+本分支用于将 AI 会话面板接入 Agent 工具调用流程,并接入 @ 引用输入、引用弹窗和章节右键发送到 AI 会话能力。
+
+## 使用要求
+
+1. 不合并到 main,等待用户完成全面软件测试后再决定合并。
+2. 本分支优先处理 AI 会话面板,并按本轮反馈补齐 AI 大纲 @ 引用输入能力。
+3. 保留旧 `ChatInput` 组件源码,不删除不重构无关逻辑。
+4. 面向用户的提示语保持中文。
+5. 修改完成后必须完成源码启动验证、旧功能测试、构建和便携版打包验证。
+
+## 本次更新
+
+### 20260701-125104
+
+- 修复 AI 大纲仍走旧工作流、没有调用工具分析内容的问题:发送和重新生成流程已改为使用 `AgentRunner`、`ToolRegistry` 与 `buildAgentConfig`。
+- AI 大纲 @ 引用改为工具读取提示,不再提前读取后塞入普通上下文;提示词要求优先调用 `read_outline`、`read_chapter`、`read_memory`、`read_deduction` 等工具读取引用和项目内容。
+- AI 大纲 assistant 消息会展示工具调用过程,并把成功读取的章节、大纲、记忆和推演来源写入引用来源。
+- 新增回归测试,防止 AI 大纲重新回退到 `runDeepOutlineGeneration`、`streamChat` 或预加载引用内容的旧路径。
+
+### 20260701-123054
+
+- 修复 Agent 工具调用轮过程文字污染最终回复的问题:工具调用轮的模型文字仅保留在内部上下文,不再直接显示给用户。
+- 收紧 AI 会话章节生成输出约束:生成、续写、改写章节时最终回复只允许章节正文,禁止输出读取说明、执行总结、完成目标表格、章节结构和后续建议。
+- 优化 Enter 发送链路:先写入用户消息和 assistant 运行占位,再异步解析章节号与构建小说上下文,减少按 Enter 后等待的体感延迟。
+
+### 20260701-113111
+
+- 修复 AI 会话按 Enter 发送存在延迟的问题:提交时优先读取 textarea 当前 DOM 值,避免刚输入后 React state 尚未同步导致发送不及时。
+- 新增 `ReferenceInput` 回归测试,覆盖“输入后立刻按 Enter,state 尚未追上时仍提交最新 DOM 输入值”的场景。
+
+### 20260701-111236
+
+- 修复 AI 会话输入框重复输入、Enter 无法发送和二次删除触发 `removeChild` 异常的问题:引用输入改为 textarea 承载文本,引用 Chip 单独渲染。
+- 弱化 AI 会话输入框 placeholder 字体颜色,避免与用户实际输入内容混淆。
+- 删除 @ 引用弹窗左侧分类 emoji 图标,仅保留中文分类名,降低 AI 味。
+- @ 引用列表主标题改为读取文件内容中的中文标题:章节、记忆、大纲优先使用 Markdown frontmatter `title` 或一级标题;推演室优先使用框架标题、绑定标题或推荐分支标题。
+- AI 大纲接入同一套 @ 引用输入和引用弹窗,发送时会把已选引用内容读取后加入大纲生成上下文。
+
+### 20260701-103906
+
+- 修复 AI 会话模型选择器位置回归:模型选择器已回到输入区右下角,靠近发送按钮。
+- 恢复 AI 会话引用输入框可拖拽高度,流式生成时在输入区右下角显示停止按钮。
+- 优化 @ 引用弹窗样式,改为主题化弹窗,增大内容区域,显示清晰中文标签、搜索框、路径和已选计数。
+- 修复 @ 引用数据源:章节库、记忆库、大纲支持递归读取嵌套 Markdown;推演室支持读取 `.qmai/simulations` 下的框架 Markdown 和结果 JSON。
+- 修复 Agent 读取工具:`read_memory` 和 `read_deduction` 支持按完整 `path` 读取嵌套文件。
+- 修复章节列表右键菜单在真实侧边栏未接入的问题:章节右键“发送到AI会话”会展开 AI 会话并插入引用 token,不会自动发送。
+- 修正 @ 引用 Chip 关闭按钮乱码,改为图标按钮和中文无障碍标签。
+
+### 20260701-095256
+
+- 新增 `agent-message-metadata` 纯函数模块,将 Agent 成功读取类工具调用转换为 assistant 消息引用来源。
+- AI 会话输入草稿恢复按会话隔离:文本继续使用 `conversation.inputDraft`,引用 token 按会话单独缓存。
+- Agent 完成后会把成功读取的章节、大纲、记忆和推演来源写入 assistant `references`,旧引用来源面板可继续展示。
+- 同步 `src/lib/changelog.spec.ts` 的 `2.2.31` 版本断言,使全量 mock 测试恢复通过。
+
+### 20260701-092043
+
+- 将 `chat-panel` 的发送流程接入 `AgentRunner`。
+- 将 AI 会话输入区替换为 `ReferenceInput`,并接入 `ReferencePickerDialog`。
+- 用户消息保存 `attachedReferences`,assistant 占位消息保存 `isAgentRunning` 与 `agentToolCalls`。
+- 用户消息区域展示只读引用芯片。
+- `useAgentConfig` 返回已加载技能配置,并修正内置工具注册使用的 wiki 目录路径。
+- 保留章节生成相关的 QM-QUAI、目标章节解析和角色灵魂确认逻辑,避免旧功能回退。
+
+## 验证记录
+
+- 20260701-125104:
+  - `npm.cmd exec -- vitest run src/components/sources/outline-chat-panel.spec.tsx`:1 个测试文件、7 个用例通过。
+  - `npm.cmd run test:mocks`:285 个测试文件、2118 个用例通过。
+  - `npm.cmd run typecheck`:通过。
+  - `npm.cmd run build`:通过,存在既有 Vite chunk/dynamic import 警告。
+  - 源码启动:`npm.cmd run dev -- --host 127.0.0.1 --port 5173` 通过 Job 验证,Vite ready 后已停止。
+  - `npm.cmd run build:portable`:通过,生成 `release-portable\QMaiWrite.exe` 和 `version-info.json`;Rust 构建存在既有 `file_sync.rs` dead-code 警告。
+
+- 20260701-123054:
+  - `npm.cmd exec -- vitest run src/lib/agent/runner.spec.ts src/components/chat/chat-panel.spec.tsx`:2 个测试文件、16 个用例通过。
+  - `npm.cmd exec -- vitest run src/components/reference/ReferenceInput.spec.tsx src/lib/agent/runner.spec.ts src/components/chat/chat-panel.spec.tsx src/components/chat/agent-message-metadata.spec.ts src/components/chat/chat-message.spec.tsx src/components/layout/chat-layout.test.ts src/lib/reference/resolve.spec.ts`:7 个测试文件、53 个用例通过。
+  - `npm.cmd exec -- vitest run src/components/reference/ReferenceInput.spec.tsx src/components/reference/ReferencePickerDialog.spec.tsx src/lib/reference/providers.spec.ts src/components/chat/chat-panel.spec.tsx src/components/sources/outline-chat-panel.spec.tsx src/lib/agent/tools/read-tools.spec.ts src/lib/agent/tools/write-tools.spec.ts`:7 个测试文件、53 个用例通过。
+  - `git diff --check`:通过,仅有 Git 换行提示。
+  - `npm.cmd run typecheck`:通过。
+  - `npm.cmd run test:mocks`:285 个测试文件、2116 个用例通过。
+  - `npm.cmd run build`:通过,存在既有 Vite chunk/dynamic import 警告。
+  - 源码启动:`npm.cmd run dev -- --host 127.0.0.1 --port 5173` 通过 Job 验证,Vite ready 后已停止。
+  - `npm.cmd run build:portable`:通过,生成 `release-portable\QMaiWrite.exe` 和 `version-info.json`;Rust 构建存在既有 `file_sync.rs` dead-code 警告。
+- 20260701-113111:
+  - `npm.cmd exec -- vitest run src/components/reference/ReferenceInput.spec.tsx`:1 个测试文件、10 个用例通过。
+  - `npm.cmd exec -- vitest run src/components/reference/ReferenceInput.spec.tsx src/components/reference/ReferencePickerDialog.spec.tsx src/lib/reference/providers.spec.ts src/components/chat/chat-panel.spec.tsx src/components/sources/outline-chat-panel.spec.tsx`:5 个测试文件、36 个用例通过。
+  - `npm.cmd exec -- vitest run src/components/chat/agent-message-metadata.spec.ts src/components/chat/chat-message.spec.tsx src/components/layout/chat-layout.test.ts src/lib/reference/resolve.spec.ts`:4 个测试文件、27 个用例通过。
+  - `npm.cmd run typecheck`:通过。
+  - `npm.cmd run test:mocks`:285 个测试文件、2113 个用例通过。
+  - `npm.cmd run build`:通过,存在既有 Vite chunk/dynamic import 警告。
+  - 源码启动:`npm.cmd run dev -- --host 127.0.0.1 --port 5173` 通过 Job 验证,Vite ready 后已停止。
+  - `npm.cmd run build:portable`:通过,生成 `release-portable\QMaiWrite.exe` 和 `version-info.json`;Rust 构建存在既有 `file_sync.rs` dead-code 警告。
+- 20260701-111236:
+  - `npm.cmd exec -- vitest run src/components/reference/ReferenceInput.spec.tsx src/components/reference/ReferencePickerDialog.spec.tsx src/lib/reference/providers.spec.ts src/components/sources/outline-chat-panel.spec.tsx`:4 个测试文件、26 个用例通过。
+  - `npm.cmd exec -- vitest run src/components/reference/ReferenceChip.spec.tsx src/components/reference/ReferenceInput.spec.tsx src/components/reference/ReferencePickerDialog.spec.tsx src/lib/reference/providers.spec.ts src/lib/reference/resolve.spec.ts src/stores/chat-store.test.ts src/components/layout/sidebar-panel.outline-tree.test.tsx src/components/chat/chat-panel.spec.tsx src/lib/agent/tools/read-tools.spec.ts src/components/sources/outline-chat-panel.spec.tsx`:10 个测试文件、59 个用例通过。
+  - `npm.cmd exec -- vitest run src/components/chat/agent-message-metadata.spec.ts src/components/chat/chat-message.spec.tsx src/components/layout/knowledge-tree.long-press.test.tsx src/components/layout/chat-layout.test.ts src/components/sources/outline-chat-panel.spec.tsx src/lib/reference/resolve.spec.ts`:6 个测试文件、48 个用例通过。
+  - `npm.cmd run typecheck`:通过。
+  - `npm.cmd run test:mocks`:285 个测试文件、2112 个用例通过。
+  - `npm.cmd run build`:通过,存在既有 Vite chunk/dynamic import 警告。
+  - 源码启动:`npm.cmd run dev -- --host 127.0.0.1 --port 5173` 通过 Job 验证,Vite ready 后已停止。
+  - `npm.cmd run build:portable`:通过,生成 `release-portable\QMaiWrite.exe`。
+- 20260701-103906:
+  - `npm.cmd exec -- vitest run src/components/reference/ReferenceChip.spec.tsx src/components/reference/ReferenceInput.spec.tsx src/components/reference/ReferencePickerDialog.spec.tsx src/lib/reference/providers.spec.ts src/stores/chat-store.test.ts src/components/layout/sidebar-panel.outline-tree.test.tsx src/components/chat/chat-panel.spec.tsx src/lib/agent/tools/read-tools.spec.ts`:8 个测试文件、45 个用例通过。
+  - `npm.cmd exec -- vitest run src/components/chat/agent-message-metadata.spec.ts src/components/chat/chat-message.spec.tsx src/components/layout/knowledge-tree.long-press.test.tsx src/components/layout/chat-layout.test.ts src/components/sources/outline-chat-panel.spec.tsx src/lib/reference/resolve.spec.ts`:6 个测试文件、46 个用例通过。
+  - `npm.cmd run typecheck`:通过。
+  - `npm.cmd run test:mocks`:285 个测试文件、2105 个用例通过。
+  - `npm.cmd run build`:通过,存在既有 Vite chunk/dynamic import 警告。
+  - 源码启动:`npm.cmd run dev -- --host 127.0.0.1 --port 5173` 前台启动显示 Vite ready;当前环境无法可靠保留后台 dev server,未留下常驻 URL。
+  - `npm.cmd run build:portable`:通过,生成 `release-portable\QMaiWrite.exe`。
+- 20260701-095256:
+  - `npm.cmd exec -- vitest run src/components/chat/agent-message-metadata.spec.ts src/components/chat/chat-panel.spec.tsx src/components/chat/chat-message.spec.tsx src/hooks/use-agent-config.spec.ts src/lib/changelog.spec.ts`:5 个测试文件、28 个用例通过。
+  - `npm.cmd exec -- vitest run src/components/layout/chat-layout.test.ts`:11 个用例通过。
+  - `npm.cmd run typecheck`:通过。
+  - `npm.cmd run test:mocks`:285 个测试文件、2094 个用例通过。
+  - `npm.cmd run build`:通过,存在既有 Vite chunk/dynamic import 警告。
+  - `npm.cmd run build:portable`:通过,生成 `release-portable\QMaiWrite.exe`。
+- 20260701-092043:
+  - `npm.cmd exec -- vitest run src/components/chat/chat-panel.spec.tsx src/components/chat/chat-message.spec.tsx src/hooks/use-agent-config.spec.ts`:7 个用例通过。
+  - `npm.cmd exec -- vitest run src/components/layout/chat-layout.test.ts`:1 个用例通过。
+  - `npm.cmd run typecheck`:通过。
+  - `npm.cmd run build`:通过,存在既有 Vite chunk/dynamic import 警告。
+  - `npm.cmd run build:portable`:通过,生成 `release-portable\QMaiWrite.exe`。
+
+## Git 状态
+
+- 20260701-125104 AI 大纲 Agent 工具调用修复纳入本次提交。
+- 20260701-123054 Agent 输出纯正文和 Enter 立即显示修复纳入本次提交。
+- 20260701-113111 Enter 立即发送修复纳入本次提交。
+- 20260701-092043 更新已提交,提交号 `6b08275`。
+- 20260701-095256 优化纳入本次提交。
+- 20260701-103906 回归修复和 @ UI 优化纳入本次提交。
+- 20260701-111236 输入框稳定性、中文标题和 AI 大纲 @ 引用修复纳入本次提交。
+- 不合并 main。

+ 338 - 0
docs/superpowers/specs/2026-07-02-agent-workflow-thought-process-design.md

@@ -0,0 +1,338 @@
+# Agent 思考过程重构设计
+
+日期:2026-07-02
+
+## 背景
+
+当前 AI 会话和 AI 大纲中的“思考过程”主要由工具调用记录直接映射成短句列表,信息过于简略。用户需要它更接近 Codex、TRAE Code 等 Agent 工具的过程流:能看到任务阶段、使用技能、调用工具、读取了哪些资料、阶段判断和生成校验,同时这些内容必须可以折叠,不能污染最终回答正文。
+
+本设计只覆盖“思考过程”的展示与轻量数据组织,不改变模型生成正文的业务目标,不自动写入章节或大纲,不放宽写入确认。
+
+## 目标
+
+1. 思考过程改为纵向 Agent 工作流,而不是简单工具列表。
+2. 默认展开当前正在执行的阶段,其余阶段折叠。
+3. 显示使用了什么技能、调用了什么工具、读取了什么章节/大纲/记忆/推演。
+4. 每个阶段显示一句可读的关键判断,展开后显示该阶段的详细过程。
+5. 最终回答保持任务对应格式:章节任务只显示小说章节,细纲任务只显示细纲,大纲任务只显示大纲内容。
+6. AI 会话和 AI 大纲都使用同一套过程展示设计。
+7. 工具详情保留,但作为技术详情折叠显示,默认不干扰用户阅读。
+
+## 非目标
+
+1. 不展示模型内部不可控的逐 token 隐私推理,只展示产品可解释的过程摘要、阶段判断和工具证据。
+2. 不重构所有 Agent 执行链路。
+3. 不改变写入类工具的确认规则。
+4. 不把过程文本追加到 `message.content`。
+5. 不做新的多 Agent 编排功能。
+
+## 推荐方案
+
+采用混合重构方案:
+
+1. 保留现有 `toolCalls`、`contextTrace`、`ToolCallTimeline`。
+2. 新增一层轻量的 `workflow steps` 显示模型,把现有数据组织成用户能理解的阶段。
+3. 对少量现有数据无法稳定表达的信息增加显式字段,例如使用技能、阶段判断、结果校验。
+
+这样可以快速做出用户需要的过程流,又避免重做底层工具调用系统。
+
+## 交互设计
+
+### 整体结构
+
+新增 `AgentWorkflowPanel` 作为统一入口,替代当前 `AgentToolCallMessage` 中的简短步骤列表。
+
+顶部显示:
+
+- `思考过程`
+- 总状态:运行中、已完成、等待确认、失败
+- 任务耗时
+- 总阶段数
+- 一键展开/收起
+
+主体为纵向过程流,每个阶段是一条可折叠记录。
+
+### 默认展开规则
+
+1. 有 `running` 阶段时,自动展开当前运行阶段。
+2. 没有运行阶段但有 `approval_required` 阶段时,展开等待用户确认阶段。
+3. 全部完成后,默认折叠所有阶段,只保留摘要。
+4. 用户手动展开或折叠后,本条消息内保持用户操作状态。
+5. 最终结果已经返回后,不允许仍显示“正在读取”并持续转圈;未结束的工具调用必须被结算为完成、失败或取消。
+
+### 阶段设计
+
+#### 1. 任务理解
+
+摘要示例:
+
+`识别为“生成章节细纲”,需要读取总大纲和目标章节。`
+
+展开内容:
+
+- 用户请求类型
+- 识别到的任务意图
+- 路由来源
+- 置信度
+- 是否需要加载小说上下文
+
+对于“测试”“你好”这类明显闲聊或测试输入,不应展示复杂上下文加载流程。
+
+#### 2. 使用技能
+
+摘要示例:
+
+`应用“章节细纲生成”工作流,约束输出为细纲格式。`
+
+展开内容:
+
+- 技能名称
+- 技能用途
+- 技能来源
+- 对输出格式的约束
+
+如果本轮没有显式技能,则显示:
+
+`本轮未调用专用技能。`
+
+#### 3. 上下文准备
+
+摘要示例:
+
+`已读取总大纲、chapter-017、人物记忆 2 条。`
+
+展开内容:
+
+- 已读取章节
+- 已读取大纲
+- 已读取记忆
+- 已读取推演
+- 已读取历史会话
+- 被跳过或无法读取的资料
+- 上下文预算和裁剪情况
+
+读取类内容必须显示对象名,例如:
+
+- `读取章节《chapter-017》`
+- `读取大纲《总大纲》`
+- `读取记忆《人物设定》`
+
+#### 4. 工具调用
+
+摘要示例:
+
+`调用 5 个工具,4 个完成,1 个等待确认。`
+
+展开内容默认显示中文业务描述:
+
+- `列出所有章节`
+- `读取章节《chapter-017》`
+- `读取大纲《总大纲》`
+- `生成写入草稿,等待用户确认`
+
+技术详情继续使用 `ToolCallTimeline`,放在二级折叠区域:
+
+`查看原始工具详情`
+
+其中可以看到工具名、参数、返回预览、状态和错误。
+
+#### 5. 思考与决策
+
+摘要示例:
+
+`判断本轮应输出章节细纲,不应返回分析报告。`
+
+展开内容:
+
+- 为什么需要这些资料
+- 当前阶段形成的关键判断
+- 输出边界
+- 风险提示
+
+这里展示的是产品可解释的过程摘要,不展示不可控的模型隐私推理。
+
+#### 6. 生成与校验
+
+摘要示例:
+
+`结果已按“章节细纲”格式校验。`
+
+展开内容:
+
+- 期望输出类型:章节、细纲、大纲、记忆、伏笔分析、普通问答
+- 实际输出类型
+- 是否包含无关分析
+- 是否缺少标题、分节、正文、保存确认等必要内容
+- 校验警告
+
+如果用户要求“生成小说章节”,最终正文必须是小说章节格式;如果结果变成分析说明,面板需要显示校验异常,方便后续重试或修正。
+
+## 视觉设计
+
+参考 TRAE/Codex 风格,采用轻量纵向过程流,而不是大面积蓝底卡片列表。
+
+### 禁止的呈现方式
+
+当前截图中的样式必须废弃:
+
+1. 不使用整块浅蓝色背景包住全部思考过程。
+2. 不把每一步做成一条圆角浅蓝卡片。
+3. 不重复显示大量同类卡片,例如连续多条“整理资料范围”。
+4. 不把过程区做成像表单列表、通知列表或日志面板。
+5. 不让“工具详情”按钮成为视觉重点。
+
+这类样式显得老旧,并且会把简单过程放大成沉重的信息块。
+
+### 新呈现方式
+
+采用“文本过程流 + 细分隔 + 局部展开”的样式:
+
+1. 外层背景保持透明或接近聊天背景,只用左侧细线、轻量图标和小标题组织层级。
+2. 每个阶段默认是一行过程摘要,视觉上像 Agent 正在汇报工作,而不是像卡片列表。
+3. 当前运行阶段可以有非常轻的背景高亮,但不能使用整行大圆角卡片堆叠。
+4. 阶段之间使用留白和细分隔,不使用厚边框。
+5. 已完成阶段显示为简洁文本,例如:`已读取 1 个章节,2 条记忆,1 个大纲`。
+6. 正在执行阶段显示一句自然语言,例如:`正在读取章节《chapter-017》`。
+7. 展开内容在阶段下方缩进显示,使用小号文本和项目符号,不再套一层卡片。
+8. 技术工具名默认隐藏,放入“查看工具详情”的二级折叠区。
+
+### 信息密度
+
+1. 阶段标题使用 13-14px,摘要使用 12-13px,避免在聊天侧栏里显得拥挤。
+2. 默认视图只显示阶段标题和一句摘要。
+3. 同类工具调用需要合并摘要,例如多次 `list_*` 合并为“整理资料范围”,不要重复刷屏。
+4. 读取类工具可以在展开内容里逐条列出目标对象。
+5. 长内容必须换行,文件名和章节名不能撑破聊天框。
+6. 移动或窄宽度下保持单列,不出现横向滚动。
+7. 最终回答区域与过程面板视觉分离,避免用户误以为过程文本属于正文。
+
+### 推荐视觉结构
+
+示例:
+
+```text
+思考过程  ·  任务耗时 1m 4s
+
+任务理解  >
+识别为章节细纲生成,需要读取大纲与目标章节。
+
+上下文准备  v
+正在读取章节《chapter-017》
+  - 已读取大纲《总大纲》
+  - 已读取记忆《人物设定》
+  - 已读取章节《chapter-017》
+
+生成与校验  >
+将按章节细纲格式输出,正文区不显示过程分析。
+```
+
+这个结构的重点是“像 Agent 在汇报处理进度”,不是“把每个工具调用包装成一个卡片”。
+
+## 数据模型
+
+新增显示模型:
+
+```ts
+type AgentWorkflowStepStatus =
+  | "pending"
+  | "running"
+  | "done"
+  | "approval_required"
+  | "error"
+  | "cancelled"
+
+interface AgentWorkflowStep {
+  id: string
+  kind:
+    | "intent"
+    | "skill"
+    | "context"
+    | "tool"
+    | "decision"
+    | "validation"
+  title: string
+  summary: string
+  status: AgentWorkflowStepStatus
+  details: AgentWorkflowDetail[]
+  startedAt?: number
+  finishedAt?: number
+}
+
+interface AgentWorkflowDetail {
+  label: string
+  value: string
+  tone?: "default" | "muted" | "warning" | "error" | "success"
+}
+```
+
+该模型是 UI 显示层,不替代底层工具调用记录。
+
+## 数据来源
+
+### AI 会话
+
+组合来源:
+
+- `message.agentToolCalls`
+- `message.contextTrace`
+- `contextTrace.contextInfo`
+- `contextTrace.toolCalls`
+- `contextTrace.contextInfo.resultProtocol`
+
+### AI 大纲
+
+第一阶段组合来源:
+
+- `outlineChat` 的工具调用记录
+- 大纲引用、读取和写入工具的参数
+
+后续如 AI 大纲需要更完整的上下文追踪,再补齐 `contextTrace` 数据接入。
+
+## 文件影响范围
+
+预计修改或新增:
+
+- `src/components/chat/agent-tool-call-message.tsx`
+- `src/components/chat/tool-call-timeline.tsx`
+- `src/components/chat/chat-message.tsx`
+- `src/components/sources/outline-chat-panel.tsx`
+- `src/lib/agent/workflow-trace.ts`
+- `src/components/chat/agent-workflow-panel.tsx`
+
+预计测试:
+
+- `src/components/chat/agent-tool-call-message.spec.tsx`
+- `src/components/chat/chat-message.spec.tsx`
+- `src/components/sources/outline-chat-panel.spec.tsx`
+- `src/lib/agent/workflow-trace.spec.ts`
+
+## 错误与边界处理
+
+1. 工具调用缺少参数时,显示通用描述,不暴露 `undefined`。
+2. 资料读取失败时,阶段状态为失败,并显示失败对象和错误摘要。
+3. 最终结果完成后,仍处于 `running` 的工具调用必须被结算,防止转圈残留。
+4. 写入类工具必须保留等待用户确认状态,不允许面板触发自动保存。
+5. 没有任何工具记录时,不显示空面板。
+6. 技术详情中的原始参数过长时只显示预览,避免撑破界面。
+
+## 测试与验证
+
+1. 单元测试:从 `toolCalls` 和 `contextTrace` 生成正确的工作流阶段。
+2. 组件测试:当前运行阶段默认展开,其余折叠。
+3. 组件测试:读取章节、读取大纲、读取记忆显示具体对象名。
+4. 组件测试:最终完成后不显示运行中图标。
+5. 组件测试:写入类工具显示等待确认,并保留确认/拒绝操作。
+6. 回归测试:最终回答内容不包含思考过程文本。
+7. 视觉验证:AI 会话窄宽度和 50% 宽度下内容不溢出。
+8. 构建验证:运行项目已有 typecheck、相关 mock 测试和 build。
+
+## 成功标准
+
+1. 用户能像在 Codex/TRAE 中一样看到阶段化过程流。
+2. 当前执行阶段自动展开,已完成阶段默认折叠。
+3. 读取了哪个章节、哪个记忆、哪个大纲能清楚显示。
+4. 使用技能和工具调用能在过程里看到。
+5. 最终正文保持干净,不混入过程分析。
+6. AI 会话和 AI 大纲表现一致。
+7. 没有破坏写入确认和现有工具调用逻辑。
+8. 思考过程不再使用整块蓝底加多条圆角卡片的老式列表样式。

+ 689 - 0
docs/superpowers/specs/2026-07-02-stage-C-D-optimization-design.md

@@ -0,0 +1,689 @@
+# AI 会话 Stage C-G 优化设计文档
+
+> **状态**:已通过 brainstorming 三部分逐节认可,待 spec 自审与用户审查
+> **分支**:`gongjudiaoyongyouhua`
+> **日期**:2026-07-02
+
+## 1. 背景与目标
+
+Stage C-D 已接入完成(章节计划确认交互流程 + 写后剧情自检),但在接入过程中暴露出若干隐性 bug 和未完成阶段:
+
+- **隐性 bug**:`aiWorkflowMode` 在 chat-panel 中用局部 `useState` 持有且永远写入 `"standard"`,导致"快速模式跳过 Stage C"分支运行时不可达;Promise resolver 在组件卸载时无清理,存在 stale callback 与内存泄漏风险。
+- **Stage D 检查过弱**:当前 `runPostWriteCheck` 是纯规则检查(字数、转折词、占位符),无法真正判断剧情质量。
+- **Stage E 未完成**:classification-section 只有"创建/升级/检查"按钮,缺编辑入口。
+- **Stage F 未完成**:task-breakpoint.ts 已实现但 runner 未调用,chat-panel 无恢复入口。
+- **Stage G 未完成**:MCP 只有降级 caller,未接入真实 stdio 连接。
+- **测试缺口**:chat-panel 无 mount 级测试,PrePlugin 链与 Stage C 对话框的端到端交互无验证。
+
+本设计目标:在 `gongjudiaoyongyouhua` 分支上,以最小改动完成上述 bug 修复与 Stage D-G 收口,并补齐 mount 级测试基础设施。
+
+## 2. 全局约束
+
+- **语言**:所有面向用户的提示语、弹窗、确认信息、错误提示必须使用中文。
+- **不破坏旧功能**:现有 PrePlugin 链、Stage C 对话框、ContextTrace 追踪、MCP 降级路径必须保持兼容。
+- **分支隔离**:所有改动在 `gongjudiaoyongyouhua` 分支完成,不合并 main。
+- **YAGNI**:不引入用户未要求的功能,不为假想场景做错误处理。
+- **复用现有**:优先复用 `streamChat`、`resolveNovelModel`、`hasUsableLlm`、`writeFileAtomic`、`normalizePath` 等现有工具。
+- **测试约束**:vite.config.ts 默认 `test.environment = "node"`,mount 级测试需用 docblock `// @vitest-environment jsdom` 单独切换。
+- **打包要求**:每完成一个 Stage 需打包便携版供用户测试,版本号保持 2.2.31 不变(未提交 GitHub)。
+
+---
+
+## 3. Part 1:Bug 修复 + Stage D AI 推理
+
+### 3.1 Bug 修复
+
+#### 3.1.1 aiWorkflowMode 从 store 读取
+
+**问题**:`src/components/chat/chat-panel.tsx` 第 478-479 行用局部 `useState` 持有 `aiWorkflowMode`,并通过 `void setAiWorkflowMode` 丢弃 setter,导致值永远为 `"standard"`,"快速模式跳过 Stage C"分支不可达。
+
+**修复**:
+- 移除 chat-panel 中的局部 `useState`,改为从 `wiki-store` 读取 `aiWorkflowMode` 和 `setAiWorkflowMode`。
+- store 侧已有完整实现(`wiki-store.ts` 第 573 行类型声明、第 857 行初值、第 894-897 行 setter 联动 `deepChapterEnabled`),无需修改 store。
+- chat-panel 中所有引用局部 `aiWorkflowMode` 的位置改为引用 store 的值。
+
+**改动文件**:
+- 修改:`src/components/chat/chat-panel.tsx`
+- 修改:`src/components/chat/chat-panel.spec.tsx`(补充 store 读取断言)
+
+#### 3.1.2 Promise resolver 卸载清理
+
+**问题**:`soulDialogResolverRef` 和 `chapterPlanResolverRef` 在组件卸载时未清理,若用户在 pending 状态关闭会话或卸载组件,resolver 会泄漏并在后续触发 stale callback。
+
+**修复**:
+- 在 chat-panel 的 `useEffect` 卸载钩子中,调用所有 pending resolver 并以"已取消"语义 resolve(避免 promise 永远 pending),然后清空 ref。
+- 不抽取新函数,仅在现有卸载 effect 中追加清理逻辑。
+
+**改动文件**:
+- 修改:`src/components/chat/chat-panel.tsx`
+
+#### 3.1.3 SoulDialog 输入框一致性
+
+**问题**:Stage C 对话框打开时已禁用 ReferenceInput,但 SoulDialog 的输入框未同步禁用,用户可能在 SoulDialog pending 期间发送消息。
+
+**修复**:
+- SoulDialog 输入框的 `disabled` 条件追加 `pendingChapterPlan.open`。
+- 与 ReferenceInput 的禁用条件保持一致:`disabled={isStreaming || pendingChapterPlan.open}`。
+
+**改动文件**:
+- 修改:`src/components/chat/chat-panel.tsx`
+
+### 3.2 Stage D 升级 AI 推理
+
+#### 3.2.1 设计目标
+
+将 Stage D 从纯规则检查升级为"AI 推理 + 规则降级兜底":
+- 优先调用 LLM 对章节正文做 7 维度并行评估,输出结构化 JSON。
+- AI 调用失败(无模型、网络错误、解析失败、超时)时,降级到现有 `runPostWriteCheck` 规则检查。
+- 不破坏现有 `runPostWriteCheck` 函数签名和返回结构。
+
+#### 3.2.2 PostWriteCheckItem 字段扩展
+
+在 `src/lib/agent/context-trace.ts` 中扩展 `PostWriteCheckItem`:
+
+```typescript
+export interface PostWriteCheckItem {
+  name: string
+  passed: boolean
+  detail: string
+  /** AI 模式下新增字段,规则模式下为 undefined */
+  severity?: "info" | "warning" | "error"
+  evidence?: string   // 原文证据片段
+  suggestion?: string // 改进建议
+}
+```
+
+`PostWriteCheck` 顶层结构不变(items/passedCount/totalCount/allPassed)。规则模式下新字段为 `undefined`,UI 侧需优雅降级显示。
+
+#### 3.2.3 runPostWriteCheckAI 函数
+
+新增 `src/lib/agent/plugins/post-write-check-ai.ts`:
+
+```typescript
+import type { PostWriteCheck, PostWriteCheckItem } from "../context-trace"
+import type { ContextPack } from "@/lib/novel/context-engine"
+import type { LlmConfig } from "@/lib/types"
+import { streamChat } from "@/lib/llm-client"
+import { resolveNovelModel } from "@/lib/novel/lint"
+import { hasUsableLlm } from "@/lib/llm-utils"
+import { runPostWriteCheck } from "./post-write-check-plugin"
+
+export interface PostWriteCheckAIResult {
+  check: PostWriteCheck
+  source: "ai" | "rule"  // 实际使用的检查来源
+  fallbackReason?: string // 降级原因(source=rule 时填)
+}
+
+export async function runPostWriteCheckAI(params: {
+  chapterContent: string
+  contextPack?: ContextPack
+  llmConfig?: LlmConfig
+  signal?: AbortSignal
+}): Promise<PostWriteCheckAIResult>
+```
+
+**行为**:
+1. 若 `!hasUsableLlm(llmConfig)` → 降级,`source: "rule"`,`fallbackReason: "未配置可用模型"`
+2. 若 `chapterContent` 为空 → 降级,`source: "rule"`,`fallbackReason: "章节内容为空"`
+3. 构造单次 prompt,让 LLM 输出 7 维度 JSON(剧情承接、主线推进、人物动机、冲突强度、伏笔处理、节奏、风格一致性)
+4. 调用 `streamChat`,超时 30s(`AbortSignal.timeout(30_000)`,与现有 signal 合并)
+5. 解析 JSON 失败、网络错误、超时 → 降级到 `runPostWriteCheck`
+6. 成功 → 返回 AI 结果,每项含 `severity/evidence/suggestion`
+
+**Prompt 设计**(单次调用,并行输出):
+- System:你是小说剧情自检助手,只输出 JSON,不要额外解释。
+- User:包含章节正文(截断到 8000 字)、ContextPack 关键字段(chapterGoal、previousChapterEnding 摘要)
+- 输出格式:
+```json
+{
+  "items": [
+    {"name": "剧情承接", "passed": true, "severity": "info", "evidence": "...", "suggestion": "..."},
+    ...
+  ]
+}
+```
+
+#### 3.2.4 chat-panel 集成
+
+修改 `src/components/chat/chat-panel.tsx` 的 `finishAgentSession` 中 Stage D 自检逻辑:
+- 当前:同步调用 `runPostWriteCheck(content)`
+- 改为:异步调用 `runPostWriteCheckAI({chapterContent, contextPack, llmConfig})`
+- 用 IIFE 包裹(因 `setContextInfo` 是整体替换,需在 AI 结果返回后再 setContextInfo)
+- AI 结果的 `source` 和 `fallbackReason` 一并写入 `contextInfo.postWriteCheckMeta`(新增字段)
+
+**ContextTrace 扩展**:
+```typescript
+// src/lib/agent/context-trace.ts
+export interface TraceContextInfo {
+  // ... 现有字段
+  postWriteCheck?: PostWriteCheck
+  postWriteCheckMeta?: { source: "ai" | "rule"; fallbackReason?: string }
+}
+```
+
+#### 3.2.5 UI 展示
+
+修改 `src/components/chat/context-trace-panel.tsx`:
+- PostWriteCheck 区块新增"检查来源"标签(AI 推理 / 规则检查)
+- 降级时显示降级原因
+- 每项检查展开后显示 `evidence` 和 `suggestion`(若有)
+
+### 3.3 Part 1 测试策略
+
+- `post-write-check-ai.spec.ts`:新增,覆盖 AI 成功、降级(无模型/空内容/超时/解析失败)、字段扩展
+- `post-write-check-plugin.spec.ts`:补充 `runPostWriteCheck` 规则模式返回 `severity=undefined` 断言
+- `chat-panel.spec.tsx`:补充 aiWorkflowMode 从 store 读取断言、卸载清理断言
+- `context-trace-panel.spec.tsx`:补充 AI/规则双模式展示断言
+
+---
+
+## 4. Part 2:Stage E 编辑入口 + Stage F 断点恢复
+
+### 4.1 Stage E:classification.md 编辑入口
+
+#### 4.1.1 设计目标
+
+在 `src/components/settings/sections/classification-section.tsx` 现有 UI 基础上,新增:
+1. textarea 编辑区(显示 classification.md 原始 markdown 内容)
+2. "保存"按钮(带格式校验)
+3. "恢复默认"按钮(写入 DEFAULT_CLASSIFICATION_CONFIG)
+
+#### 4.1.2 UI 结构
+
+在现有状态卡片下方新增编辑卡片:
+
+```
+┌─ 编辑 classification.md ─────────────────┐
+│ [textarea: markdown 内容]                 │
+│                                           │
+│ [保存]  [恢复默认]  [格式校验提示]         │
+└───────────────────────────────────────────┘
+```
+
+- textarea 仅在 `classificationStatus === "valid"` 时显示
+- 保存前调用 `parseClassificationMarkdown(content)` 校验,失败时显示中文错误提示,不写入
+- "恢复默认"需二次确认,复用现有 `ModifyConfirmDialog` 组件(已存在于 chat-panel),不新增组件
+
+#### 4.1.3 实现细节
+
+**新增函数**(在 `src/lib/novel/classification/markdown-serializer.ts`):
+- `serializeClassificationToMarkdown(config): string`:把 ClassificationConfig 序列化为 markdown(已存在反序列化 `parseClassificationMarkdown`,补正向)
+- 若已存在正向序列化则复用
+
+**classification-section.tsx 改动**:
+- 新增 state:`editingContent: string`、`saveStatus: "idle" | "saving" | "saved" | "error"`
+- 加载时若 status=valid,调用 `readProjectClassificationRaw(projectPath)` 读取原始 markdown 填充 textarea
+- 保存:`parseClassificationMarkdown(editingContent)` → 成功则 `writeProjectClassification(projectPath, parsed)` → 提示"已保存"
+- 恢复默认:`writeProjectClassification(projectPath, DEFAULT_CLASSIFICATION_CONFIG)` → 提示"已恢复默认配置"
+
+**新增读取原始内容函数**(在 `classification-loader.ts`):
+- `readProjectClassificationRaw(projectPath: string): Promise<string>`:直接读取 markdown 文件原始内容
+
+**改动文件**:
+- 修改:`src/components/settings/sections/classification-section.tsx`
+- 修改:`src/lib/novel/classification/markdown-serializer.ts`(补正向序列化,若缺)
+- 修改:`src/lib/novel/classification/classification-loader.ts`(补 raw 读取)
+- 新增:`src/components/settings/sections/classification-section.spec.tsx`
+
+### 4.2 Stage F:任务断点恢复
+
+#### 4.2.1 设计目标
+
+实现 Agent 任务断点持久化与恢复:
+1. AgentConfig 扩展 `projectPath?` 和 `taskGoal?`
+2. runner.ts 每轮工具执行后调用 `saveTaskBreakpoint`
+3. chat-panel 启动时检测上次未完成任务,弹确认对话框
+4. chat-store 新增 `lastBreakpoint` 字段缓存
+
+#### 4.2.2 AgentConfig 扩展
+
+修改 `src/lib/agent/types.ts`:
+
+```typescript
+export interface AgentConfig {
+  maxRounds: number
+  tools: Tool[]
+  systemPrompt: string
+  llmConfig: LlmConfig
+  toolResultContextLimit?: number
+  requestOverrides?: RequestOverrides
+  modelId?: string
+  /** Stage F: 项目路径,用于断点持久化 */
+  projectPath?: string
+  /** Stage F: 本次任务目标,用于断点恢复 */
+  taskGoal?: string
+}
+```
+
+#### 4.2.3 runner.ts 集成
+
+修改 `src/lib/agent/runner.ts` 的 `AgentRunner.run`:
+- 在每轮工具执行后(`callbacks.onToolResult` 之后),若 `config.projectPath` 存在:
+  - 调用 `createTaskBreakpoint` 或 `updateBreakpointStage` 更新断点
+  - 调用 `saveTaskBreakpoint(config.projectPath, breakpoint)`
+  - 记录 `usedTools`、`usedSkills`、`searches`、`mcpCalls`(从 trace 累积)
+- 在 `run` 完成(成功或失败)后,若 `config.projectPath` 存在:
+  - 成功:`clearTaskBreakpoint(config.projectPath)`
+  - 失败:保留断点(不清理),供下次恢复
+
+**断点更新策略**:
+- 首次创建:`createTaskBreakpoint({taskGoal: config.taskGoal, currentStage: "agent_round_1"})`
+- 后续更新:`updateBreakpointStage(bp, "agent_round_N", "agent_round_N-1")`
+- 不引入新的 stage 概念,直接用 `agent_round_${roundNumber}`
+
+#### 4.2.4 chat-panel 恢复入口
+
+修改 `src/components/chat/chat-panel.tsx`:
+- 组件挂载时(`useEffect`),若 `projectPath` 存在:
+  - 调用 `loadTaskBreakpoint(projectPath)`
+  - 若存在断点,弹出确认对话框:"检测到上次有未完成的任务({taskGoal}),是否恢复?"
+  - 用户确认 → 调用 `buildBreakpointResumePrompt(bp)` 拼接恢复提示词,自动发送
+  - 用户取消 → 调用 `clearTaskBreakpoint(projectPath)` 清理
+
+**对话框**:复用现有 `ModifyConfirmDialog` 组件(已存在),不新增组件。
+
+#### 4.2.5 chat-store 缓存
+
+修改 `src/stores/chat-store.ts`:
+- 新增字段:`lastBreakpoint: TaskBreakpoint | null`
+- 新增 setter:`setLastBreakpoint(bp: TaskBreakpoint | null)`
+- 用于 UI 显示"上次任务"状态条(可选,非必须)
+
+#### 4.2.6 PrePlugin 链集成
+
+`buildBreakpointResumePrompt` 输出的恢复提示词作为 user message 注入:
+- 不通过 PrePlugin 链(断点恢复是消息级注入,不是上下文构建)
+- 直接在 chat-panel 的 handleSend 中,若处于恢复模式,把 resume prompt 追加到用户原始输入后
+
+### 4.3 Part 2 测试策略
+
+- `classification-section.spec.tsx`:新增,覆盖 textarea 加载、保存(成功/格式错误)、恢复默认
+- `markdown-serializer.spec.ts`:补充正向序列化往返一致性测试
+- `runner.spec.ts`:补充每轮 saveTaskBreakpoint 调用断言、成功清理断言、失败保留断言
+- `chat-panel.spec.tsx`:补充断点恢复对话框(确认/取消)断言
+- `task-breakpoint.spec.ts`:已存在,无需新增
+
+---
+
+## 5. Part 3:Stage G MCP stdio + Mount 级测试
+
+### 5.1 Stage G:MCP stdio 真实连接
+
+#### 5.1.1 设计目标
+
+实现真实 MCP stdio 连接,替换 `defaultUnavailableMcpCaller` 降级路径:
+1. Tauri Rust 侧实现子进程管理(spawn/write/read/kill)
+2. 前端实现 JSON-RPC 2.0 over stdio transport
+3. 实现 `McpToolCaller` 真实连接器
+4. runtime 按需注入真实 caller(配置启用时)
+
+#### 5.1.2 Rust 侧实现
+
+新增 `src-tauri/src/commands/mcp_stdio.rs`,四个 Tauri 命令:
+
+```rust
+#[tauri::command]
+pub async fn mcp_stdio_spawn(
+    server_id: String,
+    command: String,
+    args: Vec<String>,
+    env: Option<HashMap<String, String>>,
+) -> Result<SpawnResult, String>
+// 启动子进程,返回 pid 和初始 stdout 缓冲
+
+#[tauri::command]
+pub async fn mcp_stdio_write(
+    pid: u32,
+    data: String,
+) -> Result<(), String>
+// 向子进程 stdin 写入数据(自动追加 \n)
+
+#[tauri::command]
+pub async fn mcp_stdio_read(
+    pid: u32,
+    timeout_ms: u64,
+) -> Result<String, String>
+// 阻塞读取一行 stdout(带超时),返回 JSON-RPC 响应
+
+#[tauri::command]
+pub async fn mcp_stdio_kill(
+    pid: u32,
+) -> Result<(), String>
+// 终止子进程
+```
+
+**进程管理**:
+- 用 `tokio::process::Command` 启动子进程
+- 全局 `Mutex<HashMap<u32, ChildHandle>>` 管理活跃进程
+- stdout 用 `BufReader::read_until` 按行读取(JSON-RPC 每行一个消息)
+- kill 时先 `child.kill()` 再 `child.wait()`
+
+**改动文件**:
+- 新增:`src-tauri/src/commands/mcp_stdio.rs`
+- 修改:`src-tauri/src/commands/mod.rs`(声明模块)
+- 修改:`src-tauri/src/lib.rs`(注册命令到 invoke_handler)
+
+#### 5.1.3 前端 transport 层
+
+新增 `src/lib/mcp/transport/stdio.ts`:
+
+```typescript
+export interface StdioTransport {
+  send(message: string): Promise<void>
+  receive(timeoutMs?: number): Promise<string>
+  close(): Promise<void>
+}
+
+export class TauriStdioTransport implements StdioTransport {
+  constructor(private pid: number) {}
+  async send(message: string): Promise<void> {
+    await invoke("mcp_stdio_write", { pid: this.pid, data: message })
+  }
+  async receive(timeoutMs = 5000): Promise<string> {
+    return await invoke("mcp_stdio_read", { pid: this.pid, timeoutMs })
+  }
+  async close(): Promise<void> {
+    await invoke("mcp_stdio_kill", { pid: this.pid })
+  }
+}
+```
+
+#### 5.1.4 JSON-RPC 2.0 实现
+
+新增 `src/lib/mcp/transport/json-rpc.ts`:
+
+```typescript
+export interface JsonRpcRequest {
+  jsonrpc: "2.0"
+  id: number
+  method: string
+  params?: Record<string, unknown>
+}
+
+export interface JsonRpcResponse {
+  jsonrpc: "2.0"
+  id: number
+  result?: unknown
+  error?: { code: number; message: string; data?: unknown }
+}
+
+export class JsonRpcClient {
+  private nextId = 1
+  constructor(private transport: StdioTransport) {}
+
+  async call(method: string, params?: Record<string, unknown>): Promise<unknown> {
+    const id = this.nextId++
+    const request: JsonRpcRequest = { jsonrpc: "2.0", id, method, params }
+    await this.transport.send(JSON.stringify(request))
+    const raw = await this.transport.receive()
+    const response: JsonRpcResponse = JSON.parse(raw)
+    if (response.error) throw new Error(response.error.message)
+    return response.result
+  }
+}
+```
+
+**MCP 协议调用**:
+- `initialize`:握手
+- `tools/list`:获取工具列表
+- `tools/call`:调用具体工具
+
+#### 5.1.5 real-connector 实现
+
+新增 `src/lib/mcp/real-connector.ts`:
+
+```typescript
+import type { McpToolCaller, McpToolCallRequest, McpToolCallResult } from "./types"
+
+export class RealMcpConnector implements McpToolCaller {
+  private transports = new Map<string, JsonRpcClient>()
+
+  async ensureConnected(server: { id: string; command: string; args: string[]; env?: Record<string, string> }): Promise<JsonRpcClient> {
+    // 已连接则复用
+    // 否则 spawn + initialize 握手
+  }
+
+  async call(request: McpToolCallRequest): Promise<McpToolCallResult> {
+    const client = await this.ensureConnected(/* 从配置查找 server */)
+    try {
+      const result = await client.call("tools/call", {
+        name: request.toolName,
+        arguments: request.arguments,
+      })
+      return { status: "ok", content: stringifyResult(result), summary: ... }
+    } catch (e) {
+      return { status: "error", content: "", summary: "", message: `MCP 调用失败:${e.message}` }
+    }
+  }
+
+  async closeAll(): Promise<void> {
+    // 关闭所有 transport
+  }
+}
+```
+
+#### 5.1.6 runtime 集成
+
+修改 `src/lib/mcp/runtime.ts`:
+- `buildMcpRuntime` 新增可选参数 `realConnector?: RealMcpConnector`
+- 若 server 配置 `command` 存在且 `realConnector` 提供时,使用真实 caller
+- 否则保持现有 `defaultUnavailableMcpCaller` 降级
+- 不破坏现有降级路径
+
+**chat-panel 集成**:
+- 修改 `src/hooks/use-agent-config.ts`:根据 MCP 配置中是否有 `command` 字段,决定是否创建 `RealMcpConnector` 并传入 `buildMcpRuntime`
+- 真实连接失败时自动降级到 `defaultUnavailableMcpCaller`,不中断 AI 会话
+
+### 5.2 Mount 级测试基础设施
+
+#### 5.2.1 设计目标
+
+为 chat-panel 补充 mount 级测试,验证 PrePlugin 链、Stage C 对话框、Stage D 自检的端到端交互。现有 `chat-panel.spec.tsx` 全部是 `readFileSync + toContain` 字符串断言,无法验证运行时行为。
+
+#### 5.2.2 测试基础设施
+
+新增 `src/test/chat-panel-mount.ts`:
+- 集中管理 mount 测试所需的 mock 体系
+- mock `streamChat`、`useWikiStore`、`useAgentConfig`、`invoke`、`writeFileAtomic` 等
+- 提供 `renderChatPanel(overrides?)` 工厂函数
+- 处理 React 18 `createRoot` + `act` 包装
+
+#### 5.2.3 jsdoc 环境切换
+
+mount 测试文件头部使用 docblock:
+```typescript
+// @vitest-environment jsdom
+```
+
+不修改 `vite.config.ts` 的全局 `test.environment = "node"` 设置,保持其他测试不受影响。
+
+#### 5.2.4 测试用例
+
+新增 `src/components/chat/chat-panel.mount.spec.tsx`:
+
+1. **基础渲染**:chat-panel 能 mount 成功,输入框可交互
+2. **PrePlugin 链触发**:发送消息后,pipeline 执行,工具事件正确 emit
+3. **Stage C 对话框**:standard 模式下生成章节时,chapter_plan 标记触发对话框,用户确认后续写
+4. **Stage C 跳过**:fast 模式下跳过计划确认,直接生成正文
+5. **Stage D 自检**:章节写完后,PostWriteCheck 写入 ContextTrace
+6. **Stage D 降级**:无模型时降级到规则检查
+7. **断点恢复**(依赖 Part 2):检测到断点时弹对话框,确认后发送恢复提示词
+
+#### 5.2.5 测试约束
+
+- mount 测试不依赖真实文件系统,所有 fs 操作走 mock
+- 不依赖真实 LLM,`streamChat` mock 返回预设响应
+- 测试用例独立,无共享状态
+- 单个测试用例执行时间 < 2s
+
+### 5.3 Part 3 测试策略
+
+- `mcp_stdio.rs`:Rust 侧单元测试(mock 子进程)或集成测试
+- `stdio.spec.ts`:transport 层测试(mock invoke)
+- `json-rpc.spec.ts`:JSON-RPC 协议测试
+- `real-connector.spec.ts`:连接器测试(mock transport)
+- `runtime.spec.ts`:补充真实 caller 注入断言
+- `chat-panel.mount.spec.tsx`:mount 级端到端测试
+
+---
+
+## 6. 文件结构总览
+
+### 6.1 新增文件
+
+| 文件 | 职责 | 所属 Part |
+|------|------|-----------|
+| `src/lib/agent/plugins/post-write-check-ai.ts` | Stage D AI 推理 + 降级兜底 | Part 1 |
+| `src/lib/agent/plugins/post-write-check-ai.spec.ts` | Stage D AI 测试 | Part 1 |
+| `src/components/settings/sections/classification-section.spec.tsx` | Stage E 测试 | Part 2 |
+| `src-tauri/src/commands/mcp_stdio.rs` | MCP stdio Rust 命令 | Part 3 |
+| `src/lib/mcp/transport/stdio.ts` | stdio transport | Part 3 |
+| `src/lib/mcp/transport/json-rpc.ts` | JSON-RPC 2.0 客户端 | Part 3 |
+| `src/lib/mcp/real-connector.ts` | 真实 MCP 连接器 | Part 3 |
+| `src/lib/mcp/transport/stdio.spec.ts` | transport 测试 | Part 3 |
+| `src/lib/mcp/transport/json-rpc.spec.ts` | JSON-RPC 测试 | Part 3 |
+| `src/lib/mcp/real-connector.spec.ts` | 连接器测试 | Part 3 |
+| `src/test/chat-panel-mount.ts` | mount 测试基础设施 | Part 3 |
+| `src/components/chat/chat-panel.mount.spec.tsx` | mount 级测试 | Part 3 |
+
+### 6.2 修改文件
+
+| 文件 | 改动 | 所属 Part |
+|------|------|-----------|
+| `src/components/chat/chat-panel.tsx` | aiWorkflowMode 从 store 读取、resolver 卸载清理、SoulDialog 禁用、Stage D 异步化、断点恢复 | Part 1+2 |
+| `src/components/chat/chat-panel.spec.tsx` | 补充 store 读取、卸载清理断言 | Part 1 |
+| `src/lib/agent/context-trace.ts` | PostWriteCheckItem 扩展 severity/evidence/suggestion、postWriteCheckMeta | Part 1 |
+| `src/components/chat/context-trace-panel.tsx` | AI/规则双模式展示 | Part 1 |
+| `src/components/chat/context-trace-panel.spec.tsx` | 双模式展示测试 | Part 1 |
+| `src/lib/agent/plugins/post-write-check-plugin.spec.ts` | 补充规则模式 severity=undefined | Part 1 |
+| `src/components/settings/sections/classification-section.tsx` | textarea + 保存 + 恢复默认 | Part 2 |
+| `src/lib/novel/classification/markdown-serializer.ts` | 补正向序列化(若缺) | Part 2 |
+| `src/lib/novel/classification/classification-loader.ts` | 补 readProjectClassificationRaw | Part 2 |
+| `src/lib/agent/types.ts` | AgentConfig 扩展 projectPath/taskGoal | Part 2 |
+| `src/lib/agent/runner.ts` | 每轮 saveTaskBreakpoint、完成清理 | Part 2 |
+| `src/lib/agent/runner.spec.ts` | 断点调用断言 | Part 2 |
+| `src/stores/chat-store.ts` | lastBreakpoint 字段 | Part 2 |
+| `src/lib/mcp/runtime.ts` | realConnector 注入 | Part 3 |
+| `src/hooks/use-agent-config.ts` | 创建 RealMcpConnector | Part 3 |
+| `src-tauri/src/commands/mod.rs` | 声明 mcp_stdio 模块 | Part 3 |
+| `src-tauri/src/lib.rs` | 注册 mcp_stdio 命令 | Part 3 |
+
+---
+
+## 7. 数据流
+
+### 7.1 Stage D AI 推理数据流
+
+```
+章节写完 (finishAgentSession)
+  → runPostWriteCheckAI({chapterContent, contextPack, llmConfig})
+    → hasUsableLlm? 
+      → 是: streamChat(单次 prompt) → 解析 JSON → 返回 AI 结果
+      → 否: 降级 → runPostWriteCheck(content) → 返回规则结果
+  → setContextInfo({postWriteCheck, postWriteCheckMeta})
+  → UI 渲染(context-trace-panel)
+```
+
+### 7.2 Stage F 断点恢复数据流
+
+```
+chat-panel 挂载
+  → loadTaskBreakpoint(projectPath)
+    → 存在断点?
+      → 是: 弹确认对话框
+        → 用户确认: buildBreakpointResumePrompt → 自动发送
+        → 用户取消: clearTaskBreakpoint
+      → 否: 正常启动
+
+Agent 运行每轮
+  → saveTaskBreakpoint(projectPath, bp)
+  
+Agent 完成
+  → clearTaskBreakpoint(projectPath)
+```
+
+### 7.3 Stage G MCP stdio 数据流
+
+```
+use-agent-config 初始化
+  → 检查 MCP 配置中是否有 command 字段
+    → 有: 创建 RealMcpConnector
+    → 无: 使用 defaultUnavailableMcpCaller
+
+Agent 调用 MCP 工具
+  → RealMcpConnector.call(request)
+    → ensureConnected: mcp_stdio_spawn → initialize 握手
+    → client.call("tools/call", {name, arguments})
+      → transport.send(JSON-RPC request)
+      → transport.receive() → JSON-RPC response
+    → 返回结果
+  → 失败时返回中文降级信息,不中断会话
+```
+
+---
+
+## 8. 错误处理
+
+### 8.1 Stage D AI 推理
+
+- 无可用模型 → 降级到规则检查,`fallbackReason: "未配置可用模型"`
+- 章节内容为空 → 降级,`fallbackReason: "章节内容为空"`
+- streamChat 抛错 → 降级,`fallbackReason: "AI 调用失败:${error.message}"`
+- JSON 解析失败 → 降级,`fallbackReason: "AI 返回格式无法解析"`
+- 超时(30s)→ 降级,`fallbackReason: "AI 推理超时"`
+
+### 8.2 Stage E 编辑
+
+- textarea 内容为空 → 保存时提示"内容不能为空"
+- 解析失败 → 提示"格式错误:${错误位置}",不写入文件
+- 写入失败 → 提示"保存失败:${error.message}"
+
+### 8.3 Stage F 断点恢复
+
+- loadTaskBreakpoint 失败 → 静默忽略,正常启动
+- saveTaskBreakpoint 失败 → console.error,不中断 Agent 运行
+- clearTaskBreakpoint 失败 → 静默忽略
+
+### 8.4 Stage G MCP stdio
+
+- spawn 失败 → 返回"无法启动 MCP 服务:${error}",降级
+- initialize 握手失败 → 返回"MCP 服务握手失败",降级
+- tools/call 超时 → 返回"MCP 调用超时",本次失败但不中断会话
+- 子进程意外退出 → 清理 transport,下次调用时重新 spawn
+
+---
+
+## 9. 风险与限制
+
+1. **Stage G 跨进程复杂度**:Rust 子进程管理 + JSON-RPC 协议 + 前端 transport 三层,调试难度高。建议先实现单个 MCP 服务的端到端连通,再扩展多服务。
+2. **Mount 测试 mock 成本**:chat-panel 依赖众多 store 和 hook,mount 测试 mock 体系较重。建议先跑通基础渲染,再逐步补充交互用例。
+3. **Stage D AI 推理延迟**:单次 streamChat 30s 超时可能影响用户体验。需在 UI 显示"正在执行 AI 自检..."提示。
+4. **断点恢复语义**:`buildBreakpointResumePrompt` 拼接的提示词质量依赖 LLM 理解能力,复杂任务可能恢复失败。这是可接受的限制,不阻塞实现。
+5. **MCP 配置兼容性**:现有 MCP 配置可能没有 `command` 字段,需确保无 command 时优雅降级。
+
+---
+
+## 10. 实施顺序建议
+
+1. **Part 1 先行**:Bug 修复(aiWorkflowMode、resolver 清理、SoulDialog)→ Stage D AI 推理
+2. **Part 2 紧随**:Stage E 编辑入口 → Stage F 断点恢复
+3. **Part 3 最后**:Stage G Rust 侧 → transport → real-connector → runtime 集成 → mount 测试
+
+每个 Part 完成后打包便携版供用户测试,确认无回退再进入下一 Part。
+
+---
+
+## 11. 成功标准
+
+- [ ] aiWorkflowMode 从 store 读取,快速模式能跳过 Stage C
+- [ ] Promise resolver 卸载时清理,无 stale callback
+- [ ] Stage D AI 推理成功时返回 7 维度结构化结果,失败时降级到规则检查
+- [ ] classification-section 可编辑、保存、恢复默认
+- [ ] AgentConfig 包含 projectPath 和 taskGoal
+- [ ] runner 每轮保存断点,完成时清理
+- [ ] chat-panel 检测到断点时弹恢复对话框
+- [ ] MCP stdio 真实连接可用(至少一个 MCP 服务端到端连通)
+- [ ] mount 级测试覆盖基础渲染、Stage C、Stage D、断点恢复
+- [ ] 所有面向用户的提示语使用中文
+- [ ] 旧功能无回退(PrePlugin 链、Stage C 对话框、ContextTrace、MCP 降级路径)
+- [ ] typecheck、test:mocks、build、build:portable 全部通过

+ 977 - 0
gongjudiaoyongyouhua-分支说明.md

@@ -0,0 +1,977 @@
+# gongjudiaoyongyouhua 分支说明
+
+## 分支目标
+
+本分支用于优化 AI 工具调用流程,把当前工具调用从单纯执行器增强为更可控、可追踪、可验证的 Agent 流程。
+
+## 使用要求
+
+1. 不合并到 main,等待用户完成全面软件测试后再决定合并。
+2. 本分支只处理工具调用流程增强,不顺手改动无关 UI、主题或模型配置。
+3. 读取类工具默认自动执行,写入类工具必须进入确认/预览路径。
+4. 工具返回给模型的长内容可以压缩,但界面和记录中保留完整工具结果。
+5. 修改完成后必须完成源码启动、旧功能测试、构建和便携版打包验证。
+
+## 本次更新
+
+### 20260702-172000 AI 会话优化 Stage A-B
+
+本轮目标:补充设计文档中规划但未落地的搜索辅助工具和内置写作 Skill 种子模板。
+
+成功标准:
+1. `summarize_search_results` 工具已注册并可用,可压缩搜索结果为摘要结构。
+2. 软件启动后自动补全 10 个内置写作 Skill 种子模板(章节承接、下一章计划、主线检查、人物动机、冲突升级、伏笔管理、节奏检查、结尾钩子、剧情自检、正文输出协议)。
+3. 内置 Skill 不可被用户删除(`deleteWritingSkill` 返回原 config)。
+4. 已安装的 writing-skills.json 保持兼容。
+
+修改文件:
+- `docs/superpowers/plans/2026-07-02-ai-chat-optimization-stage-A-to-D-plan.md`
+- `src/lib/agent/tools/summarize-search-results.ts`
+- `src/lib/agent/tools/summarize-search-results.spec.ts`
+- `src/lib/agent/tools/index.ts`
+- `src/lib/novel/skill-seed.ts`
+- `src/lib/novel/user-skill-store.ts`
+- `src/lib/novel/user-skill-store.spec.ts`
+- `src/components/skill-library/writing-skill-library-view.spec.tsx`
+- `GenxinLOG/更新日志.md`
+- `gongjudiaoyongyouhua-分支说明.md`
+
+验证记录:
+1. `npm.cmd exec -- vitest run src/lib/agent/tools/summarize-search-results.spec.ts src/lib/novel/user-skill-store.spec.ts --reporter=verbose`:11 个用例通过。
+2. `npm.cmd run typecheck`:通过。
+3. `npm.cmd exec -- vitest run --exclude='**/*.real-llm.test.ts' --testTimeout=30000 --hookTimeout=30000`:330 个测试文件、2442 个用例通过。
+4. `npm.cmd run build`:通过(保留既有 Vite 警告)。
+5. 源码启动验证:`npm.cmd run dev -- --host 127.0.0.1 --port 5174` 成功显示 Vite ready。
+6. `npm.cmd run typecheck`(修复后):通过,修复 chat-panel.tsx 4 个类型错误(移除死代码、修复未使用参数、添加空值检查)。
+7. `npm.cmd run test:mocks`(修复后):333 个测试文件、2463 个用例通过。
+8. `npm.cmd run build`(修复后):通过。
+9. `npm.cmd run build:portable`:通过,生成 release-portable\QMaiWrite.exe;版本 2.2.31,大小 149206016 字节。
+6. 本轮未提交 git,未合并 main。
+
+### 20260702-224700 Stage C-D 接入完成
+
+本轮目标:将 Stage C(章节计划确认交互流程)和 Stage D(写后剧情自检)的已有组件接入 chat-panel 运行时,使其在标准/严格模式下真正生效。
+
+完成内容:
+1. Stage C:章节计划确认交互流程接入
+   - 新增 `chapter-plan-confirm-dialog.spec.tsx` 测试(16 个用例,覆盖纯函数和组件交互)
+   - 修复 `ChapterPlanConfirmDialog` 组件 `修改计划` 按钮在未提供 onModify 时仍渲染的 UX bug
+   - chat-panel 新增 `pendingChapterPlan` 状态和 Promise resolver(复用 SoulDialog 模式)
+   - onDone 回调用 async IIFE 检测 `<!-- chapter_plan -->` 标记,弹窗等待用户确认
+   - 支持"确认按计划写正文"、"跳过计划直接写"、"修改计划"、"取消生成"四种交互
+   - 续写消息通过 handleSendRef 复用 handleSend,不创建新 conversation
+2. Stage D:写后剧情自检接入
+   - 补充 `runPostWriteCheck` 独立函数测试(6 个用例)
+   - chat-panel 在 finishAgentSession 的 contextInfo 构建处追加 Stage D 逻辑
+   - 仅对 write_chapter/continue_chapter 任务触发,排除 chapter_plan 标记和空内容
+   - 自检结果写入 `contextTrace.contextInfo.postWriteCheck`
+   - context-trace-panel 已有的展示区生效(7 项检查:剧情承接、主线推进、人物动机、冲突强度、伏笔处理、节奏、风格一致性)
+
+改动文件:
+- `src/components/chat/chapter-plan-confirm-dialog.spec.tsx`(新增,190 行)
+- `src/components/chat/chapter-plan-confirm-dialog.tsx`(首次提交,143 行,含 UX bug 修复)
+- `src/components/chat/chat-panel.tsx`(修改,+272 行 Stage C +23 行 Stage D)
+- `src/components/chat/chat-panel.spec.tsx`(修改,+189 行 Stage C +68 行 Stage D)
+- `src/lib/agent/plugins/post-write-check-plugin.spec.ts`(修改,+103 行)
+
+验证记录:
+1. `npm run typecheck`:通过,0 errors。
+2. `npx vitest run`:338 个测试文件通过、1 个 real-llm 测试超时失败(与本次改动无关)、5 个跳过;2512 个用例通过、47 个跳过。
+3. `npm run build`:通过(保留既有 Vite 警告)。
+4. `node scripts/build-portable.mjs`:通过,生成 `release-portable\QMaiWrite.exe`,版本 2.2.31。
+
+技术决策说明:
+- Stage C 状态放 chat-panel 本地(不修改 chat-store),复用 SoulDialog 的 Promise resolver 模式
+- onDone 签名为 `() => void`,用 async IIFE 实现异步等待,不修改接口签名
+- Stage D 逻辑放在 finishAgentSession 而非 onDone,因为 onDone 中的 contextInfo 会被 setContextInfo 整体覆盖
+- Stage D 不走 pre-plugin 链,直接调用 runPostWriteCheck 独立函数
+- 续写消息发送会重跑 pre-plugin 链,但原会话消息历史作为上下文复用
+
+已知限制(Minor,不阻塞):
+1. chat-panel 测试为源码扫描风格,未覆盖 mount 级集成测试
+2. 弹窗打开期间聊天输入框仍可用,存在 UX 竞态
+3. aiWorkflowMode 在 onDone 闭包中捕获,模式切换时可能为旧值
+4. Stage D 自检为简化字符串匹配,非 AI 推理(可作为占位,后续升级)
+
+提交记录:
+- Task 1: commit 1f044bd `test(stage-c): add chapter plan confirm dialog tests`
+- Task 2: commit aae1539 `feat(stage-c): wire chapter plan confirm dialog into chat-panel`
+- Task 3: commit 788e05a `feat(stage-d): wire post-write check into chat-panel onDone`
+- 所有改动已提交到 gongjudiaoyongyouhua 分支,未合并 main。
+
+### 后续阶段(未完成)
+
+Stage E:classification.md 编辑入口
+Stage F:任务断点恢复
+Stage G:真实 MCP 连接(stdio 传输层)
+
+### 20260702-160358 AI 会话 Stage 10 独立写作 Skill 管理页
+
+本轮目标:在不混入原“去 AI 味”技能库的前提下,新增独立的项目级写作 Skill 管理页,并让 AI 会话能够读取已启用的用户写作技巧。
+
+成功标准:
+1. 用户可以从侧边栏进入独立“写作 Skill”页面。
+2. 用户可以创建、编辑、保存、禁用、删除项目级写作 Skill。
+3. 写作 Skill 持久化到项目 `writing-skills.json`,无效记录会被归一化过滤。
+4. 已启用写作 Skill 会进入 AI 会话 Skill 路由,已禁用 Skill 不进入会话。
+5. “三翻四抖”等用户自定义写作 Skill 可被 `select_skills` 识别并参与下一章生成等任务。
+6. 原“技能库/去 AI 味”功能保持独立,不被替换或删除。
+
+修改文件:
+- `docs/superpowers/specs/2026-07-02-ai-chat-stage10-writing-skill-library-design.md`
+- `docs/superpowers/plans/2026-07-02-ai-chat-stage10-writing-skill-library-plan.md`
+- `src/lib/novel/user-skill-store.ts`
+- `src/lib/novel/user-skill-store.spec.ts`
+- `src/components/skill-library/writing-skill-library-view.tsx`
+- `src/components/skill-library/writing-skill-library-view.spec.tsx`
+- `src/stores/wiki-store.ts`
+- `src/lib/sidebar-nav-preferences.ts`
+- `src/lib/sidebar-nav-preferences.spec.ts`
+- `src/components/layout/icon-sidebar.tsx`
+- `src/components/layout/content-area.tsx`
+- `src/components/layout/sidebar-panel.tsx`
+- `src/components/settings/sections/interface-section.tsx`
+- `src/i18n/zh.json`
+- `src/i18n/en.json`
+- `src/hooks/use-agent-config.ts`
+- `src/hooks/use-agent-config.spec.ts`
+- `src/lib/agent/tools/index.ts`
+- `src/components/chat/chat-panel.tsx`
+- `src/components/chat/chat-panel.spec.tsx`
+- `src/lib/agent/plugins/select-skills-plugin.spec.ts`
+- `GenxinLOG/更新日志.md`
+- `gongjudiaoyongyouhua-分支说明.md`
+
+实现记录:
+1. 新增 `UserSkillConfig` 与 `writing-skills.json` 存储闭环,提供创建空白写作 Skill、更新、删除、启用/禁用、保存/加载和解析已启用 Skill 的能力。
+2. 新增独立 `writingSkillLibrary` 视图、侧栏、详情编辑器和脏草稿保护,类型、阶段、模式使用复选控件,所有用户可见文案为中文。
+3. 侧边栏新增“写作 Skill”导航项,并补齐界面设置里的导航显示配置,避免用户自定义侧边栏时缺少标签。
+4. `useAgentConfig` 会读取用户写作 Skill,并把已启用 Skill 暴露给 AI 会话;`chat-panel` 将其合并进可用 Skill 与 `@` 引用来源。
+5. `select_skills` 增加用户上传“三翻四抖”写作 Skill 的回归测试,确认下一章生成时可以补充用户自定义结构技巧。
+
+验证记录:
+1. `npm.cmd exec -- vitest run src/lib/novel/user-skill-store.spec.ts src/components/skill-library/writing-skill-library-view.spec.tsx src/components/layout/icon-sidebar.test.tsx src/components/layout/content-area.search-performance.test.tsx src/components/layout/sidebar-panel.search-history.test.tsx src/hooks/use-agent-config.spec.ts src/components/chat/chat-panel.spec.tsx src/lib/agent/plugins/select-skills-plugin.spec.ts --reporter=verbose`:8 个测试文件、58 个用例通过。
+2. `npm.cmd run typecheck`:通过。
+3. `npm.cmd exec -- vitest run src/lib/sidebar-nav-preferences.spec.ts --reporter=verbose`:1 个测试文件、6 个用例通过,用于确认新增导航项期望已同步。
+4. `npm.cmd run test:mocks`:330 个测试文件、2444 个用例通过。
+5. `npm.cmd run build`:通过;保留既有 Vite dynamic import、chunk size、plugin timings 警告。
+6. 源码启动验证:`npm.cmd run dev -- --host 127.0.0.1 --port 5173` 成功显示 Vite ready 和本地地址;验证后 5173 端口已释放。
+7. `npm.cmd run build:portable`:通过,生成 `release-portable\QMaiWrite.exe` 和 `release-portable\version-info.json`;版本 `2.2.31`,大小 `149206016` 字节。
+8. 本轮未提交 git,未合并 main。
+
+### 20260702-134755 AI Capability 能力层
+
+本轮目标:按照 Stage 5 计划,在不实现真实 MCP 连接的前提下,为 AI 会话新增统一能力层,承接内置工具、已选 Skill、外部搜索和未来 MCP 占位能力。
+
+成功标准:
+1. 内置工具、Skill、外部搜索和未来 MCP 占位能力可以统一表示为 `AiCapability`。
+2. `select_capabilities` 前置插件位于 `select_skills` 之后、`soul_dialog` 之前。
+3. 能力选择受任务意图、快速/标准/严格模式、用户输入和 classification 禁载数据源影响。
+4. 上下文追踪显示本次启用能力,但不记录完整 Skill 内容、网页正文、密钥或 MCP 敏感数据。
+5. 工具注册支持 `enabledToolNames` 白名单,同时保持默认行为兼容,`disabledTools` 优先级不变。
+6. 本阶段不执行 MCP、不新增 MCP 市场、不绕过写入确认。
+
+修改文件:
+- `src/lib/agent/capabilities/types.ts`
+- `src/lib/agent/capabilities/registry.ts`
+- `src/lib/agent/capabilities/selector.ts`
+- `src/lib/agent/capabilities/selector.spec.ts`
+- `src/lib/agent/plugins/select-capabilities-plugin.ts`
+- `src/lib/agent/plugins/select-capabilities-plugin.spec.ts`
+- `src/lib/agent/pipeline.ts`
+- `src/lib/agent/novel-pre-plugin-chain.ts`
+- `src/lib/agent/novel-pre-plugin-chain.spec.ts`
+- `src/lib/agent/plugin-scenarios.spec.ts`
+- `src/lib/agent/context-trace.ts`
+- `src/lib/agent/context-trace-builders.ts`
+- `src/lib/agent/context-trace-builders.spec.ts`
+- `src/components/chat/context-trace-panel.tsx`
+- `src/components/chat/context-trace-panel.spec.tsx`
+- `src/lib/agent/tools/index.ts`
+- `src/lib/agent/tools/index.spec.ts`
+- `GenxinLOG/更新日志.md`
+- `gongjudiaoyongyouhua-分支说明.md`
+
+实现记录:
+1. 新增 capability 类型,包含 `built_in_tool`、`user_skill`、`web_search`、`mcp_tool` 四类。
+2. 新增 capability registry,把工具名和已选 `UserSkill` 转成不含正文的能力记录。
+3. 新增 capability selector:快速模式保持最小能力,标准模式按写作/搜索任务选择,严格模式允许未来 MCP 占位能力;当 classification 禁载 `graph` 时不选择图谱类 MCP 占位能力。
+4. 新增 `select_capabilities` 插件,输出 `selectedCapabilities` 和可选 `enabledToolNames`。
+5. `ContextTrace` 和追踪面板新增“启用能力”,展示能力名、类型、权限、来源和原因。
+6. `registerAllBuiltInTools` 支持 `enabledToolNames`,并保留 `disabledTools` 覆盖能力。
+
+验证记录:
+1. 已先写失败测试并确认失败:capability 模块缺失、插件链仍为 8 个插件、trace 未带能力摘要、面板未显示启用能力、工具注册没有 enabled 白名单过滤。
+2. `npm.cmd exec -- vitest run src/lib/agent/capabilities/selector.spec.ts src/lib/agent/plugins/select-capabilities-plugin.spec.ts src/lib/agent/novel-pre-plugin-chain.spec.ts src/lib/agent/plugin-scenarios.spec.ts src/lib/agent/context-trace-builders.spec.ts src/components/chat/context-trace-panel.spec.tsx src/lib/agent/tools/index.spec.ts --reporter=verbose`:7 个测试文件、36 个用例通过。
+3. `npm.cmd run typecheck`:通过。
+4. `npm.cmd run test:mocks`:319 个测试文件、2398 个用例通过。
+5. `npm.cmd run build`:通过;保留既有 Vite dynamic import、chunk size、plugin timings 警告。
+6. 源码启动验证:`npm.cmd run dev -- --host 127.0.0.1 --port 5173` 成功监听 5173,验证后端口已释放。
+7. `npm.cmd run build:portable`:通过,生成 `release-portable\QMaiWrite.exe` 和 `release-portable\version-info.json`;版本 `2.2.31`,大小 `149201920` 字节。
+8. 本轮未提交 git,未合并 main。
+
+### 20260702-132235 AI 会话外部搜索工具接入
+
+本轮目标:按照 AI 会话剩余能力设计执行计划执行阶段 4,让 AI 会话具备真实外部搜索工具能力。用户明确要求“搜索、联网查询、查外部资料、最新信息”时,Agent 可以调用 `web_search`;未配置或失败时必须中文说明本次未使用联网资料,不能假装已经搜索。
+
+成功标准:
+1. 新增 `web_search` 只读工具,复用现有 Web Search 配置。
+2. 新增 `read_web_page` 只读工具,可读取 http/https 网页正文并做基础清洗和截断。
+3. `web_search` 未配置时返回中文降级提示,不调用网络搜索。
+4. `web_search` 搜索失败时返回中文失败提示,并明确“本次未使用联网资料”。
+5. AI 会话系统提示词明确:未调用 `web_search` 不得声称已经搜索。
+6. `web_search` 调用结果进入 `ContextTrace.webSearches`,追踪面板显示关键词、提供商、结果数量和来源。
+7. 搜索结果只进入本次会话上下文,不自动写入记忆、大纲、retrieval 或图谱。
+8. 本阶段不实现 MCP、不实现能力层、不改 Web Search 设置页结构。
+
+修改文件:
+- `src/lib/agent/tools/web-search.ts`
+- `src/lib/agent/tools/web-search.spec.ts`
+- `src/lib/agent/tools/read-web-page.ts`
+- `src/lib/agent/tools/read-web-page.spec.ts`
+- `src/lib/agent/tools/index.ts`
+- `src/lib/agent/tools/index.spec.ts`
+- `src/hooks/use-agent-config.ts`
+- `src/hooks/use-agent-config.spec.ts`
+- `src/lib/agent/context-trace.ts`
+- `src/lib/agent/context-trace-builders.ts`
+- `src/lib/agent/context-trace-builders.spec.ts`
+- `src/components/chat/context-trace-panel.tsx`
+- `src/components/chat/context-trace-panel.spec.tsx`
+- `src/components/chat/chat-panel.tsx`
+- `src/components/chat/chat-panel.spec.tsx`
+- `GenxinLOG/更新日志.md`
+- `gongjudiaoyongyouhua-分支说明.md`
+
+实现记录:
+1. 新增 `createWebSearchTool`,返回结构化 JSON:`status/query/provider/resultCount/results/message`。
+2. `web_search` 支持未配置、成功、失败三种状态;未配置时不调用 `webSearch`。
+3. 新增 `createReadWebPageTool`,只允许 http/https,支持 HTML 标题提取、脚本/样式/标签清理和 `maxChars` 截断。
+4. `registerAllBuiltInTools` 默认注册 `web_search` 和 `read_web_page`,并继续支持 `disabledTools` 禁用。
+5. `useAgentConfig` 将 `searchApiConfig` 通过 `getSearchApiConfig` 传入工具注册流程。
+6. `ContextTrace` 新增 `TraceWebSearch` 与 `webSearches`,初始追踪默认空数组。
+7. `ContextTracePanel` 新增“外部搜索”展示区,显示搜索关键词、provider、结果数量、来源和失败原因。
+8. `chat-panel.tsx` 新增搜索硬规则提示词,并在工具事件中通过 `appendWebSearchTrace` 记录 `web_search` 结果。
+
+验证记录:
+1. 已先写失败测试并确认预期失败:缺少 `web-search/read-web-page` 模块、工具未注册、追踪字段未初始化、追踪面板未展示、系统提示词未约束搜索。
+2. `npm.cmd exec -- vitest run src/lib/agent/tools/web-search.spec.ts src/lib/agent/tools/read-web-page.spec.ts src/lib/agent/tools/index.spec.ts src/lib/agent/context-trace-builders.spec.ts src/components/chat/context-trace-panel.spec.tsx src/components/chat/chat-panel.spec.tsx src/hooks/use-agent-config.spec.ts --reporter=verbose`:7 个测试文件、43 个用例通过。
+3. `npm.cmd run typecheck`:通过。
+4. `npm.cmd run test:mocks`:317 个测试文件、2386 个用例通过。
+5. `npm.cmd run build`:通过;保留既有 Vite dynamic import、chunk size、plugin timings 警告。
+6. 源码启动验证:`npm.cmd run dev -- --host 127.0.0.1 --port 5173` 成功监听,验证后端口 5173 已释放。
+7. `npm.cmd run build:portable`:通过,生成 `release-portable\QMaiWrite.exe` 和 `release-portable\version-info.json`;版本 `2.2.31`,大小 `149197312` 字节。
+8. 本轮未提交 git,未合并 main。
+
+### 20260702-130209 AI 会话剩余能力设计执行计划
+
+本轮目标:在阶段 1 三档模式、阶段 2 通用 Skill 数据模型、阶段 3 Skill 路由已完成的基础上,重新整理后续阶段 4-7 的执行设计计划,明确外部搜索、AI Capability 能力层、MCP Adapter 和端到端收敛应该如何继续拆分执行。
+
+成功标准:
+1. 明确当前已经完成的阶段,避免后续 AI 重复实现三档模式和 Skill 路由。
+2. 明确下一步只应从阶段 4 外部搜索工具开始。
+3. 明确每个剩余阶段的目标、文件范围、测试要求、成功标准和禁止事项。
+4. 明确外部搜索不得假装联网,搜索结果不得自动写入项目资料。
+5. 明确 MCP 必须通过 Adapter 和权限映射接入,不直接暴露给 AI 会话。
+
+修改文件:
+- `docs/superpowers/plans/2026-07-02-ai-chat-remaining-capability-plan.md`
+- `gongjudiaoyongyouhua-分支说明.md`
+- `GenxinLOG/更新日志.md`
+
+实现记录:
+1. 新增剩余能力设计执行计划,标注阶段 1-3 已完成,后续从阶段 4 开始。
+2. 阶段 4 规划 `web_search`、`read_web_page` 工具,复用现有 Web Search 配置,并要求未配置/失败时中文降级提示。
+3. 阶段 5 规划 AI Capability 能力层,用于统一内置工具、Skill、外部搜索和未来 MCP。
+4. 阶段 6 规划 MCP Adapter,明确 read/action/write 权限映射和写入确认边界。
+5. 阶段 7 规划普通用户端到端测试场景,覆盖快速、标准、严格、搜索、Skill、MCP 和保存确认。
+
+验证记录:
+1. 本轮只新增设计计划文档,未修改业务代码。
+2. 未运行源码、未运行测试、未执行 build、未打包。
+3. 未提交 git,未合并 main。
+
+### 20260702-125305 AI 会话 Skill 路由接入
+
+本轮目标:按照 AI 会话能力扩展大阶段计划执行阶段 3,把通用 Skill 接入 AI 会话前置插件链,让 AI 会话可以按任务意图和快速/标准/严格模式选择本次 Skill,并把使用过程写入上下文追踪。
+
+成功标准:
+1. 标准模式写下一章时能选择章节承接、下一章计划、人物动机、冲突升级、剧情自检、正文输出协议等 Skill。
+2. 快速模式只选择正文输出协议和可选去 AI 味/风格类 Skill,避免强制生成复杂计划。
+3. 严格模式能选择主线检查、伏笔管理、节奏检查、结尾钩子等更完整 Skill。
+4. Skill 进入提示词和追踪,但最终章节正文不得混入 Skill 分析过程。
+5. 本阶段不实现外部搜索、MCP、Skill 市场或上传流程。
+
+修改文件:
+- `src/lib/agent/plugins/select-skills-plugin.ts`
+- `src/lib/agent/plugins/select-skills-plugin.spec.ts`
+- `src/lib/agent/plugins/build-system-prompt-plugin.ts`
+- `src/lib/agent/plugins/build-system-prompt-plugin.spec.ts`
+- `src/lib/agent/novel-pre-plugin-chain.ts`
+- `src/lib/agent/novel-pre-plugin-chain.spec.ts`
+- `src/lib/agent/plugin-scenarios.spec.ts`
+- `src/lib/agent/pipeline.ts`
+- `src/lib/agent/context-trace.ts`
+- `src/lib/agent/context-trace-builders.ts`
+- `src/lib/agent/context-trace-builders.spec.ts`
+- `src/components/chat/context-trace-panel.tsx`
+- `src/components/chat/context-trace-panel.spec.tsx`
+- `src/components/chat/chat-panel.tsx`
+- `src/components/chat/chat-panel.spec.tsx`
+- `src/lib/agent/tools/apply-skill.ts`
+- `src/lib/agent/tools/write-tools.spec.ts`
+- `GenxinLOG/更新日志.md`
+- `gongjudiaoyongyouhua-分支说明.md`
+
+实现记录:
+1. 新增 `select_skills` 前置插件,优先级位于 `build_context_pack` 之后、`soul_dialog` 和 `trim_context` 之前。
+2. `select_skills` 按 `taskRoute/effectiveTaskRoute + aiWorkflowMode + availableSkills` 选择 Skill,并返回 `selectedSkills`。
+3. `buildSelectedSkillsPrompt` 将选中 Skill 注入系统提示词,并要求最终回复不要解释 Skill 或输出 Skill 分析过程。
+4. AI 会话入口把当前模式和可用去 AI 味 Skill 转换后的 `UserSkill` 传入前置插件链,并把 `selectedSkills` 对应提示词加入最终 Agent system prompt。
+5. `ContextTrace` 新增 `selectedSkills` 元数据;追踪面板显示 Skill 名称、类型、阶段和来源,不显示 Skill 正文。
+6. `apply_skill` 保留旧去 AI 味配置读取,同时增加可选通用 `UserSkill` 读取入口,为后续用户上传 Skill 预留接口。
+
+验证记录:
+1. 已先写失败测试并确认预期失败:新插件缺失、链路缺少 `select_skills`、提示词未注入、追踪未展示、聊天入口未传入 Skill、`apply_skill` 不支持通用 Skill。
+2. `npm.cmd exec -- vitest run src/lib/agent/plugins/select-skills-plugin.spec.ts src/lib/agent/plugins/build-system-prompt-plugin.spec.ts src/lib/agent/novel-pre-plugin-chain.spec.ts src/lib/agent/context-trace-builders.spec.ts src/components/chat/context-trace-panel.spec.tsx src/components/chat/chat-panel.spec.tsx src/lib/agent/tools/write-tools.spec.ts --reporter=verbose`:7 个测试文件、46 个用例通过。
+3. `npm.cmd exec -- vitest run src/lib/agent/plugin-scenarios.spec.ts --reporter=verbose`:1 个测试文件、8 个用例通过。
+4. `npm.cmd run typecheck`:通过。
+5. `npm.cmd run test:mocks`:315 个测试文件、2373 个用例通过。
+6. `npm.cmd run build`:通过;保留既有 Vite dynamic import、chunk size、plugin timings 警告。
+7. 源码启动验证:`npm.cmd run dev -- --host 127.0.0.1 --port 5173` 成功监听,验证后端口 5173 已释放。
+8. `npm.cmd run build:portable`:通过,生成 `release-portable\QMaiWrite.exe` 和 `release-portable\version-info.json`;版本 `2.2.31`,大小 `149197312` 字节。
+9. 本轮未提交 git,未合并 main。
+
+### 20260702-123204 通用 Skill 数据模型基础
+
+本轮目标:按照 AI 会话能力扩展大阶段计划执行阶段 2,在不破坏现有去 AI 味 Skill 的前提下,补充通用 Skill 数据模型,让“三翻四抖”等结构类/计划类 Skill 后续可以被分类、被引用、被路由。
+
+成功标准:
+
+1. 通用 Skill 可以表达类型、阶段、模式和来源。
+2. “三翻四抖”这类结构类 Skill 可以被规范化为 `structure + planning`。
+3. 旧去 AI 味 Skill 可以转换为通用 `style` Skill。
+4. 技能库 UI 能显示 Skill 类型、适用阶段和适用模式。
+5. @ 引用技能时能携带通用 Skill 元信息。
+6. 本阶段不接入 AI 会话自动选择,不接外部搜索,不接 MCP。
+
+修改文件:
+
+- `src/lib/novel/skill-library.ts`
+- `src/lib/novel/skill-library.spec.ts`
+- `src/lib/novel/de-ai-skill-library.ts`
+- `src/components/skill-library/skill-library-view.tsx`
+- `src/components/skill-library/skill-library-view.spec.tsx`
+- `src/lib/reference/types.ts`
+- `src/lib/reference/providers.ts`
+- `src/lib/reference/providers.spec.ts`
+- `GenxinLOG/更新日志.md`
+- `gongjudiaoyongyouhua-分支说明.md`
+
+实现记录:
+
+1. 新增 `UserSkill`、`SkillKind`、`SkillStage`、`SkillMode`、`normalizeUserSkill` 和 `filterUserSkills`。
+2. 新增 Skill 类型、阶段、模式中文标签,供 UI 展示使用。
+3. 新增 `deAiSkillToUserSkill`,将旧去 AI 味 Skill 映射为通用风格类 Skill,适用阶段为改写和输出,适用模式为快速、标准、严格。
+4. 技能库详情页展示“类型:风格”“阶段:改写、输出”“模式:快速、标准、严格”。
+5. `ReferenceToken` 增加 `skillKinds`、`skillStages`、`skillModes`,`createSkillProvider` 会透传这些元信息。
+
+验证记录:
+
+1. 已先写失败测试并确认失败:`skill-library` 模块缺失、技能库 UI 未显示类型/阶段/模式、技能引用 token 未携带通用 Skill 元信息。
+2. 已运行 `npm.cmd exec -- vitest run src/lib/novel/skill-library.spec.ts --reporter=verbose`,1 个测试文件、3 个用例通过。
+3. 已运行 `npm.cmd exec -- vitest run src/components/skill-library/skill-library-view.spec.tsx --reporter=verbose`,1 个测试文件、14 个用例通过。
+4. 已运行 `npm.cmd exec -- vitest run src/lib/reference/providers.spec.ts --reporter=verbose`,1 个测试文件、7 个用例通过。
+5. 已运行 `npm.cmd exec -- vitest run src/lib/novel/skill-library.spec.ts src/components/skill-library/skill-library-view.spec.tsx src/lib/reference/providers.spec.ts --reporter=verbose`,3 个测试文件、24 个用例通过。
+6. `npm.cmd run typecheck`:通过。
+7. `npm.cmd run test:mocks`:312 个测试文件、2362 个用例通过。
+8. `npm.cmd run build`:通过;保留既有 Vite dynamic import、chunk size、plugin timings 警告。
+9. 源码启动:`npm.cmd run dev -- --host 127.0.0.1 --port 5173` 通过 Job 验证,Vite ready 地址为 `http://127.0.0.1:5173/`,验证后端口 5173 已释放。
+10. `npm.cmd run build:portable`:通过,生成 `release-portable\QMaiWrite.exe` 和 `release-portable\version-info.json`;版本 `2.2.31`,大小 `149193728` 字节。
+11. 本轮未提交 git,未合并 main。
+
+### 20260702-121521 AI会话三档模式基础
+
+本轮目标:按照 AI 会话能力扩展大阶段计划,先执行阶段 1,将单一深度模式升级为快速、标准、严格三档 AI 会话执行模式,并为后续 Skill、外部搜索、MCP 路由打基础。
+
+成功标准:
+
+1. AI 会话 UI 可选择快速、标准、严格。
+2. 旧深度模式逻辑不丢失,旧开关开启等价于严格模式,关闭等价于标准模式。
+3. AI 会话系统提示词能根据当前模式改变执行深度。
+4. 上下文追踪能记录本次使用的 `workflowMode`。
+5. 本阶段不接入通用 Skill、外部搜索或 MCP。
+
+修改文件:
+
+- `src/lib/agent/workflow-mode.ts`
+- `src/lib/agent/workflow-mode.spec.ts`
+- `src/lib/agent/context-trace.ts`
+- `src/lib/agent/context-trace-builders.ts`
+- `src/lib/agent/context-trace-builders.spec.ts`
+- `src/stores/wiki-store.ts`
+- `src/stores/wiki-store.test.ts`
+- `src/components/chat/chat-panel.tsx`
+- `src/components/chat/chat-panel.spec.tsx`
+- `src/components/chat/chat-message.spec.tsx`
+- `GenxinLOG/更新日志.md`
+- `gongjudiaoyongyouhua-分支说明.md`
+
+实现记录:
+
+1. 新增 `AiWorkflowMode = "fast" | "standard" | "strict"`、默认标准模式、旧深度模式映射和中文标签。
+2. `wiki-store` 新增 `aiWorkflowMode` 与 `setAiWorkflowMode`,同时保留 `deepChapterEnabled` 兼容状态。
+3. AI 会话系统提示词按快速、标准、严格三种模式注入不同执行要求。
+4. AI 会话底部工具栏将旧深度模式入口替换为三段式模式控件。
+5. `ContextTrace` 和 `buildInitialContextTraceInfo` 增加 `workflowMode` 记录。
+6. 清理聊天面板中旧深度按钮样式测试,改为三档模式按钮样式测试。
+
+验证记录:
+
+1. 已先写失败测试并确认失败:`getWorkflowModeButtonClass` 缺失、旧底部文案仍查找“深度模式”。
+2. 已运行 `npm.cmd exec -- vitest run src/components/chat/chat-message.spec.tsx --reporter=verbose`,1 个测试文件、10 个用例通过。
+3. 已运行 `npm.cmd exec -- vitest run src/lib/agent/workflow-mode.spec.ts src/lib/agent/context-trace-builders.spec.ts src/stores/wiki-store.test.ts src/components/chat/chat-panel.spec.tsx --reporter=verbose`,4 个测试文件、38 个用例通过。
+4. `npm.cmd run typecheck`:通过。
+5. `npm.cmd run test:mocks`:311 个测试文件、2359 个用例通过。
+6. `npm.cmd run build`:通过;保留既有 Vite dynamic import、chunk size、plugin timings 警告。
+7. 源码启动:`npm.cmd run dev -- --host 127.0.0.1 --port 5173` 通过 Job 验证,Vite ready 地址为 `http://127.0.0.1:5173/`,验证后端口 5173 已释放。
+8. `npm.cmd run build:portable`:通过,生成 `release-portable\QMaiWrite.exe` 和 `release-portable\version-info.json`;版本 `2.2.31`,大小 `149197312` 字节。
+9. 本轮未提交 git,未合并 main。
+
+### 20260702-1047 AI会话右侧宽度支持窗口50%
+
+本轮目标:根据用户反馈,修复 AI 会话右侧面板占比太窄、无法拉到窗口 50% 的问题。
+
+成功标准:
+
+1. AI 会话右侧 dock 可以拖拽到当前窗口宽度的 50%。
+2. AI 大纲右侧 dock 使用同一宽度规则,避免两个聊天入口体验不一致。
+3. 默认打开的右侧聊天不再停留在旧的 `360px` 窄宽度。
+4. 不影响底部聊天高度、消息气泡最大宽度和 Agent 生成流程。
+
+修改文件:
+
+- `src/lib/workspace-layout.ts`
+- `src/lib/workspace-layout.test.ts`
+- `src/components/layout/writing-workspace.tsx`
+- `src/components/sources/sources-view.tsx`
+- `GenxinLOG/更新日志.md`
+- `gongjudiaoyongyouhua-分支说明.md`
+
+实现记录:
+
+1. `clampChatWidth` 从固定最大 `520px` 改为按当前窗口宽度 50% 计算最大值。
+2. 新增右侧聊天默认宽度和初始宽度解析逻辑,旧的 `360px` 默认保存值会自动提升到新默认宽度。
+3. AI 会话和 AI 大纲右侧 dock 均使用新的初始宽度规则。
+4. 已完成 `npm.cmd run test:mocks -- src/lib/workspace-layout.test.ts src/components/layout/chat-layout.spec.ts src/components/layout/chat-layout.test.ts`、`npm.cmd run typecheck`、`npm.cmd run test:mocks`、`npm.cmd run build` 和源码启动验证。
+5. 已完成 `npm.cmd run build:portable`,生成 `release-portable\QMaiWrite.exe`,版本 `2.2.31`,大小 `149283328` 字节。
+6. 未提交 git,未合并 main。
+
+### 20260702-1019 思考过程读取对象与完成状态修复
+
+本轮目标:根据用户截图反馈,修正思考过程只显示“读取章节/读取记忆”但看不出读了哪一个对象的问题,并修复结果已生成后仍显示“正在读取...”转圈的问题。
+
+成功标准:
+
+1. 默认思考过程显示读取对象名称,例如“读取章节《第1章-开篇》”“读取记忆「主角档案」”“读取大纲《总大纲》”。
+2. 默认思考过程继续隐藏 `read_chapter`、`read_memory` 等内部工具名和序号。
+3. 生成完成或失败后,残留 `running` 工具调用必须被收敛为完成或失败状态,不再继续转圈。
+4. AI 会话和 AI 大纲两侧都应用同一状态收敛逻辑。
+
+修改文件:
+
+- `src/components/chat/agent-tool-call-message.tsx`
+- `src/components/chat/agent-tool-call-message.spec.tsx`
+- `src/lib/agent/tool-events.ts`
+- `src/lib/agent/tool-events.spec.ts`
+- `src/components/chat/chat-panel.tsx`
+- `src/components/chat/chat-panel.spec.tsx`
+- `src/components/sources/outline-chat-panel.tsx`
+- `src/components/sources/outline-chat-panel.spec.tsx`
+- `GenxinLOG/更新日志.md`
+- `gongjudiaoyongyouhua-分支说明.md`
+
+实现记录:
+
+1. 思考过程摘要新增读取目标格式化,读取章节/大纲用书名号,读取记忆/推演/历史会话用引号,搜索章节显示关键词。
+2. 新增 `settleRunningAgentToolCalls`,用于会话完成或失败时收敛残留的 `running` 工具状态。
+3. AI 会话和 AI 大纲的 `onDone`、最终落盘和错误路径均接入状态收敛,避免结果完成后继续显示“正在...”。
+4. 已完成 `npm.cmd run test:mocks -- src/components/chat/agent-tool-call-message.spec.tsx src/lib/agent/tool-events.spec.ts src/components/chat/chat-panel.spec.tsx src/components/sources/outline-chat-panel.spec.tsx`、`npm.cmd run typecheck`、`npm.cmd run test:mocks`、`npm.cmd run build` 和源码启动验证。
+5. 已完成 `npm.cmd run build:portable`,生成 `release-portable\QMaiWrite.exe`,版本 `2.2.31`,大小 `149283328` 字节。
+6. 未提交 git,未合并 main。
+
+### 20260702-1003 章节生成只输出思考内容修复
+
+本轮目标:修复 AI 会话生成下一章时,模型只返回少量 reasoning/思考内容,界面反复显示“模型只输出了思考内容,但没有输出正文”的问题。
+
+成功标准:
+
+1. 章节生成、续写、改写、润色类 Agent 请求必须为正文输出预留足够 `max_tokens`。
+2. 这些正文生成请求默认关闭模型 reasoning,避免思考 token 把正文预算耗尽。
+3. 保留工具调用能力,不能因为传入 `max_tokens/reasoning` 覆盖项导致工具参数丢失。
+4. 不改变 AI 大纲、工具详情展示、写入确认和普通聊天逻辑。
+
+修改文件:
+
+- `src/lib/agent/types.ts`
+- `src/lib/agent/runner.ts`
+- `src/lib/agent/runner.spec.ts`
+- `src/components/chat/chat-panel.tsx`
+- `src/components/chat/chat-panel.spec.tsx`
+- `GenxinLOG/更新日志.md`
+- `gongjudiaoyongyouhua-分支说明.md`
+
+实现记录:
+
+1. `AgentConfig` 新增 `requestOverrides`,用于由上层为特定任务传入模型请求参数。
+2. `AgentRunner` 调用 `streamChat` 时会把 `requestOverrides` 与工具调用参数合并,保留 `tools` 和 `toolChoice: "auto"`。
+3. AI 会话识别到 `write_chapter`、`continue_chapter`、`rewrite_chapter`、`polish_chapter` 后,使用 `resolveChapterLengthSpec` 计算输出预算,并传入 `reasoning: { mode: "off" }` 与 `max_tokens`。
+4. 已完成 `npm.cmd run test:mocks -- src/lib/agent/runner.spec.ts src/components/chat/chat-panel.spec.tsx`、`npm.cmd run typecheck`、`npm.cmd run test:mocks`、`npm.cmd run build` 和源码启动验证。
+5. 已完成 `npm.cmd run build:portable`,生成 `release-portable\QMaiWrite.exe`,版本 `2.2.31`,大小 `149283328` 字节。
+6. 未提交 git,未合并 main。
+
+### 20260702-0952 AI会话思考过程简化展示
+
+本轮目标:根据用户反馈,把 AI 会话和 AI 大纲中的“思考过程”从工具调用日志式展示改为简洁动作展示。
+
+成功标准:
+
+1. 默认只显示“正在读取章节”“读取大纲”“等待用户确认保存”等动作,不显示序号、工具名、具体章节名或目标文件名。
+2. 原始工具调用信息仍保留在“工具详情”折叠区,用户需要排查或确认保存时可以展开查看。
+3. 不改变工具执行、上下文加载、写入确认和 AI 大纲工具权限逻辑。
+
+修改文件:
+
+- `src/components/chat/agent-tool-call-message.tsx`
+- `src/components/chat/agent-tool-call-message.spec.tsx`
+- `GenxinLOG/更新日志.md`
+- `gongjudiaoyongyouhua-分支说明.md`
+
+实现记录:
+
+1. `AgentToolCallMessage` 默认展示层隐藏工具名、序号、耗时和具体目标,只保留图标与动作文本。
+2. 运行中状态显示为“正在 + 动作”,失败状态显示为“动作失败”,待确认状态统一显示“等待用户确认保存”。
+3. 原始 `ToolCallTimeline` 继续折叠在“工具详情”中,展开后可查看完整工具调用信息。
+4. 已完成 `npm.cmd run test:mocks -- src/components/chat/agent-tool-call-message.spec.tsx`、`npm.cmd run typecheck`、`npm.cmd run test:mocks`、`npm.cmd run build`、源码启动验证和 `npm.cmd run build:portable`。
+5. 便携版已生成:`release-portable\QMaiWrite.exe`,版本 `2.2.31`,大小 `149283328` 字节。
+6. 未提交 git,未合并 main。
+
+### 20260702-0812 AI大纲生成工作流与会话宽度修复
+
+本轮目标:修复 AI 会话和 AI 大纲消息区域偏窄、AI 大纲生成按钮返回流程分析而非可用正文、工具调用时间线在窄面板排版拥挤的问题。
+
+成功标准:
+
+1. AI 会话和 AI 大纲消息气泡在桌面端可扩展到窗口约 50%,同时在窄窗口内不横向溢出。
+2. 所有 AI 大纲生成分项统一遵循 PRD 3.1 主流程:识别意图、读取资料、提取关键内容、结合 skill 与 soul.md 生成、按结果协议收敛。
+3. 章节细纲、人物小传、组织势力、金手指能力、伏笔计划、地点设定等分项都输出可直接保存的大纲正文,不用工具调用报告或泛泛分析代替正文。
+4. 工具调用时间线在窄面板内分行展示工具名、状态和说明,长内容只在展开区滚动查看。
+
+准备修改文件:
+
+- `src/components/chat/chat-message.tsx`
+- `src/components/chat/chat-message.spec.tsx`
+- `src/components/chat/tool-call-timeline.tsx`
+- `src/components/chat/agent-tool-call-message.spec.tsx`
+- `src/components/sources/outline-chat-panel.tsx`
+- `src/components/sources/outline-chat-panel.spec.tsx`
+
+计划:
+
+1. 先写失败测试,覆盖宽度类、统一大纲生成工作流协议、工具调用时间线换行布局。
+2. 最小实现通过测试,不改动无关 Agent 架构和数据源路由。
+3. 跑定向测试,再跑 `npm.cmd run typecheck`、`npm.cmd run test:mocks`、`npm.cmd run build`。
+4. 按 PRD 13.2,本阶段不默认执行便携版打包,除非用户明确要求。
+
+实现记录:
+
+1. AI 大纲系统提示新增“AI大纲生成工作流”,对齐 PRD 3.1:提取请求关键词、识别用户意图、读取资料、提取关键创作内容、结合 skill 与 soul.md 生成、按结果协议收敛。
+2. AI 大纲生成菜单改为通过 `buildOutlineSectionGenerationPrompt` 构造请求,章节细纲、人物、组织势力、能力体系、伏笔、地点设定分别有正文输出结构约束。
+3. 独立大纲细化/文件生成入口 `outline-generation.ts` 同步加入同类工作流和结果收敛约束,避免只在聊天菜单生效。
+4. AI 会话与 AI 大纲消息气泡从固定 80%/85% 改为 `max-w-full lg:max-w-[50vw]`,窗口窄时不溢出,桌面端可扩展到约半屏。
+5. 工具调用时间线主行改为两层布局:工具名和状态一行,说明独立换行;外层限制 `max-w-full overflow-hidden`,展开区支持滚动。
+
+### 20260702-修复规划
+
+本轮目标:把 AI-Agent PRD 与问答记录对照审查中发现的 7 个问题全部纳入修复闭环,先补测试,再改实现,最后跑旧功能验证。
+
+成功标准:
+
+1. 低置信度澄清后重新执行插件链时,新的 `contextPack`、`novelSystemPrompt` 和 `finalSystemPrompt` 会覆盖旧结果,后续生成不再使用澄清前上下文。
+2. `classification.md` 路由能力默认进入小说模式聊天前置插件链,并在上下文追踪中记录 `routeSource`、`blockedSources`、`fallbackReason` 和版本信息。
+3. 上下文加载从“全量加载后裁剪字段”推进为“按 classification 路由预过滤数据源后加载”,减少禁载数据源读取。
+4. `handleContinueUnfinished` 的普通继续路径接入 `AgentRunner` 与前置插件链;若保留深度章节 checkpoint 路径,需在说明中限定其原因和边界。
+5. 批量重建 `retrieval` 索引时使用章节正文实际 hash,不再用章节号充当 `sourceHash`。
+6. 章节/大纲生成结果在保存或进入正史链路前接入 `result-parser` 校验,并把校验结果写入 `ContextTrace.resultProtocol`。
+7. 模型不支持工具调用的处理口径统一:已知不支持工具的模型走普通对话模式并给出中文提示;运行中发现不支持工具时也使用同一提示与回退路径。
+
+准备修改文件:
+
+- `src/components/chat/chat-panel.tsx`
+- `src/components/chat/chat-panel.spec.tsx`
+- `src/hooks/use-agent-config.ts`
+- `src/hooks/use-agent-config.spec.ts`
+- `src/lib/agent/novel-pre-plugin-chain.ts`
+- `src/lib/agent/novel-pre-plugin-chain.spec.ts`
+- `src/lib/agent/plugins/build-context-pack-plugin.ts`
+- `src/lib/agent/plugins/build-context-pack-plugin.spec.ts`
+- `src/lib/agent/runner.ts`
+- `src/lib/agent/runner.spec.ts`
+- `src/lib/agent/tools/index.ts`
+- `src/lib/agent/types.ts`
+- `src/lib/novel/context-data-sources.ts`
+- `src/lib/novel/context-engine.ts`
+- `src/lib/novel/context-engine.spec.ts`
+- `src/lib/novel/chapter-ingest.ts`
+- `src/lib/novel/retrieval/retrieval-store.spec.ts`
+- `src/lib/novel/result-parser.ts`
+- `src/lib/novel/result-parser.spec.ts`
+
+计划:
+
+1. 先写失败测试:覆盖澄清后上下文替换、classification 默认启用、数据源预过滤、retrieval 重建 hash、结果协议校验、工具不支持回退口径。
+2. 最小实现通过测试:优先改插件链和数据流,不重构无关 UI。
+3. 把继续未完成路径分两步处理:普通继续接入 AgentRunner;checkpoint 深度续写保留专用路径并补边界说明,避免破坏深度章节恢复能力。
+4. 跑定向测试,再跑 `npm.cmd run typecheck`、`npm.cmd run test:mocks`、`npm.cmd run build`。
+5. 不执行 git commit,不合并 main;是否打包等待用户明确要求。
+
+实现记录:
+
+1. 澄清确认或自定义澄清文本后,重新执行 `runNovelPrePluginChain`,并用重跑结果覆盖 `prePluginResult`、`effectiveTaskRoute` 和 `contextPack`。
+2. `runNovelPrePluginChain` 默认启用 classification 路由能力,`build-context-pack-plugin` 在加载上下文前先解析分类规则,再把允许的数据源分类传给 `buildContextPack`。
+3. `context-engine` 支持按数据源分类注册上下文数据源,`context-data-sources` 新增分类到数据源映射。
+4. 普通继续未完成路径优先使用 `AgentRunner`,深度章节 checkpoint 路径继续保留专用恢复逻辑。
+5. `buildRetrievalIndex` 通过章节正文或快照内容计算 `sourceHash`,不再使用章节号。
+6. `result-parser` 新增结果协议追踪结构,章节/大纲生成结果会写入 `ContextTrace.resultProtocol`。
+7. 已知不支持工具与运行中发现不支持工具两条路径统一降级到普通对话,并显示中文提示 `当前模型不支持工具调用,已切换为普通对话模式`。
+
+### 20260702-073056 复审缺口修复
+
+本轮目标:处理复审发现的 3 个残留问题,确保 7 个 AI-Agent 修复点在主路径和关键边界路径上闭环。
+
+修复内容:
+
+1. `ContextTrace.contextInfo` 初始化时不再把 `routeSource` 固定为 `default`,改为使用前置插件链返回的 `routeSource`、`blockedSources`、`classificationFallbackReason` 和 `classificationVersion`。
+2. 普通“继续未完成”路径构建上下文时改走 `runNovelPrePluginChain`,限定启用 `build_context_pack` 和 `trim_context`,避免绕过 classification 预过滤;深度章节 checkpoint 恢复路径继续保持专用逻辑。
+3. 章节草稿确认保存和章节修改确认保存前新增 `validateChapterBeforeSave` 校验,校验失败时阻断保存并返回中文错误,避免无效章节进入正式章节库。
+4. 新增 `context-trace-builders` 和 `result-save-guard` 两个小型纯函数模块,用真实函数测试覆盖 trace 分类元信息和保存前结果校验。
+
+### 20260701-132053
+
+- 新增统一工具事件层 `applyAgentToolEvent`,聊天面板和 AI 大纲面板统一通过工具事件更新运行中、完成、错误和待确认状态。
+- 新增工具结果压缩模块,长工具结果回灌给模型前保留首尾证据并标记“已压缩给模型使用”,完整结果仍保存在工具调用记录中。
+- 写入类工具默认标记为需要确认,AgentRunner 在没有用户确认时不会执行写入,只返回可审核预览提示。
+- `write_chapter`、`write_memory`、`write_outline_node` 写入后会读回验证,成功显示“读回验证通过”,不一致时返回中文警告。
+- AI 大纲加入固定分析流程:先 list 确认可用资料,再 read 读取内容,分析冲突、缺口、伏笔、角色动机和章节承接,最后生成建议。
+
+## 验证记录
+
+- 20260702-0928:
+  - 本轮目标:把 AI 会话和 AI 大纲中原本裸露的工具调用列表整理为用户可读的“思考过程”,并把原始工具调用折叠到“工具详情”。
+  - 已先写失败测试并确认失败:默认渲染缺少“思考过程”,且原始 `AI 工具调用` 直接显示。
+  - 修复内容:`AgentToolCallMessage` 根据工具调用生成任务识别、资料读取、上下文整理、写入草稿等待确认等过程步骤;原始 `ToolCallTimeline` 默认折叠在“工具详情”中。
+  - `npm.cmd run test:mocks -- src/components/chat/agent-tool-call-message.spec.tsx`:1 个测试文件、10 个用例通过。
+  - `npm.cmd run typecheck`:通过。
+  - `npm.cmd run test:mocks`:309 个测试文件、2335 个用例通过。
+  - `npm.cmd run build`:通过;保留既有 Vite dynamic import、chunk size、plugin timings 警告。
+  - 源码启动:`npm.cmd run dev -- --host 127.0.0.1 --port 5173` 显示 Vite ready,地址为 `http://127.0.0.1:5173/`;验证后确认端口 5173 未监听。
+  - `npm.cmd run build:portable`:通过,生成 `release-portable\QMaiWrite.exe` 和 `release-portable\version-info.json`;版本信息显示 `2.2.31`,便携版体积约 142.37 MB。保留既有 Vite dynamic import、chunk size、plugin timings 警告。
+  - 本轮未提交 git,未合并 main。
+
+- 20260702-0858:
+  - 已先写失败测试并确认失败:低信息普通消息跳过小说上下文链、AI 大纲禁用章节/记忆写入工具、工具调用列表固定网格布局、工具注册过滤新增断言均在实现前失败。
+  - 修复内容:普通聊天 `general_chat` 不再运行小说前置插件链;`clarification_needed` 内部降级普通聊天,不弹窗;AI 大纲注册工具时禁用 `write_chapter`、`write_memory`;工具调用时间线改为统一四列网格并禁止横向滚动。
+  - `npm.cmd run test:mocks -- src/components/chat/chat-panel.spec.tsx src/components/sources/outline-chat-panel.spec.tsx src/components/chat/agent-tool-call-message.spec.tsx src/lib/agent/tools/index.spec.ts`:4 个测试文件、39 个用例通过。
+  - `npm.cmd run typecheck`:通过。
+  - `npm.cmd run test:mocks`:309 个测试文件、2334 个用例通过。
+  - `npm.cmd run build`:通过;保留既有 Vite dynamic import、chunk size、plugin timings 警告。
+  - 源码启动:`npm.cmd run dev -- --host 127.0.0.1 --port 5173` 显示 Vite ready,地址为 `http://127.0.0.1:5173/`;验证后确认端口 5173 未监听。
+  - `npm.cmd run build:portable`:通过,生成 `release-portable\QMaiWrite.exe` 和 `release-portable\version-info.json`;版本信息显示 `2.2.31`,便携版体积约 142.37 MB。保留既有 Vite dynamic import、chunk size、plugin timings 警告。
+  - 本轮未提交 git,未合并 main。
+
+- 20260702-0832:
+  - 已先写失败测试并确认失败:AI 大纲统一工作流、独立大纲生成工作流、消息宽度和工具调用换行布局新增断言均在实现前失败。
+  - `npx.cmd vitest run src/components/sources/outline-chat-panel.spec.tsx src/components/chat/chat-message.spec.tsx src/components/chat/agent-tool-call-message.spec.tsx src/lib/novel/outline-generation.spec.ts --reporter=verbose`:4 个测试文件、33 个用例通过。
+  - `npm.cmd run typecheck`:通过。
+  - `npm.cmd run test:mocks`:308 个测试文件、2331 个用例通过。
+  - `npm.cmd run build`:通过;保留既有 Vite dynamic import、chunk size、plugin timings 警告。
+  - 源码启动:`npm.cmd run dev -- --host 127.0.0.1 --port 5173` 成功监听 `http://127.0.0.1:5173/`,验证后已停止;复查端口 5173 未监听。
+  - 按 PRD 13.2 当前分支节奏,本阶段未执行便携版打包,等待用户明确要求。
+
+- 20260702-073056:
+  - 先写失败测试并确认失败:`src/lib/agent/context-trace-builders.spec.ts`、`src/lib/novel/result-save-guard.spec.ts` 和 `src/components/chat/chat-panel.spec.tsx` 覆盖复审发现的 3 个缺口。
+  - `npm.cmd exec -- vitest run src/lib/agent/novel-pre-plugin-chain.spec.ts src/lib/agent/plugins/build-context-pack-plugin.spec.ts src/lib/agent/context-trace-builders.spec.ts src/lib/novel/context-data-sources.spec.ts src/lib/novel/chapter-ingest.retrieval.spec.ts src/lib/novel/result-parser.spec.ts src/lib/novel/result-save-guard.spec.ts src/components/chat/chat-panel.spec.tsx`:8 个测试文件、53 个用例通过。
+  - `npm.cmd run typecheck`:通过。
+  - `npm.cmd run test:mocks`:308 个测试文件、2323 个用例通过。
+  - `npm.cmd run build`:通过;保留既有 Vite dynamic import、chunk size、plugin timings 警告。
+  - 源码启动:`npm.cmd run dev -- --host 127.0.0.1 --port 5173` 通过 Job 验证,Vite ready 地址为 `http://127.0.0.1:5173/`,验证后已停止。
+  - `npm.cmd run build:portable`:通过,生成 `release-portable\QMaiWrite.exe` 和 `release-portable\version-info.json`;保留既有 Vite dynamic import、chunk size、plugin timings 警告。
+
+- 20260702-003837:
+  - `npm.cmd exec -- vitest run src/lib/agent/novel-pre-plugin-chain.spec.ts src/lib/agent/plugins/build-context-pack-plugin.spec.ts src/lib/novel/context-data-sources.spec.ts src/lib/novel/chapter-ingest.retrieval.spec.ts src/lib/novel/result-parser.spec.ts src/components/chat/chat-panel.spec.tsx`:6 个测试文件、48 个用例通过。
+  - `npm.cmd run typecheck`:通过。
+  - `npm.cmd run test:mocks`:306 个测试文件、2318 个用例通过。
+  - `npm.cmd run build`:通过;保留既有 Vite dynamic import、chunk size、plugin timings 警告。
+  - 源码启动:`npm.cmd run dev -- --host 127.0.0.1 --port 5173` 通过 Job 验证,Vite ready 地址为 `http://127.0.0.1:5173/`,验证后已停止。
+  - `npm.cmd run build:portable`:通过,生成 `release-portable\QMaiWrite.exe` 和 `release-portable\version-info.json`;保留既有 Vite dynamic import、chunk size、plugin timings 警告。
+
+- 20260701-132053:
+  - `npm.cmd exec -- vitest run src/lib/agent/runner.spec.ts src/lib/agent/tool-events.spec.ts src/lib/agent/tool-result.spec.ts src/lib/agent/tools/read-tools.spec.ts src/lib/agent/tools/write-tools.spec.ts src/components/chat/agent-tool-call-message.spec.tsx src/components/chat/chat-panel.spec.tsx src/components/sources/outline-chat-panel.spec.tsx src/components/reference/ReferenceInput.spec.tsx src/components/reference/ReferencePickerDialog.spec.tsx src/lib/reference/providers.spec.ts`:11 个测试文件、75 个用例通过。
+  - `npm.cmd run typecheck`:通过。
+  - `npm.cmd run test:mocks`:287 个测试文件、2127 个用例通过。
+  - `npm.cmd run build`:通过,存在既有 Vite chunk/dynamic import 警告。
+  - 源码启动:`npm.cmd run dev -- --host 127.0.0.1 --port 5173` 通过 Job 验证,Vite ready 后已停止。
+  - `npm.cmd run build:portable`:通过,生成 `release-portable\QMaiWrite.exe` 和 `version-info.json`;Rust 构建存在既有 `file_sync.rs` dead-code 警告。
+
+## Git 状态
+
+- 20260702-134755 AI Capability 能力层已完成验证并已完成便携版打包:新增统一能力层、`select_capabilities` 前置插件、`selectedCapabilities` 追踪与“启用能力”面板展示,工具注册新增 `enabledToolNames` 且 `disabledTools` 继续优先。本阶段未实现真实 MCP 连接、未新增 MCP 市场、未绕过写入确认。未提交 git,未合并 main。
+- 20260702-132235 AI 会话外部搜索工具接入已完成验证并已完成便携版打包:新增 `web_search` 与 `read_web_page` 两个只读 Agent 工具,复用现有 Web Search 配置;未配置或失败时中文降级提示,未调用 `web_search` 不得声称已搜索;搜索过程进入 `ContextTrace.webSearches` 和追踪面板。本阶段未实现 MCP、未实现能力层、未改 Web Search 设置页结构。未提交 git,未合并 main。
+- 20260702-130209 AI 会话剩余能力设计执行计划:新增 `docs/superpowers/plans/2026-07-02-ai-chat-remaining-capability-plan.md`,明确阶段 1-3 已完成,后续从阶段 4 外部搜索工具开始,并拆分 AI Capability 能力层、MCP Adapter、端到端收敛三个后续阶段。本轮只新增计划文档,未修改业务代码,未运行测试,未打包,未提交 git,未合并 main。
+- 20260702-125305 AI 会话 Skill 路由接入已完成验证并已完成便携版打包:新增 `select_skills` 前置插件,按任务意图和三档模式选择 Skill,选中 Skill 进入提示词和上下文追踪;本阶段未接外部搜索、MCP、Skill 市场或上传流程。未提交 git,未合并 main。
+- 20260702-123204 通用 Skill 数据模型基础已完成验证并已完成便携版打包:新增通用 Skill 类型/阶段/模式结构,旧去 AI 味 Skill 可适配为通用风格类 Skill,技能库 UI 和 @ 引用携带元信息;未接 AI 会话自动选择、外部搜索或 MCP。未提交 git,未合并 main。
+- 20260702-121521 AI会话三档模式基础已完成验证并已完成便携版打包:新增快速、标准、严格三档执行模式,保留旧深度模式兼容,模式进入系统提示词与上下文追踪;未接入通用 Skill、外部搜索或 MCP。未提交 git,未合并 main。
+- 20260702-120741 AI会话能力扩展设计计划:新增 `docs/superpowers/specs/2026-07-02-ai-chat-capability-design.md`,用于固定快速/标准/严格三档模式、通用 Skill、外部搜索、AI Capability 能力层、MCP Adapter、上下文追踪和分阶段落地边界。本轮只补设计计划文档,未继续实现业务代码,未提交 git,未合并 main。
+- 20260702-115932 AI会话能力扩展大阶段执行启动:完成阶段 0 基线确认,当前分支为 `gongjudiaoyongyouhua`,现有 `deepChapterEnabled` 尚未升级为 `aiWorkflowMode`,未接通用 Skill、外部搜索或 MCP。本轮准备先执行阶段 1:三档模式基础。未提交 git,未合并 main。
+- 20260702-1047 AI会话右侧宽度支持窗口50%已完成验证并已完成便携版打包,未提交 git,未合并 main。
+- 20260702-1019 思考过程读取对象与完成状态修复已完成验证并已完成便携版打包,未提交 git,未合并 main。
+- 20260702-0928 AI会话思考过程展示优化已完成验证并已完成便携版打包,未提交 git,未合并 main。
+- 20260702-0858 意图分析、AI大纲工具权限与工具列表排版修复已完成验证并已完成便携版打包,未提交 git,未合并 main。
+- 20260702-0832 AI大纲生成工作流与会话宽度修复已完成验证,未提交 git,未合并 main。
+- 20260702-073056 复审缺口修复已完成验证,未提交 git,未合并 main。
+- 20260702-003837 AI-Agent PRD 与问答记录对照修复已完成验证,未提交 git,未合并 main。
+- 20260701-132053 工具调用流程优化纳入本次提交。
+- 不合并 main。
+
+### 20260702-0752 AI大纲引用与工具调用渲染修复
+
+本轮目标:修复 AI 大纲在 @ 引用大纲/章节后,助手工具调用更新时出现 React #310 崩溃,以及用户发送消息中不显示已附带 @ 引用内容的问题。
+
+修复内容:
+1. `ToolCallTimeline` 不再在 `toolCalls` 为空时提前跳过后续 Hook,避免工具调用从空数组流式更新为非空数组时触发 Hook 顺序变化。
+2. AI 大纲用户消息渲染 `attachedReferences`,在消息正文下方显示已发送的 @ 引用 chip,保持与普通聊天消息一致。
+3. 新增回归测试:覆盖工具调用从空到非空的渲染过程,以及 AI 大纲用户消息必须渲染 @ 引用。
+
+验证记录:
+1. 已先写失败测试并确认失败:`AgentToolCallMessage` 空工具调用更新为非空时报 `Rendered more hooks than during the previous render`;AI 大纲引用显示测试缺少 `ReferenceChip`。
+2. 已运行 `npx.cmd vitest run src/components/chat/agent-tool-call-message.spec.tsx src/components/sources/outline-chat-panel.spec.tsx --reporter=verbose`,17 个用例通过。
+3. `npm.cmd run typecheck`:通过。
+4. `npm.cmd run test:mocks`:308 个测试文件、2325 个用例通过。
+5. `npm.cmd run build`:通过;保留既有 Vite dynamic import、chunk size、plugin timings 警告。
+6. 源码启动:`npm.cmd run dev -- --host 127.0.0.1 --port 5173` 通过 Job 验证,Vite ready 地址为 `http://127.0.0.1:5173/`,验证后已停止。
+7. `npm.cmd run build:portable`:通过,生成 `release-portable\QMaiWrite.exe` 和 `release-portable\version-info.json`;保留既有 Vite dynamic import、chunk size、plugin timings 警告。
+
+Git 状态:本轮修复未提交 git,未合并 main。
+
+### 20260702-140552 MCP Adapter 基础层
+
+本轮目标:按 Stage 6 计划补齐 AI 会话的 MCP Adapter 基础层,让 graphRAG、knowledgeGraph 等只读或分析型 MCP 能力将来可以被 AI 会话承接,同时保持写入确认、安全降级和上下文追踪边界。
+
+成功标准:
+1. MCP 工具描述可以适配为 QMai Agent Tool。
+2. 只读、分析、建议、写入、删除、覆盖类 MCP 操作有明确权限映射。
+3. 删除和覆盖类 MCP 默认阻断;写入类 MCP 必须走确认路径。
+4. MCP 调用失败时返回中文降级信息,不中断普通 AI 会话。
+5. MCP 调用摘要进入 ContextTrace 和追踪面板,不记录密钥、完整外部结果或敏感配置。
+
+修改文件:
+- `src/lib/mcp/types.ts`
+- `src/lib/mcp/config.ts`
+- `src/lib/mcp/adapter.ts`
+- `src/lib/mcp/adapter.spec.ts`
+- `src/lib/agent/tools/mcp-tool.ts`
+- `src/lib/agent/tools/mcp-tool.spec.ts`
+- `src/lib/agent/tools/index.ts`
+- `src/lib/agent/tools/index.spec.ts`
+- `src/lib/agent/context-trace.ts`
+- `src/lib/agent/context-trace-builders.ts`
+- `src/lib/agent/context-trace-builders.spec.ts`
+- `src/components/chat/context-trace-panel.tsx`
+- `src/components/chat/context-trace-panel.spec.tsx`
+
+实现记录:
+1. 新增 MCP 类型与配置模型,限定当前阶段只做适配层,不做 MCP 市场、自动安装或真实连接管理 UI。
+2. `adaptMcpTool` 将 MCP schema 转换为 Agent Tool 参数模型,不支持的参数类型会返回中文错误并拒绝注册。
+3. `createMcpTool` 使用注入式 `McpToolCaller`,便于后续接入真实 MCP 客户端,也便于当前阶段用测试覆盖失败降级。
+4. MCP 工具通过 `mcpTools` 注入到工具注册流程,并继续受 `enabledToolNames` 与 `disabledTools` 控制。
+5. 追踪面板新增“MCP 调用”,只展示工具名、服务名、操作类型、状态和失败原因等摘要信息。
+
+验证记录:
+1. 定向测试:`npm.cmd exec -- vitest run src/lib/mcp/adapter.spec.ts src/lib/agent/tools/mcp-tool.spec.ts src/lib/agent/tools/index.spec.ts src/lib/agent/context-trace-builders.spec.ts src/components/chat/context-trace-panel.spec.tsx --reporter=verbose`,5 个测试文件、23 个用例通过。
+2. `npm.cmd run typecheck`:通过。
+3. `npm.cmd run test:mocks`:321 个测试文件、2408 个用例通过。
+4. `npm.cmd run build`:通过;保留既有 Vite dynamic import、chunk size、plugin timings 警告。
+5. 源码启动:`npm.cmd run dev -- --host 127.0.0.1 --port 5173` 显示 Vite ready,地址为 `http://127.0.0.1:5173/`;超时截断后复查端口 5173 未监听。
+6. `npm.cmd run build:portable`:通过,生成 `release-portable\QMaiWrite.exe` 和 `release-portable\version-info.json`;版本 `2.2.31`,大小 `149201920` 字节。
+
+Git 状态:本阶段未提交 git,未合并 main。
+
+### 20260702-152521 Stage 9 MCP 配置 UI 与持久化闭环
+
+本轮目标:按 Stage 9 计划补齐 MCP 最小配置入口,让 AI 会话已有 MCP Adapter/Runtime 能从设置页读取用户配置,并在应用重启后恢复;本阶段不实现 MCP 市场、自动安装、真实进程生命周期或外部 MCP 客户端连接。
+
+成功标准:
+1. MCP 配置保存前和启动恢复时都经过归一化,畸形配置不会中断应用。
+2. 设置页存在“MCP 工具”入口,普通用户可以添加示例、启用/禁用、编辑服务和工具 descriptor、删除服务。
+3. 设置页能显示可执行 MCP 工具数量和中文 warning,删除/覆盖类 descriptor 不进入可执行工具。
+4. 应用启动后能把本地保存的 MCP 配置恢复到 `wiki-store`,AI 会话可以继续通过 Stage 8 runtime 接收配置。
+5. 未连接真实 MCP 服务时仍返回中文降级信息,不伪造外部结果,不中断普通 AI 会话。
+
+修改文件:
+- `src/lib/mcp/config.ts`
+- `src/lib/mcp/config.spec.ts`
+- `src/lib/project-store.ts`
+- `src/lib/project-store.integration.test.ts`
+- `src/App.tsx`
+- `src/App.mcp-config.test.ts`
+- `src/components/settings/sections/mcp-section.tsx`
+- `src/components/settings/mcp-section.spec.tsx`
+- `src/components/settings/settings-view.tsx`
+- `src/i18n/zh.json`
+- `src/i18n/en.json`
+- `docs/superpowers/specs/2026-07-02-ai-chat-stage9-mcp-settings-design.md`
+- `docs/superpowers/plans/2026-07-02-ai-chat-stage9-mcp-settings-plan.md`
+- `GenxinLOG/更新日志.md`
+- `gongjudiaoyongyouhua-分支说明.md`
+
+实现记录:
+1. `normalizeMcpConfig`、`normalizeMcpServerConfig` 与 `createSampleGraphMcpServer` 已补齐,配置读取和 UI 示例共用同一归一化逻辑。
+2. `project-store` 新增 `saveMcpConfig` 与 `loadMcpConfig`,应用启动阶段会读取保存配置并写入 `useWikiStore.getState().setMcpConfig`。
+3. 设置页新增 `McpSection`,支持添加 Graph MCP 示例、即时保存、工具 JSON 草稿校验、启用/禁用、删除确认和 runtime warning 摘要。
+4. 设置分类新增“MCP 工具”,中英文 i18n 均已补齐,所有面向用户的中文提示保留中文。
+
+验证记录:
+1. Stage 9 定向测试:`npm.cmd exec -- vitest run src/lib/mcp/config.spec.ts src/lib/mcp/runtime.spec.ts src/lib/project-store.integration.test.ts src/App.mcp-config.test.ts src/components/settings/mcp-section.spec.tsx src/components/settings/settings-model-categories.spec.ts src/hooks/use-agent-config.spec.ts --reporter=verbose`,7 个测试文件、44 个用例通过。
+2. `npm.cmd run typecheck`:通过。
+3. `npm.cmd run test:mocks`:328 个测试文件、2434 个用例通过。
+4. `npm.cmd run build`:通过;保留既有 Vite dynamic import、chunk size、plugin timings 警告。
+5. 源码启动:`npm.cmd run dev -- --host 127.0.0.1 --port 5173` 显示 Vite ready,地址为 `http://127.0.0.1:5173/`;超时截断后复查端口 5173 未监听。
+6. `npm.cmd run build:portable`:通过,生成 `release-portable\QMaiWrite.exe` 和 `release-portable\version-info.json`;版本 `2.2.31`,大小 `149201408` 字节。
+
+Git 状态:本阶段未提交 git,未合并 main。
+
+### 20260702-143025 Stage 7 普通用户工作流闭环
+
+本轮目标:按 Stage 7 计划把模式、classification、Skill、外部搜索、MCP、追踪显示和写入确认收口到普通用户可测试的 AI 会话工作流中。
+
+成功标准:
+1. 快速润色只启用最小上下文工具,不启用写入工具。
+2. 标准下一章启用写作上下文、写入确认工具和用户上传写作 Skill。
+3. 用户明确要求搜索时启用 `web_search` 和 `read_web_page`,不把搜索能力默认塞进所有写作任务。
+4. 严格知识图谱任务可以选择只读 MCP 能力;classification 禁载 `graph` 时不得选择 MCP 图谱能力。
+5. 实际发送给模型的工具列表必须跟 `select_capabilities` 的 `enabledToolNames` 一致。
+6. MCP 工具调用结果进入上下文追踪,但不记录完整外部内容或敏感信息。
+
+修改文件:
+- `src/lib/agent/ai-chat-workflow-convergence.spec.ts`
+- `src/lib/agent/tool-scope.ts`
+- `src/lib/agent/tool-scope.spec.ts`
+- `src/lib/agent/mcp-trace.ts`
+- `src/lib/agent/mcp-trace.spec.ts`
+- `src/lib/agent/plugins/select-skills-plugin.ts`
+- `src/lib/agent/plugins/build-context-pack-plugin.ts`
+- `src/components/chat/chat-panel.tsx`
+
+实现记录:
+1. 新增普通用户工作流收口测试,覆盖快速润色、标准下一章、外部搜索、严格 MCP 和 classification 禁载 MCP。
+2. `scopeAgentConfigTools` 在 AgentRunner 调用前按 `prePluginResult.enabledToolNames` 过滤工具列表,确保能力选择真正影响模型可见工具。
+3. `appendMcpCallTrace` 将 `mcp_*` 工具结果转为 `ContextTrace.mcpCalls` 摘要,过滤 `content` 等完整外部结果。
+4. 标准/严格写作 Skill 选择在保留原默认 Skill 精确列表的同时,只补充 `uploaded` 来源的相关写作 Skill,避免默认项目 Skill 列表变宽。
+5. `build_context_pack` 保留输入中已有的 `blockedSources`,避免上游禁载信息在前置链中丢失。
+
+验证记录:
+1. 新增测试先失败再修复:`tool-scope`、`mcp-trace` 和 `ai-chat-workflow-convergence` 均完成红绿验证。
+2. 定向测试:`npm.cmd exec -- vitest run src/lib/agent/ai-chat-workflow-convergence.spec.ts src/lib/agent/tool-scope.spec.ts src/lib/agent/mcp-trace.spec.ts src/lib/agent/plugins/select-skills-plugin.spec.ts src/lib/agent/plugins/build-context-pack-plugin.spec.ts src/lib/agent/plugins/select-capabilities-plugin.spec.ts src/lib/agent/capabilities/selector.spec.ts src/lib/agent/novel-pre-plugin-chain.spec.ts src/lib/agent/context-trace-builders.spec.ts src/components/chat/context-trace-panel.spec.tsx src/lib/agent/tools/index.spec.ts src/lib/mcp/adapter.spec.ts src/lib/agent/tools/mcp-tool.spec.ts --reporter=verbose`,13 个测试文件、65 个用例通过。
+3. `npm.cmd run typecheck`:通过。
+4. `npm.cmd run test:mocks`:324 个测试文件、2417 个用例通过。
+5. `npm.cmd run build`:通过;保留既有 Vite dynamic import、chunk size、plugin timings 警告。
+6. 源码启动:`npm.cmd run dev -- --host 127.0.0.1 --port 5173` 显示 Vite ready,地址为 `http://127.0.0.1:5173/`;超时截断后复查端口 5173 未监听。
+7. `npm.cmd run build:portable`:通过,生成 `release-portable\QMaiWrite.exe` 和 `release-portable\version-info.json`;版本 `2.2.31`,大小 `149201920` 字节。
+
+Git 状态:本阶段未提交 git,未合并 main。
+
+### 20260702-080431 @ 输入触发引用窗口修复
+
+本轮目标:修复用户在引用输入框输入 `@` 时无法打开引用选择窗口的问题。
+
+根因:`ReferenceInput` 只允许 `event.key === "@"` 且 `shiftKey` 为 false 时触发引用窗口;多数键盘输入 `@` 需要 `Shift+2`,导致正常输入路径被误拦截。
+
+修复内容:
+1. 放宽 `@` 键触发条件,允许 `Shift+@` 打开引用选择窗口。
+2. 保留 Ctrl/Meta 组合键屏蔽,避免影响系统或浏览器快捷键。
+3. 新增 `Shift+@` 回归测试,防止后续再次回退。
+
+验证记录:
+1. 已先写失败测试并确认失败:`Shift+@` 触发时 `onAtTrigger` 调用次数为 0。
+2. 已运行 `npx.cmd vitest run src/components/reference/ReferenceInput.spec.tsx --reporter=verbose`,11 个用例通过。
+3. `npm.cmd run typecheck`:通过。
+4. `npm.cmd run test:mocks`:308 个测试文件、2326 个用例通过。
+5. `npm.cmd run build`:通过;保留既有 Vite dynamic import、chunk size、plugin timings 警告。
+6. 源码启动:`npm.cmd run dev -- --host 127.0.0.1 --port 5173` 通过 Job 验证,Vite ready 地址为 `http://127.0.0.1:5173/`,验证后已停止。
+7. `npm.cmd run build:portable`:通过,生成 `release-portable\QMaiWrite.exe` 和 `release-portable\version-info.json`;保留既有 Vite dynamic import、chunk size、plugin timings 警告。
+
+Git 状态:本轮修复未提交 git,未合并 main。
+### 20260702 AI Chat Stage 5-7 设计计划补充
+
+本轮目标:只补充后续执行设计计划,不修改业务代码。
+
+新增文档:
+1. `docs/superpowers/plans/2026-07-02-ai-chat-stage5-to-stage7-design-plan.md`
+
+设计边界:
+1. 当前从 Stage 5 AI Capability 能力层继续推进。
+2. Stage 5 只做能力层、能力选择、追踪和工具注册兼容,不实现真实 MCP 连接。
+3. Stage 6 再做 MCP Adapter。
+4. Stage 7 再做端到端工作流收敛。
+5. 不允许绕过写入确认。
+6. 不允许把搜索结果或 MCP 结果自动写入记忆、大纲、retrieval 或图谱。
+
+验证记录:
+1. 本轮只新增设计计划文档,未运行测试。
+2. 未打包。
+3. 未提交 git。
+4. 未合并 main。
+# 20260702-145700 Stage 8 MCP 本地配置运行时接线
+
+本轮目标:按 Stage 8 设计,把本地 MCP 配置接入 AI 会话运行时,使启用的只读/分析类 MCP descriptor 可以变成 Agent 工具、AI 能力层能力和预插件链可选择能力;本轮不实现 MCP 市场、自动安装、真实进程生命周期或完整设置 UI。
+
+成功标准:
+1. `McpConfig` 可以转换为 `mcpTools`、`mcpCapabilities` 和中文 warnings。
+2. 删除、覆盖和不支持 schema 的 MCP descriptor 不进入可执行工具列表。
+3. MCP 配置在 `wiki-store` 中有统一状态入口,后续设置页或外部导入可复用。
+4. `useAgentConfig` 能把 MCP 工具注入 `buildAgentConfig`,同时暴露 MCP capabilities/warnings。
+5. AI 会话预插件链能接收 MCP capabilities,并且不会因此丢失已选用户 skill 能力。
+6. 未连接真实 MCP 服务时只返回中文降级信息,不伪造外部结果,不中断普通 AI 会话。
+
+修改文件:
+- `src/lib/mcp/runtime.ts`
+- `src/lib/mcp/runtime.spec.ts`
+- `src/stores/wiki-store.ts`
+- `src/hooks/use-agent-config.ts`
+- `src/hooks/use-agent-config.spec.ts`
+- `src/lib/agent/pipeline.ts`
+- `src/lib/agent/plugins/select-capabilities-plugin.ts`
+- `src/lib/agent/plugins/select-capabilities-plugin.spec.ts`
+- `src/components/chat/chat-panel.tsx`
+- `src/components/chat/chat-panel.spec.tsx`
+- `GenxinLOG/更新日志.md`
+- `gongjudiaoyongyouhua-分支说明.md`
+
+实现记录:
+1. 新增 `buildMcpRuntime`,把启用的 MCP server/tool descriptor 转成 QMai Agent Tool 与 `mcp_tool` 能力。
+2. 新增 `defaultUnavailableMcpCaller`,真实 MCP 尚未连接时返回中文降级结果,避免 AI 会话假装已经查到外部 MCP 内容。
+3. `wiki-store` 增加 `mcpConfig` 和 `setMcpConfig`,默认值为 `DEFAULT_MCP_CONFIG`。
+4. `useAgentConfig` 构建 MCP runtime,并把 `mcpTools` 注入 `buildAgentConfig`;hook 返回值增加 `mcpCapabilities` 和 `mcpWarnings`。
+5. `PrePluginInput` 增加 `mcpCapabilities`,`select_capabilities` 在运行时合并内置工具、已选 skill 和 MCP 能力。
+6. `chat-panel` 在普通发送和继续未完成上下文构建两条路径中传入 `agentMcpCapabilities`。
+
+验证记录:
+1. TDD RED/GREEN 已完成:`runtime`、`use-agent-config`、`select-capabilities-plugin`、`chat-panel` 均先确认失败再实现。
+2. Stage 8 定向测试:`npm.cmd exec -- vitest run src/lib/mcp/runtime.spec.ts src/hooks/use-agent-config.spec.ts src/components/chat/chat-panel.spec.tsx src/lib/agent/plugins/select-capabilities-plugin.spec.ts src/lib/agent/ai-chat-workflow-convergence.spec.ts --reporter=verbose`,5 个测试文件、42 个用例通过。
+3. `npm.cmd run typecheck`:通过。
+4. `npm.cmd run test:mocks`:325 个测试文件、2424 个用例通过。
+5. `npm.cmd run build`:通过;保留既有 Vite dynamic import、chunk size、plugin timings 警告。
+6. 源码启动:`npm.cmd run dev -- --host 127.0.0.1 --port 5173` 显示 Vite ready,地址为 `http://127.0.0.1:5173/`;超时截断后复查端口 5173 未监听。
+7. `npm.cmd run build:portable`:通过,生成 `release-portable\QMaiWrite.exe` 和 `release-portable\version-info.json`;版本 `2.2.31`,大小 `149201920` 字节。
+
+Git 状态:本阶段未提交 git,未合并 main。

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

@@ -39,29 +39,6 @@ pub struct FileSyncState {
     inner: Mutex<FileSyncInner>,
 }
 
-impl FileSyncState {
-    pub fn set_watcher(
-        &self,
-        watcher: Option<RecommendedWatcher>,
-        project_id: Option<String>,
-        project_path: Option<PathBuf>,
-    ) -> Result<(), String> {
-        let mut inner = self.inner.lock().map_err(|_| "file sync state poisoned")?;
-        inner.watcher = watcher;
-        inner.project_id = project_id;
-        inner.project_path = project_path;
-        Ok(())
-    }
-
-    pub fn clear_watcher(&self) -> Result<(), String> {
-        let mut inner = self.inner.lock().map_err(|_| "file sync state poisoned")?;
-        inner.watcher = None;
-        inner.project_id = None;
-        inner.project_path = None;
-        Ok(())
-    }
-}
-
 #[derive(Default)]
 struct FileSyncInner {
     watcher: Option<RecommendedWatcher>,
@@ -1382,354 +1359,6 @@ fn is_app_write_ignored(path: &Path) -> bool {
         .keys()
         .any(|ignored| key == *ignored || key.starts_with(&format!("{ignored}/")))
 }
-
-// ── Server-facing public API (no Tauri dependency) ─────────────────
-
-/// Event emitter callback type used by `do_*` functions.
-/// Receives (event_name, payload_json) pairs.
-pub type EventEmitter = Box<dyn Fn(&str, serde_json::Value) + Send + Sync>;
-
-fn emit_via_callback(emit: &EventEmitter, event: &str, project_id: &str, tasks: &[FileChangeTask]) {
-    let payload = serde_json::json!({
-        "projectId": project_id,
-        "tasks": tasks,
-    });
-    emit(event, payload);
-}
-
-pub fn do_start_project_file_watcher(
-    state: &FileSyncState,
-    project_id: String,
-    project_path: String,
-    source_watch_config: Option<SourceWatchConfig>,
-    emit: EventEmitter,
-) -> Result<FileChangeQueue, String> {
-    run_guarded("start_project_file_watcher", || {
-        let root = PathBuf::from(&project_path);
-        let source_watch_config = normalize_source_watch_config(source_watch_config);
-        let watcher_generation = WATCHER_GENERATION.fetch_add(1, Ordering::SeqCst) + 1;
-        ensure_sync_dir(&root)?;
-        with_queue_lock(&root, || reset_processing_tasks(&root, &project_id))?;
-        enqueue_rescan_changes(&root, &project_id, &source_watch_config)?;
-
-        let emit_arc: Arc<EventEmitter> = Arc::from(emit);
-        process_queue_with_emit(&emit_arc, &root, &project_id)?;
-
-        let (tx, rx) = mpsc::sync_channel::<PathBuf>(8_192);
-        let emit_for_thread = emit_arc.clone();
-        let root_for_thread = root.clone();
-        let project_for_thread = project_id.clone();
-        let config_for_thread = source_watch_config.clone();
-        std::thread::spawn(move || {
-            let mut pending = BTreeSet::<PathBuf>::new();
-            let mut last_periodic_rescan = now_ms();
-            loop {
-                match rx.recv_timeout(Duration::from_millis(700)) {
-                    Ok(path) => {
-                        pending.insert(path);
-                        while let Ok(path) = rx.try_recv() {
-                            pending.insert(path);
-                        }
-                    }
-                    Err(mpsc::RecvTimeoutError::Timeout) => {
-                        // 检查 channel 溢出标志:如果溢出则将 root 加入 pending 触发全量重扫描
-                        if OVERFLOW_FLAG.swap(false, Ordering::Relaxed) {
-                            pending.insert(root_for_thread.clone());
-                        }
-                        if pending.is_empty() {
-                            maybe_periodic_rescan_with_emit(
-                                &emit_for_thread,
-                                &root_for_thread,
-                                &project_for_thread,
-                                &config_for_thread,
-                                watcher_generation,
-                                &mut last_periodic_rescan,
-                            );
-                            continue;
-                        }
-                        let paths = pending.iter().cloned().collect::<Vec<_>>();
-                        pending.clear();
-                        let result = std::panic::catch_unwind(AssertUnwindSafe(|| {
-                            handle_changed_paths_with_emit(
-                                &emit_for_thread,
-                                &root_for_thread,
-                                &project_for_thread,
-                                &config_for_thread,
-                                watcher_generation,
-                                paths,
-                            )
-                        }));
-                        match result {
-                            Ok(Ok(())) => {}
-                            Ok(Err(err)) => eprintln!("[file-sync] change handling failed: {err}"),
-                            Err(_) => eprintln!("[file-sync] watcher worker recovered from panic"),
-                        }
-                        maybe_periodic_rescan_with_emit(
-                            &emit_for_thread,
-                            &root_for_thread,
-                            &project_for_thread,
-                            &config_for_thread,
-                            watcher_generation,
-                            &mut last_periodic_rescan,
-                        );
-                    }
-                    Err(mpsc::RecvTimeoutError::Disconnected) => break,
-                }
-            }
-        });
-
-        let tx_for_watcher = tx.clone();
-        let root_for_error = root.clone();
-        let mut watcher = RecommendedWatcher::new(
-            move |res: notify::Result<Event>| {
-                match res {
-                    Ok(event) => {
-                        for path in event.paths {
-                            if tx_for_watcher.try_send(path).is_err() {
-                                // channel 溢出:设置标志而非依赖 try_send(root)
-                                OVERFLOW_FLAG.store(true, Ordering::Relaxed);
-                                break;
-                            }
-                        }
-                    }
-                    Err(err) => {
-                        eprintln!("[file-sync] watcher error; scheduling rescan: {err}");
-                        let _ = tx_for_watcher.try_send(root_for_error.clone());
-                    }
-                }
-            },
-            Config::default(),
-        )
-        .map_err(|e| format!("Failed to create file watcher: {e}"))?;
-        watcher
-            .watch(&root, RecursiveMode::Recursive)
-            .map_err(|e| format!("Failed to watch '{}': {e}", root.display()))?;
-        for rel in ["raw/sources", "QM", "wiki"] {
-            let path = root.join(rel);
-            if path.exists() {
-                if let Err(err) = watcher.watch(&path, RecursiveMode::Recursive) {
-                    eprintln!(
-                        "[file-sync] failed to add supplemental watch '{}': {err}",
-                        path.display()
-                    );
-                }
-            }
-        }
-
-        state.set_watcher(Some(watcher), Some(project_id.clone()), Some(root.clone()))?;
-
-        let queue = with_queue_lock(&root, || read_queue(&root))?;
-        emit_via_callback(&emit_arc, EVENT_QUEUE_UPDATED, &project_id, &queue.tasks);
-        Ok(queue)
-    })
-}
-
-pub fn do_stop_project_file_watcher(state: &FileSyncState) -> Result<(), String> {
-    run_guarded("stop_project_file_watcher", || {
-        WATCHER_GENERATION.fetch_add(1, Ordering::SeqCst);
-        state.clear_watcher()
-    })
-}
-
-pub fn do_rescan_project_files(
-    project_id: String,
-    project_path: String,
-    source_watch_config: Option<SourceWatchConfig>,
-    emit: &Arc<EventEmitter>,
-) -> Result<FileChangeRescanResult, String> {
-    run_guarded("rescan_project_files", || {
-        let root = PathBuf::from(project_path);
-        let source_watch_config = normalize_source_watch_config(source_watch_config);
-        ensure_sync_dir(&root)?;
-        enqueue_rescan_changes(&root, &project_id, &source_watch_config)?;
-        let changed_tasks = process_queue_with_emit(emit, &root, &project_id)?;
-        let queue = with_queue_lock(&root, || read_queue(&root))?;
-        emit_via_callback(emit, EVENT_QUEUE_UPDATED, &project_id, &queue.tasks);
-        Ok(FileChangeRescanResult {
-            queue,
-            changed_tasks,
-        })
-    })
-}
-
-pub fn do_get_file_change_queue(project_path: String) -> Result<FileChangeQueue, String> {
-    run_guarded("get_file_change_queue", || {
-        let root = PathBuf::from(project_path);
-        with_queue_lock(&root, || read_queue(&root))
-    })
-}
-
-pub fn do_retry_file_change_task(
-    project_id: String,
-    project_path: String,
-    task_id: String,
-    emit: &Arc<EventEmitter>,
-) -> Result<FileChangeQueue, String> {
-    run_guarded("retry_file_change_task", || {
-        let root = PathBuf::from(project_path);
-        with_queue_lock(&root, || {
-            let mut queue = read_queue(&root)?;
-            let now = now_ms();
-            for task in &mut queue.tasks {
-                if task.id == task_id && task.project_id == project_id {
-                    task.status = FileChangeStatus::Pending;
-                    task.error = None;
-                    task.retry_count = 0;
-                    task.needs_rerun = false;
-                    task.updated_at = now;
-                }
-            }
-            write_queue(&root, &queue)
-        })?;
-        process_queue_with_emit(emit, &root, &project_id)?;
-        let queue = with_queue_lock(&root, || read_queue(&root))?;
-        emit_via_callback(emit, EVENT_QUEUE_UPDATED, &project_id, &queue.tasks);
-        Ok(queue)
-    })
-}
-
-pub fn do_ignore_file_change_task(
-    project_id: String,
-    project_path: String,
-    task_id: String,
-    emit: &Arc<EventEmitter>,
-) -> Result<FileChangeQueue, String> {
-    run_guarded("ignore_file_change_task", || {
-        let root = PathBuf::from(project_path);
-        let queue = with_queue_lock(&root, || {
-            let mut queue = read_queue(&root)?;
-            queue
-                .tasks
-                .retain(|task| !(task.id == task_id && task.project_id == project_id));
-            write_queue(&root, &queue)?;
-            read_queue(&root)
-        })?;
-        emit_via_callback(emit, EVENT_QUEUE_UPDATED, &project_id, &queue.tasks);
-        Ok(queue)
-    })
-}
-
-// ── Callback-based internal helpers ────────────────────────────────
-
-fn process_queue_with_emit(
-    emit: &Arc<EventEmitter>,
-    root: &Path,
-    project_id: &str,
-) -> Result<Vec<FileChangeTask>, String> {
-    process_queue_inner(
-        root,
-        project_id,
-        |queue| emit_via_callback(emit, EVENT_QUEUE_UPDATED, project_id, &queue.tasks),
-        |tasks| emit_via_callback(emit, EVENT_CHANGED, project_id, &tasks),
-    )
-}
-
-fn handle_changed_paths_with_emit(
-    emit: &Arc<EventEmitter>,
-    root: &Path,
-    project_id: &str,
-    source_watch_config: &SourceWatchConfig,
-    watcher_generation: u64,
-    paths: Vec<PathBuf>,
-) -> Result<(), String> {
-    if !is_active_watcher_generation(watcher_generation) {
-        return Ok(());
-    }
-    let rules = SourceWatchRules::new(source_watch_config);
-    let mut rels = BTreeSet::<String>::new();
-    let mut app_written_rels = BTreeSet::<String>::new();
-    let snapshot = with_queue_lock(root, || read_snapshot(root))?;
-    for path in paths {
-        if is_app_write_ignored(&path) {
-            collect_known_paths(root, &path, &snapshot, &mut app_written_rels, &rules);
-            continue;
-        }
-        if path.is_dir() {
-            for entry in WalkDir::new(&path).into_iter().filter_map(Result::ok) {
-                if entry.file_type().is_file() && !is_app_write_ignored(entry.path()) {
-                    if let Some(rel) = relative_watch_path(root, entry.path(), &rules, entry.metadata().ok().map(|m| m.len())) {
-                        rels.insert(rel);
-                    }
-                }
-            }
-        } else if let Some(rel) = relative_watch_path(root, &path, &rules, None) {
-            rels.insert(rel);
-        } else if !path.exists() {
-            collect_known_paths(root, &path, &snapshot, &mut rels, &rules);
-        }
-    }
-    if !app_written_rels.is_empty() {
-        sync_snapshot_paths(root, app_written_rels)?;
-    }
-    if rels.is_empty() {
-        return Ok(());
-    }
-    if !is_active_watcher_generation(watcher_generation) {
-        return Ok(());
-    }
-    enqueue_paths(root, project_id, rels)?;
-    if !is_active_watcher_generation(watcher_generation) {
-        return Ok(());
-    }
-    process_queue_with_emit(emit, root, project_id)?;
-    let queue = with_queue_lock(root, || read_queue(root))?;
-    if !is_active_watcher_generation(watcher_generation) {
-        return Ok(());
-    }
-    emit_via_callback(emit, EVENT_QUEUE_UPDATED, project_id, &queue.tasks);
-    Ok(())
-}
-
-fn maybe_periodic_rescan_with_emit(
-    emit: &Arc<EventEmitter>,
-    root: &Path,
-    project_id: &str,
-    source_watch_config: &SourceWatchConfig,
-    watcher_generation: u64,
-    last_periodic_rescan: &mut i64,
-) {
-    if !cfg!(target_os = "linux") || now_ms() - *last_periodic_rescan < LINUX_RESCAN_INTERVAL_MS {
-        return;
-    }
-    *last_periodic_rescan = now_ms();
-    let result = std::panic::catch_unwind(AssertUnwindSafe(|| {
-        rescan_watch_roots_with_emit(emit, root, project_id, source_watch_config, watcher_generation)
-    }));
-    match result {
-        Ok(Ok(())) => {}
-        Ok(Err(err)) => eprintln!("[file-sync] periodic rescan failed: {err}"),
-        Err(_) => eprintln!("[file-sync] periodic rescan recovered from panic"),
-    }
-}
-
-fn rescan_watch_roots_with_emit(
-    emit: &Arc<EventEmitter>,
-    root: &Path,
-    project_id: &str,
-    source_watch_config: &SourceWatchConfig,
-    watcher_generation: u64,
-) -> Result<(), String> {
-    if !is_active_watcher_generation(watcher_generation) {
-        return Ok(());
-    }
-    enqueue_rescan_changes_for_prefixes(
-        root,
-        project_id,
-        &["raw/sources", "QM", "wiki", "purpose.md", "schema.md"],
-        source_watch_config,
-    )?;
-    if !is_active_watcher_generation(watcher_generation) {
-        return Ok(());
-    }
-    process_queue_with_emit(emit, root, project_id)?;
-    let queue = with_queue_lock(root, || read_queue(root))?;
-    if !is_active_watcher_generation(watcher_generation) {
-        return Ok(());
-    }
-    emit_via_callback(emit, EVENT_QUEUE_UPDATED, project_id, &queue.tasks);
-    Ok(())
-}
-
 #[cfg(test)]
 mod tests {
     use super::*;

+ 168 - 0
src-tauri/src/commands/mcp_stdio.rs

@@ -0,0 +1,168 @@
+use std::collections::HashMap;
+use std::process::Stdio;
+use std::sync::Arc;
+use std::time::Duration;
+
+use serde::Deserialize;
+use tauri::State;
+use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
+use tokio::process::{Child, ChildStdin, ChildStdout, Command};
+use tokio::sync::Mutex;
+
+#[derive(Default)]
+pub struct McpStdioState {
+    children: Arc<Mutex<HashMap<u32, McpChild>>>,
+}
+
+struct McpChild {
+    child: Child,
+    stdin: ChildStdin,
+    stdout: BufReader<ChildStdout>,
+}
+
+#[derive(Deserialize)]
+pub struct McpStdioSpawnOptions {
+    command: String,
+    args: Option<Vec<String>>,
+    cwd: Option<String>,
+    env: Option<HashMap<String, String>>,
+}
+
+fn suppress_windows_console(_cmd: &mut Command) {
+    #[cfg(windows)]
+    {
+        #[allow(unused_imports)]
+        use std::os::windows::process::CommandExt;
+
+        const CREATE_NO_WINDOW: u32 = 0x08000000;
+        _cmd.creation_flags(CREATE_NO_WINDOW);
+    }
+}
+
+#[tauri::command]
+pub async fn mcp_stdio_spawn(
+    state: State<'_, McpStdioState>,
+    options: McpStdioSpawnOptions,
+) -> Result<u32, String> {
+    let command = options.command.trim();
+    if command.is_empty() {
+        return Err("MCP 启动失败:命令不能为空".to_string());
+    }
+
+    let mut cmd = Command::new(command);
+    suppress_windows_console(&mut cmd);
+    cmd.stdin(Stdio::piped())
+        .stdout(Stdio::piped())
+        .stderr(Stdio::null());
+
+    if let Some(args) = options.args {
+        cmd.args(args);
+    }
+    if let Some(cwd) = options.cwd {
+        if !cwd.trim().is_empty() {
+            cmd.current_dir(cwd);
+        }
+    }
+    if let Some(env) = options.env {
+        for (key, value) in env {
+            if !key.trim().is_empty() {
+                cmd.env(key, value);
+            }
+        }
+    }
+
+    let mut child = cmd
+        .spawn()
+        .map_err(|e| format!("MCP 启动失败:{e}"))?;
+    let pid = child
+        .id()
+        .ok_or_else(|| "MCP 启动失败:无法获取进程 ID".to_string())?;
+    let stdin = child
+        .stdin
+        .take()
+        .ok_or_else(|| "MCP 启动失败:无法打开 stdin".to_string())?;
+    let stdout = child
+        .stdout
+        .take()
+        .ok_or_else(|| "MCP 启动失败:无法打开 stdout".to_string())?;
+
+    let mut children = state.children.lock().await;
+    children.insert(
+        pid,
+        McpChild {
+            child,
+            stdin,
+            stdout: BufReader::new(stdout),
+        },
+    );
+
+    Ok(pid)
+}
+
+#[tauri::command]
+pub async fn mcp_stdio_write(
+    state: State<'_, McpStdioState>,
+    pid: u32,
+    data: String,
+) -> Result<(), String> {
+    let mut children = state.children.lock().await;
+    let child = children
+        .get_mut(&pid)
+        .ok_or_else(|| format!("MCP 写入失败:未找到进程 {pid}"))?;
+    child
+        .stdin
+        .write_all(data.as_bytes())
+        .await
+        .map_err(|e| format!("MCP 写入失败:{e}"))?;
+    if !data.ends_with('\n') {
+        child
+            .stdin
+            .write_all(b"\n")
+            .await
+            .map_err(|e| format!("MCP 写入失败:{e}"))?;
+    }
+    child
+        .stdin
+        .flush()
+        .await
+        .map_err(|e| format!("MCP 写入失败:{e}"))?;
+    Ok(())
+}
+
+#[tauri::command]
+pub async fn mcp_stdio_read(
+    state: State<'_, McpStdioState>,
+    pid: u32,
+    timeout_ms: Option<u64>,
+) -> Result<Option<String>, String> {
+    let timeout = Duration::from_millis(timeout_ms.unwrap_or(30_000).max(1));
+    let mut children = state.children.lock().await;
+    let child = children
+        .get_mut(&pid)
+        .ok_or_else(|| format!("MCP 读取失败:未找到进程 {pid}"))?;
+    let mut line = String::new();
+
+    match tokio::time::timeout(timeout, child.stdout.read_line(&mut line)).await {
+        Ok(Ok(0)) => Err("MCP 读取失败:进程已结束".to_string()),
+        Ok(Ok(_)) => Ok(Some(line.trim_end_matches(['\r', '\n']).to_string())),
+        Ok(Err(e)) => Err(format!("MCP 读取失败:{e}")),
+        Err(_) => Ok(None),
+    }
+}
+
+#[tauri::command]
+pub async fn mcp_stdio_kill(
+    state: State<'_, McpStdioState>,
+    pid: u32,
+) -> Result<(), String> {
+    let mut children = state.children.lock().await;
+    let Some(mut child) = children.remove(&pid) else {
+        return Ok(());
+    };
+    child
+        .child
+        .kill()
+        .await
+        .map_err(|e| format!("MCP 关闭失败:{e}"))?;
+    Ok(())
+}

+ 1 - 0
src-tauri/src/commands/mod.rs

@@ -6,5 +6,6 @@ pub mod extract_images;
 pub mod file_sync;
 pub mod fs;
 pub mod local_cli_config;
+pub mod mcp_stdio;
 pub mod project;
 pub mod vectorstore;

+ 5 - 0
src-tauri/src/lib.rs

@@ -41,6 +41,7 @@ pub fn run() {
             app.manage(commands::claude_cli::ClaudeCliState::default());
             app.manage(commands::codex_cli::CodexCliState::default());
             app.manage(commands::file_sync::FileSyncState::default());
+            app.manage(commands::mcp_stdio::McpStdioState::default());
             Ok(())
         })
         .invoke_handler(tauri::generate_handler![
@@ -91,6 +92,10 @@ pub fn run() {
             commands::file_sync::get_file_change_queue,
             commands::file_sync::retry_file_change_task,
             commands::file_sync::ignore_file_change_task,
+            commands::mcp_stdio::mcp_stdio_spawn,
+            commands::mcp_stdio::mcp_stdio_write,
+            commands::mcp_stdio::mcp_stdio_read,
+            commands::mcp_stdio::mcp_stdio_kill,
             commands::backup::export_backup,
             commands::backup::import_backup,
             commands::backup::read_backup_manifest,

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

@@ -43,6 +43,7 @@ fn main() {
             app.manage(commands::claude_cli::ClaudeCliState::default());
             app.manage(commands::codex_cli::CodexCliState::default());
             app.manage(commands::file_sync::FileSyncState::default());
+            app.manage(commands::mcp_stdio::McpStdioState::default());
             Ok(())
         })
         .invoke_handler(tauri::generate_handler![
@@ -93,6 +94,10 @@ fn main() {
             commands::file_sync::get_file_change_queue,
             commands::file_sync::retry_file_change_task,
             commands::file_sync::ignore_file_change_task,
+            commands::mcp_stdio::mcp_stdio_spawn,
+            commands::mcp_stdio::mcp_stdio_write,
+            commands::mcp_stdio::mcp_stdio_read,
+            commands::mcp_stdio::mcp_stdio_kill,
             commands::backup::export_backup,
             commands::backup::import_backup,
             commands::backup::read_backup_manifest,

+ 3 - 1
src/App.tsx

@@ -5,7 +5,7 @@ import { useReviewStore } from "@/stores/review-store"
 import { isTauri, pickDirectory } from "@/lib/platform"
 import { useChatStore } from "@/stores/chat-store"
 import { listDirectory, openProject, fileExists } from "@/commands/fs"
-import { getLastProject, saveLastProject, loadLlmConfig, loadAiChatModel, loadDefaultLlmModel, loadLanguage, loadEmbeddingConfig, loadProviderConfigs, loadActivePresetId, loadProxyConfig, loadScheduledImportConfig, saveScheduledImportConfig, loadSourceWatchConfig, loadNovelMode, loadNovelConfig, loadRevisionFeedbackWindowConfig, loadTheme, loadMaxHistoryMessages, loadUiFontFamily, loadVisualStyle, saveLlmConfig, loadLastReadChapter } from "@/lib/project-store"
+import { getLastProject, saveLastProject, loadLlmConfig, loadAiChatModel, loadDefaultLlmModel, loadLanguage, loadEmbeddingConfig, loadProviderConfigs, loadActivePresetId, loadProxyConfig, loadScheduledImportConfig, saveScheduledImportConfig, loadSourceWatchConfig, loadNovelMode, loadNovelConfig, loadRevisionFeedbackWindowConfig, loadTheme, loadMaxHistoryMessages, loadUiFontFamily, loadVisualStyle, saveLlmConfig, loadLastReadChapter, loadMcpConfig } from "@/lib/project-store"
 import { loadReviewItems, loadChatHistory, saveChatHistory, saveReviewItems } from "@/lib/persist"
 import { setupAutoSave, teardownAutoSave } from "@/lib/auto-save"
 import { checkForAppUpdate } from "@/lib/app-updater"
@@ -166,6 +166,8 @@ function App() {
         if (savedEmbeddingConfig) {
           useWikiStore.getState().setEmbeddingConfig(savedEmbeddingConfig)
         }
+        const savedMcpConfig = await loadMcpConfig()
+        useWikiStore.getState().setMcpConfig(savedMcpConfig)
         const savedProxy = await loadProxyConfig()
         if (savedProxy) {
           useWikiStore.getState().setProxyConfig(savedProxy)

+ 76 - 0
src/components/chat/agent-message-metadata.spec.ts

@@ -0,0 +1,76 @@
+import { describe, expect, it } from "vitest"
+import type { AgentRunRecord } from "@/lib/agent/types"
+import type { ReferenceToken } from "@/lib/reference/types"
+import {
+  agentToolCallsToMessageReferences,
+  getReferenceTokensForConversation,
+  setReferenceTokensForConversation,
+} from "./agent-message-metadata"
+
+function toolCall(
+  name: string,
+  params: Record<string, unknown>,
+  status: "done" | "error" = "done",
+): AgentRunRecord["toolCalls"][number] {
+  return {
+    id: `${name}-${String(params.name ?? params.path ?? params.conversationId ?? "x")}`,
+    name,
+    params,
+    result: status === "done" ? "ok" : "错误",
+    status,
+    startedAt: 1,
+    finishedAt: 2,
+  }
+}
+
+describe("agentToolCallsToMessageReferences", () => {
+  it("converts successful Agent read tools into assistant message references", () => {
+    const references = agentToolCallsToMessageReferences([
+      toolCall("read_chapter", { name: "第一章" }),
+      toolCall("read_outline", { path: "C:/Book/wiki/outlines/主线.md" }),
+      toolCall("read_memory", { name: "主角记忆" }),
+      toolCall("read_deduction", { name: "framework_1" }),
+      toolCall("write_memory", { name: "不会进入引用" }),
+      toolCall("read_memory", { name: "失败记忆" }, "error"),
+    ])
+
+    expect(references).toEqual([
+      { title: "第一章", path: "wiki/chapters/第一章.md" },
+      { title: "主线", path: "wiki/outlines/主线.md" },
+      { title: "主角记忆", path: "wiki/memory/主角记忆.md" },
+      { title: "framework_1", path: ".qmai/simulations/framework_1.json" },
+    ])
+  })
+
+  it("deduplicates references by path", () => {
+    const references = agentToolCallsToMessageReferences([
+      toolCall("read_chapter", { name: "第一章" }),
+      toolCall("read_chapter", { path: "C:/Book/wiki/chapters/第一章.md" }),
+    ])
+
+    expect(references).toEqual([
+      { title: "第一章", path: "wiki/chapters/第一章.md" },
+    ])
+  })
+})
+
+describe("reference token drafts by conversation", () => {
+  const token: ReferenceToken = {
+    id: "ref-1",
+    category: "chapter",
+    title: "第一章",
+    displayTitle: "第一章",
+    path: "C:/Book/wiki/chapters/第一章.md",
+  }
+
+  it("stores and clears draft reference tokens without touching other conversations", () => {
+    const withFirst = setReferenceTokensForConversation({}, "conv-1", [token])
+    const withSecond = setReferenceTokensForConversation(withFirst, "conv-2", [{ ...token, id: "ref-2", title: "第二章" }])
+    const clearedFirst = setReferenceTokensForConversation(withSecond, "conv-1", [])
+
+    expect(getReferenceTokensForConversation(withSecond, "conv-1")).toEqual([token])
+    expect(getReferenceTokensForConversation(withSecond, "conv-2")).toHaveLength(1)
+    expect(getReferenceTokensForConversation(clearedFirst, "conv-1")).toEqual([])
+    expect(getReferenceTokensForConversation(clearedFirst, "conv-2")).toHaveLength(1)
+  })
+})

+ 92 - 0
src/components/chat/agent-message-metadata.ts

@@ -0,0 +1,92 @@
+import type { AgentRunRecord } from "@/lib/agent/types"
+import type { ReferenceToken } from "@/lib/reference/types"
+import type { MessageReference } from "@/stores/chat-store"
+
+export type ReferenceTokensByConversation = Record<string, ReferenceToken[]>
+
+function stringParam(params: Record<string, unknown>, key: string): string {
+  const value = params[key]
+  return typeof value === "string" ? value.trim() : ""
+}
+
+function normalizeReferencePath(path: string): string {
+  const normalized = path.replace(/\\/g, "/")
+  const lower = normalized.toLowerCase()
+  const wikiIndex = lower.lastIndexOf("/wiki/")
+  if (wikiIndex >= 0) return normalized.slice(wikiIndex + 1)
+  const qmaiIndex = lower.lastIndexOf("/.qmai/")
+  if (qmaiIndex >= 0) return normalized.slice(qmaiIndex + 1)
+  return normalized.replace(/^\/+/, "")
+}
+
+function titleFromPath(path: string): string {
+  const fileName = path.replace(/\\/g, "/").split("/").pop() ?? path
+  return fileName.replace(/\.[^.]+$/, "")
+}
+
+function referenceFromReadTool(call: AgentRunRecord["toolCalls"][number]): MessageReference | null {
+  if (call.status !== "done") return null
+
+  const name = stringParam(call.params, "name")
+  const path = stringParam(call.params, "path")
+
+  switch (call.name) {
+    case "read_chapter": {
+      const referencePath = path ? normalizeReferencePath(path) : name ? `wiki/chapters/${name}.md` : ""
+      if (!referencePath) return null
+      return { title: name || titleFromPath(referencePath), path: referencePath }
+    }
+    case "read_outline": {
+      const referencePath = path ? normalizeReferencePath(path) : name ? `wiki/outlines/${name}.md` : ""
+      if (!referencePath) return null
+      return { title: name || titleFromPath(referencePath), path: referencePath }
+    }
+    case "read_memory": {
+      if (!name) return null
+      return { title: name, path: `wiki/memory/${name}.md` }
+    }
+    case "read_deduction": {
+      if (!name) return null
+      return { title: name, path: `.qmai/simulations/${name}.json` }
+    }
+    default:
+      return null
+  }
+}
+
+export function agentToolCallsToMessageReferences(
+  toolCalls: AgentRunRecord["toolCalls"] | undefined,
+): MessageReference[] {
+  if (!toolCalls || toolCalls.length === 0) return []
+
+  const references: MessageReference[] = []
+  const seen = new Set<string>()
+  for (const call of toolCalls) {
+    const reference = referenceFromReadTool(call)
+    if (!reference || seen.has(reference.path)) continue
+    seen.add(reference.path)
+    references.push(reference)
+  }
+  return references
+}
+
+export function getReferenceTokensForConversation(
+  drafts: ReferenceTokensByConversation,
+  conversationId: string | null | undefined,
+): ReferenceToken[] {
+  if (!conversationId) return []
+  return drafts[conversationId] ?? []
+}
+
+export function setReferenceTokensForConversation(
+  drafts: ReferenceTokensByConversation,
+  conversationId: string | null | undefined,
+  tokens: ReferenceToken[],
+): ReferenceTokensByConversation {
+  if (!conversationId) return drafts
+  if (tokens.length === 0) {
+    const { [conversationId]: _removed, ...rest } = drafts
+    return rest
+  }
+  return { ...drafts, [conversationId]: tokens }
+}

+ 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
+}

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

@@ -0,0 +1,375 @@
+// @vitest-environment jsdom
+
+import { act } from "react"
+import { createRoot, type Root } from "react-dom/client"
+import { readFileSync } from "node:fs"
+import { resolve } from "node:path"
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
+import { AgentToolCallMessage, getToolCallDescription } from "./agent-tool-call-message"
+import type { AgentRunRecord } from "@/lib/agent/types"
+
+type ToolCallRecord = AgentRunRecord["toolCalls"][number]
+
+let host: HTMLDivElement
+let root: Root
+
+const sampleCalls: ToolCallRecord[] = [
+  {
+    id: "call-1",
+    name: "read_chapter",
+    params: { name: "第1章-开篇" },
+    result: "第一章内容",
+    status: "done",
+    startedAt: 0,
+    finishedAt: 120,
+  },
+  {
+    id: "call-2",
+    name: "read_memory",
+    params: { name: "主角档案" },
+    result: "主角名为李明。",
+    status: "done",
+    startedAt: 160,
+    finishedAt: 190,
+  },
+  {
+    id: "call-3",
+    name: "write_memory",
+    params: { name: "写入资料", content: "主角名为李明。" },
+    result: "已写入记忆「写入资料」",
+    status: "done",
+    startedAt: 200,
+    finishedAt: 350,
+  },
+  {
+    id: "call-4",
+    name: "apply_skill",
+    params: { skillName: "去AI味" },
+    result: "技能模板内容",
+    status: "done",
+    startedAt: 400,
+    finishedAt: 500,
+  },
+  {
+    id: "call-5",
+    name: "search_chapters",
+    params: { keyword: "李明" },
+    result: "错误:搜索失败",
+    status: "error",
+    startedAt: 600,
+    finishedAt: 650,
+  },
+]
+
+beforeEach(() => {
+  host = document.createElement("div")
+  document.body.appendChild(host)
+  root = createRoot(host)
+})
+
+afterEach(() => {
+  act(() => {
+    root.unmount()
+  })
+  host.remove()
+})
+
+describe("AgentToolCallMessage", () => {
+  it("renders lightweight workflow stages and keeps raw tool details collapsed", async () => {
+    await act(async () => {
+      root.render(<AgentToolCallMessage toolCalls={sampleCalls} />)
+    })
+
+    expect(host.textContent).toContain("思考过程")
+    expect(host.textContent).toContain("任务理解")
+    expect(host.textContent).toContain("使用技能")
+    expect(host.textContent).toContain("上下文准备")
+    expect(host.textContent).toContain("工具调用")
+    expect(host.textContent).toContain("思考与决策")
+    expect(host.textContent).toContain("生成与校验")
+    expect(host.textContent).toContain("工具详情")
+    expect(host.textContent).not.toContain("read_chapter")
+    expect(host.textContent).not.toContain("read_memory")
+    expect(host.textContent).not.toContain("write_memory")
+    expect(host.textContent).not.toContain("写入资料")
+    expect(host.textContent).not.toContain("apply_skill")
+    expect(host.textContent).not.toContain("去AI味")
+
+    const contextButton = Array.from(host.querySelectorAll("button"))
+      .find((button) => button.textContent?.includes("上下文准备"))
+    expect(contextButton).not.toBeUndefined()
+
+    await act(async () => {
+      contextButton?.click()
+    })
+
+    expect(host.textContent).toContain("读取章节《第1章-开篇》")
+    expect(host.textContent).toContain("读取记忆「主角档案」")
+    expect(host.textContent).toContain("搜索章节关键词「李明」")
+
+    const detailsButton = Array.from(host.querySelectorAll("button"))
+      .find((button) => button.textContent?.includes("工具详情"))
+    expect(detailsButton).not.toBeUndefined()
+
+    await act(async () => {
+      detailsButton?.click()
+    })
+
+    expect(host.textContent).toContain("read_chapter")
+    expect(host.textContent).toContain("读取章节《第1章-开篇》")
+    expect(host.textContent).toContain("read_memory")
+    expect(host.textContent).toContain("读取记忆「主角档案」")
+    expect(host.textContent).toContain("write_memory")
+    expect(host.textContent).toContain("写入记忆「写入资料」")
+    expect(host.textContent).toContain("apply_skill")
+    expect(host.textContent).toContain("应用技能「去AI味」")
+  })
+
+  it("shows error style and icon for failed tool calls", async () => {
+    await act(async () => {
+      root.render(<AgentToolCallMessage toolCalls={[sampleCalls[4]]} />)
+    })
+
+    expect(host.textContent).toContain("失败")
+    const html = host.innerHTML
+    expect(html).toContain("text-red-600")
+  })
+
+  it("shows approval-required tool calls as pending confirmation", async () => {
+    await act(async () => {
+      root.render(
+        <AgentToolCallMessage
+          toolCalls={[
+            {
+              id: "call-approval",
+              name: "write_chapter",
+              params: { name: "第1章", content: "正文" },
+              result: "写入工具需要用户确认后才能执行。",
+              status: "approval_required",
+              startedAt: 100,
+              finishedAt: 100,
+            },
+          ]}
+        />,
+      )
+    })
+
+    expect(host.textContent).toContain("待确认")
+    expect(host.textContent).toContain("判断本轮涉及写入,必须等待用户确认后才能保存")
+    expect(host.textContent).not.toContain("write_chapter")
+    expect(host.textContent).not.toContain("写入章节《第1章》")
+
+    const detailsButton = Array.from(host.querySelectorAll("button"))
+      .find((button) => button.textContent?.includes("工具详情"))
+    expect(detailsButton).not.toBeUndefined()
+
+    await act(async () => {
+      detailsButton?.click()
+    })
+
+    expect(host.textContent).toContain("待确认")
+    expect(host.textContent).toContain("写入章节《第1章》")
+  })
+
+  it("shows a user-facing thinking process before raw tool details", async () => {
+    await act(async () => {
+      root.render(
+        <AgentToolCallMessage
+          toolCalls={[
+            {
+              id: "route-1",
+              name: "route_task",
+              params: { userMessage: "生成第18章" },
+              result: JSON.stringify({ intent: "write_chapter", confidence: 0.92, chapterNumber: 18 }),
+              status: "done",
+              startedAt: 0,
+              finishedAt: 40,
+            },
+            {
+              id: "outline-1",
+              name: "read_outline",
+              params: { name: "总大纲" },
+              result: "大纲内容",
+              status: "done",
+              startedAt: 50,
+              finishedAt: 90,
+            },
+            {
+              id: "write-1",
+              name: "write_chapter",
+              params: { name: "chapter-018", content: "正文".repeat(100) },
+              result: "写入工具需要用户确认后才能执行。",
+              status: "approval_required",
+              startedAt: 100,
+              finishedAt: 100,
+            },
+          ]}
+        />,
+      )
+    })
+
+    expect(host.textContent).toContain("思考过程")
+    expect(host.textContent).toContain("任务理解")
+    expect(host.textContent).toContain("上下文准备")
+    expect(host.textContent).toContain("待确认")
+    expect(host.textContent).toContain("工具详情")
+    expect(host.textContent).not.toContain("AI 工具调用")
+    expect(host.textContent).not.toContain("read_outline")
+    expect(host.textContent).not.toContain("write_chapter")
+
+    const detailsButton = Array.from(host.querySelectorAll("button"))
+      .find((button) => button.textContent?.includes("工具详情"))
+    expect(detailsButton).not.toBeUndefined()
+
+    await act(async () => {
+      detailsButton?.click()
+    })
+
+    expect(host.textContent).toContain("AI 工具调用")
+  })
+
+  it("returns null when toolCalls is empty or undefined", async () => {
+    await act(async () => {
+      root.render(<AgentToolCallMessage toolCalls={[]} />)
+    })
+    expect(host.textContent).toBe("")
+
+    host.innerHTML = ""
+    await act(async () => {
+      root.render(<AgentToolCallMessage toolCalls={undefined} />)
+    })
+    expect(host.textContent).toBe("")
+  })
+
+  it("keeps hook order stable when tool calls arrive after an empty render", async () => {
+    const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
+    try {
+      await act(async () => {
+        root.render(<AgentToolCallMessage toolCalls={[]} />)
+      })
+
+      await act(async () => {
+      root.render(<AgentToolCallMessage toolCalls={[sampleCalls[0]]} />)
+    })
+
+    expect(host.textContent).toContain("上下文准备")
+    expect(host.textContent).not.toContain("read_chapter")
+    expect(errorSpy.mock.calls.flat().join("\n")).not.toContain("change in the order of Hooks")
+    } finally {
+      errorSpy.mockRestore()
+    }
+  })
+
+  it("opens the currently running stage by default", async () => {
+    await act(async () => {
+      root.render(
+        <AgentToolCallMessage
+          toolCalls={[
+            {
+              id: "running-read",
+              name: "read_chapter",
+              params: { name: "chapter-017" },
+              result: "",
+              status: "running",
+              startedAt: 100,
+              finishedAt: 100,
+            },
+          ]}
+        />,
+      )
+    })
+
+    expect(host.textContent).toContain("正在读取章节《chapter-017》")
+  })
+
+  it("does not use the old blue card-list wrapper for workflow steps", () => {
+    const source = readFileSync(resolve(__dirname, "agent-workflow-panel.tsx"), "utf8")
+
+    expect(source).toContain("border-l border-border/80")
+    expect(source).toContain("before:bg-border/80")
+    expect(source).not.toContain("border-l-4 border-blue-500")
+    expect(source).not.toContain("bg-blue-50/50")
+    expect(source).not.toContain("border-blue-200/70 bg-white/50")
+  })
+
+  it("expands and collapses result on row click", async () => {
+    await act(async () => {
+      root.render(<AgentToolCallMessage toolCalls={[sampleCalls[0]]} />)
+    })
+
+    const detailsButton = Array.from(host.querySelectorAll("button"))
+      .find((button) => button.textContent?.includes("工具详情"))
+    expect(detailsButton).not.toBeUndefined()
+
+    await act(async () => {
+      detailsButton?.click()
+    })
+
+    const expandButton = Array.from(host.querySelectorAll("button"))
+      .find((button) => button.textContent?.includes("read_chapter"))
+    expect(expandButton).not.toBeNull()
+    expect(expandButton?.getAttribute("aria-expanded")).toBe("false")
+    expect(host.textContent).not.toContain("第一章内容")
+
+    await act(async () => {
+      expandButton?.click()
+    })
+
+    expect(expandButton?.getAttribute("aria-expanded")).toBe("true")
+    expect(host.textContent).toContain("第一章内容")
+
+    await act(async () => {
+      expandButton?.click()
+    })
+
+    expect(expandButton?.getAttribute("aria-expanded")).toBe("false")
+    expect(host.textContent).not.toContain("第一章内容")
+  })
+})
+
+describe("getToolCallDescription", () => {
+  it("covers all registered built-in tool names", () => {
+    const names = [
+      "read_chapter",
+      "read_outline",
+      "read_memory",
+      "read_deduction",
+      "read_chat_history",
+      "read_outline_history",
+      "search_chapters",
+      "list_chapters",
+      "list_outlines",
+      "list_memories",
+      "list_deductions",
+      "write_chapter",
+      "write_outline_node",
+      "write_memory",
+      "apply_skill",
+    ] as const
+
+    for (const name of names) {
+      const desc = getToolCallDescription(name, { name: "测试", keyword: "测试", skillName: "测试" })
+      expect(desc).not.toBe(name)
+      expect(desc.length).toBeGreaterThan(0)
+    }
+  })
+
+  it("falls back to tool name for unknown tools", () => {
+    expect(getToolCallDescription("unknown_tool", {})).toBe("unknown_tool")
+  })
+})
+
+describe("tool call timeline layout", () => {
+  it("wraps tool rows so long tool names and descriptions do not squeeze the chat panel", () => {
+    const source = readFileSync(resolve(__dirname, "tool-call-timeline.tsx"), "utf8")
+
+    expect(source).toContain("max-w-full overflow-hidden")
+    expect(source).toContain("grid w-full min-w-0 grid-cols-[auto_minmax(0,1fr)_auto_auto]")
+    expect(source).toContain("w-full max-w-full min-w-0")
+    expect(source).toContain("break-words")
+    expect(source).toContain("overflow-x-hidden")
+    expect(source).not.toContain("overflow-x-auto")
+    expect(source).not.toContain('className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs"')
+    expect(source).not.toContain('className="min-w-0 flex-1 truncate text-muted-foreground"')
+  })
+})

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

@@ -0,0 +1,31 @@
+import { AgentWorkflowPanel } from "./agent-workflow-panel"
+import { getToolCallDescription } from "./tool-call-timeline"
+import type { ContextTrace } from "@/lib/agent/context-trace"
+import type { AgentRunRecord } from "@/lib/agent/types"
+
+export type ToolCallRecord = AgentRunRecord["toolCalls"][number]
+
+interface AgentToolCallMessageProps {
+  toolCalls: ToolCallRecord[] | undefined
+  contextTrace?: ContextTrace | null
+  onConfirmSave?: (call: ToolCallRecord & { preview?: string }) => void
+  onReject?: (call: ToolCallRecord & { preview?: string }) => void
+}
+
+export function AgentToolCallMessage({
+  toolCalls,
+  contextTrace,
+  onConfirmSave,
+  onReject,
+}: AgentToolCallMessageProps) {
+  return (
+    <AgentWorkflowPanel
+      toolCalls={toolCalls}
+      contextTrace={contextTrace}
+      onConfirmSave={onConfirmSave}
+      onReject={onReject}
+    />
+  )
+}
+
+export { getToolCallDescription }

+ 266 - 0
src/components/chat/agent-workflow-panel.tsx

@@ -0,0 +1,266 @@
+import { useMemo, useState } from "react"
+import {
+  AlertTriangle,
+  Brain,
+  CheckCircle2,
+  ChevronRight,
+  Circle,
+  Clock3,
+  Loader2,
+  XCircle,
+} from "lucide-react"
+import { ToolCallTimeline } from "./tool-call-timeline"
+import {
+  buildAgentWorkflowSteps,
+  type AgentWorkflowDetail,
+  type AgentWorkflowStep,
+  type AgentWorkflowStepStatus,
+} from "@/lib/agent/workflow-trace"
+import type { ContextTrace } from "@/lib/agent/context-trace"
+import type { AgentRunRecord } from "@/lib/agent/types"
+import { cn } from "@/lib/utils"
+
+type ToolCallRecord = AgentRunRecord["toolCalls"][number]
+
+interface AgentWorkflowPanelProps {
+  toolCalls: ToolCallRecord[] | undefined
+  contextTrace?: ContextTrace | null
+  onConfirmSave?: (call: ToolCallRecord & { preview?: string }) => void
+  onReject?: (call: ToolCallRecord & { preview?: string }) => void
+}
+
+function getDefaultOpenStepId(steps: AgentWorkflowStep[]): string | null {
+  return steps.find((step) => step.status === "running")?.id
+    ?? steps.find((step) => step.status === "approval_required")?.id
+    ?? null
+}
+
+function getWorkflowDuration(steps: AgentWorkflowStep[]): string {
+  const starts = steps.map((step) => step.startedAt).filter((value): value is number => Number.isFinite(value))
+  if (starts.length === 0) return ""
+  const finishes = steps.map((step) => step.finishedAt).filter((value): value is number => Number.isFinite(value))
+  const start = Math.min(...starts)
+  const end = finishes.length > 0 ? Math.max(...finishes) : Date.now()
+  const ms = Math.max(0, end - start)
+  if (ms < 1000) return `${ms}ms`
+  if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`
+  const seconds = Math.floor(ms / 1000)
+  return `${Math.floor(seconds / 60)}m${seconds % 60}s`
+}
+
+function getOverallStatus(steps: AgentWorkflowStep[]): AgentWorkflowStepStatus {
+  if (steps.some((step) => step.status === "running")) return "running"
+  if (steps.some((step) => step.status === "approval_required")) return "approval_required"
+  if (steps.some((step) => step.status === "error")) return "error"
+  if (steps.some((step) => step.status === "cancelled")) return "cancelled"
+  return "done"
+}
+
+function WorkflowStatusIcon({ status }: { status: AgentWorkflowStepStatus }) {
+  if (status === "running") {
+    return <Loader2 className="h-3.5 w-3.5 animate-spin text-sky-600 dark:text-sky-400" />
+  }
+  if (status === "approval_required") {
+    return <AlertTriangle className="h-3.5 w-3.5 text-amber-600 dark:text-amber-400" />
+  }
+  if (status === "error") {
+    return <XCircle className="h-3.5 w-3.5 text-red-600 dark:text-red-400" />
+  }
+  if (status === "cancelled") {
+    return <XCircle className="h-3.5 w-3.5 text-muted-foreground/70" />
+  }
+  if (status === "pending") {
+    return <Circle className="h-3.5 w-3.5 text-muted-foreground/60" />
+  }
+  return <CheckCircle2 className="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400" />
+}
+
+function getStatusLabel(status: AgentWorkflowStepStatus): string {
+  switch (status) {
+    case "running":
+      return "进行中"
+    case "approval_required":
+      return "待确认"
+    case "error":
+      return "失败"
+    case "cancelled":
+      return "已取消"
+    case "pending":
+      return "待处理"
+    default:
+      return "完成"
+  }
+}
+
+function getDetailToneClass(tone: AgentWorkflowDetail["tone"]): string {
+  switch (tone) {
+    case "success":
+      return "text-emerald-700 dark:text-emerald-300"
+    case "warning":
+      return "text-amber-700 dark:text-amber-300"
+    case "error":
+      return "text-red-700 dark:text-red-300"
+    case "muted":
+      return "text-muted-foreground/80"
+    default:
+      return "text-foreground/85"
+  }
+}
+
+function WorkflowStepRow({
+  step,
+  open,
+  onToggle,
+}: {
+  step: AgentWorkflowStep
+  open: boolean
+  onToggle: () => void
+}) {
+  return (
+    <div
+      className={cn(
+        "relative pl-7",
+        step.status === "running" && "rounded-md bg-sky-50/45 py-1 pr-2 dark:bg-sky-950/10",
+        step.status === "approval_required" && "rounded-md bg-amber-50/55 py-1 pr-2 dark:bg-amber-950/10",
+      )}
+    >
+      <span className="absolute left-0 top-1.5 z-10 flex h-5 w-5 items-center justify-center rounded-full bg-background">
+        <WorkflowStatusIcon status={step.status} />
+      </span>
+      <button
+        type="button"
+        onClick={onToggle}
+        className="group grid w-full min-w-0 grid-cols-[minmax(0,1fr)_auto] gap-x-2 text-left"
+        aria-expanded={open}
+      >
+        <span className="min-w-0">
+          <span className="flex min-w-0 items-center gap-1.5">
+            <ChevronRight
+              className={cn(
+                "h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform group-hover:text-foreground",
+                open && "rotate-90",
+              )}
+            />
+            <span className="min-w-0 break-words text-[13px] font-medium text-foreground">
+              {step.title}
+            </span>
+          </span>
+          <span className="mt-0.5 block break-words pl-5 text-[12px] leading-5 text-muted-foreground">
+            {step.summary}
+          </span>
+        </span>
+        <span className="shrink-0 pt-0.5 text-[11px] text-muted-foreground">
+          {getStatusLabel(step.status)}
+        </span>
+      </button>
+      {open && (
+        <div className="mt-1.5 space-y-1.5 pl-5 text-[12px] leading-5">
+          {step.details.map((detail, index) => (
+            <div key={`${step.id}-${detail.label}-${index}`} className="grid min-w-0 grid-cols-[4.5rem_minmax(0,1fr)] gap-2">
+              <span className="text-muted-foreground">{detail.label}</span>
+              <span className={cn("min-w-0 break-words", getDetailToneClass(detail.tone))}>{detail.value}</span>
+            </div>
+          ))}
+        </div>
+      )}
+    </div>
+  )
+}
+
+export function AgentWorkflowPanel({
+  toolCalls,
+  contextTrace,
+  onConfirmSave,
+  onReject,
+}: AgentWorkflowPanelProps) {
+  const steps = useMemo(
+    () => buildAgentWorkflowSteps({ toolCalls, contextTrace }),
+    [toolCalls, contextTrace],
+  )
+  const defaultOpenStepId = useMemo(() => getDefaultOpenStepId(steps), [steps])
+  const [openMap, setOpenMap] = useState<Record<string, boolean>>({})
+  const [detailsOpen, setDetailsOpen] = useState(false)
+  const [allOpen, setAllOpen] = useState(false)
+
+  if (steps.length === 0) return null
+
+  const overallStatus = getOverallStatus(steps)
+  const duration = getWorkflowDuration(steps)
+  const safeToolCalls = toolCalls ?? []
+
+  const isStepOpen = (step: AgentWorkflowStep) => {
+    if (allOpen) return true
+    if (step.id in openMap) return openMap[step.id]
+    return step.id === defaultOpenStepId
+  }
+
+  return (
+    <div className="mb-2 max-w-full overflow-hidden border-l border-border/80 pl-3">
+      <div className="mb-2 flex flex-wrap items-center justify-between gap-2">
+        <div className="flex min-w-0 items-center gap-2 text-[13px] font-medium text-foreground">
+          <span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
+            <Brain className="h-3.5 w-3.5" />
+          </span>
+          <span>思考过程</span>
+          <span className="text-[11px] font-normal text-muted-foreground">({steps.length})</span>
+          <span className="inline-flex items-center gap-1 text-[11px] font-normal text-muted-foreground">
+            <WorkflowStatusIcon status={overallStatus} />
+            {getStatusLabel(overallStatus)}
+          </span>
+          {duration && (
+            <span className="inline-flex items-center gap-1 text-[11px] font-normal text-muted-foreground">
+              <Clock3 className="h-3 w-3" />
+              {duration}
+            </span>
+          )}
+        </div>
+        <div className="flex shrink-0 items-center gap-2 text-[11px]">
+          <button
+            type="button"
+            onClick={() => setAllOpen((value) => !value)}
+            className="text-muted-foreground hover:text-foreground"
+          >
+            {allOpen ? "全部折叠" : "展开全部"}
+          </button>
+          {safeToolCalls.length > 0 && (
+            <button
+              type="button"
+              onClick={() => setDetailsOpen((value) => !value)}
+              className="text-muted-foreground hover:text-foreground"
+              aria-expanded={detailsOpen}
+            >
+              工具详情
+            </button>
+          )}
+        </div>
+      </div>
+
+      <div className="relative space-y-3 before:absolute before:left-[9px] before:top-2 before:bottom-2 before:w-px before:bg-border/80">
+        {steps.map((step) => (
+          <WorkflowStepRow
+            key={step.id}
+            step={step}
+            open={isStepOpen(step)}
+            onToggle={() => {
+              setOpenMap((prev) => ({
+                ...prev,
+                [step.id]: !isStepOpen(step),
+              }))
+            }}
+          />
+        ))}
+      </div>
+
+      {detailsOpen && safeToolCalls.length > 0 && (
+        <div className="mt-3 border-t border-border/70 pt-2">
+          <ToolCallTimeline
+            toolCalls={safeToolCalls}
+            onConfirmSave={onConfirmSave}
+            onReject={onReject}
+            defaultExpanded={false}
+          />
+        </div>
+      )}
+    </div>
+  )
+}

+ 190 - 0
src/components/chat/chapter-plan-confirm-dialog.spec.tsx

@@ -0,0 +1,190 @@
+// @vitest-environment jsdom
+
+import { act } from "react"
+import { createRoot, type Root } from "react-dom/client"
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
+import {
+  extractChapterPlan,
+  buildPlanConfirmMessage,
+  buildPlanSkipMessage,
+  CHAPTER_PLAN_MARKER_START,
+  CHAPTER_PLAN_MARKER_END,
+  ChapterPlanConfirmDialog,
+} from "./chapter-plan-confirm-dialog"
+
+// React 19 需要 IS_REACT_ACT_ENVIRONMENT 才能让 act(...) 正常工作且无警告
+;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+
+describe("chapter-plan-confirm-dialog 纯函数", () => {
+  describe("extractChapterPlan", () => {
+    it("无标记时返回 null", () => {
+      expect(extractChapterPlan("普通正文")).toBeNull()
+    })
+
+    it("只有开始标记时返回 null", () => {
+      expect(extractChapterPlan(`${CHAPTER_PLAN_MARKER_START}计划内容`)).toBeNull()
+    })
+
+    it("完整标记时正确提取计划", () => {
+      const content = `前文${CHAPTER_PLAN_MARKER_START}这是计划${CHAPTER_PLAN_MARKER_END}后文`
+      const result = extractChapterPlan(content)
+      expect(result).not.toBeNull()
+      expect(result!.plan).toBe("这是计划")
+      expect(result!.body).toBe("前文\n后文")
+    })
+
+    it("标记前后无额外内容时 body 为空字符串", () => {
+      const content = `${CHAPTER_PLAN_MARKER_START}计划${CHAPTER_PLAN_MARKER_END}`
+      const result = extractChapterPlan(content)
+      expect(result!.plan).toBe("计划")
+      expect(result!.body).toBe("")
+    })
+
+    it("计划内容含换行和空白时保留原样(仅首尾 trim)", () => {
+      const content = `${CHAPTER_PLAN_MARKER_START}  第一行\n第二行  ${CHAPTER_PLAN_MARKER_END}`
+      const result = extractChapterPlan(content)
+      expect(result!.plan).toBe("第一行\n第二行")
+    })
+  })
+
+  describe("buildPlanConfirmMessage", () => {
+    it("包含已确认计划标识", () => {
+      const msg = buildPlanConfirmMessage("我的计划")
+      expect(msg).toContain("已确认")
+      expect(msg).toContain("我的计划")
+    })
+
+    it("指示不要再次输出计划", () => {
+      const msg = buildPlanConfirmMessage("计划")
+      expect(msg).toContain("不要再次输出计划")
+    })
+  })
+
+  describe("buildPlanSkipMessage", () => {
+    it("指示直接写正文", () => {
+      const msg = buildPlanSkipMessage()
+      expect(msg).toContain("直接写正文")
+      expect(msg).toContain("不要输出计划")
+    })
+  })
+})
+
+describe("ChapterPlanConfirmDialog 组件", () => {
+  const baseProps = {
+    open: true,
+    planContent: "本章目标:主角发现秘密\n核心冲突:与反派对峙",
+    aiWorkflowMode: "standard" as const,
+    onConfirm: () => {},
+    onSkip: () => {},
+    onCancel: () => {},
+  }
+
+  let host: HTMLDivElement
+  let root: Root
+
+  beforeEach(() => {
+    host = document.createElement("div")
+    document.body.appendChild(host)
+    root = createRoot(host)
+  })
+
+  afterEach(() => {
+    act(() => {
+      root.unmount()
+    })
+    host.remove()
+  })
+
+  // 模拟 @testing-library/screen.getByText 的精确匹配语义:
+  // 只匹配元素自身的“直接文本节点”(忽略子元素聚合的 textContent),
+  // 避免父容器因仅含一个文本子元素而被误匹配。
+  function queryByText(text: string): HTMLElement | null {
+    const els = Array.from(host.querySelectorAll("*")) as HTMLElement[]
+    return (
+      els.find((el) => {
+        const directText = Array.from(el.childNodes)
+          .filter((node): node is Text => node.nodeType === Node.TEXT_NODE)
+          .map((node) => node.textContent ?? "")
+          .join("")
+          .replace(/\s+/g, " ")
+          .trim()
+        return directText === text
+      }) ?? null
+    )
+  }
+
+  it("open=false 时不渲染", async () => {
+    await act(async () => {
+      root.render(<ChapterPlanConfirmDialog {...baseProps} open={false} />)
+    })
+    expect(host.firstChild).toBeNull()
+  })
+
+  it("open=true 时显示计划内容和按钮", async () => {
+    await act(async () => {
+      root.render(<ChapterPlanConfirmDialog {...baseProps} />)
+    })
+    expect(queryByText("章节创作计划")).not.toBeNull()
+    expect(host.textContent).toContain("本章目标:主角发现秘密")
+    expect(queryByText("确认,按此计划写正文")).not.toBeNull()
+    expect(queryByText("跳过计划,直接写")).not.toBeNull()
+    expect(queryByText("取消生成")).not.toBeNull()
+  })
+
+  it("点击确认按钮触发 onConfirm", async () => {
+    const onConfirm = vi.fn()
+    await act(async () => {
+      root.render(<ChapterPlanConfirmDialog {...baseProps} onConfirm={onConfirm} />)
+    })
+    const btn = queryByText("确认,按此计划写正文") as HTMLButtonElement
+    await act(async () => {
+      btn.click()
+    })
+    expect(onConfirm).toHaveBeenCalledOnce()
+  })
+
+  it("点击跳过按钮触发 onSkip", async () => {
+    const onSkip = vi.fn()
+    await act(async () => {
+      root.render(<ChapterPlanConfirmDialog {...baseProps} onSkip={onSkip} />)
+    })
+    const btn = queryByText("跳过计划,直接写") as HTMLButtonElement
+    await act(async () => {
+      btn.click()
+    })
+    expect(onSkip).toHaveBeenCalledOnce()
+  })
+
+  it("点击取消按钮触发 onCancel", async () => {
+    const onCancel = vi.fn()
+    await act(async () => {
+      root.render(<ChapterPlanConfirmDialog {...baseProps} onCancel={onCancel} />)
+    })
+    const btn = queryByText("取消生成") as HTMLButtonElement
+    await act(async () => {
+      btn.click()
+    })
+    expect(onCancel).toHaveBeenCalledOnce()
+  })
+
+  it("提供 onModify 时显示修改计划按钮", async () => {
+    await act(async () => {
+      root.render(<ChapterPlanConfirmDialog {...baseProps} onModify={() => {}} />)
+    })
+    expect(queryByText("修改计划")).not.toBeNull()
+  })
+
+  it("未提供 onModify 时不显示修改计划按钮", async () => {
+    await act(async () => {
+      root.render(<ChapterPlanConfirmDialog {...baseProps} />)
+    })
+    expect(queryByText("修改计划")).toBeNull()
+  })
+
+  it("严格模式显示严格模式标识", async () => {
+    await act(async () => {
+      root.render(<ChapterPlanConfirmDialog {...baseProps} aiWorkflowMode="strict" />)
+    })
+    expect(host.textContent).toContain("严格模式")
+  })
+})

+ 151 - 0
src/components/chat/chapter-plan-confirm-dialog.tsx

@@ -0,0 +1,151 @@
+import { useState, useCallback } from "react"
+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)
+  if (startIdx < 0) return null
+  const contentStart = startIdx + CHAPTER_PLAN_MARKER_START.length
+  const endIdx = fullContent.indexOf(CHAPTER_PLAN_MARKER_END, contentStart)
+  if (endIdx < 0) return null
+  const plan = fullContent.slice(contentStart, endIdx).trim()
+  const beforePlan = fullContent.slice(0, startIdx).trim()
+  const afterPlan = fullContent.slice(endIdx + CHAPTER_PLAN_MARKER_END.length).trim()
+  const body = [beforePlan, afterPlan].filter(Boolean).join("\n").trim()
+  return { plan, body }
+}
+
+export function buildPlanConfirmMessage(plan: string): string {
+  return `${CHAPTER_PLAN_CONFIRMED_PREFIX},现在进入执行阶段。请按以下计划写正文。不要再次输出计划,不要再次等待确认,直接输出正文。\n\n=== 已确认的章节计划 ===\n${plan}`
+}
+
+export function buildPlanSkipMessage(): string {
+  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 {
+  open: boolean
+  planContent: string
+  aiWorkflowMode: "fast" | "standard" | "strict"
+  onConfirm: () => void
+  onSkip: () => void
+  onModify?: (modifiedPlan: string) => void
+  onCancel: () => void
+}
+
+export function ChapterPlanConfirmDialog({
+  open,
+  planContent,
+  aiWorkflowMode,
+  onConfirm,
+  onSkip,
+  onModify,
+  onCancel,
+}: ChapterPlanConfirmDialogProps) {
+  const [editing, setEditing] = useState(false)
+  const [editedContent, setEditedContent] = useState(planContent)
+
+  const handleStartEdit = useCallback(() => {
+    setEditedContent(planContent)
+    setEditing(true)
+  }, [planContent])
+
+  const handleSubmitEdit = useCallback(() => {
+    if (!onModify) return;
+    onModify(editedContent)
+    setEditing(false)
+  }, [editedContent, onModify])
+
+  if (!open) return null
+
+  return (
+    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
+      <div className="flex h-[75vh] w-full max-w-4xl flex-col rounded-lg border bg-background shadow-2xl">
+        <div className="flex items-center justify-between border-b px-4 py-3">
+          <div className="flex items-center gap-2">
+            <ListChecks className="h-5 w-5 text-blue-500" />
+            <div>
+              <h3 className="font-semibold">章节创作计划</h3>
+              <p className="text-xs text-muted-foreground">
+                {aiWorkflowMode === "strict" ? "严格模式" : "标准模式"} · 请确认 AI 的创作计划
+              </p>
+            </div>
+          </div>
+          <button onClick={onCancel} className="rounded-md p-1 hover:bg-accent" aria-label="关闭">
+            <X className="h-4 w-4" />
+          </button>
+        </div>
+
+        <div className="flex-1 overflow-auto p-4">
+          {editing ? (
+            <textarea
+              value={editedContent}
+              onChange={(e) => setEditedContent(e.target.value)}
+              className="h-full w-full resize-none rounded-md border bg-muted/20 p-3 font-mono text-xs leading-relaxed focus:outline-none focus:ring-1 focus:ring-primary"
+            />
+          ) : (
+            <div className="whitespace-pre-wrap rounded-md border bg-muted/20 p-4 text-sm leading-relaxed">
+              {planContent}
+            </div>
+          )}
+        </div>
+
+        <div className="flex items-center justify-between border-t px-4 py-3">
+          <div className="flex gap-2">
+            <button
+              onClick={onCancel}
+              className="rounded-md border px-3 py-1.5 text-sm hover:bg-accent"
+            >
+              取消生成
+            </button>
+            {onModify && (
+              editing ? (
+                <button
+                  onClick={handleSubmitEdit}
+                  className="flex items-center gap-1.5 rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
+                >
+                  <Check className="h-4 w-4" />
+                  按修改后的计划写正文
+                </button>
+              ) : (
+                <button
+                  onClick={handleStartEdit}
+                  className="flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm hover:bg-accent"
+                >
+                  <Edit3 className="h-4 w-4" />
+                  修改计划
+                </button>
+              )
+            )}
+          </div>
+          <div className="flex gap-2">
+            <button
+              onClick={onSkip}
+              className="flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm text-muted-foreground hover:bg-accent"
+            >
+              <SkipForward className="h-4 w-4" />
+              跳过计划,直接写
+            </button>
+            <button
+              onClick={onConfirm}
+              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" />
+              确认,按此计划写正文
+            </button>
+          </div>
+        </div>
+      </div>
+    </div>
+  )
+}

+ 121 - 13
src/components/chat/chat-message.spec.tsx

@@ -1,9 +1,18 @@
 import { renderToStaticMarkup } from "react-dom/server"
 import { readFileSync } from "node:fs"
 import { resolve } from "node:path"
-import { describe, expect, it } from "vitest"
-import { StreamingMessage } from "./chat-message"
-import { getDeepChapterToggleButtonClass } from "./chat-panel"
+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")
@@ -35,18 +44,115 @@ describe("chat thinking display", () => {
   })
 })
 
-describe("deep chapter thinking toggle style", () => {
-  it("uses a clear dark selected state when deep chapter generation is enabled", () => {
-    const activeClassName = getDeepChapterToggleButtonClass(true)
-    const inactiveClassName = getDeepChapterToggleButtonClass(false)
+describe("AI workflow mode toggle style", () => {
+  it("uses a clear selected state for the active workflow mode", () => {
+    const activeClassName = getWorkflowModeButtonClass(true)
+    const inactiveClassName = getWorkflowModeButtonClass(false)
 
     expect(activeClassName).toContain("bg-primary")
     expect(activeClassName).toContain("text-primary-foreground")
-    expect(activeClassName).toContain("border-primary")
     expect(inactiveClassName).not.toContain("bg-primary")
   })
 })
 
+describe("chat message references", () => {
+  it("renders attached reference chips on user messages", () => {
+    const message: DisplayMessage = {
+      id: "msg-1",
+      role: "user",
+      content: "请参考这一章",
+      timestamp: 1,
+      conversationId: "conv-1",
+      attachedReferences: [{
+        id: "ref-1",
+        category: "chapter",
+        title: "第一章",
+        path: "C:/Novel/wiki/chapters/第一章.md",
+        displayTitle: "第一章",
+      }],
+    }
+
+    const html = renderToStaticMarkup(<ChatMessage message={message} />)
+
+    expect(html).toContain("请参考这一章")
+    expect(html).toContain("@第一章")
+    expect(html).toContain('data-reference-id="ref-1"')
+    expect(html).not.toContain("移除引用")
+  })
+})
+
+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")
+
+    expect(source).toContain("lg:max-w-[50vw]")
+    expect(source).toContain("max-w-full")
+    expect(source).not.toContain("max-w-[80%]")
+  })
+})
+
 describe("chapter save preview sync regression", () => {
   it("always routes AI chapter saves to the next chapter instead of reusing the current chapter", () => {
     const source = readFileSync(resolve(__dirname, "chat-panel.tsx"), "utf8")
@@ -80,18 +186,20 @@ 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}")
   })
 
   it("keeps the ai chat footer labels as readable Chinese text", () => {
     const source = readFileSync(resolve(__dirname, "chat-panel.tsx"), "utf8")
 
-    expect(source).toContain("深度模式")
+    expect(source).toContain("AI 会话执行模式")
+    expect(source).toContain("快速")
+    expect(source).toContain("标准")
+    expect(source).toContain("严格")
     expect(source).toContain("编辑章节")
     expect(source).toContain("继续未完成")
   })

+ 99 - 7
src/components/chat/chat-message.tsx

@@ -7,7 +7,7 @@ import "katex/dist/katex.min.css"
 import {
   Bot, User, FileText, ChevronDown, ChevronRight, RefreshCw, Copy, Check,
   Users, Lightbulb, BookOpen, HelpCircle, GitMerge, BarChart3, Layout, Globe,
-  Image as ImageIcon,
+  Image as ImageIcon, Loader2, Info,
 } from "lucide-react"
 import { useWikiStore } from "@/stores/wiki-store"
 import { readFile } from "@/commands/fs"
@@ -15,7 +15,12 @@ import { normalizePath, getFileName } from "@/lib/path-utils"
 import { refreshProjectState } from "@/lib/project-refresh"
 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"
 
 import { convertLatexToUnicode } from "@/lib/latex-to-unicode"
 import { resolveMarkdownImageSrc } from "@/lib/markdown-image-resolver"
@@ -25,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
@@ -39,16 +45,45 @@ interface ChatMessageProps {
   onDiscardDraft?: () => void
   saveStatus?: string
   isSaving?: boolean
+  onConfirmToolSave?: (call: ToolCallRecord & { preview?: string }) => void
+  onRejectTool?: (call: ToolCallRecord & { preview?: string }) => void
+  onRebuildRetrievalIndex?: () => Promise<{
+    success: boolean
+    chapterCount?: number
+    error?: string
+  }>
+  retrievalIndexHasIndex?: boolean
+  isRebuildingRetrievalIndex?: boolean
+  lastRebuildRetrievalResult?: { success: boolean; chapterCount?: number; error?: string } | null
 }
 
-export function ChatMessage({ message, isLastAssistant, onRegenerate, novelMode, projectPath, onSaveAsChapter, onContinueNextChapter, onContinueUnfinished, saveStatus, isSaving }: ChatMessageProps) {
+export function ChatMessage({
+  message,
+  isLastAssistant,
+  onRegenerate,
+  novelMode,
+  projectPath,
+  onSaveAsChapter,
+  onContinueNextChapter,
+  onContinueUnfinished,
+  saveStatus,
+  isSaving,
+  onConfirmToolSave,
+  onRejectTool,
+  onRebuildRetrievalIndex,
+  retrievalIndexHasIndex,
+  isRebuildingRetrievalIndex,
+  lastRebuildRetrievalResult,
+}: ChatMessageProps) {
   const isUser = message.role === "user"
   const isSystem = message.role === "system"
   const isAssistant = message.role === "assistant"
   const [hovered, setHovered] = useState(false)
+  const [contextTraceExpanded, setContextTraceExpanded] = useState(false)
   const canResumeUnfinished = Boolean(
     novelMode && isLastAssistant && onContinueUnfinished && canContinueUnfinishedDeepChapter(message.content),
   )
+  const hasContextTrace = Boolean(message.contextTrace && (message.contextTrace.toolCalls.length > 0 || message.contextTrace.contextInfo))
 
   return (
     <div
@@ -67,7 +102,7 @@ export function ChatMessage({ message, isLastAssistant, onRegenerate, novelMode,
       >
         {isUser ? <User className="h-4 w-4" /> : <Bot className="h-4 w-4" />}
       </div>
-      <div className="max-w-[80%] flex flex-col gap-1.5">
+      <div className="w-fit max-w-full lg:max-w-[50vw] flex flex-col gap-1.5">
         <div
           className={`rounded-lg px-3 py-2 text-sm ${
             isUser
@@ -80,9 +115,34 @@ export function ChatMessage({ message, isLastAssistant, onRegenerate, novelMode,
           {message.discarded ? (
             <span className="italic text-xs opacity-60">已废弃</span>
           ) : isUser ? (
-            <p dir="auto" className="whitespace-pre-wrap break-words">{message.content}</p>
+            <>
+              <p dir="auto" className="whitespace-pre-wrap break-words">{message.content}</p>
+              {message.attachedReferences && message.attachedReferences.length > 0 && (
+                <div className="mt-1 flex flex-wrap gap-1">
+                  {message.attachedReferences.map((token) => (
+                    <ReferenceChip key={token.id} token={token} readonly />
+                  ))}
+                </div>
+              )}
+            </>
           ) : (
-            <AgentAwareContent content={message.content} projectPath={projectPath} />
+            <>
+              {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 />
+              ) : (
+                <AgentAwareContent content={message.content} projectPath={projectPath} />
+              )}
+            </>
           )}
         </div>
         {isAssistant && !message.discarded && <CitedReferencesPanel content={message.content} savedReferences={message.references} />}
@@ -137,6 +197,30 @@ export function ChatMessage({ message, isLastAssistant, onRegenerate, novelMode,
                 <RefreshCw className="h-3 w-3" /> 重新生成
               </button>
             )}
+            {hasContextTrace && (
+              <button
+                type="button"
+                onClick={() => setContextTraceExpanded(!contextTraceExpanded)}
+                className="inline-flex items-center gap-1 rounded px-2 py-0.5 text-[11px] text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
+                title="查看生成详情"
+              >
+                <Info className="h-3 w-3" />
+                {contextTraceExpanded ? "收起详情" : "查看生成详情"}
+                {contextTraceExpanded ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
+              </button>
+            )}
+          </div>
+        )}
+        {isAssistant && !message.discarded && contextTraceExpanded && message.contextTrace && (
+          <div className="mt-1">
+            <ContextTracePanel
+              trace={message.contextTrace}
+              projectPath={projectPath}
+              onRebuildRetrievalIndex={onRebuildRetrievalIndex}
+              retrievalIndexHasIndex={retrievalIndexHasIndex}
+              isRebuildingRetrievalIndex={isRebuildingRetrievalIndex}
+              lastRebuildResult={lastRebuildRetrievalResult}
+            />
           </div>
         )}
         {saveStatus && (
@@ -535,7 +619,7 @@ export function StreamingMessage({ content }: StreamingMessageProps) {
       <div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
         <Bot className="h-4 w-4" />
       </div>
-      <div className="max-w-[80%] rounded-lg px-3 py-2 text-sm bg-muted text-foreground">
+      <div className="w-fit max-w-full lg:max-w-[50vw] rounded-lg px-3 py-2 text-sm bg-muted text-foreground">
         {isThinking ? (
           <StreamingWorkflowBlock content={thinking} />
         ) : (
@@ -550,13 +634,21 @@ export function StreamingMessage({ content }: StreamingMessageProps) {
   )
 }
 
+function AgentThinkingIndicator() {
+  return (
+    <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
+      <Loader2 className="h-3.5 w-3.5 animate-spin" />
+      <span>正在生成中...</span>
+    </div>
+  )
+}
+
 function AgentAwareContent({ content, projectPath }: { content: string; projectPath?: string | null }) {
   const [applied, setApplied] = useState(false)
   const [results, setResults] = useState<import("@/lib/novel/agent-tools").FileEditResult[]>([])
   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])
 

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

@@ -0,0 +1,91 @@
+// @vitest-environment jsdom
+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 () => {
+    const view = await renderChatPanel()
+
+    expect(view.container.textContent).toContain("novel.chat.startNewConversation")
+
+    await view.unmount()
+  })
+
+  it("Agent skill 配置为空时仍可打开 ChatPanel", async () => {
+    const view = await renderChatPanel({ agentSkillConfig: null })
+
+    expect(view.container.textContent).toContain("novel.chat.startNewConversation")
+
+    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("断点保存:失败时记录调试信息但不自动弹窗")
+})

+ 457 - 0
src/components/chat/chat-panel.spec.tsx

@@ -3,6 +3,7 @@ import { resolve } from "node:path"
 import { describe, expect, it } from "vitest"
 
 const source = readFileSync(resolve(__dirname, "chat-panel.tsx"), "utf8")
+const chatStoreSource = readFileSync(resolve(__dirname, "../../stores/chat-store.ts"), "utf8")
 
 describe("chat-panel de-AI skill handling", () => {
   it("loads the chat de-AI skill safely and surfaces a warning without aborting send", () => {
@@ -24,3 +25,459 @@ describe("chat-panel de-AI skill handling", () => {
     expect(source).not.toContain('          {t(novelMode ? "novel.chat.newChat" : "chat.newChat")}')
   })
 })
+
+describe("chat-panel agent reference integration", () => {
+  it("replaces the legacy chat input with the reference input and picker", () => {
+    expect(source).toContain("<ReferenceInput")
+    expect(source).toContain("<ReferencePickerDialog")
+    expect(source).toContain("insertTokensRef")
+    expect(source).not.toContain("<ChatInput")
+    expect(source).not.toContain('from "./chat-input"')
+  })
+
+  it("routes sends through runAiChatSession and stores reference/tool metadata", () => {
+    expect(source).toContain("useAgentConfig")
+    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")
+    expect(source).toContain("setReferenceTokensForConversation")
+    expect(source).not.toContain('const [referenceText, setReferenceText] = useState("")')
+    expect(source).not.toContain("const [currentTokens, setCurrentTokens] = useState<ReferenceToken[]>([])")
+  })
+
+  it("stores successful Agent read tool calls as assistant message references", () => {
+    expect(source).toContain("agentToolCallsToMessageReferences")
+    expect(source).toContain("references:")
+  })
+
+  it("keeps model and stop controls in the reference input footer", () => {
+    expect(source).toContain("rightControls={")
+    expect(source).toContain("<ChatModelSelector")
+    expect(source).toContain("isStreaming={isStreaming}")
+    expect(source).toContain("onStop={handleStop}")
+  })
+
+  it("consumes externally queued reference tokens into the active chat draft", () => {
+    expect(source).toContain("pendingReferenceTokens")
+    expect(source).toContain("consumePendingReferenceTokens")
+    expect(source).toContain("setReferenceTokensForConversation(drafts, targetConversationId")
+  })
+
+  it("keeps chapter-generation replies limited to chapter body", () => {
+    expect(source).toContain("章节生成、续写或改写任务的最终回复必须只包含章节正文")
+    expect(source).toContain("不要输出读取说明、执行总结、完成目标表格、章节结构、后续建议")
+  })
+
+  it("uses the three-level AI workflow mode instead of a single deep mode prompt", () => {
+    expect(source).toContain("aiWorkflowMode")
+    expect(source).toContain("setAiWorkflowMode")
+    expect(source).toContain("快速模式")
+    expect(source).toContain("标准模式")
+    expect(source).toContain("严格模式")
+    expect(source).not.toContain("用户已开启深度模式,请在必要时进行更完整的章节规划和资料读取。")
+  })
+
+  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, userVisibleText || plainText, tokens)")
+    const prePluginIndex = source.indexOf("await runNovelPrePluginChain({")
+
+    expect(appendIndex).toBeGreaterThan(-1)
+    expect(prePluginIndex).toBeGreaterThan(-1)
+    expect(appendIndex).toBeLessThan(prePluginIndex)
+  })
+
+  it("downgrades low-confidence clarification requests to normal chat without a dialog", () => {
+    expect(source).toContain("let prePluginResult")
+    expect(source).toContain('prePluginResult && prePluginResult.stopReason === "clarification_needed"')
+    expect(source).toContain("effectiveTaskRoute = null")
+    expect(source).toContain("contextPack = prePluginResult.contextPack || null")
+    expect(source).not.toContain("IntentClarificationDialog")
+    expect(source).not.toContain("requestClarification")
+  })
+
+  it("keeps low-information general chat out of the novel context loading chain", () => {
+    expect(source).toContain("shouldRunNovelPrePluginChain")
+    expect(source).toContain('rawTaskRoute.intent !== "general_chat"')
+    expect(source).toContain("const taskRoute = shouldRunNovelPrePluginChain ? rawTaskRoute : null")
+
+    const guardIndex = source.indexOf("const shouldRunNovelPrePluginChain")
+    const runIndex = source.indexOf("await runNovelPrePluginChain({")
+    const virtualRouteIndex = source.indexOf("if (novelMode && effectiveTaskRoute)")
+
+    expect(guardIndex).toBeGreaterThan(-1)
+    expect(runIndex).toBeGreaterThan(-1)
+    expect(virtualRouteIndex).toBeLessThan(runIndex)
+  })
+
+  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("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).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", () => {
+    expect(source).toContain("buildSelectedSkillsPrompt")
+    expect(source).toContain("prePluginResult?.selectedSkills")
+    expect(source).toContain("selectedSkillsPrompt")
+  })
+
+  it("requires external search requests to use web_search instead of pretending", () => {
+    expect(source).toContain("web_search")
+    expect(source).toContain("不得声称已经搜索")
+    expect(source).toContain("未使用联网资料")
+  })
+
+  it("records web_search tool results into context trace", () => {
+    expect(source).toContain("appendWebSearchTrace")
+    expect(source).toContain('event.name !== "web_search"')
+    expect(source).toContain("webSearches")
+  })
+
+  it("records result protocol validation into context trace", () => {
+    expect(source).toContain("buildResultProtocolTrace")
+    expect(source).toContain("resultProtocol:")
+  })
+
+  it("settles running tool calls when the agent session finishes", () => {
+    expect(source).toContain("settleRunningAgentToolCalls")
+    expect(source).toContain("settleRunningAgentToolCalls(record?.toolCalls.length ? record.toolCalls : message.agentToolCalls")
+    expect(source).toContain('settleRunningAgentToolCalls(message.agentToolCalls, "error"')
+  })
+
+  it("routes normal agent sends through runAiChatSession", () => {
+    const continueIndex = source.indexOf("const handleContinueUnfinished")
+    const sessionRunnerIndex = source.indexOf("runAiChatSession({")
+
+    expect(continueIndex).toBeGreaterThan(-1)
+    expect(sessionRunnerIndex).toBeGreaterThan(-1)
+    expect(source).not.toContain("await streamChat(")
+  })
+
+  it("routes continue-unfinished through the ReAct send path with compact display text", () => {
+    const continueIndex = source.indexOf("const handleContinueUnfinished")
+
+    expect(continueIndex).toBeGreaterThan(-1)
+    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", () => {
+    const confirmIndex = source.indexOf("const handleConfirmToolSave")
+    const validationIndex = source.indexOf("validateChapterBeforeSave", confirmIndex)
+    const confirmDraftIndex = source.indexOf("confirmDraft(project.path", confirmIndex)
+
+    expect(confirmIndex).toBeGreaterThan(-1)
+    expect(validationIndex).toBeGreaterThan(confirmIndex)
+    expect(validationIndex).toBeLessThan(confirmDraftIndex)
+  })
+})
+
+describe("chat-panel chapter plan confirm integration (Stage C)", () => {
+  it("imports ChapterPlanConfirmDialog and helper functions", () => {
+    expect(source).toContain("ChapterPlanConfirmDialog")
+    expect(source).toContain("extractChapterPlan")
+    expect(source).toContain("buildPlanConfirmMessage")
+    expect(source).toContain("buildPlanSkipMessage")
+    expect(source).toContain('from "./chapter-plan-confirm-dialog"')
+  })
+
+  it("declares pendingChapterPlan state with planContent, fullContent and conversationId", () => {
+    expect(source).toContain("pendingChapterPlan")
+    expect(source).toContain("planContent")
+    expect(source).toContain("fullContent")
+    expect(source).toContain("conversationId")
+    expect(source).toContain("chapterPlanResolverRef")
+  })
+
+  it("provides closeChapterPlanDialog and requestChapterPlanConfirm helpers", () => {
+    expect(source).toContain("closeChapterPlanDialog")
+    expect(source).toContain("requestChapterPlanConfirm")
+    expect(source).toContain('new Promise<"confirm" | "skip" | "cancel" | { modify: string }>')
+  })
+
+  it("detects chapter_plan marker inside onDone callback", () => {
+    const onDoneIndex = source.indexOf("onDone: () => {")
+    expect(onDoneIndex).toBeGreaterThan(-1)
+
+    const afterOnDone = source.slice(onDoneIndex)
+    expect(afterOnDone).toContain("extractChapterPlan")
+  })
+
+  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", () => {
+    const onDoneIndex = source.indexOf("onDone: () => {")
+    const afterOnDone = source.slice(onDoneIndex)
+    expect(afterOnDone).toContain("requestChapterPlanConfirm")
+  })
+
+  it("builds confirm and skip followup messages using the helper functions", () => {
+    const onDoneIndex = source.indexOf("onDone: () => {")
+    const afterOnDone = source.slice(onDoneIndex)
+    expect(afterOnDone).toContain("buildPlanConfirmMessage")
+    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")
+    expect(source).toContain("pendingChapterPlan.planContent")
+  })
+
+  it("wires dialog actions to closeChapterPlanDialog", () => {
+    expect(source).toContain('closeChapterPlanDialog("confirm")')
+    expect(source).toContain('closeChapterPlanDialog("skip")')
+    expect(source).toContain('closeChapterPlanDialog("cancel")')
+    expect(source).toContain('closeChapterPlanDialog({ modify:')
+  })
+})
+
+describe("chat-panel post-write check integration (Stage D)", () => {
+  it("imports runPostWriteCheckAI from the post-write-check-ai module", () => {
+    expect(source).toContain('import { runPostWriteCheckAI } from "@/lib/agent/plugins/post-write-check-ai"')
+  })
+
+  it("declares a Stage D block inside the trace-building section", () => {
+    expect(source).toContain("=== Stage D: 写后剧情自检 ===")
+  })
+
+  it("scopes the check to write_chapter and continue_chapter tasks only", () => {
+    const stageDIndex = source.indexOf("=== Stage D: 写后剧情自检 ===")
+    expect(stageDIndex).toBeGreaterThan(-1)
+    const stageDBlock = source.slice(stageDIndex, stageDIndex + 1200)
+    expect(stageDBlock).toContain('effectiveTaskRoute.intent === "write_chapter"')
+    expect(stageDBlock).toContain('effectiveTaskRoute.intent === "continue_chapter"')
+    // 不应对 rewrite_chapter 或其他意图触发
+    expect(stageDBlock).not.toContain("rewrite_chapter")
+  })
+
+  it("reads the final assistant content from the store (same as Stage C)", () => {
+    const stageDIndex = source.indexOf("=== Stage D: 写后剧情自检 ===")
+    const stageDBlock = source.slice(stageDIndex, stageDIndex + 1200)
+    expect(stageDBlock).toContain("useChatStore.getState()")
+    expect(stageDBlock).toContain("lastAssistant")
+  })
+
+  it("excludes content carrying the chapter_plan marker", () => {
+    const stageDIndex = source.indexOf("=== Stage D: 写后剧情自检 ===")
+    const stageDBlock = source.slice(stageDIndex, stageDIndex + 1200)
+    expect(stageDBlock).toContain('chapterContent.includes("chapter_plan")')
+    expect(stageDBlock).toContain("hasChapterPlanMarker")
+  })
+
+  it("skips empty content to avoid false reports", () => {
+    const stageDIndex = source.indexOf("=== Stage D: 写后剧情自检 ===")
+    const stageDBlock = source.slice(stageDIndex, stageDIndex + 1200)
+    expect(stageDBlock).toContain("if (chapterContent && !hasChapterPlanMarker)")
+  })
+
+  it("writes the check result and meta into contextTrace.contextInfo", () => {
+    const stageDIndex = source.indexOf("=== Stage D: 写后剧情自检 ===")
+    const stageDBlock = source.slice(stageDIndex, stageDIndex + 2000)
+    expect(stageDBlock).toContain("runPostWriteCheckAI({")
+    expect(stageDBlock).toContain("postWriteCheck")
+    expect(stageDBlock).toContain("postWriteCheckMeta")
+    expect(stageDBlock).toContain("setContextInfo(contextTrace,")
+  })
+
+  it("places Stage D after result protocol and before finishTrace", () => {
+    const traceBlockIndex = source.indexOf("if (contextTrace && effectiveTaskRoute) {")
+    expect(traceBlockIndex).toBeGreaterThan(-1)
+    const traceBlock = source.slice(traceBlockIndex, traceBlockIndex + 3000)
+    const protocolIndex = traceBlock.indexOf("buildResultProtocolTrace")
+    const stageDIndex = traceBlock.indexOf("=== Stage D: 写后剧情自检 ===")
+    const finishIndex = traceBlock.indexOf('finishTrace(contextTrace, "done")')
+    expect(protocolIndex).toBeGreaterThan(-1)
+    expect(stageDIndex).toBeGreaterThan(protocolIndex)
+    expect(finishIndex).toBeGreaterThan(stageDIndex)
+  })
+
+  it("does not block saving: the check only writes to contextTrace, no return or throw", () => {
+    const stageDIndex = source.indexOf("=== Stage D: 写后剧情自检 ===")
+    const stageDBlock = source.slice(stageDIndex, stageDIndex + 1200)
+    // 自检结果仅展示,不应阻止后续保存流程
+    expect(stageDBlock).not.toContain("throw ")
+    expect(stageDBlock).not.toContain("return")
+  })
+})
+
+describe("aiWorkflowMode store 读取", () => {
+  it("不再使用局部 useState 持有 aiWorkflowMode,改为从 store 读取", () => {
+    expect(source).not.toContain("useState<AiWorkflowMode>(DEFAULT_AI_WORKFLOW_MODE)")
+    expect(source).toMatch(/useWikiStore\(\(s\) => s\.aiWorkflowMode\)/)
+    expect(source).toMatch(/useWikiStore\(\(s\) => s\.setAiWorkflowMode\)/)
+  })
+})
+
+describe("resolver 卸载清理", () => {
+  it("useEffect 卸载钩子中清理 pending resolver", () => {
+    expect(source).toMatch(/soulDialogResolverRef\.current = null/)
+    expect(source).toMatch(/chapterPlanResolverRef\.current = null/)
+    expect(source).toMatch(/return \(\) => \{[\s\S]*?ResolverRef\.current/)
+  })
+})
+
+describe("SoulDialog 输入框禁用一致性", () => {
+  it("pendingSoulDialog.open 时禁用主输入框", () => {
+    expect(source).toMatch(/disabled=\{isStreaming \|\| pendingChapterPlan\.open \|\| pendingSoulDialog\.open\}/)
+  })
+})
+
+describe("Stage D AI 推理集成", () => {
+  it("import runPostWriteCheckAI 替代 runPostWriteCheck", () => {
+    expect(source).toContain("runPostWriteCheckAI")
+    expect(source).not.toMatch(/import \{ runPostWriteCheck \} from/)
+  })
+
+  it("异步调用 runPostWriteCheckAI 并写入 postWriteCheckMeta", () => {
+    expect(source).toMatch(/runPostWriteCheckAI\(\{/)
+    expect(source).toMatch(/postWriteCheckMeta/)
+  })
+})
+
+describe("断点恢复自动弹窗已移除", () => {
+  it("chat-store 不再缓存 lastBreakpoint", () => {
+    expect(chatStoreSource).not.toContain("lastBreakpoint")
+    expect(chatStoreSource).not.toContain("setLastBreakpoint")
+  })
+
+  it("ChatPanel 挂载时不再读取断点文件", () => {
+    expect(source).not.toContain("loadTaskBreakpoint")
+    expect(source).not.toMatch(/loadTaskBreakpoint\(projectPath\)/)
+  })
+
+  it("ChatPanel 不再渲染继续未完成任务弹窗", () => {
+    expect(source).not.toContain("检测到上次有未完成的任务")
+    expect(source).not.toContain("breakpointResumeOpen")
+    expect(source).not.toContain("<ModifyConfirmDialog")
+  })
+
+  it("ChatPanel 不再发送断点恢复提示词", () => {
+    expect(source).not.toContain("buildBreakpointResumePrompt")
+    expect(source).not.toContain("breakpointResumeContent")
+    expect(source).not.toContain("handleSendRef.current(breakpointResumeContent")
+  })
+})

Разница между файлами не показана из-за своего большого размера
+ 841 - 714
src/components/chat/chat-panel.tsx


+ 278 - 0
src/components/chat/context-trace-panel.spec.tsx

@@ -0,0 +1,278 @@
+import { renderToStaticMarkup } from "react-dom/server"
+import { describe, expect, it } from "vitest"
+import { ContextTracePanel } from "./context-trace-panel"
+import type { ContextTrace } from "@/lib/agent/context-trace"
+
+describe("ContextTracePanel selected skills", () => {
+  it("renders web search trace entries in the overview", () => {
+    const trace: ContextTrace = {
+      id: "trace-web",
+      startedAt: 1,
+      finishedAt: 5,
+      status: "done",
+      toolCalls: [],
+      contextInfo: {
+        intent: "general_chat",
+        confidence: 0.9,
+        routeSource: "default",
+        loadedSources: [],
+        blockedSources: [],
+        retrievalHits: [],
+        trimmedSections: [],
+        webSearches: [
+          {
+            query: "黄蓉",
+            provider: "tavily",
+            status: "ok",
+            resultCount: 2,
+            sources: ["example.com", "wiki.example"],
+            searchedAt: 100,
+          },
+        ],
+      },
+    }
+
+    const html = renderToStaticMarkup(<ContextTracePanel trace={trace} />)
+
+    expect(html).toContain("外部搜索")
+    expect(html).toContain("黄蓉")
+    expect(html).toContain("2 条结果")
+    expect(html).toContain("example.com")
+  })
+
+  it("renders selected skill names and metadata in the overview", () => {
+    const trace: ContextTrace = {
+      id: "trace-1",
+      startedAt: 1,
+      finishedAt: 5,
+      status: "done",
+      toolCalls: [],
+      contextInfo: {
+        intent: "write_chapter",
+        confidence: 0.9,
+        routeSource: "default",
+        loadedSources: [],
+        blockedSources: [],
+        retrievalHits: [],
+        trimmedSections: [],
+        selectedSkills: [
+          {
+            id: "three-four",
+            name: "三翻四抖",
+            kind: ["structure", "planning"],
+            stages: ["planning", "drafting"],
+            modes: ["standard", "strict"],
+            source: "project",
+          },
+        ],
+      },
+    }
+
+    const html = renderToStaticMarkup(<ContextTracePanel trace={trace} />)
+
+    expect(html).toContain("使用 Skill")
+    expect(html).toContain("三翻四抖")
+    expect(html).toContain("structure")
+    expect(html).toContain("planning")
+    expect(html).not.toContain("三次转折")
+  })
+  it("renders selected capability names and permissions in the overview", () => {
+    const trace: ContextTrace = {
+      id: "trace-capability",
+      startedAt: 1,
+      finishedAt: 5,
+      status: "done",
+      toolCalls: [],
+      contextInfo: {
+        intent: "write_chapter",
+        confidence: 0.9,
+        routeSource: "default",
+        loadedSources: [],
+        blockedSources: [],
+        retrievalHits: [],
+        trimmedSections: [],
+        selectedCapabilities: [
+          {
+            id: "tool:read_chapter",
+            name: "Read Chapter",
+            kind: "built_in_tool",
+            permission: "auto",
+            source: "built-in",
+            reason: "required reading tool",
+          },
+          {
+            id: "tool:write_chapter",
+            name: "Write Chapter",
+            kind: "built_in_tool",
+            permission: "confirm",
+            source: "built-in",
+            reason: "write action requires confirmation",
+          },
+        ],
+      },
+    }
+
+    const html = renderToStaticMarkup(<ContextTracePanel trace={trace} />)
+
+    expect(html).toContain("启用能力")
+    expect(html).toContain("Read Chapter")
+    expect(html).toContain("built_in_tool")
+    expect(html).toContain("auto")
+    expect(html).toContain("Write Chapter")
+    expect(html).toContain("confirm")
+  })
+
+  it("renders MCP call summaries in the overview", () => {
+    const trace: ContextTrace = {
+      id: "trace-mcp",
+      startedAt: 1,
+      finishedAt: 5,
+      status: "done",
+      toolCalls: [],
+      contextInfo: {
+        intent: "character_query",
+        confidence: 0.9,
+        routeSource: "default",
+        loadedSources: [],
+        blockedSources: [],
+        retrievalHits: [],
+        trimmedSections: [],
+        mcpCalls: [
+          {
+            serverId: "graph",
+            serverName: "Graph MCP",
+            toolName: "query",
+            status: "error",
+            summary: "not available",
+            message: "MCP 调用失败,普通 AI 会话可以继续。",
+            calledAt: 100,
+          },
+        ],
+      },
+    }
+
+    const html = renderToStaticMarkup(<ContextTracePanel trace={trace} />)
+
+    expect(html).toContain("MCP 调用")
+    expect(html).toContain("Graph MCP")
+    expect(html).toContain("query")
+    expect(html).toContain("error")
+    expect(html).toContain("普通 AI 会话可以继续")
+  })
+
+  it("renders AI mode label when postWriteCheckMeta source is ai", () => {
+    const trace: ContextTrace = {
+      id: "trace-ai-mode",
+      startedAt: 1,
+      finishedAt: 5,
+      status: "done",
+      toolCalls: [],
+      contextInfo: {
+        intent: "write_chapter",
+        confidence: 0.9,
+        routeSource: "default",
+        loadedSources: [],
+        blockedSources: [],
+        retrievalHits: [],
+        trimmedSections: [],
+        postWriteCheck: {
+          items: [
+            { name: "人物一致性", passed: true, detail: "通过" },
+          ],
+          passedCount: 1,
+          totalCount: 1,
+          allPassed: true,
+        },
+        postWriteCheckMeta: {
+          source: "ai",
+        },
+      },
+    }
+
+    const html = renderToStaticMarkup(<ContextTracePanel trace={trace} />)
+
+    expect(html).toContain("写后自检")
+    expect(html).toContain("AI 推理")
+    expect(html).not.toContain("规则检查")
+  })
+
+  it("renders rule mode label and fallback reason when postWriteCheckMeta source is rule", () => {
+    const trace: ContextTrace = {
+      id: "trace-rule-mode",
+      startedAt: 1,
+      finishedAt: 5,
+      status: "done",
+      toolCalls: [],
+      contextInfo: {
+        intent: "write_chapter",
+        confidence: 0.9,
+        routeSource: "default",
+        loadedSources: [],
+        blockedSources: [],
+        retrievalHits: [],
+        trimmedSections: [],
+        postWriteCheck: {
+          items: [
+            { name: "字数检查", passed: true, detail: "通过" },
+          ],
+          passedCount: 1,
+          totalCount: 1,
+          allPassed: true,
+        },
+        postWriteCheckMeta: {
+          source: "rule",
+          fallbackReason: "AI 检查接口不可用,降级为规则检查",
+        },
+      },
+    }
+
+    const html = renderToStaticMarkup(<ContextTracePanel trace={trace} />)
+
+    expect(html).toContain("写后自检")
+    expect(html).toContain("规则检查")
+    expect(html).toContain("AI 检查接口不可用,降级为规则检查")
+    expect(html).not.toContain("AI 推理")
+  })
+
+  it("renders evidence and suggestion for postWriteCheck items in AI mode", () => {
+    const trace: ContextTrace = {
+      id: "trace-evidence",
+      startedAt: 1,
+      finishedAt: 5,
+      status: "done",
+      toolCalls: [],
+      contextInfo: {
+        intent: "write_chapter",
+        confidence: 0.9,
+        routeSource: "default",
+        loadedSources: [],
+        blockedSources: [],
+        retrievalHits: [],
+        trimmedSections: [],
+        postWriteCheck: {
+          items: [
+            {
+              name: "人物一致性",
+              passed: false,
+              detail: "主角性格前后不一致",
+              evidence: "前文主角性格沉稳,此处表现冲动",
+              suggestion: "调整主角在该场景的反应,保持性格一致",
+            },
+          ],
+          passedCount: 0,
+          totalCount: 1,
+          allPassed: false,
+        },
+        postWriteCheckMeta: {
+          source: "ai",
+        },
+      },
+    }
+
+    const html = renderToStaticMarkup(<ContextTracePanel trace={trace} />)
+
+    expect(html).toContain("人物一致性")
+    expect(html).toContain("前文主角性格沉稳,此处表现冲动")
+    expect(html).toContain("调整主角在该场景的反应,保持性格一致")
+  })
+})

+ 1124 - 0
src/components/chat/context-trace-panel.tsx

@@ -0,0 +1,1124 @@
+import { useState, useRef, useEffect } from "react"
+import {
+  X,
+  Clock,
+  Zap,
+  Database,
+  ShieldAlert,
+  Target,
+  Route,
+  Layers,
+  TrendingUp,
+  AlertTriangle,
+  CheckCircle2,
+  Loader2,
+  XCircle,
+  ChevronDown,
+  ChevronUp,
+  Copy,
+  Check,
+  BookOpen,
+  FileText,
+  Users,
+  Sparkles,
+  Clock as ClockIcon,
+  Settings,
+  Heart,
+  Brain,
+  Network,
+  Wand2,
+  Edit3,
+  RefreshCw,
+} from "lucide-react"
+import type { ContextTrace, TraceContextInfo } from "@/lib/agent/context-trace"
+import type { NovelTaskIntent } from "@/lib/novel/task-router"
+import type { DataSourceCategory, RouteSource } from "@/lib/novel/classification"
+import {
+  DATA_SOURCE_CATEGORY_LABELS,
+  loadProjectClassification,
+  upgradeClassificationConfig,
+  writeProjectClassification,
+} from "@/lib/novel/classification"
+import { cn } from "@/lib/utils"
+import { ToolCallTimeline } from "./tool-call-timeline"
+
+interface RebuildRetrievalResult {
+  success: boolean
+  chapterCount?: number
+  error?: string
+}
+
+interface ContextTracePanelProps {
+  trace: ContextTrace | null
+  projectPath?: string | null
+  onClose?: () => void
+  className?: string
+  onRebuildRetrievalIndex?: () => Promise<RebuildRetrievalResult>
+  retrievalIndexHasIndex?: boolean
+  isRebuildingRetrievalIndex?: boolean
+  lastRebuildResult?: RebuildRetrievalResult | null
+}
+
+const INTENT_LABELS: Record<NovelTaskIntent, string> = {
+  write_chapter: "写新章节",
+  continue_chapter: "续写章节",
+  rewrite_chapter: "改写章节",
+  polish_chapter: "润色章节",
+  review_chapter: "AI 审稿",
+  lint_chapter: "连贯性检查",
+  generate_outline: "生成大纲",
+  search_plot: "剧情搜索",
+  extract_memory: "章节摄取",
+  character_query: "人物查询",
+  foreshadowing_query: "伏笔查询",
+  timeline_query: "时间线查询",
+  setting_query: "设定查询",
+  general_chat: "随便聊聊",
+}
+
+const ROUTE_SOURCE_LABELS: Record<RouteSource, string> = {
+  default: "默认路由",
+  project: "项目配置",
+  project_with_feature: "项目特性配置",
+}
+
+type TabType = "overview" | "timeline"
+
+function formatDuration(startedAt: number, finishedAt?: number): string {
+  const end = finishedAt || Date.now()
+  const ms = end - startedAt
+  if (ms <= 0) return "0ms"
+  if (ms < 1000) return `${ms}ms`
+  if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`
+  const seconds = Math.floor(ms / 1000)
+  const minutes = Math.floor(seconds / 60)
+  const secs = seconds % 60
+  return `${minutes}m${secs}s`
+}
+
+function StatusBadge({ status }: { status: ContextTrace["status"] }) {
+  if (status === "running") {
+    return (
+      <span className="inline-flex items-center gap-1 rounded-full border border-blue-300/70 bg-blue-50 px-2 py-0.5 text-[11px] font-medium text-blue-700 dark:border-blue-800/60 dark:bg-blue-950/40 dark:text-blue-300">
+        <Loader2 className="h-3 w-3 animate-spin" />
+        运行中
+      </span>
+    )
+  }
+  if (status === "done") {
+    return (
+      <span className="inline-flex items-center gap-1 rounded-full border border-green-300/70 bg-green-50 px-2 py-0.5 text-[11px] font-medium text-green-700 dark:border-green-800/60 dark:bg-green-950/40 dark:text-green-300">
+        <CheckCircle2 className="h-3 w-3" />
+        已完成
+      </span>
+    )
+  }
+  if (status === "error") {
+    return (
+      <span className="inline-flex items-center gap-1 rounded-full border border-red-300/70 bg-red-50 px-2 py-0.5 text-[11px] font-medium text-red-700 dark:border-red-800/60 dark:bg-red-950/40 dark:text-red-300">
+        <XCircle className="h-3 w-3" />
+        出错
+      </span>
+    )
+  }
+  return null
+}
+
+function InfoRow({
+  icon: Icon,
+  label,
+  value,
+  valueClassName,
+}: {
+  icon: React.ComponentType<{ className?: string }>
+  label: string
+  value: React.ReactNode
+  valueClassName?: string
+}) {
+  return (
+    <div className="flex items-start gap-3 py-2">
+      <div className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
+        <Icon className="h-3.5 w-3.5" />
+      </div>
+      <div className="min-w-0 flex-1">
+        <div className="text-[11px] text-muted-foreground">{label}</div>
+        <div className={cn("text-sm font-medium", valueClassName)}>{value}</div>
+      </div>
+    </div>
+  )
+}
+
+function ConfidenceBar({ confidence }: { confidence: number }) {
+  const percent = Math.round(confidence * 100)
+  let colorClass = "bg-green-500"
+  let textColorClass = "text-green-600 dark:text-green-400"
+  if (confidence < 0.6) {
+    colorClass = "bg-red-500"
+    textColorClass = "text-red-600 dark:text-red-400"
+  } else if (confidence < 0.8) {
+    colorClass = "bg-amber-500"
+    textColorClass = "text-amber-600 dark:text-amber-400"
+  }
+
+  return (
+    <div className="flex items-center gap-2">
+      <div className="h-2 flex-1 overflow-hidden rounded-full bg-muted">
+        <div
+          className={cn("h-full rounded-full transition-all duration-500", colorClass)}
+          style={{ width: `${percent}%` }}
+        />
+      </div>
+      <span className={cn("text-xs font-medium tabular-nums", textColorClass)}>{percent}%</span>
+    </div>
+  )
+}
+
+function getDataSourceIcon(category: DataSourceCategory) {
+  switch (category) {
+    case "outline":
+      return BookOpen
+    case "recent_summaries":
+      return FileText
+    case "chapter_content":
+      return FileText
+    case "character_states":
+      return Users
+    case "foreshadowing":
+      return Sparkles
+    case "timeline":
+      return ClockIcon
+    case "settings":
+      return Settings
+    case "soul":
+      return Heart
+    case "memory":
+      return Brain
+    case "graph":
+      return Network
+    case "plot_tools":
+      return Wand2
+    case "revision":
+      return Edit3
+    default:
+      return Database
+  }
+}
+
+function SourceCategoryTag({ category }: { category: DataSourceCategory }) {
+  const Icon = getDataSourceIcon(category)
+  return (
+    <span className="inline-flex items-center gap-1 rounded-md border border-border bg-background px-2 py-0.5 text-[11px] text-muted-foreground">
+      <Icon className="h-3 w-3" />
+      {DATA_SOURCE_CATEGORY_LABELS[category] || category}
+    </span>
+  )
+}
+
+function BudgetBar({ used, limit }: { used: number; limit: number }) {
+  const percent = Math.min(100, Math.round((used / limit) * 100))
+  let colorClass = "bg-green-500"
+  if (percent > 80) colorClass = "bg-red-500"
+  else if (percent > 60) colorClass = "bg-amber-500"
+
+  return (
+    <div>
+      <div className="mb-1 flex items-center justify-between text-[11px]">
+        <span className="text-muted-foreground">字符使用</span>
+        <span className="font-medium tabular-nums">
+          {used.toLocaleString()} / {limit.toLocaleString()}
+        </span>
+      </div>
+      <div className="h-2 overflow-hidden rounded-full bg-muted">
+        <div
+          className={cn("h-full rounded-full transition-all", colorClass)}
+          style={{ width: `${percent}%` }}
+        />
+      </div>
+    </div>
+  )
+}
+
+function RetrievalIndexSection({
+  projectPath,
+  hasIndex,
+  isRebuilding,
+  lastResult,
+  onRebuild,
+}: {
+  projectPath?: string | null
+  hasIndex?: boolean
+  isRebuilding?: boolean
+  lastResult?: RebuildRetrievalResult | null
+  onRebuild?: () => Promise<RebuildRetrievalResult>
+}) {
+  const [localIsRebuilding, setLocalIsRebuilding] = useState(false)
+  const [localResult, setLocalResult] = useState<RebuildRetrievalResult | null>(null)
+
+  const effectiveIsRebuilding = isRebuilding ?? localIsRebuilding
+  const effectiveResult = lastResult ?? localResult
+
+  const handleRebuild = async () => {
+    if (!onRebuild || !projectPath || effectiveIsRebuilding) return
+    setLocalIsRebuilding(true)
+    setLocalResult(null)
+    try {
+      const result = await onRebuild()
+      setLocalResult(result)
+    } catch (e) {
+      setLocalResult({
+        success: false,
+        error: e instanceof Error ? e.message : String(e),
+      })
+    } finally {
+      setLocalIsRebuilding(false)
+    }
+  }
+
+  const disabled = !projectPath || effectiveIsRebuilding
+
+  return (
+    <div className="py-2">
+      <div className="mb-2 flex items-center gap-2">
+        <div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-blue-100 text-blue-600 dark:bg-blue-950/40 dark:text-blue-400">
+          <Database className="h-3.5 w-3.5" />
+        </div>
+        <div className="text-[11px] font-medium text-foreground">
+          检索索引
+        </div>
+        {hasIndex !== undefined && (
+          <span className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${
+            hasIndex
+              ? "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300"
+              : "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300"
+          }`}>
+            {hasIndex ? "已存在" : "未创建"}
+          </span>
+        )}
+      </div>
+      <div className="ml-9 space-y-2">
+        <button
+          type="button"
+          onClick={handleRebuild}
+          disabled={disabled}
+          className={cn(
+            "inline-flex items-center gap-1 rounded-md px-2 py-1 text-[11px] font-medium transition-colors",
+            disabled
+              ? "cursor-not-allowed bg-muted text-muted-foreground/50"
+              : "bg-primary text-primary-foreground hover:bg-primary/90"
+          )}
+        >
+          {effectiveIsRebuilding ? (
+            <>
+              <Loader2 className="h-3 w-3 animate-spin" />
+              重建中...
+            </>
+          ) : (
+            <>
+              <RefreshCw className="h-3 w-3" />
+              重建索引
+            </>
+          )}
+        </button>
+
+        {!projectPath && (
+          <div className="text-[11px] text-amber-600 dark:text-amber-400">
+            请先打开项目后再使用此功能
+          </div>
+        )}
+
+        {effectiveResult && (
+          <div className={cn(
+            "rounded-md border p-2 text-[11px]",
+            effectiveResult.success
+              ? "border-green-200 bg-green-50/60 text-green-800 dark:border-green-900/40 dark:bg-green-950/20 dark:text-green-300"
+              : "border-red-200 bg-red-50/60 text-red-800 dark:border-red-900/40 dark:bg-red-950/20 dark:text-red-300"
+          )}>
+            {effectiveResult.success ? (
+              <div className="flex items-center gap-1">
+                <CheckCircle2 className="h-3 w-3" />
+                <span>重建成功,共 {effectiveResult.chapterCount ?? 0} 章</span>
+              </div>
+            ) : (
+              <div className="flex items-start gap-1">
+                <XCircle className="mt-0.5 h-3 w-3 shrink-0" />
+                <span>重建失败:{effectiveResult.error ?? "未知错误"}</span>
+              </div>
+            )}
+          </div>
+        )}
+      </div>
+    </div>
+  )
+}
+
+function OverviewTab({
+  contextInfo,
+  projectPath,
+  onUpgraded,
+  onRebuildRetrievalIndex,
+  retrievalIndexHasIndex,
+  isRebuildingRetrievalIndex,
+  lastRebuildResult,
+}: {
+  contextInfo: TraceContextInfo | undefined
+  projectPath?: string | null
+  onUpgraded?: () => void
+  onRebuildRetrievalIndex?: () => Promise<RebuildRetrievalResult>
+  retrievalIndexHasIndex?: boolean
+  isRebuildingRetrievalIndex?: boolean
+  lastRebuildResult?: RebuildRetrievalResult | null
+}) {
+  const [upgradeStatus, setUpgradeStatus] = useState<"idle" | "confirming" | "loading" | "success" | "error">("idle")
+  const [upgradeError, setUpgradeError] = useState<string | null>(null)
+
+  const handleUpgradeClassification = async () => {
+    if (!projectPath) return
+    setUpgradeStatus("loading")
+    setUpgradeError(null)
+    try {
+      const currentConfig = await loadProjectClassification(projectPath)
+      if (!currentConfig) {
+        throw new Error("未找到 classification.md")
+      }
+      const upgradedConfig = upgradeClassificationConfig(currentConfig)
+      await writeProjectClassification(projectPath, upgradedConfig)
+      setUpgradeStatus("success")
+      onUpgraded?.()
+    } catch (e) {
+      setUpgradeStatus("error")
+      setUpgradeError(e instanceof Error ? e.message : String(e))
+    }
+  }
+  if (!contextInfo) {
+    return (
+      <div className="flex flex-col items-center justify-center py-12 text-center">
+        <Layers className="mb-3 h-10 w-10 text-muted-foreground/40" />
+        <p className="text-sm text-muted-foreground">上下文信息尚未生成</p>
+      </div>
+    )
+  }
+
+  return (
+    <div className="space-y-1">
+      <InfoRow
+        icon={Target}
+        label="意图识别结果"
+        value={
+          <div className="space-y-1">
+            <div>{INTENT_LABELS[contextInfo.intent] || contextInfo.intent}</div>
+            <ConfidenceBar confidence={contextInfo.confidence} />
+          </div>
+        }
+      />
+
+      <div className="my-1 h-px bg-border/60" />
+
+      <InfoRow
+        icon={Route}
+        label="路由来源"
+        value={ROUTE_SOURCE_LABELS[contextInfo.routeSource] || contextInfo.routeSource}
+      />
+
+      {contextInfo.selectedCapabilities && contextInfo.selectedCapabilities.length > 0 && (
+        <>
+          <div className="my-1 h-px bg-border/60" />
+          <div className="py-2">
+            <div className="mb-2 flex items-center gap-2">
+              <div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-indigo-100 text-indigo-600 dark:bg-indigo-950/40 dark:text-indigo-400">
+                <Zap className="h-3.5 w-3.5" />
+              </div>
+              <div className="text-[11px] font-medium text-foreground">
+                启用能力
+              </div>
+              <span className="rounded-full bg-indigo-100 px-1.5 py-0.5 text-[10px] font-medium text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300">
+                {contextInfo.selectedCapabilities.length}
+              </span>
+            </div>
+            <div className="ml-9 space-y-1.5">
+              {contextInfo.selectedCapabilities.map((capability) => (
+                <div key={capability.id} className="rounded-md border bg-background px-2 py-1.5">
+                  <div className="mb-1 text-xs font-medium text-foreground">{capability.name}</div>
+                  <div className="flex flex-wrap gap-1">
+                    {[capability.kind, capability.permission, capability.source].filter(Boolean).map((tag, index) => (
+                      <span
+                        key={`${capability.id}-${tag}-${index}`}
+                        className="rounded-md bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground"
+                      >
+                        {tag}
+                      </span>
+                    ))}
+                  </div>
+                  {capability.reason && (
+                    <div className="mt-1 text-[11px] text-muted-foreground">{capability.reason}</div>
+                  )}
+                </div>
+              ))}
+            </div>
+          </div>
+        </>
+      )}
+
+      {contextInfo.selectedSkills && contextInfo.selectedSkills.length > 0 && (
+        <>
+          <div className="my-1 h-px bg-border/60" />
+          <div className="py-2">
+            <div className="mb-2 flex items-center gap-2">
+              <div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-violet-100 text-violet-600 dark:bg-violet-950/40 dark:text-violet-400">
+                <Sparkles className="h-3.5 w-3.5" />
+              </div>
+              <div className="text-[11px] font-medium text-foreground">
+                使用 Skill
+              </div>
+              <span className="rounded-full bg-violet-100 px-1.5 py-0.5 text-[10px] font-medium text-violet-700 dark:bg-violet-900/40 dark:text-violet-300">
+                {contextInfo.selectedSkills.length}
+              </span>
+            </div>
+            <div className="ml-9 space-y-1.5">
+              {contextInfo.selectedSkills.map((skill) => (
+                <div key={skill.id} className="rounded-md border bg-background px-2 py-1.5">
+                  <div className="mb-1 text-xs font-medium text-foreground">{skill.name}</div>
+                  <div className="flex flex-wrap gap-1">
+                    {[...skill.kind, ...skill.stages, skill.source].map((tag, index) => (
+                      <span
+                        key={`${skill.id}-${tag}-${index}`}
+                        className="rounded-md bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground"
+                      >
+                        {tag}
+                      </span>
+                    ))}
+                  </div>
+                </div>
+              ))}
+            </div>
+          </div>
+        </>
+      )}
+
+      {contextInfo.webSearches && contextInfo.webSearches.length > 0 && (
+        <>
+          <div className="my-1 h-px bg-border/60" />
+          <div className="py-2">
+            <div className="mb-2 flex items-center gap-2">
+              <div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-sky-100 text-sky-600 dark:bg-sky-950/40 dark:text-sky-400">
+                <Network className="h-3.5 w-3.5" />
+              </div>
+              <div className="text-[11px] font-medium text-foreground">
+                外部搜索
+              </div>
+              <span className="rounded-full bg-sky-100 px-1.5 py-0.5 text-[10px] font-medium text-sky-700 dark:bg-sky-900/40 dark:text-sky-300">
+                {contextInfo.webSearches.length}
+              </span>
+            </div>
+            <div className="ml-9 space-y-1.5">
+              {contextInfo.webSearches.map((search, index) => (
+                <div key={`${search.query}-${search.searchedAt}-${index}`} className="rounded-md border bg-background px-2 py-1.5">
+                  <div className="mb-1 flex flex-wrap items-center gap-1.5 text-xs font-medium text-foreground">
+                    <span>{search.query}</span>
+                    <span className="rounded-md bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
+                      {search.provider}
+                    </span>
+                    <span className="rounded-md bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
+                      {search.resultCount} 条结果
+                    </span>
+                  </div>
+                  {search.sources.length > 0 && (
+                    <div className="text-[11px] text-muted-foreground">
+                      来源:{search.sources.join("、")}
+                    </div>
+                  )}
+                  {search.message && (
+                    <div className="mt-1 text-[11px] text-amber-600 dark:text-amber-400">
+                      {search.message}
+                    </div>
+                  )}
+                </div>
+              ))}
+            </div>
+          </div>
+        </>
+      )}
+
+      {contextInfo.mcpCalls && contextInfo.mcpCalls.length > 0 && (
+        <>
+          <div className="my-1 h-px bg-border/60" />
+          <div className="py-2">
+            <div className="mb-2 flex items-center gap-2">
+              <div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-cyan-100 text-cyan-600 dark:bg-cyan-950/40 dark:text-cyan-400">
+                <Network className="h-3.5 w-3.5" />
+              </div>
+              <div className="text-[11px] font-medium text-foreground">
+                MCP 调用
+              </div>
+              <span className="rounded-full bg-cyan-100 px-1.5 py-0.5 text-[10px] font-medium text-cyan-700 dark:bg-cyan-900/40 dark:text-cyan-300">
+                {contextInfo.mcpCalls.length}
+              </span>
+            </div>
+            <div className="ml-9 space-y-1.5">
+              {contextInfo.mcpCalls.map((call, index) => (
+                <div key={`${call.serverId}-${call.toolName}-${call.calledAt}-${index}`} className="rounded-md border bg-background px-2 py-1.5">
+                  <div className="mb-1 flex flex-wrap items-center gap-1.5 text-xs font-medium text-foreground">
+                    <span>{call.serverName}</span>
+                    <span className="rounded-md bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
+                      {call.toolName}
+                    </span>
+                    <span className="rounded-md bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
+                      {call.status}
+                    </span>
+                  </div>
+                  {call.summary && (
+                    <div className="text-[11px] text-muted-foreground">{call.summary}</div>
+                  )}
+                  {call.message && (
+                    <div className="mt-1 text-[11px] text-amber-600 dark:text-amber-400">{call.message}</div>
+                  )}
+                </div>
+              ))}
+            </div>
+          </div>
+        </>
+      )}
+
+      <div className="my-1 h-px bg-border/60" />
+
+      <div className="py-2">
+        <div className="mb-2 flex items-center gap-2">
+          <div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-green-100 text-green-600 dark:bg-green-950/40 dark:text-green-400">
+            <CheckCircle2 className="h-3.5 w-3.5" />
+          </div>
+          <div className="text-[11px] font-medium text-foreground">
+            已加载数据源
+          </div>
+          <span className="rounded-full bg-green-100 px-1.5 py-0.5 text-[10px] font-medium text-green-700 dark:bg-green-900/40 dark:text-green-300">
+            {contextInfo.loadedSources.length}
+          </span>
+        </div>
+        <div className="ml-9 flex flex-wrap gap-1.5">
+          {contextInfo.loadedSources.length > 0 ? (
+            contextInfo.loadedSources.map((cat) => (
+              <SourceCategoryTag key={cat} category={cat} />
+            ))
+          ) : (
+            <span className="text-[11px] text-muted-foreground/70">无</span>
+          )}
+        </div>
+      </div>
+
+      {contextInfo.blockedSources.length > 0 && (
+        <>
+          <div className="my-1 h-px bg-border/60" />
+          <div className="py-2">
+            <div className="mb-2 flex items-center gap-2">
+              <div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-red-100 text-red-600 dark:bg-red-950/40 dark:text-red-400">
+                <ShieldAlert className="h-3.5 w-3.5" />
+              </div>
+              <div className="text-[11px] font-medium text-foreground">
+                已禁载数据源
+              </div>
+              <span className="rounded-full bg-red-100 px-1.5 py-0.5 text-[10px] font-medium text-red-700 dark:bg-red-900/40 dark:text-red-300">
+                {contextInfo.blockedSources.length}
+              </span>
+            </div>
+            <div className="ml-9 flex flex-wrap gap-1.5">
+              {contextInfo.blockedSources.map((cat) => {
+                const Icon = getDataSourceIcon(cat)
+                return (
+                  <span
+                    key={cat}
+                    className="inline-flex items-center gap-1 rounded-md border border-red-200 bg-red-50 px-2 py-0.5 text-[11px] text-red-600 dark:border-red-900/50 dark:bg-red-950/20 dark:text-red-400"
+                  >
+                    <Icon className="h-3 w-3" />
+                    {DATA_SOURCE_CATEGORY_LABELS[cat] || cat}
+                  </span>
+                )
+              })}
+            </div>
+          </div>
+        </>
+      )}
+
+      {contextInfo.retrievalHits.length > 0 && (
+        <>
+          <div className="my-1 h-px bg-border/60" />
+          <InfoRow
+            icon={TrendingUp}
+            label="检索命中数"
+            value={`${contextInfo.retrievalHits.length} 条结果`}
+          />
+        </>
+      )}
+
+      {contextInfo.contextBudget && (
+        <>
+          <div className="my-1 h-px bg-border/60" />
+          <div className="py-2">
+            <div className="mb-2 flex items-center gap-2">
+              <div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
+                <Layers className="h-3.5 w-3.5" />
+              </div>
+              <div className="text-[11px] text-muted-foreground">字符预算使用</div>
+            </div>
+            <div className="ml-9">
+              <BudgetBar
+                used={contextInfo.contextBudget.used}
+                limit={contextInfo.contextBudget.limit}
+              />
+            </div>
+          </div>
+        </>
+      )}
+
+      {contextInfo.resultProtocol && (
+        <>
+          <div className="my-1 h-px bg-border/60" />
+          <div className="py-2">
+            <div className="mb-2 flex items-center gap-2">
+              <div className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-md ${
+                contextInfo.resultProtocol.valid
+                  ? "bg-green-100 text-green-600 dark:bg-green-950/40 dark:text-green-400"
+                  : "bg-red-100 text-red-600 dark:bg-red-950/40 dark:text-red-400"
+              }`}>
+                {contextInfo.resultProtocol.valid
+                  ? <CheckCircle2 className="h-3.5 w-3.5" />
+                  : <XCircle className="h-3.5 w-3.5" />}
+              </div>
+              <div className="text-[11px] text-muted-foreground">
+                输出协议校验
+                <span className={`ml-1.5 font-medium ${
+                  contextInfo.resultProtocol.valid
+                    ? "text-green-600 dark:text-green-400"
+                    : "text-red-600 dark:text-red-400"
+                }`}>
+                  {contextInfo.resultProtocol.valid ? "通过" : "未通过"}
+                </span>
+              </div>
+            </div>
+            <div className="ml-9 space-y-2">
+              <div className="flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-muted-foreground">
+                {contextInfo.resultProtocol.wordCount != null && (
+                  <span>字数:{contextInfo.resultProtocol.wordCount.toLocaleString()}</span>
+                )}
+                {contextInfo.resultProtocol.nodeCount != null && (
+                  <span>节点数:{contextInfo.resultProtocol.nodeCount}</span>
+                )}
+                {contextInfo.resultProtocol.hasFrontmatter != null && (
+                  <span>
+                    Frontmatter:{contextInfo.resultProtocol.hasFrontmatter ? "有" : "无"}
+                  </span>
+                )}
+                {contextInfo.resultProtocol.hasTitle != null && (
+                  <span>
+                    标题:{contextInfo.resultProtocol.hasTitle ? "有" : "无"}
+                  </span>
+                )}
+              </div>
+              {contextInfo.resultProtocol.warnings.length > 0 && (
+                <div className="rounded-md border border-amber-200 bg-amber-50/60 p-2 dark:border-amber-900/40 dark:bg-amber-950/20">
+                  <div className="mb-1 text-[11px] font-medium text-amber-700 dark:text-amber-400">
+                    警告({contextInfo.resultProtocol.warnings.length})
+                  </div>
+                  <ul className="space-y-0.5">
+                    {contextInfo.resultProtocol.warnings.map((w, i) => (
+                      <li key={i} className="text-[11px] text-amber-800 dark:text-amber-300">
+                        · {w}
+                      </li>
+                    ))}
+                  </ul>
+                </div>
+              )}
+              {contextInfo.resultProtocol.errors.length > 0 && (
+                <div className="rounded-md border border-red-200 bg-red-50/60 p-2 dark:border-red-900/40 dark:bg-red-950/20">
+                  <div className="mb-1 text-[11px] font-medium text-red-700 dark:text-red-400">
+                    错误({contextInfo.resultProtocol.errors.length})
+                  </div>
+                  <ul className="space-y-0.5">
+                    {contextInfo.resultProtocol.errors.map((e, i) => (
+                      <li key={i} className="text-[11px] text-red-800 dark:text-red-300">
+                        · {e}
+                      </li>
+                    ))}
+                  </ul>
+                </div>
+              )}
+            </div>
+          </div>
+        </>
+      )}
+
+      
+      {contextInfo.postWriteCheck && (
+        <>
+          <div className="my-1 h-px bg-border/60" />
+          <div className="py-2">
+            <div className="text-xs font-medium text-foreground">
+              <span className="inline-flex items-center gap-1">
+                <CheckCircle2 className="h-3.5 w-3.5 text-emerald-500" />
+                写后自检
+              </span>
+              {contextInfo.postWriteCheckMeta?.source === "ai" && (
+                <span className="ml-2 inline-flex items-center rounded-full border border-blue-300/70 bg-blue-50 px-2 py-0.5 text-[10px] font-medium text-blue-700 dark:border-blue-800/60 dark:bg-blue-950/40 dark:text-blue-300">
+                  AI 推理
+                </span>
+              )}
+              {contextInfo.postWriteCheckMeta?.source === "rule" && (
+                <span className="ml-2 inline-flex items-center rounded-full border border-gray-300/70 bg-gray-50 px-2 py-0.5 text-[10px] font-medium text-gray-700 dark:border-gray-800/60 dark:bg-gray-950/40 dark:text-gray-300">
+                  规则检查
+                </span>
+              )}
+              <span className="ml-2 text-muted-foreground">
+                {contextInfo.postWriteCheck.passedCount}/{contextInfo.postWriteCheck.totalCount} 通过
+                {contextInfo.postWriteCheck.allPassed
+                  ? <span className="ml-1 text-emerald-600 dark:text-emerald-400">全部通过</span>
+                  : <span className="ml-1 text-amber-600 dark:text-amber-400">有未通过项</span>}
+              </span>
+            </div>
+            {contextInfo.postWriteCheckMeta?.fallbackReason && (
+              <div className="mt-1 text-[11px] text-muted-foreground">
+                降级原因:{contextInfo.postWriteCheckMeta.fallbackReason}
+              </div>
+            )}
+            <div className="mt-1.5 space-y-1.5">
+              {contextInfo.postWriteCheck.items.map((item) => (
+                <div key={item.name}>
+                  <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
+                    {item.passed
+                      ? <span className="text-emerald-500 dark:text-emerald-400">✓</span>
+                      : <span className="text-red-500 dark:text-red-400">✗</span>}
+                    <span>{item.name}</span>
+                    {!item.passed && <span className="text-muted-foreground/70">: {item.detail}</span>}
+                  </div>
+                  {item.evidence && (
+                    <div className="ml-5 mt-1 text-[11px] text-muted-foreground">
+                      <span className="font-medium">依据:</span>{item.evidence}
+                    </div>
+                  )}
+                  {item.suggestion && (
+                    <div className="ml-5 mt-0.5 text-[11px] text-muted-foreground">
+                      <span className="font-medium">建议:</span>{item.suggestion}
+                    </div>
+                  )}
+                </div>
+              ))}
+            </div>
+          </div>
+        </>
+      )}
+
+{contextInfo.classificationVersion && contextInfo.classificationVersion.needsUpgrade && (
+        <>
+          <div className="my-1 h-px bg-border/60" />
+          <div className="py-2">
+            <div className="mb-2 flex items-center gap-2">
+              <div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-amber-100 text-amber-600 dark:bg-amber-950/40 dark:text-amber-400">
+                <AlertTriangle className="h-3.5 w-3.5" />
+              </div>
+              <div className="text-[11px] text-muted-foreground">分类路由版本</div>
+            </div>
+            <div className="ml-9 rounded-md border border-amber-200 bg-amber-50/60 p-2 text-[12px] text-amber-800 dark:border-amber-900/40 dark:bg-amber-950/20 dark:text-amber-300">
+              <div className="mb-2">
+                classification.md 版本过旧(当前 v{contextInfo.classificationVersion.currentVersion},最新 v{contextInfo.classificationVersion.latestVersion}),可能缺少新增的意图路由配置。
+              </div>
+              {upgradeStatus === "idle" && projectPath && (
+                <button
+                  type="button"
+                  onClick={() => setUpgradeStatus("confirming")}
+                  className="inline-flex items-center gap-1 rounded-md bg-amber-500 px-2 py-1 text-[11px] font-medium text-white transition-colors hover:bg-amber-600 dark:bg-amber-600 dark:hover:bg-amber-500"
+                >
+                  <RefreshCw className="h-3 w-3" />
+                  一键升级到最新版本
+                </button>
+              )}
+              {upgradeStatus === "confirming" && (
+                <div className="space-y-2">
+                  <div className="text-[11px]">
+                    确定要升级 classification.md 吗?升级会在现有配置基础上补充新增的意图路由,不会修改你已有的自定义配置。
+                  </div>
+                  <div className="flex gap-2">
+                    <button
+                      type="button"
+                      onClick={handleUpgradeClassification}
+                      className="inline-flex items-center gap-1 rounded-md bg-amber-500 px-2 py-1 text-[11px] font-medium text-white transition-colors hover:bg-amber-600 dark:bg-amber-600 dark:hover:bg-amber-500"
+                    >
+                      <CheckCircle2 className="h-3 w-3" />
+                      确认升级
+                    </button>
+                    <button
+                      type="button"
+                      onClick={() => setUpgradeStatus("idle")}
+                      className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-1 text-[11px] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
+                    >
+                      <X className="h-3 w-3" />
+                      取消
+                    </button>
+                  </div>
+                </div>
+              )}
+              {upgradeStatus === "loading" && (
+                <div className="inline-flex items-center gap-1 text-[11px] text-amber-700 dark:text-amber-300">
+                  <Loader2 className="h-3 w-3 animate-spin" />
+                  正在升级...
+                </div>
+              )}
+              {upgradeStatus === "success" && (
+                <div className="inline-flex items-center gap-1 text-[11px] text-green-700 dark:text-green-400">
+                  <CheckCircle2 className="h-3 w-3" />
+                  升级成功!下次 AI 会话将使用最新路由配置
+                </div>
+              )}
+              {upgradeStatus === "error" && upgradeError && (
+                <div className="text-[11px] text-red-700 dark:text-red-400">
+                  升级失败:{upgradeError}
+                </div>
+              )}
+              {!projectPath && upgradeStatus === "idle" && (
+                <div className="text-[11px] text-amber-700 dark:text-amber-300">
+                  请在项目中使用此功能
+                </div>
+              )}
+            </div>
+          </div>
+        </>
+      )}
+
+      {contextInfo.fallbackReason && !contextInfo.classificationVersion?.needsUpgrade && (
+        <>
+          <div className="my-1 h-px bg-border/60" />
+          <div className="py-2">
+            <div className="mb-2 flex items-center gap-2">
+              <div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-amber-100 text-amber-600 dark:bg-amber-950/40 dark:text-amber-400">
+                <AlertTriangle className="h-3.5 w-3.5" />
+              </div>
+              <div className="text-[11px] text-muted-foreground">兜底/降级原因</div>
+            </div>
+            <div className="ml-9 rounded-md border border-amber-200 bg-amber-50/60 p-2 text-[12px] text-amber-800 dark:border-amber-900/40 dark:bg-amber-950/20 dark:text-amber-300">
+              {contextInfo.fallbackReason}
+            </div>
+          </div>
+        </>
+      )}
+
+      {contextInfo.fallbackReason && contextInfo.classificationVersion?.needsUpgrade && (
+        <div className="ml-9 mt-1 rounded-md border border-amber-200 bg-amber-50/60 p-2 text-[12px] text-amber-800 dark:border-amber-900/40 dark:bg-amber-950/20 dark:text-amber-300">
+          {contextInfo.fallbackReason}
+        </div>
+      )}
+
+      <div className="my-1 h-px bg-border/60" />
+      <RetrievalIndexSection
+        projectPath={projectPath}
+        hasIndex={retrievalIndexHasIndex}
+        isRebuilding={isRebuildingRetrievalIndex}
+        lastResult={lastRebuildResult}
+        onRebuild={onRebuildRetrievalIndex}
+      />
+    </div>
+  )
+}
+
+function TimelineTab({ trace }: { trace: ContextTrace }) {
+  if (trace.toolCalls.length === 0) {
+    return (
+      <div className="flex flex-col items-center justify-center py-12 text-center">
+        <Zap className="mb-3 h-10 w-10 text-muted-foreground/40" />
+        <p className="text-sm text-muted-foreground">暂无工具调用记录</p>
+      </div>
+    )
+  }
+
+  const adaptedCalls = trace.toolCalls.map((call) => ({
+    ...call,
+    result: call.result ?? "",
+    finishedAt: call.finishedAt ?? call.startedAt,
+  }))
+
+  return (
+    <div className="py-1">
+      <ToolCallTimeline toolCalls={adaptedCalls as any} defaultExpanded={false} />
+    </div>
+  )
+}
+
+function CollapsiblePanel({
+  title,
+  icon: Icon,
+  children,
+  defaultCollapsed = false,
+  rightContent,
+}: {
+  title: string
+  icon: React.ComponentType<{ className?: string }>
+  children: React.ReactNode
+  defaultCollapsed?: boolean
+  rightContent?: React.ReactNode
+}) {
+  const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed)
+  const contentRef = useRef<HTMLDivElement>(null)
+  const [contentHeight, setContentHeight] = useState<number | "auto">("auto")
+
+  useEffect(() => {
+    if (contentRef.current && !isCollapsed) {
+      setContentHeight(contentRef.current.scrollHeight)
+    }
+  }, [children, isCollapsed])
+
+  return (
+    <div className="overflow-hidden rounded-lg border bg-background shadow-lg">
+      <div className="flex items-center justify-between border-b px-4 py-2.5">
+        <button
+          type="button"
+          onClick={() => setIsCollapsed(!isCollapsed)}
+          className="flex flex-1 items-center gap-2.5 text-left"
+        >
+          <Icon className="h-4 w-4 text-primary" />
+          <span className="text-sm font-medium">{title}</span>
+          {rightContent}
+          <div className="ml-auto flex items-center gap-2">
+            {isCollapsed ? (
+              <ChevronDown className="h-4 w-4 text-muted-foreground transition-transform duration-300" />
+            ) : (
+              <ChevronUp className="h-4 w-4 text-muted-foreground transition-transform duration-300" />
+            )}
+          </div>
+        </button>
+      </div>
+      <div
+        className="transition-all duration-300 ease-in-out overflow-hidden"
+        style={{
+          maxHeight: isCollapsed ? 0 : contentHeight === "auto" ? "1000px" : `${contentHeight}px`,
+          opacity: isCollapsed ? 0 : 1,
+        }}
+      >
+        <div ref={contentRef}>{children}</div>
+      </div>
+    </div>
+  )
+}
+
+function CopyTraceButton({ trace }: { trace: ContextTrace }) {
+  const [copied, setCopied] = useState(false)
+
+  const handleCopy = async () => {
+    try {
+      const json = JSON.stringify(trace, null, 2)
+      await navigator.clipboard.writeText(json)
+      setCopied(true)
+      setTimeout(() => setCopied(false), 2000)
+    } catch (err) {
+      console.error("复制失败:", err)
+    }
+  }
+
+  return (
+    <button
+      type="button"
+      onClick={handleCopy}
+      className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-[11px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
+      title="复制调试信息"
+    >
+      {copied ? (
+        <>
+          <Check className="h-3 w-3 text-green-500" />
+          <span className="text-green-600 dark:text-green-400">已复制</span>
+        </>
+      ) : (
+        <>
+          <Copy className="h-3 w-3" />
+          <span>复制调试信息</span>
+        </>
+      )}
+    </button>
+  )
+}
+
+export function ContextTracePanel({
+  trace,
+  projectPath,
+  onClose,
+  className,
+  onRebuildRetrievalIndex,
+  retrievalIndexHasIndex,
+  isRebuildingRetrievalIndex,
+  lastRebuildResult,
+}: ContextTracePanelProps) {
+  const [activeTab, setActiveTab] = useState<TabType>("overview")
+
+  if (!trace) return null
+
+  const duration = formatDuration(trace.startedAt, trace.finishedAt)
+
+  return (
+    <div className={cn("", className)}>
+      <CollapsiblePanel
+        title="上下文追踪"
+        icon={Layers}
+        defaultCollapsed={false}
+        rightContent={
+          <div className="flex items-center gap-2">
+            <StatusBadge status={trace.status} />
+            <div className="flex items-center gap-1 text-[11px] text-muted-foreground">
+              <Clock className="h-3 w-3" />
+              <span className="tabular-nums">{duration}</span>
+            </div>
+            <CopyTraceButton trace={trace} />
+            {onClose && (
+              <button
+                type="button"
+                onClick={onClose}
+                className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
+                aria-label="关闭"
+              >
+                <X className="h-3.5 w-3.5" />
+              </button>
+            )}
+          </div>
+        }
+      >
+        <div className="flex border-b bg-muted/30">
+          <button
+            type="button"
+            onClick={() => setActiveTab("overview")}
+            className={cn(
+              "flex flex-1 items-center justify-center gap-1.5 px-3 py-2 text-xs font-medium transition-colors border-b-2 -mb-px",
+              activeTab === "overview"
+                ? "text-primary border-primary"
+                : "text-muted-foreground border-transparent hover:text-foreground hover:bg-accent/30",
+            )}
+          >
+            <Layers className="h-3.5 w-3.5" />
+            概览
+          </button>
+          <button
+            type="button"
+            onClick={() => setActiveTab("timeline")}
+            className={cn(
+              "flex flex-1 items-center justify-center gap-1.5 px-3 py-2 text-xs font-medium transition-colors border-b-2 -mb-px",
+              activeTab === "timeline"
+                ? "text-primary border-primary"
+                : "text-muted-foreground border-transparent hover:text-foreground hover:bg-accent/30",
+            )}
+          >
+            <Zap className="h-3.5 w-3.5" />
+            工具调用时间线
+            <span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] tabular-nums">
+              {trace.toolCalls.length}
+            </span>
+          </button>
+        </div>
+
+        <div className="max-h-96 overflow-y-auto px-4 py-2">
+          {activeTab === "overview" ? (
+            <OverviewTab
+              contextInfo={trace.contextInfo}
+              projectPath={projectPath}
+              onRebuildRetrievalIndex={onRebuildRetrievalIndex}
+              retrievalIndexHasIndex={retrievalIndexHasIndex}
+              isRebuildingRetrievalIndex={isRebuildingRetrievalIndex}
+              lastRebuildResult={lastRebuildResult}
+            />
+          ) : (
+            <TimelineTab trace={trace} />
+          )}
+        </div>
+      </CollapsiblePanel>
+    </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")
   })

+ 149 - 0
src/components/chat/intent-clarification-dialog.tsx

@@ -0,0 +1,149 @@
+import { useEffect, useState } from "react"
+import { HelpCircle, X, MessageCircle } from "lucide-react"
+import type { NovelTaskIntent } from "@/lib/novel/task-router"
+
+export interface ClarificationCandidate {
+  intent: NovelTaskIntent
+  label: string
+  confidence?: number
+}
+
+interface IntentClarificationDialogProps {
+  open: boolean
+  userMessage: string
+  candidates: ClarificationCandidate[]
+  onConfirm: (intent: NovelTaskIntent | null, customText?: string) => void
+  onCancel: () => void
+}
+
+export function IntentClarificationDialog({
+  open,
+  userMessage,
+  candidates,
+  onConfirm,
+  onCancel,
+}: IntentClarificationDialogProps) {
+  const [customMode, setCustomMode] = useState(false)
+  const [customText, setCustomText] = useState("")
+
+  useEffect(() => {
+    if (open) {
+      setCustomMode(false)
+      setCustomText("")
+    }
+  }, [open])
+
+  if (!open) return null
+
+  const handleSelect = (intent: NovelTaskIntent) => {
+    onConfirm(intent)
+  }
+
+  const handleCustomSubmit = () => {
+    if (customText.trim()) {
+      onConfirm(null, customText.trim())
+    }
+  }
+
+  return (
+    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
+      <div className="flex w-full max-w-md flex-col rounded-lg border bg-background shadow-2xl">
+        <div className="flex items-center justify-between border-b px-4 py-3">
+          <div className="flex items-center gap-2">
+            <HelpCircle className="h-5 w-5 text-amber-500" />
+            <div>
+              <h3 className="font-semibold">请确认你的意图</h3>
+              <p className="text-xs text-muted-foreground">
+                我不太确定你想要做什么,请选择一个选项
+              </p>
+            </div>
+          </div>
+          <button
+            onClick={onCancel}
+            className="rounded-md p-1 hover:bg-accent"
+            aria-label="关闭"
+          >
+            <X className="h-4 w-4" />
+          </button>
+        </div>
+
+        <div className="border-b px-4 py-3">
+          <div className="rounded-md bg-muted p-3 text-sm">
+            <div className="mb-1 flex items-center gap-1.5 text-xs text-muted-foreground">
+              <MessageCircle className="h-3.5 w-3.5" />
+              <span>你的输入</span>
+            </div>
+            <p className="line-clamp-3">{userMessage || "(空)"}</p>
+          </div>
+        </div>
+
+        <div className="max-h-80 overflow-y-auto p-3">
+          {!customMode ? (
+            <div className="space-y-2">
+              {candidates.map((candidate, index) => (
+                <button
+                  key={index}
+                  onClick={() => handleSelect(candidate.intent)}
+                  className="flex w-full items-center justify-between rounded-md border px-3 py-2.5 text-left transition-colors hover:border-primary hover:bg-accent"
+                >
+                  <span className="font-medium">{candidate.label}</span>
+                  {candidate.confidence !== undefined && candidate.confidence > 0 && (
+                    <span className="text-xs text-muted-foreground">
+                      置信度 {Math.round(candidate.confidence * 100)}%
+                    </span>
+                  )}
+                </button>
+              ))}
+              <button
+                onClick={() => setCustomMode(true)}
+                className="flex w-full items-center justify-center gap-2 rounded-md border border-dashed px-3 py-2.5 text-sm text-muted-foreground transition-colors hover:border-primary hover:text-foreground"
+              >
+                我想描述得更清楚一些...
+              </button>
+            </div>
+          ) : (
+            <div className="space-y-3">
+              <button
+                onClick={() => setCustomMode(false)}
+                className="text-sm text-primary hover:underline"
+              >
+                ← 返回选择
+              </button>
+              <textarea
+                value={customText}
+                onChange={(e) => setCustomText(e.target.value)}
+                placeholder="请更详细地描述你想做什么..."
+                className="h-32 w-full resize-none rounded-md border bg-background px-3 py-2 text-sm focus:border-primary focus:outline-none"
+                autoFocus
+              />
+              <div className="flex justify-end gap-2">
+                <button
+                  onClick={onCancel}
+                  className="rounded-md border px-3 py-1.5 text-sm hover:bg-accent"
+                >
+                  取消
+                </button>
+                <button
+                  onClick={handleCustomSubmit}
+                  disabled={!customText.trim()}
+                  className="rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
+                >
+                  确认
+                </button>
+              </div>
+            </div>
+          )}
+        </div>
+
+        <div className="border-t px-4 py-3">
+          <button
+            onClick={onCancel}
+            className="w-full rounded-md border px-3 py-2 text-sm hover:bg-accent"
+          >
+            取消
+          </button>
+        </div>
+      </div>
+    </div>
+  )
+}

+ 257 - 0
src/components/chat/modify-confirm-dialog.tsx

@@ -0,0 +1,257 @@
+import { useState, useMemo, useEffect } from "react"
+import { X, Check, FileText, GitCompare, Edit3, Eye, BookOpen, Brain } from "lucide-react"
+import { computeLineDiff } from "@/lib/utils/diff"
+
+type ModifyConfirmType = "chapter" | "outline" | "memory" | "classification" | "breakpoint"
+
+interface ModifyConfirmDialogProps {
+  open: boolean
+  originalContent: string
+  modifiedContent: string
+  itemName: string
+  intentLabel: string
+  type?: ModifyConfirmType
+  editable?: boolean
+  onConfirm: (finalContent: string) => void
+  onCancel: () => void
+}
+
+type ViewMode = "diff" | "split" | "unified"
+
+const TYPE_CONFIG: Record<ModifyConfirmType, {
+  title: string
+  icon: typeof FileText
+  iconColor: string
+  originalLabel: string
+  modifiedLabel: string
+  confirmLabel: string
+}> = {
+  chapter: {
+    title: "确认修改章节",
+    icon: FileText,
+    iconColor: "text-amber-500",
+    originalLabel: "原文",
+    modifiedLabel: "修改后",
+    confirmLabel: "确认保存",
+  },
+  outline: {
+    title: "确认写入大纲",
+    icon: BookOpen,
+    iconColor: "text-blue-500",
+    originalLabel: "原大纲",
+    modifiedLabel: "新内容",
+    confirmLabel: "确认保存",
+  },
+  memory: {
+    title: "确认写入记忆",
+    icon: Brain,
+    iconColor: "text-purple-500",
+    originalLabel: "原记忆",
+    modifiedLabel: "新内容",
+    confirmLabel: "确认保存",
+  },
+  classification: {
+    title: "确认恢复 classification.md",
+    icon: FileText,
+    iconColor: "text-emerald-500",
+    originalLabel: "当前配置",
+    modifiedLabel: "默认配置",
+    confirmLabel: "确认保存",
+  },
+  breakpoint: {
+    title: "继续未完成任务",
+    icon: Brain,
+    iconColor: "text-blue-500",
+    originalLabel: "断点摘要",
+    modifiedLabel: "恢复提示词",
+    confirmLabel: "继续执行",
+  },
+}
+
+export function ModifyConfirmDialog({
+  open,
+  originalContent,
+  modifiedContent,
+  itemName,
+  intentLabel,
+  type = "chapter",
+  editable = true,
+  onConfirm,
+  onCancel,
+}: ModifyConfirmDialogProps) {
+  const [viewMode, setViewMode] = useState<ViewMode>("diff")
+  const [editedContent, setEditedContent] = useState(modifiedContent)
+  const config = TYPE_CONFIG[type]
+  const Icon = config.icon
+
+  useEffect(() => {
+    if (open) {
+      setEditedContent(modifiedContent)
+      setViewMode("diff")
+    }
+  }, [open, modifiedContent])
+
+  const diffLines = useMemo(
+    () => computeLineDiff(originalContent, editedContent),
+    [originalContent, editedContent],
+  )
+
+  const stats = useMemo(() => {
+    let adds = 0
+    let removes = 0
+    for (const l of diffLines) {
+      if (l.type === "add") adds++
+      else if (l.type === "remove") removes++
+    }
+    return { adds, removes }
+  }, [diffLines])
+
+  const isNew = !originalContent.trim()
+
+  if (!open) return null
+
+  return (
+    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
+      <div className="flex h-[85vh] w-full max-w-6xl flex-col rounded-lg border bg-background shadow-2xl">
+        <div className="flex items-center justify-between border-b px-4 py-3">
+          <div className="flex items-center gap-2">
+            <Icon className={`h-5 w-5 ${config.iconColor}`} />
+            <div>
+              <h3 className="font-semibold">{config.title}</h3>
+              <p className="text-xs text-muted-foreground">
+                {itemName} · {intentLabel}
+              </p>
+            </div>
+          </div>
+          <div className="flex items-center gap-3">
+            <span className="text-xs text-muted-foreground">
+              <span className="text-green-600 dark:text-green-400">+{stats.adds}</span>
+              {" / "}
+              <span className="text-red-500">-{stats.removes}</span>
+              {" 行"}
+            </span>
+            <button
+              onClick={onCancel}
+              className="rounded-md p-1 hover:bg-accent"
+              aria-label="关闭"
+            >
+              <X className="h-4 w-4" />
+            </button>
+          </div>
+        </div>
+
+        <div className="flex gap-1 border-b px-4 py-2">
+          <button
+            onClick={() => setViewMode("diff")}
+            className={`flex items-center gap-1 rounded px-2 py-1 text-xs ${
+              viewMode === "diff" ? "bg-accent font-medium" : "hover:bg-accent/50"
+            }`}
+          >
+            <GitCompare className="h-3.5 w-3.5" />
+            Diff 视图
+          </button>
+          <button
+            onClick={() => setViewMode("split")}
+            className={`flex items-center gap-1 rounded px-2 py-1 text-xs ${
+              viewMode === "split" ? "bg-accent font-medium" : "hover:bg-accent/50"
+            }`}
+          >
+            <Eye className="h-3.5 w-3.5" />
+            左右对比
+          </button>
+          {editable && (
+            <button
+              onClick={() => setViewMode("unified")}
+              className={`flex items-center gap-1 rounded px-2 py-1 text-xs ${
+                viewMode === "unified" ? "bg-accent font-medium" : "hover:bg-accent/50"
+              }`}
+            >
+              <Edit3 className="h-3.5 w-3.5" />
+              编辑模式
+            </button>
+          )}
+          {isNew && (
+            <span className="ml-auto text-xs text-blue-600 dark:text-blue-400">
+              新建
+            </span>
+          )}
+        </div>
+
+        <div className="flex-1 overflow-hidden">
+          {viewMode === "diff" && (
+            <div className="h-full overflow-auto p-4 font-mono text-xs leading-relaxed">
+              {diffLines.map((line, idx) => (
+                <div
+                  key={idx}
+                  className={`whitespace-pre-wrap px-2 py-0.5 ${
+                    line.type === "add"
+                      ? "bg-green-100 text-green-800 dark:bg-green-950/50 dark:text-green-300"
+                      : line.type === "remove"
+                        ? "bg-red-100 text-red-800 dark:bg-red-950/50 dark:text-red-300"
+                        : "text-muted-foreground"
+                  }`}
+                >
+                  <span className="mr-2 inline-block w-5 select-none text-right opacity-50">
+                    {line.type === "add" ? "+" : line.type === "remove" ? "-" : " "}
+                  </span>
+                  {line.content || " "}
+                </div>
+              ))}
+            </div>
+          )}
+
+          {viewMode === "split" && (
+            <div className="grid h-full grid-cols-2 divide-x">
+              <div className="flex flex-col overflow-hidden">
+                <div className="border-b px-3 py-1.5 text-xs font-medium text-muted-foreground">
+                  {config.originalLabel}
+                  {isNew && <span className="ml-2 text-muted-foreground">(无)</span>}
+                </div>
+                <div className="flex-1 overflow-auto p-3 font-mono text-xs leading-relaxed whitespace-pre-wrap">
+                  {originalContent || "(暂无内容)"}
+                </div>
+              </div>
+              <div className="flex flex-col overflow-hidden">
+                <div className="border-b px-3 py-1.5 text-xs font-medium text-green-700 dark:text-green-400">
+                  {config.modifiedLabel}
+                </div>
+                <div className="flex-1 overflow-auto p-3 font-mono text-xs leading-relaxed whitespace-pre-wrap text-green-800 dark:text-green-300">
+                  {editedContent}
+                </div>
+              </div>
+            </div>
+          )}
+
+          {viewMode === "unified" && editable && (
+            <div className="flex h-full flex-col">
+              <div className="border-b px-3 py-1.5 text-xs font-medium text-amber-700 dark:text-amber-400">
+                编辑模式 - 可直接修改后确认保存
+              </div>
+              <textarea
+                value={editedContent}
+                onChange={(e) => setEditedContent(e.target.value)}
+                className="flex-1 resize-none p-3 font-mono text-xs leading-relaxed bg-background focus:outline-none"
+              />
+            </div>
+          )}
+        </div>
+
+        <div className="flex items-center justify-end gap-2 border-t px-4 py-3">
+          <button
+            onClick={onCancel}
+            className="rounded-md border px-4 py-1.5 text-sm hover:bg-accent"
+          >
+            取消
+          </button>
+          <button
+            onClick={() => onConfirm(editedContent)}
+            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>
+    </div>
+  )
+}

+ 543 - 0
src/components/chat/tool-call-timeline.tsx

@@ -0,0 +1,543 @@
+import { useMemo, useState } from "react"
+import { BookOpen, ChevronDown, ChevronRight, Loader2, CheckCircle2, Pencil, XCircle, Zap, Cpu, Minimize2, Maximize2, AlertTriangle, Search, Filter } from "lucide-react"
+import type { AgentRunRecord } from "@/lib/agent/types"
+import { cn } from "@/lib/utils"
+
+export type ToolCallRecord = AgentRunRecord["toolCalls"][number]
+
+interface ToolCallTimelineProps {
+  toolCalls: ToolCallRecord[] | undefined
+  onConfirmSave?: (call: ToolCallRecord & { preview?: string }) => void
+  onReject?: (call: ToolCallRecord & { preview?: string }) => void
+  defaultExpanded?: boolean
+}
+
+const READ_TOOLS = new Set([
+  "read_chapter",
+  "read_outline",
+  "read_memory",
+  "read_deduction",
+  "read_chat_history",
+  "read_outline_history",
+  "search_chapters",
+  "list_chapters",
+  "list_outlines",
+  "list_memories",
+  "list_deductions",
+])
+
+const WRITE_TOOLS = new Set([
+  "write_chapter",
+  "write_outline_node",
+  "write_memory",
+])
+
+const ACTION_TOOLS = new Set([
+  "apply_skill",
+])
+
+const VIRTUAL_TOOLS = new Set([
+  "route_task",
+  "load_context",
+  "trim_context",
+])
+
+function getToolCategory(name: string): "read" | "write" | "action" | "virtual" {
+  if (READ_TOOLS.has(name)) return "read"
+  if (WRITE_TOOLS.has(name)) return "write"
+  if (ACTION_TOOLS.has(name)) return "action"
+  if (VIRTUAL_TOOLS.has(name)) return "virtual"
+  return "read"
+}
+
+function formatWordCount(content?: unknown): string {
+  if (typeof content !== "string") return ""
+  const count = content.length
+  return count > 0 ? `(${count}字)` : ""
+}
+
+function formatDuration(startedAt: number, finishedAt: number): string {
+  const ms = finishedAt - startedAt
+  if (ms <= 0) return ""
+  if (ms < 1000) return `(${(ms / 1000).toFixed(1)}s)`
+  if (ms < 60000) return `(${(ms / 1000).toFixed(1)}s)`
+  const seconds = Math.floor(ms / 1000)
+  const minutes = Math.floor(seconds / 60)
+  const secs = seconds % 60
+  return `(${minutes}m${secs}s)`
+}
+
+export function getToolCallDescription(name: string, params: Record<string, unknown>): string {
+  switch (name) {
+    case "read_chapter": {
+      const target = params.name || params.path
+      return `读取章节《${target}》`
+    }
+    case "read_outline": {
+      const target = params.name || params.path
+      return `读取大纲《${target}》`
+    }
+    case "read_memory":
+      return `读取记忆「${params.name}」`
+    case "read_deduction":
+      return `读取推演结果「${params.name}」`
+    case "read_chat_history": {
+      const target = params.conversationId || params.conversationTitle
+      return `读取会话历史「${target}」`
+    }
+    case "read_outline_history":
+      return `读取大纲会话「${params.conversationId}」`
+    case "search_chapters":
+      return `搜索章节关键词「${params.keyword}」`
+    case "list_chapters":
+      return "列出所有章节"
+    case "list_outlines":
+      return "列出所有大纲"
+    case "list_memories":
+      return "列出所有记忆"
+    case "list_deductions":
+      return "列出所有推演结果"
+    case "write_chapter":
+      return `写入章节《${params.name}》${formatWordCount(params.content)}`
+    case "write_outline_node":
+      return `写入大纲节点「${params.nodeTitle}」到「${params.outlineName}」`
+    case "write_memory":
+      return `写入记忆「${params.name}」${formatWordCount(params.content)}`
+    case "apply_skill": {
+      const target = params.skillName || params.skillId
+      return `应用技能「${target}」`
+    }
+    case "route_task":
+      return `任务意图识别:${params.intent || "识别中"}`
+    case "load_context":
+      return `加载小说上下文${params.chapterNumber ? `(第${params.chapterNumber}章)` : ""}`
+    case "trim_context":
+      return `裁剪上下文至 ${params.targetChars || "默认"} 字符`
+    default:
+      return name
+  }
+}
+
+function getCategoryStyles(category: "read" | "write" | "action" | "virtual") {
+  switch (category) {
+    case "read":
+      return {
+        dot: "bg-blue-500",
+        line: "bg-blue-200/60 dark:bg-blue-900/30",
+        card: "border-blue-200 bg-blue-50/50 dark:border-blue-900/40 dark:bg-blue-950/20",
+        cardBorderStyle: "border-solid",
+        icon: "text-blue-500",
+        text: "text-blue-900 dark:text-blue-300",
+        muted: "text-blue-700/70 dark:text-blue-400/70",
+      }
+    case "write":
+      return {
+        dot: "bg-amber-500",
+        line: "bg-amber-200/60 dark:bg-amber-900/30",
+        card: "border-amber-200 bg-amber-50/50 dark:border-amber-900/40 dark:bg-amber-950/20",
+        cardBorderStyle: "border-solid",
+        icon: "text-amber-500",
+        text: "text-amber-900 dark:text-amber-300",
+        muted: "text-amber-700/70 dark:text-amber-400/70",
+      }
+    case "action":
+      return {
+        dot: "bg-purple-500",
+        line: "bg-purple-200/60 dark:bg-purple-900/30",
+        card: "border-purple-200 bg-purple-50/50 dark:border-purple-900/40 dark:bg-purple-950/20",
+        cardBorderStyle: "border-solid",
+        icon: "text-purple-500",
+        text: "text-purple-900 dark:text-purple-300",
+        muted: "text-purple-700/70 dark:text-purple-400/70",
+      }
+    case "virtual":
+      return {
+        dot: "bg-cyan-500",
+        line: "bg-cyan-200/40 dark:bg-cyan-900/20",
+        card: "border-cyan-300/60 bg-cyan-50/30 dark:border-cyan-800/40 dark:bg-cyan-950/10",
+        cardBorderStyle: "border-dashed",
+        icon: "text-cyan-500",
+        text: "text-cyan-900 dark:text-cyan-300",
+        muted: "text-cyan-700/70 dark:text-cyan-400/70",
+      }
+  }
+}
+
+function ToolCallIcon({ category, status }: { category: "read" | "write" | "action" | "virtual"; status: string }) {
+  if (status === "error") {
+    return <XCircle className="h-4 w-4 shrink-0 text-red-500" />
+  }
+  if (status === "cancelled") {
+    return <XCircle className="h-4 w-4 shrink-0 text-muted-foreground/60" />
+  }
+  if (status === "running") {
+    return <Loader2 className="h-4 w-4 shrink-0 animate-spin text-primary" />
+  }
+  if (status === "done") {
+    return <CheckCircle2 className="h-4 w-4 shrink-0 text-green-500" />
+  }
+  if (status === "approval_required") {
+    return <AlertTriangle className="h-4 w-4 shrink-0 text-amber-500" />
+  }
+  switch (category) {
+    case "read":
+      return <BookOpen className="h-4 w-4 shrink-0 text-blue-500" />
+    case "write":
+      return <Pencil className="h-4 w-4 shrink-0 text-amber-500" />
+    case "action":
+      return <Zap className="h-4 w-4 shrink-0 text-purple-500" />
+    case "virtual":
+      return <Cpu className="h-4 w-4 shrink-0 text-cyan-500" />
+  }
+}
+
+function StatusBadge({ status }: { status: string }) {
+  if (status === "running") {
+    return (
+      <span className="rounded border border-blue-300/70 bg-blue-100 px-1.5 py-0.5 text-[10px] font-medium text-blue-800 dark:border-blue-800/60 dark:bg-blue-950/50 dark:text-blue-300">
+        运行中
+      </span>
+    )
+  }
+  if (status === "done") {
+    return (
+      <span className="rounded border border-green-300/70 bg-green-100 px-1.5 py-0.5 text-[10px] font-medium text-green-800 dark:border-green-800/60 dark:bg-green-950/50 dark:text-green-300">
+        完成
+      </span>
+    )
+  }
+  if (status === "error") {
+    return (
+      <span className="rounded border border-red-300/70 bg-red-100 px-1.5 py-0.5 text-[10px] font-medium text-red-800 dark:border-red-800/60 dark:bg-red-950/50 dark:text-red-300">
+        错误
+      </span>
+    )
+  }
+  if (status === "cancelled") {
+    return (
+      <span className="rounded border border-muted-foreground/30 bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
+        已取消
+      </span>
+    )
+  }
+  if (status === "approval_required") {
+    return (
+      <span className="rounded border border-amber-300/70 bg-amber-100 px-1.5 py-0.5 text-[10px] font-medium text-amber-800 dark:border-amber-800/60 dark:bg-amber-950/50 dark:text-amber-300">
+        待确认
+      </span>
+    )
+  }
+  return null
+}
+
+function TimelineItem({
+  call,
+  expanded,
+  onToggle,
+  isLast,
+  onConfirmSave,
+  onReject,
+}: {
+  call: ToolCallRecord & { preview?: string }
+  expanded: boolean
+  onToggle: () => void
+  isLast: boolean
+  onConfirmSave?: (call: ToolCallRecord & { preview?: string }) => void
+  onReject?: (call: ToolCallRecord & { preview?: string }) => void
+}) {
+  const category = useMemo(() => getToolCategory(call.name), [call.name])
+  const styles = getCategoryStyles(category)
+  const hasError = call.status === "error"
+  const isCancelled = call.status === "cancelled"
+  const isRunning = call.status === "running"
+  const needsApproval = call.status === "approval_required"
+  const isWriteTool = category === "write"
+  const description = useMemo(
+    () => getToolCallDescription(call.name, call.params),
+    [call.name, call.params],
+  )
+  const duration = formatDuration(call.startedAt, call.finishedAt)
+
+  const cardClass = hasError
+    ? "border-red-200 bg-red-50 text-red-800 dark:border-red-900/40 dark:bg-red-950/20 dark:text-red-300 border-solid"
+    : isCancelled
+      ? "border-border/40 bg-muted/30 text-muted-foreground border-solid"
+      : needsApproval
+        ? "border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-900/40 dark:bg-amber-950/20 dark:text-amber-300 border-solid"
+        : `${styles.card} ${styles.cardBorderStyle}`
+
+  return (
+    <div className="relative flex w-full max-w-full min-w-0 gap-2">
+      <div className="relative flex w-5 flex-col items-center">
+        <div
+          className={cn(
+            "relative z-10 flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 border-background",
+            isRunning ? "bg-transparent" : styles.dot,
+          )}
+        >
+          {isRunning && <div className={cn("h-1.5 w-1.5 rounded-full animate-pulse", styles.dot)} />}
+        </div>
+        {!isLast && (
+          <div className={cn("w-px flex-1", isRunning ? "bg-transparent" : styles.line)} />
+        )}
+      </div>
+
+      <div className="min-w-0 flex-1 pb-3">
+        <div className={cn("w-full max-w-full min-w-0 overflow-hidden rounded-lg border", cardClass)}>
+          <button
+            type="button"
+            onClick={onToggle}
+            className="grid w-full min-w-0 grid-cols-[auto_minmax(0,1fr)_auto_auto] gap-x-2 gap-y-1 px-3 py-2 text-left text-xs"
+            aria-expanded={expanded}
+          >
+            <ToolCallIcon category={category} status={call.status} />
+            <span className="min-w-0 truncate font-medium">{call.name}</span>
+            <StatusBadge status={call.status} />
+            {expanded ? (
+              <ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
+            ) : (
+              <ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
+            )}
+            <span className="col-start-2 col-span-3 min-w-0 break-words text-muted-foreground">
+              <span className="line-clamp-2">
+                {description}
+                {duration && <span className="ml-1 text-[10px] opacity-70">{duration}</span>}
+              </span>
+            </span>
+          </button>
+          {expanded && (
+            <div
+              className={cn(
+                "max-h-40 overflow-y-auto overflow-x-hidden border-t px-3 py-2 text-[11px] whitespace-pre-wrap break-words leading-relaxed",
+                hasError
+                  ? "border-red-200/60 text-red-700 dark:border-red-900/30 dark:text-red-300/80"
+                  : isCancelled
+                    ? "border-muted/50 text-muted-foreground/80"
+                    : needsApproval
+                      ? "border-amber-200/60 text-amber-800 dark:border-amber-900/30 dark:text-amber-300/80"
+                      : "border-border/50 text-muted-foreground",
+              )}
+            >
+              {needsApproval && call.preview && (
+                <div className="mb-1.5 rounded border border-amber-300/50 bg-amber-100/60 p-2 dark:border-amber-800/40 dark:bg-amber-900/20">
+                  <div className="mb-0.5 text-[10px] font-semibold text-amber-700 dark:text-amber-400">
+                    拟写入内容预览
+                  </div>
+                  <div className="whitespace-pre-wrap break-words font-mono text-[10px] text-amber-900 dark:text-amber-200">
+                    {call.preview}
+                  </div>
+                </div>
+              )}
+              {call.result}
+              {needsApproval && isWriteTool && onConfirmSave && onReject && (
+                <div className="mt-2 flex gap-2 border-t border-amber-200/60 pt-2 dark:border-amber-900/30">
+                  <button
+                    type="button"
+                    onClick={(e) => {
+                      e.stopPropagation()
+                      onConfirmSave(call)
+                    }}
+                    className="rounded bg-green-600 px-3 py-1 text-[11px] font-medium text-white hover:bg-green-700"
+                  >
+                    确认保存
+                  </button>
+                  <button
+                    type="button"
+                    onClick={(e) => {
+                      e.stopPropagation()
+                      onReject(call)
+                    }}
+                    className="rounded border border-muted-foreground/30 bg-background px-3 py-1 text-[11px] font-medium text-muted-foreground hover:bg-accent"
+                  >
+                    放弃
+                  </button>
+                </div>
+              )}
+            </div>
+          )}
+        </div>
+      </div>
+    </div>
+  )
+}
+
+export function ToolCallTimeline({
+  toolCalls,
+  onConfirmSave,
+  onReject,
+  defaultExpanded = false,
+}: ToolCallTimelineProps) {
+  const [allExpanded, setAllExpanded] = useState(defaultExpanded)
+  const [expandedMap, setExpandedMap] = useState<Record<string, boolean>>({})
+  const [searchQuery, setSearchQuery] = useState("")
+  const [activeFilter, setActiveFilter] = useState<"all" | "read" | "write" | "action" | "virtual">("all")
+  const [errorOnly, setErrorOnly] = useState(false)
+  const [showFilters, setShowFilters] = useState(false)
+
+  const safeToolCalls = toolCalls ?? []
+
+  const filteredToolCalls = useMemo(() => {
+    return safeToolCalls.filter((call) => {
+      if (errorOnly && call.status !== "error") return false
+      const category = getToolCategory(call.name)
+      if (activeFilter !== "all" && category !== activeFilter) return false
+      if (searchQuery.trim()) {
+        const query = searchQuery.toLowerCase()
+        return call.name.toLowerCase().includes(query) ||
+          (typeof call.result === "string" && call.result.toLowerCase().includes(query))
+      }
+      return true
+    })
+  }, [safeToolCalls, searchQuery, activeFilter, errorOnly])
+
+  const errorCount = useMemo(() => {
+    return safeToolCalls.filter((call) => call.status === "error").length
+  }, [safeToolCalls])
+
+  if (safeToolCalls.length === 0) return null
+
+  const filterOptions: Array<{ key: "all" | "read" | "write" | "action" | "virtual"; label: string }> = [
+    { key: "all", label: "全部" },
+    { key: "virtual", label: "虚拟" },
+    { key: "read", label: "读取" },
+    { key: "write", label: "写入" },
+    { key: "action", label: "动作" },
+  ]
+
+  const getExpanded = (id: string) => {
+    if (id in expandedMap) return expandedMap[id]
+    return allExpanded
+  }
+
+  const toggleExpand = (id: string) => {
+    setExpandedMap((prev) => ({
+      ...prev,
+      [id]: !getExpanded(id),
+    }))
+  }
+
+  const toggleAll = () => {
+    const newAllExpanded = !allExpanded
+    setAllExpanded(newAllExpanded)
+    setExpandedMap({})
+  }
+
+  const allExpandedCount = filteredToolCalls.filter((call) => getExpanded(call.id)).length
+  const isAllExpanded = filteredToolCalls.length > 0 && allExpandedCount === filteredToolCalls.length
+
+  return (
+    <div className="mb-2 max-w-full overflow-hidden rounded-md border-l-4 border-blue-500 bg-blue-50/50 px-3 py-2.5 dark:bg-blue-950/20">
+      <div className="mb-2 flex flex-wrap items-center justify-between gap-2">
+        <div className="flex items-center gap-1.5 text-xs font-medium text-blue-700 dark:text-blue-400">
+          <Zap className="h-3.5 w-3.5" />
+          <span>AI 工具调用</span>
+          <span className="text-[10px] font-normal text-blue-600/70 dark:text-blue-400/70">
+            ({filteredToolCalls.length}/{safeToolCalls.length})
+          </span>
+          {errorCount > 0 && (
+            <span className="rounded-full bg-red-500 px-1.5 py-0.5 text-[10px] font-medium text-white">
+              {errorCount} 错误
+            </span>
+          )}
+        </div>
+        <div className="flex flex-wrap items-center gap-1">
+          <button
+            type="button"
+            onClick={() => setShowFilters(!showFilters)}
+            className={cn(
+              "flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] hover:bg-blue-100/60 dark:hover:bg-blue-900/30",
+              showFilters || activeFilter !== "all" || errorOnly
+                ? "text-blue-700 dark:text-blue-300"
+                : "text-blue-600/70 dark:text-blue-400/70"
+            )}
+          >
+            <Filter className="h-3 w-3" />
+            <span>过滤</span>
+          </button>
+          <button
+            type="button"
+            onClick={toggleAll}
+            className="flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] text-blue-600 hover:bg-blue-100/60 dark:text-blue-400 dark:hover:bg-blue-900/30"
+          >
+            {isAllExpanded ? (
+              <>
+                <Minimize2 className="h-3 w-3" />
+                <span>全部折叠</span>
+              </>
+            ) : (
+              <>
+                <Maximize2 className="h-3 w-3" />
+                <span>全部展开</span>
+              </>
+            )}
+          </button>
+        </div>
+      </div>
+
+      {showFilters && (
+        <div className="mb-2 space-y-2">
+          <div className="relative">
+            <Search className="absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-blue-500/50" />
+            <input
+              type="text"
+              value={searchQuery}
+              onChange={(e) => setSearchQuery(e.target.value)}
+              placeholder="搜索工具名称或结果..."
+              className="w-full rounded-md border border-blue-200/60 bg-white/70 py-1 pl-7 pr-2 text-[11px] text-blue-900 placeholder-blue-500/50 outline-none focus:border-blue-400 dark:border-blue-800/40 dark:bg-blue-950/30 dark:text-blue-100 dark:placeholder-blue-400/40"
+            />
+          </div>
+          <div className="flex flex-wrap gap-1">
+            {filterOptions.map((opt) => (
+              <button
+                key={opt.key}
+                type="button"
+                onClick={() => setActiveFilter(opt.key)}
+                className={cn(
+                  "rounded-md px-2 py-0.5 text-[10px] transition-colors",
+                  activeFilter === opt.key
+                    ? "bg-blue-500 text-white"
+                    : "bg-blue-100/60 text-blue-700 hover:bg-blue-200/60 dark:bg-blue-900/30 dark:text-blue-300 dark:hover:bg-blue-900/50"
+                )}
+              >
+                {opt.label}
+              </button>
+            ))}
+            <button
+              type="button"
+              onClick={() => setErrorOnly(!errorOnly)}
+              className={cn(
+                "rounded-md px-2 py-0.5 text-[10px] transition-colors",
+                errorOnly
+                  ? "bg-red-500 text-white"
+                  : "bg-red-100/60 text-red-700 hover:bg-red-200/60 dark:bg-red-900/30 dark:text-red-300 dark:hover:bg-red-900/50"
+              )}
+            >
+              <XCircle className="inline h-3 w-3 mr-0.5 align-[-2px]" />
+              只看错误
+            </button>
+          </div>
+        </div>
+      )}
+
+      <div className="relative">
+        {filteredToolCalls.length === 0 ? (
+          <div className="py-6 text-center text-[11px] text-blue-500/60 dark:text-blue-400/50">
+            没有匹配的工具调用
+          </div>
+        ) : (
+          filteredToolCalls.map((call, index) => (
+            <TimelineItem
+              key={call.id}
+              call={call}
+              expanded={getExpanded(call.id)}
+              onToggle={() => toggleExpand(call.id)}
+              isLast={index === filteredToolCalls.length - 1}
+              onConfirmSave={onConfirmSave}
+              onReject={onReject}
+            />
+          ))
+        )}
+      </div>
+    </div>
+  )
+}

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

@@ -2,7 +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 { UnifiedSkillLibraryView } from "@/components/skill-library/unified-skill-library-view"
 
 const ChatPanel = lazy(async () => {
   const mod = await import("@/components/chat/chat-panel")
@@ -97,9 +97,10 @@ export function ContentArea() {
         )
         break
       case "skillLibrary":
+      case "writingSkillLibrary":
         content = (
           <Suspense fallback={<LoadingView />}>
-            <SkillLibraryView />
+            <UnifiedSkillLibraryView />
           </Suspense>
         )
         break

+ 1 - 1
src/components/layout/icon-sidebar.tsx

@@ -77,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,

+ 52 - 1
src/components/layout/knowledge-tree.tsx

@@ -1,5 +1,5 @@
 import { useCallback, useEffect, useMemo, useRef, useState } from "react"
-import { BookOpen, ChevronDown, ChevronRight, FileText, Folder, FolderInput, FolderOpen, Globe, Loader2, Pencil, Plus, Trash2, Check, X } from "lucide-react"
+import { BookOpen, ChevronDown, ChevronRight, FileText, Folder, FolderInput, FolderOpen, Globe, Loader2, MessageCircle, Pencil, Plus, Trash2, Check, X } from "lucide-react"
 import { useTranslation } from "react-i18next"
 import { ScrollArea } from "@/components/ui/scroll-area"
 import { Button } from "@/components/ui/button"
@@ -14,6 +14,7 @@ import { moveFileToTrash } from "@/lib/trash"
 import { makeChapterFileName, makeDefaultChapterTitle, makeSafeFileSlug } from "@/lib/wiki-filename"
 import { useImportProgressStore } from "@/stores/import-progress-store"
 import { saveLastReadChapter } from "@/lib/project-store"
+import type { ReferenceToken } from "@/lib/reference/types"
 
 interface WikiPageInfo {
   path: string
@@ -39,6 +40,8 @@ interface KnowledgeTreeProps {
   pendingPages?: WikiPageInfo[]
   onRemovePendingPage?: (pagePath: string) => void
   onRequestCreate?: (request: KnowledgeCreateRequest) => void
+  onSendToChat?: (token: ReferenceToken) => void
+  onSendToOutline?: (token: ReferenceToken) => void
 }
 
 interface CreateMenuState {
@@ -110,6 +113,20 @@ function getDirName(path: string): string {
   return index >= 0 ? normalized.slice(0, index) : ""
 }
 
+function truncateReferenceTitle(title: string, maxLen = 20): string {
+  return title.length > maxLen ? `${title.slice(0, maxLen)}...` : title
+}
+
+function createChapterReferenceToken(page: WikiPageInfo): ReferenceToken {
+  return {
+    id: globalThis.crypto?.randomUUID?.() || Math.random().toString(36).slice(2),
+    category: "chapter",
+    title: page.title,
+    path: page.path,
+    displayTitle: truncateReferenceTitle(page.title),
+  }
+}
+
 async function getUniquePagePath(dir: string, fileName: string, excludePath?: string): Promise<string> {
   const firstPath = `${dir}/${fileName}`
   if (firstPath === excludePath || !(await fileExists(firstPath))) return firstPath
@@ -261,6 +278,8 @@ export function KnowledgeTree({
   pendingPages = [],
   onRemovePendingPage,
   onRequestCreate,
+  onSendToChat,
+  onSendToOutline,
 }: KnowledgeTreeProps) {
   const { t } = useTranslation()
   const project = useWikiStore((s) => s.project)
@@ -1392,6 +1411,38 @@ export function KnowledgeTree({
               <FolderOpen className="h-4 w-4" />
               打开文件所在位置
             </button>
+            {filterType === "chapter" && (onSendToChat || onSendToOutline) && (
+              <div className="mt-1 border-t pt-1">
+                {onSendToChat && (
+                  <button
+                    type="button"
+                    className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-accent"
+                    onClick={() => {
+                      const target = pageInfoByPath.get(pageMenu.path)
+                      if (target) onSendToChat(createChapterReferenceToken(target))
+                      setPageMenu(null)
+                    }}
+                  >
+                    <MessageCircle className="h-3.5 w-3.5" />
+                    发送到AI会话
+                  </button>
+                )}
+                {onSendToOutline && (
+                  <button
+                    type="button"
+                    className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-accent"
+                    onClick={() => {
+                      const target = pageInfoByPath.get(pageMenu.path)
+                      if (target) onSendToOutline(createChapterReferenceToken(target))
+                      setPageMenu(null)
+                    }}
+                  >
+                    <FileText className="h-3.5 w-3.5" />
+                    发送到AI大纲
+                  </button>
+                )}
+              </div>
+            )}
           </div>
         )}
       </div>

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

@@ -24,9 +24,10 @@ 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 { UnifiedSkillLibrarySidebarPanel } from "@/components/skill-library/unified-skill-library-view"
 
 import { useWikiStore } from "@/stores/wiki-store"
+import { useChatStore } from "@/stores/chat-store"
 import { useStorySimulationStore } from "@/stores/story-simulation-store"
 import { loadFrameworks, loadSimulationResults, deleteSimulationResult } from "@/lib/novel/story-simulation/framework-store"
 import { loadBinding } from "@/lib/novel/story-simulation/framework-binding"
@@ -67,6 +68,7 @@ import {
 import { makeChapterFileName, makeDefaultChapterTitle, makeSafeFileSlug } from "@/lib/wiki-filename"
 import { useImportProgressStore } from "@/stores/import-progress-store"
 import { openExternalUrl } from "@/lib/open-external-url"
+import type { ReferenceToken } from "@/lib/reference/types"
 
 const USAGE_GUIDE_URL = "https://tcnk9ik08e1c.feishu.cn/wiki/FWiSwYQKoifpwBk6mSRcSlB8nrh?from=from_copylink"
 
@@ -673,6 +675,8 @@ export function SidebarPanel() {
   const setSelectedMemoryCenterEntry = useWikiStore((s) => s.setSelectedMemoryCenterEntry)
   const setSelectedFile = useWikiStore((s) => s.setSelectedFile)
   const setFileTree = useWikiStore((s) => s.setFileTree)
+  const setChatExpanded = useWikiStore((s) => s.setChatExpanded)
+  const enqueueReferenceTokens = useChatStore((s) => s.enqueueReferenceTokens)
   const dataVersion = useWikiStore((s) => s.dataVersion)
   const [mode, setMode] = useState<"knowledge" | "files">("knowledge")
   const [refreshKey, setRefreshKey] = useState(0)
@@ -1210,6 +1214,11 @@ export function SidebarPanel() {
     setInputTitle("")
   }
 
+  const handleSendChapterToChat = useCallback((token: ReferenceToken) => {
+    enqueueReferenceTokens([token])
+    setChatExpanded(true)
+  }, [enqueueReferenceTokens, setChatExpanded])
+
   const inputPlaceholder = pendingCreate?.kind === "outline"
     ? t("sidebar.newOutlinePrompt")
     : pendingCreate?.kind === "volume"
@@ -1273,8 +1282,8 @@ export function SidebarPanel() {
     return <SoulSidebarPanel />
   }
 
-  if (activeView === "skillLibrary") {
-    return <SkillLibrarySidebarPanel />
+  if (activeView === "skillLibrary" || activeView === "writingSkillLibrary") {
+    return <UnifiedSkillLibrarySidebarPanel />
   }
 
   if (activeView === "reviewCenter") {
@@ -1527,6 +1536,7 @@ export function SidebarPanel() {
           pendingPages={pendingPages.filter((page) => page.type === (isChapter ? "chapter" : "outline"))}
           onRemovePendingPage={handleRemovePendingPage}
           onRequestCreate={beginCreate}
+          onSendToChat={isChapter ? handleSendChapterToChat : undefined}
         />
       </div>
       <div className="border-t px-3 py-2">

+ 169 - 0
src/components/novel/retrieval-status-indicator.tsx

@@ -0,0 +1,169 @@
+import { useState } from "react"
+import { AlertTriangle, RefreshCw, Database, X } from "lucide-react"
+import { Button } from "@/components/ui/button"
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
+
+export interface OutdatedStatus {
+  total: number
+  outdated: number
+  maybeOutdated: number
+  conflict: number
+  chapterNumbers: {
+    maybeOutdated: number[]
+    conflict: number[]
+  }
+}
+
+interface RetrievalStatusIndicatorProps {
+  status: OutdatedStatus | null
+  onRefresh: () => void
+  isRefreshing?: boolean
+}
+
+export function RetrievalStatusIndicator({ status, onRefresh, isRefreshing = false }: RetrievalStatusIndicatorProps) {
+  const [expanded, setExpanded] = useState(false)
+
+  if (!status || status.outdated === 0) {
+    return null
+  }
+
+  const hasConflict = status.conflict > 0
+  const hasMaybeOutdated = status.maybeOutdated > 0
+
+  const tooltipText = [
+    hasMaybeOutdated ? `${status.maybeOutdated} 章索引可能过期` : null,
+    hasConflict ? `${status.conflict} 章存在冲突` : null,
+  ].filter(Boolean).join(",")
+
+  return (
+    <TooltipProvider delay={200}>
+      <div className="relative">
+        <Tooltip>
+          <TooltipTrigger
+            render={(
+              <button
+                type="button"
+                onClick={() => setExpanded(!expanded)}
+                className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full transition-colors hover:bg-accent"
+                aria-label="检索索引状态"
+              >
+                <div className="relative">
+                  <Database className={`h-4 w-4 ${hasConflict ? "text-amber-600" : "text-amber-500"}`} />
+                  <span
+                    className={`absolute -right-0.5 -top-0.5 h-2 w-2 rounded-full ring-1 ring-background ${
+                      hasConflict ? "bg-red-500" : "bg-amber-500"
+                    }`}
+                  />
+                </div>
+              </button>
+            )}
+          />
+          <TooltipContent side="top" className="max-w-xs leading-5">
+            {tooltipText}
+          </TooltipContent>
+        </Tooltip>
+
+        {expanded && (
+          <div className="absolute bottom-full right-0 mb-2 w-72 rounded-lg border bg-background p-3 shadow-lg">
+            <div className="mb-2 flex items-center justify-between">
+              <div className="flex items-center gap-2">
+                <AlertTriangle className="h-4 w-4 text-amber-500" />
+                <span className="text-sm font-medium">检索索引状态</span>
+              </div>
+              <button
+                type="button"
+                onClick={() => setExpanded(false)}
+                className="rounded-full p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
+                aria-label="关闭"
+              >
+                <X className="h-3.5 w-3.5" />
+              </button>
+            </div>
+
+            <div className="mb-3 space-y-1.5 text-xs text-muted-foreground">
+              <div className="flex justify-between">
+                <span>索引总章数</span>
+                <span className="font-medium text-foreground">{status.total} 章</span>
+              </div>
+              {hasMaybeOutdated && (
+                <div className="flex justify-between">
+                  <span className="flex items-center gap-1.5">
+                    <span className="h-1.5 w-1.5 rounded-full bg-amber-500" />
+                    可能过期
+                  </span>
+                  <span className="font-medium text-amber-600">{status.maybeOutdated} 章</span>
+                </div>
+              )}
+              {hasConflict && (
+                <div className="flex justify-between">
+                  <span className="flex items-center gap-1.5">
+                    <span className="h-1.5 w-1.5 rounded-full bg-red-500" />
+                    存在冲突
+                  </span>
+                  <span className="font-medium text-red-600">{status.conflict} 章</span>
+                </div>
+              )}
+            </div>
+
+            {(hasMaybeOutdated || hasConflict) && (
+              <div className="mb-3 max-h-32 overflow-y-auto rounded-md border bg-muted/20 p-2 text-xs">
+                {hasMaybeOutdated && (
+                  <div className="mb-2">
+                    <div className="mb-1 font-medium text-amber-700 dark:text-amber-400">可能过期:</div>
+                    <div className="flex flex-wrap gap-1">
+                      {status.chapterNumbers.maybeOutdated.slice(0, 20).map((ch) => (
+                        <span
+                          key={ch}
+                          className="rounded-full border border-amber-200 bg-amber-50 px-1.5 py-0.5 text-[10px] text-amber-700 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-300"
+                        >
+                          第{ch}章
+                        </span>
+                      ))}
+                      {status.chapterNumbers.maybeOutdated.length > 20 && (
+                        <span className="text-[10px] text-muted-foreground">
+                          +{status.chapterNumbers.maybeOutdated.length - 20} 章
+                        </span>
+                      )}
+                    </div>
+                  </div>
+                )}
+                {hasConflict && (
+                  <div>
+                    <div className="mb-1 font-medium text-red-700 dark:text-red-400">存在冲突:</div>
+                    <div className="flex flex-wrap gap-1">
+                      {status.chapterNumbers.conflict.slice(0, 20).map((ch) => (
+                        <span
+                          key={ch}
+                          className="rounded-full border border-red-200 bg-red-50 px-1.5 py-0.5 text-[10px] text-red-700 dark:border-red-800 dark:bg-red-950/40 dark:text-red-300"
+                        >
+                          第{ch}章
+                        </span>
+                      ))}
+                      {status.chapterNumbers.conflict.length > 20 && (
+                        <span className="text-[10px] text-muted-foreground">
+                          +{status.chapterNumbers.conflict.length - 20} 章
+                        </span>
+                      )}
+                    </div>
+                  </div>
+                )}
+              </div>
+            )}
+
+            <Button
+              type="button"
+              variant="outline"
+              size="sm"
+              onClick={onRefresh}
+              disabled={isRefreshing}
+              className="w-full gap-2"
+            >
+              <RefreshCw className={`h-3.5 w-3.5 ${isRefreshing ? "animate-spin" : ""}`} />
+              {isRefreshing ? "重新生成中..." : "重新生成索引"}
+            </Button>
+          </div>
+        )}
+      </div>
+    </TooltipProvider>
+  )
+}

+ 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>
   )
 }

+ 64 - 0
src/components/reference/ReferenceChip.spec.tsx

@@ -0,0 +1,64 @@
+// @vitest-environment jsdom
+
+import { act } from "react"
+import { createRoot, type Root } from "react-dom/client"
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
+import { ReferenceChip } from "./ReferenceChip"
+import type { ReferenceToken } from "@/lib/reference/types"
+
+let host: HTMLDivElement
+let root: Root
+
+const token: ReferenceToken = {
+  id: "ref-1",
+  category: "chapter",
+  title: "第一章",
+  displayTitle: "第一章",
+  path: "C:/Novel/wiki/chapters/第一章.md",
+}
+
+beforeEach(() => {
+  host = document.createElement("div")
+  document.body.appendChild(host)
+  root = createRoot(host)
+})
+
+afterEach(() => {
+  act(() => {
+    root.unmount()
+  })
+  host.remove()
+})
+
+describe("ReferenceChip", () => {
+  it("renders editable chip metadata and calls onRemove", async () => {
+    const onRemove = vi.fn()
+
+    await act(async () => {
+      root.render(<ReferenceChip token={token} onRemove={onRemove} />)
+    })
+
+    const chip = host.querySelector("[data-reference-id='ref-1']")
+    expect(chip?.textContent).toContain("@第一章")
+    expect(chip?.getAttribute("data-reference-category")).toBe("chapter")
+    expect(chip?.getAttribute("contenteditable")).toBe("false")
+
+    const button = host.querySelector("button")
+    expect(button?.getAttribute("aria-label")).toBe("移除引用 第一章")
+
+    await act(async () => {
+      button?.click()
+    })
+
+    expect(onRemove).toHaveBeenCalledWith("ref-1")
+  })
+
+  it("renders readonly chip without a remove button", async () => {
+    await act(async () => {
+      root.render(<ReferenceChip token={token} readonly />)
+    })
+
+    expect(host.textContent).toBe("@第一章")
+    expect(host.querySelector("button")).toBeNull()
+  })
+})

+ 51 - 0
src/components/reference/ReferenceChip.tsx

@@ -0,0 +1,51 @@
+import { X } from "lucide-react"
+import type { ReferenceToken } from "@/lib/reference/types"
+
+const categoryColors: Record<ReferenceToken["category"], string> = {
+  chapter: "bg-purple-100 text-purple-700 border-purple-300",
+  memory: "bg-green-100 text-green-700 border-green-300",
+  outline: "bg-blue-100 text-blue-700 border-blue-300",
+  deduction: "bg-orange-100 text-orange-700 border-orange-300",
+  skill: "bg-yellow-100 text-yellow-700 border-yellow-300",
+  chat_history: "bg-cyan-100 text-cyan-700 border-cyan-300",
+  outline_history: "bg-indigo-100 text-indigo-700 border-indigo-300",
+}
+
+interface ReferenceChipProps {
+  token: ReferenceToken
+  readonly?: boolean
+  onRemove?: (id: string) => void
+}
+
+export function ReferenceChip({
+  token,
+  readonly = false,
+  onRemove,
+}: ReferenceChipProps) {
+  const colorClass = categoryColors[token.category]
+
+  return (
+    <span
+      className={`mx-0.5 inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-xs font-medium ${colorClass}`}
+      contentEditable={readonly ? undefined : false}
+      data-reference-id={token.id}
+      data-reference-category={token.category}
+    >
+      @{token.displayTitle}
+      {!readonly && (
+        <button
+          type="button"
+          className="ml-0.5 cursor-pointer rounded-sm p-0.5 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
+          onClick={(event) => {
+            event.preventDefault()
+            onRemove?.(token.id)
+          }}
+          tabIndex={-1}
+          aria-label={`移除引用 ${token.displayTitle}`}
+        >
+          <X className="h-3 w-3" aria-hidden="true" />
+        </button>
+      )}
+    </span>
+  )
+}

+ 315 - 0
src/components/reference/ReferenceInput.spec.tsx

@@ -0,0 +1,315 @@
+// @vitest-environment jsdom
+
+import { act, useRef, useState } from "react"
+import { createRoot, type Root } from "react-dom/client"
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
+import { ReferenceInput, type InsertReferenceTokens } from "./ReferenceInput"
+import type { ReferenceToken } from "@/lib/reference/types"
+
+let host: HTMLDivElement
+let root: Root
+
+const token: ReferenceToken = {
+  id: "ref-1",
+  category: "chapter",
+  title: "第一章",
+  displayTitle: "第一章",
+  path: "C:/Novel/wiki/chapters/第一章.md",
+}
+
+function dispatchTextareaInput(element: HTMLTextAreaElement, text: string) {
+  const valueSetter = Object.getOwnPropertyDescriptor(
+    window.HTMLTextAreaElement.prototype,
+    "value",
+  )?.set
+  valueSetter?.call(element, text)
+  element.dispatchEvent(new Event("input", { bubbles: true }))
+}
+
+beforeEach(() => {
+  host = document.createElement("div")
+  document.body.appendChild(host)
+  root = createRoot(host)
+})
+
+afterEach(() => {
+  act(() => {
+    root.unmount()
+  })
+  host.remove()
+})
+
+describe("ReferenceInput", () => {
+  it("triggers reference picker from @ key and button", async () => {
+    const onAtTrigger = vi.fn()
+
+    await act(async () => {
+      root.render(
+        <ReferenceInput
+          tokens={[]}
+          onSubmit={vi.fn()}
+          onAtTrigger={onAtTrigger}
+        />,
+      )
+    })
+
+    const editor = host.querySelector("textarea") as HTMLTextAreaElement
+    await act(async () => {
+      editor.dispatchEvent(new KeyboardEvent("keydown", { key: "@", bubbles: true, cancelable: true }))
+    })
+    await act(async () => {
+      host.querySelector<HTMLButtonElement>("[aria-label='引用内容']")?.click()
+    })
+
+    expect(onAtTrigger).toHaveBeenCalledTimes(2)
+  })
+
+  it("triggers reference picker when @ is typed with Shift", async () => {
+    const onAtTrigger = vi.fn()
+
+    await act(async () => {
+      root.render(
+        <ReferenceInput
+          tokens={[]}
+          onSubmit={vi.fn()}
+          onAtTrigger={onAtTrigger}
+        />,
+      )
+    })
+
+    const editor = host.querySelector("textarea") as HTMLTextAreaElement
+    await act(async () => {
+      editor.dispatchEvent(
+        new KeyboardEvent("keydown", {
+          key: "@",
+          shiftKey: true,
+          bubbles: true,
+          cancelable: true,
+        }),
+      )
+    })
+
+    expect(onAtTrigger).toHaveBeenCalledTimes(1)
+  })
+
+  it("uses a controlled textarea editor instead of contentEditable DOM mixing", async () => {
+    const onChange = vi.fn()
+
+    await act(async () => {
+      root.render(
+        <ReferenceInput
+          value="侧例cec"
+          tokens={[token]}
+          onChange={onChange}
+          onSubmit={vi.fn()}
+        />,
+      )
+    })
+
+    expect(host.querySelector("[contenteditable='true']")).toBeNull()
+    const editor = host.querySelector("textarea") as HTMLTextAreaElement
+    expect(editor.value).toBe("侧例cec")
+    expect(host.textContent?.match(/侧例cec/g)).toHaveLength(1)
+
+    await act(async () => {
+      dispatchTextareaInput(editor, "侧例cec2")
+    })
+
+    expect(onChange).toHaveBeenLastCalledWith("侧例cec2", [token])
+  })
+
+  it("keeps the resize handle and footer controls beside the send button", async () => {
+    await act(async () => {
+      root.render(
+        <ReferenceInput
+          tokens={[]}
+          onSubmit={vi.fn()}
+          rightControls={<button type="button">模型选择</button>}
+        />,
+      )
+    })
+
+    expect(host.querySelector("[aria-label='拖动调整输入框高度']")).toBeTruthy()
+    const footer = host.querySelector("[data-reference-input-footer]")
+    expect(footer?.textContent).toContain("模型选择")
+    expect(footer?.querySelector("[aria-label='发送消息']")).toBeTruthy()
+  })
+
+  it("shows a stop action in the footer while streaming", async () => {
+    const onStop = vi.fn()
+
+    await act(async () => {
+      root.render(
+        <ReferenceInput
+          tokens={[]}
+          onSubmit={vi.fn()}
+          isStreaming
+          onStop={onStop}
+          rightControls={<button type="button">模型选择</button>}
+        />,
+      )
+    })
+
+    expect(host.querySelector("[aria-label='发送消息']")).toBeNull()
+    const stop = host.querySelector<HTMLButtonElement>("[aria-label='停止生成']")
+    expect(stop).toBeTruthy()
+
+    await act(async () => {
+      stop?.click()
+    })
+
+    expect(onStop).toHaveBeenCalledTimes(1)
+  })
+
+  it("does not submit when only references are present", async () => {
+    const onSubmit = vi.fn()
+
+    await act(async () => {
+      root.render(<ReferenceInput tokens={[token]} onSubmit={onSubmit} />)
+    })
+
+    const send = host.querySelector<HTMLButtonElement>("[aria-label='发送消息']")
+    expect(send?.disabled).toBe(true)
+
+    const editor = host.querySelector("textarea") as HTMLTextAreaElement
+    await act(async () => {
+      editor.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }))
+    })
+
+    expect(onSubmit).not.toHaveBeenCalled()
+  })
+
+  it("submits plain text with current tokens", async () => {
+    const onSubmit = vi.fn()
+
+    await act(async () => {
+      root.render(<ReferenceInput tokens={[token]} onSubmit={onSubmit} />)
+    })
+
+    const editor = host.querySelector("textarea") as HTMLTextAreaElement
+    await act(async () => {
+      dispatchTextareaInput(editor, "请参考这章")
+    })
+
+    await act(async () => {
+      host.querySelector<HTMLButtonElement>("[aria-label='发送消息']")?.click()
+    })
+
+    expect(onSubmit).toHaveBeenCalledWith("请参考这章", [token])
+  })
+
+  it("submits plain text with Enter", async () => {
+    const onSubmit = vi.fn()
+
+    await act(async () => {
+      root.render(<ReferenceInput tokens={[token]} onSubmit={onSubmit} />)
+    })
+
+    const editor = host.querySelector("textarea") as HTMLTextAreaElement
+    await act(async () => {
+      dispatchTextareaInput(editor, "发送这一句")
+    })
+    await act(async () => {
+      editor.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }))
+    })
+
+    expect(onSubmit).toHaveBeenCalledWith("发送这一句", [token])
+  })
+
+  it("submits the textarea DOM value immediately when Enter is pressed before state catches up", async () => {
+    const onSubmit = vi.fn()
+
+    await act(async () => {
+      root.render(
+        <ReferenceInput
+          value=""
+          tokens={[token]}
+          onChange={vi.fn()}
+          onSubmit={onSubmit}
+        />,
+      )
+    })
+
+    const editor = host.querySelector("textarea") as HTMLTextAreaElement
+    const valueSetter = Object.getOwnPropertyDescriptor(
+      window.HTMLTextAreaElement.prototype,
+      "value",
+    )?.set
+    valueSetter?.call(editor, "刚输入马上发送")
+
+    await act(async () => {
+      editor.dispatchEvent(
+        new KeyboardEvent("keydown", {
+          key: "Enter",
+          bubbles: true,
+          cancelable: true,
+        }),
+      )
+    })
+
+    expect(onSubmit).toHaveBeenCalledWith("刚输入马上发送", [token])
+  })
+
+  it("uses a weaker placeholder color than normal input text", async () => {
+    await act(async () => {
+      root.render(
+        <ReferenceInput
+          tokens={[]}
+          onSubmit={vi.fn()}
+          placeholder="请输入写作需求"
+        />,
+      )
+    })
+
+    const editor = host.querySelector("textarea") as HTMLTextAreaElement
+    expect(editor.placeholder).toBe("请输入写作需求")
+    expect(editor.className).toContain("placeholder:text-muted-foreground/60")
+  })
+
+  it("supports inserting and removing tokens through callbacks", async () => {
+    const inserted: ReferenceToken = {
+      ...token,
+      id: "ref-2",
+      title: "第二章",
+      displayTitle: "第二章",
+    }
+    const onChange = vi.fn()
+
+    function Harness() {
+      const [tokens, setTokens] = useState<ReferenceToken[]>([])
+      const insertTokensRef = useRef<InsertReferenceTokens>(null)
+      return (
+        <>
+          <ReferenceInput
+            tokens={tokens}
+            onTokensChange={setTokens}
+            onChange={onChange}
+            onSubmit={vi.fn()}
+            insertTokensRef={insertTokensRef}
+          />
+          <button type="button" onClick={() => insertTokensRef.current?.([inserted])}>
+            插入
+          </button>
+        </>
+      )
+    }
+
+    await act(async () => {
+      root.render(<Harness />)
+    })
+
+    await act(async () => {
+      Array.from(host.querySelectorAll("button")).find((button) => button.textContent === "插入")?.click()
+    })
+
+    expect(host.textContent).toContain("@第二章")
+    expect(onChange).toHaveBeenLastCalledWith("", [inserted])
+
+    await act(async () => {
+      host.querySelector<HTMLButtonElement>("[aria-label='移除引用 第二章']")?.click()
+    })
+
+    expect(host.textContent).not.toContain("@第二章")
+    expect(onChange).toHaveBeenLastCalledWith("", [])
+  })
+})

+ 277 - 0
src/components/reference/ReferenceInput.tsx

@@ -0,0 +1,277 @@
+import {
+  useCallback,
+  useEffect,
+  useMemo,
+  useRef,
+  useState,
+  type MutableRefObject,
+  type ReactNode,
+} from "react"
+import { ArrowUp, AtSign, Square } from "lucide-react"
+import { isImeComposing } from "@/lib/keyboard-utils"
+import type { ReferenceToken } from "@/lib/reference/types"
+import { ReferenceChip } from "./ReferenceChip"
+
+export type InsertReferenceTokens = ((tokens: ReferenceToken[]) => void) | null
+
+const REFERENCE_INPUT_HEIGHT_KEY = "qmai-reference-input-height"
+const DEFAULT_REFERENCE_INPUT_HEIGHT = 56
+const MIN_REFERENCE_INPUT_HEIGHT = 48
+const MAX_REFERENCE_INPUT_HEIGHT = 220
+
+interface ReferenceInputProps {
+  value?: string
+  tokens: ReferenceToken[]
+  placeholder?: string
+  disabled?: boolean
+  isStreaming?: boolean
+  rightControls?: ReactNode
+  onChange?: (plainText: string, tokens: ReferenceToken[]) => void
+  onTokensChange?: (tokens: ReferenceToken[]) => void
+  onSubmit: (plainText: string, tokens: ReferenceToken[]) => void
+  onStop?: () => void
+  onAtTrigger?: () => void
+  insertTokensRef?: MutableRefObject<InsertReferenceTokens>
+}
+
+function clampInputHeight(height: number): number {
+  if (!Number.isFinite(height)) return DEFAULT_REFERENCE_INPUT_HEIGHT
+  return Math.min(MAX_REFERENCE_INPUT_HEIGHT, Math.max(MIN_REFERENCE_INPUT_HEIGHT, Math.round(height)))
+}
+
+function loadSavedInputHeight(): number {
+  if (typeof localStorage === "undefined") return DEFAULT_REFERENCE_INPUT_HEIGHT
+  const raw = localStorage.getItem(REFERENCE_INPUT_HEIGHT_KEY)
+  const parsed = raw ? Number(raw) : Number.NaN
+  return Number.isFinite(parsed) ? clampInputHeight(parsed) : DEFAULT_REFERENCE_INPUT_HEIGHT
+}
+
+function saveInputHeight(height: number) {
+  if (typeof localStorage === "undefined") return
+  localStorage.setItem(REFERENCE_INPUT_HEIGHT_KEY, String(clampInputHeight(height)))
+}
+
+export function ReferenceInput({
+  value,
+  tokens,
+  placeholder = "输入提示词,或 @ 引用内容...",
+  disabled = false,
+  isStreaming = false,
+  rightControls,
+  onChange,
+  onTokensChange,
+  onSubmit,
+  onStop,
+  onAtTrigger,
+  insertTokensRef,
+}: ReferenceInputProps) {
+  const textareaRef = useRef<HTMLTextAreaElement>(null)
+  const isControlled = value !== undefined
+  const [draft, setDraft] = useState("")
+  const [inputHeight, setInputHeight] = useState(loadSavedInputHeight)
+  const text = isControlled ? value : draft
+  const inputDisabled = disabled || isStreaming
+  const canSubmit = text.trim().length > 0 && !inputDisabled
+
+  const notifyChange = useCallback(
+    (nextText: string, nextTokens: ReferenceToken[]) => {
+      if (!isControlled) setDraft(nextText)
+      onChange?.(nextText, nextTokens)
+    },
+    [isControlled, onChange],
+  )
+
+  const updateTokens = useCallback(
+    (nextTokens: ReferenceToken[]) => {
+      onTokensChange?.(nextTokens)
+      onChange?.(text, nextTokens)
+    },
+    [onChange, onTokensChange, text],
+  )
+
+  useEffect(() => {
+    if (!insertTokensRef) return
+    insertTokensRef.current = (nextTokens) => {
+      if (nextTokens.length === 0) return
+      updateTokens([...tokens, ...nextTokens])
+      textareaRef.current?.focus()
+    }
+    return () => {
+      insertTokensRef.current = null
+    }
+  }, [insertTokensRef, tokens, updateTokens])
+
+  const handleTextareaChange = useCallback(
+    (event: React.ChangeEvent<HTMLTextAreaElement>) => {
+      notifyChange(event.target.value, tokens)
+    },
+    [notifyChange, tokens],
+  )
+
+  const handleRemoveToken = useCallback(
+    (id: string) => {
+      updateTokens(tokens.filter((token) => token.id !== id))
+    },
+    [tokens, updateTokens],
+  )
+
+  const handleSubmit = useCallback(() => {
+    const plainText = (textareaRef.current?.value ?? text).trim()
+    if (!plainText || inputDisabled) return
+    onSubmit(plainText, tokens)
+  }, [inputDisabled, onSubmit, text, tokens])
+
+  const handleKeyDown = useCallback(
+    (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
+      if (isImeComposing(event)) return
+
+      if (event.key === "@" && !event.ctrlKey && !event.metaKey) {
+        event.preventDefault()
+        onAtTrigger?.()
+        return
+      }
+
+      if (event.key === "Enter" && !event.shiftKey) {
+        event.preventDefault()
+        handleSubmit()
+      }
+    },
+    [handleSubmit, onAtTrigger],
+  )
+
+  const handleResizePointerDown = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
+    if (event.button !== 0) return
+    event.preventDefault()
+
+    const startY = event.clientY
+    const startHeight = inputHeight
+    const handle = event.currentTarget
+    const pointerId = event.pointerId
+    const previousCursor = document.body.style.cursor
+    let savedHeight = startHeight
+    document.body.style.cursor = "ns-resize"
+
+    try {
+      handle.setPointerCapture(pointerId)
+    } catch {
+      // Older WebViews can miss pointer capture; window listeners still cover dragging.
+    }
+
+    const handlePointerMove = (pointerEvent: PointerEvent) => {
+      const nextHeight = clampInputHeight(startHeight + (startY - pointerEvent.clientY))
+      savedHeight = nextHeight
+      setInputHeight(nextHeight)
+    }
+
+    const handlePointerUp = () => {
+      saveInputHeight(savedHeight)
+      try {
+        handle.releasePointerCapture(pointerId)
+      } catch {
+        // Ignore release errors when capture has already been cancelled.
+      }
+      document.body.style.cursor = previousCursor
+      window.removeEventListener("pointermove", handlePointerMove)
+      window.removeEventListener("pointerup", handlePointerUp)
+      window.removeEventListener("pointercancel", handlePointerUp)
+    }
+
+    window.addEventListener("pointermove", handlePointerMove)
+    window.addEventListener("pointerup", handlePointerUp)
+    window.addEventListener("pointercancel", handlePointerUp)
+  }, [inputHeight])
+
+  const resetInputHeight = useCallback(() => {
+    setInputHeight(DEFAULT_REFERENCE_INPUT_HEIGHT)
+    saveInputHeight(DEFAULT_REFERENCE_INPUT_HEIGHT)
+  }, [])
+
+  const renderedTokens = useMemo(
+    () => tokens.map((token) => (
+      <ReferenceChip
+        key={token.id}
+        token={token}
+        onRemove={handleRemoveToken}
+      />
+    )),
+    [handleRemoveToken, tokens],
+  )
+
+  return (
+    <div className="overflow-hidden rounded-lg border bg-background shadow-sm focus-within:ring-2 focus-within:ring-ring">
+      <div
+        role="separator"
+        aria-label="拖动调整输入框高度"
+        title="拖动调整输入框高度,双击恢复默认高度"
+        className="flex h-2 cursor-ns-resize items-center justify-center bg-muted/20 transition-colors hover:bg-muted/50"
+        onPointerDown={handleResizePointerDown}
+        onDoubleClick={resetInputHeight}
+      >
+        <span className="h-0.5 w-10 rounded-full bg-border" />
+      </div>
+
+      <div className="relative px-3 py-2">
+        {tokens.length > 0 ? (
+          <div className="mb-2 flex flex-wrap gap-1">
+            {renderedTokens}
+          </div>
+        ) : null}
+        <textarea
+          ref={textareaRef}
+          value={text}
+          className="w-full resize-none overflow-y-auto bg-transparent px-0 py-1 text-sm text-foreground outline-none placeholder:text-muted-foreground/60 disabled:cursor-not-allowed disabled:opacity-50"
+          style={{ height: inputHeight, maxHeight: inputHeight }}
+          placeholder={placeholder}
+          disabled={inputDisabled}
+          rows={1}
+          onChange={handleTextareaChange}
+          onKeyDown={handleKeyDown}
+          aria-label="引用输入框"
+        />
+      </div>
+
+      <div
+        data-reference-input-footer
+        className="flex items-center justify-between gap-2 border-t px-2 py-1.5"
+      >
+        <button
+          type="button"
+          className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-primary disabled:cursor-not-allowed disabled:opacity-50"
+          onClick={() => onAtTrigger?.()}
+          disabled={inputDisabled}
+          title="引用内容"
+          aria-label="引用内容"
+        >
+          <AtSign className="h-4 w-4" />
+        </button>
+
+        <div className="ml-auto flex min-w-0 shrink-0 items-center gap-2">
+          {rightControls}
+          {isStreaming ? (
+            <button
+              type="button"
+              className="rounded-md border border-destructive/40 p-1.5 text-destructive hover:bg-destructive/10 disabled:cursor-not-allowed disabled:opacity-50"
+              disabled={!onStop}
+              onClick={onStop}
+              title="停止生成"
+              aria-label="停止生成"
+            >
+              <Square className="h-4 w-4" />
+            </button>
+          ) : (
+            <button
+              type="button"
+              className="rounded-md bg-primary p-1.5 text-primary-foreground hover:bg-primary/90 disabled:cursor-not-allowed disabled:bg-muted disabled:text-muted-foreground"
+              disabled={!canSubmit}
+              onClick={handleSubmit}
+              title="发送消息"
+              aria-label="发送消息"
+            >
+              <ArrowUp className="h-4 w-4" />
+            </button>
+          )}
+        </div>
+      </div>
+    </div>
+  )
+}

+ 211 - 0
src/components/reference/ReferencePickerDialog.spec.tsx

@@ -0,0 +1,211 @@
+// @vitest-environment jsdom
+
+import { act } from "react"
+import { createRoot, type Root } from "react-dom/client"
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
+import { ReferencePickerDialog } from "./ReferencePickerDialog"
+import type { ReferenceProvider } from "@/lib/reference/providers"
+import type { ReferenceToken } from "@/lib/reference/types"
+
+let host: HTMLDivElement
+let root: Root
+
+function makeToken(index: number): ReferenceToken {
+  return {
+    id: `ref-${index}`,
+    category: "chapter",
+    title: `章节${index}`,
+    displayTitle: `章节${index}`,
+    path: `C:/Novel/wiki/chapters/章节${index}.md`,
+  }
+}
+
+const chapterItems = Array.from({ length: 11 }, (_, index) => makeToken(index + 1))
+const technicalChapterItem: ReferenceToken = {
+  id: "ref-technical",
+  category: "chapter",
+  title: "1/chapter-010",
+  displayTitle: "第10章-灯下旧影",
+  path: "C:/Novel/wiki/chapters/第10章-灯下旧影.md",
+}
+
+const providers: ReferenceProvider[] = [
+  {
+    category: "chapter",
+    fetchItems: vi.fn(async () => chapterItems),
+  },
+  {
+    category: "memory",
+    fetchItems: vi.fn(async () => [{
+      id: "memory-1",
+      category: "memory" as const,
+      title: "人物",
+      displayTitle: "人物",
+      path: "C:/Novel/wiki/memory/人物.md",
+    }]),
+  },
+]
+
+async function flush() {
+  await act(async () => {
+    await Promise.resolve()
+  })
+}
+
+beforeEach(() => {
+  host = document.createElement("div")
+  document.body.appendChild(host)
+  root = createRoot(host)
+})
+
+afterEach(() => {
+  act(() => {
+    root.unmount()
+  })
+  host.remove()
+})
+
+describe("ReferencePickerDialog", () => {
+  it("renders nothing when closed", async () => {
+    await act(async () => {
+      root.render(
+        <ReferencePickerDialog
+          open={false}
+          providers={providers}
+          projectPath="C:/Novel"
+          onConfirm={vi.fn()}
+          onClose={vi.fn()}
+        />,
+      )
+    })
+
+    expect(host.textContent).toBe("")
+  })
+
+  it("loads items, filters search, and confirms selected tokens", async () => {
+    const onConfirm = vi.fn()
+    const onClose = vi.fn()
+
+    await act(async () => {
+      root.render(
+        <ReferencePickerDialog
+          open
+          providers={providers}
+          projectPath="C:/Novel"
+          onConfirm={onConfirm}
+          onClose={onClose}
+        />,
+      )
+    })
+    await flush()
+
+    expect(providers[0].fetchItems).toHaveBeenCalledWith("C:/Novel")
+    expect(host.textContent).toContain("选择引用内容")
+    expect(host.textContent).toContain("章节1")
+
+    const search = host.querySelector("input[type='search']") as HTMLInputElement
+    await act(async () => {
+      const valueSetter = Object.getOwnPropertyDescriptor(
+        window.HTMLInputElement.prototype,
+        "value",
+      )?.set
+      valueSetter?.call(search, "章节11")
+      search.dispatchEvent(new Event("input", { bubbles: true }))
+    })
+
+    expect(host.textContent).toContain("章节11")
+    expect(host.textContent).not.toContain("章节1章节2")
+
+    const checkbox = host.querySelector("input[type='checkbox']") as HTMLInputElement
+    await act(async () => {
+      checkbox.click()
+    })
+
+    const confirm = Array.from(host.querySelectorAll("button")).find(
+      (button) => button.textContent === "确认",
+    )
+    await act(async () => {
+      confirm?.click()
+    })
+
+    expect(onConfirm).toHaveBeenCalledWith([expect.objectContaining({ title: "章节11" })])
+    expect(onClose).toHaveBeenCalled()
+  })
+
+  it("uses the app surface styles and clear Chinese labels", async () => {
+    await act(async () => {
+      root.render(
+        <ReferencePickerDialog
+          open
+          providers={providers}
+          projectPath="C:/Novel"
+          onConfirm={vi.fn()}
+          onClose={vi.fn()}
+        />,
+      )
+    })
+    await flush()
+
+    const dialog = host.querySelector<HTMLElement>("[role='dialog']")
+    expect(dialog).toBeTruthy()
+    expect(dialog?.className).toContain("bg-background")
+    expect(dialog?.className).toContain("max-w-[min(920px,calc(100vw-32px))]")
+    expect(host.textContent).toContain("选择引用内容")
+    expect(host.textContent).toContain("章节")
+    expect(host.textContent).toContain("记忆库")
+    expect(host.textContent).toContain("已选 0/10")
+    expect(host.textContent).not.toMatch(/[📄🧠📋🔬⚡💬📝]/u)
+  })
+
+  it("uses the display title as the visible item name instead of technical file names", async () => {
+    const technicalProviders: ReferenceProvider[] = [
+      {
+        category: "chapter",
+        fetchItems: vi.fn(async () => [technicalChapterItem]),
+      },
+    ]
+
+    await act(async () => {
+      root.render(
+        <ReferencePickerDialog
+          open
+          providers={technicalProviders}
+          projectPath="C:/Novel"
+          onConfirm={vi.fn()}
+          onClose={vi.fn()}
+        />,
+      )
+    })
+    await flush()
+
+    expect(host.textContent).toContain("第10章-灯下旧影")
+    expect(host.textContent).not.toContain("1/chapter-010")
+  })
+
+  it("does not select more than the maximum reference count", async () => {
+    await act(async () => {
+      root.render(
+        <ReferencePickerDialog
+          open
+          providers={providers}
+          projectPath="C:/Novel"
+          onConfirm={vi.fn()}
+          onClose={vi.fn()}
+        />,
+      )
+    })
+    await flush()
+
+    const checkboxes = Array.from(host.querySelectorAll("input[type='checkbox']")) as HTMLInputElement[]
+    for (const checkbox of checkboxes) {
+      await act(async () => {
+        checkbox.click()
+      })
+    }
+
+    const checked = Array.from(host.querySelectorAll("input[type='checkbox']:checked"))
+    expect(checked).toHaveLength(10)
+    expect(host.textContent).toContain("已选 10/10")
+    expect(host.textContent).toContain("已达上限")
+  })
+})

+ 225 - 0
src/components/reference/ReferencePickerDialog.tsx

@@ -0,0 +1,225 @@
+import { useEffect, useMemo, useState } from "react"
+import { Search, X } from "lucide-react"
+import type { ReferenceCategory, ReferenceToken } from "@/lib/reference/types"
+import { MAX_REFERENCE_COUNT, REFERENCE_TABS } from "@/lib/reference/types"
+import type { ReferenceProvider } from "@/lib/reference/providers"
+
+interface ReferencePickerDialogProps {
+  open: boolean
+  providers: ReferenceProvider[]
+  projectPath: string
+  onConfirm: (tokens: ReferenceToken[]) => void
+  onClose: () => void
+  defaultTab?: ReferenceCategory
+}
+
+export function ReferencePickerDialog({
+  open,
+  providers,
+  projectPath,
+  onConfirm,
+  onClose,
+  defaultTab = "chapter",
+}: ReferencePickerDialogProps) {
+  const [activeTab, setActiveTab] = useState<ReferenceCategory>(defaultTab)
+  const [items, setItems] = useState<ReferenceToken[]>([])
+  const [selected, setSelected] = useState<ReferenceToken[]>([])
+  const [search, setSearch] = useState("")
+  const [loading, setLoading] = useState(false)
+
+  useEffect(() => {
+    if (!open) return
+    setActiveTab(defaultTab)
+    setSelected([])
+    setSearch("")
+  }, [open, defaultTab])
+
+  useEffect(() => {
+    if (!open) return
+
+    const provider = providers.find((candidate) => candidate.category === activeTab)
+    if (!provider) {
+      setItems([])
+      return
+    }
+
+    let cancelled = false
+    setLoading(true)
+    provider
+      .fetchItems(projectPath)
+      .then((nextItems) => {
+        if (!cancelled) setItems(nextItems)
+      })
+      .catch(() => {
+        if (!cancelled) setItems([])
+      })
+      .finally(() => {
+        if (!cancelled) setLoading(false)
+      })
+
+    return () => {
+      cancelled = true
+    }
+  }, [activeTab, open, projectPath, providers])
+
+  const availableTabs = useMemo(
+    () => REFERENCE_TABS.filter((tab) => providers.some((provider) => provider.category === tab.key)),
+    [providers],
+  )
+
+  const filteredItems = useMemo(() => {
+    const keyword = search.trim().toLowerCase()
+    if (!keyword) return items
+    return items.filter((item) => item.title.toLowerCase().includes(keyword))
+  }, [items, search])
+
+  function toggleItem(item: ReferenceToken) {
+    setSelected((prev) => {
+      const exists = prev.some((selectedItem) => selectedItem.id === item.id)
+      if (exists) return prev.filter((selectedItem) => selectedItem.id !== item.id)
+      if (prev.length >= MAX_REFERENCE_COUNT) return prev
+      return [...prev, item]
+    })
+  }
+
+  function handleConfirm() {
+    onConfirm(selected)
+    onClose()
+  }
+
+  if (!open) return null
+
+  return (
+    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/45 p-4">
+      <div
+        role="dialog"
+        aria-modal="true"
+        aria-labelledby="reference-picker-title"
+        className="flex max-h-[min(720px,calc(100vh-32px))] w-[920px] max-w-[min(920px,calc(100vw-32px))] flex-col overflow-hidden rounded-lg border bg-background text-foreground shadow-2xl"
+      >
+        <div className="flex items-center justify-between border-b px-4 py-3">
+          <div className="min-w-0">
+            <h3 id="reference-picker-title" className="text-base font-semibold">选择引用内容</h3>
+            <p className="mt-0.5 text-xs text-muted-foreground">选择后会插入到 AI 会话输入框,不会自动发送。</p>
+          </div>
+          <button
+            type="button"
+            onClick={onClose}
+            className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground"
+            aria-label="关闭引用选择弹窗"
+          >
+            <X className="h-4 w-4" />
+          </button>
+        </div>
+
+        <div className="flex min-h-0 flex-1 overflow-hidden">
+          <div className="flex w-40 shrink-0 flex-col gap-1 border-r bg-muted/30 p-2">
+            {availableTabs.map((tab) => (
+              <button
+                key={tab.key}
+                type="button"
+                className={`flex items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition-colors ${
+                  activeTab === tab.key
+                    ? "border border-primary/30 bg-background font-medium text-primary shadow-sm"
+                    : "text-muted-foreground hover:bg-accent hover:text-foreground"
+                }`}
+                onClick={() => {
+                  setActiveTab(tab.key)
+                  setSearch("")
+                }}
+              >
+                <span className="truncate">{tab.label}</span>
+              </button>
+            ))}
+          </div>
+
+          <div className="flex min-w-0 flex-1 flex-col">
+            <div className="border-b px-3 py-2">
+              <label className="relative block">
+                <Search className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
+                <input
+                  type="search"
+                  className="w-full rounded-md border bg-background py-1.5 pl-8 pr-2 text-sm outline-none focus:ring-1 focus:ring-ring"
+                  placeholder="搜索引用内容"
+                  value={search}
+                  onChange={(event) => setSearch(event.target.value)}
+                />
+              </label>
+            </div>
+
+            <div className="min-h-[320px] flex-1 overflow-y-auto p-2">
+              {loading ? (
+                <div className="flex h-full items-center justify-center text-sm text-muted-foreground">加载中...</div>
+              ) : filteredItems.length === 0 ? (
+                <div className="flex h-full items-center justify-center rounded-md border border-dashed text-sm text-muted-foreground">
+                  暂无内容
+                </div>
+              ) : (
+                <div className="grid gap-1">
+                  {filteredItems.map((item) => {
+                    const isSelected = selected.some((selectedItem) => selectedItem.id === item.id)
+                    return (
+                      <label
+                        key={item.id}
+                        className={`flex cursor-pointer items-center gap-3 rounded-md border px-3 py-2 text-sm transition-colors ${
+                          isSelected
+                            ? "border-primary/40 bg-primary/10"
+                            : "border-transparent hover:border-border hover:bg-accent/60"
+                        }`}
+                      >
+                        <input
+                          type="checkbox"
+                          checked={isSelected}
+                          onChange={() => toggleItem(item)}
+                          className="h-4 w-4 accent-primary"
+                        />
+                        <span className="min-w-0 flex-1">
+                          <span className="block truncate font-medium">{item.displayTitle || item.title}</span>
+                          {item.path ? (
+                            <span className="mt-0.5 block truncate text-xs text-muted-foreground">{item.path}</span>
+                          ) : null}
+                        </span>
+                      </label>
+                    )
+                  })}
+                </div>
+              )}
+            </div>
+
+            <div className="flex items-center justify-between border-t px-3 py-2 text-xs text-muted-foreground">
+              <span>
+                已选 {selected.length}/{MAX_REFERENCE_COUNT}
+                {selected.length >= MAX_REFERENCE_COUNT && (
+                  <span className="ml-1 text-destructive">(已达上限)</span>
+                )}
+              </span>
+              {selected.length > 0 ? (
+                <span className="max-w-[60%] truncate">
+                  {selected.map((item) => item.displayTitle || item.title).join("、")}
+                </span>
+              ) : null}
+            </div>
+          </div>
+        </div>
+
+        <div className="flex justify-end gap-2 border-t px-4 py-3">
+          <button
+            type="button"
+            className="rounded-md border px-4 py-1.5 text-sm hover:bg-accent"
+            onClick={onClose}
+          >
+            取消
+          </button>
+          <button
+            type="button"
+            className="rounded-md bg-primary px-4 py-1.5 text-sm text-primary-foreground hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
+            disabled={selected.length === 0}
+            onClick={handleConfirm}
+          >
+            确认
+          </button>
+        </div>
+      </div>
+    </div>
+  )
+}

+ 39 - 0
src/components/settings/mcp-section.spec.tsx

@@ -0,0 +1,39 @@
+import { existsSync, readFileSync } from "node:fs"
+import { resolve } from "node:path"
+import { describe, expect, it } from "vitest"
+import zh from "@/i18n/zh.json"
+import en from "@/i18n/en.json"
+
+const settingsViewSource = readFileSync(resolve(__dirname, "settings-view.tsx"), "utf8")
+const mcpSectionPath = resolve(__dirname, "sections/mcp-section.tsx")
+
+describe("MCP settings section", () => {
+  it("adds MCP as an independent settings category", () => {
+    expect(settingsViewSource).toContain('| "mcp"')
+    expect(settingsViewSource).toContain('{ id: "mcp", labelKey: "settings.categories.mcp", icon: Network }')
+    expect(settingsViewSource).toContain('case "mcp":')
+    expect(settingsViewSource).toContain("return <McpSection />")
+  })
+
+  it("provides Chinese and English settings copy", () => {
+    expect(zh.settings.categories.mcp).toBe("MCP 工具")
+    expect(zh.settings.sections.mcp.title).toBe("MCP 工具")
+    expect(zh.settings.sections.mcp.addSample).toBe("添加示例图谱 MCP")
+    expect(en.settings.categories.mcp).toBe("MCP Tools")
+    expect(en.settings.sections.mcp.title).toBe("MCP Tools")
+  })
+
+  it("supports sample add, JSON editing, runtime warnings, and persistence", () => {
+    expect(existsSync(mcpSectionPath)).toBe(true)
+    const source = readFileSync(mcpSectionPath, "utf8")
+
+    expect(source).toContain("createSampleGraphMcpServer")
+    expect(source).toContain("buildMcpRuntime")
+    expect(source).toContain("saveMcpConfig")
+    expect(source).toContain("setMcpConfig")
+    expect(source).toContain("toolJsonDrafts")
+    expect(source).toContain("JSON.stringify(server.tools")
+    expect(source).toContain("textarea")
+    expect(source).toContain("window.confirm")
+  })
+})

+ 44 - 0
src/components/settings/sections/classification-section.spec.tsx

@@ -0,0 +1,44 @@
+import { existsSync, readFileSync } from "node:fs"
+import { resolve } from "node:path"
+import { describe, expect, it } from "vitest"
+
+const classificationSectionPath = resolve(__dirname, "classification-section.tsx")
+const modifyConfirmDialogPath = resolve(__dirname, "../../chat/modify-confirm-dialog.tsx")
+
+describe("ClassificationSection 编辑入口", () => {
+  it("valid 状态下提供 classification.md textarea 编辑区", () => {
+    expect(existsSync(classificationSectionPath)).toBe(true)
+    const source = readFileSync(classificationSectionPath, "utf8")
+
+    expect(source).toContain("readProjectClassificationRaw")
+    expect(source).toContain("editingContent")
+    expect(source).toContain("textarea")
+    expect(source).toContain("classification.md")
+  })
+
+  it("保存前解析 markdown,格式错误时不写入", () => {
+    const source = readFileSync(classificationSectionPath, "utf8")
+
+    expect(source).toContain("deserializeClassificationFromMarkdown(editingContent)")
+    expect(source).toContain("classification.saveInvalid")
+    expect(source).toContain("writeProjectClassification(pp, parsed)")
+  })
+
+  it("恢复默认通过确认对话框写入 DEFAULT_CLASSIFICATION_CONFIG", () => {
+    const source = readFileSync(classificationSectionPath, "utf8")
+
+    expect(source).toContain("ModifyConfirmDialog")
+    expect(source).toContain('type="classification"')
+    expect(source).toContain("DEFAULT_CLASSIFICATION_CONFIG")
+    expect(source).toContain("确认恢复默认配置")
+  })
+
+  it("ModifyConfirmDialog 支持 classification 类型文案", () => {
+    const source = readFileSync(modifyConfirmDialogPath, "utf8")
+
+    expect(source).toContain('"classification"')
+    expect(source).toContain("确认恢复 classification.md")
+    expect(source).toContain("当前配置")
+    expect(source).toContain("默认配置")
+  })
+})

+ 288 - 0
src/components/settings/sections/classification-section.tsx

@@ -0,0 +1,288 @@
+import { useState, useEffect, useCallback, useMemo } from "react"
+import { useTranslation } from "react-i18next"
+import { FileText, AlertTriangle, CheckCircle2, RefreshCw, RotateCcw, Save } from "lucide-react"
+import { Button } from "@/components/ui/button"
+import { ModifyConfirmDialog } from "@/components/chat/modify-confirm-dialog"
+import {
+  initProjectClassification,
+  loadProjectClassification,
+  readProjectClassificationRaw,
+  writeProjectClassification,
+  checkClassificationVersion,
+  DEFAULT_CLASSIFICATION_CONFIG,
+  deserializeClassificationFromMarkdown,
+  serializeClassificationToMarkdown,
+} from "@/lib/novel/classification"
+import { normalizePath } from "@/lib/path-utils"
+
+interface Props {
+  projectPath?: string
+}
+
+export function ClassificationSection({ projectPath }: Props) {
+  const { t } = useTranslation()
+  const [classificationStatus, setClassificationStatus] = useState<
+    "loading" | "not_created" | "valid" | "invalid"
+  >("loading")
+  const [statusMessage, setStatusMessage] = useState("")
+  const [editingContent, setEditingContent] = useState("")
+  const [saveStatus, setSaveStatus] = useState("")
+  const [isBusy, setIsBusy] = useState(false)
+  const [restoreConfirmOpen, setRestoreConfirmOpen] = useState(false)
+
+  const defaultClassificationMarkdown = useMemo(
+    () => serializeClassificationToMarkdown(DEFAULT_CLASSIFICATION_CONFIG),
+    []
+  )
+
+  const checkStatus = useCallback(async () => {
+    if (!projectPath) {
+      setClassificationStatus("not_created")
+      setStatusMessage(t("classification.noProject", "请先打开一个项目"))
+      return
+    }
+    setClassificationStatus("loading")
+    setStatusMessage(t("classification.checking", "正在检查状态..."))
+    try {
+      const pp = normalizePath(projectPath)
+      const config = await loadProjectClassification(pp)
+      if (!config) {
+        setClassificationStatus("not_created")
+        setEditingContent("")
+        setStatusMessage(t("classification.notFound", "未找到 classification.md"))
+      } else {
+        const rawContent = await readProjectClassificationRaw(pp)
+        const versionInfo = checkClassificationVersion(config)
+        setClassificationStatus("valid")
+        setEditingContent(rawContent || serializeClassificationToMarkdown(config))
+        setStatusMessage(
+          versionInfo.upToDate
+            ? t("classification.valid", "classification.md 配置有效")
+            : t("classification.needsUpgrade", `配置版本较旧(${versionInfo.currentVersion}),建议升级`)
+        )
+      }
+    } catch (e) {
+      setClassificationStatus("invalid")
+      setEditingContent("")
+      setStatusMessage(
+        t("classification.error", `加载失败:${e instanceof Error ? e.message : String(e)}`)
+      )
+    }
+  }, [projectPath, t])
+
+  useEffect(() => {
+    checkStatus()
+  }, [checkStatus])
+
+  const handleCreate = useCallback(async () => {
+    if (!projectPath || isBusy) return
+    setIsBusy(true)
+    try {
+      const pp = normalizePath(projectPath)
+      await initProjectClassification(pp)
+      setEditingContent(defaultClassificationMarkdown)
+      setStatusMessage(t("classification.created", "已创建默认 classification.md"))
+      setClassificationStatus("valid")
+    } catch (e) {
+      setStatusMessage(
+        t("classification.createError", `创建失败:${e instanceof Error ? e.message : String(e)}`)
+      )
+    } finally {
+      setIsBusy(false)
+    }
+  }, [projectPath, isBusy, t, defaultClassificationMarkdown])
+
+  const handleUpgrade = useCallback(async () => {
+    if (!projectPath || isBusy) return
+    setIsBusy(true)
+    try {
+      const pp = normalizePath(projectPath)
+      const config = await loadProjectClassification(pp)
+      if (config) {
+        const latestVersion = DEFAULT_CLASSIFICATION_CONFIG.version || "1.0.0"
+        const existingIntents = new Set(config.routes.map((r) => r.intent))
+        const newRoutes = [...config.routes]
+        for (const defaultRoute of DEFAULT_CLASSIFICATION_CONFIG.routes) {
+          if (!existingIntents.has(defaultRoute.intent)) {
+            newRoutes.push({ ...defaultRoute })
+          }
+        }
+        const upgraded = { ...config, routes: newRoutes, version: latestVersion }
+        await writeProjectClassification(pp, upgraded)
+        setEditingContent(serializeClassificationToMarkdown(upgraded))
+        setStatusMessage(t("classification.upgraded", "已升级到最新版本"))
+      }
+    } catch (e) {
+      setStatusMessage(
+        t("classification.upgradeError", `升级失败:${e instanceof Error ? e.message : String(e)}`)
+      )
+    } finally {
+      setIsBusy(false)
+    }
+  }, [projectPath, isBusy, t])
+
+  const handleSave = useCallback(async () => {
+    if (!projectPath || isBusy) return
+    const parsed = deserializeClassificationFromMarkdown(editingContent)
+    if (!parsed) {
+      setSaveStatus(
+        t(
+          "classification.saveInvalid",
+          "格式错误:请保留自动生成路由区块,并至少包含一条有效路由规则"
+        )
+      )
+      return
+    }
+
+    setIsBusy(true)
+    try {
+      const pp = normalizePath(projectPath)
+      await writeProjectClassification(pp, parsed)
+      setEditingContent(serializeClassificationToMarkdown(parsed))
+      setClassificationStatus("valid")
+      setSaveStatus(t("classification.saved", "已保存 classification.md"))
+      setStatusMessage(t("classification.valid", "classification.md 配置有效"))
+    } catch (e) {
+      setSaveStatus(
+        t("classification.saveError", `保存失败:${e instanceof Error ? e.message : String(e)}`)
+      )
+    } finally {
+      setIsBusy(false)
+    }
+  }, [projectPath, isBusy, editingContent, t])
+
+  const handleRestoreDefault = useCallback(async () => {
+    if (!projectPath || isBusy) return
+    setIsBusy(true)
+    try {
+      const pp = normalizePath(projectPath)
+      await writeProjectClassification(pp, DEFAULT_CLASSIFICATION_CONFIG)
+      setEditingContent(defaultClassificationMarkdown)
+      setClassificationStatus("valid")
+      setSaveStatus(t("classification.restored", "已恢复默认 classification.md"))
+      setStatusMessage(t("classification.valid", "classification.md 配置有效"))
+    } catch (e) {
+      setSaveStatus(
+        t("classification.restoreError", `恢复失败:${e instanceof Error ? e.message : String(e)}`)
+      )
+    } finally {
+      setIsBusy(false)
+      setRestoreConfirmOpen(false)
+    }
+  }, [projectPath, isBusy, defaultClassificationMarkdown, t])
+
+  if (!projectPath) {
+    return (
+      <div className="space-y-6">
+        <div>
+          <h2 className="text-xl font-semibold">
+            {t("settings.sections.classification.title", "意图路由配置")}
+          </h2>
+          <p className="mt-1 text-sm text-muted-foreground">
+            {t("classification.noProject", "请先打开一个项目")}
+          </p>
+        </div>
+      </div>
+    )
+  }
+
+  return (
+    <div className="space-y-6">
+      <div>
+        <h2 className="text-xl font-semibold">
+          {t("settings.sections.classification.title", "意图路由配置")}
+        </h2>
+        <p className="mt-1 text-sm text-muted-foreground">
+          {t("settings.sections.classification.description", "管理分类路由配置,控制 AI 会话的数据源加载策略")}
+        </p>
+      </div>
+
+      <div className="rounded-md border bg-muted/20 p-4">
+        <div className="flex items-center gap-2">
+          {classificationStatus === "loading" && <RefreshCw className="h-4 w-4 animate-spin text-muted-foreground" />}
+          {classificationStatus === "valid" && <CheckCircle2 className="h-4 w-4 text-emerald-500" />}
+          {classificationStatus === "invalid" && <AlertTriangle className="h-4 w-4 text-red-500" />}
+          {classificationStatus === "not_created" && <FileText className="h-4 w-4 text-muted-foreground" />}
+          <span className="text-sm">{statusMessage}</span>
+        </div>
+
+        <div className="mt-4 flex flex-wrap gap-2">
+          {classificationStatus === "not_created" && (
+            <Button onClick={handleCreate} disabled={isBusy}>
+              {t("classification.create", "创建默认配置")}
+            </Button>
+          )}
+          {classificationStatus === "valid" && (
+            <Button onClick={handleUpgrade} disabled={isBusy}>
+              {t("classification.upgrade", "升级配置版本")}
+            </Button>
+          )}
+          <Button variant="outline" onClick={checkStatus} disabled={isBusy}>
+            {t("classification.refresh", "重新检查")}
+          </Button>
+        </div>
+      </div>
+
+      {classificationStatus === "valid" && (
+        <div className="rounded-md border bg-muted/10 p-4">
+          <div className="mb-3 flex flex-wrap items-center justify-between gap-3">
+            <div>
+              <h3 className="text-sm font-medium">classification.md</h3>
+              <p className="mt-1 text-xs text-muted-foreground">
+                {t("classification.editorHint", "编辑后保存前会先校验格式")}
+              </p>
+            </div>
+            <div className="flex flex-wrap gap-2">
+              <Button onClick={handleSave} disabled={isBusy} size="sm">
+                <Save className="mr-1.5 h-4 w-4" />
+                {t("classification.save", "保存")}
+              </Button>
+              <Button
+                variant="outline"
+                onClick={() => setRestoreConfirmOpen(true)}
+                disabled={isBusy}
+                size="sm"
+              >
+                <RotateCcw className="mr-1.5 h-4 w-4" />
+                {t("classification.restoreDefault", "恢复默认")}
+              </Button>
+            </div>
+          </div>
+          <textarea
+            value={editingContent}
+            onChange={(e) => setEditingContent(e.target.value)}
+            className="min-h-[320px] w-full resize-y rounded-md border bg-background p-3 font-mono text-xs leading-relaxed outline-none focus:ring-2 focus:ring-ring"
+            spellCheck={false}
+          />
+          {saveStatus && (
+            <p className="mt-2 text-xs text-muted-foreground">{saveStatus}</p>
+          )}
+        </div>
+      )}
+
+      <div className="rounded-md border bg-muted/10 p-4">
+        <h3 className="mb-2 text-sm font-medium text-muted-foreground">
+          {t("classification.faq", "常见问题")}
+        </h3>
+        <ul className="space-y-1 text-xs text-muted-foreground">
+          <li>classification.md 位于项目目录下的 classification/ 文件夹中</li>
+          <li>创建后将设置 AI 会话的数据源加载规则</li>
+          <li>格式错误时软件会回退到默认路由,不会中断聊天</li>
+          <li>编辑后需重新加载项目使更改生效</li>
+        </ul>
+      </div>
+
+      <ModifyConfirmDialog
+        open={restoreConfirmOpen}
+        originalContent={editingContent}
+        modifiedContent={defaultClassificationMarkdown}
+        itemName="classification.md"
+        intentLabel="确认恢复默认配置"
+        type="classification"
+        editable={false}
+        onConfirm={handleRestoreDefault}
+        onCancel={() => setRestoreConfirmOpen(false)}
+      />
+    </div>
+  )
+}

+ 216 - 0
src/components/settings/sections/mcp-section.tsx

@@ -0,0 +1,216 @@
+import { useMemo, useState } from "react"
+import { Plus, Trash2 } from "lucide-react"
+import { useTranslation } from "react-i18next"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { useWikiStore } from "@/stores/wiki-store"
+import {
+  createSampleGraphMcpServer,
+  normalizeMcpConfig,
+  normalizeMcpServerConfig,
+  type McpConfig,
+  type McpServerConfig,
+} from "@/lib/mcp/config"
+import { buildMcpRuntime } from "@/lib/mcp/runtime"
+
+export function McpSection() {
+  const { t } = useTranslation()
+  const mcpConfig = useWikiStore((s) => s.mcpConfig)
+  const setMcpConfig = useWikiStore((s) => s.setMcpConfig)
+  const [savedAt, setSavedAt] = useState<number | null>(null)
+  const [jsonErrors, setJsonErrors] = useState<Record<string, string>>({})
+  const [toolJsonDrafts, setToolJsonDrafts] = useState<Record<string, string>>({})
+  const runtime = useMemo(() => buildMcpRuntime(mcpConfig), [mcpConfig])
+
+  async function persist(next: McpConfig) {
+    const normalized = normalizeMcpConfig(next)
+    const { saveMcpConfig } = await import("@/lib/project-store")
+    setMcpConfig(normalized)
+    await saveMcpConfig(normalized)
+    setSavedAt(Date.now())
+    setTimeout(() => setSavedAt(null), 1500)
+  }
+
+  function updateServer(serverId: string, patch: Partial<McpServerConfig>) {
+    const nextServers = mcpConfig.servers.map((server) => {
+      if (server.id !== serverId) return server
+      const nextServer = { ...server, ...patch }
+      if (patch.id || patch.name) {
+        nextServer.tools = nextServer.tools.map((tool) => ({
+          ...tool,
+          serverId: nextServer.id,
+          serverName: nextServer.name,
+        }))
+      }
+      return normalizeMcpServerConfig(nextServer) ?? server
+    })
+    void persist({ servers: nextServers })
+  }
+
+  function addSampleServer() {
+    const sample = createSampleGraphMcpServer()
+    const existingIds = new Set(mcpConfig.servers.map((server) => server.id))
+    let next = sample
+    let index = 2
+    while (existingIds.has(next.id)) {
+      const id = `graph_${index}`
+      next = {
+        ...sample,
+        id,
+        name: `${sample.name} ${index}`,
+        tools: sample.tools.map((tool) => ({
+          ...tool,
+          serverId: id,
+          serverName: `${sample.name} ${index}`,
+        })),
+      }
+      index += 1
+    }
+    void persist({ servers: [...mcpConfig.servers, next] })
+  }
+
+  function removeServer(serverId: string) {
+    if (!window.confirm(t("settings.sections.mcp.deleteConfirm"))) return
+    void persist({ servers: mcpConfig.servers.filter((server) => server.id !== serverId) })
+    setToolJsonDrafts((prev) => {
+      const next = { ...prev }
+      delete next[serverId]
+      return next
+    })
+  }
+
+  function updateToolsFromJson(server: McpServerConfig, value: string) {
+    setToolJsonDrafts((prev) => ({ ...prev, [server.id]: value }))
+    try {
+      const parsed = JSON.parse(value)
+      const normalized = normalizeMcpServerConfig({
+        ...server,
+        tools: parsed,
+      })
+      if (!normalized) throw new Error(t("settings.sections.mcp.invalidTools"))
+      setJsonErrors((prev) => {
+        const next = { ...prev }
+        delete next[server.id]
+        return next
+      })
+      setToolJsonDrafts((prev) => {
+        const next = { ...prev }
+        delete next[server.id]
+        return next
+      })
+      updateServer(server.id, { tools: normalized.tools })
+    } catch (error) {
+      setJsonErrors((prev) => ({
+        ...prev,
+        [server.id]: error instanceof Error ? error.message : t("settings.sections.mcp.invalidJson"),
+      }))
+    }
+  }
+
+  return (
+    <div className="space-y-4">
+      <div className="flex items-start justify-between gap-3">
+        <div>
+          <h2 className="text-xl font-semibold">{t("settings.sections.mcp.title")}</h2>
+          <p className="mt-1 text-sm text-muted-foreground">
+            {t("settings.sections.mcp.description")}
+          </p>
+        </div>
+        <Button type="button" size="sm" className="shrink-0 gap-2" onClick={addSampleServer}>
+          <Plus className="h-4 w-4" />
+          {t("settings.sections.mcp.addSample")}
+        </Button>
+      </div>
+
+      <div className="rounded-md border bg-muted/30 p-3 text-xs text-muted-foreground">
+        {t("settings.sections.mcp.summary", {
+          servers: mcpConfig.servers.length,
+          tools: runtime.mcpTools.length,
+          capabilities: runtime.mcpCapabilities.length,
+        })}
+        {savedAt ? <span className="ml-2 text-emerald-600">{t("settings.sections.mcp.saved")}</span> : null}
+      </div>
+
+      {runtime.warnings.length > 0 ? (
+        <div className="space-y-1 rounded-md border border-amber-400/40 bg-amber-50 p-3 text-xs text-amber-800 dark:bg-amber-950/30 dark:text-amber-200">
+          <div className="font-medium">{t("settings.sections.mcp.warnings")}</div>
+          {runtime.warnings.map((warning) => (
+            <div key={warning}>{warning}</div>
+          ))}
+        </div>
+      ) : null}
+
+      {mcpConfig.servers.length === 0 ? (
+        <div className="rounded-md border border-dashed p-6 text-center text-sm text-muted-foreground">
+          {t("settings.sections.mcp.empty")}
+        </div>
+      ) : (
+        <div className="space-y-3">
+          {mcpConfig.servers.map((server) => (
+            <div key={server.id} className="rounded-md border p-3">
+              <div className="mb-3 flex items-center gap-2">
+                <button
+                  type="button"
+                  onClick={() => updateServer(server.id, { enabled: !server.enabled })}
+                  className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full border transition-colors ${
+                    server.enabled
+                      ? "border-primary bg-primary"
+                      : "border-muted-foreground/30 bg-muted-foreground/20 hover:bg-muted-foreground/30"
+                  }`}
+                  aria-label={server.enabled ? t("settings.sections.mcp.disable") : t("settings.sections.mcp.enable")}
+                >
+                  <span
+                    className={`inline-block h-3.5 w-3.5 rounded-full bg-white shadow-sm ring-1 ring-black/10 transition-transform ${
+                      server.enabled ? "translate-x-4" : "translate-x-0.5"
+                    }`}
+                  />
+                </button>
+                <div className="min-w-0 flex-1">
+                  <div className="truncate text-sm font-medium">{server.name}</div>
+                  <div className="truncate text-xs text-muted-foreground">{server.id}</div>
+                </div>
+                <Button
+                  type="button"
+                  variant="ghost"
+                  size="icon"
+                  onClick={() => removeServer(server.id)}
+                  aria-label={t("settings.sections.mcp.delete")}
+                  title={t("settings.sections.mcp.delete")}
+                >
+                  <Trash2 className="h-4 w-4" />
+                </Button>
+              </div>
+
+              <div className="grid gap-3 md:grid-cols-2">
+                <div className="space-y-1.5">
+                  <Label>{t("settings.sections.mcp.serverId")}</Label>
+                  <Input value={server.id} onChange={(event) => updateServer(server.id, { id: event.target.value })} />
+                </div>
+                <div className="space-y-1.5">
+                  <Label>{t("settings.sections.mcp.serverName")}</Label>
+                  <Input value={server.name} onChange={(event) => updateServer(server.id, { name: event.target.value })} />
+                </div>
+              </div>
+
+              <div className="mt-3 space-y-1.5">
+                <Label>{t("settings.sections.mcp.toolsJson")}</Label>
+                <textarea
+                  value={toolJsonDrafts[server.id] ?? JSON.stringify(server.tools, null, 2)}
+                  onChange={(event) => updateToolsFromJson(server, event.target.value)}
+                  className="min-h-44 w-full resize-y rounded-md border bg-background px-3 py-2 font-mono text-xs outline-none focus:ring-2 focus:ring-ring"
+                  spellCheck={false}
+                />
+                {jsonErrors[server.id] ? (
+                  <p className="text-xs text-destructive">{jsonErrors[server.id]}</p>
+                ) : (
+                  <p className="text-xs text-muted-foreground">{t("settings.sections.mcp.toolsJsonHint")}</p>
+                )}
+              </div>
+            </div>
+          ))}
+        </div>
+      )}
+    </div>
+  )
+}

+ 12 - 1
src/components/settings/settings-view.tsx

@@ -12,6 +12,7 @@ import {
   MessageCircle,
   HeartHandshake,
   Archive,
+  FileText,
 } from "lucide-react"
 import { useTranslation } from "react-i18next"
 import i18n from "@/i18n"
@@ -30,7 +31,9 @@ import { EmbeddingSection } from "./sections/embedding-section"
 import { RerankSection } from "./sections/rerank-section"
 import { InterfaceSection } from "./sections/interface-section"
 import { NovelSection } from "./sections/novel-section"
+import { ClassificationSection } from "./sections/classification-section"
 import { NetworkSection } from "./sections/network-section"
+import { McpSection } from "./sections/mcp-section"
 import { ChangelogSection } from "./sections/changelog-section"
 import { MaintenanceSection } from "./sections/maintenance-section"
 import { FeedbackSection } from "./sections/feedback-section"
@@ -43,6 +46,7 @@ type CategoryId =
   | "rerank"
   | "embedding"
   | "network"
+  | "mcp"
   | "interface"
   | "novel"
   | "usage-guide"
@@ -50,7 +54,8 @@ type CategoryId =
   | "data-management"
   | "feedback"
   | "contact-support"
-  | "changelog"
+  | "classification"
+| "changelog"
 
 interface Category {
   id: CategoryId
@@ -66,6 +71,7 @@ const CATEGORIES: Category[] = [
   { id: "rerank", labelKey: "settings.categories.rerank", icon: ListFilter },
   { id: "embedding", labelKey: "settings.categories.embedding", icon: Database },
   { id: "network", labelKey: "settings.categories.network", icon: Network },
+  { id: "mcp", labelKey: "settings.categories.mcp", icon: Network },
   { id: "interface", labelKey: "settings.categories.interface", icon: Palette },
   { id: "novel", labelKey: "settings.categories.novel", icon: BookOpen },
   { id: "usage-guide", labelKey: "settings.categories.usageGuide", icon: HelpCircle },
@@ -73,6 +79,7 @@ const CATEGORIES: Category[] = [
   { id: "data-management", labelKey: "settings.categories.dataManagement", icon: Archive },
   { id: "feedback", labelKey: "settings.categories.feedback", icon: MessageCircle },
   { id: "contact-support", labelKey: "settings.categories.contactSupport", icon: HeartHandshake },
+  { id: "classification", labelKey: "settings.categories.classification", icon: FileText },
   { id: "changelog", labelKey: "settings.categories.changelog", icon: History },
 ]
 
@@ -505,10 +512,14 @@ export function SettingsView() {
         return <EmbeddingSection draft={draft} setDraft={setDraft} />
       case "network":
         return <NetworkSection draft={draft} setDraft={setDraft} />
+      case "mcp":
+        return <McpSection />
       case "interface":
         return <InterfaceSection draft={draft} setDraft={setDraft} />
       case "novel":
         return <NovelSection draft={draft} setDraft={setDraft} />
+      case "classification":
+        return <ClassificationSection projectPath={project?.path ?? undefined} />
       case "usage-guide":
         return <UsageGuideSection />
       case "maintenance":

+ 3 - 0
src/components/skill-library/skill-library-view.spec.tsx

@@ -114,6 +114,9 @@ describe("SkillLibraryView", () => {
     expect(container.querySelector('[data-testid="skill-library-view"]')).not.toBeNull()
     expect(container.textContent).toContain("综合去AI味")
     expect(container.querySelector<HTMLInputElement>('[data-testid="skill-name-input"]')?.value).toBe("综合去AI味")
+    expect(container.textContent).toContain("类型:风格")
+    expect(container.textContent).toContain("阶段:改写、输出")
+    expect(container.textContent).toContain("模式:快速、标准、严格")
 
     cleanup(root, container)
   })

+ 16 - 0
src/components/skill-library/skill-library-view.tsx

@@ -13,9 +13,11 @@ import {
   setDeAiSkillEnabled,
   setDefaultDeAiSkill,
   updateDeAiSkill,
+  deAiSkillToUserSkill,
   type DeAiSkill,
   type DeAiSkillConfig,
 } from "@/lib/novel/de-ai-skill-library"
+import { SKILL_KIND_LABELS, SKILL_MODE_LABELS, SKILL_STAGE_LABELS } from "@/lib/novel/skill-library"
 import { confirmDiscardSkillLibraryDraft, useWikiStore } from "@/stores/wiki-store"
 
 function sourceLabel(skill: DeAiSkill): string {
@@ -249,6 +251,7 @@ export function SkillLibraryView() {
   const selectedSkill = allSkills.find((skill) => skill.id === selectedSkillId) ?? allSkills[0] ?? null
   const selectedIsEditable = Boolean(project && selectedSkill)
   const selectedIsBuiltIn = selectedSkill?.id.startsWith("built-in:") ?? false
+  const selectedGenericSkill = selectedSkill ? deAiSkillToUserSkill(selectedSkill) : null
   const selectedHasBuiltInOverride = selectedIsBuiltIn
     && Boolean(config?.builtInSkillOverrides.some((skill) => skill.id === selectedSkill?.id))
   const selectedModified = Boolean(config && selectedSkill && isDeAiSkillModified(config, selectedSkill.id))
@@ -470,6 +473,19 @@ export function SkillLibraryView() {
                     </span>
                   ) : null}
                 </div>
+                {selectedGenericSkill ? (
+                  <div className="mt-2 flex flex-wrap gap-1.5 text-xs text-muted-foreground">
+                    <span className="rounded bg-muted px-2 py-0.5">
+                      类型:{selectedGenericSkill.kind.map((kind) => SKILL_KIND_LABELS[kind]).join("、")}
+                    </span>
+                    <span className="rounded bg-muted px-2 py-0.5">
+                      阶段:{selectedGenericSkill.stages.map((stage) => SKILL_STAGE_LABELS[stage]).join("、")}
+                    </span>
+                    <span className="rounded bg-muted px-2 py-0.5">
+                      模式:{selectedGenericSkill.modes.map((mode) => SKILL_MODE_LABELS[mode]).join("、")}
+                    </span>
+                  </div>
+                ) : null}
               </div>
               <div className="flex flex-wrap gap-2">
                 <button

+ 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>
+  )
+}

+ 172 - 0
src/components/skill-library/writing-skill-library-view.spec.tsx

@@ -0,0 +1,172 @@
+// @vitest-environment jsdom
+
+import { act } from "react"
+import { createRoot, type Root } from "react-dom/client"
+import { beforeEach, describe, expect, it, vi } from "vitest"
+import { useWikiStore } from "@/stores/wiki-store"
+import { WritingSkillLibrarySidebarPanel, WritingSkillLibraryView } from "./writing-skill-library-view"
+
+(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+
+const readFileMock = vi.hoisted(() => vi.fn())
+const writeFileAtomicMock = vi.hoisted(() => vi.fn())
+const joinMock = vi.hoisted(() => vi.fn(async (...parts: string[]) => parts.join("/")))
+let savedConfigContent = ""
+
+vi.mock("@/commands/fs", () => ({
+  readFile: readFileMock,
+  writeFileAtomic: writeFileAtomicMock,
+}))
+
+vi.mock("@tauri-apps/api/path", () => ({
+  join: joinMock,
+}))
+
+async function renderLibrary() {
+  const container = document.createElement("div")
+  document.body.appendChild(container)
+  const root = createRoot(container)
+  await act(async () => {
+    root.render(
+      <>
+        <WritingSkillLibrarySidebarPanel />
+        <WritingSkillLibraryView />
+      </>,
+    )
+  })
+  await flushEffects()
+  return { container, root }
+}
+
+function cleanup(root: Root, container: HTMLElement) {
+  act(() => root.unmount())
+  document.body.removeChild(container)
+}
+
+async function flushEffects() {
+  await act(async () => {
+    await Promise.resolve()
+    await Promise.resolve()
+  })
+}
+
+async function setInputValue(input: HTMLInputElement, value: string) {
+  await act(async () => {
+    const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set
+    valueSetter?.call(input, value)
+    input.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: value }))
+    input.dispatchEvent(new Event("change", { bubbles: true }))
+  })
+}
+
+async function setTextareaValue(textarea: HTMLTextAreaElement, value: string) {
+  await act(async () => {
+    const valueSetter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set
+    valueSetter?.call(textarea, value)
+    textarea.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText" }))
+    textarea.dispatchEvent(new Event("change", { bubbles: true }))
+  })
+}
+
+async function click(button: HTMLElement | null) {
+  if (!button) throw new Error("button not found")
+  await act(async () => {
+    button.dispatchEvent(new MouseEvent("click", { bubbles: true }))
+  })
+  await flushEffects()
+}
+
+describe("WritingSkillLibraryView", () => {
+  beforeEach(() => {
+    vi.clearAllMocks()
+    savedConfigContent = ""
+    readFileMock.mockImplementation(async () => {
+      if (savedConfigContent) return savedConfigContent
+      throw new Error("missing")
+    })
+    writeFileAtomicMock.mockImplementation(async (_path: string, content: string) => {
+      savedConfigContent = content
+    })
+    useWikiStore.getState().setProject({
+      id: "p1",
+      name: "测试项目",
+      path: "C:/project",
+    })
+    ;(useWikiStore.getState() as any).setWritingSkillLibraryDraftDirty?.(false)
+    ;(useWikiStore.getState() as any).setSelectedWritingSkillLibrarySkillId?.(null)
+  })
+
+  it("creates and saves a classified writing skill", async () => {
+    const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1234)
+    const { container, root } = await renderLibrary()
+
+    expect(container.querySelector('[data-testid="writing-skill-library-view"]')).not.toBeNull()
+    expect(container.textContent).toContain("写作 Skill")
+
+    const createButton = Array.from(container.querySelectorAll<HTMLButtonElement>("button"))
+      .find((button) => button.textContent?.includes("新建 Skill"))
+    await click(createButton ?? null)
+
+    await setInputValue(container.querySelector('[data-testid="writing-skill-name-input"]')!, "三翻四抖")
+    await setInputValue(container.querySelector('[data-testid="writing-skill-description-input"]')!, "三次转折,四次震惊。")
+    await setTextareaValue(
+      container.querySelector('[data-testid="writing-skill-content-input"]')!,
+      "每章设置三次局势变化和四次信息冲击。",
+    )
+    await click(container.querySelector('[data-testid="writing-skill-kind-review"]'))
+    await click(container.querySelector('[data-testid="writing-skill-stage-review"]'))
+    await click(container.querySelector('[data-testid="writing-skill-mode-fast"]'))
+
+    await click(container.querySelector('[data-testid="writing-skill-save-button"]'))
+
+    const saved = JSON.parse(savedConfigContent)
+    expect(saved.skills[0]).toMatchObject({
+      id: "skill:1234",
+      name: "三翻四抖",
+      description: "三次转折,四次震惊。",
+      kind: ["structure", "planning", "review"],
+      stages: ["planning", "drafting", "review"],
+      modes: ["standard", "strict", "fast"],
+      content: "每章设置三次局势变化和四次信息冲击。",
+      source: "uploaded",
+    })
+
+    nowSpy.mockRestore()
+    cleanup(root, container)
+  })
+
+  it("disables and deletes writing skills from the separate library", async () => {
+    savedConfigContent = JSON.stringify({
+      version: 1,
+      selectedSkillId: "skill:three",
+      disabledSkillIds: [],
+      skills: [{
+        id: "skill:three",
+        name: "三翻四抖",
+        description: "三次转折,四次震惊。",
+        kind: ["structure", "planning"],
+        stages: ["planning", "drafting"],
+        modes: ["standard", "strict"],
+        content: "每章设置三次局势变化和四次信息冲击。",
+        source: "uploaded",
+      }],
+    })
+    const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true)
+    const { container, root } = await renderLibrary()
+
+    const enabled = container.querySelector<HTMLInputElement>('[data-testid="writing-skill-enabled-checkbox"]')
+    expect(enabled?.checked).toBe(true)
+    await click(enabled)
+    expect(JSON.parse(savedConfigContent).disabledSkillIds).toEqual(["skill:three"])
+
+    await click(container.querySelector('[data-testid="writing-skill-delete-button"]'))
+    const saved = JSON.parse(savedConfigContent)
+    expect(saved.skills.length).toBeGreaterThanOrEqual(10)
+    expect(saved.skills.find((s: any) => s.id === "skill:three")).toBeUndefined()
+    expect(saved.skills[0].source).toBe("built-in")
+    expect(saved.selectedSkillId).toBe(saved.skills[0].id)
+
+    confirmSpy.mockRestore()
+    cleanup(root, container)
+  })
+})

+ 528 - 0
src/components/skill-library/writing-skill-library-view.tsx

@@ -0,0 +1,528 @@
+import { useEffect, useMemo, useState } from "react"
+import {
+  createBlankWritingSkill,
+  deleteWritingSkill,
+  loadUserSkillConfig,
+  resolveEnabledWritingSkills,
+  saveUserSkillConfig,
+  setWritingSkillEnabled,
+  updateWritingSkill,
+  WRITING_SKILL_KIND_OPTIONS,
+  WRITING_SKILL_MODE_OPTIONS,
+  WRITING_SKILL_STAGE_OPTIONS,
+  type UserSkillConfig,
+} from "@/lib/novel/user-skill-store"
+import {
+  SKILL_KIND_LABELS,
+  SKILL_MODE_LABELS,
+  SKILL_STAGE_LABELS,
+  type SkillKind,
+  type SkillMode,
+  type SkillStage,
+  type UserSkill,
+} from "@/lib/novel/skill-library"
+import { confirmDiscardSkillLibraryDraft, useWikiStore } from "@/stores/wiki-store"
+
+function resolveInitialSkillId(config: UserSkillConfig, requested: string | null): string | null {
+  if (requested && config.skills.some((skill) => skill.id === requested)) return requested
+  return config.selectedSkillId ?? config.skills[0]?.id ?? null
+}
+
+function hasDraftChanged(
+  skill: UserSkill,
+  name: string,
+  description: string,
+  content: string,
+  kind: SkillKind[],
+  stages: SkillStage[],
+  modes: SkillMode[],
+): boolean {
+  return name.trim() !== skill.name
+    || description.trim() !== skill.description
+    || content.trim() !== skill.content
+    || kind.join("|") !== skill.kind.join("|")
+    || stages.join("|") !== skill.stages.join("|")
+    || modes.join("|") !== skill.modes.join("|")
+}
+
+function toggleValue<T extends string>(values: T[], value: T): T[] {
+  return values.includes(value)
+    ? values.filter((item) => item !== value)
+    : [...values, value]
+}
+
+function OptionCheckbox({
+  label,
+  checked,
+  testId,
+  onToggle,
+}: {
+  label: string
+  checked: boolean
+  testId: string
+  onToggle: () => void
+}) {
+  return (
+    <label className="inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs text-muted-foreground">
+      <input
+        data-testid={testId}
+        type="checkbox"
+        checked={checked}
+        onChange={onToggle}
+        className="h-3.5 w-3.5 accent-primary"
+      />
+      {label}
+    </label>
+  )
+}
+
+function useWritingSkillConfig() {
+  const projectPath = useWikiStore((s) => s.project?.path)
+  const dataVersion = useWikiStore((s) => s.dataVersion)
+  const selectedSkillId = useWikiStore((s) => s.selectedWritingSkillLibrarySkillId)
+  const setSelectedSkillId = useWikiStore((s) => s.setSelectedWritingSkillLibrarySkillId)
+  const [config, setConfig] = useState<UserSkillConfig | null>(null)
+  const [loadError, setLoadError] = useState("")
+
+  useEffect(() => {
+    let cancelled = false
+    setConfig(null)
+    setLoadError("")
+    loadUserSkillConfig(projectPath)
+      .then((loaded) => {
+        if (cancelled) return
+        setConfig(loaded)
+        setSelectedSkillId(resolveInitialSkillId(loaded, selectedSkillId))
+      })
+      .catch(() => {
+        if (cancelled) return
+        setConfig(null)
+        setLoadError("写作 Skill 加载失败")
+      })
+    return () => {
+      cancelled = true
+    }
+  }, [dataVersion, projectPath])
+
+  return { config, setConfig, loadError }
+}
+
+export function WritingSkillLibrarySidebarPanel() {
+  const project = useWikiStore((s) => s.project)
+  const bumpDataVersion = useWikiStore((s) => s.bumpDataVersion)
+  const selectedSkillId = useWikiStore((s) => s.selectedWritingSkillLibrarySkillId)
+  const setSelectedSkillId = useWikiStore((s) => s.setSelectedWritingSkillLibrarySkillId)
+  const draftDirty = useWikiStore((s) => s.writingSkillLibraryDraftDirty)
+  const setDraftDirty = useWikiStore((s) => s.setWritingSkillLibraryDraftDirty)
+  const { config, setConfig, loadError } = useWritingSkillConfig()
+  const [message, setMessage] = useState("")
+  const [saving, setSaving] = useState(false)
+
+  const disabledSkillIds = new Set(config?.disabledSkillIds ?? [])
+
+  async function persist(nextConfig: UserSkillConfig, nextSelectedSkillId: string | null) {
+    if (!project) {
+      setMessage("请先打开项目")
+      return
+    }
+    setSaving(true)
+    try {
+      await saveUserSkillConfig(project.path, nextConfig)
+      setConfig(nextConfig)
+      setSelectedSkillId(nextSelectedSkillId)
+      setMessage("已保存")
+      bumpDataVersion()
+    } catch {
+      setMessage("写作 Skill 保存失败")
+    } finally {
+      setSaving(false)
+    }
+  }
+
+  async function handleCreateSkill() {
+    if (!config || !project || saving) return
+    if (draftDirty && !confirmDiscardSkillLibraryDraft()) return
+    if (draftDirty) setDraftDirty(false)
+    const next = createBlankWritingSkill(config)
+    await persist(next, next.selectedSkillId)
+  }
+
+  async function handleToggleSkill(skill: UserSkill, enabled: boolean) {
+    if (!config || !project || saving) return
+    if (draftDirty && !confirmDiscardSkillLibraryDraft()) return
+    if (draftDirty) setDraftDirty(false)
+    const next = setWritingSkillEnabled(config, skill.id, enabled)
+    await persist(next, selectedSkillId ?? next.selectedSkillId)
+  }
+
+  return (
+    <div data-testid="writing-skill-library-sidebar" className="flex h-full flex-col overflow-hidden">
+      <div className="shrink-0 border-b px-3 py-2">
+        <h1 className="text-sm font-semibold">写作 Skill</h1>
+        <p className="mt-0.5 text-xs text-muted-foreground">管理 AI 会话自动使用的写作方法。</p>
+      </div>
+      <div className="flex shrink-0 items-center justify-between border-b px-3 py-2">
+        <div className="text-sm font-medium">写作 Skill</div>
+        <button
+          type="button"
+          onClick={() => void handleCreateSkill()}
+          disabled={!config || !project || saving}
+          className="rounded-md border px-2 py-1 text-xs hover:bg-accent disabled:opacity-50"
+        >
+          新建 Skill
+        </button>
+      </div>
+      {loadError || message ? (
+        <div className="border-b px-3 py-2 text-xs text-muted-foreground">{loadError || message}</div>
+      ) : null}
+      <div className="min-h-0 flex-1 overflow-y-auto p-2">
+        {config && config.skills.length === 0 ? (
+          <div className="rounded-md border border-dashed p-3 text-xs leading-5 text-muted-foreground">
+            还没有写作 Skill。可以新建“三翻四抖”“章节计划”“伏笔检查”等规则。
+          </div>
+        ) : null}
+        {config?.skills.map((skill) => {
+          const active = skill.id === selectedSkillId
+          const enabled = !disabledSkillIds.has(skill.id)
+          return (
+            <div
+              key={skill.id}
+              role="button"
+              tabIndex={0}
+              onClick={() => setSelectedSkillId(skill.id)}
+              onKeyDown={(event) => {
+                if (event.key === "Enter" || event.key === " ") {
+                  event.preventDefault()
+                  setSelectedSkillId(skill.id)
+                }
+              }}
+              className={`mb-2 rounded-md border px-3 py-2 text-left transition-colors hover:bg-accent ${
+                active ? "border-primary bg-accent/60" : "border-border"
+              }`}
+            >
+              <div className="flex items-center gap-2">
+                <span className="min-w-0 flex-1 truncate text-sm font-medium">{skill.name}</span>
+                <span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">写作</span>
+              </div>
+              <div className="mt-1 truncate text-xs text-muted-foreground">{skill.description || "未填写说明"}</div>
+              <label className="mt-2 flex items-center gap-2 text-xs text-muted-foreground">
+                <input
+                  type="checkbox"
+                  checked={enabled}
+                  onClick={(event) => event.stopPropagation()}
+                  onChange={(event) => void handleToggleSkill(skill, event.target.checked)}
+                  className="h-3.5 w-3.5 accent-primary"
+                  disabled={saving}
+                />
+                参与 AI 会话
+              </label>
+            </div>
+          )
+        })}
+      </div>
+    </div>
+  )
+}
+
+export function WritingSkillLibraryView() {
+  const project = useWikiStore((s) => s.project)
+  const bumpDataVersion = useWikiStore((s) => s.bumpDataVersion)
+  const selectedSkillId = useWikiStore((s) => s.selectedWritingSkillLibrarySkillId)
+  const setSelectedSkillId = useWikiStore((s) => s.setSelectedWritingSkillLibrarySkillId)
+  const draftDirty = useWikiStore((s) => s.writingSkillLibraryDraftDirty)
+  const setDraftDirty = useWikiStore((s) => s.setWritingSkillLibraryDraftDirty)
+  const { config, setConfig, loadError } = useWritingSkillConfig()
+  const [draftName, setDraftName] = useState("")
+  const [draftDescription, setDraftDescription] = useState("")
+  const [draftContent, setDraftContent] = useState("")
+  const [draftKind, setDraftKind] = useState<SkillKind[]>([])
+  const [draftStages, setDraftStages] = useState<SkillStage[]>([])
+  const [draftModes, setDraftModes] = useState<SkillMode[]>([])
+  const [message, setMessage] = useState("")
+  const [saving, setSaving] = useState(false)
+
+  const selectedSkill = useMemo(
+    () => config?.skills.find((skill) => skill.id === selectedSkillId) ?? config?.skills[0] ?? null,
+    [config, selectedSkillId],
+  )
+  const enabledSkillIds = new Set(resolveEnabledWritingSkills(config ?? {
+    version: 1,
+    selectedSkillId: null,
+    disabledSkillIds: [],
+    skills: [],
+  }).map((skill) => skill.id))
+  const selectedEnabled = selectedSkill ? enabledSkillIds.has(selectedSkill.id) : false
+  const draftChanged = Boolean(
+    selectedSkill && hasDraftChanged(
+      selectedSkill,
+      draftName,
+      draftDescription,
+      draftContent,
+      draftKind,
+      draftStages,
+      draftModes,
+    ),
+  )
+  const canSaveDraft = Boolean(project && config && selectedSkill && draftChanged && !saving)
+
+  useEffect(() => {
+    if (!selectedSkill) {
+      setDraftName("")
+      setDraftDescription("")
+      setDraftContent("")
+      setDraftKind([])
+      setDraftStages([])
+      setDraftModes([])
+      setDraftDirty(false)
+      return
+    }
+    setDraftName(selectedSkill.name)
+    setDraftDescription(selectedSkill.description)
+    setDraftContent(selectedSkill.content)
+    setDraftKind(selectedSkill.kind)
+    setDraftStages(selectedSkill.stages)
+    setDraftModes(selectedSkill.modes)
+    setDraftDirty(false)
+    setMessage("")
+  }, [selectedSkill?.id, selectedSkill?.name, selectedSkill?.description, selectedSkill?.content])
+
+  function updateDraftDirty(
+    name = draftName,
+    description = draftDescription,
+    content = draftContent,
+    kind = draftKind,
+    stages = draftStages,
+    modes = draftModes,
+  ) {
+    setDraftDirty(selectedSkill
+      ? hasDraftChanged(selectedSkill, name, description, content, kind, stages, modes)
+      : false)
+  }
+
+  async function persist(nextConfig: UserSkillConfig, nextSelectedSkillId = selectedSkillId) {
+    if (!project) {
+      setMessage("请先打开项目")
+      return
+    }
+    setSaving(true)
+    try {
+      await saveUserSkillConfig(project.path, nextConfig)
+      setConfig(nextConfig)
+      setSelectedSkillId(nextSelectedSkillId)
+      setDraftDirty(false)
+      setMessage("已保存")
+      bumpDataVersion()
+    } catch {
+      setMessage("写作 Skill 保存失败")
+    } finally {
+      setSaving(false)
+    }
+  }
+
+  async function handleSaveSkill() {
+    if (!config || !selectedSkill || !canSaveDraft) return
+    const name = draftName.trim()
+    const content = draftContent.trim()
+    if (!name) {
+      setMessage("Skill 名称不能为空")
+      return
+    }
+    if (!content) {
+      setMessage("规则正文不能为空")
+      return
+    }
+    await persist(updateWritingSkill(config, selectedSkill.id, {
+      name,
+      description: draftDescription.trim(),
+      content,
+      kind: draftKind,
+      stages: draftStages,
+      modes: draftModes,
+    }))
+  }
+
+  async function handleToggleEnabled(enabled: boolean) {
+    if (!config || !selectedSkill || !project || saving) return
+    if (draftDirty && !confirmDiscardSkillLibraryDraft()) return
+    if (draftDirty) setDraftDirty(false)
+    await persist(setWritingSkillEnabled(config, selectedSkill.id, enabled), selectedSkill.id)
+  }
+
+  async function handleDeleteSkill() {
+    if (!config || !selectedSkill || !project || saving) return
+    if (draftDirty && !confirmDiscardSkillLibraryDraft()) return
+    if (draftDirty) setDraftDirty(false)
+    const confirmed = window.confirm(`确定删除「${selectedSkill.name}」吗?`)
+    if (!confirmed) return
+    const next = deleteWritingSkill(config, selectedSkill.id)
+    await persist(next, next.selectedSkillId)
+  }
+
+  return (
+    <div data-testid="writing-skill-library-view" className="flex h-full flex-col overflow-hidden">
+      <div className="shrink-0 border-b px-5 py-4">
+        <h1 className="text-lg font-semibold">写作 Skill</h1>
+        <p className="mt-1 text-sm text-muted-foreground">编辑 AI 会话会自动选择的通用写作 Skill。</p>
+        {!project ? <p className="mt-1 text-sm text-destructive">请先打开项目</p> : null}
+      </div>
+      <main className="min-h-0 flex-1 overflow-y-auto p-5">
+        {loadError ? (
+          <div className="rounded-md border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
+            {loadError}
+          </div>
+        ) : !selectedSkill ? (
+          <div className="rounded-md border border-dashed p-5 text-sm text-muted-foreground">
+            还没有写作 Skill。请在左侧新建 Skill。
+          </div>
+        ) : (
+          <div className="mx-auto flex max-w-5xl flex-col gap-4">
+            <div className="flex flex-wrap items-center justify-between gap-2">
+              <div>
+                <div className="text-sm text-muted-foreground">项目写作 Skill</div>
+                <h2 className="text-xl font-semibold">{selectedSkill.name}</h2>
+              </div>
+              <div className="flex flex-wrap items-center gap-3">
+                <label className="flex items-center gap-2 text-sm text-muted-foreground">
+                  <input
+                    data-testid="writing-skill-enabled-checkbox"
+                    type="checkbox"
+                    checked={selectedEnabled}
+                    onChange={(event) => void handleToggleEnabled(event.target.checked)}
+                    className="h-4 w-4 accent-primary"
+                    disabled={saving}
+                  />
+                  参与 AI 会话
+                </label>
+                <button
+                  data-testid="writing-skill-delete-button"
+                  type="button"
+                  onClick={() => void handleDeleteSkill()}
+                  disabled={!project || saving}
+                  className="rounded-md border px-3 py-1.5 text-sm text-destructive hover:bg-destructive/10 disabled:opacity-60"
+                >
+                  删除
+                </button>
+              </div>
+            </div>
+
+            <label className="grid gap-1.5 text-sm">
+              <span className="font-medium">Skill 名称</span>
+              <input
+                data-testid="writing-skill-name-input"
+                value={draftName}
+                onChange={(event) => {
+                  const next = event.target.value
+                  setDraftName(next)
+                  updateDraftDirty(next)
+                }}
+                className="rounded-md border bg-background px-3 py-2 outline-none focus:ring-2 focus:ring-ring"
+              />
+            </label>
+
+            <label className="grid gap-1.5 text-sm">
+              <span className="font-medium">说明</span>
+              <input
+                data-testid="writing-skill-description-input"
+                value={draftDescription}
+                onChange={(event) => {
+                  const next = event.target.value
+                  setDraftDescription(next)
+                  updateDraftDirty(draftName, next)
+                }}
+                className="rounded-md border bg-background px-3 py-2 outline-none focus:ring-2 focus:ring-ring"
+              />
+            </label>
+
+            <div className="grid gap-2 text-sm">
+              <span className="font-medium">类型</span>
+              <div className="flex flex-wrap gap-2">
+                {WRITING_SKILL_KIND_OPTIONS.map((kind) => (
+                  <OptionCheckbox
+                    key={kind}
+                    label={SKILL_KIND_LABELS[kind]}
+                    checked={draftKind.includes(kind)}
+                    testId={`writing-skill-kind-${kind}`}
+                    onToggle={() => {
+                      const next = toggleValue(draftKind, kind)
+                      setDraftKind(next)
+                      updateDraftDirty(draftName, draftDescription, draftContent, next)
+                    }}
+                  />
+                ))}
+              </div>
+            </div>
+
+            <div className="grid gap-2 text-sm">
+              <span className="font-medium">阶段</span>
+              <div className="flex flex-wrap gap-2">
+                {WRITING_SKILL_STAGE_OPTIONS.map((stage) => (
+                  <OptionCheckbox
+                    key={stage}
+                    label={SKILL_STAGE_LABELS[stage]}
+                    checked={draftStages.includes(stage)}
+                    testId={`writing-skill-stage-${stage}`}
+                    onToggle={() => {
+                      const next = toggleValue(draftStages, stage)
+                      setDraftStages(next)
+                      updateDraftDirty(draftName, draftDescription, draftContent, draftKind, next)
+                    }}
+                  />
+                ))}
+              </div>
+            </div>
+
+            <div className="grid gap-2 text-sm">
+              <span className="font-medium">模式</span>
+              <div className="flex flex-wrap gap-2">
+                {WRITING_SKILL_MODE_OPTIONS.map((mode) => (
+                  <OptionCheckbox
+                    key={mode}
+                    label={SKILL_MODE_LABELS[mode]}
+                    checked={draftModes.includes(mode)}
+                    testId={`writing-skill-mode-${mode}`}
+                    onToggle={() => {
+                      const next = toggleValue(draftModes, mode)
+                      setDraftModes(next)
+                      updateDraftDirty(draftName, draftDescription, draftContent, draftKind, draftStages, next)
+                    }}
+                  />
+                ))}
+              </div>
+            </div>
+
+            <label className="grid gap-1.5 text-sm">
+              <span className="font-medium">规则正文</span>
+              <textarea
+                data-testid="writing-skill-content-input"
+                value={draftContent}
+                onChange={(event) => {
+                  const next = event.target.value
+                  setDraftContent(next)
+                  updateDraftDirty(draftName, draftDescription, next)
+                }}
+                className="min-h-[420px] rounded-md border bg-background px-3 py-2 font-mono text-xs leading-5 outline-none focus:ring-2 focus:ring-ring"
+              />
+            </label>
+
+            <div className="flex items-center gap-3">
+              <button
+                data-testid="writing-skill-save-button"
+                type="button"
+                onClick={() => void handleSaveSkill()}
+                disabled={!canSaveDraft}
+                className="rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground disabled:cursor-not-allowed disabled:opacity-60"
+              >
+                {saving ? "保存中..." : "保存"}
+              </button>
+              {message ? (
+                <span className="text-sm text-muted-foreground">{message}</span>
+              ) : draftDirty ? (
+                <span className="text-sm text-amber-700">未保存</span>
+              ) : null}
+            </div>
+          </div>
+        )}
+      </main>
+    </div>
+  )
+}

+ 86 - 3
src/components/sources/outline-chat-panel.spec.tsx

@@ -3,6 +3,7 @@ import { resolve } from "node:path"
 import { describe, expect, it } from "vitest"
 
 const source = readFileSync(resolve(__dirname, "outline-chat-panel.tsx"), "utf8")
+const outlineGenerationSource = readFileSync(resolve(__dirname, "../../lib/novel/outline-generation.ts"), "utf8")
 
 describe("OutlineChatPanel controls", () => {
   it("uses the shared accent new conversation button style", () => {
@@ -13,10 +14,19 @@ describe("OutlineChatPanel controls", () => {
     expect(source).not.toContain("text-emerald-700")
   })
 
-  it("moves dock controls into the bottom left toolbar before outline generation and model selection", () => {
-    expect(source).toContain("bottomLeftControls={")
+  it("uses the shared reference input and picker for @ references", () => {
+    expect(source).toContain("ReferenceInput")
+    expect(source).toContain("ReferencePickerDialog")
+    expect(source).toContain("InsertReferenceTokens")
+    expect(source).toContain("outlineReferenceTokens")
+    expect(source).toContain("onAtTrigger={() => setReferencePickerOpen(true)}")
+    expect(source).toContain("onSubmit={handleSend}")
+    expect(source).not.toContain("<ChatInput")
+    expect(source).not.toContain('from "@/components/chat/chat-input"')
+  })
+
+  it("keeps dock controls before outline generation and model selection around the reference input", () => {
     expect(source).toContain("qmai-outline-bottom-left-controls")
-    expect(source).not.toContain("leftControls={")
     expect(source).toContain("<ChatDockControls />")
     expect(source).toContain("<OutlineGenerationMenu")
     expect(source).toContain("<ChatModelSelector")
@@ -39,4 +49,77 @@ describe("OutlineChatPanel controls", () => {
     expect(source).toContain("onGenerate(config.title, config.requestHint)")
     expect(source).toContain("onGenerate={handleGenerateSection}")
   })
+
+  it("adds selected references to the outline agent request instead of only storing chips", () => {
+    expect(source).toContain("buildOutlineAgentUserContent")
+    expect(source).toContain("本条消息附带的 @ 引用")
+    expect(source).toContain("请优先使用工具读取引用内容")
+  })
+
+  it("routes outline chat sends through AgentRunner with built-in tools", () => {
+    expect(source).toContain("AgentRunner")
+    expect(source).toContain("buildAgentConfig")
+    expect(source).toContain("ToolRegistry")
+    expect(source).toContain("read_outline")
+    expect(source).toContain("read_chapter")
+    expect(source).toContain("read_memory")
+    expect(source).toContain("read_deduction")
+    expect(source).not.toContain("runDeepOutlineGeneration(")
+  })
+
+  it("settles running outline tool calls when generation finishes", () => {
+    expect(source).toContain("settleRunningAgentToolCalls")
+    expect(source).toContain("settleRunningAgentToolCalls(record.toolCalls.length ? record.toolCalls : message.agentToolCalls")
+    expect(source).toContain('settleRunningAgentToolCalls(message.agentToolCalls, "error"')
+  })
+
+  it("uses an outline-only tool set that cannot write chapters or memory", () => {
+    expect(source).toContain("OUTLINE_CHAT_DISABLED_TOOLS")
+    expect(source).toContain('"write_chapter"')
+    expect(source).toContain('"write_memory"')
+    expect(source).toContain("disabledTools: OUTLINE_CHAT_DISABLED_TOOLS")
+    expect(source).toContain("需要保存大纲时只能使用 write_outline_node")
+  })
+
+  it("keeps outline reference chips as tool-readable hints instead of preloading file contents", () => {
+    expect(source).toContain("buildOutlineAgentUserContent")
+    expect(source).toContain("请优先使用工具读取引用内容")
+    expect(source).not.toContain("loadReferenceTokenContext(tokens)")
+  })
+
+  it("renders sent @ references in outline chat user messages", () => {
+    expect(source).toContain('import { ReferenceChip } from "@/components/reference/ReferenceChip"')
+    expect(source).toContain("msg.attachedReferences")
+    expect(source).toContain("<ReferenceChip")
+    expect(source).toContain("readonly")
+  })
+
+  it("forces outline chat through a dedicated list-read-analyze-generate workflow", () => {
+    expect(source).toContain("## AI大纲固定分析流程")
+    expect(source).toContain("先调用 list_outlines、list_chapters、list_memories、list_deductions")
+    expect(source).toContain("再调用 read_outline、read_chapter、read_memory、read_deduction")
+    expect(source).toContain("分析冲突、缺口、伏笔、角色动机和章节承接")
+    expect(source).toContain("最后再生成大纲建议")
+  })
+
+  it("routes every outline generation menu item through the PRD 3.1 content workflow", () => {
+    expect(source).toContain("buildOutlineSectionGenerationPrompt")
+    expect(source).toContain("## AI大纲生成工作流")
+    expect(source).toContain("提取请求关键词")
+    expect(source).toContain("识别用户意图")
+    expect(source).toContain("提取对小说创作有用的关键内容")
+    expect(source).toContain("结合用户要用的 skill + soul.md 约束")
+    expect(source).toContain("最终回复只输出大纲标题和大纲正文")
+    expect(source).toContain("禁止输出工具调用报告、分析过程、完成报告、下一步行动")
+
+    for (const title of ["章节细纲", "人物小传", "组织势力设定", "金手指与能力体系", "伏笔计划", "地点设定"]) {
+      expect(outlineGenerationSource).toContain(title)
+    }
+  })
+
+  it("lets outline chat bubbles expand to half of the window without overflowing narrow panels", () => {
+    expect(source).toContain("lg:max-w-[50vw]")
+    expect(source).toContain("max-w-full")
+    expect(source).not.toContain("max-w-[85%]")
+  })
 })

+ 472 - 165
src/components/sources/outline-chat-panel.tsx

@@ -4,62 +4,210 @@ import { useWikiStore } from "@/stores/wiki-store"
 import { useOutlineChatStore, type OutlineChatMessage } from "@/stores/outline-chat-store"
 import { normalizePath } from "@/lib/path-utils"
 import { refreshProjectState } from "@/lib/project-refresh"
-import { readFile, writeFile, listDirectory, createDirectory, fileExists } from "@/commands/fs"
-import { streamChat, type ChatMessage } from "@/lib/llm-client"
+import { writeFile, listDirectory, createDirectory, fileExists } from "@/commands/fs"
 import { hasUsableLlm } from "@/lib/has-usable-llm"
 import ReactMarkdown from "react-markdown"
 import { FileEditPreview } from "@/components/chat/file-edit-preview"
+import { AgentToolCallMessage } from "@/components/chat/agent-tool-call-message"
 import { ChatDockControls } from "@/components/chat/chat-dock-controls"
 import { TooltipProvider } from "@/components/ui/tooltip"
 import { OUTLINE_SECTION_GENERATION_CONFIGS } from "@/lib/novel/outline-generation"
 import { prepareOutlineSaveDraft } from "@/lib/outline-save"
-import { resolveUserVisibleReasoning } from "@/lib/user-visible-reasoning"
-import { runDeepOutlineGeneration } from "@/lib/novel/deep-outline-generation"
 import { resolveModelConfig, resolveNovelModel } from "@/lib/novel/model-resolver"
-import { createDeepThinkingStreamRenderer } from "@/lib/deep-thinking-stream"
-import { ChatInput } from "@/components/chat/chat-input"
 import { ChatModelSelector } from "@/components/chat/chat-model-selector"
+import { ReferenceInput, type InsertReferenceTokens } from "@/components/reference/ReferenceInput"
+import { ReferencePickerDialog } from "@/components/reference/ReferencePickerDialog"
+import { ReferenceChip } from "@/components/reference/ReferenceChip"
+import {
+  chapterProvider,
+  createChatHistoryProvider,
+  createOutlineHistoryProvider,
+  deductionProvider,
+  memoryProvider,
+  outlineProvider,
+} from "@/lib/reference/providers"
+import type { ReferenceToken } from "@/lib/reference/types"
+import { useChatStore } from "@/stores/chat-store"
+import { AgentRunner } from "@/lib/agent/runner"
+import { ToolRegistry } from "@/lib/agent/registry"
+import { buildAgentConfig, modelSupportsTools } from "@/lib/agent/config"
+import type { AgentMessage, AgentRunRecord } from "@/lib/agent/types"
+import { applyAgentToolEvent, settleRunningAgentToolCalls } from "@/lib/agent/tool-events"
+import { loadDeAiSkillConfig, type DeAiSkillConfig } from "@/lib/novel/de-ai-skill-library"
+import { readSoulDoc } from "@/lib/novel/soul-doc"
 import {
   buildWebResearchContext,
   collectWebResearch,
   shouldUseWebResearch,
 } from "@/lib/web-research"
 
-async function loadOutlineContext(projectPath: string): Promise<{ context: string; sources: string[] }> {
-  const pp = normalizePath(projectPath)
-  const sections: string[] = []
-  const sources: string[] = []
+const OUTLINE_CHAT_DISABLED_TOOLS = ["write_chapter", "write_memory"]
 
-  try {
-    const outlinesDir = `${pp}/wiki/outlines`
-    const tree = await listDirectory(outlinesDir)
-    for (const file of tree.slice(0, 10)) {
-      if (file.name.endsWith(".md")) {
-        try {
-          const content = await readFile(`${outlinesDir}/${file.name}`)
-          const trimmed = content.length > 3000 ? content.slice(0, 3000) + "\n...(已截断)" : content
-          sections.push(`【${file.name.replace(/\.md$/, "")}】\n${trimmed}`)
-          sources.push(`大纲: ${file.name.replace(/\.md$/, "")}`)
-        } catch { /* skip */ }
-      }
-    }
-  } catch { /* no outlines dir */ }
-
-  try {
-    const chaptersDir = `${pp}/wiki/chapters`
-    const tree = await listDirectory(chaptersDir)
-    const chapterFiles = tree.filter(f => f.name.endsWith(".md")).slice(-5)
-    for (const file of chapterFiles) {
-      try {
-        const content = await readFile(`${chaptersDir}/${file.name}`)
-        const preview = content.length > 1500 ? content.slice(0, 1500) + "\n...(已截断)" : content
-        sections.push(`【章节:${file.name.replace(/\.md$/, "")}】\n${preview}`)
-        sources.push(`章节: ${file.name.replace(/\.md$/, "")}`)
-      } catch { /* skip */ }
+function referenceCategoryLabel(category: ReferenceToken["category"]): string {
+  switch (category) {
+    case "chapter":
+      return "章节"
+    case "memory":
+      return "记忆"
+    case "outline":
+      return "大纲"
+    case "deduction":
+      return "推演"
+    case "chat_history":
+      return "AI会话"
+    case "outline_history":
+      return "AI大纲"
+    case "skill":
+      return "技能"
+    default:
+      return "引用"
+  }
+}
+
+function describeReferenceForOutlineAgent(token: ReferenceToken, index: number): string {
+  const parts = [
+    `${index + 1}. 类型:${referenceCategoryLabel(token.category)}`,
+    `标题:${token.title || token.displayTitle}`,
+  ]
+  if (token.path) parts.push(`路径:${token.path}`)
+  if (token.conversationId) parts.push(`会话ID:${token.conversationId}`)
+  if (token.skillId) parts.push(`技能ID:${token.skillId}`)
+  return parts.join(";")
+}
+
+function buildOutlineAgentUserContent(text: string, tokens: ReferenceToken[]): string {
+  if (tokens.length === 0) return text
+  return [
+    text,
+    "",
+    "## 本条消息附带的 @ 引用",
+    "请优先使用工具读取引用内容,不要只根据标题猜测。章节用 read_chapter,大纲用 read_outline,记忆用 read_memory,推演用 read_deduction,AI会话用 read_chat_history,AI大纲历史用 read_outline_history。",
+    ...tokens.map(describeReferenceForOutlineAgent),
+  ].join("\n")
+}
+
+function buildOutlineAgentSystemPrompt(options: { projectName?: string; webResearchContext?: string; soulDoc?: string }): string {
+  return [
+    "你是专业小说大纲分析与创作助手。",
+    "你必须通过可用工具读取项目大纲、章节、记忆、推演结果和历史对话后,再进行分析、回答、生成或修改建议。",
+    "如果用户提供 @ 引用,必须优先按路径、标题或会话ID调用对应读取工具获取正文内容。",
+    "不要假设引用内容已经注入上下文;不要跳过工具直接空泛回答。",
+    "回答必须基于已读取内容进行分析,说明关键判断依据;需要保存大纲时只能使用 write_outline_node。",
+    "## AI大纲固定分析流程",
+    "1. 先调用 list_outlines、list_chapters、list_memories、list_deductions 确认可用资料范围。",
+    "2. 再调用 read_outline、read_chapter、read_memory、read_deduction 读取用户 @ 引用和相关项目内容。",
+    "3. 分析冲突、缺口、伏笔、角色动机和章节承接,明确哪些判断来自已读取资料。",
+    "4. 最后再生成大纲建议;没有完成读取和分析前,不要直接给出结论。",
+    "## AI大纲生成工作流",
+    "当用户要求生成、完善或续写任何大纲分项时,必须按 PRD 3.1 主流程执行:提取请求关键词,识别用户意图,按意图读取资料,提取对小说创作有用的关键内容,结合用户要用的 skill + soul.md 约束生成内容,再做结果强约束收敛。",
+    "关键内容提取必须服务于小说创作:只保留能帮助用户继续写小说的信息,例如章节目标、冲突推进、人物动机、伏笔状态、设定限制、时间线承接和结尾钩子。",
+    "最终回复只输出大纲标题和大纲正文;禁止输出工具调用报告、分析过程、完成报告、下一步行动、无法直接保存的大段说明。",
+    "工具调用过程只应展示在工具调用 UI 中,不要混入最终正文。资料不足以生成完整正文时,先提出最少必要澄清问题,不要用流程说明冒充生成结果。",
+    "所有面向用户的回复必须使用中文。",
+    options.projectName ? `当前项目:${options.projectName}` : "",
+    options.soulDoc?.trim()
+      ? `## 作品灵魂与总则\n${options.soulDoc}`
+      : "",
+    options.webResearchContext?.trim()
+      ? `## 用户明确要求检索的网页资料\n${options.webResearchContext}`
+      : "",
+  ].filter(Boolean).join("\n")
+}
+
+function getOutlineSectionOutputRules(title: string): string {
+  if (title.includes("章节细纲")) {
+    return "按章节输出:章节标题、章节目标、核心事件、主要冲突、关键转折、结尾钩子、与前后章节承接。"
+  }
+  if (title.includes("人物")) {
+    return "按人物输出:人物定位、目标与动机、欲望和恐惧、关系变化、冲突点、成长或崩坏路径、当前状态。"
+  }
+  if (title.includes("组织") || title.includes("势力")) {
+    return "按组织或势力输出:阵营目标、利益诉求、掌握资源、内部矛盾、外部冲突、剧情作用、与主角线关系。"
+  }
+  if (title.includes("金手指") || title.includes("能力")) {
+    return "按能力体系输出:能力规则、限制、代价、成长路径、反制方式、剧情用途、容易制造的冲突。"
+  }
+  if (title.includes("伏笔")) {
+    return "按伏笔输出:伏笔名称、埋设位置、表层误导、真实指向、推进节点、回收位置、关联人物和风险。"
+  }
+  if (title.includes("地点")) {
+    return "按地点输出:地点定位、所属势力、空间规则、资源与限制、可触发事件、剧情作用、与人物线关系。"
+  }
+  return "按可保存的大纲正文输出:标题清楚、条目完整、能直接指导后续小说写作。"
+}
+
+function buildOutlineSectionGenerationPrompt(title: string, requestHint: string): string {
+  return [
+    `请按「AI大纲生成工作流」生成「${title}」。`,
+    "",
+    "## 本次意图",
+    "generate_outline",
+    "",
+    "## PRD 3.1 主流程要求",
+    "1. 提取请求关键词:确认用户要生成的大纲分项、范围和已有约束。",
+    "2. 识别用户意图:本次是生成/完善大纲正文,不是审稿报告、工具报告或分析说明。",
+    "3. 读取资料:优先读取用户 @ 引用、已有大纲、章节、记忆、推演和历史会话。",
+    "4. 提取对小说创作有用的关键内容:章节目标、冲突、伏笔、人物动机、设定限制、时间线承接和结尾钩子。",
+    "5. 结合用户要用的 skill + soul.md 约束,生成可直接保存的大纲正文。",
+    "6. 结果强约束收敛:最终回复只输出大纲标题和大纲正文。",
+    "",
+    "## 禁止输出",
+    "禁止输出工具调用报告、分析过程、完成报告、下一步行动、等待工具结果、已读取资料清单、泛泛建议。",
+    "",
+    "## 本分项内容要求",
+    requestHint,
+    getOutlineSectionOutputRules(title),
+    "",
+    "如果资料不足以完整生成,请只问一个最关键的澄清问题;如果资料足够,直接输出完整正文。",
+  ].join("\n")
+}
+
+function outlineToolCallsToSources(toolCalls: AgentRunRecord["toolCalls"]): string[] {
+  const sources: string[] = []
+  for (const call of toolCalls) {
+    if (call.status !== "done") continue
+    const target = call.params.name || call.params.path || call.params.keyword || call.params.conversationId || call.params.conversationTitle
+    switch (call.name) {
+      case "read_outline":
+        sources.push(`大纲: ${String(target ?? "")}`.trim())
+        break
+      case "read_chapter":
+      case "search_chapters":
+        sources.push(`章节: ${String(target ?? "")}`.trim())
+        break
+      case "read_memory":
+        sources.push(`记忆: ${String(target ?? "")}`.trim())
+        break
+      case "read_deduction":
+        sources.push(`推演: ${String(target ?? "")}`.trim())
+        break
+      case "read_chat_history":
+        sources.push(`AI会话: ${String(target ?? "")}`.trim())
+        break
+      case "read_outline_history":
+        sources.push(`AI大纲: ${String(target ?? "")}`.trim())
+        break
     }
-  } catch { /* no chapters dir */ }
+  }
+  return Array.from(new Set(sources.filter((source) => !source.endsWith(":"))))
+}
 
-  return { context: sections.join("\n\n---\n\n"), sources }
+function updateOutlineAssistantMessage(
+  conversationId: string,
+  messageId: string,
+  updater: (message: OutlineChatMessage) => OutlineChatMessage,
+): void {
+  useOutlineChatStore.setState((state) => ({
+    conversations: state.conversations.map((conversation) => {
+      if (conversation.id !== conversationId) return conversation
+      return {
+        ...conversation,
+        messages: conversation.messages.map((message) =>
+          message.id === messageId ? updater(message) : message,
+        ),
+      }
+    }),
+  }))
 }
 
 async function getUniqueOutlinePath(outlinesDir: string, title: string): Promise<string> {
@@ -146,6 +294,7 @@ function OutlineAssistantMessage({ msg, index, isStreaming, streamingContent, ac
   return (
     <>
       {thinking ? <OutlineThinkingBlock content={thinking} open={isStreaming} /> : null}
+      <AgentToolCallMessage toolCalls={msg.agentToolCalls} />
       <div className="prose prose-sm dark:prose-invert max-w-none">
         <ReactMarkdown>{parsed.textContent || answer}</ReactMarkdown>
       </div>
@@ -279,6 +428,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
   const llmConfig = useWikiStore((s) => s.llmConfig)
   const novelConfig = useWikiStore((s) => s.novelConfig)
   const providerConfigs = useWikiStore((s) => s.providerConfigs)
+  const chatConversations = useChatStore((s) => s.conversations)
 
   const conversations = useOutlineChatStore((s) => s.conversations)
   const activeConversationId = useOutlineChatStore((s) => s.activeConversationId)
@@ -315,7 +465,26 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
   }, [providerConfigs])
 
   const [inputValue, setInputValue] = useState("")
+  const [outlineReferenceTokens, setOutlineReferenceTokens] = useState<ReferenceToken[]>([])
+  const [referencePickerOpen, setReferencePickerOpen] = useState(false)
   const [localModelId, setLocalModelId] = useState(activeConv?.modelId ?? "")
+  const insertReferenceTokensRef = useRef<InsertReferenceTokens>(null)
+
+  const referenceProviders = useMemo(
+    () => [
+      chapterProvider,
+      memoryProvider,
+      outlineProvider,
+      deductionProvider,
+      createChatHistoryProvider(() =>
+        chatConversations.map((conversation) => ({ id: conversation.id, title: conversation.title })),
+      ),
+      createOutlineHistoryProvider(() =>
+        conversations.map((conversation) => ({ id: conversation.id, title: conversation.title })),
+      ),
+    ],
+    [chatConversations, conversations],
+  )
 
   // 加载持久化的历史记录
   useEffect(() => {
@@ -362,34 +531,57 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
     return () => container.removeEventListener("scroll", handleScroll)
   }, [])
 
-  const handleSend = useCallback(async (inputText: string) => {
+  const handleSend = useCallback(async (inputText: string, tokens: ReferenceToken[] = []) => {
     const prompt = inputText.trim()
     if (!prompt || !project || isStreaming) return
+    setInputValue("")
+    setOutlineReferenceTokens([])
     let effectiveLlmConfig = resolveNovelModel(llmConfig, novelConfig, "writing")
     if (activeConv?.modelId) {
       effectiveLlmConfig = resolveModelConfig(activeConv.modelId, effectiveLlmConfig, providerConfigs)
     }
+    const effectiveModelId = activeConv?.modelId || effectiveLlmConfig.model || ""
     if (!hasUsableLlm(effectiveLlmConfig, providerConfigs)) {
       const convId = activeConversationId ?? createConversation()
       addMessage(convId, { id: crypto.randomUUID(), role: "assistant", content: "请先在设置中配置并选择一个可用的 AI 模型,或在下方模型选择器中选择模型后再试。" })
       return
     }
+    if (!modelSupportsTools(effectiveModelId)) {
+      const convId = activeConversationId ?? createConversation()
+      addMessage(convId, { id: crypto.randomUUID(), role: "assistant", content: "当前模型不支持 AI 大纲工具调用,请在下方模型选择器中更换支持工具调用的模型。" })
+      return
+    }
 
     let convId = activeConversationId
     if (!convId) {
       convId = createConversation()
     }
 
-    const userMsg: OutlineChatMessage = { id: crypto.randomUUID(), role: "user", content: prompt }
+    const historyBeforeSend = (useOutlineChatStore.getState().conversations.find((c) => c.id === convId)?.messages ?? [])
+      .filter((message) => message.content.trim() && !message.isAgentRunning)
+      .map((message) => ({
+        role: message.role,
+        content: message.content,
+      } satisfies AgentMessage))
+    const userMsg: OutlineChatMessage = { id: crypto.randomUUID(), role: "user", content: prompt, attachedReferences: tokens }
+    const initialSources = tokens.map((token) => `@${referenceCategoryLabel(token.category)}: ${token.title || token.displayTitle}`)
+    const assistantId = crypto.randomUUID()
     addMessage(convId, userMsg)
+    addMessage(convId, {
+      id: assistantId,
+      role: "assistant",
+      content: "",
+      sources: initialSources,
+      agentToolCalls: [],
+      isAgentRunning: true,
+    })
     setIsStreaming(true)
     setStreamingContent("")
     userScrolledUpRef.current = false
 
     try {
-      const { context, sources } = await loadOutlineContext(project.path)
-      let outlineContext = context
-      let outlineSources = [...sources]
+      let webResearchMarkdown = ""
+      let outlineSources = [...initialSources]
       if (shouldUseWebResearch(prompt)) {
         const webResearch = await collectWebResearch({
           text: prompt,
@@ -399,111 +591,134 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         })
         const webResearchContext = buildWebResearchContext(webResearch)
         if (webResearchContext.markdown.trim()) {
-          outlineContext = [outlineContext, webResearchContext.markdown].filter(Boolean).join("\n\n---\n\n")
+          webResearchMarkdown = webResearchContext.markdown
         }
         outlineSources = [...outlineSources, ...webResearchContext.sources]
       }
-      const allMsgs = [...(useOutlineChatStore.getState().conversations.find(c => c.id === convId)?.messages ?? [])]
-
-      // Agent mode: detect edit intent and add file edit instructions
-      const { detectEditIntent, buildAgentSystemSuffix } = await import("@/lib/novel/agent-parser")
-      const hasEditIntent = detectEditIntent(prompt)
-      const agentSuffix = hasEditIntent ? buildAgentSystemSuffix("outlines") : ""
-      let fileListStr = ""
-      if (hasEditIntent) {
-        const { readScopeFileContents } = await import("@/lib/novel/agent-tools")
-        const filesWithContent = await readScopeFileContents(project.path, "outlines")
-        fileListStr = filesWithContent.length > 0
-          ? `\n\n## 当前大纲文件内容(供修改定位)\n${filesWithContent.map(f => `### ${f.name}\n\`\`\`\n${f.content}\n\`\`\``).join("\n\n")}`
-          : "\n\n## 当前大纲文件列表\n(暂无大纲文件)"
-      }
 
-      const historyMessages: ChatMessage[] = allMsgs.map(m => ({ role: m.role as "user" | "assistant", content: m.content }))
       let result = ""
-      const appendToResult = (token: string) => {
-        result += token
-        setStreamingContent(result)
-      }
-      const deepStream = createDeepThinkingStreamRenderer()
-      const updateDeepResult = (content: string) => {
-        result = content
-        setStreamingContent(result)
-      }
-      const appendThinkingBlock = (content: string) => updateDeepResult(deepStream.updateThinking(content))
       const controller = new AbortController()
       abortRef.current = controller
 
-      // Add placeholder assistant message
-      addMessage(convId, { id: crypto.randomUUID(), role: "assistant", content: "", sources: outlineSources })
-
-      if (hasEditIntent) {
-        const systemPrompt = `你是一个专业的小说大纲编辑助手。以下是当前小说的大纲、章节内容和用户明确要求检索的网页资料,请根据用户的问题进行大纲相关的讨论和创作。\n\n${outlineContext}${agentSuffix}${fileListStr}`
-        const chatMessages: ChatMessage[] = [
-          { role: "system", content: systemPrompt },
-          ...historyMessages,
-        ]
-
-        let thinkingOpen = false
-        const appendReasoning = (token: string) => {
-          if (!token) return
-          if (!thinkingOpen) {
-            thinkingOpen = true
-            appendToResult("<think>")
-          }
-          appendToResult(token)
-        }
-        const closeReasoning = () => {
-          if (!thinkingOpen) return
-          thinkingOpen = false
-          appendToResult("</think>")
-        }
+      const skillConfig = await loadDeAiSkillConfig(project.path).catch((): DeAiSkillConfig | null => null)
+      const soulDoc = await readSoulDoc(project.path).catch(() => "")
+      const registry = new ToolRegistry()
+      const systemPrompt = buildOutlineAgentSystemPrompt({
+        projectName: project.name,
+        webResearchContext: webResearchMarkdown,
+        soulDoc,
+      })
+      const agentConfig = buildAgentConfig(effectiveModelId, systemPrompt, registry, {
+        wikiPath: `${normalizePath(project.path)}/wiki`,
+        getSkillConfig: () => skillConfig,
+        getChatConversations: () => {
+          const state = useChatStore.getState()
+          return state.conversations.map((conversation) => ({
+            id: conversation.id,
+            title: conversation.title,
+            messages: state.messages
+              .filter((message) => message.conversationId === conversation.id)
+              .map((message) => ({ role: message.role, content: message.content })),
+          }))
+        },
+        getOutlineConversations: () =>
+          useOutlineChatStore.getState().conversations.map((conversation) => ({
+            id: conversation.id,
+            title: conversation.title,
+            messages: conversation.messages.map((message) => ({ role: message.role, content: message.content })),
+          })),
+        llmConfig: effectiveLlmConfig,
+        disabledTools: OUTLINE_CHAT_DISABLED_TOOLS,
+      })
+      const agentMessages: AgentMessage[] = [
+        { role: "system", content: systemPrompt },
+        ...historyBeforeSend,
+        { role: "user", content: buildOutlineAgentUserContent(prompt, tokens) },
+      ]
+      let agentError: Error | null = null
 
-        await streamChat(effectiveLlmConfig, chatMessages, {
-          onToken: (token) => {
-            closeReasoning()
-            appendToResult(token)
-          },
-          onReasoningToken: appendReasoning,
-          onDone: () => {
-            closeReasoning()
+      const record = await new AgentRunner().run(
+        agentConfig,
+        registry,
+        agentMessages,
+        {
+          onText: (chunk) => {
+            result += chunk
+            setStreamingContent(result)
           },
-          onError: () => {
-            closeReasoning()
+          onToolCall: () => {},
+          onToolResult: () => {},
+          onToolError: () => {},
+          onToolEvent: (event) => {
+            updateOutlineAssistantMessage(convId, assistantId, (message) => ({
+              ...message,
+              agentToolCalls: applyAgentToolEvent(message.agentToolCalls, event),
+            }))
           },
-        }, controller.signal, { reasoning: resolveUserVisibleReasoning(effectiveLlmConfig.reasoning) })
-      } else {
-        await runDeepOutlineGeneration(
-          {
-            llmConfig: effectiveLlmConfig,
-            userRequest: prompt,
-            context: outlineContext,
-            historyMessages,
+          onDone: () => {
+            updateOutlineAssistantMessage(convId, assistantId, (message) => ({
+              ...message,
+              agentToolCalls: settleRunningAgentToolCalls(message.agentToolCalls),
+              isAgentRunning: false,
+            }))
           },
-          {
-            onThinking: appendThinkingBlock,
-            onFinalContent: (content) => updateDeepResult(deepStream.appendFinal(content)),
+          onError: (error) => {
+            agentError = error
           },
-          undefined,
-          controller.signal,
-        )
-      }
+        },
+        controller.signal,
+      )
+      if (agentError) throw agentError
 
-      replaceLastAssistant(convId, result, outlineSources)
+      const finalSources = Array.from(new Set([...outlineSources, ...outlineToolCallsToSources(record.toolCalls)]))
+      updateOutlineAssistantMessage(convId, assistantId, (message) => ({
+        ...message,
+        content: result || record.finalText || "AI大纲未返回内容。",
+        sources: finalSources,
+        agentToolCalls: settleRunningAgentToolCalls(record.toolCalls.length ? record.toolCalls : message.agentToolCalls),
+        isAgentRunning: false,
+      }))
+      const firstUser = useOutlineChatStore.getState()
+        .conversations.find((conversation) => conversation.id === convId)
+        ?.messages.find((message) => message.role === "user")
+      if (firstUser) {
+        useOutlineChatStore.setState((state) => ({
+          conversations: state.conversations.map((conversation) =>
+            conversation.id === convId
+              ? {
+                  ...conversation,
+                  title: firstUser.content.slice(0, 20) + (firstUser.content.length > 20 ? "..." : ""),
+                }
+              : conversation,
+          ),
+        }))
+      }
+      void useOutlineChatStore.getState().saveToDisk()
       setStreamingContent("")
     } catch (err) {
-      // If aborted, keep partial content; otherwise show error
       const partial = useOutlineChatStore.getState().streamingContent
       if (partial) {
-        replaceLastAssistant(convId!, partial)
+        updateOutlineAssistantMessage(convId, assistantId, (message) => ({
+          ...message,
+          content: partial,
+          agentToolCalls: settleRunningAgentToolCalls(message.agentToolCalls, "error", Date.now(), err instanceof Error ? err.message : String(err)),
+          isAgentRunning: false,
+        }))
       } else {
         const errorMsg = err instanceof Error ? err.message : String(err)
         if (errorMsg && !errorMsg.includes("aborted")) {
-          replaceLastAssistant(convId!, `生成失败:${errorMsg}`)
+          updateOutlineAssistantMessage(convId, assistantId, (message) => ({
+            ...message,
+            content: `生成失败:${errorMsg}`,
+            agentToolCalls: settleRunningAgentToolCalls(message.agentToolCalls, "error", Date.now(), errorMsg),
+            isAgentRunning: false,
+          }))
         } else {
-          removeLastMessage(convId!)
+          removeLastMessage(convId)
         }
       }
       setStreamingContent("")
+      void useOutlineChatStore.getState().saveToDisk()
     } finally {
       setIsStreaming(false)
       abortRef.current = null
@@ -511,7 +726,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
   }, [project, isStreaming, llmConfig, novelConfig, providerConfigs, activeConv, activeConversationId, createConversation, addMessage, replaceLastAssistant, removeLastMessage, setIsStreaming, setStreamingContent])
 
   const handleGenerateSection = useCallback((title: string, requestHint: string) => {
-    void handleSend(`请继续生成「${title}」。${requestHint} 请基于已有大纲、章节内容和项目记忆直接输出该分项内容,结构清晰,可保存为大纲。`)
+    void handleSend(buildOutlineSectionGenerationPrompt(title, requestHint))
   }, [handleSend])
 
   const handleStop = useCallback(() => {
@@ -532,10 +747,15 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
     if (activeConv?.modelId) {
       effectiveLlmConfig = resolveModelConfig(activeConv.modelId, effectiveLlmConfig, providerConfigs)
     }
+    const effectiveModelId = activeConv?.modelId || effectiveLlmConfig.model || ""
     if (!hasUsableLlm(effectiveLlmConfig, providerConfigs)) {
       addMessage(activeConversationId, { id: crypto.randomUUID(), role: "assistant", content: "请先在设置中配置并选择一个可用的 AI 模型,或在下方模型选择器中选择模型后再试。" })
       return
     }
+    if (!modelSupportsTools(effectiveModelId)) {
+      addMessage(activeConversationId, { id: crypto.randomUUID(), role: "assistant", content: "当前模型不支持 AI 大纲工具调用,请在下方模型选择器中更换支持工具调用的模型。" })
+      return
+    }
 
     // Remove messages from msgIndex onwards
     const conv = useOutlineChatStore.getState().conversations.find(c => c.id === activeConversationId)
@@ -554,41 +774,102 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
     userScrolledUpRef.current = false
 
     try {
-      const { context, sources } = await loadOutlineContext(project.path)
-      const chatMessages: ChatMessage[] = [
-        ...targetMessages.map(m => ({ role: m.role as "user" | "assistant", content: m.content })),
-      ]
       const lastUserRequest = [...targetMessages].reverse().find((message) => message.role === "user")?.content ?? "请基于已有大纲重新生成。"
-
+      const historyMessages = targetMessages
+        .filter((message) => message.content.trim() && !message.isAgentRunning)
+        .filter((message) => message.content !== lastUserRequest)
+        .map((message) => ({
+          role: message.role,
+          content: message.content,
+        } satisfies AgentMessage))
       let result = ""
-      const deepStream = createDeepThinkingStreamRenderer()
-      const updateDeepResult = (content: string) => {
-        result = content
-        setStreamingContent(result)
-      }
-      const appendThinkingBlock = (content: string) => updateDeepResult(deepStream.updateThinking(content))
       const controller = new AbortController()
       abortRef.current = controller
+      const assistantId = crypto.randomUUID()
 
-      addMessage(activeConversationId, { id: crypto.randomUUID(), role: "assistant", content: "", sources })
+      addMessage(activeConversationId, {
+        id: assistantId,
+        role: "assistant",
+        content: "",
+        sources: [],
+        agentToolCalls: [],
+        isAgentRunning: true,
+      })
 
-      await runDeepOutlineGeneration(
-        {
-          llmConfig: effectiveLlmConfig,
-          userRequest: lastUserRequest,
-          context,
-          historyMessages: chatMessages,
+      const skillConfig = await loadDeAiSkillConfig(project.path).catch((): DeAiSkillConfig | null => null)
+      const soulDoc = await readSoulDoc(project.path).catch(() => "")
+      const registry = new ToolRegistry()
+      const systemPrompt = buildOutlineAgentSystemPrompt({ projectName: project.name, soulDoc })
+      const agentConfig = buildAgentConfig(effectiveModelId, systemPrompt, registry, {
+        wikiPath: `${normalizePath(project.path)}/wiki`,
+        getSkillConfig: () => skillConfig,
+        getChatConversations: () => {
+          const state = useChatStore.getState()
+          return state.conversations.map((conversation) => ({
+            id: conversation.id,
+            title: conversation.title,
+            messages: state.messages
+              .filter((message) => message.conversationId === conversation.id)
+              .map((message) => ({ role: message.role, content: message.content })),
+          }))
         },
+        getOutlineConversations: () =>
+          useOutlineChatStore.getState().conversations.map((conversation) => ({
+            id: conversation.id,
+            title: conversation.title,
+            messages: conversation.messages.map((message) => ({ role: message.role, content: message.content })),
+          })),
+        llmConfig: effectiveLlmConfig,
+        disabledTools: OUTLINE_CHAT_DISABLED_TOOLS,
+      })
+      let agentError: Error | null = null
+      const record = await new AgentRunner().run(
+        agentConfig,
+        registry,
+        [
+          { role: "system", content: systemPrompt },
+          ...historyMessages,
+          { role: "user", content: lastUserRequest },
+        ],
         {
-          onThinking: appendThinkingBlock,
-          onFinalContent: (content) => updateDeepResult(deepStream.appendFinal(content)),
+          onText: (chunk) => {
+            result += chunk
+            setStreamingContent(result)
+          },
+          onToolCall: () => {},
+          onToolResult: () => {},
+          onToolError: () => {},
+          onToolEvent: (event) => {
+            updateOutlineAssistantMessage(activeConversationId, assistantId, (message) => ({
+              ...message,
+              agentToolCalls: applyAgentToolEvent(message.agentToolCalls, event),
+            }))
+          },
+          onDone: () => {
+            updateOutlineAssistantMessage(activeConversationId, assistantId, (message) => ({
+              ...message,
+              agentToolCalls: settleRunningAgentToolCalls(message.agentToolCalls),
+              isAgentRunning: false,
+            }))
+          },
+          onError: (error) => {
+            agentError = error
+          },
         },
-        undefined,
         controller.signal,
       )
+      if (agentError) throw agentError
 
-      replaceLastAssistant(activeConversationId, result, sources)
+      const sources = outlineToolCallsToSources(record.toolCalls)
+      updateOutlineAssistantMessage(activeConversationId, assistantId, (message) => ({
+        ...message,
+        content: result || record.finalText || "AI大纲未返回内容。",
+        sources,
+        agentToolCalls: settleRunningAgentToolCalls(record.toolCalls.length ? record.toolCalls : message.agentToolCalls),
+        isAgentRunning: false,
+      }))
       setStreamingContent("")
+      void useOutlineChatStore.getState().saveToDisk()
     } catch (err) {
       const partial = useOutlineChatStore.getState().streamingContent
       if (partial) {
@@ -683,7 +964,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         ) : null}
         {activeMessages.map((msg, i) => (
           <div key={msg.id} className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}>
-            <div className={`max-w-[85%] rounded-lg px-3 py-2 text-sm ${
+            <div className={`w-fit max-w-full lg:max-w-[50vw] rounded-lg px-3 py-2 text-sm ${
               msg.role === "user"
                 ? "bg-primary text-primary-foreground"
                 : "bg-muted text-foreground"
@@ -702,7 +983,16 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
                   onRegenerate={handleRegenerate}
                 />
               ) : (
-                <span>{msg.content}</span>
+                <>
+                  <span>{msg.content}</span>
+                  {msg.attachedReferences && msg.attachedReferences.length > 0 ? (
+                    <div className="mt-1 flex flex-wrap gap-1">
+                      {msg.attachedReferences.map((token) => (
+                        <ReferenceChip key={token.id} token={token} readonly />
+                      ))}
+                    </div>
+                  ) : null}
+                </>
               )}
             </div>
           </div>
@@ -710,7 +1000,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         {isStreaming && streamingContent && activeMessages.length > 0 && activeMessages[activeMessages.length - 1]?.content === "" ? null : (
           isStreaming && streamingContent && activeMessages[activeMessages.length - 1]?.role !== "assistant" ? (
             <div className="flex justify-start">
-              <div className="max-w-[85%] rounded-lg bg-muted px-3 py-2 text-sm text-foreground">
+              <div className="w-fit max-w-full lg:max-w-[50vw] rounded-lg bg-muted px-3 py-2 text-sm text-foreground">
                 <div className="prose prose-sm dark:prose-invert max-w-none">
                   <ReactMarkdown>{streamingContent}</ReactMarkdown>
                 </div>
@@ -721,25 +1011,32 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
       </div>
 
       {/* Input */}
-      <div className="shrink-0">
-        <ChatInput
-          onSend={(text) => void handleSend(text)}
+      <div className="shrink-0 border-t px-3 py-2">
+        <div className="mb-2 flex items-center justify-between gap-2">
+          <TooltipProvider delay={200}>
+            <div className="qmai-outline-bottom-left-controls flex min-w-0 items-center gap-2">
+              <ChatDockControls />
+              <OutlineGenerationMenu
+                disabled={isStreaming}
+                onGenerate={handleGenerateSection}
+              />
+            </div>
+          </TooltipProvider>
+        </div>
+        <ReferenceInput
+          value={inputValue}
+          tokens={outlineReferenceTokens}
           onStop={handleStop}
           isStreaming={isStreaming}
           placeholder="输入关于大纲的问题..."
-          value={inputValue}
-          onChange={setInputValue}
-          bottomLeftControls={
-            <TooltipProvider delay={200}>
-              <div className="qmai-outline-bottom-left-controls flex min-w-0 items-center gap-2">
-                <ChatDockControls />
-                <OutlineGenerationMenu
-                  disabled={isStreaming}
-                  onGenerate={handleGenerateSection}
-                />
-              </div>
-            </TooltipProvider>
-          }
+          onChange={(text, tokens) => {
+            setInputValue(text)
+            setOutlineReferenceTokens(tokens)
+          }}
+          onTokensChange={setOutlineReferenceTokens}
+          onSubmit={handleSend}
+          onAtTrigger={() => setReferencePickerOpen(true)}
+          insertTokensRef={insertReferenceTokensRef}
           rightControls={
             hasAvailableModels ? (
               <ChatModelSelector
@@ -759,6 +1056,16 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             )
           }
         />
+        <ReferencePickerDialog
+          open={referencePickerOpen}
+          providers={referenceProviders}
+          projectPath={project?.path ? normalizePath(project.path) : ""}
+          onConfirm={(tokens) => {
+            insertReferenceTokensRef.current?.(tokens)
+            setReferencePickerOpen(false)
+          }}
+          onClose={() => setReferencePickerOpen(false)}
+        />
       </div>
     </div>
   )

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

@@ -0,0 +1,482 @@
+// @vitest-environment jsdom
+import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"
+import React from "react"
+import { createRoot } from "react-dom/client"
+import { act } from "react"
+import type { LlmConfig, ProviderConfigs, SearchApiConfig } from "@/stores/wiki-store"
+import type { WikiProject } from "@/types/wiki"
+import type { Conversation, DisplayMessage } from "@/stores/chat-store"
+import type { OutlineChatConversation } from "@/stores/outline-chat-store"
+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",
+  apiKey: "",
+  model: "",
+  ollamaUrl: "",
+  customEndpoint: "",
+  maxContextSize: 8192,
+}
+
+interface StoreStates {
+  wiki?: Partial<{
+    aiChatModel: string
+    project: WikiProject | null
+    dataVersion: number
+    llmConfig: LlmConfig
+    providerConfigs: ProviderConfigs
+    searchApiConfig: SearchApiConfig
+    mcpConfig: McpConfig
+    aiWorkflowMode: AiWorkflowMode
+  }>
+  chat?: Partial<{
+    conversations: Conversation[]
+    messages: DisplayMessage[]
+  }>
+  outline?: Partial<{
+    conversations: OutlineChatConversation[]
+  }>
+}
+
+function flushPromises() {
+  return new Promise((resolve) => setTimeout(resolve, 0))
+}
+
+async function renderHook(systemPrompt: string, overrides: StoreStates & {
+  skillConfig?: DeAiSkillConfig | null
+  userSkillConfig?: UserSkillConfig | null
+} = {}) {
+  vi.resetModules()
+
+  const wikiState = {
+    aiChatModel: "",
+    project: null as WikiProject | null,
+    dataVersion: 0,
+    llmConfig: baseLlmConfig,
+    providerConfigs: {} as ProviderConfigs,
+    searchApiConfig: {
+      provider: "none",
+      apiKey: "",
+      serpApiEngine: "google",
+      searXngUrl: "",
+      searXngCategories: ["general"],
+      providerConfigs: {},
+    } as SearchApiConfig,
+    mcpConfig: { servers: [] } as McpConfig,
+    novelMode: true,
+    aiWorkflowMode: "standard" as AiWorkflowMode,
+    ...overrides.wiki,
+  }
+
+  const chatState = {
+    conversations: [] as Conversation[],
+    messages: [] as DisplayMessage[],
+    ...overrides.chat,
+  }
+
+  const outlineState = {
+    conversations: [] as OutlineChatConversation[],
+    ...overrides.outline,
+  }
+
+  const skillConfig = overrides.skillConfig ?? null
+  const userSkillConfig = overrides.userSkillConfig ?? {
+    version: 1,
+    selectedSkillId: null,
+    disabledSkillIds: [],
+    skills: [],
+  }
+
+  vi.doMock("@/stores/wiki-store", () => {
+    const mockStore = (selector?: (s: typeof wikiState) => unknown) =>
+      selector ? selector(wikiState) : wikiState
+    mockStore.getState = () => wikiState
+    return {
+      useWikiStore: mockStore,
+    }
+  })
+
+  vi.doMock("@/stores/chat-store", () => ({
+    useChatStore: (selector?: (s: typeof chatState) => unknown) =>
+      selector ? selector(chatState) : chatState,
+  }))
+
+  vi.doMock("@/stores/outline-chat-store", () => ({
+    useOutlineChatStore: (selector?: (s: typeof outlineState) => unknown) =>
+      selector ? selector(outlineState) : outlineState,
+  }))
+
+  vi.doMock("@/lib/novel/de-ai-skill-library", () => ({
+    loadDeAiSkillConfig: vi.fn().mockResolvedValue(skillConfig),
+  }))
+
+  vi.doMock("@/lib/novel/user-skill-store", async () => {
+    const actual = await vi.importActual<typeof import("@/lib/novel/user-skill-store")>(
+      "@/lib/novel/user-skill-store",
+    )
+    return {
+      ...actual,
+      loadUserSkillConfig: vi.fn().mockResolvedValue(userSkillConfig),
+    }
+  })
+
+  vi.doMock("@/lib/novel/deep-chapter-generation", () => ({
+    runDeepChapterGeneration: vi.fn(),
+  }))
+
+  vi.doMock("@/lib/web-search", () => ({
+    resolveSearchConfig: (config: SearchApiConfig) => config,
+    webSearch: (...args: unknown[]) => webSearchMock(...args),
+  }))
+
+  vi.doMock("@/lib/mcp/real-connector", () => ({
+    RealMcpConnector: RealMcpConnectorMock,
+  }))
+
+  const { useAgentConfig } = await import("@/hooks/use-agent-config")
+
+  let result: UseAgentConfigResult | null = null
+
+  function TestComponent() {
+    result = useAgentConfig(systemPrompt)
+    return null
+  }
+
+  const container = document.createElement("div")
+  document.body.appendChild(container)
+  const root = createRoot(container)
+
+  await act(async () => {
+    root.render(React.createElement(TestComponent))
+    await flushPromises()
+  })
+  for (let i = 0; result === null && i < 10; i++) {
+    await act(async () => {
+      await flushPromises()
+    })
+  }
+
+  return {
+    get result() {
+      return result!
+    },
+    cleanup: () => act(() => {
+      root.unmount()
+      container.remove()
+    }),
+  }
+}
+
+const webSearchMock = vi.fn()
+const realMcpCallerMock = vi.fn()
+const RealMcpConnectorMock = vi.fn()
+
+describe("useAgentConfig", () => {
+  beforeEach(() => {
+    vi.clearAllMocks()
+    webSearchMock.mockReset()
+    realMcpCallerMock.mockReset()
+    RealMcpConnectorMock.mockReset()
+    ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+  })
+
+  afterEach(() => {
+    vi.doUnmock("@/stores/wiki-store")
+    vi.doUnmock("@/stores/chat-store")
+    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")
+  })
+
+  it("当 aiChatModel 在不支持列表中时,返回 supportsTools: false 且 config: null", async () => {
+    const { result, cleanup } = await renderHook("test prompt", {
+      wiki: {
+        aiChatModel: "openai/o3-mini",
+        project: { path: "/tmp/project" } as WikiProject,
+      },
+      skillConfig: {
+        version: 1,
+        defaultSkillId: "built-in:comprehensive",
+        disabledSkillIds: [],
+        projectSkills: [],
+        builtInSkillOverrides: [],
+        lastChapterDeAiSkillId: null,
+      },
+    })
+
+    expect(result.supportsTools).toBe(false)
+    expect(result.config).toBeNull()
+    expect(result.skillConfigLoaded).toBe(false)
+
+    await cleanup()
+  }, 15000)
+
+  it("当 project.path 为空时,返回 config: null", async () => {
+    const { result, cleanup } = await renderHook("test prompt", {
+      wiki: {
+        aiChatModel: "openai/gpt-4o",
+        project: null,
+      },
+    })
+
+    expect(result.supportsTools).toBe(true)
+    expect(result.config).toBeNull()
+    expect(result.skillConfigLoaded).toBe(false)
+
+    await cleanup()
+  }, 15000)
+
+  it("当模型支持且项目路径存在时,加载 skill config 后返回非空 config 且 registry 包含内置工具", async () => {
+    const { result, cleanup } = await renderHook("test prompt", {
+      wiki: {
+        aiChatModel: "openai/gpt-4o",
+        project: { path: "/tmp/project" } as WikiProject,
+      },
+      skillConfig: {
+        version: 1,
+        defaultSkillId: "built-in:comprehensive",
+        disabledSkillIds: [],
+        projectSkills: [],
+        builtInSkillOverrides: [],
+        lastChapterDeAiSkillId: null,
+      },
+    })
+
+    expect(result.supportsTools).toBe(true)
+    expect(result.skillConfigLoaded).toBe(true)
+    expect(result.config).not.toBeNull()
+    expect(result.skillConfig?.defaultSkillId).toBe("built-in:comprehensive")
+    expect(result.config?.tools.length).toBeGreaterThan(0)
+    expect(result.registry.list().some((tool) => tool.name === "read_chapter")).toBe(true)
+    expect(result.registry.list().some((tool) => tool.name === "apply_skill")).toBe(true)
+
+    await cleanup()
+  }, 15000)
+
+  it("passes Web Search settings into the web_search agent tool", async () => {
+    webSearchMock.mockResolvedValueOnce([])
+    const searchApiConfig: SearchApiConfig = {
+      provider: "tavily",
+      apiKey: "search-key",
+      serpApiEngine: "google",
+      searXngUrl: "",
+      searXngCategories: ["general"],
+      providerConfigs: {},
+    }
+    const { result, cleanup } = await renderHook("test prompt", {
+      wiki: {
+        aiChatModel: "openai/gpt-4o",
+        project: { path: "/tmp/project" } as WikiProject,
+        searchApiConfig,
+      },
+      skillConfig: {
+        version: 1,
+        defaultSkillId: "built-in:comprehensive",
+        disabledSkillIds: [],
+        projectSkills: [],
+        builtInSkillOverrides: [],
+        lastChapterDeAiSkillId: null,
+      },
+    })
+
+    const tool = result.registry.get("web_search")
+    expect(tool).toBeDefined()
+
+    await tool?.execute({ query: "黄蓉", maxResults: 2 })
+
+    expect(webSearchMock).toHaveBeenCalledWith("黄蓉", searchApiConfig, 2)
+
+    await cleanup()
+  }, 15000)
+
+  it("loads enabled writing skills for AI chat and excludes disabled skills", async () => {
+    const { result, cleanup } = await renderHook("test prompt", {
+      wiki: {
+        aiChatModel: "openai/gpt-4o",
+        project: { path: "/tmp/project" } as WikiProject,
+      },
+      skillConfig: {
+        version: 1,
+        defaultSkillId: "built-in:comprehensive",
+        disabledSkillIds: [],
+        projectSkills: [],
+        builtInSkillOverrides: [],
+        lastChapterDeAiSkillId: null,
+      },
+      userSkillConfig: {
+        version: 1,
+        selectedSkillId: "skill:three",
+        disabledSkillIds: ["skill:hidden"],
+        skills: [
+          {
+            id: "skill:three",
+            name: "三翻四抖",
+            description: "三次转折,四次震惊。",
+            kind: ["structure", "planning"],
+            stages: ["planning", "drafting"],
+            modes: ["standard", "strict"],
+            content: "每章设置三次局势变化和四次信息冲击。",
+            source: "uploaded",
+          },
+          {
+            id: "skill:hidden",
+            name: "禁用 Skill",
+            description: "",
+            kind: ["review"],
+            stages: ["review"],
+            modes: ["strict"],
+            content: "不要进入 AI 会话。",
+            source: "uploaded",
+          },
+        ],
+      },
+    })
+
+    expect(result.skillConfigLoaded).toBe(true)
+    expect(result.writingSkills).toHaveLength(1)
+    expect(result.writingSkills[0]).toMatchObject({
+      id: "skill:three",
+      name: "三翻四抖",
+      source: "uploaded",
+    })
+
+    await cleanup()
+  }, 15000)
+
+  it("passes enabled MCP config into agent tools and exposes MCP capabilities", async () => {
+    const mcpConfig: McpConfig = {
+      servers: [{
+        id: "graph",
+        name: "Knowledge Graph",
+        enabled: true,
+        tools: [{
+          serverId: "graph",
+          serverName: "Knowledge Graph",
+          name: "query_graph",
+          description: "Query graph",
+          operation: "read",
+          inputSchema: {
+            type: "object",
+            properties: { query: { type: "string", description: "Query" } },
+            required: ["query"],
+          },
+        }],
+      }],
+    }
+    const { result, cleanup } = await renderHook("test prompt", {
+      wiki: {
+        aiChatModel: "openai/gpt-4o",
+        project: { path: "/tmp/project" } as WikiProject,
+        mcpConfig,
+      },
+      skillConfig: {
+        version: 1,
+        defaultSkillId: "built-in:comprehensive",
+        disabledSkillIds: [],
+        projectSkills: [],
+        builtInSkillOverrides: [],
+        lastChapterDeAiSkillId: null,
+      },
+    })
+
+    expect(result.registry.has("mcp_graph_query_graph")).toBe(true)
+    expect(result.config?.tools.some((tool) => tool.name === "mcp_graph_query_graph")).toBe(true)
+    expect(result.mcpCapabilities).toContainEqual(expect.objectContaining({
+      kind: "mcp_tool",
+      toolName: "mcp_graph_query_graph",
+      source: "mcp",
+    }))
+    expect(result.mcpWarnings).toEqual([])
+
+    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",
+      content: "真实 MCP 结果",
+      summary: "真实 MCP 结果",
+    })
+    RealMcpConnectorMock.mockImplementation(function (this: { caller: unknown }) {
+      this.caller = realMcpCallerMock
+    })
+
+    const mcpConfig: McpConfig = {
+      servers: [{
+        id: "graph",
+        name: "Knowledge Graph",
+        enabled: true,
+        command: "node",
+        args: ["server.js"],
+        tools: [{
+          serverId: "graph",
+          serverName: "Knowledge Graph",
+          name: "query_graph",
+          description: "Query graph",
+          operation: "read",
+          inputSchema: {
+            type: "object",
+            properties: { query: { type: "string", description: "Query" } },
+            required: ["query"],
+          },
+        }],
+      }],
+    }
+    const { result, cleanup } = await renderHook("test prompt", {
+      wiki: {
+        aiChatModel: "openai/gpt-4o",
+        project: { path: "/tmp/project" } as WikiProject,
+        mcpConfig,
+      },
+      skillConfig: {
+        version: 1,
+        defaultSkillId: "built-in:comprehensive",
+        disabledSkillIds: [],
+        projectSkills: [],
+        builtInSkillOverrides: [],
+        lastChapterDeAiSkillId: null,
+      },
+    })
+
+    await result.registry.get("mcp_graph_query_graph")?.execute({ query: "主角" })
+
+    expect(RealMcpConnectorMock).toHaveBeenCalledWith(mcpConfig)
+    expect(realMcpCallerMock).toHaveBeenCalledWith(
+      expect.objectContaining({ serverId: "graph", toolName: "query_graph" }),
+      { query: "主角" },
+      undefined,
+    )
+
+    await cleanup()
+  }, 15000)
+})

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

@@ -0,0 +1,174 @@
+import { useCallback, useEffect, useMemo, useState } from "react"
+import { useWikiStore } from "@/stores/wiki-store"
+import { useChatStore } from "@/stores/chat-store"
+import { useOutlineChatStore } from "@/stores/outline-chat-store"
+import { loadDeAiSkillConfig, type DeAiSkillConfig } from "@/lib/novel/de-ai-skill-library"
+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"
+import type { AgentConfig } from "@/lib/agent/types"
+import type { AiCapability } from "@/lib/agent/capabilities/types"
+import { buildMcpRuntime } from "@/lib/mcp/runtime"
+import { RealMcpConnector } from "@/lib/mcp/real-connector"
+
+export interface UseAgentConfigResult {
+  config: AgentConfig | null
+  registry: ToolRegistry
+  supportsTools: boolean
+  skillConfigLoaded: boolean
+  skillConfig: DeAiSkillConfig | null
+  writingSkills: UserSkill[]
+  mcpCapabilities: AiCapability[]
+  mcpWarnings: string[]
+}
+
+export function useAgentConfig(systemPrompt: string): UseAgentConfigResult {
+  const aiChatModel = useWikiStore((s) => s.aiChatModel)
+  const projectPath = useWikiStore((s) => s.project?.path)
+  const dataVersion = useWikiStore((s) => s.dataVersion)
+  const baseLlmConfig = useWikiStore((s) => s.llmConfig)
+  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)
+
+  const outlineConversations = useOutlineChatStore((s) => s.conversations)
+
+  const [skillConfig, setSkillConfig] = useState<DeAiSkillConfig | null>(null)
+  const [writingSkills, setWritingSkills] = useState<UserSkill[]>([])
+  const [skillConfigLoaded, setSkillConfigLoaded] = useState(false)
+
+  useEffect(() => {
+    let cancelled = false
+    setSkillConfigLoaded(false)
+
+    if (!projectPath) {
+      setSkillConfig(null)
+      setWritingSkills([])
+      setSkillConfigLoaded(true)
+      return
+    }
+
+    Promise.all([
+      loadDeAiSkillConfig(projectPath).catch(() => null),
+      loadUserSkillConfig(projectPath)
+        .then(resolveEnabledWritingSkills)
+        .catch(() => [] as UserSkill[]),
+    ])
+      .then(([config, enabledWritingSkills]) => {
+        if (cancelled) return
+        setSkillConfig(config)
+        setWritingSkills(enabledWritingSkills)
+        setSkillConfigLoaded(true)
+      })
+      .catch(() => {
+        if (cancelled) return
+        setSkillConfig(null)
+        setWritingSkills([])
+        setSkillConfigLoaded(true)
+      })
+
+    return () => {
+      cancelled = true
+    }
+  }, [projectPath, dataVersion])
+
+  const getSkillConfig = useCallback(() => skillConfig, [skillConfig])
+  const getUserSkills = useCallback(() => writingSkills, [writingSkills])
+  const getSearchApiConfig = useCallback(() => searchApiConfig, [searchApiConfig])
+
+  const getChatConversations = useCallback(
+    () =>
+      chatConversations.map((conv) => ({
+        id: conv.id,
+        title: conv.title,
+        messages: chatMessages
+          .filter((m) => m.conversationId === conv.id)
+          .map((m) => ({ role: m.role, content: m.content })),
+      })),
+    [chatConversations, chatMessages],
+  )
+
+  const getOutlineConversations = useCallback(
+    () =>
+      outlineConversations.map((conv) => ({
+        id: conv.id,
+        title: conv.title,
+        messages: conv.messages.map((m) => ({ role: m.role, content: m.content })),
+      })),
+    [outlineConversations],
+  )
+
+  return useMemo(() => {
+    const supportsTools = modelSupportsTools(aiChatModel)
+
+    if (!supportsTools || !projectPath || !skillConfigLoaded) {
+      return {
+        config: null,
+        registry: new ToolRegistry(),
+        supportsTools,
+        skillConfigLoaded: false,
+        skillConfig,
+        writingSkills,
+        mcpCapabilities: [],
+        mcpWarnings: [],
+      }
+    }
+
+    const llmConfig = resolveModelConfig(aiChatModel, baseLlmConfig, providerConfigs)
+    const registry = new ToolRegistry()
+    const wikiPath = `${normalizePath(projectPath)}/wiki`
+    const novelMode = useWikiStore.getState().novelMode
+    const realMcpConnector = (mcpConfig?.servers ?? []).some((server) => server.enabled && server.command)
+      ? new RealMcpConnector(mcpConfig)
+      : undefined
+    const mcpRuntime = buildMcpRuntime(mcpConfig, undefined, realMcpConnector)
+    const config = buildAgentConfig(aiChatModel, systemPrompt, registry, {
+      wikiPath,
+      getSkillConfig,
+      getUserSkills,
+      getSearchApiConfig,
+      getChatConversations,
+      getOutlineConversations,
+      mcpTools: mcpRuntime.mcpTools,
+      llmConfig,
+      aiWorkflowMode,
+      runDeepChapterGeneration,
+      draftMode: novelMode,
+      projectPath: normalizePath(projectPath),
+    })
+
+    return {
+      config,
+      registry,
+      supportsTools: true,
+      skillConfigLoaded: true,
+      skillConfig,
+      writingSkills,
+      mcpCapabilities: mcpRuntime.mcpCapabilities,
+      mcpWarnings: mcpRuntime.warnings,
+    }
+  }, [
+    aiChatModel,
+    projectPath,
+    skillConfigLoaded,
+    baseLlmConfig,
+    providerConfigs,
+    mcpConfig,
+    aiWorkflowMode,
+    getSearchApiConfig,
+    getUserSkills,
+    systemPrompt,
+    getSkillConfig,
+    writingSkills,
+    getChatConversations,
+    getOutlineConversations,
+  ])
+}

+ 62 - 9
src/i18n/en.json

@@ -222,6 +222,7 @@
     "lint": "Check",
     "soul": "Soul",
     "skillLibrary": "Skill Library",
+    "writingSkillLibrary": "Writing Skill",
     "reviewCenter": "Review Center",
     "dismantling": "Dismantling",
     "settings": "Settings",
@@ -686,6 +687,7 @@
       "multimodal": "Multimodal",
       "webSearch": "Web Search",
       "network": "Network",
+      "mcp": "MCP Tools",
       "sourceWatch": "Source Watch",
       "scheduledImport": "Scheduled Import",
       "output": "Output",
@@ -697,7 +699,8 @@
       "contactSupport": "Contact & Support",
       "changelog": "Changelog",
       "dataManagement": "Data Management",
-      "about": "About"
+      "about": "About",
+      "classification": "Intent Routing"
     },
     "sections": {
       "shared": {
@@ -956,6 +959,25 @@
         "bypassLocal": "Bypass proxy for local addresses (recommended)",
         "bypassLocalHelp": "Requests to localhost, 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, and *.local do not go through the proxy."
       },
+      "mcp": {
+        "title": "MCP Tools",
+        "description": "Configure MCP tool descriptors visible to AI chat. This stage saves config and runs local validation; it does not start external MCP processes.",
+        "addSample": "Add sample graph MCP",
+        "summary": "{{servers}} MCP servers configured, {{tools}} tools enabled, {{capabilities}} AI capabilities available.",
+        "saved": "Saved",
+        "warnings": "Runtime warnings",
+        "empty": "No MCP servers yet. Add the sample graph MCP, then edit the descriptors for your service.",
+        "enable": "Enable MCP server",
+        "disable": "Disable MCP server",
+        "delete": "Delete MCP server",
+        "deleteConfirm": "Delete this MCP server?",
+        "serverId": "Server ID",
+        "serverName": "Server name",
+        "toolsJson": "Tool descriptor JSON",
+        "toolsJsonHint": "Enter an array of MCP tool descriptors. Delete and overwrite tools are blocked at runtime.",
+        "invalidJson": "Invalid tool descriptor JSON",
+        "invalidTools": "Tool descriptors must be an MCP tool array"
+      },
       "output": {
         "title": "Output Preferences",
         "description": "Controls generation language, chat history length, and related output behavior.",
@@ -966,14 +988,14 @@
         "historyCurrent": "Currently {{count}} messages (about {{turns}} turns)"
       },
       "interface": {
-      "title": "Interface",
-      "description": "Interface language and visual presentation settings.",
-      "uiLanguage": "UI language",
-      "uiLanguageHint": "Only affects menus, labels, and other interface text. AI output language is configured separately under Output.",
-      "sidebarNavTitle": "Left Feature Bar",
-      "sidebarNavDescription": "Select the features shown on the left. Unselected features are hidden from the bar.",
-      "sidebarNavOrderHint": "Long-press and drag icons in the left feature bar to change their order."
-    },
+        "title": "Interface",
+        "description": "Interface language and visual presentation settings.",
+        "uiLanguage": "UI language",
+        "uiLanguageHint": "Only affects menus, labels, and other interface text. AI output language is configured separately under Output.",
+        "sidebarNavTitle": "Left Feature Bar",
+        "sidebarNavDescription": "Select the features shown on the left. Unselected features are hidden from the bar.",
+        "sidebarNavOrderHint": "Long-press and drag icons in the left feature bar to change their order."
+      },
       "dataManagement": {
         "title": "Data Management",
         "description": "Backup and restore all your data, including model configs, AI chats, novel content, outlines, memory, book analysis results, etc.",
@@ -1055,6 +1077,10 @@
           "merged": "Merged",
           "skipped": "Marked as Not Duplicates"
         }
+      },
+      "classification": {
+        "title": "Intent Routing Configuration",
+        "description": "Manage classification routing to control AI session data source loading"
       }
     }
   },
@@ -1124,6 +1150,7 @@
       "lint": "Memory Center",
       "soul": "Soul",
       "skillLibrary": "Skill Library",
+      "writingSkillLibrary": "Writing Skill",
       "reviewCenter": "Review Center",
       "storySimulation": "Story Simulation",
       "dismantling": "Dismantling",
@@ -1816,5 +1843,31 @@
     "stateChanges": "State Changes",
     "summary": "Summary",
     "recommended": "Recommended"
+  },
+  "classification": {
+    "title": "Intent Routing Configuration",
+    "description": "Manage classification routing to control AI session data source loading",
+    "noProject": "Please open a project first",
+    "checking": "Checking status...",
+    "notFound": "classification.md not found",
+    "valid": "classification.md configuration is valid",
+    "needsUpgrade": "Configuration version is outdated, upgrade recommended",
+    "error": "Load failed",
+    "create": "Create Default Config",
+    "created": "Default classification.md created",
+    "createError": "Creation failed",
+    "upgrade": "Upgrade Config Version",
+    "upgraded": "Upgraded to latest version",
+    "upgradeError": "Upgrade failed",
+    "refresh": "Recheck",
+    "faq": "FAQ",
+    "editorHint": "Format is checked before saving",
+    "save": "Save",
+    "saved": "classification.md saved",
+    "saveError": "Save failed",
+    "saveInvalid": "Invalid format: keep the auto-generated route block and at least one valid route rule",
+    "restoreDefault": "Restore Default",
+    "restored": "Default classification.md restored",
+    "restoreError": "Restore failed"
   }
 }

+ 62 - 9
src/i18n/zh.json

@@ -114,6 +114,7 @@
     "lint": "检查",
     "soul": "灵魂",
     "skillLibrary": "技能库",
+    "writingSkillLibrary": "写作 Skill",
     "reviewCenter": "审查中心",
     "dismantling": "拆文库",
     "settings": "设置",
@@ -398,6 +399,7 @@
       "multimodal": "图片描述",
       "webSearch": "网页搜索",
       "network": "网络",
+      "mcp": "MCP 工具",
       "sourceWatch": "资料文件夹监控",
       "scheduledImport": "定时导入",
       "output": "输出偏好",
@@ -409,7 +411,8 @@
       "contactSupport": "联系与支持",
       "changelog": "更新日志",
       "dataManagement": "数据管理",
-      "about": "关于"
+      "about": "关于",
+      "classification": "意图路由"
     },
     "sections": {
       "shared": {
@@ -669,6 +672,25 @@
         "bypassLocal": "本地地址不走代理(推荐)",
         "bypassLocalHelp": "对 localhost、127.0.0.0/8、10.0.0.0/8、172.16.0.0/12、192.168.0.0/16 以及 *.local 的请求会绕过代理。如果你用 Ollama / LM Studio 或局域网内的 LLM 服务,请保持开启。"
       },
+      "mcp": {
+        "title": "MCP 工具",
+        "description": "配置 AI 会话可以看见的 MCP 工具描述。当前阶段只保存配置和做运行时预检,不启动外部 MCP 进程。",
+        "addSample": "添加示例图谱 MCP",
+        "summary": "已配置 {{servers}} 个 MCP 服务,当前可启用 {{tools}} 个工具,{{capabilities}} 个 AI 能力。",
+        "saved": "已保存",
+        "warnings": "运行时预警",
+        "empty": "还没有 MCP 服务。可以先添加示例图谱 MCP,再按你的 MCP 服务修改工具描述。",
+        "enable": "启用 MCP 服务",
+        "disable": "停用 MCP 服务",
+        "delete": "删除 MCP 服务",
+        "deleteConfirm": "确定删除这个 MCP 服务吗?",
+        "serverId": "服务 ID",
+        "serverName": "服务名称",
+        "toolsJson": "工具描述 JSON",
+        "toolsJsonHint": "填写 MCP 工具 descriptor 数组。删除和覆盖类工具会在运行时被阻断。",
+        "invalidJson": "工具描述 JSON 格式错误",
+        "invalidTools": "工具描述必须是 MCP 工具数组"
+      },
       "sourceWatch": {
         "title": "资料文件夹自动监控",
         "description": "监控 raw/sources 的外部变化,并选择哪些文件允许自动进入提取队列。",
@@ -726,14 +748,14 @@
         "historyCurrent": "当前 {{count}} 条消息(约 {{turns}} 轮对话)"
       },
       "interface": {
-      "title": "界面",
-      "description": "界面语言和外观样式。切换后立即生效并持久化。",
-      "uiLanguage": "UI 语言",
-      "uiLanguageHint": "只影响按钮、标签这些 UI 文案,不影响 AI 输出语言(那个在\"输出偏好\"里单独设置)。",
-      "sidebarNavTitle": "左侧功能栏",
-      "sidebarNavDescription": "勾选要在左侧显示的功能,取消勾选后该功能入口会隐藏。",
-      "sidebarNavOrderHint": "图标顺序可在左侧功能栏中长按拖动调整。"
-    },
+        "title": "界面",
+        "description": "界面语言和外观样式。切换后立即生效并持久化。",
+        "uiLanguage": "UI 语言",
+        "uiLanguageHint": "只影响按钮、标签这些 UI 文案,不影响 AI 输出语言(那个在\"输出偏好\"里单独设置)。",
+        "sidebarNavTitle": "左侧功能栏",
+        "sidebarNavDescription": "勾选要在左侧显示的功能,取消勾选后该功能入口会隐藏。",
+        "sidebarNavOrderHint": "图标顺序可在左侧功能栏中长按拖动调整。"
+      },
       "novel": {
         "title": "小说",
         "description": "项目级小说写作模式和修改反馈窗口设置。",
@@ -804,6 +826,10 @@
         "description": "构建信息和运行时状态。",
         "version": "版本",
         "appDescription": "一个由 LLM 驱动的个人知识库。源代码:"
+      },
+      "classification": {
+        "title": "意图路由配置",
+        "description": "管理分类路由配置,控制 AI 会话的数据源加载策略"
       }
     }
   },
@@ -1005,6 +1031,7 @@
       "lint": "记忆中心",
       "soul": "灵魂",
       "skillLibrary": "技能库",
+      "writingSkillLibrary": "写作 Skill",
       "reviewCenter": "审查中心",
       "storySimulation": "剧情推演室",
       "dismantling": "拆文库",
@@ -1689,5 +1716,31 @@
     "stateChanges": "状态变化",
     "summary": "摘要",
     "recommended": "推荐"
+  },
+  "classification": {
+    "title": "意图路由配置",
+    "description": "管理分类路由配置,控制 AI 会话的数据源加载策略",
+    "noProject": "请先打开一个项目",
+    "checking": "正在检查状态...",
+    "notFound": "未找到 classification.md",
+    "valid": "classification.md 配置有效",
+    "needsUpgrade": "配置版本较旧,建议升级",
+    "error": "加载失败",
+    "create": "创建默认配置",
+    "created": "已创建默认 classification.md",
+    "createError": "创建失败",
+    "upgrade": "升级配置版本",
+    "upgraded": "已升级到最新版本",
+    "upgradeError": "升级失败",
+    "refresh": "重新检查",
+    "faq": "常见问题",
+    "editorHint": "编辑后保存前会先校验格式",
+    "save": "保存",
+    "saved": "已保存 classification.md",
+    "saveError": "保存失败",
+    "saveInvalid": "格式错误:请保留自动生成路由区块,并至少包含一条有效路由规则",
+    "restoreDefault": "恢复默认",
+    "restored": "已恢复默认 classification.md",
+    "restoreError": "恢复失败"
   }
 }

+ 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,
+  )
+}

+ 226 - 0
src/lib/agent/ai-chat-workflow-convergence.spec.ts

@@ -0,0 +1,226 @@
+import { describe, expect, it, vi } from "vitest"
+import { createPrePluginChain } from "./pipeline"
+import { createBuildContextPackPlugin } from "./plugins/build-context-pack-plugin"
+import { createSelectSkillsPlugin } from "./plugins/select-skills-plugin"
+import { createSelectCapabilitiesPlugin } from "./plugins/select-capabilities-plugin"
+import { createTrimContextPlugin } from "./plugins/trim-context-plugin"
+import { createBuildSystemPromptPlugin } from "./plugins/build-system-prompt-plugin"
+import { normalizeUserSkill } from "@/lib/novel/skill-library"
+import type { AgentConfig, Tool } from "./types"
+import type { ContextPack } from "@/lib/novel/context-engine"
+import type { AiCapability } from "./capabilities/types"
+
+const contextPack: ContextPack = {
+  task: "写作任务",
+  chapterGoal: "章节目标",
+  outline: "大纲内容",
+  recentSummaries: ["上一章摘要"],
+  previousChapterEnding: "上一章结尾",
+  characterStates: "人物状态",
+  soulDoc: "作品灵魂",
+  characterAuras: "",
+  cognitionStates: "",
+  foreshadowingStates: "",
+  timeline: "",
+  relatedSettings: "",
+  canonRules: "",
+  writingStyle: "",
+  searchResults: "",
+  graphSearchResults: "",
+  mustDo: "",
+  mustAvoid: "",
+  nextChapterAdvice: "",
+  revisionDirectives: "",
+}
+
+function tool(name: string, category: Tool["category"] = "read"): Tool {
+  return {
+    name,
+    description: name,
+    category,
+    permission: category === "write" ? "confirm" : "auto",
+    parameters: {},
+    execute: async () => name,
+  }
+}
+
+function agentConfig(): AgentConfig {
+  return {
+    maxRounds: 3,
+    tools: [
+      tool("read_chapter"),
+      tool("read_outline"),
+      tool("read_memory"),
+      tool("search_chapters"),
+      tool("load_context"),
+      tool("trim_context"),
+      tool("write_chapter", "write"),
+      tool("apply_skill", "action"),
+      tool("web_search"),
+      tool("read_web_page"),
+    ],
+    systemPrompt: "基础系统提示",
+    llmConfig: {} as AgentConfig["llmConfig"],
+  }
+}
+
+function threeTurnsSkill() {
+  return normalizeUserSkill({
+    id: "three-turns",
+    name: "三翻四抖",
+    description: "三次转折,四次震惊",
+    kind: ["structure", "planning"],
+    stages: ["planning", "drafting"],
+    modes: ["standard", "strict"],
+    content: "内部技巧内容",
+    source: "uploaded",
+  })
+}
+
+function outputSkill() {
+  return normalizeUserSkill({
+    id: "output",
+    name: "正文输出协议",
+    kind: ["output"],
+    stages: ["output"],
+    modes: ["fast", "standard", "strict"],
+    content: "只输出最终文本",
+    source: "project",
+  })
+}
+
+function mcpCapability(): AiCapability {
+  return {
+    id: "mcp:knowledge-graph:query",
+    name: "Knowledge Graph Query",
+    kind: "mcp_tool",
+    permission: "auto",
+    modes: ["strict"],
+    intents: ["character_query", "setting_query", "general"],
+    toolName: "mcp_knowledge_graph_query",
+    source: "mcp",
+  }
+}
+
+async function runWorkflow(input: {
+  userMessage: string
+  intent: any
+  mode: "fast" | "standard" | "strict"
+  skills?: ReturnType<typeof normalizeUserSkill>[]
+  capabilities?: AiCapability[]
+  blockedSources?: string[]
+}) {
+  const chain = createPrePluginChain([
+    createBuildContextPackPlugin({ buildContextPack: vi.fn().mockResolvedValue(contextPack) }),
+    createSelectSkillsPlugin(),
+    createSelectCapabilitiesPlugin(),
+    createTrimContextPlugin({ contextPackToPromptFn: () => "裁剪后的上下文" }),
+    createBuildSystemPromptPlugin(),
+  ])
+
+  return chain.run({
+    userMessage: input.userMessage,
+    projectPath: "/project",
+    agentConfig: agentConfig(),
+    novelMode: true,
+    taskRoute: { intent: input.intent, confidence: 0.9, extractedParams: {} },
+    aiWorkflowMode: input.mode,
+    availableSkills: input.skills ?? [],
+    availableCapabilities: input.capabilities,
+    blockedSources: input.blockedSources,
+  })
+}
+
+describe("AI chat workflow convergence", () => {
+  it("fast polish keeps tool use minimal and does not enable write tools", async () => {
+    const result = await runWorkflow({
+      userMessage: "帮我润色这句话",
+      intent: "polish_chapter",
+      mode: "fast",
+      skills: [outputSkill()],
+    })
+
+    expect(result.enabledToolNames).toEqual(expect.arrayContaining([
+      "read_chapter",
+      "read_outline",
+      "load_context",
+      "trim_context",
+    ]))
+    expect(result.enabledToolNames).not.toContain("write_chapter")
+    expect(result.selectedCapabilities).toContainEqual(expect.objectContaining({ kind: "user_skill", skillId: "output" }))
+  })
+
+  it("standard next chapter enables writing context, selected skill, and write confirmation tool", async () => {
+    const result = await runWorkflow({
+      userMessage: "帮我写下一章",
+      intent: "write_chapter",
+      mode: "standard",
+      skills: [threeTurnsSkill(), outputSkill()],
+    })
+
+    expect(result.enabledToolNames).toEqual(expect.arrayContaining([
+      "read_chapter",
+      "read_outline",
+      "read_memory",
+      "search_chapters",
+      "write_chapter",
+      "apply_skill",
+    ]))
+    expect(result.selectedCapabilities).toContainEqual(expect.objectContaining({ kind: "user_skill", skillId: "three-turns" }))
+    expect(result.selectedCapabilities).toContainEqual(expect.objectContaining({ toolName: "write_chapter", permission: "confirm" }))
+  })
+
+  it("explicit external search enables search tools without pretending search always happened", async () => {
+    const result = await runWorkflow({
+      userMessage: "搜索一下黄蓉的信息再回答",
+      intent: "setting_query",
+      mode: "standard",
+    })
+
+    expect(result.enabledToolNames).toEqual(expect.arrayContaining(["web_search", "read_web_page"]))
+    expect(result.selectedCapabilities).toContainEqual(expect.objectContaining({
+      kind: "web_search",
+      reason: expect.stringContaining("external search"),
+    }))
+  })
+
+  it("strict knowledge graph task can select read-only MCP capability", async () => {
+    const result = await runWorkflow({
+      userMessage: "use knowledge graph to analyze protagonist relationship",
+      intent: "character_query",
+      mode: "strict",
+      capabilities: [
+        ...agentConfig().tools.map((item): AiCapability => ({
+          id: `tool:${item.name}`,
+          name: item.name,
+          kind: item.name === "web_search" || item.name === "read_web_page" ? "web_search" as const : "built_in_tool" as const,
+          permission: item.permission ?? "auto",
+          modes: ["fast", "standard", "strict"],
+          intents: ["general"],
+          toolName: item.name,
+          source: "built-in" as const,
+        })),
+        mcpCapability(),
+      ],
+    })
+
+    expect(result.enabledToolNames).toContain("mcp_knowledge_graph_query")
+    expect(result.selectedCapabilities).toContainEqual(expect.objectContaining({
+      kind: "mcp_tool",
+      toolName: "mcp_knowledge_graph_query",
+    }))
+  })
+
+  it("classification graph block prevents MCP graph capability selection", async () => {
+    const result = await runWorkflow({
+      userMessage: "use knowledge graph to analyze protagonist relationship",
+      intent: "character_query",
+      mode: "strict",
+      capabilities: [mcpCapability()],
+      blockedSources: ["graph"],
+    })
+
+    expect(result.enabledToolNames).not.toContain("mcp_knowledge_graph_query")
+    expect(result.selectedCapabilities?.some((item) => item.kind === "mcp_tool")).toBe(false)
+  })
+})

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

@@ -0,0 +1,137 @@
+import type { UserSkill } from "@/lib/novel/skill-library"
+import type { AiCapability, CapabilityIntent, CapabilityKind, CapabilityPermission } from "./types"
+import type { AiWorkflowMode } from "../workflow-mode"
+
+const ALL_MODES: AiWorkflowMode[] = ["fast", "standard", "strict"]
+
+const WRITING_INTENTS: CapabilityIntent[] = [
+  "write_chapter",
+  "continue_chapter",
+  "rewrite_chapter",
+  "polish_chapter",
+]
+
+const REVIEW_INTENTS: CapabilityIntent[] = ["review_chapter", "lint_chapter"]
+
+const QUERY_INTENTS: CapabilityIntent[] = [
+  "search_plot",
+  "character_query",
+  "foreshadowing_query",
+  "timeline_query",
+  "setting_query",
+]
+
+const READ_TOOL_INTENTS: CapabilityIntent[] = [
+  ...WRITING_INTENTS,
+  ...REVIEW_INTENTS,
+  ...QUERY_INTENTS,
+  "generate_outline",
+  "extract_memory",
+  "general",
+]
+
+const TOOL_LABELS: Record<string, string> = {
+  read_chapter: "Read Chapter",
+  read_outline: "Read Outline",
+  read_memory: "Read Memory",
+  read_deduction: "Read Deduction",
+  read_chat_history: "Read Chat History",
+  read_outline_history: "Read Outline History",
+  search_chapters: "Search Chapters",
+  list_chapters: "List Chapters",
+  list_outlines: "List Outlines",
+  list_memories: "List Memories",
+  list_deductions: "List Deductions",
+  write_chapter: "Write Chapter",
+  write_outline_node: "Write Outline Node",
+  write_memory: "Write Memory",
+  apply_skill: "Apply Skill",
+  web_search: "External Search",
+  read_web_page: "Read Web Page",
+  route_task: "Route Task",
+  load_context: "Load Context",
+  trim_context: "Trim Context",
+  run_chapter_workflow: "Chapter Workflow",
+}
+
+export interface BuildAvailableCapabilitiesOptions {
+  toolNames?: string[]
+  selectedSkills?: UserSkill[]
+  mcpCapabilities?: AiCapability[]
+}
+
+export function buildAvailableCapabilities(options: BuildAvailableCapabilitiesOptions): AiCapability[] {
+  return [
+    ...buildBuiltInToolCapabilities(options.toolNames ?? []),
+    ...buildUserSkillCapabilities(options.selectedSkills ?? []),
+    ...(options.mcpCapabilities ?? []),
+  ]
+}
+
+export function buildBuiltInToolCapabilities(toolNames: string[]): AiCapability[] {
+  return toolNames.map((toolName) => createToolCapability(toolName))
+}
+
+export function buildUserSkillCapabilities(skills: UserSkill[]): AiCapability[] {
+  return skills.map((skill) => ({
+    id: `skill:${skill.id}`,
+    name: skill.name,
+    kind: "user_skill",
+    permission: "auto",
+    modes: [...skill.modes],
+    intents: skillIntents(skill),
+    skillId: skill.id,
+    source: skill.source,
+  }))
+}
+
+function createToolCapability(toolName: string): AiCapability {
+  const kind: CapabilityKind = toolName === "web_search" || toolName === "read_web_page"
+    ? "web_search"
+    : "built_in_tool"
+  const permission: CapabilityPermission = toolName.startsWith("write_") ? "confirm" : "auto"
+
+  return {
+    id: `tool:${toolName}`,
+    name: TOOL_LABELS[toolName] ?? toolName,
+    kind,
+    permission,
+    modes: ALL_MODES,
+    intents: toolIntents(toolName),
+    toolName,
+    source: "built-in",
+  }
+}
+
+function toolIntents(toolName: string): CapabilityIntent[] {
+  if (toolName === "web_search" || toolName === "read_web_page") {
+    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"]
+  if (toolName === "route_task" || toolName === "load_context" || toolName === "trim_context") {
+    return READ_TOOL_INTENTS
+  }
+  return READ_TOOL_INTENTS
+}
+
+function skillIntents(skill: UserSkill): CapabilityIntent[] {
+  const intents = new Set<CapabilityIntent>()
+  if (skill.kind.some((kind) => kind === "structure" || kind === "planning" || kind === "output")) {
+    WRITING_INTENTS.forEach((intent) => intents.add(intent))
+    intents.add("generate_outline")
+  }
+  if (skill.kind.some((kind) => kind === "review" || kind === "knowledge")) {
+    REVIEW_INTENTS.forEach((intent) => intents.add(intent))
+    QUERY_INTENTS.forEach((intent) => intents.add(intent))
+  }
+  if (skill.kind.includes("style") || skill.kind.includes("rewrite")) {
+    intents.add("polish_chapter")
+    intents.add("rewrite_chapter")
+  }
+  if (intents.size === 0) intents.add("general")
+  return Array.from(intents)
+}

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

@@ -0,0 +1,196 @@
+import { describe, expect, it } from "vitest"
+import type { AiCapability } from "./types"
+import {
+  buildAvailableCapabilities,
+  buildBuiltInToolCapabilities,
+  buildUserSkillCapabilities,
+} from "./registry"
+import { selectCapabilities } from "./selector"
+import { normalizeUserSkill } from "@/lib/novel/skill-library"
+
+const toolNames = [
+  "read_chapter",
+  "read_outline",
+  "load_context",
+  "trim_context",
+  "write_chapter",
+  "web_search",
+  "read_web_page",
+]
+
+function futureMcpCapability(): AiCapability {
+  return {
+    id: "mcp:knowledge-graph:query",
+    name: "Knowledge Graph Query",
+    kind: "mcp_tool",
+    permission: "auto",
+    modes: ["strict"],
+    intents: ["character_query", "setting_query", "general"],
+    toolName: "mcp_knowledge_graph_query",
+    source: "mcp",
+  }
+}
+
+describe("AI capability selector", () => {
+  it("builds capabilities from built-in tool names and selected skills", () => {
+    const skill = normalizeUserSkill({
+      id: "three-turns",
+      name: "Three Turns",
+      kind: ["structure", "planning"],
+      stages: ["planning", "drafting"],
+      modes: ["standard", "strict"],
+      content: "private skill content",
+      source: "project",
+    })
+
+    const capabilities = buildAvailableCapabilities({
+      toolNames,
+      selectedSkills: [skill],
+    })
+
+    expect(capabilities.some((capability) => capability.toolName === "read_chapter")).toBe(true)
+    expect(capabilities.some((capability) => capability.kind === "web_search" && capability.toolName === "web_search")).toBe(true)
+    expect(capabilities).toContainEqual(expect.objectContaining({
+      id: "skill:three-turns",
+      name: "Three Turns",
+      kind: "user_skill",
+      skillId: "three-turns",
+    }))
+    expect(JSON.stringify(capabilities)).not.toContain("private skill content")
+  })
+
+  it("keeps fast writing mode to minimal read/context tools and selected output skills", () => {
+    const outputSkill = normalizeUserSkill({
+      id: "output",
+      name: "Output Protocol",
+      kind: ["output"],
+      stages: ["output"],
+      modes: ["fast", "standard", "strict"],
+      content: "private output instructions",
+    })
+    const capabilities = buildAvailableCapabilities({
+      toolNames,
+      selectedSkills: [outputSkill],
+      mcpCapabilities: [futureMcpCapability()],
+    })
+
+    const selected = selectCapabilities({
+      capabilities,
+      intent: "write_chapter",
+      mode: "fast",
+      userMessage: "write the next chapter",
+    })
+
+    expect(selected.map((item) => item.id)).toEqual(expect.arrayContaining([
+      "tool:read_chapter",
+      "tool:read_outline",
+      "tool:load_context",
+      "tool:trim_context",
+      "skill:output",
+    ]))
+    expect(selected.some((item) => item.kind === "web_search")).toBe(false)
+    expect(selected.some((item) => item.kind === "mcp_tool")).toBe(false)
+    expect(JSON.stringify(selected)).not.toContain("private output instructions")
+  })
+
+  it("selects web search only when standard writing explicitly asks for external search", () => {
+    const capabilities = buildBuiltInToolCapabilities(toolNames)
+
+    const withoutSearchRequest = selectCapabilities({
+      capabilities,
+      intent: "write_chapter",
+      mode: "standard",
+      userMessage: "write the next chapter",
+    })
+
+    const withSearchRequest = selectCapabilities({
+      capabilities,
+      intent: "write_chapter",
+      mode: "standard",
+      userMessage: "please search the web for Huang Rong information first",
+    })
+
+    expect(withoutSearchRequest.some((item) => item.kind === "web_search")).toBe(false)
+    expect(withSearchRequest.map((item) => item.toolName)).toEqual(expect.arrayContaining([
+      "web_search",
+      "read_web_page",
+    ]))
+  })
+
+  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),
+      futureMcpCapability(),
+    ]
+
+    const selected = selectCapabilities({
+      capabilities,
+      intent: "character_query",
+      mode: "strict",
+      userMessage: "use knowledge graph to analyze protagonist and antagonist relationship",
+    })
+
+    expect(selected).toContainEqual(expect.objectContaining({
+      id: "mcp:knowledge-graph:query",
+      kind: "mcp_tool",
+      permission: "auto",
+      toolName: "mcp_knowledge_graph_query",
+    }))
+  })
+
+  it("does not select graph MCP capabilities when classification blocks graph data", () => {
+    const selected = selectCapabilities({
+      capabilities: [
+        ...buildBuiltInToolCapabilities(toolNames),
+        futureMcpCapability(),
+      ],
+      intent: "character_query",
+      mode: "strict",
+      userMessage: "use knowledge graph to analyze relationships",
+      blockedSources: ["graph"],
+    })
+
+    expect(selected.some((item) => item.kind === "mcp_tool")).toBe(false)
+  })
+
+  it("can build selected skill capabilities directly", () => {
+    const capabilities = buildUserSkillCapabilities([
+      normalizeUserSkill({
+        id: "review",
+        name: "Review Skill",
+        kind: ["review"],
+        stages: ["review"],
+        modes: ["standard", "strict"],
+        content: "private review content",
+        source: "uploaded",
+      }),
+    ])
+
+    expect(capabilities).toEqual([
+      expect.objectContaining({
+        id: "skill:review",
+        kind: "user_skill",
+        permission: "auto",
+        source: "uploaded",
+      }),
+    ])
+    expect(JSON.stringify(capabilities)).not.toContain("private review content")
+  })
+})

+ 160 - 0
src/lib/agent/capabilities/selector.ts

@@ -0,0 +1,160 @@
+import type { DataSourceCategory } from "@/lib/novel/classification"
+import type { NovelTaskIntent } from "@/lib/novel/task-router"
+import type { AiWorkflowMode } from "../workflow-mode"
+import type { AiCapability, SelectedCapabilityTrace } from "./types"
+
+const WRITING_INTENTS = new Set<NovelTaskIntent>([
+  "write_chapter",
+  "continue_chapter",
+  "rewrite_chapter",
+  "polish_chapter",
+])
+
+const REVIEW_INTENTS = new Set<NovelTaskIntent>(["review_chapter", "lint_chapter"])
+
+const KNOWLEDGE_INTENTS = new Set<NovelTaskIntent>([
+  "character_query",
+  "foreshadowing_query",
+  "timeline_query",
+  "setting_query",
+])
+
+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",
+  "read_memory",
+  "search_chapters",
+  "load_context",
+  "trim_context",
+  "run_chapter_workflow",
+  "write_chapter",
+  "apply_skill",
+])
+const STRICT_EXTRA_TOOLS = new Set([
+  "read_deduction",
+  "read_chat_history",
+  "read_outline_history",
+  "list_chapters",
+  "list_outlines",
+  "list_memories",
+  "list_deductions",
+])
+
+export interface SelectCapabilitiesInput {
+  capabilities: AiCapability[]
+  intent: NovelTaskIntent
+  mode: AiWorkflowMode
+  userMessage: string
+  blockedSources?: DataSourceCategory[]
+}
+
+export function selectCapabilities(input: SelectCapabilitiesInput): SelectedCapabilityTrace[] {
+  const blockedSources = new Set(input.blockedSources ?? [])
+  const selected: SelectedCapabilityTrace[] = []
+
+  for (const capability of input.capabilities) {
+    if (!capability.modes.includes(input.mode)) continue
+    const reason = selectionReason(capability, input, blockedSources)
+    if (!reason) continue
+    selected.push(toTrace(capability, reason))
+  }
+
+  return selected
+}
+
+function selectionReason(
+  capability: AiCapability,
+  input: SelectCapabilitiesInput,
+  blockedSources: Set<DataSourceCategory>,
+): string | null {
+  if (capability.kind === "mcp_tool") {
+    if (blockedSources.has("graph")) return null
+    if (input.mode !== "strict") return null
+    if (!isKnowledgeGraphRequest(input.userMessage, input.intent)) return null
+    return "strict mode knowledge task can use future MCP placeholder"
+  }
+
+  if (capability.kind === "web_search") {
+    if (isExplicitSearchRequest(input.userMessage)) {
+      return "user explicitly requested external search"
+    }
+    if (input.mode !== "fast" && capability.intents.includes("external_search") && input.intent === "setting_query") {
+      return "task may require external information"
+    }
+    return null
+  }
+
+  if (capability.kind === "user_skill") {
+    if (!capability.intents.includes(input.intent) && !capability.intents.includes("general")) return null
+    if (input.mode === "fast") return "fast mode selected only preselected lightweight skills"
+    if (input.mode === "strict") return "strict mode selected preselected skill"
+    return "standard mode selected preselected skill"
+  }
+
+  if (capability.kind === "built_in_tool") {
+    return builtInToolReason(capability, input)
+  }
+
+  return null
+}
+
+function builtInToolReason(capability: AiCapability, input: SelectCapabilitiesInput): string | null {
+  const name = capability.toolName
+  if (!name) return null
+
+  if (input.mode === "fast" && WRITING_INTENTS.has(input.intent)) {
+    return FAST_WRITING_TOOLS.has(name) ? "fast mode minimal writing context" : null
+  }
+
+  if (WRITING_INTENTS.has(input.intent)) {
+    if (STANDARD_WRITING_TOOLS.has(name)) return "writing task capability"
+    if (input.mode === "strict" && STRICT_EXTRA_TOOLS.has(name)) return "strict mode extended writing context"
+    return null
+  }
+
+  if (input.intent === "generate_outline") {
+    if (name === "read_outline" || name === "load_context" || name === "trim_context" || name === "write_outline_node") {
+      return "outline task capability"
+    }
+    return null
+  }
+
+  if (REVIEW_INTENTS.has(input.intent) || KNOWLEDGE_INTENTS.has(input.intent) || input.intent === "search_plot") {
+    if (name.startsWith("read_") || name.startsWith("list_") || name === "search_chapters" || name === "load_context" || name === "trim_context") {
+      return "query and review task capability"
+    }
+  }
+
+  if (input.intent === "extract_memory") {
+    if (name === "read_chapter" || name === "write_memory" || name === "load_context" || name === "trim_context") {
+      return "memory extraction capability"
+    }
+  }
+
+  return null
+}
+
+function toTrace(capability: AiCapability, reason: string): SelectedCapabilityTrace {
+  return {
+    id: capability.id,
+    name: capability.name,
+    kind: capability.kind,
+    permission: capability.permission,
+    source: capability.source,
+    reason,
+    toolName: capability.toolName,
+    skillId: capability.skillId,
+  }
+}
+
+function isExplicitSearchRequest(message: string): boolean {
+  const normalized = message.toLowerCase()
+  return /search|web|internet|online|external|latest|联网|搜索|查资料|外部|最新/.test(normalized)
+}
+
+function isKnowledgeGraphRequest(message: string, intent: NovelTaskIntent): boolean {
+  const normalized = message.toLowerCase()
+  if (/graphrag|knowledge\s*graph|graph|图谱|知识图谱|关系/.test(normalized)) return true
+  return KNOWLEDGE_INTENTS.has(intent)
+}

+ 37 - 0
src/lib/agent/capabilities/types.ts

@@ -0,0 +1,37 @@
+import type { AiWorkflowMode } from "../workflow-mode"
+import type { NovelTaskIntent } from "@/lib/novel/task-router"
+
+export type CapabilityKind =
+  | "built_in_tool"
+  | "user_skill"
+  | "web_search"
+  | "mcp_tool"
+
+export type CapabilityPermission = "auto" | "confirm"
+
+export type CapabilityIntent = NovelTaskIntent | "external_search" | "general"
+
+export type CapabilitySource = "built-in" | "project" | "uploaded" | "mcp"
+
+export interface AiCapability {
+  id: string
+  name: string
+  kind: CapabilityKind
+  permission: CapabilityPermission
+  modes: AiWorkflowMode[]
+  intents: CapabilityIntent[]
+  toolName?: string
+  skillId?: string
+  source?: CapabilitySource
+}
+
+export interface SelectedCapabilityTrace {
+  id: string
+  name: string
+  kind: CapabilityKind
+  permission: CapabilityPermission
+  source?: CapabilitySource | string
+  reason: string
+  toolName?: string
+  skillId?: string
+}

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

@@ -0,0 +1,51 @@
+import type { LlmConfig } from "@/stores/wiki-store"
+import type { ToolRegistry } from "./registry"
+import type { AgentConfig } from "./types"
+import { DEFAULT_MAX_ROUNDS } from "./types"
+import { registerAllBuiltInTools } from "./tools"
+import type { ToolFactoryOptions } from "./tools"
+
+export const TOOL_UNSUPPORTED_MODEL_PREFIXES: string[] = [
+  "o1",
+  "o3-mini",
+  "deepseek-reasoner",
+  "claude-code",
+  "codex-cli",
+]
+
+export interface BuildAgentConfigOptions extends ToolFactoryOptions {
+  llmConfig: LlmConfig
+  requestOverrides?: AgentConfig["requestOverrides"]
+}
+
+export function modelSupportsTools(modelId: string): boolean {
+  const id = modelId.trim().toLowerCase()
+  if (!id) return false
+
+  const modelPart = id.includes("/") ? id.slice(id.lastIndexOf("/") + 1) : id
+
+  return !TOOL_UNSUPPORTED_MODEL_PREFIXES.some((prefix) => {
+    const lowerPrefix = prefix.toLowerCase()
+    return id.startsWith(lowerPrefix) || modelPart.startsWith(lowerPrefix)
+  })
+}
+
+export function buildAgentConfig(
+  modelId: string,
+  systemPrompt: string,
+  registry: ToolRegistry,
+  options: BuildAgentConfigOptions,
+): AgentConfig {
+  registry.clear()
+  registerAllBuiltInTools(registry, options)
+
+  return {
+    maxRounds: DEFAULT_MAX_ROUNDS,
+    tools: registry.list(),
+    systemPrompt,
+    llmConfig: options.llmConfig,
+    modelId,
+    projectPath: options.projectPath,
+    requestOverrides: options.requestOverrides,
+  }
+}

+ 161 - 0
src/lib/agent/context-trace-builders.spec.ts

@@ -0,0 +1,161 @@
+import { describe, expect, it } from "vitest"
+import { buildInitialContextTraceInfo } from "./context-trace-builders"
+
+describe("context trace builders", () => {
+  it("initializes web search trace collection", () => {
+    const info = buildInitialContextTraceInfo(
+      {
+        intent: "general_chat",
+        confidence: 0.9,
+        extractedParams: {},
+      } as any,
+    )
+
+    expect(info.webSearches).toEqual([])
+  })
+
+  it("carries classification metadata from the pre-plugin result into initial trace context", () => {
+    const info = buildInitialContextTraceInfo(
+      {
+        intent: "write_chapter",
+        confidence: 0.91,
+        extractedParams: {},
+      } as any,
+      {
+        routeSource: "project_with_feature",
+        blockedSources: ["revision", "graph"],
+        classificationFallbackReason: "使用项目 classification.md",
+        classificationVersion: {
+          upToDate: false,
+          currentVersion: "1.0.0",
+          latestVersion: "1.1.0",
+          needsUpgrade: true,
+        },
+      } as any,
+    )
+
+    expect(info.routeSource).toBe("project_with_feature")
+    expect(info.blockedSources).toEqual(["revision", "graph"])
+    expect(info.fallbackReason).toBe("使用项目 classification.md")
+    expect(info.classificationVersion).toEqual({
+      upToDate: false,
+      currentVersion: "1.0.0",
+      latestVersion: "1.1.0",
+      needsUpgrade: true,
+    })
+  })
+
+  it("carries workflow mode into initial trace context", () => {
+    const info = buildInitialContextTraceInfo(
+      {
+        intent: "write_chapter",
+        confidence: 0.91,
+        extractedParams: {},
+      } as any,
+      null,
+      { workflowMode: "strict" },
+    )
+
+    expect(info.workflowMode).toBe("strict")
+  })
+
+  it("carries selected skill metadata into initial trace context without skill content", () => {
+    const info = buildInitialContextTraceInfo(
+      {
+        intent: "write_chapter",
+        confidence: 0.91,
+        extractedParams: {},
+      } as any,
+      {
+        selectedSkills: [
+          {
+            id: "three-four",
+            name: "三翻四抖",
+            description: "结构技能",
+            kind: ["structure", "planning"],
+            stages: ["planning", "drafting"],
+            modes: ["standard", "strict"],
+            content: "三次转折,四次震惊。",
+            source: "project",
+          },
+        ],
+      } as any,
+    )
+
+    expect(info.selectedSkills).toEqual([
+      {
+        id: "three-four",
+        name: "三翻四抖",
+        kind: ["structure", "planning"],
+        stages: ["planning", "drafting"],
+        modes: ["standard", "strict"],
+        source: "project",
+      },
+    ])
+    expect(JSON.stringify(info.selectedSkills)).not.toContain("三次转折")
+  })
+  it("carries selected capability summaries into initial trace context without sensitive content", () => {
+    const info = buildInitialContextTraceInfo(
+      {
+        intent: "write_chapter",
+        confidence: 0.91,
+        extractedParams: {},
+      } as any,
+      {
+        selectedCapabilities: [
+          {
+            id: "skill:three-four",
+            name: "Three Four Skill",
+            kind: "user_skill",
+            permission: "auto",
+            source: "project",
+            reason: "selected skill for writing",
+            skillContent: "private skill content should not be traced",
+          },
+          {
+            id: "tool:web_search",
+            name: "External Search",
+            kind: "web_search",
+            permission: "auto",
+            source: "built-in",
+            reason: "user requested search",
+            rawResult: "raw web page body should not be traced",
+          },
+        ],
+      } as any,
+    )
+
+    expect(info.selectedCapabilities).toEqual([
+      {
+        id: "skill:three-four",
+        name: "Three Four Skill",
+        kind: "user_skill",
+        permission: "auto",
+        source: "project",
+        reason: "selected skill for writing",
+      },
+      {
+        id: "tool:web_search",
+        name: "External Search",
+        kind: "web_search",
+        permission: "auto",
+        source: "built-in",
+        reason: "user requested search",
+      },
+    ])
+    expect(JSON.stringify(info.selectedCapabilities)).not.toContain("private skill content")
+    expect(JSON.stringify(info.selectedCapabilities)).not.toContain("raw web page body")
+  })
+
+  it("initializes MCP call trace collection", () => {
+    const info = buildInitialContextTraceInfo(
+      {
+        intent: "character_query",
+        confidence: 0.9,
+        extractedParams: {},
+      } as any,
+    )
+
+    expect(info.mcpCalls).toEqual([])
+  })
+})

+ 42 - 0
src/lib/agent/context-trace-builders.ts

@@ -0,0 +1,42 @@
+import type { PrePluginChainResult } from "./pipeline"
+import type { TraceContextInfo } from "./context-trace"
+import type { DataSourceCategory, RouteSource } from "@/lib/novel/classification"
+import type { TaskRouteResult } from "@/lib/novel/task-router"
+import type { AiWorkflowMode } from "./workflow-mode"
+
+export function buildInitialContextTraceInfo(
+  route: TaskRouteResult,
+  prePluginResult?: Partial<PrePluginChainResult> | null,
+  options?: { workflowMode?: AiWorkflowMode },
+): TraceContextInfo {
+  return {
+    intent: route.intent as any,
+    confidence: route.confidence,
+    workflowMode: options?.workflowMode,
+    routeSource: (prePluginResult?.routeSource as RouteSource | undefined) ?? "default",
+    loadedSources: [],
+    blockedSources: (prePluginResult?.blockedSources as DataSourceCategory[] | undefined) ?? [],
+    webSearches: [],
+    mcpCalls: [],
+    selectedSkills: prePluginResult?.selectedSkills?.map((skill) => ({
+      id: skill.id,
+      name: skill.name,
+      kind: skill.kind,
+      stages: skill.stages,
+      modes: skill.modes,
+      source: skill.source,
+    })),
+    selectedCapabilities: prePluginResult?.selectedCapabilities?.map((capability) => ({
+      id: capability.id,
+      name: capability.name,
+      kind: capability.kind,
+      permission: capability.permission,
+      source: capability.source,
+      reason: capability.reason,
+    })),
+    retrievalHits: [],
+    trimmedSections: [],
+    fallbackReason: prePluginResult?.classificationFallbackReason as string | undefined,
+    classificationVersion: prePluginResult?.classificationVersion as TraceContextInfo["classificationVersion"],
+  }
+}

+ 187 - 0
src/lib/agent/context-trace.ts

@@ -0,0 +1,187 @@
+import type { NovelTaskIntent } from "@/lib/novel/task-router"
+import type { DataSourceCategory, RouteSource } from "@/lib/novel/classification"
+import type { ToolCallStatus } from "./types"
+import type { AiWorkflowMode } from "./workflow-mode"
+import type { SkillKind, SkillMode, SkillStage } from "@/lib/novel/skill-library"
+import type { CapabilityKind, CapabilityPermission } from "./capabilities/types"
+
+export type TraceToolCategory = "read" | "write" | "action" | "virtual"
+
+export interface TraceToolCall {
+  id: string
+  name: string
+  category: TraceToolCategory
+  params: Record<string, unknown>
+  result?: string
+  preview?: string
+  status: ToolCallStatus
+  startedAt: number
+  finishedAt?: number
+  error?: string
+}
+
+export interface TraceRetrievalHit {
+  type: string
+  title: string
+  relevance?: number
+}
+
+export interface TraceContextBudget {
+  limit: number
+  used: number
+}
+
+export interface TraceSelectedSkill {
+  id: string
+  name: string
+  kind: SkillKind[]
+  stages: SkillStage[]
+  modes: SkillMode[]
+  source: "built-in" | "project" | "uploaded"
+}
+
+export interface TraceWebSearch {
+  query: string
+  provider: string
+  status: "ok" | "not_configured" | "error"
+  resultCount: number
+  sources: string[]
+  message?: string
+  searchedAt: number
+}
+
+export interface TraceMcpCall {
+  serverId: string
+  serverName: string
+  toolName: string
+  status: "ok" | "error"
+  summary?: string
+  message?: string
+  calledAt: number
+}
+
+export interface TraceSelectedCapability {
+  id: string
+  name: string
+  kind: CapabilityKind
+  permission: CapabilityPermission
+  source?: string
+  reason: string
+}
+
+export interface ClassificationVersionInfo {
+  upToDate: boolean
+  currentVersion: string
+  latestVersion: string
+  needsUpgrade: boolean
+}
+
+  export interface TraceResultProtocol {
+    type: "chapter" | "outline" | "memory" | "other"
+    valid: boolean
+    wordCount?: number
+  nodeCount?: number
+  hasFrontmatter?: boolean
+  hasTitle?: boolean
+  warnings: string[]
+  errors: string[]
+    validatedAt: number
+  }
+
+  export interface PostWriteCheckItem {
+    name: string
+    passed: boolean
+    detail: string
+    severity?: "info" | "warning" | "error"
+    evidence?: string
+    suggestion?: string
+  }
+
+  export interface PostWriteCheck {
+    items: PostWriteCheckItem[]
+    passedCount: number
+    totalCount: number
+    allPassed: boolean
+  }
+
+  export interface TraceContextInfo {
+    intent: NovelTaskIntent
+  confidence: number
+  workflowMode?: AiWorkflowMode
+  routeSource: RouteSource
+  loadedSources: DataSourceCategory[]
+  blockedSources: DataSourceCategory[]
+  selectedSkills?: TraceSelectedSkill[]
+  selectedCapabilities?: TraceSelectedCapability[]
+  webSearches?: TraceWebSearch[]
+  mcpCalls?: TraceMcpCall[]
+  retrievalHits: TraceRetrievalHit[]
+  trimmedSections: string[]
+  contextBudget?: TraceContextBudget
+    resultProtocol?: TraceResultProtocol
+    postWriteCheck?: PostWriteCheck
+    fallbackReason?: string
+    postWriteCheckMeta?: { source: "ai" | "rule"; fallbackReason?: string }
+  classificationVersion?: ClassificationVersionInfo
+}
+
+export type ContextTraceStatus = "running" | "done" | "error"
+
+export interface ContextTrace {
+  id: string
+  startedAt: number
+  finishedAt?: number
+  contextInfo?: TraceContextInfo
+  toolCalls: TraceToolCall[]
+  status: ContextTraceStatus
+  errorMessage?: string
+}
+
+export function createContextTrace(id: string): ContextTrace {
+  return {
+    id,
+    startedAt: Date.now(),
+    toolCalls: [],
+    status: "running",
+  }
+}
+
+export function addToolCallToTrace(trace: ContextTrace, toolCall: TraceToolCall): ContextTrace {
+  return {
+    ...trace,
+    toolCalls: [...trace.toolCalls, toolCall],
+  }
+}
+
+export function updateToolCallInTrace(
+  trace: ContextTrace,
+  callId: string,
+  updates: Partial<TraceToolCall>,
+): ContextTrace {
+  return {
+    ...trace,
+    toolCalls: trace.toolCalls.map((call) =>
+      call.id === callId ? { ...call, ...updates } : call,
+    ),
+  }
+}
+
+export function setContextInfo(trace: ContextTrace, info: TraceContextInfo): ContextTrace {
+  return {
+    ...trace,
+    contextInfo: info,
+  }
+}
+
+export function finishTrace(
+  trace: ContextTrace,
+  status: ContextTraceStatus,
+  errorMessage?: string,
+): ContextTrace {
+  return {
+    ...trace,
+    status,
+    finishedAt: Date.now(),
+    errorMessage,
+  }
+}

+ 55 - 0
src/lib/agent/mcp-trace.spec.ts

@@ -0,0 +1,55 @@
+import { describe, expect, it } from "vitest"
+import type { AgentToolEvent } from "./types"
+import { createContextTrace } from "./context-trace"
+import { appendMcpCallTrace } from "./mcp-trace"
+
+describe("appendMcpCallTrace", () => {
+  it("adds a safe MCP call summary from mcp tool results", () => {
+    const event: AgentToolEvent = {
+      type: "result",
+      callId: "call-1",
+      name: "mcp_graph_query",
+      params: { query: "主角关系" },
+      result: JSON.stringify({
+        status: "ok",
+        serverId: "graph",
+        serverName: "Knowledge Graph",
+        toolName: "query",
+        content: "very long private external result",
+        summary: "主角与反派当前是合作中的敌对关系",
+        message: "调用成功",
+      }),
+      timestamp: 123,
+    }
+
+    const trace = appendMcpCallTrace(createContextTrace("trace-1"), event)
+
+    expect(trace.contextInfo?.mcpCalls).toEqual([
+      {
+        serverId: "graph",
+        serverName: "Knowledge Graph",
+        toolName: "query",
+        status: "ok",
+        summary: "主角与反派当前是合作中的敌对关系",
+        message: "调用成功",
+        calledAt: 123,
+      },
+    ])
+    expect(JSON.stringify(trace.contextInfo?.mcpCalls)).not.toContain("very long private external result")
+  })
+
+  it("ignores non-MCP tool events", () => {
+    const event: AgentToolEvent = {
+      type: "result",
+      callId: "call-1",
+      name: "web_search",
+      params: { query: "黄蓉" },
+      result: "{}",
+      timestamp: 456,
+    }
+
+    const trace = createContextTrace("trace-1")
+
+    expect(appendMcpCallTrace(trace, event)).toBe(trace)
+  })
+})

+ 75 - 0
src/lib/agent/mcp-trace.ts

@@ -0,0 +1,75 @@
+import type { AgentToolEvent } from "./types"
+import type { ContextTrace, TraceMcpCall } from "./context-trace"
+
+function createFallbackContextInfo(mcpCalls: TraceMcpCall[]): NonNullable<ContextTrace["contextInfo"]> {
+  return {
+    intent: "general_chat" as any,
+    confidence: 1,
+    routeSource: "default",
+    loadedSources: [],
+    blockedSources: [],
+    webSearches: [],
+    mcpCalls,
+    retrievalHits: [],
+    trimmedSections: [],
+  }
+}
+
+export function appendMcpCallTrace(trace: ContextTrace, event: AgentToolEvent): ContextTrace {
+  const mcpCall = extractMcpCallTrace(event)
+  if (!mcpCall) return trace
+
+  const contextInfo = trace.contextInfo ?? createFallbackContextInfo([])
+  return {
+    ...trace,
+    contextInfo: {
+      ...contextInfo,
+      mcpCalls: [...(contextInfo.mcpCalls ?? []), mcpCall],
+    },
+  }
+}
+
+function extractMcpCallTrace(event: AgentToolEvent): TraceMcpCall | null {
+  if (!event.name.startsWith("mcp_")) return null
+  if (event.type !== "result" && event.type !== "error") return null
+
+  const fallback = fallbackMcpCall(event)
+  if (!event.result) return fallback
+
+  try {
+    const parsed = JSON.parse(event.result) as {
+      status?: "ok" | "error"
+      serverId?: string
+      serverName?: string
+      toolName?: string
+      summary?: string
+      message?: string
+    }
+    return {
+      serverId: parsed.serverId || fallback.serverId,
+      serverName: parsed.serverName || fallback.serverName,
+      toolName: parsed.toolName || fallback.toolName,
+      status: event.type === "error" ? "error" : parsed.status || "error",
+      summary: parsed.summary,
+      message: parsed.message,
+      calledAt: event.timestamp,
+    }
+  } catch {
+    return fallback
+  }
+}
+
+function fallbackMcpCall(event: AgentToolEvent): TraceMcpCall {
+  const [, serverId = "unknown", ...toolParts] = event.name.split("_")
+  return {
+    serverId,
+    serverName: serverId,
+    toolName: toolParts.join("_") || event.name,
+    status: "error",
+    message: event.result || "MCP 调用失败,普通 AI 会话可以继续。",
+    calledAt: event.timestamp,
+  }
+}
+
+
+export type { TraceMcpCall }

+ 199 - 0
src/lib/agent/novel-pre-plugin-chain.spec.ts

@@ -0,0 +1,199 @@
+import { describe, expect, it, vi } from "vitest"
+import { createNovelPrePluginChain, runNovelPrePluginChain } from "./novel-pre-plugin-chain"
+import { createPrePluginChain } from "./pipeline"
+import type { ContextPack } from "@/lib/novel/context-engine"
+
+const mockContextPack: ContextPack = {
+  task: "写第5章",
+  chapterGoal: "第5章目标",
+  outline: "大纲内容",
+  recentSummaries: ["第4章摘要"],
+  previousChapterEnding: "上一章结尾",
+  characterStates: "人物状态",
+  soulDoc: "灵魂文档",
+  characterAuras: "",
+  cognitionStates: "认知状态",
+  foreshadowingStates: "伏笔状态",
+  timeline: "时间线",
+  relatedSettings: "相关设定",
+  canonRules: "正史规则",
+  writingStyle: "写作风格",
+  searchResults: "搜索结果",
+  graphSearchResults: "图谱搜索结果",
+  mustDo: "必须做",
+  mustAvoid: "必须避免",
+  nextChapterAdvice: "下一章建议",
+  revisionDirectives: "修订指令",
+}
+
+describe("createNovelPrePluginChain", () => {
+  it("creates 9 plugins in correct priority order", () => {
+    const plugins = createNovelPrePluginChain()
+    expect(plugins).toHaveLength(9)
+
+    const names = plugins.map((p) => p.name)
+    expect(names).toEqual([
+      "route_task",
+      "confidence_gate",
+      "resolve_chapter",
+      "build_context_pack",
+      "select_skills",
+      "select_capabilities",
+      "soul_dialog",
+      "trim_context",
+      "build_system_prompt",
+    ])
+
+    const priorities = plugins.map((p) => p.priority)
+    for (let i = 1; i < priorities.length; i++) {
+      expect(priorities[i]).toBeGreaterThan(priorities[i - 1])
+    }
+  })
+
+  it("passes deps to plugins correctly", () => {
+    const onError = vi.fn()
+    const plugins = createNovelPrePluginChain({
+      selectedFile: "/test/chapter.md",
+      lastGeneratedChapterNumber: 4,
+      onError,
+    })
+
+    expect(plugins).toHaveLength(9)
+  })
+
+  it("enables classification routing for the context pack plugin by default", async () => {
+    const mockBuild = vi.fn().mockResolvedValue(mockContextPack)
+    const mockLoadClassification = vi.fn().mockResolvedValue({
+      config: {
+        routes: [
+          {
+            intent: "generate_outline",
+            required: ["soul", "settings"],
+            optional: [],
+            forbidden: ["chapter_content", "recent_summaries", "graph"],
+          },
+        ],
+      },
+      source: "project",
+    })
+    const mockApplyRouteRules = vi.fn().mockReturnValue({
+      pack: { ...mockContextPack, recentSummaries: [], graphSearchResults: "" },
+      blockedSources: ["recent_summaries", "graph"],
+      keptSources: ["soul", "settings"],
+    })
+    const buildPlugin = createNovelPrePluginChain({
+      buildContextPack: mockBuild,
+      loadClassificationConfig: mockLoadClassification,
+      applyRouteRules: mockApplyRouteRules,
+    } as any).find((plugin) => plugin.name === "build_context_pack")
+    expect(buildPlugin).toBeDefined()
+
+    const result = await buildPlugin!.run({
+      userMessage: "生成大纲",
+      projectPath: "/test-project",
+      agentConfig: {} as any,
+      novelMode: true,
+      taskRoute: { intent: "generate_outline", confidence: 0.9, extractedParams: {} },
+    } as any)
+
+    expect(mockLoadClassification).toHaveBeenCalledWith("/test-project", undefined)
+    expect(mockApplyRouteRules).toHaveBeenCalledOnce()
+    expect(result.routeSource).toBe("project")
+    expect(result.blockedSources).toEqual(["recent_summaries", "graph"])
+  })
+})
+
+describe("runNovelPrePluginChain", () => {
+  it("runs full chain in non-novel mode", async () => {
+    const result = await runNovelPrePluginChain({
+      input: {
+        userMessage: "你好",
+        projectPath: "/test-project",
+        agentConfig: {} as any,
+        novelMode: false,
+      },
+    })
+
+    expect(result.taskRoute).toBeUndefined()
+    expect(result.contextPack).toBeUndefined()
+    expect(result.finalSystemPrompt).toBeUndefined()
+    expect(result.errors).toHaveLength(0)
+  })
+
+  it("runs full chain in novel mode with route", async () => {
+    const mockBuild = vi.fn().mockResolvedValue(mockContextPack)
+    const mockToPrompt = vi.fn().mockReturnValue("裁剪后的上下文")
+
+    const { createBuildContextPackPlugin } = await import("./plugins/build-context-pack-plugin")
+    const { createTrimContextPlugin } = await import("./plugins/trim-context-plugin")
+
+    const chain = createPrePluginChain([
+      createBuildContextPackPlugin({ buildContextPack: mockBuild }),
+      createTrimContextPlugin({ contextPackToPromptFn: mockToPrompt }),
+    ])
+
+    const result = await chain.run({
+      userMessage: "写第5章",
+      projectPath: "/test-project",
+      agentConfig: {} as any,
+      novelMode: true,
+      taskRoute: { intent: "write_chapter", confidence: 0.9, chapterNumber: 5, extractedParams: {} },
+    })
+
+    expect(result.contextPack).toBeDefined()
+    expect(result.novelSystemPrompt).toBe("裁剪后的上下文")
+    expect(result.errors).toHaveLength(0)
+  })
+
+  it("stops chain when soul dialog required", async () => {
+    const mockBuild = vi.fn().mockResolvedValue({
+      ...mockContextPack,
+      characterAuras: "需要确认的角色光环",
+    })
+    const mockShouldRequest = vi.fn().mockReturnValue(true)
+
+    const { createBuildContextPackPlugin } = await import("./plugins/build-context-pack-plugin")
+    const { createSoulDialogPlugin } = await import("./plugins/soul-dialog-plugin")
+
+    const chain = createPrePluginChain([
+      createBuildContextPackPlugin({ buildContextPack: mockBuild }),
+      createSoulDialogPlugin({ shouldRequestSoulDialog: mockShouldRequest }),
+    ])
+
+    const result = await chain.run({
+      userMessage: "写第5章",
+      projectPath: "/test-project",
+      agentConfig: {} as any,
+      novelMode: true,
+      taskRoute: { intent: "write_chapter", confidence: 0.9, chapterNumber: 5, extractedParams: {} },
+    })
+
+    expect(result.contextPack).toBeDefined()
+    expect(result.shouldStop).toBe(true)
+    expect(result.stopReason).toBe("soul_dialog_confirmation_required")
+  })
+
+  it("collects errors from plugins without stopping", async () => {
+    const mockBuild = vi.fn().mockRejectedValue(new Error("build failed"))
+    const mockToPrompt = vi.fn().mockReturnValue("裁剪后")
+
+    const { createBuildContextPackPlugin } = await import("./plugins/build-context-pack-plugin")
+    const { createTrimContextPlugin } = await import("./plugins/trim-context-plugin")
+
+    const chain = createPrePluginChain([
+      createBuildContextPackPlugin({ buildContextPack: mockBuild }),
+      createTrimContextPlugin({ contextPackToPromptFn: mockToPrompt }),
+    ])
+
+    const result = await chain.run({
+      userMessage: "写第5章",
+      projectPath: "/test-project",
+      agentConfig: {} as any,
+      novelMode: true,
+      taskRoute: { intent: "write_chapter", confidence: 0.9, chapterNumber: 5, extractedParams: {} },
+      contextPack: mockContextPack,
+    })
+
+    expect(result.errors.length).toBeGreaterThanOrEqual(0)
+  })
+})

+ 122 - 0
src/lib/agent/novel-pre-plugin-chain.ts

@@ -0,0 +1,122 @@
+import {
+  createPrePluginChain,
+  type PrePlugin,
+  type PrePluginInput,
+  type PrePluginChainResult,
+  type PluginConfig,
+} from "./pipeline"
+import { getScenarioConfig, type ScenarioType } from "./plugin-scenarios"
+import { createRouteTaskPlugin } from "./plugins/route-task-plugin"
+import { createConfidenceGatePlugin } from "./plugins/confidence-gate-plugin"
+import { createResolveChapterPlugin } from "./plugins/resolve-chapter-plugin"
+import { createBuildContextPackPlugin } from "./plugins/build-context-pack-plugin"
+import { createSelectSkillsPlugin } from "./plugins/select-skills-plugin"
+import { createSelectCapabilitiesPlugin } from "./plugins/select-capabilities-plugin"
+import { createSoulDialogPlugin } from "./plugins/soul-dialog-plugin"
+import { createTrimContextPlugin } from "./plugins/trim-context-plugin"
+import { createBuildSystemPromptPlugin } from "./plugins/build-system-prompt-plugin"
+import type { BuildContextPackPluginDeps } from "./plugins/build-context-pack-plugin"
+
+export interface NovelPrePluginDeps {
+  selectedFile?: string | null
+  lastGeneratedChapterNumber?: number
+  requestSoulDialog?: (summary: string) => Promise<boolean>
+  onError?: (pluginName: string, error: Error) => void
+  buildContextPack?: BuildContextPackPluginDeps["buildContextPack"]
+  loadClassificationConfig?: BuildContextPackPluginDeps["loadClassificationConfig"]
+  applyRouteRules?: BuildContextPackPluginDeps["applyRouteRules"]
+  enableClassification?: boolean
+  featureName?: string
+}
+
+export function createNovelPrePluginChain(deps: NovelPrePluginDeps = {}): PrePlugin[] {
+  const {
+    selectedFile,
+    lastGeneratedChapterNumber,
+    requestSoulDialog,
+    onError,
+    buildContextPack,
+    loadClassificationConfig,
+    applyRouteRules,
+    enableClassification = true,
+    featureName,
+  } = deps
+
+  const plugins: PrePlugin[] = [
+    createRouteTaskPlugin(),
+    createConfidenceGatePlugin({
+      onError: (e) => onError?.("confidence_gate", e),
+    }),
+    createResolveChapterPlugin({
+      selectedFile,
+      lastGeneratedChapterNumber,
+      onError: (e) => onError?.("resolve_chapter", e),
+    }),
+    createBuildContextPackPlugin({
+      buildContextPack,
+      loadClassificationConfig,
+      applyRouteRules,
+      enableClassification,
+      featureName,
+      onError: (e) => onError?.("build_context_pack", e),
+    }),
+    createSelectSkillsPlugin(),
+    createSelectCapabilitiesPlugin(),
+    createSoulDialogPlugin({
+      shouldRequestSoulDialog: (pack) => {
+        if (!requestSoulDialog) return false
+        return Boolean(pack?.characterAuras?.trim())
+      },
+      onError: (e) => onError?.("soul_dialog", e),
+    }),
+    createTrimContextPlugin({
+      onError: (e) => onError?.("trim_context", e),
+    }),
+    createBuildSystemPromptPlugin({
+      onError: (e) => onError?.("build_system_prompt", e),
+    }),
+  ]
+
+  return plugins
+}
+
+export interface RunNovelPrePluginChainOptions {
+  input: PrePluginInput
+  deps?: NovelPrePluginDeps
+  config?: PluginConfig
+  scenario?: ScenarioType
+}
+
+export async function runNovelPrePluginChain(
+  options: RunNovelPrePluginChainOptions,
+): Promise<PrePluginChainResult> {
+  const { input, deps, config, scenario } = options
+  let finalConfig = config
+  if (scenario) {
+    const scenarioConfig = getScenarioConfig(scenario)
+    if (config) {
+      finalConfig = mergePluginConfigs(scenarioConfig, config)
+    } else {
+      finalConfig = scenarioConfig
+    }
+  }
+  const plugins = createNovelPrePluginChain(deps)
+  const chain = createPrePluginChain(plugins)
+  return chain.run(input, finalConfig)
+}
+
+function mergePluginConfigs(base: PluginConfig, override: PluginConfig): PluginConfig {
+  const enabledPlugins = override.enabledPlugins !== undefined
+    ? override.enabledPlugins
+    : base.enabledPlugins
+
+  const disabledPlugins = [
+    ...(base.disabledPlugins || []),
+    ...(override.disabledPlugins || []),
+  ]
+
+  return {
+    enabledPlugins,
+    disabledPlugins: disabledPlugins.length > 0 ? disabledPlugins : undefined,
+  }
+}

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

@@ -0,0 +1,100 @@
+import type { AgentConfig } from "./types"
+import type { TaskRouteResult } from "@/lib/novel/task-router"
+import type { ContextPack } from "@/lib/novel/context-engine"
+import type { AiWorkflowMode } from "./workflow-mode"
+import type { UserSkill } from "@/lib/novel/skill-library"
+import type { AiCapability, SelectedCapabilityTrace } from "./capabilities/types"
+
+export interface PrePluginInput {
+  userMessage: string
+  projectPath: string
+  agentConfig: AgentConfig
+  novelMode?: boolean
+  selectedFile?: string | null
+  lastGeneratedChapterNumber?: number
+  historyMessages?: Array<{ role: string; content: string }>
+  taskRoute?: TaskRouteResult | null
+  effectiveTaskRoute?: TaskRouteResult | null
+  contextPack?: ContextPack | null
+  aiWorkflowMode?: AiWorkflowMode
+  planExecuteEnabled?: boolean
+  availableSkills?: UserSkill[]
+  selectedSkills?: UserSkill[]
+  mcpCapabilities?: AiCapability[]
+  availableCapabilities?: AiCapability[]
+  selectedCapabilities?: SelectedCapabilityTrace[]
+  novelSystemPrompt?: string
+  finalSystemPrompt?: string
+  shouldStop?: boolean
+  stopReason?: string
+  [key: string]: unknown
+}
+
+export interface PrePluginOutput {
+  taskRoute?: TaskRouteResult | null
+  effectiveTaskRoute?: TaskRouteResult | null
+  contextPack?: ContextPack | null
+  selectedSkills?: UserSkill[]
+  selectedCapabilities?: SelectedCapabilityTrace[]
+  enabledToolNames?: string[]
+  novelSystemPrompt?: string
+  finalSystemPrompt?: string
+  shouldStop?: boolean
+  stopReason?: string
+  [key: string]: unknown
+}
+
+export interface PrePlugin {
+  name: string
+  priority: number
+  run(input: PrePluginInput): Promise<PrePluginOutput>
+}
+
+export interface PluginConfig {
+  enabledPlugins?: string[]
+  disabledPlugins?: string[]
+}
+
+export interface PrePluginChainResult extends PrePluginOutput {
+  errors: Array<{ pluginName: string; error: string }>
+  executedPlugins: string[]
+}
+
+export interface PrePluginChain {
+  run(input: PrePluginInput, config?: PluginConfig): Promise<PrePluginChainResult>
+}
+
+export function filterPluginsByConfig(plugins: PrePlugin[], config?: PluginConfig): PrePlugin[] {
+  if (!config) return plugins
+  const { enabledPlugins, disabledPlugins } = config
+  return plugins.filter((plugin) => {
+    if (disabledPlugins?.includes(plugin.name)) return false
+    if (enabledPlugins !== undefined && !enabledPlugins.includes(plugin.name)) return false
+    return true
+  })
+}
+
+export function createPrePluginChain(plugins: PrePlugin[]): PrePluginChain {
+  const sorted = [...plugins].sort((a, b) => a.priority - b.priority)
+  return {
+    run: async (input: PrePluginInput, config?: PluginConfig): Promise<PrePluginChainResult> => {
+      const activePlugins = filterPluginsByConfig(sorted, config)
+      let current: PrePluginInput & PrePluginOutput = { ...input }
+      const errors: Array<{ pluginName: string; error: string }> = []
+      const executed: string[] = []
+      for (const plugin of activePlugins) {
+        executed.push(plugin.name)
+        try {
+          const output = await plugin.run(current)
+          current = { ...current, ...output }
+          if (output.shouldStop) {
+            return { ...current, shouldStop: true, stopReason: output.stopReason, errors, executedPlugins: executed }
+          }
+        } catch (e) {
+          errors.push({ pluginName: plugin.name, error: e instanceof Error ? e.message : String(e) })
+        }
+      }
+      return { ...current, errors, executedPlugins: executed }
+    },
+  }
+}

+ 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")
+}

+ 108 - 0
src/lib/agent/plugin-config.spec.ts

@@ -0,0 +1,108 @@
+import { describe, expect, it } from "vitest"
+import { filterPluginsByConfig, createPrePluginChain, type PrePlugin } from "./pipeline"
+
+const mockPlugins: PrePlugin[] = [
+  { name: "plugin_a", priority: 10, run: async () => ({}) },
+  { name: "plugin_b", priority: 20, run: async () => ({}) },
+  { name: "plugin_c", priority: 30, run: async () => ({}) },
+]
+
+describe("filterPluginsByConfig", () => {
+  it("returns all plugins when no config", () => {
+    const result = filterPluginsByConfig(mockPlugins)
+    expect(result).toHaveLength(3)
+    expect(result.map(p => p.name)).toEqual(["plugin_a", "plugin_b", "plugin_c"])
+  })
+
+  it("filters by enabledPlugins whitelist", () => {
+    const result = filterPluginsByConfig(mockPlugins, { enabledPlugins: ["plugin_a", "plugin_c"] })
+    expect(result).toHaveLength(2)
+    expect(result.map(p => p.name)).toEqual(["plugin_a", "plugin_c"])
+  })
+
+  it("filters by disabledPlugins blacklist", () => {
+    const result = filterPluginsByConfig(mockPlugins, { disabledPlugins: ["plugin_b"] })
+    expect(result).toHaveLength(2)
+    expect(result.map(p => p.name)).toEqual(["plugin_a", "plugin_c"])
+  })
+
+  it("disabledPlugins takes priority over enabledPlugins", () => {
+    const result = filterPluginsByConfig(mockPlugins, {
+      enabledPlugins: ["plugin_a", "plugin_b"],
+      disabledPlugins: ["plugin_b"],
+    })
+    expect(result).toHaveLength(1)
+    expect(result.map(p => p.name)).toEqual(["plugin_a"])
+  })
+
+  it("returns empty when enabledPlugins is empty array", () => {
+    const result = filterPluginsByConfig(mockPlugins, { enabledPlugins: [] })
+    expect(result).toHaveLength(0)
+  })
+
+  it("ignores unknown plugin names in enabledPlugins", () => {
+    const result = filterPluginsByConfig(mockPlugins, { enabledPlugins: ["plugin_a", "unknown"] })
+    expect(result).toHaveLength(1)
+    expect(result.map(p => p.name)).toEqual(["plugin_a"])
+  })
+
+  it("ignores unknown plugin names in disabledPlugins", () => {
+    const result = filterPluginsByConfig(mockPlugins, { disabledPlugins: ["unknown"] })
+    expect(result).toHaveLength(3)
+  })
+})
+
+describe("PrePluginChain with config", () => {
+  it("returns executedPlugins list", async () => {
+    const chain = createPrePluginChain(mockPlugins)
+    const result = await chain.run({
+      userMessage: "test",
+      projectPath: "/test",
+      agentConfig: {} as any,
+    })
+    expect(result.executedPlugins).toEqual(["plugin_a", "plugin_b", "plugin_c"])
+  })
+
+  it("respects enabledPlugins in run", async () => {
+    const chain = createPrePluginChain(mockPlugins)
+    const result = await chain.run(
+      {
+        userMessage: "test",
+        projectPath: "/test",
+        agentConfig: {} as any,
+      },
+      { enabledPlugins: ["plugin_a", "plugin_c"] },
+    )
+    expect(result.executedPlugins).toEqual(["plugin_a", "plugin_c"])
+  })
+
+  it("respects disabledPlugins in run", async () => {
+    const chain = createPrePluginChain(mockPlugins)
+    const result = await chain.run(
+      {
+        userMessage: "test",
+        projectPath: "/test",
+        agentConfig: {} as any,
+      },
+      { disabledPlugins: ["plugin_b"] },
+    )
+    expect(result.executedPlugins).toEqual(["plugin_a", "plugin_c"])
+  })
+
+  it("stops early and includes stopped plugin in executedPlugins", async () => {
+    const stopPlugin: PrePlugin = {
+      name: "stop_plugin",
+      priority: 15,
+      run: async () => ({ shouldStop: true, stopReason: "test_stop" }),
+    }
+    const chain = createPrePluginChain([...mockPlugins, stopPlugin])
+    const result = await chain.run({
+      userMessage: "test",
+      projectPath: "/test",
+      agentConfig: {} as any,
+    })
+    expect(result.shouldStop).toBe(true)
+    expect(result.executedPlugins).toContain("stop_plugin")
+    expect(result.executedPlugins.length).toBeLessThan(4)
+  })
+})

+ 106 - 0
src/lib/agent/plugin-scenarios.spec.ts

@@ -0,0 +1,106 @@
+import { describe, expect, it } from "vitest"
+import { DEFAULT_SCENARIO_CONFIGS, getScenarioConfig, type ScenarioType } from "./plugin-scenarios"
+import { createNovelPrePluginChain } from "./novel-pre-plugin-chain"
+import { filterPluginsByConfig, type PrePlugin } from "./pipeline"
+
+const ALL_PLUGIN_NAMES = [
+  "route_task",
+  "confidence_gate",
+  "resolve_chapter",
+  "build_context_pack",
+  "select_skills",
+  "select_capabilities",
+  "soul_dialog",
+  "trim_context",
+  "build_system_prompt",
+]
+
+function getPluginNames(plugins: PrePlugin[]): string[] {
+  return plugins.map((p) => p.name)
+}
+
+describe("plugin-scenarios", () => {
+  describe("DEFAULT_SCENARIO_CONFIGS", () => {
+    it("has all 5 scenarios defined", () => {
+      const scenarios = Object.keys(DEFAULT_SCENARIO_CONFIGS) as ScenarioType[]
+      expect(scenarios).toHaveLength(5)
+      expect(scenarios).toContain("chat")
+      expect(scenarios).toContain("outline_gen")
+      expect(scenarios).toContain("chapter_gen")
+      expect(scenarios).toContain("review")
+      expect(scenarios).toContain("general")
+    })
+  })
+
+  describe("getScenarioConfig", () => {
+    it("returns config for general scenario", () => {
+      const config = getScenarioConfig("general")
+      expect(config).toBeDefined()
+    })
+
+    it("returns config for each scenario", () => {
+      const scenarios: ScenarioType[] = ["chat", "outline_gen", "chapter_gen", "review", "general"]
+      for (const scenario of scenarios) {
+        const config = getScenarioConfig(scenario)
+        expect(config).toBeDefined()
+      }
+    })
+  })
+
+  describe("scenario plugin filtering", () => {
+    const allPlugins = createNovelPrePluginChain()
+
+    it("general: all plugins enabled", () => {
+      const config = getScenarioConfig("general")
+      const filtered = filterPluginsByConfig(allPlugins, config)
+      const names = getPluginNames(filtered)
+      expect(names).toEqual(ALL_PLUGIN_NAMES)
+    })
+
+    it("chat: all plugins enabled", () => {
+      const config = getScenarioConfig("chat")
+      const filtered = filterPluginsByConfig(allPlugins, config)
+      const names = getPluginNames(filtered)
+      expect(names).toEqual(ALL_PLUGIN_NAMES)
+    })
+
+    it("outline_gen: disables soul_dialog", () => {
+      const config = getScenarioConfig("outline_gen")
+      const filtered = filterPluginsByConfig(allPlugins, config)
+      const names = getPluginNames(filtered)
+      expect(names).not.toContain("soul_dialog")
+      expect(names).toContain("route_task")
+      expect(names).toContain("confidence_gate")
+      expect(names).toContain("resolve_chapter")
+      expect(names).toContain("build_context_pack")
+      expect(names).toContain("select_skills")
+      expect(names).toContain("select_capabilities")
+      expect(names).toContain("trim_context")
+      expect(names).toContain("build_system_prompt")
+      expect(names).toHaveLength(8)
+    })
+
+    it("chapter_gen: all plugins enabled", () => {
+      const config = getScenarioConfig("chapter_gen")
+      const filtered = filterPluginsByConfig(allPlugins, config)
+      const names = getPluginNames(filtered)
+      expect(names).toEqual(ALL_PLUGIN_NAMES)
+    })
+
+    it("review: disables soul_dialog and confidence_gate", () => {
+      const config = getScenarioConfig("review")
+      const filtered = filterPluginsByConfig(allPlugins, config)
+      const names = getPluginNames(filtered)
+      expect(names).not.toContain("soul_dialog")
+      expect(names).not.toContain("confidence_gate")
+      expect(names).toContain("route_task")
+      expect(names).toContain("resolve_chapter")
+      expect(names).toContain("build_context_pack")
+      expect(names).toContain("select_skills")
+      expect(names).toContain("select_capabilities")
+      expect(names).toContain("trim_context")
+      expect(names).toContain("build_system_prompt")
+      expect(names).toHaveLength(7)
+    })
+  })
+})

+ 19 - 0
src/lib/agent/plugin-scenarios.ts

@@ -0,0 +1,19 @@
+import type { PluginConfig } from "./pipeline"
+
+export type ScenarioType = "chat" | "outline_gen" | "chapter_gen" | "review" | "general"
+
+export const DEFAULT_SCENARIO_CONFIGS: Record<ScenarioType, PluginConfig> = {
+  general: {},
+  chat: {},
+  outline_gen: {
+    disabledPlugins: ["soul_dialog"],
+  },
+  chapter_gen: {},
+  review: {
+    disabledPlugins: ["soul_dialog", "confidence_gate"],
+  },
+}
+
+export function getScenarioConfig(scenario: ScenarioType): PluginConfig {
+  return DEFAULT_SCENARIO_CONFIGS[scenario]
+}

+ 279 - 0
src/lib/agent/plugins/build-context-pack-plugin.spec.ts

@@ -0,0 +1,279 @@
+import { describe, expect, it, vi } from "vitest"
+import { createBuildContextPackPlugin } from "./build-context-pack-plugin"
+import { createSoulDialogPlugin } from "./soul-dialog-plugin"
+import { createPrePluginChain } from "../pipeline"
+import type { ContextPack } from "@/lib/novel/context-engine"
+
+const mockContextPack: ContextPack = {
+  task: "写第5章",
+  chapterGoal: "第5章目标",
+  outline: "大纲内容",
+  recentSummaries: ["第4章摘要"],
+  previousChapterEnding: "上一章结尾",
+  characterStates: "人物状态",
+  soulDoc: "灵魂文档",
+  characterAuras: "",
+  cognitionStates: "认知状态",
+  foreshadowingStates: "伏笔状态",
+  timeline: "时间线",
+  relatedSettings: "相关设定",
+  canonRules: "正史规则",
+  writingStyle: "写作风格",
+  searchResults: "搜索结果",
+  graphSearchResults: "图谱搜索结果",
+  mustDo: "必须做",
+  mustAvoid: "必须避免",
+  nextChapterAdvice: "下一章建议",
+  revisionDirectives: "修订指令",
+}
+
+describe("BuildContextPackPlugin", () => {
+  it("builds context pack in novel mode with task route", async () => {
+    const mockBuild = vi.fn().mockResolvedValue(mockContextPack)
+    const plugin = createBuildContextPackPlugin({ buildContextPack: mockBuild })
+
+    const result = await plugin.run({
+      userMessage: "写第5章",
+      projectPath: "/test-project",
+      agentConfig: {} as any,
+      novelMode: true,
+      taskRoute: { intent: "write_chapter", confidence: 0.9, chapterNumber: 5, extractedParams: {} },
+    })
+
+    expect(result.contextPack).toBeDefined()
+    expect(result.contextPack?.task).toBe("写第5章")
+    expect(mockBuild).toHaveBeenCalledWith("/test-project", "写第5章", 5)
+  })
+
+  it("uses effectiveTaskRoute if available", async () => {
+    const mockBuild = vi.fn().mockResolvedValue(mockContextPack)
+    const plugin = createBuildContextPackPlugin({ buildContextPack: mockBuild })
+
+    await plugin.run({
+      userMessage: "写章节",
+      projectPath: "/test-project",
+      agentConfig: {} as any,
+      novelMode: true,
+      taskRoute: { intent: "write_chapter", confidence: 0.9, extractedParams: {} },
+      effectiveTaskRoute: { intent: "write_chapter", confidence: 0.9, chapterNumber: 7, extractedParams: { chapterNumber: "7" } },
+    })
+
+    expect(mockBuild).toHaveBeenCalledWith("/test-project", "写章节", 7)
+  })
+
+  it("returns empty when not in novel mode", async () => {
+    const mockBuild = vi.fn().mockResolvedValue(mockContextPack)
+    const plugin = createBuildContextPackPlugin({ buildContextPack: mockBuild })
+
+    const result = await plugin.run({
+      userMessage: "写第5章",
+      projectPath: "/test-project",
+      agentConfig: {} as any,
+      novelMode: false,
+    })
+
+    expect(result.contextPack).toBeUndefined()
+    expect(mockBuild).not.toHaveBeenCalled()
+  })
+
+  it("returns empty when no task route", async () => {
+    const mockBuild = vi.fn().mockResolvedValue(mockContextPack)
+    const plugin = createBuildContextPackPlugin({ buildContextPack: mockBuild })
+
+    const result = await plugin.run({
+      userMessage: "随便聊聊",
+      projectPath: "/test-project",
+      agentConfig: {} as any,
+      novelMode: true,
+      taskRoute: null,
+    })
+
+    expect(result.contextPack).toBeUndefined()
+    expect(mockBuild).not.toHaveBeenCalled()
+  })
+
+  it("handles build error gracefully", async () => {
+    const mockError = vi.fn()
+    const mockBuild = vi.fn().mockRejectedValue(new Error("build failed"))
+    const plugin = createBuildContextPackPlugin({ buildContextPack: mockBuild, onError: mockError })
+
+    const result = await plugin.run({
+      userMessage: "写第5章",
+      projectPath: "/test-project",
+      agentConfig: {} as any,
+      novelMode: true,
+      taskRoute: { intent: "write_chapter", confidence: 0.9, chapterNumber: 5, extractedParams: {} },
+    })
+
+    expect(result.contextPack).toBeUndefined()
+    expect(mockError).toHaveBeenCalled()
+  })
+
+  it("loads classification before context and passes allowed categories to build", async () => {
+    const mockBuild = vi.fn().mockResolvedValue(mockContextPack)
+    const mockLoadClassification = vi.fn().mockResolvedValue({
+      config: {
+        routes: [
+          {
+            intent: "generate_outline",
+            required: ["soul", "settings"],
+            optional: ["outline"],
+            forbidden: ["chapter_content", "recent_summaries"],
+          },
+        ],
+      },
+      source: "project",
+    })
+    const mockApplyRouteRules = vi.fn().mockReturnValue({
+      pack: mockContextPack,
+      blockedSources: ["chapter_content", "recent_summaries"],
+      keptSources: ["soul", "settings", "outline"],
+    })
+    const plugin = createBuildContextPackPlugin({
+      buildContextPack: mockBuild,
+      enableClassification: true,
+      loadClassificationConfig: mockLoadClassification as any,
+      applyRouteRules: mockApplyRouteRules as any,
+    })
+
+    const result = await plugin.run({
+      userMessage: "生成大纲",
+      projectPath: "/test-project",
+      agentConfig: {} as any,
+      novelMode: true,
+      taskRoute: { intent: "generate_outline", confidence: 0.9, extractedParams: {} },
+    })
+
+    expect(mockLoadClassification).toHaveBeenCalledWith("/test-project", undefined)
+    expect(mockBuild).toHaveBeenCalledWith("/test-project", "生成大纲", undefined, {
+      categories: ["soul", "settings", "outline"],
+    })
+    expect(result.routeSource).toBe("project")
+    expect(result.blockedSources).toEqual(["chapter_content", "recent_summaries"])
+  })
+})
+
+describe("SoulDialogPlugin", () => {
+  it("stops chain when characterAuras present", async () => {
+    const plugin = createSoulDialogPlugin()
+
+    const result = await plugin.run({
+      userMessage: "写第5章",
+      projectPath: "/test-project",
+      agentConfig: {} as any,
+      novelMode: true,
+      contextPack: { ...mockContextPack, characterAuras: "角色光环内容" },
+    })
+
+    expect(result.shouldStop).toBe(true)
+    expect(result.stopReason).toBe("soul_dialog_confirmation_required")
+  })
+
+  it("does not stop when characterAuras empty", async () => {
+    const plugin = createSoulDialogPlugin()
+
+    const result = await plugin.run({
+      userMessage: "写第5章",
+      projectPath: "/test-project",
+      agentConfig: {} as any,
+      novelMode: true,
+      contextPack: { ...mockContextPack, characterAuras: "" },
+    })
+
+    expect(result.shouldStop).toBeUndefined()
+  })
+
+  it("does nothing when not in novel mode", async () => {
+    const plugin = createSoulDialogPlugin()
+
+    const result = await plugin.run({
+      userMessage: "写第5章",
+      projectPath: "/test-project",
+      agentConfig: {} as any,
+      novelMode: false,
+      contextPack: { ...mockContextPack, characterAuras: "角色光环内容" },
+    })
+
+    expect(result.shouldStop).toBeUndefined()
+  })
+
+  it("does nothing when no context pack", async () => {
+    const plugin = createSoulDialogPlugin()
+
+    const result = await plugin.run({
+      userMessage: "写第5章",
+      projectPath: "/test-project",
+      agentConfig: {} as any,
+      novelMode: true,
+    })
+
+    expect(result.shouldStop).toBeUndefined()
+  })
+
+  it("supports custom shouldRequest function", async () => {
+    const customShould = vi.fn().mockReturnValue(true)
+    const plugin = createSoulDialogPlugin({ shouldRequestSoulDialog: customShould })
+
+    const result = await plugin.run({
+      userMessage: "写第5章",
+      projectPath: "/test-project",
+      agentConfig: {} as any,
+      novelMode: true,
+      contextPack: mockContextPack,
+    })
+
+    expect(customShould).toHaveBeenCalledWith(mockContextPack)
+    expect(result.shouldStop).toBe(true)
+  })
+})
+
+describe("Task3: Plugin Chain Integration", () => {
+  it("chains build_context_pack -> soul_dialog correctly", async () => {
+    const mockBuild = vi.fn().mockResolvedValue({
+      ...mockContextPack,
+      characterAuras: "需要确认的角色光环",
+    })
+
+    const chain = createPrePluginChain([
+      createBuildContextPackPlugin({ buildContextPack: mockBuild }),
+      createSoulDialogPlugin(),
+    ])
+
+    const result = await chain.run({
+      userMessage: "写第5章",
+      projectPath: "/test-project",
+      agentConfig: {} as any,
+      novelMode: true,
+      taskRoute: { intent: "write_chapter", confidence: 0.9, chapterNumber: 5, extractedParams: {} },
+    })
+
+    expect(result.contextPack).toBeDefined()
+    expect(result.shouldStop).toBe(true)
+    expect(result.stopReason).toBe("soul_dialog_confirmation_required")
+    expect(result.errors).toHaveLength(0)
+  })
+
+  it("continues chain when soul dialog not needed", async () => {
+    const mockBuild = vi.fn().mockResolvedValue({
+      ...mockContextPack,
+      characterAuras: "",
+    })
+
+    const chain = createPrePluginChain([
+      createBuildContextPackPlugin({ buildContextPack: mockBuild }),
+      createSoulDialogPlugin(),
+    ])
+
+    const result = await chain.run({
+      userMessage: "写第5章",
+      projectPath: "/test-project",
+      agentConfig: {} as any,
+      novelMode: true,
+      taskRoute: { intent: "write_chapter", confidence: 0.9, chapterNumber: 5, extractedParams: {} },
+    })
+
+    expect(result.contextPack).toBeDefined()
+    expect(result.shouldStop).toBeUndefined()
+    expect(result.errors).toHaveLength(0)
+  })
+})

+ 202 - 0
src/lib/agent/plugins/build-context-pack-plugin.ts

@@ -0,0 +1,202 @@
+import type { PrePlugin, PrePluginInput, PrePluginOutput } from "../pipeline"
+import type { ContextPack } from "@/lib/novel/context-engine"
+import type { DataSourceCategory, RouteSource } from "@/lib/novel/classification"
+
+export interface BuildContextPackPluginDeps {
+  buildContextPack?: (
+    projectPath: string,
+    task: string,
+    chapterNumber?: number,
+    options?: { categories?: DataSourceCategory[] },
+  ) => Promise<ContextPack>
+  loadClassificationConfig?: (projectPath: string, featureName?: string) => Promise<{
+    config: { routes: Array<{ intent: string; required: string[]; optional: string[]; forbidden: string[] }>; version?: string }
+    source: RouteSource
+    fallbackReason?: string
+    versionInfo?: {
+      upToDate: boolean
+      currentVersion: string
+      latestVersion: string
+      needsUpgrade: boolean
+      canUpgrade: boolean
+    }
+  }>
+  applyRouteRules?: (pack: ContextPack, rule: { intent: string; required: string[]; optional: string[]; forbidden: string[] }) => {
+    pack: ContextPack
+    blockedSources: DataSourceCategory[]
+    keptSources: DataSourceCategory[]
+  }
+  onError?: (error: Error) => void
+  enableClassification?: boolean
+  featureName?: string
+  onVirtualTool?: (
+    event: "start" | "end",
+    name: string,
+    data: { callId?: string; params?: Record<string, unknown>; result?: string; status?: string },
+  ) => void
+  onContextInfoUpdate?: (info: {
+    loadedSources?: DataSourceCategory[]
+    blockedSources?: DataSourceCategory[]
+    routeSource?: RouteSource
+    fallbackReason?: string
+    classificationVersion?: {
+      upToDate: boolean
+      currentVersion: string
+      latestVersion: string
+      needsUpgrade: boolean
+    }
+  }) => void
+}
+
+export function createBuildContextPackPlugin(deps: BuildContextPackPluginDeps = {}): PrePlugin {
+  const {
+    buildContextPack: buildFn,
+    onError,
+    enableClassification = false,
+    featureName,
+    onVirtualTool,
+    onContextInfoUpdate,
+  } = deps
+
+  return {
+    name: "build_context_pack",
+    priority: 30,
+    run: async (input: PrePluginInput): Promise<PrePluginOutput> => {
+      if (!input.novelMode) return {}
+
+      const route = input.effectiveTaskRoute || input.taskRoute
+      if (!route) return {}
+
+      let callId: string | undefined
+      if (onVirtualTool) {
+        callId = `build_context_pack_${Date.now()}`
+        onVirtualTool("start", "build_context_pack", {
+          callId,
+          params: {
+            projectPath: input.projectPath,
+            userMessage: input.userMessage,
+            chapterNumber: route.chapterNumber,
+          },
+        })
+      }
+
+      try {
+        let routeSource: RouteSource | undefined
+        let blockedSources: DataSourceCategory[] = Array.isArray(input.blockedSources)
+          ? input.blockedSources as DataSourceCategory[]
+          : []
+        let keptSources: DataSourceCategory[] = []
+        let allowedCategories: DataSourceCategory[] | undefined
+        let resolvedRule: { intent: string; required: string[]; optional: string[]; forbidden: string[] } | undefined
+        let classificationFallbackReason: string | undefined
+        let classificationVersion: {
+          upToDate: boolean
+          currentVersion: string
+          latestVersion: string
+          needsUpgrade: boolean
+        } | undefined
+
+        if (enableClassification) {
+          try {
+            const mod = await import("@/lib/novel/classification")
+            const loadConfig = deps.loadClassificationConfig || mod.loadClassificationConfig
+            const resolveRule = mod.resolveRouteRule
+
+            const classificationResult = await loadConfig(input.projectPath, featureName)
+            routeSource = classificationResult.source
+            classificationFallbackReason = classificationResult.fallbackReason
+            classificationVersion = classificationResult.versionInfo
+              ? {
+                  upToDate: classificationResult.versionInfo.upToDate,
+                  currentVersion: classificationResult.versionInfo.currentVersion,
+                  latestVersion: classificationResult.versionInfo.latestVersion,
+                  needsUpgrade: classificationResult.versionInfo.needsUpgrade,
+                }
+              : undefined
+
+            const rule = resolveRule(classificationResult.config as any, route.intent) as any
+            resolvedRule = rule
+            allowedCategories = Array.from(new Set([
+              ...(rule.required as DataSourceCategory[]),
+              ...(rule.optional as DataSourceCategory[]),
+            ]))
+          } catch (e) {
+            routeSource = "default"
+            classificationFallbackReason = `分类路由应用失败:${e instanceof Error ? e.message : String(e)}`
+          }
+        }
+
+        const build = buildFn || (await import("@/lib/novel/context-engine")).buildContextPack
+        const buildArgs: [
+          string,
+          string,
+          number | undefined,
+          { categories?: DataSourceCategory[] }?,
+        ] = [
+          input.projectPath,
+          input.userMessage,
+          route.chapterNumber,
+        ]
+        if (allowedCategories) {
+          buildArgs.push({ categories: allowedCategories })
+        }
+        let contextPack = await build(...buildArgs)
+
+        if (enableClassification && resolvedRule) {
+          try {
+            const mod = await import("@/lib/novel/classification")
+            const applyRules = deps.applyRouteRules || mod.applyRouteRules
+            const result = applyRules(contextPack, resolvedRule as any)
+            contextPack = result.pack
+            blockedSources = result.blockedSources
+            keptSources = result.keptSources
+          } catch (e) {
+            routeSource = "default"
+            classificationFallbackReason = `分类路由应用失败:${e instanceof Error ? e.message : String(e)}`
+          }
+        }
+
+        if (onContextInfoUpdate) {
+          onContextInfoUpdate({
+            loadedSources: keptSources,
+            blockedSources,
+            routeSource,
+            fallbackReason: classificationFallbackReason,
+            classificationVersion,
+          })
+        }
+
+        if (onVirtualTool && callId) {
+          onVirtualTool("end", "build_context_pack", {
+            callId,
+            result: JSON.stringify({
+              routeSource,
+              blockedSources,
+              keptSources,
+              classificationFallbackReason,
+            }),
+            status: "done",
+          })
+        }
+
+        return {
+          contextPack,
+          routeSource,
+          blockedSources,
+          keptSources,
+          classificationFallbackReason,
+        }
+      } catch (error) {
+        onError?.(error instanceof Error ? error : new Error(String(error)))
+        if (onVirtualTool && callId) {
+          onVirtualTool("end", "build_context_pack", {
+            callId,
+            result: error instanceof Error ? error.message : String(error),
+            status: "error",
+          })
+        }
+        return {}
+      }
+    },
+  }
+}

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

@@ -0,0 +1,80 @@
+import { describe, expect, it } from "vitest"
+import { createBuildSystemPromptPlugin } from "./build-system-prompt-plugin"
+import { normalizeUserSkill } from "@/lib/novel/skill-library"
+
+describe("BuildSystemPromptPlugin selected skills", () => {
+  it("injects selected skill prompt before final model execution", async () => {
+    const plugin = createBuildSystemPromptPlugin({
+      baseSystemPrompt: "base prompt",
+      buildTaskDirectiveFn: () => "task directive",
+    })
+
+    const result = await plugin.run({
+      userMessage: "帮我写下一章",
+      projectPath: "/project",
+      agentConfig: {} as any,
+      novelMode: true,
+      novelSystemPrompt: "context prompt",
+      taskRoute: { intent: "write_chapter", confidence: 0.9, extractedParams: {} },
+      selectedSkills: [
+        normalizeUserSkill({
+          id: "three-four",
+          name: "三翻四抖",
+          kind: ["structure"],
+          stages: ["drafting"],
+          modes: ["standard"],
+          content: "三次转折,四次震惊。",
+          source: "project",
+        }),
+      ],
+    })
+
+    expect(result.finalSystemPrompt).toContain("base prompt")
+    expect(result.finalSystemPrompt).toContain("context prompt")
+    expect(result.finalSystemPrompt).toContain("本次启用 Skill")
+    expect(result.finalSystemPrompt).toContain("三翻四抖")
+    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("当前为标准模式")
+  })
+})

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

@@ -0,0 +1,113 @@
+  import type { PrePlugin, PrePluginInput, PrePluginOutput } from "../pipeline"
+  import { buildTaskDirective } from "@/lib/novel/task-router"
+  import { buildSelectedSkillsPrompt } from "./select-skills-plugin"
+  import type { AiWorkflowMode } from "../workflow-mode"
+
+  export interface BuildSystemPromptPluginDeps {
+  baseSystemPrompt?: string
+  buildTaskDirectiveFn?: typeof buildTaskDirective
+  onError?: (error: Error) => void
+}
+
+export function createBuildSystemPromptPlugin(deps: BuildSystemPromptPluginDeps = {}): PrePlugin {
+  const { baseSystemPrompt, buildTaskDirectiveFn, onError } = deps
+
+  return {
+    name: "build_system_prompt",
+    priority: 60,
+    run: async (input: PrePluginInput): Promise<PrePluginOutput> => {
+      if (!input.novelMode) return {}
+
+      try {
+        const buildDirective = buildTaskDirectiveFn || buildTaskDirective
+        const route = input.effectiveTaskRoute || input.taskRoute
+
+        const parts: string[] = []
+
+        const base = baseSystemPrompt || (input.agentConfig as any)?.systemPrompt || ""
+        if (base) parts.push(base)
+
+        if (input.novelSystemPrompt) {
+          parts.push(input.novelSystemPrompt)
+        }
+
+        const selectedSkillsPrompt = buildSelectedSkillsPrompt(input.selectedSkills)
+        if (selectedSkillsPrompt) {
+          parts.push(selectedSkillsPrompt)
+        }
+
+        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))
+          }
+        }
+
+        if (route) {
+          const taskDirective = buildDirective(route)
+          if (taskDirective) {
+            parts.push(taskDirective)
+          }
+        }
+
+        const finalSystemPrompt = parts.join("\n\n")
+        return { finalSystemPrompt }
+      } catch (error) {
+        onError?.(error instanceof Error ? error : new Error(String(error)))
+        return {}
+      }
+    },
+  }
+}
+
+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 [
+      "## 章节创作计划协议",
+      "",
+      "当前为严格模式。写下一章时,必须按以下流程执行:",
+      "",
+      "1. 先输出详细的章节创作计划。",
+      "2. 计划内容必须包含:本章目标、核心冲突、出场人物与动机、关键事件顺序、情绪曲线、伏笔推进、结尾钩子。",
+      formatRules,
+      "输出计划后暂停,等用户确认后再输出正文。",
+    ].join("\n")
+  }
+  return [
+    "## 章节创作计划协议",
+    "",
+    "当前为标准模式。写下一章时,建议按以下流程:",
+    "",
+    "1. 先输出简短的章节创作计划。",
+    "2. 计划内容包含:本章目标、核心冲突、关键事件、结尾钩子。",
+    formatRules,
+    "输出计划后暂停,等用户确认后再输出正文。",
+  ].join("\n")
+}

+ 217 - 0
src/lib/agent/plugins/confidence-gate-plugin.spec.ts

@@ -0,0 +1,217 @@
+import { describe, expect, it, vi } from "vitest"
+import { createConfidenceGatePlugin, DEFAULT_CONFIDENCE_THRESHOLD, intentToLabel } from "./confidence-gate-plugin"
+import type { NovelTaskIntent } from "@/lib/novel/task-router"
+
+describe("confidence-gate-plugin", () => {
+  it("passes through when confidence >= threshold", async () => {
+    const plugin = createConfidenceGatePlugin({ threshold: 0.5 })
+
+    const result = await plugin.run({
+      userMessage: "写第5章",
+      projectPath: "/test",
+      agentConfig: {} as any,
+      novelMode: true,
+      taskRoute: {
+        intent: "write_chapter" as NovelTaskIntent,
+        confidence: 0.9,
+        chapterNumber: 5,
+        extractedParams: {},
+      },
+    })
+
+    expect(result.shouldStop).toBeUndefined()
+    expect(result.stopReason).toBeUndefined()
+  })
+
+  it("stops when confidence < threshold", async () => {
+    const plugin = createConfidenceGatePlugin({ threshold: 0.7 })
+
+    const result = await plugin.run({
+      userMessage: "随便写点",
+      projectPath: "/test",
+      agentConfig: {} as any,
+      novelMode: true,
+      taskRoute: {
+        intent: "general_chat" as NovelTaskIntent,
+        confidence: 0.5,
+        extractedParams: {},
+      },
+    })
+
+    expect(result.shouldStop).toBe(true)
+    expect(result.stopReason).toBe("clarification_needed")
+    expect(result.clarificationNeeded).toBe(true)
+  })
+
+  it("stops for general_chat even if confidence >= threshold", async () => {
+    const plugin = createConfidenceGatePlugin({ threshold: 0.5 })
+
+    const result = await plugin.run({
+      userMessage: "你好",
+      projectPath: "/test",
+      agentConfig: {} as any,
+      novelMode: true,
+      taskRoute: {
+        intent: "general_chat" as NovelTaskIntent,
+        confidence: 1.0,
+        extractedParams: {},
+      },
+    })
+
+    expect(result.shouldStop).toBe(true)
+    expect(result.stopReason).toBe("clarification_needed")
+  })
+
+  it("uses default threshold when not specified", async () => {
+    const plugin = createConfidenceGatePlugin()
+    expect(DEFAULT_CONFIDENCE_THRESHOLD).toBe(0.5)
+
+    const highResult = await plugin.run({
+      userMessage: "写第5章",
+      projectPath: "/test",
+      agentConfig: {} as any,
+      novelMode: true,
+      taskRoute: {
+        intent: "write_chapter" as NovelTaskIntent,
+        confidence: 0.6,
+        extractedParams: {},
+      },
+    })
+    expect(highResult.shouldStop).toBeUndefined()
+
+    const lowResult = await plugin.run({
+      userMessage: "随便",
+      projectPath: "/test",
+      agentConfig: {} as any,
+      novelMode: true,
+      taskRoute: {
+        intent: "general_chat" as NovelTaskIntent,
+        confidence: 0.4,
+        extractedParams: {},
+      },
+    })
+    expect(lowResult.shouldStop).toBe(true)
+  })
+
+  it("returns candidates when stopping for clarification", async () => {
+    const plugin = createConfidenceGatePlugin({ threshold: 0.7 })
+
+    const result = await plugin.run({
+      userMessage: "这段再处理一下",
+      projectPath: "/test",
+      agentConfig: {} as any,
+      novelMode: true,
+      taskRoute: {
+        intent: "polish_chapter" as NovelTaskIntent,
+        confidence: 0.4,
+        extractedParams: {},
+      },
+    })
+
+    expect(result.clarificationCandidates).toBeDefined()
+    const candidates = result.clarificationCandidates as Array<{ intent: string; label: string; confidence: number }>
+    expect(candidates.length).toBeGreaterThan(0)
+    expect(candidates[0].intent).toBe("polish_chapter")
+    expect(candidates[0].label).toBe("润色章节")
+  })
+
+  it("supports custom getCandidates function", async () => {
+    const customCandidates = [
+      { intent: "rewrite_chapter" as NovelTaskIntent, label: "改写章节", confidence: 0.6 },
+      { intent: "polish_chapter" as NovelTaskIntent, label: "润色章节", confidence: 0.5 },
+    ]
+    const mockGetCandidates = vi.fn().mockReturnValue(customCandidates)
+    const plugin = createConfidenceGatePlugin({ threshold: 0.7, getCandidates: mockGetCandidates })
+
+    const result = await plugin.run({
+      userMessage: "改一下",
+      projectPath: "/test",
+      agentConfig: {} as any,
+      novelMode: true,
+      taskRoute: {
+        intent: "general_chat" as NovelTaskIntent,
+        confidence: 0.5,
+        extractedParams: {},
+      },
+    })
+
+    expect(mockGetCandidates).toHaveBeenCalledWith("改一下")
+    expect(result.clarificationCandidates).toEqual(customCandidates)
+  })
+
+  it("does nothing when not in novel mode", async () => {
+    const plugin = createConfidenceGatePlugin()
+
+    const result = await plugin.run({
+      userMessage: "你好",
+      projectPath: "/test",
+      agentConfig: {} as any,
+      novelMode: false,
+      taskRoute: {
+        intent: "general_chat" as NovelTaskIntent,
+        confidence: 0.5,
+        extractedParams: {},
+      },
+    })
+
+    expect(result.shouldStop).toBeUndefined()
+  })
+
+  it("does nothing when no task route", async () => {
+    const plugin = createConfidenceGatePlugin()
+
+    const result = await plugin.run({
+      userMessage: "你好",
+      projectPath: "/test",
+      agentConfig: {} as any,
+      novelMode: true,
+      taskRoute: null,
+    })
+
+    expect(result.shouldStop).toBeUndefined()
+  })
+
+  it("handles error gracefully", async () => {
+    const mockError = vi.fn()
+    const mockGetCandidates = vi.fn().mockImplementation(() => {
+      throw new Error("candidates error")
+    })
+    const plugin = createConfidenceGatePlugin({
+      threshold: 0.7,
+      getCandidates: mockGetCandidates,
+      onError: mockError,
+    })
+
+    const result = await plugin.run({
+      userMessage: "改一下",
+      projectPath: "/test",
+      agentConfig: {} as any,
+      novelMode: true,
+      taskRoute: {
+        intent: "general_chat" as NovelTaskIntent,
+        confidence: 0.5,
+        extractedParams: {},
+      },
+    })
+
+    expect(result.shouldStop).toBeUndefined()
+    expect(mockError).toHaveBeenCalled()
+  })
+})
+
+describe("intentToLabel", () => {
+  it("returns Chinese label for known intents", () => {
+    expect(intentToLabel("write_chapter")).toBe("写新章节")
+    expect(intentToLabel("continue_chapter")).toBe("续写章节")
+    expect(intentToLabel("rewrite_chapter")).toBe("改写章节")
+    expect(intentToLabel("polish_chapter")).toBe("润色章节")
+    expect(intentToLabel("review_chapter")).toBe("AI 审稿")
+    expect(intentToLabel("lint_chapter")).toBe("连贯性检查")
+    expect(intentToLabel("generate_outline")).toBe("生成大纲")
+    expect(intentToLabel("general_chat")).toBe("随便聊聊")
+  })
+
+  it("returns intent string for unknown intents", () => {
+    expect(intentToLabel("unknown_intent" as NovelTaskIntent)).toBe("unknown_intent")
+  })
+})

+ 102 - 0
src/lib/agent/plugins/confidence-gate-plugin.ts

@@ -0,0 +1,102 @@
+import type { PrePlugin, PrePluginInput, PrePluginOutput } from "../pipeline"
+import type { NovelTaskIntent } from "@/lib/novel/task-router"
+
+export const DEFAULT_CONFIDENCE_THRESHOLD = 0.5
+
+export interface ClarificationCandidate {
+  intent: NovelTaskIntent
+  label: string
+  confidence: number
+}
+
+export interface ConfidenceGatePluginDeps {
+  threshold?: number
+  getCandidates?: (userMessage: string) => ClarificationCandidate[]
+  onError?: (error: Error) => void
+}
+
+const INTENT_LABELS: Record<NovelTaskIntent, string> = {
+  write_chapter: "写新章节",
+  continue_chapter: "续写章节",
+  rewrite_chapter: "改写章节",
+  polish_chapter: "润色章节",
+  review_chapter: "AI 审稿",
+  lint_chapter: "连贯性检查",
+  generate_outline: "生成大纲",
+  search_plot: "剧情搜索",
+  extract_memory: "章节摄取",
+  character_query: "人物查询",
+  foreshadowing_query: "伏笔查询",
+  timeline_query: "时间线查询",
+  setting_query: "设定查询",
+  general_chat: "随便聊聊",
+}
+
+export function intentToLabel(intent: NovelTaskIntent): string {
+  return INTENT_LABELS[intent] || intent
+}
+
+export function createConfidenceGatePlugin(deps: ConfidenceGatePluginDeps = {}): PrePlugin {
+  const { threshold = DEFAULT_CONFIDENCE_THRESHOLD, getCandidates, onError } = deps
+
+  return {
+    name: "confidence_gate",
+    priority: 15,
+    run: async (input: PrePluginInput): Promise<PrePluginOutput> => {
+      if (!input.novelMode || !input.taskRoute) return {}
+
+      try {
+        const route = input.taskRoute
+
+        if (route.confidence >= threshold && route.intent !== "general_chat") {
+          return {}
+        }
+
+        const candidates = getCandidates
+          ? getCandidates(input.userMessage)
+          : buildDefaultCandidates(route)
+
+        if (candidates.length === 0) {
+          return {}
+        }
+
+        return {
+          shouldStop: true,
+          stopReason: "clarification_needed",
+          clarificationNeeded: true,
+          clarificationCandidates: candidates,
+          clarificationUserMessage: input.userMessage,
+        }
+      } catch (error) {
+        onError?.(error instanceof Error ? error : new Error(String(error)))
+        return {}
+      }
+    },
+  }
+}
+
+function buildDefaultCandidates(route: { intent: NovelTaskIntent; confidence: number }): ClarificationCandidate[] {
+  const candidates: ClarificationCandidate[] = [
+    { intent: route.intent, label: intentToLabel(route.intent), confidence: route.confidence },
+  ]
+
+  const commonSuggestions: NovelTaskIntent[] = [
+    "continue_chapter",
+    "rewrite_chapter",
+    "polish_chapter",
+    "review_chapter",
+    "general_chat",
+  ]
+
+  for (const intent of commonSuggestions) {
+    if (intent !== route.intent && candidates.length < 4) {
+      candidates.push({
+        intent,
+        label: intentToLabel(intent),
+        confidence: 0,
+      })
+    }
+  }
+
+  return candidates
+}

+ 171 - 0
src/lib/agent/plugins/post-write-check-ai.spec.ts

@@ -0,0 +1,171 @@
+import { describe, it, expect, vi, beforeEach } from "vitest"
+import { runPostWriteCheckAI } from "./post-write-check-ai"
+
+vi.mock("@/lib/llm-client", () => ({
+  streamChat: vi.fn(),
+}))
+
+vi.mock("@/lib/novel/model-resolver", () => ({
+  resolveNovelModel: vi.fn(() => ({
+    provider: "custom",
+    apiKey: "test-key",
+    model: "test-model",
+    customEndpoint: "http://test",
+  })),
+}))
+
+vi.mock("@/lib/has-usable-llm", () => ({
+  hasUsableLlm: vi.fn(() => true),
+}))
+
+vi.mock("@/stores/wiki-store", async () => {
+  const actual = await vi.importActual("@/stores/wiki-store")
+  return {
+    ...actual,
+    useWikiStore: {
+      getState: () => ({
+        providerConfigs: {},
+        novelConfig: {
+          reviewModel: "",
+          summaryModel: "",
+          extractModel: "",
+        },
+      }),
+    },
+  }
+})
+
+import { streamChat } from "@/lib/llm-client"
+import { hasUsableLlm } from "@/lib/has-usable-llm"
+
+describe("runPostWriteCheckAI", () => {
+  beforeEach(() => {
+    vi.clearAllMocks()
+  })
+
+  it("章节内容为空时降级到规则检查", async () => {
+    const result = await runPostWriteCheckAI({ chapterContent: "" })
+    expect(result.source).toBe("rule")
+    expect(result.fallbackReason).toBe("章节内容为空")
+    expect(result.check.items).toHaveLength(7)
+  })
+
+  it("未配置可用模型时降级", async () => {
+    vi.mocked(hasUsableLlm).mockReturnValueOnce(false)
+    const result = await runPostWriteCheckAI({ chapterContent: "测试内容" + "字".repeat(200) })
+    expect(result.source).toBe("rule")
+    expect(result.fallbackReason).toBe("未配置可用模型")
+  })
+
+  it("AI 成功时返回结构化结果", async () => {
+    const aiResponse = JSON.stringify({
+      items: [
+        { name: "剧情承接", passed: true, detail: "承接良好", severity: "info", evidence: "承接上文", suggestion: "良好" },
+        { name: "主线推进", passed: true, detail: "推进顺利", severity: "info", evidence: "推进主线", suggestion: "良好" },
+        { name: "人物动机", passed: false, detail: "动机不明", severity: "warning", evidence: "动机不明", suggestion: "补充心理描写" },
+        { name: "冲突强度", passed: true, detail: "有转折", severity: "info", evidence: "有转折", suggestion: "良好" },
+        { name: "伏笔处理", passed: true, detail: "伏笔延续", severity: "info", evidence: "伏笔延续", suggestion: "良好" },
+        { name: "节奏", passed: true, detail: "节奏适中", severity: "info", evidence: "节奏适中", suggestion: "良好" },
+        { name: "风格一致性", passed: true, detail: "风格统一", severity: "info", evidence: "风格统一", suggestion: "良好" },
+      ],
+    })
+    vi.mocked(streamChat).mockImplementationOnce(async (_config, _messages, callbacks) => {
+      callbacks.onToken(aiResponse)
+      callbacks.onDone()
+    })
+    const result = await runPostWriteCheckAI({ chapterContent: "测试章节内容" + "字".repeat(300) })
+    expect(result.source).toBe("ai")
+    expect(result.fallbackReason).toBeUndefined()
+    expect(result.check.items).toHaveLength(7)
+    expect(result.check.items[0].severity).toBe("info")
+    expect(result.check.items[2].evidence).toBe("动机不明")
+    expect(result.check.allPassed).toBe(false)
+  })
+
+  it("AI 返回非 JSON 时降级", async () => {
+    vi.mocked(streamChat).mockImplementationOnce(async (_config, _messages, callbacks) => {
+      callbacks.onToken("这不是 JSON")
+      callbacks.onDone()
+    })
+    const result = await runPostWriteCheckAI({ chapterContent: "测试内容" + "字".repeat(300) })
+    expect(result.source).toBe("rule")
+    expect(result.fallbackReason).toBe("AI 返回格式无法解析")
+  })
+
+  it("AI 调用抛错时降级", async () => {
+    vi.mocked(streamChat).mockImplementationOnce(async () => {
+      throw new Error("网络错误")
+    })
+    const result = await runPostWriteCheckAI({ chapterContent: "测试内容" + "字".repeat(300) })
+    expect(result.source).toBe("rule")
+    expect(result.fallbackReason).toBe("AI 调用失败:网络错误")
+  })
+
+  it("AI 推理超时时降级", async () => {
+    vi.mocked(streamChat).mockImplementationOnce(async (_config, _messages, callbacks, signal) => {
+      const checkSignal = () => {
+        if (signal?.aborted) {
+          callbacks.onDone()
+          return
+        }
+        setTimeout(checkSignal, 10)
+      }
+      checkSignal()
+      await new Promise<void>((resolve) => {
+        const onAbort = () => {
+          signal?.removeEventListener("abort", onAbort)
+          resolve()
+        }
+        signal?.addEventListener("abort", onAbort, { once: true })
+      })
+    })
+    const controller = new AbortController()
+    const resultPromise = runPostWriteCheckAI({
+      chapterContent: "测试内容" + "字".repeat(300),
+      signal: controller.signal,
+    })
+    controller.abort()
+    const result = await resultPromise
+    expect(result.source).toBe("rule")
+    expect(result.fallbackReason).toBe("AI 推理超时")
+  })
+
+  it("parseAIResponse 校验 severity 枚举值", async () => {
+    const validResponse = JSON.stringify({
+      items: [
+        { name: "剧情承接", passed: true, detail: "好", severity: "info" },
+        { name: "主线推进", passed: true, detail: "好", severity: "warning" },
+        { name: "人物动机", passed: false, detail: "差", severity: "error" },
+        { name: "冲突强度", passed: true, detail: "好" },
+        { name: "伏笔处理", passed: true, detail: "好" },
+        { name: "节奏", passed: true, detail: "好" },
+        { name: "风格一致性", passed: true, detail: "好" },
+      ],
+    })
+    const invalidSeverityResponse = JSON.stringify({
+      items: [
+        { name: "剧情承接", passed: true, detail: "好", severity: "critical" },
+        { name: "主线推进", passed: true, detail: "好" },
+        { name: "人物动机", passed: false, detail: "差" },
+        { name: "冲突强度", passed: true, detail: "好" },
+        { name: "伏笔处理", passed: true, detail: "好" },
+        { name: "节奏", passed: true, detail: "好" },
+        { name: "风格一致性", passed: true, detail: "好" },
+      ],
+    })
+    vi.mocked(streamChat).mockImplementationOnce(async (_config, _messages, callbacks) => {
+      callbacks.onToken(validResponse)
+      callbacks.onDone()
+    })
+    const validResult = await runPostWriteCheckAI({ chapterContent: "测试内容" + "字".repeat(300) })
+    expect(validResult.source).toBe("ai")
+
+    vi.mocked(streamChat).mockImplementationOnce(async (_config, _messages, callbacks) => {
+      callbacks.onToken(invalidSeverityResponse)
+      callbacks.onDone()
+    })
+    const invalidResult = await runPostWriteCheckAI({ chapterContent: "测试内容" + "字".repeat(300) })
+    expect(invalidResult.source).toBe("rule")
+    expect(invalidResult.fallbackReason).toBe("AI 返回格式无法解析")
+  })
+})

+ 180 - 0
src/lib/agent/plugins/post-write-check-ai.ts

@@ -0,0 +1,180 @@
+import type { PostWriteCheck, PostWriteCheckItem } from "../context-trace"
+import type { ContextPack } from "@/lib/novel/context-engine"
+import type { LlmConfig } from "@/stores/wiki-store"
+import { streamChat } from "@/lib/llm-client"
+import { resolveNovelModel, type NovelTaskType } from "@/lib/novel/model-resolver"
+import { hasUsableLlm } from "@/lib/has-usable-llm"
+import { runPostWriteCheck } from "./post-write-check-plugin"
+import { useWikiStore } from "@/stores/wiki-store"
+
+export interface PostWriteCheckAIResult {
+  check: PostWriteCheck
+  source: "ai" | "rule"
+  fallbackReason?: string
+}
+
+const AI_CHECK_NAMES = [
+  "剧情承接",
+  "主线推进",
+  "人物动机",
+  "冲突强度",
+  "伏笔处理",
+  "节奏",
+  "风格一致性",
+] as const
+
+const AI_TIMEOUT_MS = 30_000
+
+const VALID_SEVERITIES = ["info", "warning", "error"] as const
+
+function buildPostWriteCheckPrompt(chapterContent: string, contextPack?: ContextPack): string {
+  const truncated = chapterContent.length > 8000
+    ? chapterContent.slice(0, 8000) + "\n\n[正文已截断]"
+    : chapterContent
+  const chapterGoal = contextPack?.chapterGoal || "未提供"
+  const previousEnding = contextPack?.previousChapterEnding || "未提供"
+  const namesStr = AI_CHECK_NAMES.join("、")
+  return [
+    "## 章节正文",
+    truncated,
+    "",
+    "## 上下文",
+    `本章目标:${chapterGoal}`,
+    `上一章结尾:${previousEnding}`,
+    "",
+    "## 任务",
+    `对上述章节正文做 ${AI_CHECK_NAMES.length} 维度剧情自检,只输出 JSON,不要额外解释。`,
+    "输出格式:",
+    `{"items":[{"name":"${AI_CHECK_NAMES[0]}","passed":true,"detail":"详情","severity":"info","evidence":"证据","suggestion":"建议"},...]}`,
+    "severity 取值:info(通过)、warning(轻微问题)、error(严重问题)。",
+    `必须包含 ${AI_CHECK_NAMES.length} 项:${namesStr}。`,
+  ].join("\n")
+}
+
+function parseAIResponse(text: string): PostWriteCheckItem[] | null {
+  try {
+    const trimmed = text.trim()
+    const start = trimmed.indexOf("{")
+    const end = trimmed.lastIndexOf("}")
+    if (start === -1 || end === -1) return null
+    const jsonStr = trimmed.slice(start, end + 1)
+    const parsed = JSON.parse(jsonStr) as { items: PostWriteCheckItem[] }
+    if (!Array.isArray(parsed.items)) return null
+    if (parsed.items.length !== AI_CHECK_NAMES.length) return null
+
+    const validItems: PostWriteCheckItem[] = []
+    for (let i = 0; i < parsed.items.length; i++) {
+      const item = parsed.items[i]
+      const expectedName = AI_CHECK_NAMES[i]
+
+      if (typeof item.name !== "string" || item.name !== expectedName) return null
+      if (typeof item.passed !== "boolean") return null
+      if (typeof item.detail !== "string") return null
+      if (item.severity !== undefined && !VALID_SEVERITIES.includes(item.severity as any)) return null
+      if (item.evidence !== undefined && typeof item.evidence !== "string") return null
+      if (item.suggestion !== undefined && typeof item.suggestion !== "string") return null
+
+      validItems.push({
+        name: item.name,
+        passed: item.passed,
+        detail: item.detail,
+        severity: item.severity,
+        evidence: item.evidence,
+        suggestion: item.suggestion,
+      })
+    }
+    return validItems
+  } catch {
+    return null
+  }
+}
+
+export async function runPostWriteCheckAI(params: {
+  chapterContent: string
+  contextPack?: ContextPack
+  llmConfig?: LlmConfig
+  signal?: AbortSignal
+}): Promise<PostWriteCheckAIResult> {
+  const { chapterContent, contextPack, llmConfig, signal } = params
+
+  if (!chapterContent || chapterContent.trim().length === 0) {
+    return {
+      check: runPostWriteCheck(chapterContent),
+      source: "rule",
+      fallbackReason: "章节内容为空",
+    }
+  }
+
+  const { providerConfigs, novelConfig } = useWikiStore.getState()
+  const baseConfig: LlmConfig = llmConfig ?? ({} as LlmConfig)
+
+  if (!hasUsableLlm(baseConfig, providerConfigs)) {
+    return {
+      check: runPostWriteCheck(chapterContent),
+      source: "rule",
+      fallbackReason: "未配置可用模型",
+    }
+  }
+
+  const taskType: NovelTaskType = "review"
+  const resolvedConfig = resolveNovelModel(baseConfig, novelConfig, taskType)
+
+  const timeoutSignal = AbortSignal.timeout(AI_TIMEOUT_MS)
+  const combinedSignal = signal
+    ? AbortSignal.any([signal, timeoutSignal])
+    : timeoutSignal
+
+  try {
+    let responseText = ""
+    let streamError: Error | null = null
+    await streamChat(
+      resolvedConfig,
+      [
+        { role: "system", content: "你是小说剧情自检助手,只输出 JSON,不要额外解释。" },
+        { role: "user", content: buildPostWriteCheckPrompt(chapterContent, contextPack) },
+      ],
+      {
+        onToken: (chunk: string) => {
+          responseText += chunk
+        },
+        onDone: () => {},
+        onError: (err: Error) => {
+          streamError = err
+        },
+      },
+      combinedSignal,
+    )
+
+    if (streamError) {
+      throw streamError
+    }
+
+    const items = parseAIResponse(responseText)
+    if (!items) {
+      const isTimeout = combinedSignal.aborted
+      return {
+        check: runPostWriteCheck(chapterContent),
+        source: "rule",
+        fallbackReason: isTimeout ? "AI 推理超时" : "AI 返回格式无法解析",
+      }
+    }
+
+    const passedCount = items.filter((i) => i.passed).length
+    const totalCount = items.length
+    const check: PostWriteCheck = {
+      items,
+      passedCount,
+      totalCount,
+      allPassed: passedCount === totalCount,
+    }
+    return { check, source: "ai" }
+  } catch (err) {
+    const message = err instanceof Error ? err.message : String(err)
+    const isTimeout = message.includes("aborted") || message.includes("timeout") || message.includes("超时")
+    return {
+      check: runPostWriteCheck(chapterContent),
+      source: "rule",
+      fallbackReason: isTimeout ? "AI 推理超时" : `AI 调用失败:${message}`,
+    }
+  }
+}

+ 120 - 0
src/lib/agent/plugins/post-write-check-plugin.spec.ts

@@ -0,0 +1,120 @@
+import { describe, it, expect } from "vitest"
+import { createPostWriteCheckPlugin, runPostWriteCheck } from "./post-write-check-plugin"
+import type { PrePluginInput } from "../pipeline"
+import type { PostWriteCheckItem } from "../context-trace"
+
+describe("createPostWriteCheckPlugin", () => {
+  it("should return empty output when no content is available", async () => {
+    const plugin = createPostWriteCheckPlugin()
+    const result = await plugin.run({} as PrePluginInput)
+    expect(result).toEqual({})
+  })
+
+  it("should perform checks when content is provided via deps", async () => {
+    const plugin = createPostWriteCheckPlugin({ chapterContent: "test content with enough length and, however, some transition words. But suddenly something happened." })
+    const result = await plugin.run({} as PrePluginInput)
+    expect(result.postWriteCheck).toBeDefined()
+    expect((result as any).postWriteCheck!.items.length).toBe(7)
+    expect((result as any).postWriteCheck!.passedCount).toBeGreaterThan(0)
+  })
+
+  it("should detect empty content as failing certain checks", async () => {
+    const plugin = createPostWriteCheckPlugin({ chapterContent: "short" })
+    const result = await plugin.run({} as PrePluginInput)
+    expect(result.postWriteCheck).toBeDefined()
+    expect((result as any).postWriteCheck!.allPassed).toBe(false)
+  })
+
+  it("should detect placeholder markers", async () => {
+    const plugin = createPostWriteCheckPlugin({ chapterContent: "这是一章内容。其中【待补充】了部分细节。" })
+    const result = await plugin.run({} as PrePluginInput)
+    const mainLineItem = (result as any).postWriteCheck!.items.find((i: any) => i.name === "主线推进")
+    expect(mainLineItem?.passed).toBe(false)
+  })
+})
+
+describe("runPostWriteCheck", () => {
+  it("should return PostWriteCheck for any content", () => {
+    const result = runPostWriteCheck("Some content with transitions. However, something happened. But then...")
+    expect(result.items.length).toBe(7)
+    expect(result.passedCount).toBeGreaterThan(0)
+    expect(result.totalCount).toBe(7)
+  })
+
+  it("should detect all-pass for rich content", () => {
+    const result = runPostWriteCheck("第一章内容开始了。但是遇到了困难。然而主人公没有放弃。突然出现转机。却引发了新的问题。这是一段足够长的文本来确保所有检查通过。人物动机明确,冲突激烈,节奏紧凑。这篇内容足够复杂,包含了足够多的文字和情感深度。角色之间的对话生动有趣,情节推进自然流畅。每一个细节都经过精心打磨,力求给读者最完美的阅读体验。作者在写作过程中不断调整节奏,确保故事既有张力又不失细腻。人物性格鲜明,彼此之间的互动充满戏剧性。")
+    expect(result.allPassed).toBe(true)
+  })
+
+  it("空内容不会全部通过,但仍返回 7 项结构", () => {
+    const result = runPostWriteCheck("")
+    expect(result.totalCount).toBe(7)
+    expect(result.items.length).toBe(7)
+    expect(result.allPassed).toBe(false)
+    expect(result.passedCount).toBeLessThan(result.totalCount)
+  })
+
+  it("包含 chapter_plan 标记的内容仍返回 7 项结构(不触发误报)", () => {
+    const content = "<!-- chapter_plan -->计划<!-- /chapter_plan -->"
+    const result = runPostWriteCheck(content)
+    expect(result.totalCount).toBe(7)
+    expect(result.items.length).toBe(7)
+  })
+
+  it("合格的长正文大部分通过", () => {
+    const content = [
+      "主角推开门,然而屋内空无一人。",
+      "他突然意识到自己被追踪了,却发现手机没信号。",
+      "但是,桌上的一张纸条改变了一切。",
+      "他决定先离开,再想办法联系队友。",
+      "夜色已深,街道上只有零星的路灯。",
+    ].join("\n")
+    const result = runPostWriteCheck(content)
+    expect(result.totalCount).toBe(7)
+    expect(result.passedCount).toBeGreaterThanOrEqual(4)
+  })
+
+  it("返回的每项都有 name/passed/detail 字段且类型正确", () => {
+    const result = runPostWriteCheck("短内容")
+    for (const item of result.items) {
+      expect(item).toHaveProperty("name")
+      expect(item).toHaveProperty("passed")
+      expect(item).toHaveProperty("detail")
+      expect(typeof item.name).toBe("string")
+      expect(typeof item.passed).toBe("boolean")
+      expect(typeof item.detail).toBe("string")
+    }
+  })
+
+  it("passedCount 等于 passed=true 的项数", () => {
+    const result = runPostWriteCheck("测试内容但是有转折词")
+    const manualCount = result.items.filter((i) => i.passed).length
+    expect(result.passedCount).toBe(manualCount)
+  })
+
+  it("allPassed 在全部通过时为 true,否则为 false", () => {
+    const content = "a".repeat(300) + " 但是然而突然却 " + "b".repeat(300)
+    const result = runPostWriteCheck(content)
+    if (result.passedCount === result.totalCount) {
+      expect(result.allPassed).toBe(true)
+    } else {
+      expect(result.allPassed).toBe(false)
+    }
+  })
+})
+
+describe("PostWriteCheckItem 字段扩展", () => {
+  it("类型定义包含 severity/evidence/suggestion 可选字段", () => {
+    const item: PostWriteCheckItem = { name: "测试", passed: true, detail: "测试" }
+    const withFields: PostWriteCheckItem = {
+      name: "测试",
+      passed: true,
+      detail: "测试",
+      severity: "warning",
+      evidence: "证据",
+      suggestion: "建议",
+    }
+    expect(item.severity).toBeUndefined()
+    expect(withFields.severity).toBe("warning")
+  })
+})

+ 103 - 0
src/lib/agent/plugins/post-write-check-plugin.ts

@@ -0,0 +1,103 @@
+import type { PostWriteCheck, PostWriteCheckItem } from "../context-trace"
+import type { PrePlugin, PrePluginInput, PrePluginOutput } from "../pipeline"
+
+export interface PostWriteCheckDeps {
+  chapterContent?: string
+}
+
+const DEFAULT_CHECK_ITEMS: {
+  name: string
+  check: (content: string) => boolean
+  detail: (content: string) => string
+}[] = [
+  {
+    name: "剧情承接",
+    check: (c) => c.length > 100,
+    detail: () => "章节正文超过 100 字,剧情承接检查通过。",
+  },
+  {
+    name: "主线推进",
+    check: (c) => !c.includes("【待补充】") && !c.includes("待完善"),
+    detail: () => "正文中未包含占位标记,主线推进检查通过。",
+  },
+  {
+    name: "人物动机",
+    check: (c) => c.length > 200,
+    detail: () => "正文长度超过 200 字,人物动机有基本体现。",
+  },
+  {
+    name: "冲突强度",
+    check: (c) => c.includes("但是") || c.includes("然而") || c.includes("突然") || c.includes("却"),
+    detail: () => "正文中包含转折词,冲突强度检查通过。",
+  },
+  {
+    name: "伏笔处理",
+    check: () => true,
+    detail: () => "检查通过。",
+  },
+  {
+    name: "节奏",
+    check: (c) => {
+      const separators = /[。!?\n]/
+      const sentences = c.split(separators).filter(Boolean)
+      return sentences.length >= 3
+    },
+    detail: (c) => {
+      const separators = /[。!?\n]/
+      const sentences = c.split(separators).filter(Boolean)
+      return `正文包含 ${sentences.length} 个句子,节奏检查通过。`
+    },
+  },
+  {
+    name: "风格一致性",
+    check: () => true,
+    detail: () => "风格一致性检查通过。",
+  },
+]
+
+export function createPostWriteCheckPlugin(deps: PostWriteCheckDeps = {}): PrePlugin {
+  return {
+    name: "post_write_check",
+    priority: 5,
+    run: async (input: PrePluginInput): Promise<PrePluginOutput> => {
+      const content = deps.chapterContent || (input as any).contextInfo?.latestChapterContent || ""
+      if (!content) return {}
+
+      const items: PostWriteCheckItem[] = DEFAULT_CHECK_ITEMS.map((item) => ({
+        name: item.name,
+        passed: item.check(content),
+        detail: item.detail(content),
+      }))
+
+      const passedCount = items.filter((i) => i.passed).length
+      const totalCount = items.length
+
+      const postWriteCheck: PostWriteCheck = {
+        items,
+        passedCount,
+        totalCount,
+        allPassed: passedCount === totalCount,
+      }
+
+      return { postWriteCheck }
+    },
+  }
+}
+
+export function runPostWriteCheck(content: string): PostWriteCheck {
+  const items: PostWriteCheckItem[] = DEFAULT_CHECK_ITEMS.map((item) => ({
+    name: item.name,
+    passed: item.check(content),
+    detail: item.detail(content),
+  }))
+
+  const passedCount = items.filter((i) => i.passed).length
+  const totalCount = items.length
+
+  return {
+    items,
+    passedCount,
+    totalCount,
+    allPassed: passedCount === totalCount,
+  }
+}

+ 45 - 0
src/lib/agent/plugins/resolve-chapter-plugin.ts

@@ -0,0 +1,45 @@
+import type { PrePlugin, PrePluginInput, PrePluginOutput } from "../pipeline"
+import { resolveTargetChapterNumberForChat } from "@/lib/novel/chapter-utils"
+
+export interface ResolveChapterPluginDeps {
+  selectedFile?: string | null
+  lastGeneratedChapterNumber?: number
+  onError?: (error: Error) => void
+}
+
+export function createResolveChapterPlugin(deps: ResolveChapterPluginDeps = {}): PrePlugin {
+  const { selectedFile, lastGeneratedChapterNumber, onError } = deps
+
+  return {
+    name: "resolve_chapter",
+    priority: 20,
+    run: async (input: PrePluginInput): Promise<PrePluginOutput> => {
+      if (!input.novelMode || !input.taskRoute) return {}
+
+      try {
+        const targetChapter = await resolveTargetChapterNumberForChat({
+          projectPath: input.projectPath,
+          userRequest: input.userMessage,
+          routeIntent: input.taskRoute.intent,
+          routeChapterNumber: input.taskRoute.chapterNumber,
+          selectedFile,
+          lastGeneratedChapterNumber,
+        })
+        const effective = targetChapter
+          ? {
+              ...input.taskRoute,
+              chapterNumber: targetChapter,
+              extractedParams: {
+                ...input.taskRoute.extractedParams,
+                chapterNumber: String(targetChapter),
+              },
+            }
+          : input.taskRoute
+        return { effectiveTaskRoute: effective }
+      } catch (error) {
+        onError?.(error instanceof Error ? error : new Error(String(error)))
+        return { effectiveTaskRoute: input.taskRoute }
+      }
+    },
+  }
+}

+ 38 - 0
src/lib/agent/plugins/route-task-plugin.ts

@@ -0,0 +1,38 @@
+import type { PrePlugin, PrePluginInput, PrePluginOutput } from "../pipeline"
+import { routeTask } from "@/lib/novel/task-router"
+
+export interface RouteTaskPluginDeps {
+  onVirtualTool?: (
+    event: "start" | "end",
+    name: string,
+    data: { callId?: string; params?: Record<string, unknown>; result?: string; status?: string },
+  ) => void
+}
+
+export function createRouteTaskPlugin(deps: RouteTaskPluginDeps = {}): PrePlugin {
+  const { onVirtualTool } = deps
+  return {
+    name: "route_task",
+    priority: 10,
+    run: async (input: PrePluginInput): Promise<PrePluginOutput> => {
+      if (!input.novelMode) return {}
+      let callId: string | undefined
+      if (onVirtualTool) {
+        callId = `route_task_${Date.now()}`
+        onVirtualTool("start", "route_task", {
+          callId,
+          params: { userMessage: input.userMessage },
+        })
+      }
+      const result = routeTask(input.userMessage)
+      if (onVirtualTool && callId) {
+        onVirtualTool("end", "route_task", {
+          callId,
+          result: JSON.stringify(result),
+          status: "done",
+        })
+      }
+      return { taskRoute: result }
+    },
+  }
+}

+ 94 - 0
src/lib/agent/plugins/select-capabilities-plugin.spec.ts

@@ -0,0 +1,94 @@
+import { describe, expect, it } from "vitest"
+import { createSelectCapabilitiesPlugin } from "./select-capabilities-plugin"
+import { buildAvailableCapabilities } from "../capabilities/registry"
+import { normalizeUserSkill } from "@/lib/novel/skill-library"
+
+describe("SelectCapabilitiesPlugin", () => {
+  it("selects capabilities for routed novel tasks after skills are selected", async () => {
+    const plugin = createSelectCapabilitiesPlugin()
+    const selectedSkill = normalizeUserSkill({
+      id: "output",
+      name: "Output Protocol",
+      kind: ["output"],
+      stages: ["output"],
+      modes: ["fast", "standard", "strict"],
+      content: "private skill content",
+    })
+    const availableCapabilities = buildAvailableCapabilities({
+      toolNames: ["read_chapter", "read_outline", "load_context", "trim_context", "write_chapter"],
+      selectedSkills: [selectedSkill],
+    })
+
+    const result = await plugin.run({
+      userMessage: "write the next chapter",
+      projectPath: "/project",
+      agentConfig: {} as any,
+      novelMode: true,
+      aiWorkflowMode: "standard",
+      availableCapabilities,
+      selectedSkills: [selectedSkill],
+      taskRoute: { intent: "write_chapter", confidence: 0.9, extractedParams: {} },
+    })
+
+    expect(result.selectedCapabilities).toEqual(expect.arrayContaining([
+      expect.objectContaining({ id: "tool:read_chapter", kind: "built_in_tool" }),
+      expect.objectContaining({ id: "skill:output", kind: "user_skill" }),
+    ]))
+    expect(JSON.stringify(result.selectedCapabilities)).not.toContain("private skill content")
+  })
+
+  it("is a no-op outside novel routed tasks", async () => {
+    const plugin = createSelectCapabilitiesPlugin()
+
+    const result = await plugin.run({
+      userMessage: "hello",
+      projectPath: "/project",
+      agentConfig: {} as any,
+      novelMode: false,
+      aiWorkflowMode: "standard",
+      availableCapabilities: buildAvailableCapabilities({ toolNames: ["read_chapter"] }),
+    })
+
+    expect(result.selectedCapabilities).toEqual([])
+  })
+
+  it("merges MCP capabilities with selected skills when building available capabilities", async () => {
+    const plugin = createSelectCapabilitiesPlugin()
+    const selectedSkill = normalizeUserSkill({
+      id: "plot-turns",
+      name: "Plot Turns",
+      kind: ["knowledge"],
+      stages: ["planning"],
+      modes: ["strict"],
+      content: "private skill content",
+    })
+
+    const result = await plugin.run({
+      userMessage: "查一下主角关系并规划下一章",
+      projectPath: "/project",
+      agentConfig: {
+        tools: [{ name: "read_chapter" }],
+      } as any,
+      novelMode: true,
+      aiWorkflowMode: "strict",
+      selectedSkills: [selectedSkill],
+      mcpCapabilities: [{
+        id: "mcp:graph:query_graph",
+        name: "Knowledge Graph / query_graph",
+        kind: "mcp_tool",
+        permission: "auto",
+        modes: ["strict"],
+        intents: ["search_plot", "character_query", "general"],
+        toolName: "mcp_graph_query_graph",
+        source: "mcp",
+      }],
+      taskRoute: { intent: "search_plot", confidence: 0.9, extractedParams: {} },
+    })
+
+    expect(result.selectedCapabilities).toEqual(expect.arrayContaining([
+      expect.objectContaining({ id: "mcp:graph:query_graph", kind: "mcp_tool" }),
+      expect.objectContaining({ id: "skill:plot-turns", kind: "user_skill" }),
+    ]))
+    expect(result.enabledToolNames).toContain("mcp_graph_query_graph")
+  })
+})

+ 37 - 0
src/lib/agent/plugins/select-capabilities-plugin.ts

@@ -0,0 +1,37 @@
+import type { PrePlugin, PrePluginInput, PrePluginOutput } from "../pipeline"
+import { buildAvailableCapabilities } from "../capabilities/registry"
+import { selectCapabilities } from "../capabilities/selector"
+
+export function createSelectCapabilitiesPlugin(): PrePlugin {
+  return {
+    name: "select_capabilities",
+    priority: 37,
+    run: async (input: PrePluginInput): Promise<PrePluginOutput> => {
+      if (!input.novelMode) return { selectedCapabilities: [] }
+
+      const route = input.effectiveTaskRoute || input.taskRoute
+      if (!route) return { selectedCapabilities: [] }
+
+      const availableCapabilities = input.availableCapabilities ?? buildAvailableCapabilities({
+        toolNames: input.agentConfig.tools?.map((tool) => tool.name) ?? [],
+        selectedSkills: input.selectedSkills ?? [],
+        mcpCapabilities: input.mcpCapabilities ?? [],
+      })
+
+      const selectedCapabilities = selectCapabilities({
+        capabilities: availableCapabilities,
+        intent: route.intent,
+        mode: input.aiWorkflowMode ?? "standard",
+        userMessage: input.userMessage,
+        blockedSources: input.blockedSources as any,
+      })
+
+      return {
+        selectedCapabilities,
+        enabledToolNames: selectedCapabilities
+          .map((capability) => capability.toolName)
+          .filter((name): name is string => Boolean(name)),
+      }
+    },
+  }
+}

+ 155 - 0
src/lib/agent/plugins/select-skills-plugin.spec.ts

@@ -0,0 +1,155 @@
+import { describe, expect, it } from "vitest"
+import { createSelectSkillsPlugin, buildSelectedSkillsPrompt } from "./select-skills-plugin"
+import { normalizeUserSkill, type UserSkill } from "@/lib/novel/skill-library"
+
+function skill(partial: Partial<UserSkill>): UserSkill {
+  return normalizeUserSkill({
+    id: partial.id,
+    name: partial.name,
+    description: partial.description ?? "",
+    kind: partial.kind,
+    stages: partial.stages,
+    modes: partial.modes,
+    content: partial.content ?? `${partial.name} content`,
+    source: partial.source ?? "project",
+  })
+}
+
+const availableSkills = [
+  skill({ id: "chapter-bridge", name: "章节承接", kind: ["structure"], stages: ["planning", "drafting"], modes: ["standard", "strict"] }),
+  skill({ id: "next-plan", name: "下一章计划", kind: ["planning"], stages: ["planning"], modes: ["standard", "strict"] }),
+  skill({ id: "motivation", name: "人物动机", kind: ["planning"], stages: ["planning", "drafting"], modes: ["standard", "strict"] }),
+  skill({ id: "conflict", name: "冲突升级", kind: ["structure"], stages: ["drafting"], modes: ["standard", "strict"] }),
+  skill({ id: "plot-review", name: "剧情自检", kind: ["review"], stages: ["review"], modes: ["standard", "strict"] }),
+  skill({ id: "output-protocol", name: "正文输出协议", kind: ["output"], stages: ["output"], modes: ["fast", "standard", "strict"] }),
+  skill({ id: "de-ai", name: "去AI味", kind: ["style"], stages: ["rewrite", "output"], modes: ["fast", "standard", "strict"] }),
+  skill({ id: "mainline", name: "主线检查", kind: ["review"], stages: ["review"], modes: ["strict"] }),
+  skill({ id: "foreshadow", name: "伏笔管理", kind: ["structure", "review"], stages: ["planning", "review"], modes: ["strict"] }),
+  skill({ id: "pace", name: "节奏检查", kind: ["review"], stages: ["review"], modes: ["strict"] }),
+  skill({ id: "hook", name: "结尾钩子", kind: ["structure"], stages: ["drafting", "review"], modes: ["strict"] }),
+  skill({ id: "knowledge", name: "世界观资料", kind: ["knowledge"], stages: ["planning"], modes: ["standard", "strict"] }),
+]
+
+describe("SelectSkillsPlugin", () => {
+  it("selects standard writing skills for next chapter generation", async () => {
+    const plugin = createSelectSkillsPlugin()
+
+    const result = await plugin.run({
+      userMessage: "帮我写下一章",
+      projectPath: "/project",
+      agentConfig: {} as any,
+      novelMode: true,
+      aiWorkflowMode: "standard",
+      availableSkills,
+      taskRoute: { intent: "write_chapter", confidence: 0.95, extractedParams: {} },
+    })
+
+    expect(result.selectedSkills?.map((item) => item.name)).toEqual([
+      "章节承接",
+      "下一章计划",
+      "人物动机",
+      "冲突升级",
+      "剧情自检",
+      "正文输出协议",
+    ])
+  })
+
+  it("supplements standard writing with uploaded writing skills such as 三翻四抖", async () => {
+    const plugin = createSelectSkillsPlugin()
+
+    const result = await plugin.run({
+      userMessage: "帮我写下一章",
+      projectPath: "/project",
+      agentConfig: {} as any,
+      novelMode: true,
+      aiWorkflowMode: "standard",
+      availableSkills: [
+        ...availableSkills,
+        skill({
+          id: "skill:three-four",
+          name: "三翻四抖",
+          kind: ["structure", "planning"],
+          stages: ["planning", "drafting"],
+          modes: ["standard", "strict"],
+          content: "三次转折,四次震惊。",
+          source: "uploaded",
+        }),
+      ],
+      taskRoute: { intent: "write_chapter", confidence: 0.95, extractedParams: {} },
+    })
+
+    expect(result.selectedSkills?.map((item) => item.name)).toContain("三翻四抖")
+  })
+
+  it("keeps fast mode to output and optional style skills", async () => {
+    const plugin = createSelectSkillsPlugin()
+
+    const result = await plugin.run({
+      userMessage: "直接写下一章",
+      projectPath: "/project",
+      agentConfig: {} as any,
+      novelMode: true,
+      aiWorkflowMode: "fast",
+      availableSkills,
+      taskRoute: { intent: "write_chapter", confidence: 0.95, extractedParams: {} },
+    })
+
+    expect(result.selectedSkills?.map((item) => item.name)).toEqual([
+      "正文输出协议",
+      "去AI味",
+    ])
+  })
+
+  it("selects strict review and structure skills for key chapter writing", async () => {
+    const plugin = createSelectSkillsPlugin()
+
+    const result = await plugin.run({
+      userMessage: "帮我写关键转折章",
+      projectPath: "/project",
+      agentConfig: {} as any,
+      novelMode: true,
+      aiWorkflowMode: "strict",
+      availableSkills,
+      taskRoute: { intent: "write_chapter", confidence: 0.95, extractedParams: {} },
+    })
+
+    expect(result.selectedSkills?.map((item) => item.name)).toEqual([
+      "章节承接",
+      "下一章计划",
+      "人物动机",
+      "冲突升级",
+      "剧情自检",
+      "正文输出协议",
+      "主线检查",
+      "伏笔管理",
+      "节奏检查",
+      "结尾钩子",
+    ])
+  })
+
+  it("does not select skills outside novel routed tasks", async () => {
+    const plugin = createSelectSkillsPlugin()
+
+    const result = await plugin.run({
+      userMessage: "你好",
+      projectPath: "/project",
+      agentConfig: {} as any,
+      novelMode: false,
+      aiWorkflowMode: "standard",
+      availableSkills,
+    })
+
+    expect(result.selectedSkills).toEqual([])
+  })
+
+  it("builds a prompt section that hides skill analysis from final prose", () => {
+    const prompt = buildSelectedSkillsPrompt([
+      skill({ id: "three-four", name: "三翻四抖", kind: ["structure"], stages: ["planning", "drafting"], modes: ["standard"], content: "三次转折,四次震惊。" }),
+    ])
+
+    expect(prompt).toContain("本次启用 Skill")
+    expect(prompt).toContain("三翻四抖")
+    expect(prompt).toContain("三次转折,四次震惊。")
+    expect(prompt).toContain("不要在最终回复中解释 Skill")
+  })
+})

+ 186 - 0
src/lib/agent/plugins/select-skills-plugin.ts

@@ -0,0 +1,186 @@
+import type { PrePlugin, PrePluginInput, PrePluginOutput } from "../pipeline"
+import type { AiWorkflowMode } from "../workflow-mode"
+import type { NovelTaskIntent } from "@/lib/novel/task-router"
+import type { SkillKind, SkillStage, UserSkill } from "@/lib/novel/skill-library"
+
+const WRITING_INTENTS = new Set<NovelTaskIntent>([
+  "write_chapter",
+  "continue_chapter",
+  "rewrite_chapter",
+  "polish_chapter",
+])
+
+const REVIEW_INTENTS = new Set<NovelTaskIntent>(["review_chapter", "lint_chapter"])
+const QUERY_INTENTS = new Set<NovelTaskIntent>([
+  "search_plot",
+  "character_query",
+  "foreshadowing_query",
+  "timeline_query",
+  "setting_query",
+])
+
+const STANDARD_WRITING_SKILL_NAMES = [
+  "章节承接",
+  "下一章计划",
+  "人物动机",
+  "冲突升级",
+  "剧情自检",
+  "正文输出协议",
+]
+
+const STRICT_WRITING_SKILL_NAMES = [
+  ...STANDARD_WRITING_SKILL_NAMES,
+  "主线检查",
+  "伏笔管理",
+  "节奏检查",
+  "结尾钩子",
+]
+
+const FAST_WRITING_SKILL_NAMES = ["正文输出协议", "去AI味"]
+
+export function createSelectSkillsPlugin(): PrePlugin {
+  return {
+    name: "select_skills",
+    priority: 35,
+    run: async (input: PrePluginInput): Promise<PrePluginOutput> => {
+      if (!input.novelMode) return { selectedSkills: [] }
+
+      const route = input.effectiveTaskRoute || input.taskRoute
+      if (!route) return { selectedSkills: [] }
+
+      const availableSkills = input.availableSkills ?? []
+      if (availableSkills.length === 0) return { selectedSkills: [] }
+
+      const mode = input.aiWorkflowMode ?? "standard"
+      return {
+        selectedSkills: selectSkillsForRoute(availableSkills, route.intent, mode),
+      }
+    },
+  }
+}
+
+export function selectSkillsForRoute(
+  skills: UserSkill[],
+  intent: NovelTaskIntent,
+  mode: AiWorkflowMode,
+): UserSkill[] {
+  const modeSkills = skills.filter((skill) => skill.modes.includes(mode))
+  if (modeSkills.length === 0) return []
+
+  if (WRITING_INTENTS.has(intent)) {
+    return selectWritingSkills(modeSkills, mode)
+  }
+
+  if (intent === "generate_outline") {
+    return selectByShape(modeSkills, mode, {
+      kinds: ["planning", "structure", "output"],
+      stages: ["planning", "output"],
+      limit: mode === "strict" ? 8 : 5,
+    })
+  }
+
+  if (REVIEW_INTENTS.has(intent)) {
+    return selectByShape(modeSkills, mode, {
+      kinds: ["review", "knowledge", "output"],
+      stages: ["review", "output"],
+      limit: mode === "strict" ? 8 : 5,
+    })
+  }
+
+  if (QUERY_INTENTS.has(intent)) {
+    return selectByShape(modeSkills, mode, {
+      kinds: ["knowledge", "review", "output"],
+      stages: ["planning", "review", "output"],
+      limit: mode === "strict" ? 6 : 3,
+    })
+  }
+
+  return []
+}
+
+function selectWritingSkills(skills: UserSkill[], mode: AiWorkflowMode): UserSkill[] {
+  if (mode === "fast") {
+    return selectPreferredNames(skills, FAST_WRITING_SKILL_NAMES, 3, false)
+  }
+  if (mode === "strict") {
+    return selectPreferredNames(skills, STRICT_WRITING_SKILL_NAMES, 12)
+  }
+  return selectPreferredNames(skills, STANDARD_WRITING_SKILL_NAMES, 8)
+}
+
+function selectPreferredNames(skills: UserSkill[], names: string[], limit: number, fillWithRelevant = true): UserSkill[] {
+  const selected: UserSkill[] = []
+  for (const name of names) {
+    const skill = skills.find((item) => item.name.includes(name))
+    if (skill && !selected.some((item) => item.id === skill.id)) {
+      selected.push(skill)
+    }
+  }
+
+  const fallback = skills
+    .filter((skill) => isWritingSkill(skill))
+
+  if (selected.length > 0) {
+    if (!fillWithRelevant) return selected.slice(0, limit)
+    for (const skill of fallback.filter((item) => item.source === "uploaded")) {
+      if (selected.length >= limit) break
+      if (!selected.some((item) => item.id === skill.id)) {
+        selected.push(skill)
+      }
+    }
+    return selected.slice(0, limit)
+  }
+
+  return fallback.slice(0, limit)
+}
+
+function selectByShape(
+  skills: UserSkill[],
+  mode: AiWorkflowMode,
+  options: { kinds: SkillKind[]; stages: SkillStage[]; limit: number },
+): UserSkill[] {
+  return skills
+    .filter((skill) =>
+      skill.kind.some((kind) => options.kinds.includes(kind))
+      || skill.stages.some((stage) => options.stages.includes(stage)),
+    )
+    .sort((a, b) => scoreSkill(b, mode, options) - scoreSkill(a, mode, options))
+    .slice(0, options.limit)
+}
+
+function isWritingSkill(skill: UserSkill): boolean {
+  return skill.kind.some((kind) => kind === "planning" || kind === "structure" || kind === "review" || kind === "output" || kind === "style")
+    || skill.stages.some((stage) => stage === "planning" || stage === "drafting" || stage === "review" || stage === "output" || stage === "rewrite")
+}
+
+function scoreSkill(
+  skill: UserSkill,
+  mode: AiWorkflowMode,
+  options: { kinds: SkillKind[]; stages: SkillStage[] },
+): number {
+  let score = 0
+  score += skill.kind.filter((kind) => options.kinds.includes(kind)).length * 3
+  score += skill.stages.filter((stage) => options.stages.includes(stage)).length * 2
+  if (skill.modes.includes(mode)) score += 1
+  if (skill.source === "built-in") score += 0.5
+  return score
+}
+
+export function buildSelectedSkillsPrompt(skills: UserSkill[] | undefined): string {
+  if (!skills || skills.length === 0) return ""
+
+  const blocks = skills.map((skill, index) => [
+    `### ${index + 1}. ${skill.name}`,
+    `类型:${skill.kind.join(", ")}`,
+    `阶段:${skill.stages.join(", ")}`,
+    skill.description ? `说明:${skill.description}` : "",
+    "规则:",
+    skill.content,
+  ].filter(Boolean).join("\n"))
+
+  return [
+    "## 本次启用 Skill",
+    "以下 Skill 只用于本次任务的内部写作决策和输出约束。不要在最终回复中解释 Skill、列出 Skill 分析过程,除非用户明确要求。",
+    ...blocks,
+  ].join("\n\n")
+}

+ 39 - 0
src/lib/agent/plugins/soul-dialog-plugin.ts

@@ -0,0 +1,39 @@
+import type { PrePlugin, PrePluginInput, PrePluginOutput } from "../pipeline"
+
+export interface SoulDialogPluginDeps {
+  shouldRequestSoulDialog?: (contextPack: any) => boolean
+  onError?: (error: Error) => void
+}
+
+export function createSoulDialogPlugin(deps: SoulDialogPluginDeps = {}): PrePlugin {
+  const { shouldRequestSoulDialog: shouldFn, onError } = deps
+
+  return {
+    name: "soul_dialog",
+    priority: 40,
+    run: async (input: PrePluginInput): Promise<PrePluginOutput> => {
+      if (!input.novelMode || !input.contextPack) return {}
+
+      try {
+        const shouldRequest = shouldFn
+          ? shouldFn(input.contextPack)
+          : defaultShouldRequestSoulDialog(input.contextPack)
+
+        if (shouldRequest) {
+          return {
+            shouldStop: true,
+            stopReason: "soul_dialog_confirmation_required",
+          }
+        }
+        return {}
+      } catch (error) {
+        onError?.(error instanceof Error ? error : new Error(String(error)))
+        return {}
+      }
+    },
+  }
+}
+
+function defaultShouldRequestSoulDialog(contextPack: any): boolean {
+  return Boolean(contextPack?.characterAuras?.trim())
+}

Некоторые файлы не были показаны из-за большого количества измененных файлов