Prechádzať zdrojové kódy

fix(outline): 修复大纲交互并完善生成质量流程

Mochocyang 2 mesiacov pred
rodič
commit
5b11f75ef5
39 zmenil súbory, kde vykonal 4310 pridanie a 212 odobranie
  1. 912 0
      dagangjiaohuxiufu-分支说明.md
  2. 2 0
      src/App.tsx
  3. 6 1
      src/components/chat/chat-panel.mount.spec.tsx
  4. 45 1
      src/components/common/conversation-run-status-icon.spec.tsx
  5. 14 1
      src/components/common/conversation-run-status-icon.tsx
  6. 2 0
      src/components/reference/ReferenceInput.spec.tsx
  7. 66 0
      src/components/sources/novel-generation-request-message.spec.tsx
  8. 31 0
      src/components/sources/novel-generation-request-message.tsx
  9. 673 6
      src/components/sources/outline-chat-panel.spec.tsx
  10. 283 125
      src/components/sources/outline-chat-panel.tsx
  11. 275 13
      src/components/sources/outline-multi-agent-panel.spec.tsx
  12. 142 11
      src/components/sources/outline-multi-agent-panel.tsx
  13. 109 0
      src/components/sources/outline-next-step-card.spec.tsx
  14. 44 14
      src/components/sources/outline-next-step-card.tsx
  15. 45 0
      src/components/sources/outline-wizard-dialog.spec.tsx
  16. 28 15
      src/components/sources/outline-wizard-dialog.tsx
  17. 44 0
      src/lib/ai-outline-model-initialization.spec.ts
  18. 12 0
      src/lib/ai-outline-model-initialization.ts
  19. 65 0
      src/lib/llm-model-keys.spec.ts
  20. 55 24
      src/lib/llm-model-keys.ts
  21. 29 0
      src/lib/novel/markdown-quality-ai-repair.spec.ts
  22. 38 0
      src/lib/novel/markdown-quality-ai-repair.ts
  23. 212 0
      src/lib/novel/markdown-quality-finalizer.spec.ts
  24. 194 0
      src/lib/novel/markdown-quality-finalizer.ts
  25. 192 0
      src/lib/novel/markdown-quality-pipeline.spec.ts
  26. 373 0
      src/lib/novel/markdown-quality-pipeline.ts
  27. 12 0
      src/lib/novel/model-resolver.ts
  28. 66 0
      src/lib/novel/novel-generation-request-package.spec.ts
  29. 144 0
      src/lib/novel/novel-generation-request-package.ts
  30. 5 0
      src/lib/novel/outline-chat-session-state.spec.ts
  31. 12 0
      src/lib/novel/outline-chat-session-state.ts
  32. 0 1
      src/lib/novel/outline-next-step.ts
  33. 3 0
      src/lib/novel/outline-wizard.ts
  34. 74 0
      src/lib/project-store.spec.ts
  35. 21 0
      src/lib/project-store.ts
  36. 42 0
      src/stores/outline-chat-store.spec.ts
  37. 8 0
      src/stores/outline-chat-store.ts
  38. 22 0
      src/stores/wiki-store.ai-outline-model.spec.ts
  39. 10 0
      src/stores/wiki-store.ts

+ 912 - 0
dagangjiaohuxiufu-分支说明.md

@@ -0,0 +1,912 @@
+# 大纲交互修复分支说明
+
+## 分支目标
+
+本分支用于执行“大纲交互修复”实施计划。当前任务仅建立分支基线、确认项目局部依赖状态并记录定向验证结果,不修改任何功能代码。
+
+## 六项需求
+
+1. 仅在 `C:\QMAI_C\QMAI-main\.worktrees\dagangjiaohuxiufu` 工作,不回退或覆盖其他工作者的改动。
+2. 检查并记录 Git 工作区状态、HEAD 和当前分支,固定实施前基线。
+3. 创建并维护本分支说明文档,使用中文记录需求、预计范围、验证清单和提交状态。
+4. 检查 `node_modules` 是否可用;如需准备依赖,只采用项目局部、低风险方式,不修改全局环境。
+5. 运行 `npm run typecheck`,并定向运行 `outline-chat-panel.spec.tsx`、`outline-multi-agent-panel.spec.tsx` 基线测试,准确记录结果。
+6. 本任务不修改功能代码、不删除文件、不执行 `git commit`。
+
+## 当前基线
+
+- 当前分支:`dagangjiaohuxiufu`
+- 当前 HEAD:`24b3a26e6bb1b3c4c7985423406300da49314436`
+- 初始工作区状态:干净(`git status --short --branch` 仅显示 `## dagangjiaohuxiufu`)
+
+## 预计范围
+
+- 预计新增:`dagangjiaohuxiufu-分支说明.md`
+- 预计检查:项目根目录 `node_modules`、`package.json` 及 npm 脚本可用性。
+- 预计验证:TypeScript 类型检查和两个指定的大纲面板测试文件。
+- 明确不在范围内:功能代码修改、文件删除、全局环境修改、Git 提交或合并。
+
+## 验证清单
+
+- [x] 已确认工作目录为指定 worktree。
+- [x] 已检查 Git 状态、HEAD 和当前分支。
+- [x] 已确认 `node_modules` 可用或完成项目局部依赖准备。
+- [x] 已运行 `npm run typecheck` 并记录准确结果。
+- [x] 已运行 `outline-chat-panel.spec.tsx` 定向基线测试并记录准确结果。
+- [x] 已运行 `outline-multi-agent-panel.spec.tsx` 定向基线测试并记录准确结果。
+- [x] 已复核最终 Git 状态,确认没有功能代码修改和文件删除。
+
+## 验证结果
+
+- 依赖检查:初始不存在 `node_modules`,本地 `tsc`、`vitest` 不可用。
+- 局部依赖准备:`npm.cmd ci --no-audit --no-fund`,退出码 `0`,在当前 worktree 添加 `800` 个包;未执行全局安装。命令输出包含 `shamefully-hoist` 未知项目配置警告和 `@milkdown/plugin-math@7.5.9` 弃用警告。
+- 类型检查:`npm.cmd run typecheck`,退出码 `0`;执行 `qmai@2.2.33` 的 `tsc --build --pretty`,无 TypeScript 错误。输出包含 `shamefully-hoist` 警告。
+- 大纲会话面板基线:`npm.cmd exec -- vitest run src/components/sources/outline-chat-panel.spec.tsx`,退出码 `0`;`1` 个测试文件通过,`33` 个测试通过,耗时 `3.47s`。
+- 大纲多 Agent 面板基线:`npm.cmd exec -- vitest run src/components/sources/outline-multi-agent-panel.spec.tsx`,退出码 `0`;`1` 个测试文件通过,`2` 个测试通过,耗时 `333ms`。
+- 两次 Vitest 输出均包含 `shamefully-hoist` 警告。
+
+## 提交状态
+
+未提交。
+
+
+
+
+
+## 任务 2-3:结构化小说生成需求包与消息原地展开/收起(2026-07-11)
+
+### 目标与影响分析
+
+- 将向导提交内容拆分为用户可见摘要、用户主动选择/填写的详情、模型实际请求三层。
+- 用户消息默认只显示紧凑摘要,可在当前消息下原地展开/收起详情。
+- 固定工作流、多 Agent、Skill、Agent 计划和回退规则仅保留在模型实际请求中,不进入用户正文或详情。
+- 旧历史消息未携带结构化需求包时继续按原有 `content` 渲染和参与模型历史,不改变旧数据格式。
+- 详情使用视口相关 `max-height`、纵向滚动、自动断词和 `max-width`,降低长内容及小窗口越界风险。
+- 本次为局部新增类型和 focused 组件,并仅接入向导提交路径;未删除函数、未修改普通聊天消息行为。
+
+### 修改文件
+
+- 新增 `src/lib/novel/novel-generation-request-package.ts`
+- 新增 `src/lib/novel/novel-generation-request-package.spec.ts`
+- 新增 `src/components/sources/novel-generation-request-message.tsx`
+- 新增 `src/components/sources/novel-generation-request-message.spec.tsx`
+- 修改 `src/stores/outline-chat-store.ts`
+- 修改 `src/components/sources/outline-chat-panel.tsx`
+- 更新 `dagangjiaohuxiufu-分支说明.md`
+
+### TDD 记录
+
+- RED:先创建两个 focused spec,运行 `npm.cmd exec -- vitest run src/lib/novel/novel-generation-request-package.spec.ts src/components/sources/novel-generation-request-message.spec.tsx`;退出码 `1`,两个 suite 均因待实现模块不存在而失败,`0 test`,确认失败原因与新增功能缺失一致。
+- GREEN:最小实现需求包类型/构造器、模型内容读取函数、原地展开组件及向导发送/历史消息接入后,重复同一命令;退出码 `0`,`2` 个文件、`2` 个测试全部通过。
+
+### 最终验证
+
+- 定向回归:`npm.cmd exec -- vitest run src/lib/novel/novel-generation-request-package.spec.ts src/components/sources/novel-generation-request-message.spec.tsx src/components/sources/outline-chat-panel.spec.tsx src/components/sources/outline-multi-agent-panel.spec.tsx`;退出码 `0`,`4` 个文件、`37` 个测试全部通过。
+- 类型检查:`npm.cmd run typecheck`;退出码 `0`,无 TypeScript 错误。
+- 已知警告:npm 仍输出项目既有的 `shamefully-hoist` 未知配置警告,本次未修改该配置。
+- 打包:本任务按用户要求仅运行定向测试与 typecheck,未执行打包。
+
+### 提交状态
+
+未提交。
+
+## 任务 2-3 规格审查修复(2026-07-11)
+
+### 审查缺口修复
+
+1. 新增统一的 `getOutlineMessageModelContent`,结构化消息返回完整 `modelContent`,旧消息回退到原 `content`。
+2. 普通后续发送历史、重新生成的最后用户请求与历史消息均通过统一函数取得模型内容。
+3. 两处 `getOutlineConversations`(提供给 `read_outline_history`)均通过统一函数映射消息,避免工具只读取用户摘要。
+4. 用户详情过滤系统默认值:篇幅/频道/题材为 `auto`、空卖点、空规模、自动叙事和 `none` 资料来源不再显示。
+5. 持久化结构保持向后兼容:结构化需求包随消息保存和重载;旧消息仍按原正文展示和进入模型。
+
+### 第二轮 TDD 记录
+
+- RED:先补需求包默认值过滤、统一模型内容解析、跨面板链路断言和旧消息兼容测试。首次定向运行退出码 `1`:默认值仍出现在详情、`getOutlineMessageModelContent` 尚不存在、重新生成及历史工具链路尚未统一映射。修正测试文件字符编码后再次运行,得到 `3` 个测试中 `2` 个按预期失败,明确验证了功能缺口。
+- GREEN:实现统一解析和默认值过滤,并接入后续历史、重新生成与两处 `read_outline_history` 后,focused 测试通过。
+- 跨模块持久化测试:通过真实 store 保存/重载结构化消息,验证摘要保留、完整模型请求恢复、旧消息回退正文。
+
+### 最终验证(第二轮)
+
+- 命令:`npm.cmd exec -- vitest run src/stores/outline-chat-store.spec.ts src/lib/novel/novel-generation-request-package.spec.ts src/components/sources/novel-generation-request-message.spec.tsx src/components/sources/outline-chat-panel.spec.tsx src/components/sources/outline-multi-agent-panel.spec.tsx`
+- 结果:退出码 `0`,`5` 个测试文件、`54` 个测试全部通过。
+- 类型检查:`npm.cmd run typecheck`,退出码 `0`,无 TypeScript 错误。
+- 已知警告:npm 仍输出项目既有的 `shamefully-hoist` 未知配置警告。
+- 打包:本轮按用户要求运行定向测试与 typecheck,未执行打包。
+- Git:未提交。
+
+## 任务 2-3 第三轮规格复审修复(2026-07-11)
+
+### 修复内容
+
+- 修复 `novel-generation-request-package.ts` 中真实问号乱码,恢复“生成任务:”“篇幅类型:”“频道方向:”“题材类型:”“故事灵感/处理要求:”“核心卖点:”“生成目标:”“作品规模:”“叙事要求:”“已有资料:”及中文顿号。
+- `OutlineWizardRequest` 新增可选 `explicit` 字段,记录用户实际操作过的字段;真实默认请求初始化为 `explicit: {}`。
+- 向导的任务、篇幅、频道、题材、自定义题材、灵感、卖点、目标、规模、叙事和资料来源交互会分别标记显式字段;频道切换带出的首个题材仍视为系统派生值,只有用户操作题材选择框才标记题材显式。
+- 用户详情仅展示显式选择/填写内容。真实默认 `sellingPoints: ["AI 根据灵感推荐"]`、默认长篇/男频/默认题材/第三人称/默认目标/无资料不再自动进入详情;用户主动点击后,即使值与默认相同也可展示。
+- 新增可导出的真实行为函数:`mapOutlineMessagesForModel`、`buildOutlineRegenerationInput`、`mapOutlineConversationsForModel`;普通模型历史、重新生成、两处 `read_outline_history` 均由这些函数实际组装,不再依赖源码字符串断言。
+
+### 第三轮 TDD 证据
+
+- RED:先增加 UTF-8 中文标签、真实默认请求显式状态、默认值显式/隐式过滤、普通历史、重新生成和大纲历史映射行为测试。首次运行退出码 `1`:中文标签断言收到真实 `????`、默认请求导出函数不存在、三个映射函数不存在;修正测试输入编码后仍有 `4` 个行为测试按预期失败。
+- GREEN:恢复 UTF-8 中文、加入 `explicit` 状态、实现并接入三个纯行为函数后,相关 focused 测试通过。
+- 渲染断言:实际渲染 `NovelGenerationRequestMessage` 并展开,验证“篇幅类型:长篇小说”“核心卖点:升级变强”可见且不含问号乱码。
+
+### 最终验证(第三轮)
+
+- 定向测试:`6` 个测试文件、`62` 个测试全部通过,退出码 `0`。
+- TypeScript:`npm.cmd run typecheck` 退出码 `0`,无类型错误。
+- `git diff --check`:清理新增文件尾空行后退出码 `0`。
+- npm 仍提示项目既有 `shamefully-hoist` 未知配置警告,本轮未修改该配置。
+- 未打包,未提交。
+
+## 任务 2-3 第四轮代码质量审查修复(2026-07-11)
+
+- `handleChannelChange` 现在保留频道显式状态,同时清除 `explicit.genre` 和 `explicit.customGenre`;行为测试覆盖先选自定义题材、切换频道后派生题材不进入详情、再次主动选题材后才进入详情。
+- 新增 `isNovelGenerationRequestPackage` 最小运行时 v1 校验:必须是 `version: 1`、字符串 `summary`、字符串数组 `details`、字符串 `modelContent`。持久化加载遇到未知版本、缺少 `modelContent` 或 `details` 非数组时移除需求包,模型内容回退消息 `content`。
+- 需求详情按钮使用 React `useId`,按钮设置 `aria-controls`,详情设置对应 `id`;测试验证收起/展开时 `aria-expanded` 及控件关联。
+- 清理需求包测试中重复的“篇幅类型:长篇小说”断言。
+- TDD RED:新增测试首次运行退出码 `1`,频道切换仍残留显式题材,三类非法持久化包未被忽略,ARIA 关联尚不存在。
+- GREEN:定向测试 `6` 个文件、`66` 个测试全部通过;`npm.cmd run typecheck` 退出码 `0`;清理文件尾空行后 `git diff --check` 退出码 `0`。
+- 未打包,未提交。
+
+## 任务 2-3 第五轮低问题修复(2026-07-11)
+
+- 删除消息渲染测试中重复的篇幅断言,替换为独立的“核心卖点:升级变强”断言。
+- 详情节点改为常驻 DOM,并通过 `hidden={!expanded}` 控制显示,避免 `aria-controls` 在收起时指向不存在节点。
+- 行为测试完整验证 `aria-expanded` 为 `false → true → false`、`aria-controls` 与详情 `id` 一致,以及 `hidden` 为 `true → false → true`。
+- 频道切换测试执行两次真实提交:切换频道后未主动选题材时,断言 `explicit.genre/customGenre` 清除且详情不含派生题材;再次主动选题材后,断言 `explicit.genre=true` 且详情包含题材。
+- TDD RED:常驻详情测试首先因收起状态详情节点不存在而失败;修复后 GREEN。
+- 定向测试:6 个文件、67 个测试通过;typecheck 通过;清理文件尾空行后 diff-check 通过。
+- 未提交。
+
+## 任务 4:移除 AI 会话与 AI 大纲的可见生成提示长条(2026-07-11)
+
+### 目标与影响范围
+
+- AI 会话真实路径:`chat-panel.tsx` 的会话标签通过 `ConversationRunStatusIcon` 渲染运行状态;停止按钮继续由 `ReferenceInput` 渲染。
+- AI 大纲真实路径:顶部标签和历史记录通过 `ConversationRunStatusIcon` 渲染;输入区原有独立的“正在生成...”状态长条;停止按钮继续由 `ReferenceInput` 渲染。
+- 仅移除运行状态的可视 Tooltip,以及 AI 大纲输入区状态长条的文字、边框、背景、闪烁动画和底部间距。
+- 保留旋转生成图标、`data-conversation-run-status` 状态标记、中文 `aria-label="正在生成"`、停止按钮和原有停止逻辑。
+- 完成、失败、中断状态仍保留原有 Tooltip;未修改 `StreamingSpinner`、消息正文及其他业务中的正常生成提示。
+
+### 修改文件
+
+- `src/components/common/conversation-run-status-icon.tsx`
+- `src/components/common/conversation-run-status-icon.spec.tsx`
+- `src/components/chat/chat-panel.mount.spec.tsx`
+- `src/components/sources/outline-chat-panel.tsx`
+- `src/components/sources/outline-chat-panel.spec.tsx`
+- `dagangjiaohuxiufu-分支说明.md`
+
+### 严格 TDD 证据
+
+- RED:先修改真实组件测试,再运行 `npm.cmd exec -- vitest run src/components/common/conversation-run-status-icon.spec.tsx src/components/chat/chat-panel.mount.spec.tsx src/components/sources/outline-chat-panel.spec.tsx`。
+- RED 结果:退出码 `1`;3 个文件均按预期失败,分别证明运行图标仍是 Tooltip 触发器、AI 会话仍可触发可视 Tooltip、AI 大纲仍存在生成提示长条;其余 `47` 个测试通过,`6` 个 todo。
+- GREEN:最小实现后同一命令退出码 `0`,`3` 个文件、`50` 个测试通过,`6` 个 todo。
+- 同步修正一条旧源码断言:由要求存在“正在生成...”改为要求不存在,避免旧规格与任务 4 冲突。
+
+### 验证结果
+
+- 定向回归:`npm.cmd exec -- vitest run src/components/common/conversation-run-status-icon.spec.tsx src/components/reference/ReferenceInput.spec.tsx src/components/chat/chat-panel.mount.spec.tsx src/components/sources/outline-chat-panel.spec.tsx src/components/sources/outline-chat-multi-session.spec.tsx src/components/sources/outline-multi-agent-panel.spec.tsx`;退出码 `0`,`6` 个测试文件、`71` 个测试通过,`6` 个 todo。
+- TypeScript:`npm.cmd run typecheck`;退出码 `0`,无类型错误。
+- npm 仍提示项目既有的 `shamefully-hoist` 未知配置警告,本任务未修改该配置。
+- 源码运行:通过真实 ChatPanel、OutlineChatPanel 的 jsdom 挂载测试验证;未单独启动 Vite 开发服务器。
+- 打包:本任务按指定范围执行定向测试、typecheck 和 diff-check,未执行打包。
+- Git:未提交,未合并。
+## 任务 4 质量审查:补齐 Tooltip 与停止行为测试(2026-07-11)
+
+### 审查修复范围
+
+- `ConversationRunStatusIcon` 对 `completed_unread`、`failed`、`interrupted` 使用真实鼠标事件序列触发 Tooltip,分别验证中文“已完成,点击查看”“生成失败:接口错误”“任务已中断,可重新发送”真实出现在 Portal 内容中。
+- `running` 状态继续验证为纯图标:保留旋转图标和中文 `aria-label="正在生成"`,即使触发 focus/hover 事件也不存在 Tooltip 内容。
+- AI 大纲停止测试改用真实 `assistant` 运行占位消息:
+  - 有部分流内容时,停止后保留部分内容、设置 `isAgentRunning: false`、清空该会话 streaming、运行状态回到 `idle`。
+  - 无部分流内容时,停止后删除运行中的助手占位消息,同时清空 streaming 并回到 `idle`。
+- 增加稳定的运行守卫测试:停止后状态为 `idle` 时,`canApplyOutlineRunEffect` 拒绝晚到回调,避免覆盖最终停止状态。
+- 本轮现有生产实现已满足全部新增行为测试,因此未修改任何生产代码,也未扩展功能。
+
+### 本轮修改文件
+
+- `src/components/common/conversation-run-status-icon.spec.tsx`
+- `src/components/sources/outline-chat-panel.spec.tsx`
+- `src/lib/novel/outline-chat-session-state.spec.ts`
+- `dagangjiaohuxiufu-分支说明.md`
+
+### 测试优先证据
+
+- 先补 focus 行为、两种停止分支和晚到回调守卫测试,首次定向运行退出码 `0`,`3` 个文件、`49` 个测试通过,证明既有生产行为无需修正。
+- 将 Tooltip 测试进一步收紧为真实 hover 后,首次仅发送 `mouseover` 时 3 个 Tooltip 用例按预期失败,证明测试确实依赖交互打开而非静态 DOM。
+- 按 Base UI Tooltip 的真实交互机制补齐 `pointerover(mouse) → mouseenter → mousemove → delay` 测试事件序列后,Tooltip 测试通过;未修改生产组件。
+- 最终 focused 测试:`3` 个文件、`49` 个测试全部通过。
+
+### 最终验证
+
+- 定向回归:`npm.cmd exec -- vitest run src/components/common/conversation-run-status-icon.spec.tsx src/components/reference/ReferenceInput.spec.tsx src/components/chat/chat-panel.mount.spec.tsx src/components/sources/outline-chat-panel.spec.tsx src/lib/novel/outline-chat-session-state.spec.ts src/components/sources/outline-chat-multi-session.spec.tsx src/components/sources/outline-multi-agent-panel.spec.tsx src/stores/outline-chat-store.spec.ts`;退出码 `0`,`8` 个测试文件、`96` 个测试通过,`6` 个 todo。
+- TypeScript:`npm.cmd run typecheck`;退出码 `0`,无类型错误。
+- npm 仍提示项目既有的 `shamefully-hoist` 未知配置警告,本轮未修改该配置。
+- 打包:本轮按指定范围执行测试、typecheck 和 diff-check,未执行打包。
+- Git:未提交,未合并。
+## 任务 5 首次实现记录(规格审查未通过,不作为完成结论)(20260711-2137)
+
+> 状态更正:首次实现随后未通过规格审查,原“完成”结论撤销。主要缺口为全文 markdown 围栏在质量检查前被清空、完整性阈值可能放行内容丢失、接入测试仅检查源码字符串。以下内容仅作为首次实现历史记录。
+
+### 目标与影响分析
+
+- 新增 focused Markdown 纯函数模块,识别结构化资料并检查一级标题、成对加粗、全文代码围栏和表格分隔行。
+- 结构化生成完成后采用固定线性流程:首次检查 → 确定性本地修复 → 复检 → 仍失败时最多一次 AI 纯格式修复 → 第三检;没有递归调用 handleSend。
+- 仅 intentPhase === "generation" 或固定小说生成向导请求可启用管线,并再次执行结构化资料识别;普通问答保持原完成路径。
+- AI 格式修复仅发送正文和四项简短 Markdown 规则,不携带会话历史、工具、项目资料或长系统提示;保存协议 JSON 保留在正文修复之外。
+- 本地或 AI 候选若明显丢失内容则拒绝覆盖;第三检仍失败或 AI 请求失败时保留问题更少且内容完整的版本,并使用中文非阻塞提示。
+- AI 大纲系统提示词新增简短 Markdown 格式约束,不改变既有生成工作流、保存协议和普通问答行为。
+
+### 修改文件
+
+- src/lib/novel/markdown-quality-pipeline.ts(新增)
+- src/lib/novel/markdown-quality-pipeline.spec.ts(新增)
+- src/components/sources/outline-chat-panel.tsx
+- src/components/sources/outline-chat-panel.spec.tsx
+- dagangjiaohuxiufu-分支说明.md
+
+### 严格 TDD 证据
+
+- RED 1:先创建纯函数 spec,首次运行因 markdown-quality-pipeline 模块不存在而失败,证明测试先于生产实现。
+- GREEN 1:最小实现后 7 项纯函数测试通过。
+- RED 2:先补 5 项面板接入契约,首次运行 5 项全部按缺少三检、短请求、完整性保护、普通问答旁路和提示词约束失败。
+- GREEN 2:最小接入后纯函数与面板 47 项测试通过。
+- RED 3:复审新增两项多行表格边界测试,首次均失败,分别复现已有分隔行被误报和重复插入分隔行。
+- GREEN 3:按连续表格块首行判断后 49 项 focused 测试通过。
+
+### 验证结果
+
+- 定向回归:10 个测试文件,110 项通过,6 项既有 todo,无失败。
+- TypeScript:npm.cmd run typecheck 通过,无类型错误。
+- 前端构建:npm.cmd run build 成功;Vite 既有动态导入和大 chunk 警告仍存在,本任务未扩大处理范围。
+- Windows 便携打包:npm.cmd run build:portable 成功。
+- 产物:C:\QMAI_C\QMAI-main\.worktrees\dagangjiaohuxiufu\release-portable\QMaiWrite.exe。
+- 版本:2.2.33;大小:149359616 字节。
+- diff-check:git diff --check 无空白错误;未删除文件,未回退其他工作者改动。
+- npm 仍提示既有 shamefully-hoist 未知项目配置警告,本任务未修改该配置。
+
+### 提交状态
+
+未提交,未合并。
+
+
+## 任务 5 规格审查修复记录(后续质量审查未通过,不作为完成结论)(20260711-2233)
+
+> 状态更正:该轮修复随后仍未通过质量审查,不能视为任务完成。后续发现代码区域加粗误判、单标题围栏旁路、协议分隔边界不足、完整性归一化过宽和 AI 无 Token 预算等问题。
+
+### 审查问题与修复
+
+- 修正真实完成路径:不再在质量管线前调用 `extractBodyContent`。新增可执行 finalizer,只拆分尾部合法的 `outlineSaveRequest` / `outlineSaveRequests` JSON 保存协议;全文 ` ```markdown ... ``` ` 会先进入首次检查,再由本地修复去除围栏并形成最终消息。
+- 完整性比较改为严格模式:本地和 AI 候选始终与首次原始正文比较;归一化仅忽略 Markdown 标记、空白和表格分隔行,事实、段落文本、标点顺序或任一表格行变化都会拒绝覆盖,不再使用 0.8 长度或字符覆盖率阈值。
+- 本地候选若未保持首次正文完整性,不会作为最佳候选,也不会发送给 AI;AI 改用首次原始正文。
+- 质量编排保持线性:首次检查 → 本地修复 → 第二检 → 最多一次 AI 修复 → 第三检;没有递归。
+- 删除任务 5 的源码 `toContain` 接入契约,改为执行真实 finalizer 和提示词构造函数的行为测试。
+- 覆盖行为:全文围栏到最终消息、尾部保存协议保留、AI 最多一次、普通问答旁路、本地不完整时 AI 使用原文、第三检失败选择最佳完整候选、AI 丢失表格行拒绝覆盖、短事实丢失拒绝。
+
+### 本轮文件
+
+- 新增:`src/lib/novel/markdown-quality-finalizer.ts`
+- 新增:`src/lib/novel/markdown-quality-finalizer.spec.ts`
+- 修改:`src/lib/novel/markdown-quality-pipeline.ts`
+- 修改:`src/lib/novel/markdown-quality-pipeline.spec.ts`
+- 修改:`src/components/sources/outline-chat-panel.tsx`
+- 修改:`src/components/sources/outline-chat-panel.spec.tsx`
+- 更新:`dagangjiaohuxiufu-分支说明.md`
+
+### TDD 与验证
+
+- RED:finalizer 模块不存在;严格完整性 API 不存在,相关测试按预期失败。
+- GREEN:纯函数严格完整性测试 11 项通过;finalizer 与面板 focused 测试合计 54 项通过。
+- RED:面板提示词行为测试首次因 `buildOutlineAgentSystemPrompt` 不可执行导出而失败;导出真实构造函数后通过。
+- 定向回归:11 个测试文件,115 项通过,6 项既有 todo,无失败。
+- TypeScript:`npm.cmd run typecheck` 通过。
+- diff-check:`git diff --check` 通过。
+- 本轮按用户要求未重新打包、未修改外部更新日志、未删除既有产物。
+
+### 提交状态
+
+未提交,未合并。
+
+
+## 任务 5 质量审查修复(未完成,待审查批准)(20260711-2341)
+
+> 当前状态:实现与本轮自动验证已完成,但任务仍标记为“未完成”,必须等待下一轮质量审查明确批准后才能改为完成。
+
+### 本轮修复
+
+- 加粗检查和本地修复新增代码区域掩码:跳过 fenced code 与 inline code;`**/*.md` glob 双星号不再作为加粗标记,也不会被删除。
+- 完整性比较对代码区域做精确保留,代码内 `**`、`|`、`---`、空白和其他字面内容变化都会拒绝覆盖;全文非 markdown 代码围栏也不会被当作 Markdown 外包装解包。
+- 全文 `markdown` / `md` 围栏即使内部只有一个一级标题,也强制进入首次检查和本地去围栏,不再受一般结构化证据数量门控。
+- 保存协议只识别尾部合法 ` ```json ` 围栏,兼容单换行、CRLF 和与正文紧邻;协议从不进入本地 Token 估算或 AI 请求,并按原始换行保留。
+- 完整性归一化不再全局删除 `|`,也不再按外形删除任意 `---` 行;只有具备同列数表头、分隔行和数据行的真实表格分隔行允许忽略。
+- 新增本地 Markdown AI Token 估算:总输入估算上限 3200 token(含 80 token 短规则预留);超限直接旁路 AI。
+- AI 输出 `max_tokens` 按正文估算动态设置为 256–4096,并通过独立可执行适配器传给 `streamChat` 第五参数。
+- finalizer 的 AI 回调改为强制对象契约 `{ content, maxTokens }`,面板无法静默忽略预算。
+
+### TDD 证据
+
+- RED:fenced/inline code、glob、字面管道符、伪分隔行和单标题全文围栏共 5 项按预期失败;最小修复后 GREEN。
+- RED:单换行、CRLF、紧邻保存协议 3 项均证明协议进入 AI;收紧尾部合法 JSON 围栏拆分后 GREEN。
+- RED:Token 估算、输入上限、输出上下限、`max_tokens` 映射、超长旁路及强制回调对象契约按预期失败;实现后 GREEN。
+- RED:代码内和 glob 双星号完整性测试、全文非 markdown 代码围栏标点测试按预期失败;代码感知归一化后 GREEN。
+- AI 传输适配器使用模拟 `streamChat` 真实验证单条正文消息、动态 `max_tokens` 和流式输出聚合。
+
+### 本轮文件
+
+- 新增:`src/lib/novel/markdown-quality-ai-repair.ts`
+- 新增:`src/lib/novel/markdown-quality-ai-repair.spec.ts`
+- 修改:`src/lib/novel/markdown-quality-pipeline.ts`
+- 修改:`src/lib/novel/markdown-quality-pipeline.spec.ts`
+- 修改:`src/lib/novel/markdown-quality-finalizer.ts`
+- 修改:`src/lib/novel/markdown-quality-finalizer.spec.ts`
+- 修改:`src/components/sources/outline-chat-panel.tsx`
+- 更新:`dagangjiaohuxiufu-分支说明.md`
+
+### 验证记录
+
+- focused 测试:4 个文件,73 项通过。
+- 定向回归:12 个测试文件,134 项通过,6 项既有 todo,无失败。
+- TypeScript:`npm.cmd run typecheck` 通过。
+- 本轮按要求未执行 build、未打包、未修改任何外部更新日志、未删除既有产物。
+
+### 提交状态
+
+未提交,未合并;状态保持未完成,等待质量审查批准。
+
+
+## 任务 5 质量复审修复(仍未完成,待审查批准)(20260712-0022)
+
+> 当前状态:本轮三项复审问题已实现并完成自动验证,但任务继续标记为“未完成”,等待质量审查明确批准。
+
+### 本轮修复
+
+- fenced code 关闭判定收紧:关闭行必须使用与 opening 相同字符,反引号/波浪线长度不少于 opening,且去除首尾空白后只能包含 fence 字符;` ```not-a-close ` 不会关闭代码块,后续 `**literal` 保持代码字面量且不参与加粗修复。
+- 表格 cell 改为逐字符解析:转义 `\|` 和 inline code 内 `|` 不再拆列;支持不同长度 inline backtick marker 和转义 backtick。
+- 复杂 cell 表格的缺失分隔行检查、本地确定性补行和完整性比较统一使用同一 cell 解析结果。
+- 表格完整性只排除 fenced-code 行;数据 cell 含 inline code 时仍能识别真实表格分隔行。
+- Token 估算改为按 Unicode 码点保守计数:CJK、emoji、罕见 Unicode、组合/特殊符号和密集标点每个非空白码点至少计 1 token;英文单词仍按每 5 字符至少 1 token。
+- 4680 个 emoji 的输入估算超过 3200 token 上限,`shouldCallAi=false`,直接旁路 AI。
+
+### TDD 证据
+
+- RED:` ```not-a-close ` 被错误关闭并使后续 `**literal` 触发加粗错误;修正关闭条件后 GREEN。较短 fence 不关闭更长 opening 的测试同步通过。
+- RED:含转义管道符和 inline-code 管道符的表格无法报告缺分隔行、无法本地补行、完整性误判,共 3 项失败;逐字符 cell 解析并区分 fenced/inline code 后 GREEN。
+- RED:emoji 10 个只估算 7 token、密集标点 6 个只估算 2 token、4680 emoji 恰好估算 3200 并允许 AI;按 Unicode 码点保守估算后 GREEN。
+
+### 本轮文件
+
+- 修改:`src/lib/novel/markdown-quality-pipeline.ts`
+- 修改:`src/lib/novel/markdown-quality-pipeline.spec.ts`
+- 修改:`src/lib/novel/markdown-quality-finalizer.ts`
+- 修改:`src/lib/novel/markdown-quality-finalizer.spec.ts`
+- 更新:`dagangjiaohuxiufu-分支说明.md`
+
+### 验证记录
+
+- focused 测试:4 个文件,81 项通过。
+- 定向回归:12 个测试文件,142 项通过,6 项既有 todo,无失败。
+- TypeScript:`npm.cmd run typecheck` 通过。
+- 本轮未执行 build、未打包、未修改外部更新日志、未删除既有产物。
+
+### 提交状态
+
+未提交,未合并;状态仍为未完成,等待质量审查批准。
+
+## 任务 6:AI 大纲“下一步”推荐按钮交互修复(20260712-0047)
+
+### 任务目标
+
+- “下一步”卡片的两个推荐按钮点击后,将推荐 `label` 作为普通用户消息直接发送到当前大纲会话。
+- 复用当前会话 ID、会话模型、历史上下文和输入区现有引用,不创建新会话。
+- 发送期间防重复点击并显示 busy/disabled 状态;当前会话生成中按既有并发规则禁用,并提供中文原因。
+- 发送失败后恢复按钮,并通过中文非阻塞提示告知用户可重试。
+- 推荐文本按 React 普通文本安全渲染和传递,不解释为 HTML。
+
+### TDD 记录
+
+- RED:先新增真实 `NextStepCard` 组件交互测试;旧实现中连续点击触发两次回调,且生成中按钮未禁用,2 项按预期失败。
+- GREEN:增加同步防重入锁、单项 busy、全卡片 disabled 和中文禁用原因后,组件行为测试通过。
+- 补充行为覆盖:失败后按钮恢复并可再次点击;包含 Markdown/HTML 符号的 label 仅作为文本显示,且原样交给发送行为。
+- 面板发送链路改为异步结果:推荐项不再映射固定模块或聚焦输入框,而是直接调用当前会话发送链路;发送链路继续按当前会话解析模型和历史上下文,并携带输入区现有引用。
+
+### 本轮文件
+
+- 修改:`src/components/sources/outline-next-step-card.tsx`
+- 新增:`src/components/sources/outline-next-step-card.spec.tsx`
+- 修改:`src/components/sources/outline-chat-panel.tsx`
+- 更新:`dagangjiaohuxiufu-分支说明.md`
+
+### 影响分析
+
+- 仅改变 AI 回复内“下一步”推荐卡片的点击行为;大纲生成菜单、意图选项卡、普通输入发送、停止生成和多会话逻辑保持原路径。
+- 不删除或回退任务 1—5 的既有未提交改动。
+- 未执行打包、未写外部更新日志、未提交、未合并。
+
+### 提交状态
+
+未提交。
+
+## 任务 6 规格复审修正(20260712-0113)
+
+> 本节取代上一节任务 6 的验证结论;上一实现因乱码、并发上限和发送结果误判问题未通过规格审查,本轮已按真实面板链路重新执行 RED/GREEN。
+
+### 修正内容
+
+- 修复任务 6 在 `outline-chat-panel.tsx` 中引入的所有问号乱码,恢复正常中文禁用原因和失败提示。
+- “下一步”卡片禁用条件与输入区统一:当前会话正在运行时禁用;其他会话已占满全局 3 个并发名额时同样禁用。
+- 当前会话运行原因:`当前会话正在生成,请等待生成完成后再选择下一步。`
+- 全局并发原因:`大纲 AI 会话最多同时运行 3 个任务,请等待任一任务结束后再发送。`
+- `handleSend` 改为明确返回 `{ started, sent }`;空文本、无项目、当前会话运行、模型不可用、模型不支持工具、无法取得运行名额等提前返回均为未开始/未发送。
+- 已开始但运行失效、中止或异常时返回已开始但未发送成功;只有完整成功结束才返回 `sent: true`。
+- 推荐发送只在 `sent: true` 时清理当前引用;失败和 Promise reject 均保留引用、恢复卡片状态并显示中文非阻塞提示。
+- 推荐 label 继续作为普通 React 文本渲染和发送,HTML/Markdown 字符不会被解释为 DOM 或富文本。
+
+### 真实 TDD 证据
+
+- RED:新增真实 `OutlineChatPanel` 链路测试后共 4 项失败:两个推荐 label 未进入 Agent 链路、全局 3 并发未禁用、失败提示未触发。
+- GREEN:通过真实 `AgentRunner` 方法替身验证两个推荐 label 均进入当前会话;会话数量不增加;当前模型、既有用户/助手历史、上下文发送规划和引用均进入真实面板发送链路。
+- 成功链路验证第一次消息携带引用,成功后第二次推荐消息不再携带引用;失败链路验证引用保留、按钮恢复、中文非阻塞提示触发。
+- 当前运行和全局 3 并发上限均通过真实面板按钮 disabled/title 行为验证。
+- `NextStepCard` 继续覆盖同步防重复、busy、失败恢复和 HTML/Markdown label 安全处理。
+- 修正测试环境的 `act` 配置及异步等待后,本轮定向测试输出无新增 act 警告。
+
+### 本轮文件
+
+- 修改:`src/components/sources/outline-chat-panel.tsx`
+- 修改:`src/components/sources/outline-chat-panel.spec.tsx`
+- 修改:`src/components/sources/outline-next-step-card.tsx`
+- 新增:`src/components/sources/outline-next-step-card.spec.tsx`
+- 更新:`dagangjiaohuxiufu-分支说明.md`
+
+### 限制与状态
+
+- 未提交、未合并。
+- 未打包、未写更新日志。
+- 未回退或覆盖任务 1—5 及工作树内其他既有改动。
+
+## 任务 6 测试证据复审修正(20260712-0719)
+
+### 修正内容
+
+- `outline-next-step-card.spec.tsx` 在 `beforeEach` 中显式设置 React `IS_REACT_ACT_ENVIRONMENT`,组件渲染、点击、Promise 完成和卸载产生的状态更新全部由 `act` 包裹。
+- 新增真实安全文本测试,使用 `<script>alert(1)</script> **继续**` 作为推荐 label,验证不产生 `script` DOM、按钮文本原样展示、回调收到完全相同的 label。
+- 真实 `OutlineChatPanel` 推荐链路增加同一 HTML/Markdown label 用例,验证页面不产生 `script` DOM,且该 label 原样进入当前会话用户消息和 Agent 用户输入。
+- 面板链路不再只设置 `contextSummary`:`AgentRunner.run` 的真实入参断言必须包含 `{ role: "assistant", content: "当前会话摘要" }`,直接证明缓存摘要进入 `historyPlan.messages` 并随 Agent 消息发送。
+
+### 验证证据
+
+- 两个定向 spec:2 个测试文件、45 项测试全部通过。
+- 定向测试标准输出和错误输出均无 React `act(...)` 警告。
+- HTML/Markdown 安全 label 同时由独立卡片组件和真实面板发送链路覆盖。
+- 本轮未新增源码字符串断言。
+
+### 文件范围与状态
+
+- 修改:`src/components/sources/outline-next-step-card.spec.tsx`
+- 修改:`src/components/sources/outline-chat-panel.spec.tsx`
+- 更新:`dagangjiaohuxiufu-分支说明.md`
+- 未提交、未合并、未打包、未写更新日志。
+
+## 任务 6 跨会话引用清理修正(20260712-0732)
+
+### 问题与修复
+
+- 问题复现:A 会话点击“下一步”并携带 A 引用发送;请求未完成时切换到 B,并在 B 输入区加入新引用;A 成功回调会错误清空 B 的引用。
+- 修复沿用现有 `shouldClearOutlineDraft` 跨会话副作用隔离模式,在同一会话状态辅助模块增加 `shouldClearOutlineReferences`。
+- 推荐发送开始时固定捕获发起会话 ID 和发送引用集合。
+- A 成功后仅在以下两个条件同时成立时清理引用:
+  1. 当前活动会话仍是发起会话。
+  2. 当前输入区引用 ID 集合仍与发送时捕获集合相同。
+- 活动会话已切换,或当前引用被新增、删除、替换时,晚到成功回调不执行清理。
+
+### TDD 证据
+
+- RED:真实 `OutlineChatPanel` 测试挂起 A 的 `AgentRunner.run`,切换到 B 并加入 `B new reference` 后完成 A;修复前最终断言无法找到 B 引用,1 项按预期失败。
+- GREEN:增加活动会话与引用集合双重校验后,同一真实面板测试通过;A 完成后活动会话保持 B,B 新引用仍显示。
+- 两个定向 spec 共 46 项测试通过,测试输出无 act 警告。
+
+### 本轮文件与状态
+
+- 修改:`src/lib/novel/outline-chat-session-state.ts`
+- 修改:`src/components/sources/outline-chat-panel.tsx`
+- 修改:`src/components/sources/outline-chat-panel.spec.tsx`
+- 更新:`dagangjiaohuxiufu-分支说明.md`
+- 未提交、未合并、未打包、未写更新日志。
+
+## 任务 7:AI 大纲模型全局记忆(20260712-0801)
+
+### 目标与影响面
+
+- AI 大纲模型选择改为应用级全局记忆,关闭面板后重进、重启应用及切换项目后继续恢复同一稳定 `providerId/modelId`。
+- 使用专属持久化键 `aiOutlineModel`,不读写 `aiChatModel` 或 `defaultLlmModel`,不改变 AI 会话及其他任务模型选择。
+- 保留现有大纲会话 `modelId` 字段作为兼容数据和活动会话同步目标,不删除旧字段、不回退任务 1—6 的发送链路。
+
+### 实现内容
+
+- 在既有 `wiki-store` 增加 `aiOutlineModel` 与 `setAiOutlineModel`,未新建重复 Zustand store。
+- 在既有 `project-store` 增加 `saveAiOutlineModel` / `loadAiOutlineModel`,复用 Tauri `app-state.json` 持久化模式;`App` 启动初始化时恢复该值。
+- 模型选择变化时先立即更新内存和活动大纲会话,再异步持久化;持久化失败不回滚、不阻断当前使用,并显示中文非阻塞提示。
+- 统一把旧纯模型名迁移为稳定 `providerId/modelId`,迁移本身不误报“模型不可用”。
+- 已存模型不存在、服务商停用或配置不可用时,按现有写作模型优先级回退到可用的 AI 会话模型/工作流默认模型,显示中文非阻塞提示并尝试保存自愈结果。
+- 大纲发送与重新生成统一使用已解析的全局 AI 大纲模型,不再让其他项目的旧会话模型覆盖全局选择。
+
+### 严格 TDD 证据
+
+- RED 1:真实 `project-store` 测试首次因 `saveAiOutlineModel is not a function` 失败;增加专属键读写后转绿。
+- RED 2:真实 `wiki-store` 测试首次因 `setAiOutlineModel is not a function` 失败;增加隔离状态后转绿,并验证 AI 会话模型和默认模型保持不变。
+- RED 3:真实 `OutlineChatPanel` 的重进/切项目恢复、稳定 ID 立即保存、保存失败继续使用、失效回退四项测试首次全部失败;最小实现后转绿。
+- RED 4:服务商停用且 AI 会话仍指向该服务商时,首次无法回退到工作流默认模型;补充可用稳定键解析后转绿。
+- RED 5:旧纯模型名迁移首次错误显示不可用提示;区分“可用旧键迁移”和“真实失效回退”后转绿。
+
+### 本轮文件
+
+- 修改:`src/App.tsx`
+- 修改:`src/components/sources/outline-chat-panel.tsx`
+- 修改:`src/components/sources/outline-chat-panel.spec.tsx`
+- 修改:`src/lib/llm-model-keys.ts`
+- 修改:`src/lib/novel/model-resolver.ts`
+- 修改:`src/lib/project-store.ts`
+- 新增:`src/lib/project-store.spec.ts`
+- 修改:`src/stores/wiki-store.ts`
+- 新增:`src/stores/wiki-store.ai-outline-model.spec.ts`
+- 更新:`dagangjiaohuxiufu-分支说明.md`
+
+### 验证与状态
+
+- 定向回归:5 个测试文件、67 项测试全部通过,无失败、无新增 React 警告。
+- TypeScript:`npm.cmd run typecheck` 通过。
+- diff-check:`git diff --check` 通过;无 tracked 文件删除。
+- 本任务未单独启动桌面 UI;关闭/重进、切项目与重启持久化分别由真实面板重挂载测试、全局 store/persist 读写测试及 `App` 启动恢复接线覆盖。
+- 未提交、未合并、未打包、未写更新日志。
+
+## 任务 7 质量审查修复(20260712-0828)
+
+> 状态更正:上一节任务 7 的完成结论已被本轮质量审查否决;以下交错初始化、latest-wins 持久化及旧模型 ID 兼容修复完成并通过新一轮验证后,才作为当前实现结论。
+
+### 审查问题与根因
+
+1. `App` 在异步读取 `aiOutlineModel` 后无条件写入 store;读取期间若用户手选新模型,旧持久值会覆盖新选择。
+2. 自动回退保存与随后手选保存可以同时 pending;底层异步写入逆序完成时,旧回退值可能最后落盘。
+3. 旧模型 ID 只要包含 `/` 就被直接视为 `provider/model`;当前缀不是实际 provider 时无法按完整模型 ID 匹配。同名模型也没有显式复用现有“内置 provider 优先、自定义 provider 其次”的可用 provider 顺序。
+
+### 修复内容
+
+- `wiki-store` 增加 `aiOutlineModelRevision`;所有运行时 `setAiOutlineModel` 都递增 revision。
+- 新增 `initializeAiOutlineModelFromStorage` 初始化协调器:加载前捕获 revision,加载完成后仅在 revision 未变化时应用旧值。`App` 改为调用该协调器,避免初始化覆盖加载期间的新手选。
+- `saveAiOutlineModel` 增加写入 revision 和最新值修复循环。多个保存可并发 pending;任一旧写入晚完成后都会重新写入当时最新 revision 的模型,且 revision 在修复期间再次变化时继续追到最新值。
+- 模型键解析先判断 `/` 前缀是否真实存在于 `providerConfigs`:
+  - 真实 provider 前缀:按稳定 `provider/model` 精确验证。
+  - 非真实 provider 前缀:把包含 `/` 的完整字符串作为旧模型 ID,在全部可用 provider 中匹配。
+- 可用 provider 顺序统一为现有顺序:内置 provider 按配置插入顺序优先,其后是自定义 provider 按配置插入顺序;多 provider 同名结果稳定可预测。
+
+### 严格 TDD 证据
+
+- RED 1:新增真实异步交错测试时,初始化协调模块不存在,测试套件按预期失败;实现后验证加载 pending 期间的手选 revision +1,旧值完成后不覆盖新选择。
+- RED 2:自动回退值与手选值同时 pending,先完成手选、后完成回退;修复前最终落盘为旧回退值,断言按预期失败。版本化修复后会产生第三次最新值修复写入,最终稳定为手选值。
+- RED 3:包含 `/` 且前缀不是 provider 的旧模型 ID 首次返回空值;多 provider 同名测试首次也返回空值,两项按预期失败。修复前缀验证和 provider 顺序后转绿。
+- 额外覆盖:当前缀确实是 provider 时仍保持精确稳定键解析;初始化期间没有新选择时仍正常应用已存模型。
+
+### 本轮文件
+
+- 修改:`src/App.tsx`
+- 新增:`src/lib/ai-outline-model-initialization.ts`
+- 新增:`src/lib/ai-outline-model-initialization.spec.ts`
+- 修改:`src/lib/project-store.ts`
+- 修改:`src/lib/project-store.spec.ts`
+- 修改:`src/lib/llm-model-keys.ts`
+- 新增:`src/lib/llm-model-keys.spec.ts`
+- 修改:`src/stores/wiki-store.ts`
+- 更新:`dagangjiaohuxiufu-分支说明.md`
+
+### 验证与状态
+
+- 定向回归:7 个测试文件、73 项测试全部通过。
+- TypeScript:`npm.cmd run typecheck` 通过。
+- diff-check:`git diff --check` 通过;无 tracked 文件删除。
+- npm 仅输出既有 `shamefully-hoist` 未知项目配置提示,本轮未修改该配置。
+- 未提交、未合并、未打包、未写更新日志。
+
+## 任务 7 质量复审剩余问题修复(20260712-0835)
+
+### 剩余问题与根因
+
+- 上一轮已能区分真实 provider 前缀和未知前缀,但真实 provider 精确匹配失败后仍会停止解析。
+- 审查示例中 `anthropic` provider 存在但已停用,另一个可用 `openrouter` provider 保存的旧模型 ID 是完整字符串 `anthropic/claude-3-7-sonnet`;旧实现会返回空值,无法迁移到 `openrouter/anthropic/claude-3-7-sonnet`。
+
+### 最小修复
+
+- 真实 provider 精确匹配成功时仍立即返回稳定 `provider/model`。
+- 精确匹配失败或原 provider 停用时,不再提前返回空值;改为将完整输入作为旧 model ID,在其他可用 provider 中继续匹配。
+- 完整旧 ID 回退明确排除精确匹配失败的原 provider,避免在同一 provider 内产生歧义。
+- 多个其他 provider 同时保存完整旧 ID 时,继续复用既有稳定顺序:内置 provider 按配置顺序优先,其后为自定义 provider。
+
+### 严格 TDD 证据
+
+- RED 1:`anthropic` 存在但停用、`openrouter` 保存完整 `anthropic/claude-3-7-sonnet` 时,修复前返回空值,审查示例测试按预期失败。
+- RED 2:`openrouter`、`groq` 和自定义 provider 同时保存完整旧 ID 时,修复前同样返回空值;稳定消歧测试按预期失败。
+- GREEN:移除精确失败后的过早返回,并在其他 provider 列表中按稳定顺序完整匹配后,5 项模型键解析测试全部通过。
+
+### 本轮文件与验证
+
+- 修改:`src/lib/llm-model-keys.ts`
+- 修改:`src/lib/llm-model-keys.spec.ts`
+- 更新:`dagangjiaohuxiufu-分支说明.md`
+- 定向回归:7 个测试文件、75 项测试全部通过。
+- TypeScript:`npm.cmd run typecheck` 通过。
+- diff-check:`git diff --check` 通过;无 tracked 文件删除。
+- 未提交、未合并、未打包、未写更新日志。
+
+## 任务 8:多 Agent 失败回退摘要与诊断折叠(20260712-0856)
+
+### 目标与影响分析
+
+- 多 Agent 失败并回退时,默认只显示简短中文摘要:`多 Agent 生成失败,已自动切换为普通生成。`
+- 用户点击“查看详情”后,才在原地显示 Agent 状态、失败原因、任务摘要、Skill 和合并状态。
+- 任务摘要只取第一个非标题行并限制长度,不展示完整系统提示词或默认铺开 Agent 处理内容。
+- 详情容器使用 `max-height` 和内部纵向滚动,容器、文本和网格均允许收缩/换行,避免小窗口越界。
+- 折叠按钮与详情区通过 `aria-expanded`、`aria-controls`、`role=region` 和唯一 ID 建立可访问性关联。
+- 回退仍调用原 `runSingleAgentFallback` 闭包,不新增用户消息,沿用同一模型、会话、需求包和引用;固定向导提交时改为将当前引用交给原发送流程。
+- 只清除回退初始时的旧长提示,普通生成结果继续通过原流式回调输出,保存和下一步按钮不受阻断。
+- 影响边界:正常多 Agent 运行/完成态仍保留原详细面板;未修改编排器、保存器、模型解析或会话持久化结构。
+
+### 本轮文件
+
+- 修改:`src/components/sources/outline-multi-agent-panel.tsx`
+- 修改:`src/components/sources/outline-multi-agent-panel.spec.tsx`
+- 修改:`src/components/sources/outline-chat-panel.tsx`
+- 修改:`src/components/sources/outline-chat-panel.spec.tsx`
+- 更新:`dagangjiaohuxiufu-分支说明.md`
+
+### 严格 TDD 证据
+
+- RED 1:先增加真实组件交互测试,旧面板因回退态默认铺开 Agent、Skill、错误和任务内容而按预期失败。
+- RED 2:先增加真实固定向导→多 Agent 全失败→普通生成流程测试,旧实现因回退摘要不符和固定向导未传递当前引用而失败。
+- GREEN:实施最小回退态分支和引用传递后,组件折叠、诊断限制、同会话/模型/需求包/引用、单条用户消息、保存与下一步全部通过。
+
+### 验证结果
+
+- 定向回归:6 个测试文件、87 项测试全部通过。
+- 可复现命令:`npm.cmd exec -- vitest run src/components/sources/outline-multi-agent-panel.spec.tsx src/components/sources/outline-chat-panel.spec.tsx src/components/sources/outline-wizard-dialog.spec.tsx src/components/sources/outline-next-step-card.spec.tsx src/lib/novel/outline-multi-agent-orchestrator.spec.ts src/stores/outline-chat-store.spec.ts --reporter=verbose --testTimeout=15000`。
+- TypeScript:`npm.cmd run typecheck` 通过。
+- diff-check:`git diff --check` 通过;无 tracked 文件删除,无调试输出残留。
+- 仅有 npm 现存 `shamefully-hoist` 未知项目配置提示,本轮未修改该配置。
+- 未提交、未合并、未打包、未写更新日志。
+
+
+## 任务 8 规格复审修复(20260712-0916)
+
+- 复审确认 `fallbackReason` 和 `failureDetails` 仍存在换行后内容直出风险,`merge.error` 也未进入统一诊断展示路径。
+- `fallbackReason`、`failureDetails`、`agent.error`、`merge.error` 与任务摘要现在全部复用同一个诊断摘要函数:仅取首个非空行,单条最多 160 字符,超长以省略号结束。
+- `failureDetails` 在摘要后去重,最多展示 5 条;换行后的系统提示词、请求体、Agent 完整内容和其他秘密不进入 DOM。
+- 展开详情补充脱敏后的合并失败原因,同时保留原合并状态。
+- 正常多 Agent 面板的兼容诊断渲染也统一使用同一摘要函数,避免持久化异常状态绕过脱敏。
+- RED:在四类诊断字段的换行后放入不同秘密,并加入重复项和第六条诊断;修复前秘密、重复项和超额项均能进入展开详情,测试按预期失败。
+- GREEN:统一摘要函数和去重/限量处理后,新增真实组件测试通过。
+- 定向验证命令:`npm.cmd exec -- vitest run src/components/sources/outline-multi-agent-panel.spec.tsx src/components/sources/outline-chat-panel.spec.tsx src/components/sources/outline-wizard-dialog.spec.tsx src/components/sources/outline-next-step-card.spec.tsx src/lib/novel/outline-multi-agent-orchestrator.spec.ts src/stores/outline-chat-store.spec.ts --reporter=verbose --testTimeout=15000`。
+- 验证结果:6 个测试文件、87 项测试全部通过;`npm.cmd run typecheck` 通过;`git diff --check` 通过;无 tracked 文件删除,无调试输出残留。
+- 未提交、未合并、未打包、未写更新日志。
+
+## 任务 8 第二轮质量审查修复(20260712-0944)
+
+### 修复内容
+
+- 诊断首行切分统一支持 `\r`、`\n`、`U+2028` 和 `U+2029`,再对首行执行凭据掩码和长度限制。
+- 常见 `Authorization`、`Bearer`、`API key`、`token`、`password`、`passwd`、`secret` 值以 `***` 掩码,URL query 中同类凭据也不再原样展示。
+- 合并 Agent 调用抛错时,先在消息状态中写入 `merge.status = error`、`merge.error` 和完成时间;回退回调和最终收敛更新遇到 merge error 时保留原状态,不再覆盖为 `skipped`。
+- 折叠时不渲染诊断区,`aria-controls` 也不存在;展开后才同时渲染详情节点并建立 ID 关联,折叠期间敏感正文不进入 DOM。
+
+### 严格 TDD 证据
+
+- RED 1:单行凭据和四种换行分隔测试修复前失败,原凭据与 `U+2028/U+2029` 后正文可进入 DOM。
+- RED 2:真实 OutlineChatPanel 流程中至少一个子 Agent 成功、合并 Agent 抛错后自动回退,修复前最终 `merge.status` 为 `skipped` 而不是 `error`。
+- RED 3:折叠详情未挂载时,按钮 `aria-controls` 仍指向不存在节点,可访问性测试按预期失败。
+- GREEN:三项最小修复后,凭据掩码、分隔符切分、合并错误保留、折叠 DOM/ARIA 关联全部转绿。
+
+### 验证
+
+- 定向命令:`npm.cmd exec -- vitest run src/components/sources/outline-multi-agent-panel.spec.tsx src/components/sources/outline-chat-panel.spec.tsx src/components/sources/outline-wizard-dialog.spec.tsx src/components/sources/outline-next-step-card.spec.tsx src/lib/novel/outline-multi-agent-orchestrator.spec.ts src/stores/outline-chat-store.spec.ts --reporter=verbose --testTimeout=15000`。
+- 定向结果:6 个测试文件、87 项测试全部通过。
+- `npm.cmd run typecheck` 通过。
+- `git diff --check` 通过;无 tracked 文件删除,无调试输出残留。
+- 未提交、未合并、未打包、未写更新日志。
+
+## 任务 9:验证证据(20260712-1010)
+
+### 验证范围与基线
+
+- 仅在 `C:\QMAI_C\QMAI-main\.worktrees\dagangjiaohuxiufu` 执行验证;分支为 `dagangjiaohuxiufu`,验证起点 HEAD 为 `24b3a26e6bb1b3c4c7985423406300da49314436`,与当时 `main` 工作树 HEAD 相同。
+- 本任务未修改功能代码、未删除文件、未提交、未合并、未打包、未写更新日志;仅向本分支说明追加本节验证证据。
+
+### Git、删除与敏感信息检查
+
+- 执行命令:`git status --short --branch`、`git diff --name-status`、`git diff --cached --name-status`、`git diff --stat`、`git diff --check`、`git diff --diff-filter=D --name-status`、`git diff --cached --diff-filter=D --name-status`、`git ls-files --deleted`。
+- 结果:上述命令退出码均为 `0`;无 staged 变更;无 tracked 文件删除;`git diff --check` 无空白错误。
+- 验证时工作区已有 `20` 个 tracked 修改和 `17` 个 untracked 文件;tracked diff 为 `1618 insertions(+), 210 deletions(-)`。本任务不修改、不清理这些功能改动。
+- 使用 PowerShell 内嵌 Python 对 `20` 个 tracked 变更文件的新增 diff 行和 `17` 个 untracked 文件全文执行敏感信息粗扫,规则覆盖私钥块、OpenAI/GitHub/AWS/Slack 令牌及常见凭据赋值。共命中 `8` 处,全部位于 `src/components/sources/outline-chat-panel.spec.tsx` 的测试凭据样例;疑似真实凭据 `0`。
+- Git 警告:`src/components/sources/outline-next-step-card.tsx` 当前为 LF,Git 提示下次触碰时可能转换为 CRLF;本任务未触碰该文件。
+
+### 新增与相关定向测试
+
+- 测试集合由 `17` 个新增/修改 spec 与分支说明明确引用的 `3` 个关联回归 spec 合并而成,共 `20` 个文件,均存在。
+- 执行命令:`npm.cmd exec -- vitest run src/components/chat/chat-panel.mount.spec.tsx src/components/common/conversation-run-status-icon.spec.tsx src/components/reference/ReferenceInput.spec.tsx src/components/sources/novel-generation-request-message.spec.tsx src/components/sources/outline-chat-multi-session.spec.tsx src/components/sources/outline-chat-panel.spec.tsx src/components/sources/outline-multi-agent-panel.spec.tsx src/components/sources/outline-next-step-card.spec.tsx src/components/sources/outline-wizard-dialog.spec.tsx src/lib/ai-outline-model-initialization.spec.ts src/lib/llm-model-keys.spec.ts src/lib/novel/markdown-quality-ai-repair.spec.ts src/lib/novel/markdown-quality-finalizer.spec.ts src/lib/novel/markdown-quality-pipeline.spec.ts src/lib/novel/novel-generation-request-package.spec.ts src/lib/novel/outline-chat-session-state.spec.ts src/lib/novel/outline-multi-agent-orchestrator.spec.ts src/lib/project-store.spec.ts src/stores/outline-chat-store.spec.ts src/stores/wiki-store.ai-outline-model.spec.ts --reporter=default --testTimeout=15000`。
+- 结果:退出码 `0`;`20/20` 个测试文件通过;`195` 项通过、`6` 项 todo,共 `201` 项。
+- 警告:npm 输出项目配置 `shamefully-hoist` 未知;`ReferenceInput` 和 `NovelGenerationRequestMessage` 的部分用例输出“testing environment is not configured to support act(...)”,但测试未失败。
+
+### TypeScript 与完整 mocks 基线
+
+- `npm.cmd run typecheck`:退出码 `0`,通过。
+- `npm.cmd run test:mocks`:退出码 `1`;角色灵魂内容校验通过;Vitest 为 `5 failed | 274 passed` 个文件,`39 failed | 1836 passed | 6 todo` 项测试,共 `279` 个文件、`1881` 项。
+- 与已知主线基线“5 文件、39 失败”完全一致,无新增失败数量或失败文件差异,因此未进入差异定位分支。
+- 失败文件集合与数量:`src/components/layout/workspace-top-bars.spec.ts`(2)、`src/lib/agent/tools/run-chapter-workflow.spec.ts`(9)、`src/components/sources/outline-workbench-integration.spec.ts`(2)、`src/lib/novel/outline-generation.spec.ts`(1)、`src/lib/novel/deep-chapter-generation.spec.ts`(25),合计 `39`。
+
+### 构建与源码服务
+
+- `npm.cmd run build`:退出码 `0`;命令内 typecheck 通过;Vite `8.1.3` 转换 `5053` 个模块并完成生产构建,用时约 `2.19s`。这是前端 build 验证,不是 Tauri 打包。
+- 构建警告:npm 的 `shamefully-hoist` project/env 配置提示;若干模块同时静态与动态导入导致动态导入不能拆包;存在压缩后大于 `700 kB` 的 chunk,最大输出 `book-analysis-vendor` 约 `1738.87 kB`。本任务不处理这些既有构建警告。
+- 源码服务实际命令:`npm.cmd run dev -- --host 127.0.0.1 --port 41739 --strictPort`。最终验证返回 HTTP `200`,入口 HTML 含 `id="root"`,确认 Vite 源码服务可访问。
+- 进程安全证据:启动前目标工作树/端口匹配进程为 `0`;最终以根 PID `8940` 精确终止进程树,`taskkill` 退出码 `0`;停止后端口 `41739` 监听数为 `0`,目标工作树/端口相关 Node/CMD/npm 进程数为 `0`。
+- 过程说明:前两次控制脚本分别在进程创建前因 `.NET ArgumentList` 兼容性和 PowerShell 引号解析失败;第三次重定向流方案超时并留下本次启动的根 PID `40976` 进程树,随后已用 `taskkill /PID 40976 /T /F` 精确清理,复查监听与相关进程均为 `0`。改用无重定向的隐藏进程方案后取得上述最终成功证据,当前无残留进程。
+
+### 折叠组件样式与 ARIA 边界
+
+- 静态核对命令:`rg -n -C 8 "aria-expanded|aria-controls|detailsId|overflow|break-|whitespace|max-h|展开|收起|Chevron" src/components/sources/novel-generation-request-message.tsx src/components/sources/novel-generation-request-message.spec.tsx`,以及对 `outline-multi-agent-panel.tsx/.spec.tsx` 的同类检索。
+- 结构化需求消息:切换控件为 `type="button"`;`aria-expanded` 随状态切换;`aria-controls` 始终关联稳定且实际存在的 `useId()` 详情节点;折叠时节点带 `hidden`;详情高度限制为 `min(40vh,20rem)`,使用 `overflow-y-auto`、`overscroll-contain`、`max-w-full` 和 `break-words`,未发现弹出软件边界或长文本横向撑开问题。
+- 多 Agent 回退诊断:折叠时详情 region 不挂载,按钮不输出悬空 `aria-controls`;展开时同步输出 `aria-expanded=true`、详情 ID、`role="region"` 与中文 `aria-label`。详情高度限制为 `min(24rem,55vh)`,支持纵向滚动和任意断词,外层限制最大宽度并隐藏横向溢出;折叠时诊断秘密正文不进入 DOM。
+- 上述交互与样式断言已包含在通过的定向测试中。未发现折叠样式/ARIA 阻断问题。限制:按任务要求采用测试与静态核对,未进行真实浏览器截图或人工视觉走查。
+
+### 最终状态
+
+- 定向测试、typecheck、build、Vite 短时源码服务通过。
+- 完整 `test:mocks` 仍为已知主线基线 `5` 文件、`39` 失败,命令退出码为 `1`;不能表述为全量测试通过。
+- 未提交、未合并、未打包、未写更新日志。
+## 任务 9:React act 测试警告修复验证(20260712-1020)
+
+### 目标与影响分析
+
+- 目标:消除 `ReferenceInput.spec.tsx` 与 `NovelGenerationRequestMessage.spec.tsx` 在任务 9 的 20 文件定向测试中输出的 React `act(...)` 环境警告。
+- 影响范围严格限制为两份测试文件;不修改生产组件、共享测试配置或运行时行为。
+- 本轮不提交、不合并、不打包、不写更新日志。
+
+### RED 与根因
+
+- 复现命令:`npm.cmd exec -- vitest run src/components/reference/ReferenceInput.spec.tsx src/components/sources/novel-generation-request-message.spec.tsx --reporter=default`。
+- 修复前结果:2 个文件、17 项测试均通过,但 stderr 稳定输出多条 `The current testing environment is not configured to support act(...)`。
+- 两份 spec 的渲染、交互和卸载已基本使用 React `act` 包装。与仓库内无此警告的 jsdom spec 对比后确认,根因是目标测试文件未声明 `globalThis.IS_REACT_ACT_ENVIRONMENT = true`,不是生产组件行为问题。
+
+### 最小测试修复
+
+- `src/components/reference/ReferenceInput.spec.tsx`:仅在 import 后增加 `IS_REACT_ACT_ENVIRONMENT = true`。
+- `src/components/sources/novel-generation-request-message.spec.tsx`:仅在 import 后增加同一测试环境标志。
+- 未修改测试断言和生产代码;未引入共享配置变化。
+
+### GREEN 与完整定向验证
+
+- 两文件复验命令:`npm.cmd exec -- vitest run src/components/reference/ReferenceInput.spec.tsx src/components/sources/novel-generation-request-message.spec.tsx --reporter=default`。
+- 两文件结果:退出码 `0`;2/2 文件、17/17 项通过;对完整 stdout/stderr 程序化扫描,act 环境警告及 `not wrapped in act(...)` 警告行均为 `0`。
+- 20 文件命令:`npm.cmd exec -- vitest run src/components/chat/chat-panel.mount.spec.tsx src/components/common/conversation-run-status-icon.spec.tsx src/components/reference/ReferenceInput.spec.tsx src/components/sources/novel-generation-request-message.spec.tsx src/components/sources/outline-chat-multi-session.spec.tsx src/components/sources/outline-chat-panel.spec.tsx src/components/sources/outline-multi-agent-panel.spec.tsx src/components/sources/outline-next-step-card.spec.tsx src/components/sources/outline-wizard-dialog.spec.tsx src/lib/ai-outline-model-initialization.spec.ts src/lib/llm-model-keys.spec.ts src/lib/novel/markdown-quality-ai-repair.spec.ts src/lib/novel/markdown-quality-finalizer.spec.ts src/lib/novel/markdown-quality-pipeline.spec.ts src/lib/novel/novel-generation-request-package.spec.ts src/lib/novel/outline-chat-session-state.spec.ts src/lib/novel/outline-multi-agent-orchestrator.spec.ts src/lib/project-store.spec.ts src/stores/outline-chat-store.spec.ts src/stores/wiki-store.ai-outline-model.spec.ts --reporter=default --testTimeout=15000`。
+- 20 文件结果:退出码 `0`;20/20 文件通过;195 项通过、6 项 todo,共 201 项;完整输出中的 act 环境警告和 `not wrapped in act(...)` 警告行均为 `0`。
+- 仍有 npm 既有 `Unknown project config "shamefully-hoist"` 提示;它不是 React act 警告,也不是本轮新增。
+
+### TypeScript、diff 与状态
+
+- `npm.cmd run typecheck`:退出码 `0`,通过。
+- `git diff --check`:退出码 `0`,通过。
+- Git 仍提示 `src/components/sources/outline-next-step-card.tsx` 下次触碰时可能由 LF 转为 CRLF;该文件不是本轮修改目标,本轮未触碰。
+- 本轮仅修改两份指定 spec 和本分支说明;未修改生产行为。
+- 未提交、未合并、未打包、未写更新日志。
+## 任务 10:最终复核、便携版打包与日志(20260712-1030)
+
+### 执行边界
+
+- 仅在指定工作树执行项目命令;未修改功能代码,未删除文件,未提交、未合并,未执行 GitHub Release。
+- 应用版本保持 2.2.33,没有升级。package.json、src-tauri/tauri.conf.json、src-tauri/Cargo.toml 均为 2.2.33;既有 package-lock.json 根版本仍为 2.2.32,本任务不修改版本文件。
+
+### 前置验证证据
+
+- 20 个定向测试文件全部通过:195 项通过、6 项 todo,React ct(...) 相关警告为 0。
+- 
+pm.cmd run typecheck、
+pm.cmd run build 通过;Vite 短时源码服务返回 HTTP 200。
+- 完整 	est:mocks 与主线基线一致:5 个测试文件、39 项失败,退出码 1;不能表述为全量测试通过,本任务未重复修复这些既有基线问题。
+
+### 便携版构建证据
+
+- 命令:
+pm.cmd run build:portable。
+- 结果:退出码 ;开始 2026-07-12 10:24:18 +08:00,结束 2026-07-12 10:27:35 +08:00,耗时 196.591 秒。
+- 主产物:C:\QMAI_C\QMAI-main\.worktrees\dagangjiaohuxiufu\release-portable\QMaiWrite.exe。
+- 产物版本:2.2.33;大小:149359616 字节(142.440 MiB);文件时间:2026-07-12 10:27:33 +08:00。
+- SHA-256:2CA17135F58063539E8ABD95B32B137D3DFCE3E08A3442104BF160ABA5B160CB。
+- 版本信息:C:\QMAI_C\QMAI-main\.worktrees\dagangjiaohuxiufu\release-portable\version-info.json,文件时间 2026-07-12 10:27:35 +08:00。
+- 便携目录:586 个文件、308495345 字节(294.204 MiB)。
+
+### 构建警告
+
+- npm 既有 shamefully-hoist 未知配置提示:project 3 次、env 2 次。
+- Vite/Rolldown:6 条 INEFFECTIVE_DYNAMIC_IMPORT;涉及 outline-generation.ts、Tauri dialog、ingest-queue.ts、wiki-store.ts、commands/fs.ts、six-dimension-engine.ts。
+- Vite:1 条压缩后 chunk 大于 700 kB 提示。
+- 上述均为非阻断警告,构建退出码仍为 0;本任务未修改相关配置或功能代码。
+
+### 日志与提交状态
+
+- 已追加工作树 GenxinLOG/更新日志.md(该目录由 .gitignore 排除)。
+- 已追加 E:\QMAI\GenxinLOG\更新日志.md,未覆盖早先内容。
+- 未提交、未合并。
+
+### 任务 10 日志格式更正(20260712-1033)
+
+- 20260712-1030 任务10条目中的 Markdown 反引号被 PowerShell 解释为转义符,导致 act、npm、test:mocks 和退出码等字样出现控制字符。
+- 遵守“只追加、不删除、不覆盖早先内容”,未删除或覆盖该条目;已向工作树与 E 盘更新日志追加“任务10最终准确条目”,其准确数据取代 20260712-1030 条目的表述。
+- 最终准确结论:版本 2.2.33 未升级;build:portable 退出码 0;QMaiWrite.exe 为 149359616 字节;test:mocks 仍为与主线一致的 5 文件、39 项基线失败。
+
+### 任务 10 最终 Git 与安全检查
+
+- git diff --check 与 git diff --cached --check 均为退出码 0。
+- tracked 删除 0,暂存文件 0;未执行提交或合并。
+- GenxinLOG/更新日志.md、release-portable/QMaiWrite.exe、src-tauri/target/release/qmai.exe 均由 .gitignore 规则排除,不进入提交范围。
+- 对 38 个已修改或未跟踪文件执行高置信度密钥模式扫描,命中 0;未发现真实私钥、常见云密钥、GitHub token、OpenAI 风格长 token 或长 Bearer token。
+- Git 仅保留本分支既有功能/测试改动与分支说明;构建过程对 src-tauri/Cargo.toml 仅产生时间戳/stat 变化,工作树 blob 与索引 blob 哈希一致,刷新索引后该文件不再显示修改。
+- Git 仍报告 src/components/sources/outline-next-step-card.tsx 下次触碰时可能由 LF 转为 CRLF;该文件不是任务10修改目标,本任务未触碰或修复该既有换行提示。
+
+
+## 2026-07-12 最终审查中等问题修复
+
+- 主发送、重新生成和明确生成型下一步统一经过 Markdown 最终收尾。
+- 重新生成根据原用户的结构化需求包或明确生成内容决定是否启用收尾。
+- 下一步仅在推荐 label 同时包含明确生成动作和结构化目标时触发,普通问答不触发。
+- 修复 next_step 清理误删普通 Markdown 全文围栏结束标记的问题,保留保存协议与 next_step 推荐。
+- 严格 TDD:先补真实入口失败测试,再最小修复。
+- 未打包,未写更新日志,未提交 Git。
+
+
+### 2026-07-12 复审追加修复
+
+- 下一步生成识别改为结合当前消息推荐来源和会话结构化生成上下文,不再固定传 false。
+- 覆盖继续完善人物关系、继续补充世界观、细化当前大纲、继续完善当前模块;解释、分析、回答类普通追问不触发。
+- 结构化下一步向 handleSend 传入当前 outlineReferenceTokens,不重复发送。
+- 发送成功后按既有会话与引用快照竞态保护清理,失败时保留引用。
+- 已补真实入口 TDD 测试;未打包、未写更新日志、未提交。
+
+## 最终复审后新鲜验证与重包(20260712-1122)
+
+### 纳入重包的最终修复
+
+- 主发送、重新生成和明确生成型下一步统一经过 Markdown 最终收尾;普通解释、分析、回答类追问不触发。
+- 下一步生成识别结合推荐来源与会话结构化生成上下文,覆盖继续完善人物关系、继续补充世界观、细化当前大纲和继续完善当前模块。
+- 结构化下一步沿用当前 outlineReferenceTokens,不重复发送;成功后按既有会话和引用快照竞态保护清理,失败时保留引用。
+- next_step 清理不再误删普通 Markdown 全文围栏结束标记,并保留保存协议和 next_step 推荐。
+
+### 新鲜验证
+
+- 20文件定向测试:退出码0;20/20文件通过;204项通过、6项todo,共210项;React act环境警告0,not wrapped in act警告0。测试时间2026-07-12 11:14:18至11:14:27 +08:00。
+- npm.cmd run typecheck:退出码0;时间2026-07-12 11:14:35至11:14:57 +08:00。
+- npm.cmd run build:退出码0;时间2026-07-12 11:15:16至11:15:40 +08:00,耗时24.824秒。
+- npm.cmd run build:portable:退出码0;时间2026-07-12 11:16:21至11:19:27 +08:00,耗时186.581秒。
+- 完整test:mocks本轮未重跑;此前证据仍为与主线一致的5文件、39项基线失败,不能表述为全量测试通过。
+
+### 最终新产物
+
+- 路径:C:\QMAI_C\QMAI-main\.worktrees\dagangjiaohuxiufu\release-portable\QMaiWrite.exe。
+- 版本:2.2.33,未升级。
+- 大小:149363712字节(142.444 MiB)。
+- 文件时间:2026-07-12 11:19:24 +08:00。
+- SHA-256:389EFE5E2CCCF330A240301E25D9BE6BEC7580A21B71266E76ABE7977FEDCE2A。
+- version-info.json时间:2026-07-12 11:19:27 +08:00;builtAt为2026-07-12T03:19:27.837Z。
+- 便携目录:586个文件、308499441字节(294.208 MiB)。该新包取代10:27:33旧包作为最终产物。
+
+### 警告与边界
+
+- npm既有shamefully-hoist未知配置提示。
+- Vite/Rolldown仍报告6条INEFFECTIVE_DYNAMIC_IMPORT和1条大于700 kB的chunk提示;均未阻断构建。
+- 本轮只执行验证、重包、追加日志与更新分支说明;未修改功能代码,未删除,未提交、未合并,未执行GitHub Release。
+
+### 最终重包后的 Git 与安全检查
+
+- git diff --check和git diff --cached --check退出码均为0。
+- tracked删除0,暂存文件0;GenxinLOG、release-portable和src-tauri/target均由.gitignore排除。
+- 对39个已修改或未跟踪文件执行高置信度密钥模式扫描,命中0。
+- 两处日志均恰好包含1条20260712-1122“最终复审后重包”记录。
+- 最终产物复核:版本2.2.33,149363712字节,文件时间2026-07-12 11:19:24 +08:00,SHA-256为389EFE5E2CCCF330A240301E25D9BE6BEC7580A21B71266E76ABE7977FEDCE2A。
+- 未提交、未合并、未执行GitHub Release。

+ 2 - 0
src/App.tsx

@@ -7,6 +7,7 @@ import { useChatStore } from "@/stores/chat-store"
 import { openProject, fileExists, listDirectory, readFile } from "@/commands/fs"
 import { openProject, fileExists, listDirectory, readFile } 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, loadMcpConfig } 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 { loadReviewItems, loadChatHistory, saveChatHistory, saveReviewItems } from "@/lib/persist"
+import { initializeAiOutlineModelFromStorage } from "@/lib/ai-outline-model-initialization"
 import { setupAutoSave, teardownAutoSave } from "@/lib/auto-save"
 import { setupAutoSave, teardownAutoSave } from "@/lib/auto-save"
 import { checkForAppUpdate } from "@/lib/app-updater"
 import { checkForAppUpdate } from "@/lib/app-updater"
 import { initAnalytics } from "@/lib/analytics"
 import { initAnalytics } from "@/lib/analytics"
@@ -249,6 +250,7 @@ function App() {
         if (savedAiChatModel) {
         if (savedAiChatModel) {
           useWikiStore.getState().setAiChatModel(savedAiChatModel)
           useWikiStore.getState().setAiChatModel(savedAiChatModel)
         }
         }
+        await initializeAiOutlineModelFromStorage()
         const savedDefaultLlmModel = await loadDefaultLlmModel()
         const savedDefaultLlmModel = await loadDefaultLlmModel()
         if (savedDefaultLlmModel) {
         if (savedDefaultLlmModel) {
           useWikiStore.getState().setDefaultLlmModel(savedDefaultLlmModel)
           useWikiStore.getState().setDefaultLlmModel(savedDefaultLlmModel)

+ 6 - 1
src/components/chat/chat-panel.mount.spec.tsx

@@ -135,7 +135,12 @@ describe("ChatPanel mount 基础设施", () => {
       },
       },
     })
     })
 
 
-    expect(view.container.querySelector('[aria-label="正在生成"]')).not.toBeNull()
+    const statusIcon = view.container.querySelector('[aria-label="正在生成"]')
+    expect(statusIcon).not.toBeNull()
+    expect(statusIcon?.querySelector("svg")).not.toBeNull()
+    expect(statusIcon?.hasAttribute("data-slot")).toBe(false)
+    expect(view.container.textContent).not.toContain("正在生成")
+    expect(document.body.querySelector('[data-slot="tooltip-content"]')).toBeNull()
     await view.unmount()
     await view.unmount()
   })
   })
 
 

+ 45 - 1
src/components/common/conversation-run-status-icon.spec.tsx

@@ -8,7 +8,12 @@ import { ConversationRunStatusIcon } from "./conversation-run-status-icon"
 
 
 let host: HTMLDivElement
 let host: HTMLDivElement
 let root: Root
 let root: Root
-beforeEach(() => { host = document.createElement("div"); document.body.appendChild(host); root = createRoot(host) })
+beforeEach(() => {
+  ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+  host = document.createElement("div")
+  document.body.appendChild(host)
+  root = createRoot(host)
+})
 afterEach(() => { act(() => root.unmount()); host.remove() })
 afterEach(() => { act(() => root.unmount()); host.remove() })
 
 
 async function renderState(state: ConversationRunState) {
 async function renderState(state: ConversationRunState) {
@@ -28,6 +33,45 @@ describe("ConversationRunStatusIcon", () => {
     expect(icon?.querySelector("svg")).not.toBeNull()
     expect(icon?.querySelector("svg")).not.toBeNull()
   })
   })
 
 
+  it.each([
+    ["completed_unread", "已完成,点击查看"],
+    ["failed", "生成失败:接口错误"],
+    ["interrupted", "任务已中断,可重新发送"],
+  ] as const)("shows the %s Chinese tooltip after real hover interaction", async (status, label) => {
+    await renderState({ status, updatedAt: 1, error: "接口错误" })
+    const icon = host.querySelector(`[aria-label="${label}"]`)
+    expect(icon).not.toBeNull()
+
+    await act(async () => {
+      const pointerOver = new Event("pointerover", { bubbles: true })
+      Object.defineProperty(pointerOver, "pointerType", { value: "mouse" })
+      icon?.dispatchEvent(pointerOver)
+      icon?.dispatchEvent(new MouseEvent("mouseenter"))
+      icon?.dispatchEvent(new MouseEvent("mousemove", { bubbles: true }))
+      await new Promise((resolve) => setTimeout(resolve, 300))
+    })
+
+    const tooltip = document.body.querySelector('[data-slot="tooltip-content"]')
+    expect(tooltip).not.toBeNull()
+    expect(tooltip?.textContent).toContain(label)
+  })
+
+  it("keeps the running status as an icon-only accessible label without a visible tooltip", async () => {
+    await renderState({ status: "running", updatedAt: 1 })
+    const icon = host.querySelector('[aria-label="正在生成"]')
+
+    expect(icon).not.toBeNull()
+    expect(icon?.querySelector("svg")).not.toBeNull()
+    expect(icon?.textContent).toBe("")
+    expect(icon?.hasAttribute("data-slot")).toBe(false)
+    await act(async () => {
+      icon?.dispatchEvent(new FocusEvent("focusin", { bubbles: true }))
+      icon?.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }))
+      await new Promise((resolve) => setTimeout(resolve, 250))
+    })
+    expect(document.body.querySelector('[data-slot="tooltip-content"]')).toBeNull()
+  })
+
   it("does not render idle status", async () => {
   it("does not render idle status", async () => {
     await renderState({ status: "idle", updatedAt: 1 })
     await renderState({ status: "idle", updatedAt: 1 })
     expect(host.innerHTML).toBe("")
     expect(host.innerHTML).toBe("")

+ 14 - 1
src/components/common/conversation-run-status-icon.tsx

@@ -28,6 +28,19 @@ export function ConversationRunStatusIcon({ state }: { state?: ConversationRunSt
         ? "text-red-500"
         ? "text-red-500"
         : "text-amber-500"
         : "text-amber-500"
 
 
+  if (state.status === "running") {
+    return (
+      <span
+        role="img"
+        aria-label={label}
+        data-conversation-run-status={state.status}
+        className="relative inline-flex h-4 w-4 shrink-0 items-center justify-center"
+      >
+        <Icon aria-hidden="true" className={`h-3.5 w-3.5 ${color} animate-spin`} />
+      </span>
+    )
+  }
+
   return (
   return (
     <TooltipProvider delay={200}>
     <TooltipProvider delay={200}>
       <Tooltip>
       <Tooltip>
@@ -41,7 +54,7 @@ export function ConversationRunStatusIcon({ state }: { state?: ConversationRunSt
             />
             />
           }
           }
         >
         >
-          <Icon aria-hidden="true" className={`h-3.5 w-3.5 ${color} ${state.status === "running" ? "animate-spin" : ""}`} />
+          <Icon aria-hidden="true" className={`h-3.5 w-3.5 ${color}`} />
           {state.status === "completed_unread" ? (
           {state.status === "completed_unread" ? (
             <span aria-hidden="true" className="absolute -right-0.5 -top-0.5 h-1.5 w-1.5 rounded-full border border-background bg-sky-500" />
             <span aria-hidden="true" className="absolute -right-0.5 -top-0.5 h-1.5 w-1.5 rounded-full border border-background bg-sky-500" />
           ) : null}
           ) : null}

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

@@ -6,6 +6,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
 import { ReferenceInput, type InsertReferenceTokens } from "./ReferenceInput"
 import { ReferenceInput, type InsertReferenceTokens } from "./ReferenceInput"
 import type { ReferenceToken } from "@/lib/reference/types"
 import type { ReferenceToken } from "@/lib/reference/types"
 
 
+(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+
 let host: HTMLDivElement
 let host: HTMLDivElement
 let root: Root
 let root: Root
 
 

+ 66 - 0
src/components/sources/novel-generation-request-message.spec.tsx

@@ -0,0 +1,66 @@
+// @vitest-environment jsdom
+import { act } from "react"
+import { createRoot } from "react-dom/client"
+import { describe, expect, it } from "vitest"
+import { NovelGenerationRequestMessage } from "./novel-generation-request-message"
+import { createNovelGenerationRequestPackage } from "@/lib/novel/novel-generation-request-package"
+
+(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+
+describe("NovelGenerationRequestMessage", () => {
+  it("默认只显示摘要,并在当前消息原地展开和收起用户详情", async () => {
+    const container = document.createElement("div")
+    const root = createRoot(container)
+    await act(async () => root.render(<NovelGenerationRequestMessage request={{ version: 1, summary: "创建新书大纲 · 玄幻", details: ["故事灵感:废墟中的剑"], modelContent: "固定工作流" }} />))
+    expect(container.textContent).toContain("创建新书大纲 · 玄幻")
+    const button = container.querySelector("button")!
+    expect(button.textContent).toContain("展开详情")
+    await act(async () => button.click())
+    expect(container.textContent).toContain("废墟中的剑")
+    expect(container.textContent).not.toContain("固定工作流")
+    expect(container.querySelector('[data-novel-request-details]')?.className).toContain("max-h-")
+    expect(container.querySelector('[data-novel-request-details]')?.className).toContain("overflow-y-auto")
+    await act(async () => button.click())
+    await act(async () => root.unmount())
+  })
+
+  it("keeps legacy message content compatible", async () => {
+    const { getOutlineMessageModelContent } = await import("@/lib/novel/novel-generation-request-package")
+    expect(getOutlineMessageModelContent({ content: "legacy message content" })).toBe("legacy message content")
+  })
+  it("renders UTF-8 Chinese detail labels without question-mark corruption", async () => {
+    const request = createNovelGenerationRequestPackage({
+      task: "newBook", length: "long", channel: "male", genre: "xuanhuan", customGenre: "",
+      inspiration: "遗迹中的剑", sellingPoints: ["升级变强"], targets: ["总纲"], scale: "",
+      narrative: "thirdPerson", materialSource: "none",
+      explicit: { length: true, channel: true, genre: true, sellingPoints: true },
+    }, "model workflow")
+    const container = document.createElement("div")
+    const root = createRoot(container)
+    await act(async () => root.render(<NovelGenerationRequestMessage request={request} />))
+    await act(async () => container.querySelector("button")!.click())
+    expect(container.textContent).toContain("篇幅类型:长篇小说")
+    expect(container.textContent).toContain("核心卖点:升级变强")
+    expect(container.textContent).not.toContain("?")
+    await act(async () => root.unmount())
+  })
+
+  it("keeps details associated while toggling aria-expanded false true false", async () => {
+    const container = document.createElement("div")
+    const root = createRoot(container)
+    await act(async () => root.render(<NovelGenerationRequestMessage request={{ version: 1, summary: "summary", details: ["detail"], modelContent: "model" }} />))
+    const button = container.querySelector("button")!
+    const details = container.querySelector<HTMLElement>('[data-novel-request-details]')!
+    expect(button.getAttribute("aria-expanded")).toBe("false")
+    expect(button.getAttribute("aria-controls")).toBe(details.id)
+    expect(details.hidden).toBe(true)
+    await act(async () => button.click())
+    expect(button.getAttribute("aria-expanded")).toBe("true")
+    expect(details.hidden).toBe(false)
+    await act(async () => button.click())
+    expect(button.getAttribute("aria-expanded")).toBe("false")
+    expect(details.hidden).toBe(true)
+    await act(async () => root.unmount())
+  })
+
+})

+ 31 - 0
src/components/sources/novel-generation-request-message.tsx

@@ -0,0 +1,31 @@
+import { useId, useState } from "react"
+import { ChevronDown, ChevronRight } from "lucide-react"
+import type { NovelGenerationRequestPackage } from "@/lib/novel/novel-generation-request-package"
+
+export function NovelGenerationRequestMessage({ request }: { request: NovelGenerationRequestPackage }) {
+  const [expanded, setExpanded] = useState(false)
+  const detailsId = useId()
+  return (
+    <div className="min-w-0 max-w-full">
+      <span className="block break-words">{request.summary}</span>
+      <button
+        type="button"
+        className="mt-1 inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
+        aria-expanded={expanded}
+        aria-controls={detailsId}
+        onClick={() => setExpanded((value) => !value)}
+      >
+        {expanded ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
+        {expanded ? "收起详情" : "展开详情"}
+      </button>
+      <div
+        id={detailsId}
+        data-novel-request-details
+        hidden={!expanded}
+        className="mt-2 max-h-[min(40vh,20rem)] max-w-full overflow-y-auto overscroll-contain rounded-md border border-border/60 p-2 text-sm"
+      >
+        {request.details.map((detail) => <div key={detail} className="break-words py-0.5">{detail}</div>)}
+      </div>
+    </div>
+  )
+}

+ 673 - 6
src/components/sources/outline-chat-panel.spec.tsx

@@ -5,7 +5,20 @@ import { act } from "react"
 import { createRoot, type Root } from "react-dom/client"
 import { createRoot, type Root } from "react-dom/client"
 import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
 import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
 
 
-import { OutlineChatPanel } from "./outline-chat-panel"
+const outlineModelPreferenceMocks = vi.hoisted(() => ({
+  saveAiOutlineModel: vi.fn(async (_modelId: string) => {}),
+}))
+
+vi.mock("@/lib/project-store", async (importOriginal) => ({
+  ...(await importOriginal<typeof import("@/lib/project-store")>()),
+  saveAiOutlineModel: outlineModelPreferenceMocks.saveAiOutlineModel,
+}))
+
+import { outlineConversationRunRegistry } from "@/lib/conversation-run-registry"
+import { AgentRunner } from "@/lib/agent/runner"
+import { toast } from "@/lib/toast"
+import { useWikiStore } from "@/stores/wiki-store"
+import { buildOutlineAgentSystemPrompt, OutlineChatPanel } from "./outline-chat-panel"
 import {
 import {
   useOutlineChatStore,
   useOutlineChatStore,
   type OutlineChatConversation,
   type OutlineChatConversation,
@@ -30,14 +43,19 @@ function conversation(messages: OutlineChatMessage[] = []): OutlineChatConversat
 function setOutlineConversations(
 function setOutlineConversations(
   conversations: OutlineChatConversation[],
   conversations: OutlineChatConversation[],
   activeConversationId: string | null,
   activeConversationId: string | null,
+  options: {
+    streamingContents?: Record<string, string>
+    runStates?: ReturnType<typeof useOutlineChatStore.getState>["runStates"]
+    pendingReferenceTokens?: ReturnType<typeof useOutlineChatStore.getState>["pendingReferenceTokens"]
+  } = {},
 ) {
 ) {
   useOutlineChatStore.setState({
   useOutlineChatStore.setState({
     conversations,
     conversations,
     activeConversationId,
     activeConversationId,
-    streamingContents: {},
-    runStates: {},
+    streamingContents: options.streamingContents ?? {},
+    runStates: options.runStates ?? {},
     loaded: true,
     loaded: true,
-    pendingReferenceTokens: [],
+    pendingReferenceTokens: options.pendingReferenceTokens ?? [],
   })
   })
 }
 }
 
 
@@ -68,6 +86,15 @@ beforeEach(() => {
     value: vi.fn(),
     value: vi.fn(),
   })
   })
   setOutlineConversations([], null)
   setOutlineConversations([], null)
+  useWikiStore.setState({
+    project: { id: "project-1", name: "测试项目", path: "C:/Book" },
+    llmConfig: { ...useWikiStore.getState().llmConfig, provider: "openai", apiKey: "test-key", model: "gpt-4o" },
+    providerConfigs: { openai: { apiKey: "test-key", enabled: true, savedModels: [{ id: "gpt-4o", model: "gpt-4o", name: "GPT-4o", createdAt: 1 }] } },
+    aiChatModel: "gpt-4o",
+    aiOutlineModel: "",
+  })
+  outlineModelPreferenceMocks.saveAiOutlineModel.mockReset()
+  outlineModelPreferenceMocks.saveAiOutlineModel.mockResolvedValue(undefined)
 })
 })
 
 
 afterEach(async () => {
 afterEach(async () => {
@@ -80,9 +107,201 @@ afterEach(async () => {
     mounted.container.remove()
     mounted.container.remove()
   }
   }
   setOutlineConversations([], null)
   setOutlineConversations([], null)
+  vi.restoreAllMocks()
 })
 })
 
 
 describe("OutlineChatPanel controls", () => {
 describe("OutlineChatPanel controls", () => {
+
+  it.each([
+    ["继续完善人物弧光", "A"],
+    ["检查伏笔闭环", "B"],
+    ["<script>alert(1)</script> **\u7ee7\u7eed**", "safe"],
+  ])("下一步按钮把推荐 label 发送到当前会话且继承模型、历史、上下文和引用:%s", async (label, recId) => {
+    const reference = { id: "ref-1", category: "outline" as const, title: "人物设定", displayTitle: "人物设定", path: "大纲/人物设定.md" }
+    const runSpy = vi.spyOn(AgentRunner.prototype, "run").mockImplementation(async (config, _registry, messages, callbacks) => {
+      callbacks.onText("完成")
+      callbacks.onDone()
+      expect(config.modelId).toBe("gpt-4o")
+      expect(config.llmConfig.model).toBe("gpt-4o")
+      expect(messages).toEqual(expect.arrayContaining([
+        expect.objectContaining({ role: "assistant", content: "\u5f53\u524d\u4f1a\u8bdd\u6458\u8981" }),
+        expect.objectContaining({ role: "user", content: "已有问题" }),
+        expect.objectContaining({ role: "assistant", content: "已有回答" }),
+        expect.objectContaining({ role: "user", content: expect.stringContaining(label) }),
+      ]))
+      return { toolCalls: [], roundsUsed: 1, finalText: "完成" }
+    })
+    setOutlineConversations([{
+      ...conversation([
+        { id: "old-user", role: "user", content: "已有问题" },
+        { id: "old-assistant", role: "assistant", content: "已有回答", nextStepRecommendation: { recommendations: [
+          { id: recId, label, reason: "推荐理由" },
+          { id: "other", label: "另一个建议", reason: "其他理由" },
+        ] } },
+      ]), modelId: "gpt-4o", contextSummary: "当前会话摘要",
+    }], "outline-active", { pendingReferenceTokens: [reference] })
+    const container = await renderOutlineChatPanel()
+    const beforeCount = useOutlineChatStore.getState().conversations.length
+    const button = Array.from(container.querySelectorAll("button")).find((item) => item.textContent?.includes(label)) as HTMLButtonElement
+    expect(container.querySelector("script")).toBeNull()
+    expect(button.textContent).toContain(label)
+    await act(async () => {
+      button.click()
+      for (let attempt = 0; attempt < 50 && button.disabled; attempt += 1) {
+        await new Promise((resolve) => setTimeout(resolve, 5))
+      }
+    })
+    expect(button.disabled).toBe(false)
+    const state = useOutlineChatStore.getState()
+    const current = state.conversations.find((item) => item.id === "outline-active")
+    expect(runSpy).toHaveBeenCalled()
+    expect(state.conversations).toHaveLength(beforeCount)
+    expect(state.activeConversationId).toBe("outline-active")
+    expect(current?.messages).toContainEqual(expect.objectContaining({ role: "user", content: label, attachedReferences: [reference] }))
+
+  })
+
+
+  it("A recommendation completion does not clear references added after switching to B", async () => {
+    const referenceA = { id: "ref-a", category: "outline" as const, title: "A reference", displayTitle: "A reference", path: "outline/a.md" }
+    const referenceB = { id: "ref-b", category: "outline" as const, title: "B new reference", displayTitle: "B new reference", path: "outline/b.md" }
+    let releaseA!: () => void
+    const pendingA = new Promise<void>((resolve) => { releaseA = resolve })
+    const runSpy = vi.spyOn(AgentRunner.prototype, "run").mockImplementation(async (_config, _registry, _messages, callbacks) => {
+      await pendingA
+      callbacks.onText("A done")
+      callbacks.onDone()
+      return { toolCalls: [], roundsUsed: 1, finalText: "A done" }
+    })
+    const nextStep = { recommendations: [{ id: "next", label: "Continue A", reason: "next" }] }
+    setOutlineConversations([
+      { id: "conversation-a", title: "A", createdAt: 1, updatedAt: 1, modelId: "gpt-4o", messages: [{ id: "a-assistant", role: "assistant", content: "A answer", nextStepRecommendation: nextStep }] },
+      { id: "conversation-b", title: "B", createdAt: 2, updatedAt: 2, modelId: "gpt-4o", messages: [{ id: "b-assistant", role: "assistant", content: "B answer" }] },
+    ], "conversation-a", { pendingReferenceTokens: [referenceA] })
+    const container = await renderOutlineChatPanel()
+    const sendA = Array.from(container.querySelectorAll("button")).find((button) => button.textContent?.includes("Continue A")) as HTMLButtonElement
+
+    await act(async () => {
+      sendA.click()
+      for (let attempt = 0; attempt < 20 && runSpy.mock.calls.length === 0; attempt += 1) {
+        await new Promise((resolve) => setTimeout(resolve, 5))
+      }
+    })
+    expect(runSpy).toHaveBeenCalledTimes(1)
+
+    await act(async () => {
+      useOutlineChatStore.getState().setActiveConversation("conversation-b")
+      useOutlineChatStore.getState().enqueueReferenceTokens([referenceB])
+      await new Promise((resolve) => setTimeout(resolve, 0))
+    })
+    expect(container.textContent).toContain("B new reference")
+
+    await act(async () => {
+      releaseA()
+      await new Promise((resolve) => setTimeout(resolve, 20))
+    })
+
+    expect(useOutlineChatStore.getState().activeConversationId).toBe("conversation-b")
+    expect(container.textContent).toContain("B new reference")
+  })
+
+  it("当前会话运行或已达到全局 3 并发上限时禁用下一步按钮并显示与输入区一致的中文原因", async () => {
+    const recommendationMessage = { id: "assistant-next", role: "assistant" as const, content: "已有回答", nextStepRecommendation: { recommendations: [{ id: "A", label: "继续完善", reason: "推荐" }] } }
+    setOutlineConversations([conversation([recommendationMessage])], "outline-active", { runStates: { "outline-active": { status: "running", updatedAt: 1, runId: "active-run" } } })
+    const container = await renderOutlineChatPanel()
+    let button = Array.from(container.querySelectorAll("button")).find((item) => item.textContent?.includes("继续完善")) as HTMLButtonElement
+    expect(button.disabled).toBe(true)
+    expect(button.title).toBe("当前会话正在生成,请等待生成完成后再选择下一步。")
+    await act(async () => setOutlineConversations([conversation([recommendationMessage])], "outline-active", { runStates: { one: { status: "running", updatedAt: 1, runId: "1" }, two: { status: "running", updatedAt: 2, runId: "2" }, three: { status: "running", updatedAt: 3, runId: "3" } } }))
+    button = Array.from(container.querySelectorAll("button")).find((item) => item.textContent?.includes("继续完善")) as HTMLButtonElement
+    expect(button.disabled).toBe(true)
+    expect(button.title).toBe("大纲 AI 会话最多同时运行 3 个任务,请等待任一任务结束后再发送。")
+  })
+
+  it("下一步发送失败时保留引用、恢复按钮并显示中文非阻塞提示", async () => {
+    const reference = { id: "ref-fail", category: "outline" as const, title: "失败引用", displayTitle: "失败引用", path: "大纲/失败.md" }
+    vi.spyOn(AgentRunner.prototype, "run").mockRejectedValue(new Error("网络中断"))
+    const toastSpy = vi.spyOn(toast, "info")
+    setOutlineConversations([conversation([{ id: "assistant-next", role: "assistant", content: "已有回答", nextStepRecommendation: { recommendations: [{ id: "A", label: "继续完善", reason: "推荐" }] } }])], "outline-active", { pendingReferenceTokens: [reference] })
+    const container = await renderOutlineChatPanel()
+    const button = Array.from(container.querySelectorAll("button")).find((item) => item.textContent?.includes("继续完善")) as HTMLButtonElement
+    await act(async () => { button.click(); await new Promise((resolve) => setTimeout(resolve, 0)) })
+    expect(button.disabled).toBe(false)
+    expect(container.textContent).toContain("失败引用")
+    expect(toastSpy).toHaveBeenCalledWith("发送失败,推荐操作已恢复,请稍后重试。", expect.objectContaining({ dedupeKey: expect.any(String) }))
+  })
+
+  it("停止大纲生成时保留部分内容并清理运行状态", async () => {
+    const controller = new AbortController()
+    outlineConversationRunRegistry.register("outline-active", controller)
+    setOutlineConversations([conversation([{
+      id: "assistant-running",
+      role: "assistant",
+      content: "",
+      isAgentRunning: true,
+    }])], "outline-active", {
+      streamingContents: { "outline-active": "部分大纲" },
+      runStates: {
+        "outline-active": { status: "running", updatedAt: 200, runId: "outline-run" },
+      },
+    })
+    const container = await renderOutlineChatPanel()
+
+    const statusIcon = container.querySelector('[aria-label="正在生成"]')
+    expect(statusIcon).not.toBeNull()
+    expect(statusIcon?.querySelector("svg")).not.toBeNull()
+    expect(statusIcon?.hasAttribute("data-slot")).toBe(false)
+    expect(container.textContent).not.toContain("\u6b63\u5728\u751f\u6210...")
+    expect(container.querySelector(".animate-pulse.rounded-md.border.bg-sky-50")).toBeNull()
+
+    const stopButton = container.querySelector<HTMLButtonElement>('[aria-label="停止生成"]')
+    expect(stopButton).not.toBeNull()
+    await act(async () => {
+      stopButton?.click()
+    })
+
+    const state = useOutlineChatStore.getState()
+    const stoppedConversation = state.conversations.find((item) => item.id === "outline-active")
+    expect(controller.signal.aborted).toBe(true)
+    expect(state.runStates["outline-active"]?.status).toBe("idle")
+    expect(state.streamingContents["outline-active"]).toBeUndefined()
+    expect(stoppedConversation?.messages).toEqual([expect.objectContaining({
+      id: "assistant-running",
+      role: "assistant",
+      content: "部分大纲",
+      isAgentRunning: false,
+    })])
+  })
+
+  it("停止无部分内容的大纲生成时删除助手占位消息", async () => {
+    const controller = new AbortController()
+    outlineConversationRunRegistry.register("outline-active", controller)
+    setOutlineConversations([conversation([{
+      id: "assistant-running-empty",
+      role: "assistant",
+      content: "",
+      isAgentRunning: true,
+    }])], "outline-active", {
+      runStates: {
+        "outline-active": { status: "running", updatedAt: 200, runId: "outline-run-empty" },
+      },
+    })
+    const container = await renderOutlineChatPanel()
+
+    const stopButton = container.querySelector<HTMLButtonElement>('[aria-label="停止生成"]')
+    expect(stopButton).not.toBeNull()
+    await act(async () => {
+      stopButton?.click()
+    })
+
+    const state = useOutlineChatStore.getState()
+    const stoppedConversation = state.conversations.find((item) => item.id === "outline-active")
+    expect(controller.signal.aborted).toBe(true)
+    expect(state.runStates["outline-active"]?.status).toBe("idle")
+    expect(state.streamingContents["outline-active"]).toBeUndefined()
+    expect(stoppedConversation?.messages).toEqual([])
+  })
+
   it("根据当前大纲会话的已发送用户消息实时控制新建按钮", async () => {
   it("根据当前大纲会话的已发送用户消息实时控制新建按钮", async () => {
     const container = await renderOutlineChatPanel()
     const container = await renderOutlineChatPanel()
     let button = getNewConversationButton(container)
     let button = getNewConversationButton(container)
@@ -255,10 +474,10 @@ describe("OutlineChatPanel controls", () => {
 
 
   it("提供 AI 大纲上下文状态、强制刷新和预算面板", () => {
   it("提供 AI 大纲上下文状态、强制刷新和预算面板", () => {
     // 已删除上下文状态条,不再展示 "上下文状态" 和 "强制刷新上下文"
     // 已删除上下文状态条,不再展示 "上下文状态" 和 "强制刷新上下文"
-    // 改为在输入框之上展示 "正在生成..." 状态提示
+    // 输入区不再渲染独立的可见生成提示长条。
     expect(source).not.toContain("上下文状态")
     expect(source).not.toContain("上下文状态")
     expect(source).not.toContain("强制刷新上下文")
     expect(source).not.toContain("强制刷新上下文")
-    expect(source).toContain("正在生成...")
+    expect(source).not.toContain("正在生成...")
     expect(source).toContain("isStreaming")
     expect(source).toContain("isStreaming")
   })
   })
 
 
@@ -436,4 +655,452 @@ describe("OutlineChatPanel controls", () => {
     expect(source).toContain("buildFallbackCharacterDraftsFromRequests")
     expect(source).toContain("buildFallbackCharacterDraftsFromRequests")
     expect(source).toContain("无法自动拆分角色")
     expect(source).toContain("无法自动拆分角色")
   })
   })
+
+
+  it("AI 大纲系统提示实际包含简短 Markdown 约束", () => {
+    const prompt = buildOutlineAgentSystemPrompt({ projectName: "测试项目" })
+
+    expect(prompt).toContain("Markdown 格式约束:结构化资料使用一级标题")
+    expect(prompt).toContain("不要用代码围栏包裹全文")
+  })
+
+
+  async function chooseOutlineModel(container: HTMLElement, label: string) {
+    const trigger = container.querySelector<HTMLButtonElement>(".h-8.w-32") ?? undefined
+    expect(trigger).toBeDefined()
+    await act(async () => {
+      trigger?.click()
+      await new Promise((resolve) => setTimeout(resolve, 20))
+    })
+    const option = Array.from(document.body.querySelectorAll("button")).find((button) =>
+      button !== trigger && button.textContent?.includes(label),
+    ) as HTMLButtonElement | undefined
+    expect(option).toBeDefined()
+    await act(async () => {
+      option?.click()
+      await Promise.resolve()
+    })
+  }
+
+  it("restores the global outline model after remounting and switching projects", async () => {
+    useWikiStore.setState({
+      aiOutlineModel: "openai/gpt-4.1",
+      providerConfigs: {
+        openai: {
+          apiKey: "test-key",
+          enabled: true,
+          savedModels: [
+            { id: "gpt-4o", model: "gpt-4o", name: "GPT-4o", createdAt: 1 },
+            { id: "gpt-4.1", model: "gpt-4.1", name: "GPT-4.1", createdAt: 2 },
+          ],
+        },
+      },
+    })
+    setOutlineConversations([{ ...conversation(), modelId: "openai/gpt-4o" }], "outline-active")
+
+    const firstContainer = await renderOutlineChatPanel()
+    expect(firstContainer.textContent).toContain("GPT-4.1")
+
+    const firstMounted = mountedRoots.pop()
+    expect(firstMounted).toBeDefined()
+    await act(async () => firstMounted?.root.unmount())
+    firstMounted?.container.remove()
+
+    useWikiStore.setState({ project: { id: "project-2", name: "Project 2", path: "C:/Book-2" } })
+    setOutlineConversations([{ ...conversation(), id: "project-2-conversation", modelId: "openai/gpt-4o" }], "project-2-conversation")
+    const secondContainer = await renderOutlineChatPanel()
+
+    expect(secondContainer.textContent).toContain("GPT-4.1")
+    expect(useWikiStore.getState().aiOutlineModel).toBe("openai/gpt-4.1")
+  })
+
+  it("saves a stable outline model id immediately without changing the AI chat model", async () => {
+    useWikiStore.setState({
+      aiChatModel: "openai/gpt-4o",
+      aiOutlineModel: "openai/gpt-4o",
+      providerConfigs: {
+        openai: {
+          apiKey: "test-key",
+          enabled: true,
+          savedModels: [
+            { id: "gpt-4o", model: "gpt-4o", name: "GPT-4o", createdAt: 1 },
+            { id: "gpt-4.1", model: "gpt-4.1", name: "GPT-4.1", createdAt: 2 },
+          ],
+        },
+      },
+    })
+    setOutlineConversations([conversation()], "outline-active")
+    const container = await renderOutlineChatPanel()
+
+    await chooseOutlineModel(container, "GPT-4.1")
+
+    expect(useWikiStore.getState().aiOutlineModel).toBe("openai/gpt-4.1")
+    expect(useWikiStore.getState().aiChatModel).toBe("openai/gpt-4o")
+    expect(useOutlineChatStore.getState().conversations[0].modelId).toBe("openai/gpt-4.1")
+    expect(outlineModelPreferenceMocks.saveAiOutlineModel).toHaveBeenCalledWith("openai/gpt-4.1")
+  })
+
+  it("keeps the selected outline model usable when persistence fails", async () => {
+    outlineModelPreferenceMocks.saveAiOutlineModel.mockRejectedValueOnce(new Error("disk failed"))
+    const toastSpy = vi.spyOn(toast, "info").mockImplementation(() => {})
+    useWikiStore.setState({
+      aiOutlineModel: "openai/gpt-4o",
+      providerConfigs: {
+        openai: {
+          apiKey: "test-key",
+          enabled: true,
+          savedModels: [
+            { id: "gpt-4o", model: "gpt-4o", name: "GPT-4o", createdAt: 1 },
+            { id: "gpt-4.1", model: "gpt-4.1", name: "GPT-4.1", createdAt: 2 },
+          ],
+        },
+      },
+    })
+    setOutlineConversations([conversation()], "outline-active")
+    const container = await renderOutlineChatPanel()
+
+    await chooseOutlineModel(container, "GPT-4.1")
+    await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)) })
+
+    expect(useWikiStore.getState().aiOutlineModel).toBe("openai/gpt-4.1")
+    expect(toastSpy).toHaveBeenCalledWith(
+      "\u0041\u0049 \u5927\u7eb2\u6a21\u578b\u4fdd\u5b58\u5931\u8d25\uff0c\u672c\u6b21\u9009\u62e9\u4ecd\u53ef\u7ee7\u7eed\u4f7f\u7528\u3002",
+      expect.objectContaining({ dedupeKey: "outline-model-save-failed" }),
+    )
+  })
+
+  it("falls back when the persisted outline model or provider is unavailable", async () => {
+    const toastSpy = vi.spyOn(toast, "info").mockImplementation(() => {})
+    useWikiStore.setState({
+      aiChatModel: "openai/gpt-4o",
+      aiOutlineModel: "removed/missing-model",
+      providerConfigs: {
+        openai: {
+          apiKey: "test-key",
+          enabled: true,
+          savedModels: [{ id: "gpt-4o", model: "gpt-4o", name: "GPT-4o", createdAt: 1 }],
+        },
+      },
+    })
+    setOutlineConversations([{ ...conversation(), modelId: "removed/old-model" }], "outline-active")
+
+    const container = await renderOutlineChatPanel()
+    await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)) })
+
+    expect(container.textContent).toContain("GPT-4o")
+    expect(useWikiStore.getState().aiOutlineModel).toBe("openai/gpt-4o")
+    expect(useWikiStore.getState().aiChatModel).toBe("openai/gpt-4o")
+    expect(outlineModelPreferenceMocks.saveAiOutlineModel).toHaveBeenCalledWith("openai/gpt-4o")
+    expect(toastSpy).toHaveBeenCalledWith(
+      "\u539f \u0041\u0049 \u5927\u7eb2\u6a21\u578b\u5df2\u4e0d\u53ef\u7528\uff0c\u5df2\u56de\u9000\u5230\u5f53\u524d\u9ed8\u8ba4\u6a21\u578b\u3002",
+      expect.objectContaining({ dedupeKey: "outline-model-fallback" }),
+    )
+  })
+
+  it("migrates a legacy plain model id to a stable key without an unavailable warning", async () => {
+    const toastSpy = vi.spyOn(toast, "info").mockImplementation(() => {})
+    useWikiStore.setState({
+      aiChatModel: "openai/gpt-4o",
+      aiOutlineModel: "gpt-4o",
+      providerConfigs: {
+        openai: {
+          apiKey: "test-key",
+          enabled: true,
+          savedModels: [{ id: "gpt-4o", model: "gpt-4o", name: "GPT-4o", createdAt: 1 }],
+        },
+      },
+    })
+    setOutlineConversations([conversation()], "outline-active")
+
+    const container = await renderOutlineChatPanel()
+    await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)) })
+
+    expect(container.textContent).toContain("GPT-4o")
+    expect(useWikiStore.getState().aiOutlineModel).toBe("openai/gpt-4o")
+    expect(outlineModelPreferenceMocks.saveAiOutlineModel).toHaveBeenCalledWith("openai/gpt-4o")
+    expect(toastSpy).not.toHaveBeenCalledWith(
+      "\u539f \u0041\u0049 \u5927\u7eb2\u6a21\u578b\u5df2\u4e0d\u53ef\u7528\uff0c\u5df2\u56de\u9000\u5230\u5f53\u524d\u9ed8\u8ba4\u6a21\u578b\u3002",
+      expect.anything(),
+    )
+  })
+
+  it("falls back when the selected outline provider is disabled", async () => {
+    const toastSpy = vi.spyOn(toast, "info").mockImplementation(() => {})
+    useWikiStore.setState({
+      aiChatModel: "openai/gpt-4o",
+      aiOutlineModel: "openai/gpt-4o",
+      defaultLlmModel: "anthropic/claude-sonnet",
+      novelConfig: { ...useWikiStore.getState().novelConfig, defaultLlmModel: "anthropic/claude-sonnet" },
+      providerConfigs: {
+        openai: {
+          apiKey: "old-key",
+          enabled: false,
+          savedModels: [{ id: "gpt-4o", model: "gpt-4o", name: "GPT-4o", createdAt: 1 }],
+        },
+        anthropic: {
+          apiKey: "test-key",
+          enabled: true,
+          savedModels: [{ id: "claude-sonnet", model: "claude-sonnet", name: "Claude Sonnet", createdAt: 2 }],
+        },
+      },
+    })
+    setOutlineConversations([{ ...conversation(), modelId: "openai/gpt-4o" }], "outline-active")
+
+    const container = await renderOutlineChatPanel()
+    await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)) })
+
+    expect(container.textContent).toContain("Claude Sonnet")
+    expect(useWikiStore.getState().aiOutlineModel).toBe("anthropic/claude-sonnet")
+    expect(outlineModelPreferenceMocks.saveAiOutlineModel).toHaveBeenCalledWith("anthropic/claude-sonnet")
+    expect(toastSpy).toHaveBeenCalledWith(
+      "\u539f \u0041\u0049 \u5927\u7eb2\u6a21\u578b\u5df2\u4e0d\u53ef\u7528\uff0c\u5df2\u56de\u9000\u5230\u5f53\u524d\u9ed8\u8ba4\u6a21\u578b\u3002",
+      expect.objectContaining({ dedupeKey: "outline-model-fallback" }),
+    )
+  })
+
+
+  it("\u591a Agent \u5168\u90e8\u5931\u8d25\u540e\u6cbf\u7528\u540c\u4e00\u9700\u6c42\u5305\u3001\u6a21\u578b\u3001\u4f1a\u8bdd\u548c\u5f15\u7528\u56de\u9000\uff0c\u4e14\u4e0d\u963b\u65ad\u4fdd\u5b58\u4e0e\u4e0b\u4e00\u6b65", async () => {
+    const reference = {
+      id: "fallback-reference",
+      category: "outline" as const,
+      title: "\u65e2\u6709\u4e16\u754c\u89c2",
+      displayTitle: "\u65e2\u6709\u4e16\u754c\u89c2",
+      path: "\u5927\u7eb2/\u4e16\u754c\u89c2.md",
+    }
+    const inspirationText = "\u57fa\u4e8e\u73b0\u6709\u4e16\u754c\u89c2\u751f\u6210\u4e00\u4efd\u65b0\u7684\u6545\u4e8b\u603b\u7eb2"
+    const fallbackText = [
+      "# \u6545\u4e8b\u603b\u7eb2",
+      "",
+      "## \u6838\u5fc3\u8bbe\u5b9a",
+      "\u6cbf\u7528\u65e2\u6709\u4e16\u754c\u89c2\u5b8c\u6210\u666e\u901a\u751f\u6210\u7ed3\u679c\u3002",
+      "",
+      "<!-- next_step -->",
+      JSON.stringify({
+        completedModule: "\u6545\u4e8b\u603b\u7eb2",
+        completedScope: "\u6838\u5fc3\u8bbe\u5b9a",
+        recommendations: [
+          { id: "A", label: "\u7ee7\u7eed\u5b8c\u5584\u4eba\u7269\u5173\u7cfb", reason: "\u8865\u9f50\u4eba\u7269\u51b2\u7a81\u3002" },
+          { id: "D", label: "\u81ea\u5b9a\u4e49", reason: "\u81ea\u884c\u8bf4\u660e\u4e0b\u4e00\u6b65\u3002" },
+        ],
+      }),
+      "<!-- /next_step -->",
+    ].join("\n")
+    const fallbackCalls: Array<{ modelId: string; messages: Array<{ role: string; content: string }> }> = []
+    vi.spyOn(AgentRunner.prototype, "run").mockImplementation(async (config, _registry, messages, callbacks) => {
+      const system = messages.find((message) => message.role === "system")?.content ?? ""
+      if (system.includes("\u53ea\u8d1f\u8d23\u89c4\u5212\u5927\u7eb2\u5b50 Agent \u4efb\u52a1\u56fe")) {
+        return { toolCalls: [], roundsUsed: 1, finalText: "{}" }
+      }
+      if (system.includes("\u5b50 Agent \u8fd0\u884c\u89c4\u5219")) {
+        throw new Error("\u4e0a\u6e38 Agent \u670d\u52a1\u4e0d\u53ef\u7528")
+      }
+      fallbackCalls.push({ modelId: config.modelId, messages })
+      callbacks.onText(fallbackText)
+      callbacks.onDone()
+      return { toolCalls: [], roundsUsed: 1, finalText: fallbackText }
+    })
+    setOutlineConversations([{ ...conversation(), modelId: "openai/gpt-4o" }], "outline-active", {
+      pendingReferenceTokens: [reference],
+    })
+    const container = await renderOutlineChatPanel()
+    const wizardTrigger = Array.from(container.querySelectorAll<HTMLButtonElement>("button"))
+      .find((button) => button.textContent?.includes("\u9009\u62e9\u751f\u6210\u4f60\u60f3\u8981\u7684\u5c0f\u8bf4"))
+    expect(wizardTrigger).toBeDefined()
+
+    await act(async () => wizardTrigger?.click())
+    const inspiration = document.querySelector<HTMLTextAreaElement>("#outline-wizard-inspiration")
+    expect(inspiration).not.toBeNull()
+    await act(async () => {
+      if (!inspiration) return
+      const setValue = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set
+      setValue?.call(inspiration, inspirationText)
+      inspiration.dispatchEvent(new Event("input", { bubbles: true }))
+    })
+    const submit = Array.from(document.querySelectorAll<HTMLButtonElement>("button"))
+      .find((button) => button.textContent?.includes("\u786e\u5b9a\u751f\u6210"))
+    expect(submit).toBeDefined()
+    await act(async () => {
+      submit?.click()
+      for (let attempt = 0; attempt < 300; attempt += 1) {
+        const run = useOutlineChatStore.getState().runStates["outline-active"]
+        if (fallbackCalls.length > 0 && run?.status !== "running") break
+        await new Promise((resolve) => setTimeout(resolve, 5))
+      }
+    })
+
+    const state = useOutlineChatStore.getState()
+    const current = state.conversations.find((item) => item.id === "outline-active")
+    const userMessages = current?.messages.filter((message) => message.role === "user") ?? []
+    const assistant = current?.messages.findLast((message) => message.role === "assistant")
+    expect(fallbackCalls).toHaveLength(1)
+    expect(fallbackCalls[0].modelId).toBe("openai/gpt-4o")
+    expect(fallbackCalls[0].messages.some((message) => message.role === "user" && message.content.includes(inspirationText))).toBe(true)
+    expect(fallbackCalls[0].messages.some((message) => message.role === "user" && message.content.includes("\u65e2\u6709\u4e16\u754c\u89c2"))).toBe(true)
+    expect(state.activeConversationId).toBe("outline-active")
+    expect(current?.modelId).toBe("openai/gpt-4o")
+    expect(userMessages).toHaveLength(1)
+    expect(userMessages[0].attachedReferences).toEqual([reference])
+    expect(userMessages[0].novelGenerationRequest?.modelContent).toContain(inspirationText)
+    expect(assistant?.multiAgentRun?.mode).toBe("single-agent-fallback")
+    expect(assistant?.content).toContain("\u6cbf\u7528\u65e2\u6709\u4e16\u754c\u89c2\u5b8c\u6210\u666e\u901a\u751f\u6210\u7ed3\u679c\u3002")
+    expect(container.textContent).toContain("\u591a Agent \u751f\u6210\u5931\u8d25\uff0c\u5df2\u81ea\u52a8\u5207\u6362\u4e3a\u666e\u901a\u751f\u6210\u3002")
+    const saveButton = Array.from(container.querySelectorAll<HTMLButtonElement>("button"))
+      .find((button) => button.textContent?.includes("\u4fdd\u5b58\u4e3a\u5927\u7eb2"))
+    const nextButton = Array.from(container.querySelectorAll<HTMLButtonElement>("button"))
+      .find((button) => button.textContent?.includes("\u7ee7\u7eed\u5b8c\u5584\u4eba\u7269\u5173\u7cfb"))
+    expect(saveButton?.disabled).toBe(false)
+    expect(nextButton?.disabled).toBe(false)
+  })
+  it("\u81f3\u5c11\u4e00\u4e2a\u5b50 Agent \u6210\u529f\u4f46\u5408\u5e76\u629b\u9519\u65f6\uff0c\u56de\u9000\u4fdd\u7559 merge error \u72b6\u6001\u4e0e\u539f\u56e0", async () => {
+    const fallbackText = "# \u56de\u9000\u5927\u7eb2\n\n## \u7ed3\u679c\n\u5408\u5e76\u5931\u8d25\u540e\u7684\u666e\u901a\u751f\u6210\u7ed3\u679c\u3002"
+    let subAgentCallCount = 0
+    let fallbackCallCount = 0
+    vi.spyOn(AgentRunner.prototype, "run").mockImplementation(async (_config, _registry, messages, callbacks) => {
+      const system = messages.find((message) => message.role === "system")?.content ?? ""
+      if (system.includes("\u53ea\u8d1f\u8d23\u89c4\u5212\u5927\u7eb2\u5b50 Agent \u4efb\u52a1\u56fe")) {
+        return { toolCalls: [], roundsUsed: 1, finalText: "{}" }
+      }
+      if (system.includes("\u5b50 Agent \u8fd0\u884c\u89c4\u5219")) {
+        subAgentCallCount += 1
+        if (subAgentCallCount === 1) {
+          return {
+            toolCalls: [],
+            roundsUsed: 1,
+            finalText: JSON.stringify({
+              agent_id: "outline-agent",
+              agent_name: "\u5927\u7eb2 Agent",
+              stage: "outline",
+              used_skills: ["outline-master-builder"],
+              confidence: 0.8,
+              summary: "\u5b50 Agent \u5df2\u6210\u529f",
+              content_markdown: "## \u5b50 Agent \u7ed3\u679c",
+              constraints: [],
+              writeback_items: [],
+              risks: [],
+              questions: [],
+            }),
+          }
+        }
+        throw new Error("\u5176\u4ed6\u5b50 Agent \u5931\u8d25")
+      }
+      if (system.includes("\u5408\u5e76 Agent \u8fd0\u884c\u89c4\u5219")) {
+        throw new Error("\u5408\u5e76\u670d\u52a1\u5931\u8d25\nMERGE_FLOW_SECRET_BODY")
+      }
+      fallbackCallCount += 1
+      callbacks.onText(fallbackText)
+      callbacks.onDone()
+      return { toolCalls: [], roundsUsed: 1, finalText: fallbackText }
+    })
+    setOutlineConversations([{ ...conversation(), modelId: "openai/gpt-4o" }], "outline-active")
+    const container = await renderOutlineChatPanel()
+    const wizardTrigger = Array.from(container.querySelectorAll<HTMLButtonElement>("button"))
+      .find((button) => button.textContent?.includes("\u9009\u62e9\u751f\u6210\u4f60\u60f3\u8981\u7684\u5c0f\u8bf4"))
+    await act(async () => wizardTrigger?.click())
+    const inspiration = document.querySelector<HTMLTextAreaElement>("#outline-wizard-inspiration")
+    await act(async () => {
+      const setValue = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set
+      if (inspiration) setValue?.call(inspiration, "\u89e6\u53d1\u5408\u5e76\u5931\u8d25\u56de\u9000")
+      inspiration?.dispatchEvent(new Event("input", { bubbles: true }))
+    })
+    const submit = Array.from(document.querySelectorAll<HTMLButtonElement>("button"))
+      .find((button) => button.textContent?.includes("\u786e\u5b9a\u751f\u6210"))
+    await act(async () => {
+      submit?.click()
+      for (let attempt = 0; attempt < 300; attempt += 1) {
+        const run = useOutlineChatStore.getState().runStates["outline-active"]
+        if (fallbackCallCount > 0 && run?.status !== "running") break
+        await new Promise((resolve) => setTimeout(resolve, 5))
+      }
+    })
+
+    const current = useOutlineChatStore.getState().conversations.find((item) => item.id === "outline-active")
+    const assistant = current?.messages.findLast((message) => message.role === "assistant")
+    expect(subAgentCallCount).toBeGreaterThan(1)
+    expect(fallbackCallCount).toBe(1)
+    expect(assistant?.multiAgentRun?.mode).toBe("single-agent-fallback")
+    expect(assistant?.multiAgentRun?.merge?.status).toBe("error")
+    expect(assistant?.multiAgentRun?.merge?.error).toContain("\u5408\u5e76\u670d\u52a1\u5931\u8d25")
+    expect(assistant?.multiAgentRun?.agents.some((agent) => agent.status === "done")).toBe(true)
+    expect(current?.messages.filter((message) => message.role === "user")).toHaveLength(1)
+    const detailsButton = Array.from(container.querySelectorAll<HTMLButtonElement>("button"))
+      .find((button) => button.textContent?.includes("\u67e5\u770b\u8be6\u60c5"))
+    await act(async () => detailsButton?.click())
+    expect(container.textContent).toContain("\u5408\u5e76\u670d\u52a1\u5931\u8d25")
+    expect(container.textContent).not.toContain("MERGE_FLOW_SECRET_BODY")
+  })
+
+  it("regeneration finalizes whole-document Markdown fences for a structured original request", async () => {
+    vi.spyOn(AgentRunner.prototype, "run").mockImplementation(async (_config, _registry, _messages, callbacks) => {
+      const text = "```markdown\n# \u4eba\u7269\u8bbe\u5b9a\n\n## \u4e3b\u89d2\n\u6210\u957f\u5f27\u5149\n```"
+      callbacks.onText(text)
+      callbacks.onDone()
+      return { toolCalls: [], roundsUsed: 1, finalText: text }
+    })
+    setOutlineConversations([conversation([
+      { id: "u1", role: "user", content: "\u751f\u6210\u4eba\u7269\u8bbe\u5b9a", novelGenerationRequest: { version: 1, summary: "\u751f\u6210\u4eba\u7269\u8bbe\u5b9a", details: [], modelContent: "\u8bf7\u751f\u6210\u4eba\u7269\u8bbe\u5b9a" } },
+      { id: "a1", role: "assistant", content: "# \u65e7\u7ed3\u679c\n\n## \u4e3b\u89d2\n\u65e7\u5185\u5bb9" },
+    ])], "outline-active")
+    const container = await renderOutlineChatPanel()
+    const button = Array.from(container.querySelectorAll<HTMLButtonElement>("button")).find((item) => item.textContent?.includes("\u91cd\u65b0\u751f\u6210"))
+    await act(async () => { button?.click(); for (let i = 0; i < 100 && useOutlineChatStore.getState().runStates["outline-active"]?.status === "running"; i += 1) await new Promise((resolve) => setTimeout(resolve, 5)) })
+    const answer = useOutlineChatStore.getState().conversations[0].messages.at(-1)?.content ?? ""
+    expect(answer).toContain("# \u4eba\u7269\u8bbe\u5b9a")
+    expect(answer).not.toContain("```markdown")
+  })
+
+  it.each(["\u751f\u6210\u4eba\u7269\u8bbe\u5b9a", "\u751f\u6210\u4e16\u754c\u89c2", "\u7ee7\u7eed\u5b8c\u5584\u4eba\u7269\u5173\u7cfb", "\u7ee7\u7eed\u8865\u5145\u4e16\u754c\u89c2", "\u7ec6\u5316\u5f53\u524d\u5927\u7eb2", "\u7ee7\u7eed\u5b8c\u5584\u5f53\u524d\u6a21\u5757"])("structured next step triggers Markdown finalization: %s", async (label) => {
+    vi.spyOn(AgentRunner.prototype, "run").mockImplementation(async (_config, _registry, _messages, callbacks) => {
+      const text = "```markdown\n# \u8bbe\u5b9a\n\n## \u7ed3\u679c\n\u5185\u5bb9\n```"
+      callbacks.onText(text); callbacks.onDone()
+      return { toolCalls: [], roundsUsed: 1, finalText: text }
+    })
+    setOutlineConversations([conversation([{ id: "u0", role: "user", content: "\u751f\u6210\u5927\u7eb2", novelGenerationRequest: { version: 1, summary: "\u751f\u6210\u5927\u7eb2", details: [], modelContent: "\u751f\u6210\u5927\u7eb2" } }, { id: "a1", role: "assistant", content: "# \u5927\u7eb2\n\n## \u7ed3\u679c\n\u5df2\u5b8c\u6210", nextStepRecommendation: { recommendations: [{ id: "A", label, reason: "\u7ee7\u7eed" }] } }])], "outline-active")
+    const container = await renderOutlineChatPanel()
+    const button = Array.from(container.querySelectorAll<HTMLButtonElement>("button")).find((item) => item.textContent?.includes(label))
+    await act(async () => { button?.click(); for (let i = 0; i < 100 && useOutlineChatStore.getState().runStates["outline-active"]?.status === "running"; i += 1) await new Promise((resolve) => setTimeout(resolve, 5)) })
+    expect(useOutlineChatStore.getState().conversations[0].messages.at(-1)?.content).not.toContain("```markdown")
+  })
+
+  it("ordinary Q&A next step does not trigger AI Markdown finalization", async () => {
+    vi.spyOn(AgentRunner.prototype, "run").mockImplementation(async (_config, _registry, _messages, callbacks) => {
+      const text = "```markdown\n# \u666e\u901a\u56de\u7b54\n\n## \u8bf4\u660e\n\u5185\u5bb9\n```"
+      callbacks.onText(text); callbacks.onDone()
+      return { toolCalls: [], roundsUsed: 1, finalText: text }
+    })
+    const label = "\u89e3\u91ca\u4e00\u4e0b\u8fd9\u4e2a\u8bbe\u5b9a"
+    setOutlineConversations([conversation([{ id: "a1", role: "assistant", content: "\u5df2\u56de\u7b54", nextStepRecommendation: { recommendations: [{ id: "A", label, reason: "\u8bf4\u660e" }] } }])], "outline-active")
+    const container = await renderOutlineChatPanel()
+    const button = Array.from(container.querySelectorAll<HTMLButtonElement>("button")).find((item) => item.textContent?.includes(label))
+    await act(async () => { button?.click(); for (let i = 0; i < 100 && useOutlineChatStore.getState().runStates["outline-active"]?.status === "running"; i += 1) await new Promise((resolve) => setTimeout(resolve, 5)) })
+    expect(useOutlineChatStore.getState().conversations[0].messages.at(-1)?.content).toContain("```markdown")
+  })
+
+  it("structured next step forwards references to Agent and clears them after successful send", async () => {
+    const reference = { id: "next-ref", category: "outline" as const, title: "\u4eba\u7269\u8bbe\u5b9a", displayTitle: "\u4eba\u7269\u8bbe\u5b9a", path: "\u5927\u7eb2/\u4eba\u7269.md" }
+    let sentMessages: Array<{ role: string; content: string }> = []
+    vi.spyOn(AgentRunner.prototype, "run").mockImplementation(async (_config, _registry, messages, callbacks) => {
+      sentMessages = messages
+      callbacks.onText("# \u4eba\u7269\u5173\u7cfb\n\n## \u7ed3\u679c\n\u5b8c\u6210"); callbacks.onDone()
+      return { toolCalls: [], roundsUsed: 1, finalText: "# \u4eba\u7269\u5173\u7cfb\n\n## \u7ed3\u679c\n\u5b8c\u6210" }
+    })
+    const label = "\u7ee7\u7eed\u5b8c\u5584\u4eba\u7269\u5173\u7cfb"
+    setOutlineConversations([conversation([{ id: "u0", role: "user", content: "\u751f\u6210\u5927\u7eb2", novelGenerationRequest: { version: 1, summary: "\u751f\u6210\u5927\u7eb2", details: [], modelContent: "\u751f\u6210\u5927\u7eb2" } }, { id: "a1", role: "assistant", content: "# \u5927\u7eb2\n\n## \u7ed3\u679c\n\u5b8c\u6210", nextStepRecommendation: { completedModule: "\u5927\u7eb2", completedScope: "", recommendations: [{ id: "A", label, reason: "\u7ee7\u7eed" }] } }])], "outline-active", { pendingReferenceTokens: [reference] })
+    const container = await renderOutlineChatPanel()
+    const button = Array.from(container.querySelectorAll<HTMLButtonElement>("button")).find((item) => item.textContent?.includes(label))
+    await act(async () => { button?.click(); for (let i = 0; i < 100 && useOutlineChatStore.getState().runStates["outline-active"]?.status === "running"; i += 1) await new Promise((resolve) => setTimeout(resolve, 5)) })
+    expect(sentMessages.some((message) => message.role === "user" && message.content.includes("\u4eba\u7269\u8bbe\u5b9a"))).toBe(true)
+    expect(container.querySelector("[aria-label=\"\u79fb\u9664\u5f15\u7528\u0020\u4eba\u7269\u8bbe\u5b9a\"]")).toBeNull()
+  })
+
+  it("structured next step keeps references when send fails", async () => {
+    const reference = { id: "failed-ref", category: "outline" as const, title: "\u4e16\u754c\u89c2", displayTitle: "\u4e16\u754c\u89c2", path: "\u5927\u7eb2/\u4e16\u754c\u89c2.md" }
+    vi.spyOn(AgentRunner.prototype, "run").mockRejectedValue(new Error("network failed"))
+    const label = "\u7ee7\u7eed\u8865\u5145\u4e16\u754c\u89c2"
+    setOutlineConversations([conversation([{ id: "u0", role: "user", content: "\u751f\u6210\u5927\u7eb2", novelGenerationRequest: { version: 1, summary: "\u751f\u6210\u5927\u7eb2", details: [], modelContent: "\u751f\u6210\u5927\u7eb2" } }, { id: "a1", role: "assistant", content: "# \u5927\u7eb2\n\n## \u7ed3\u679c\n\u5b8c\u6210", nextStepRecommendation: { completedModule: "\u5927\u7eb2", completedScope: "", recommendations: [{ id: "A", label, reason: "\u7ee7\u7eed" }] } }])], "outline-active", { pendingReferenceTokens: [reference] })
+    const container = await renderOutlineChatPanel()
+    const button = Array.from(container.querySelectorAll<HTMLButtonElement>("button")).find((item) => item.textContent?.includes(label))
+    await act(async () => { button?.click(); await new Promise((resolve) => setTimeout(resolve, 20)) })
+    expect(container.querySelector("[aria-label=\"\u79fb\u9664\u5f15\u7528\u0020\u4e16\u754c\u89c2\"]")).not.toBeNull()
+  })
+
 })
 })

+ 283 - 125
src/components/sources/outline-chat-panel.tsx

@@ -21,6 +21,7 @@ import {
   ArrowDown,
   ArrowDown,
 } from "lucide-react";
 } from "lucide-react";
 import { useWikiStore } from "@/stores/wiki-store";
 import { useWikiStore } from "@/stores/wiki-store";
+import { saveAiOutlineModel } from "@/lib/project-store";
 import {
 import {
   useOutlineChatStore,
   useOutlineChatStore,
   type OutlineMultiAgentRunState,
   type OutlineMultiAgentRunState,
@@ -52,6 +53,7 @@ import {
   type OutlineSaveConfirmPayload,
   type OutlineSaveConfirmPayload,
 } from "@/components/sources/outline-save-confirm-dialog";
 } from "@/components/sources/outline-save-confirm-dialog";
 import { OutlineWizardDialog } from "@/components/sources/outline-wizard-dialog";
 import { OutlineWizardDialog } from "@/components/sources/outline-wizard-dialog";
+import { NovelGenerationRequestMessage } from "@/components/sources/novel-generation-request-message";
 import { OutlineMultiAgentPanel } from "@/components/sources/outline-multi-agent-panel";
 import { OutlineMultiAgentPanel } from "@/components/sources/outline-multi-agent-panel";
 import { TooltipProvider } from "@/components/ui/tooltip";
 import { TooltipProvider } from "@/components/ui/tooltip";
 import { Button } from "@/components/ui/button";
 import { Button } from "@/components/ui/button";
@@ -69,6 +71,14 @@ import {
   getOutlineWizardSkillNames,
   getOutlineWizardSkillNames,
   type OutlineWizardRequest,
   type OutlineWizardRequest,
 } from "@/lib/novel/outline-wizard";
 } from "@/lib/novel/outline-wizard";
+import {
+  createNovelGenerationRequestPackage,
+  mapOutlineMessagesForModel,
+  buildOutlineRegenerationInput,
+  isExplicitStructuredGenerationFollowUp,
+  mapOutlineConversationsForModel,
+  type NovelGenerationRequestPackage,
+} from "@/lib/novel/novel-generation-request-package";
 import {
 import {
   type OutlineSubAgentPlan,
   type OutlineSubAgentPlan,
   planOutlineSubAgents,
   planOutlineSubAgents,
@@ -103,6 +113,7 @@ import {
 import {
 import {
   resolveModelConfig,
   resolveModelConfig,
   resolveNovelModel,
   resolveNovelModel,
+  resolveUsableModelKey,
 } from "@/lib/novel/model-resolver";
 } from "@/lib/novel/model-resolver";
 import { ChatModelSelector } from "@/components/chat/chat-model-selector";
 import { ChatModelSelector } from "@/components/chat/chat-model-selector";
 import { useStreamingText } from "@/hooks/use-streaming-text";
 import { useStreamingText } from "@/hooks/use-streaming-text";
@@ -182,6 +193,8 @@ import {
 } from "@/lib/conversation-create-guard";
 } from "@/lib/conversation-create-guard";
 import { outlineConversationRunRegistry } from "@/lib/conversation-run-registry";
 import { outlineConversationRunRegistry } from "@/lib/conversation-run-registry";
 import { toast } from "@/lib/toast";
 import { toast } from "@/lib/toast";
+import { finalizeStructuredMarkdownMessage } from "@/lib/novel/markdown-quality-finalizer";
+import { repairMarkdownFormatWithAi } from "@/lib/novel/markdown-quality-ai-repair";
 import {
 import {
   type OutlineWorkflowStage,
   type OutlineWorkflowStage,
   canTransitionOutlineWorkflow,
   canTransitionOutlineWorkflow,
@@ -190,8 +203,11 @@ import {
   canApplyOutlineRunEffect,
   canApplyOutlineRunEffect,
   setOutlineSessionValue,
   setOutlineSessionValue,
   shouldClearOutlineDraft,
   shouldClearOutlineDraft,
+  shouldClearOutlineReferences,
 } from "@/lib/novel/outline-chat-session-state";
 } from "@/lib/novel/outline-chat-session-state";
 
 
+type OutlineSendResult = { started: boolean; sent: boolean };
+
 const OUTLINE_CHAT_DISABLED_TOOLS = ["write_chapter", "write_memory"];
 const OUTLINE_CHAT_DISABLED_TOOLS = ["write_chapter", "write_memory"];
 const OUTLINE_CHAT_WIZARD_DISABLED_TOOLS = [
 const OUTLINE_CHAT_WIZARD_DISABLED_TOOLS = [
   ...OUTLINE_CHAT_DISABLED_TOOLS,
   ...OUTLINE_CHAT_DISABLED_TOOLS,
@@ -296,7 +312,7 @@ function buildOutlineAgentUserContent(
   ].join("\n");
   ].join("\n");
 }
 }
 
 
-function buildOutlineAgentSystemPrompt(options: {
+export function buildOutlineAgentSystemPrompt(options: {
   projectName?: string;
   projectName?: string;
   webResearchContext?: string;
   webResearchContext?: string;
   soulDoc?: string;
   soulDoc?: string;
@@ -336,6 +352,7 @@ function buildOutlineAgentSystemPrompt(options: {
     "关键内容提取必须服务于小说创作:只保留能帮助用户继续写小说的信息,例如章节目标、冲突推进、人物动机、伏笔状态、设定限制、时间线承接和结尾钩子。",
     "关键内容提取必须服务于小说创作:只保留能帮助用户继续写小说的信息,例如章节目标、冲突推进、人物动机、伏笔状态、设定限制、时间线承接和结尾钩子。",
     "生成章纲时必须使用章纲标准结构:基础信息、上层依据、本章目标、核心事件、场景顺序、结构节点、章首钩子、爽点设计、章尾钩子、执行约束、人物状态、伏笔与追踪、待写回设定、写作约束、AI写作提示。核心事件不少于6条,场景顺序为2-4个场景。",
     "生成章纲时必须使用章纲标准结构:基础信息、上层依据、本章目标、核心事件、场景顺序、结构节点、章首钩子、爽点设计、章尾钩子、执行约束、人物状态、伏笔与追踪、待写回设定、写作约束、AI写作提示。核心事件不少于6条,场景顺序为2-4个场景。",
     "结构节点必须包含 CBN、CPNs、CEN;CEN 必须能承接下一章 CBN。执行约束必须包含必须覆盖节点和本章禁区。基础信息必须包含时间锚点、章内时间跨度和与上章时间差。",
     "结构节点必须包含 CBN、CPNs、CEN;CEN 必须能承接下一章 CBN。执行约束必须包含必须覆盖节点和本章禁区。基础信息必须包含时间锚点、章内时间跨度和与上章时间差。",
+    "Markdown 格式约束:结构化资料使用一级标题,** 必须成对,不要用代码围栏包裹全文,已有表格必须保留合法分隔行。",
     "## AI 大纲输出协议",
     "## AI 大纲输出协议",
     "当本轮生成了可保存的大纲、卷纲、章纲、人物、设定、伏笔、组织或质量检查内容时,最终回复末尾必须附加一个 json 代码块,顶层字段为 outlineSaveRequest 或 outlineSaveRequests。",
     "当本轮生成了可保存的大纲、卷纲、章纲、人物、设定、伏笔、组织或质量检查内容时,最终回复末尾必须附加一个 json 代码块,顶层字段为 outlineSaveRequest 或 outlineSaveRequests。",
     "保存请求必须包含 targetFolder、fileName、fileType、writeMode、referencedSkills、sourceIntent。fileName 必须是 .md 文件,targetFolder 必须位于大纲文件树文件夹内。",
     "保存请求必须包含 targetFolder、fileName、fileType、writeMode、referencedSkills、sourceIntent。fileName 必须是 .md 文件,targetFolder 必须位于大纲文件树文件夹内。",
@@ -818,8 +835,10 @@ function OutlineAssistantMessage({
   onRegenerate,
   onRegenerate,
   onConfirmToolSave,
   onConfirmToolSave,
   onRejectTool,
   onRejectTool,
-  onGenerateSection,
   onSendMessage,
   onSendMessage,
+  nextStepDisabled,
+  nextStepDisabledReason,
+  generationContext,
   onFocusInput,
   onFocusInput,
 }: {
 }: {
   msg: import("@/stores/outline-chat-store").OutlineChatMessage;
   msg: import("@/stores/outline-chat-store").OutlineChatMessage;
@@ -834,8 +853,10 @@ function OutlineAssistantMessage({
   onRegenerate: (index: number) => Promise<void>;
   onRegenerate: (index: number) => Promise<void>;
   onConfirmToolSave: (call: ToolCallRecord & { preview?: string }) => void;
   onConfirmToolSave: (call: ToolCallRecord & { preview?: string }) => void;
   onRejectTool: (call: ToolCallRecord & { preview?: string }) => void;
   onRejectTool: (call: ToolCallRecord & { preview?: string }) => void;
-  onGenerateSection: (title: string, requestHint: string) => void;
-  onSendMessage: (text: string, options?: { intentPhase?: "intent_analysis" | "generation" | "waiting_user_input"; scope?: string }) => void;
+  onSendMessage: (text: string, options?: { intentPhase?: "intent_analysis" | "generation" | "waiting_user_input"; scope?: string }) => Promise<boolean>;
+  nextStepDisabled: boolean;
+  nextStepDisabledReason?: string;
+  generationContext: boolean;
   onFocusInput: () => void;
   onFocusInput: () => void;
 }) {
 }) {
   const [editApplied, setEditApplied] = useState(false);
   const [editApplied, setEditApplied] = useState(false);
@@ -970,19 +991,14 @@ function OutlineAssistantMessage({
         />
         />
       ) : null}
       ) : null}
       {/* 下一步推荐 */}
       {/* 下一步推荐 */}
-      {msg.nextStepRecommendation && msg.nextStepRecommendation.recommendations.length > 0 && !isStreaming ? (
+      {msg.nextStepRecommendation && msg.nextStepRecommendation.recommendations.length > 0 ? (
         <NextStepCard
         <NextStepCard
           recommendation={msg.nextStepRecommendation}
           recommendation={msg.nextStepRecommendation}
-          onSelectRecommendation={(recId, label) => {
-            if (recId === "D") {
-              onFocusInput();
-            } else {
-              const config = OUTLINE_SECTION_GENERATION_CONFIGS.find(c => c.title === label);
-              if (config) {
-                onGenerateSection(config.title, config.requestHint);
-              }
-            }
-          }}
+          onSelectRecommendation={(_recId, label) => onSendMessage(label, isExplicitStructuredGenerationFollowUp(label, { generationContext })
+            ? { intentPhase: "generation", scope: label }
+            : undefined)}
+          disabled={nextStepDisabled}
+          disabledReason={nextStepDisabledReason}
         />
         />
       ) : null}
       ) : null}
     </>
     </>
@@ -1089,6 +1105,10 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
   const llmConfig = useWikiStore((s) => s.llmConfig);
   const llmConfig = useWikiStore((s) => s.llmConfig);
   const novelConfig = useWikiStore((s) => s.novelConfig);
   const novelConfig = useWikiStore((s) => s.novelConfig);
   const providerConfigs = useWikiStore((s) => s.providerConfigs);
   const providerConfigs = useWikiStore((s) => s.providerConfigs);
+  const aiChatModel = useWikiStore((s) => s.aiChatModel);
+  const aiOutlineModel = useWikiStore((s) => s.aiOutlineModel);
+  const defaultLlmModel = useWikiStore((s) => s.defaultLlmModel);
+  const setAiOutlineModel = useWikiStore((s) => s.setAiOutlineModel);
   const chatConversations = useChatStore((s) => s.conversations);
   const chatConversations = useChatStore((s) => s.conversations);
 
 
   const conversations = useOutlineChatStore((s) => s.conversations);
   const conversations = useOutlineChatStore((s) => s.conversations);
@@ -1169,14 +1189,38 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
     return false;
     return false;
   }, [providerConfigs]);
   }, [providerConfigs]);
 
 
+  const defaultOutlineLlmConfig = useMemo(
+    () => resolveNovelModel(llmConfig, novelConfig, "writing"),
+    [aiChatModel, defaultLlmModel, llmConfig, novelConfig, providerConfigs],
+  );
+  const storedOutlineModelId = useMemo(
+    () => resolveUsableModelKey(aiOutlineModel, llmConfig, providerConfigs),
+    [aiOutlineModel, llmConfig, providerConfigs],
+  );
+  const fallbackOutlineModelId = useMemo(() => {
+    const workflowDefaultModel = novelConfig.defaultLlmModel?.trim() || defaultLlmModel.trim();
+    return resolveUsableModelKey(aiChatModel, llmConfig, providerConfigs)
+      || resolveUsableModelKey(workflowDefaultModel, llmConfig, providerConfigs)
+      || resolveUsableModelKey(defaultOutlineLlmConfig.model, llmConfig, providerConfigs);
+  }, [
+    aiChatModel,
+    defaultLlmModel,
+    defaultOutlineLlmConfig.model,
+    llmConfig,
+    novelConfig.defaultLlmModel,
+    providerConfigs,
+  ]);
+  const effectiveOutlineModelId = storedOutlineModelId || fallbackOutlineModelId;
+
   const [inputValue, setInputValue] = useState("");
   const [inputValue, setInputValue] = useState("");
   const [outlineReferenceTokens, setOutlineReferenceTokens] = useState<
   const [outlineReferenceTokens, setOutlineReferenceTokens] = useState<
     ReferenceToken[]
     ReferenceToken[]
   >([]);
   >([]);
+  const outlineReferenceTokensRef = useRef<ReferenceToken[]>([]);
   const [forceRefreshNext, setForceRefreshNext] = useState(false);
   const [forceRefreshNext, setForceRefreshNext] = useState(false);
   const [referencePickerOpen, setReferencePickerOpen] = useState(false);
   const [referencePickerOpen, setReferencePickerOpen] = useState(false);
   const [outlineWizardOpen, setOutlineWizardOpen] = useState(false);
   const [outlineWizardOpen, setOutlineWizardOpen] = useState(false);
-  const [localModelId, setLocalModelId] = useState(activeConv?.modelId ?? "");
+  const [localModelId, setLocalModelId] = useState(effectiveOutlineModelId);
   const insertReferenceTokensRef = useRef<InsertReferenceTokens>(null);
   const insertReferenceTokensRef = useRef<InsertReferenceTokens>(null);
   const [hoveredConversationId, setHoveredConversationId] = useState<string | null>(null);
   const [hoveredConversationId, setHoveredConversationId] = useState<string | null>(null);
   const [historyOpen, setHistoryOpen] = useState(false);
   const [historyOpen, setHistoryOpen] = useState(false);
@@ -1278,9 +1322,43 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
   }, [project?.path]);
   }, [project?.path]);
 
 
   // 当前会话切换或持久化 modelId 变化时,同步本地选择状态
   // 当前会话切换或持久化 modelId 变化时,同步本地选择状态
+  const persistOutlineModel = useCallback((modelId: string) => {
+    void saveAiOutlineModel(modelId).catch(() => {
+      toast.info(
+        "\u0041\u0049 \u5927\u7eb2\u6a21\u578b\u4fdd\u5b58\u5931\u8d25\uff0c\u672c\u6b21\u9009\u62e9\u4ecd\u53ef\u7ee7\u7eed\u4f7f\u7528\u3002",
+        { dedupeKey: "outline-model-save-failed" },
+      );
+    });
+  }, []);
+
   useEffect(() => {
   useEffect(() => {
-    setLocalModelId(activeConv?.modelId ?? "");
-  }, [activeConv?.modelId]);
+    setLocalModelId(effectiveOutlineModelId);
+    if (!effectiveOutlineModelId) return;
+
+    if (activeConversationId && activeConv?.modelId !== effectiveOutlineModelId) {
+      setConversationModel(activeConversationId, effectiveOutlineModelId);
+    }
+    if (aiOutlineModel === effectiveOutlineModelId) return;
+
+    const unavailableStoredModel = aiOutlineModel.trim().length > 0 && !storedOutlineModelId;
+    setAiOutlineModel(effectiveOutlineModelId);
+    if (unavailableStoredModel) {
+      toast.info(
+        "\u539f \u0041\u0049 \u5927\u7eb2\u6a21\u578b\u5df2\u4e0d\u53ef\u7528\uff0c\u5df2\u56de\u9000\u5230\u5f53\u524d\u9ed8\u8ba4\u6a21\u578b\u3002",
+        { dedupeKey: "outline-model-fallback" },
+      );
+    }
+    persistOutlineModel(effectiveOutlineModelId);
+  }, [
+    activeConv?.modelId,
+    activeConversationId,
+    aiOutlineModel,
+    effectiveOutlineModelId,
+    persistOutlineModel,
+    setAiOutlineModel,
+    setConversationModel,
+    storedOutlineModelId,
+  ]);
 
 
   useEffect(() => {
   useEffect(() => {
     if (!historyOpen) return;
     if (!historyOpen) return;
@@ -1608,15 +1686,16 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         conversationId?: string;
         conversationId?: string;
         clearDraft?: boolean;
         clearDraft?: boolean;
         intentPhase?: "intent_analysis" | "generation" | "waiting_user_input";
         intentPhase?: "intent_analysis" | "generation" | "waiting_user_input";
+        novelGenerationRequest?: NovelGenerationRequestPackage;
       } = {},
       } = {},
-    ) => {
+    ): Promise<OutlineSendResult> => {
       const prompt = inputText.trim();
       const prompt = inputText.trim();
-      if (!prompt || !project) return;
+      if (!prompt || !project) return { started: false, sent: false };
       const requestedConversationId = options.conversationId ?? activeConversationId;
       const requestedConversationId = options.conversationId ?? activeConversationId;
       const requestedRunState = requestedConversationId
       const requestedRunState = requestedConversationId
         ? useOutlineChatStore.getState().runStates[requestedConversationId]
         ? useOutlineChatStore.getState().runStates[requestedConversationId]
         : undefined;
         : undefined;
-      if (requestedRunState?.status === "running") return;
+      if (requestedRunState?.status === "running") return { started: false, sent: false };
       let convId = requestedConversationId;
       let convId = requestedConversationId;
       if (!convId) convId = createConversation();
       if (!convId) convId = createConversation();
       let effectiveLlmConfig = resolveNovelModel(
       let effectiveLlmConfig = resolveNovelModel(
@@ -1627,15 +1706,14 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
       const targetConversation = options.conversationId
       const targetConversation = options.conversationId
         ? useOutlineChatStore.getState().conversations.find((conversation) => conversation.id === options.conversationId)
         ? useOutlineChatStore.getState().conversations.find((conversation) => conversation.id === options.conversationId)
         : activeConv;
         : activeConv;
-      if (targetConversation?.modelId) {
+      if (effectiveOutlineModelId) {
         effectiveLlmConfig = resolveModelConfig(
         effectiveLlmConfig = resolveModelConfig(
-          targetConversation.modelId,
+          effectiveOutlineModelId,
           effectiveLlmConfig,
           effectiveLlmConfig,
           providerConfigs,
           providerConfigs,
         );
         );
       }
       }
-      const effectiveModelId =
-        targetConversation?.modelId || effectiveLlmConfig.model || "";
+      const effectiveModelId = effectiveOutlineModelId || effectiveLlmConfig.model || "";
       if (!hasUsableLlm(effectiveLlmConfig, providerConfigs)) {
       if (!hasUsableLlm(effectiveLlmConfig, providerConfigs)) {
         addMessage(convId, {
         addMessage(convId, {
           id: crypto.randomUUID(),
           id: crypto.randomUUID(),
@@ -1643,7 +1721,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           content:
           content:
             "请先在设置中配置并选择一个可用的 AI 模型,或在下方模型选择器中选择模型后再试。",
             "请先在设置中配置并选择一个可用的 AI 模型,或在下方模型选择器中选择模型后再试。",
         });
         });
-        return;
+        return { started: false, sent: false };
       }
       }
       if (!modelSupportsTools(effectiveModelId)) {
       if (!modelSupportsTools(effectiveModelId)) {
         addMessage(convId, {
         addMessage(convId, {
@@ -1652,12 +1730,12 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           content:
           content:
             "当前模型不支持 AI 大纲工具调用,请在下方模型选择器中更换支持工具调用的模型。",
             "当前模型不支持 AI 大纲工具调用,请在下方模型选择器中更换支持工具调用的模型。",
         });
         });
-        return;
+        return { started: false, sent: false };
       }
       }
 
 
       const capturedConvId = convId;
       const capturedConvId = convId;
       const runId = crypto.randomUUID();
       const runId = crypto.randomUUID();
-      if (!startConversationRun(capturedConvId, runId)) return;
+      if (!startConversationRun(capturedConvId, runId)) return { started: false, sent: false };
       const controller = new AbortController();
       const controller = new AbortController();
       outlineConversationRunRegistry.register(capturedConvId, controller);
       outlineConversationRunRegistry.register(capturedConvId, controller);
       const isCurrentRun = () => canApplyOutlineRunEffect(
       const isCurrentRun = () => canApplyOutlineRunEffect(
@@ -1666,7 +1744,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         runId,
         runId,
       );
       );
       const setCapturedWorkflowStage = (stage: OutlineWorkflowStage) => {
       const setCapturedWorkflowStage = (stage: OutlineWorkflowStage) => {
-        if (!isCurrentRun()) return;
+        if (!isCurrentRun()) return { started: true, sent: false };
         setOutlineWorkflowStages((stages) => setOutlineSessionValue(stages, capturedConvId, stage));
         setOutlineWorkflowStages((stages) => setOutlineSessionValue(stages, capturedConvId, stage));
       };
       };
       if (shouldClearOutlineDraft({
       if (shouldClearOutlineDraft({
@@ -1675,22 +1753,15 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         activeConversationId: useOutlineChatStore.getState().activeConversationId,
         activeConversationId: useOutlineChatStore.getState().activeConversationId,
       })) {
       })) {
         setInputValue("");
         setInputValue("");
+        outlineReferenceTokensRef.current = [];
         setOutlineReferenceTokens([]);
         setOutlineReferenceTokens([]);
       }
       }
 
 
-      const historyBeforeSend = (
+      const historyBeforeSend = mapOutlineMessagesForModel(
         useOutlineChatStore
         useOutlineChatStore
           .getState()
           .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,
-        );
+          .conversations.find((c) => c.id === convId)?.messages ?? [],
+      ) satisfies AgentMessage[];
       const hasPriorAssistantAnswer = historyBeforeSend.some(
       const hasPriorAssistantAnswer = historyBeforeSend.some(
         (message) => message.role === "assistant" && message.content.trim(),
         (message) => message.role === "assistant" && message.content.trim(),
       );
       );
@@ -1720,7 +1791,8 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
       const userMsg: OutlineChatMessage = {
       const userMsg: OutlineChatMessage = {
         id: crypto.randomUUID(),
         id: crypto.randomUUID(),
         role: "user",
         role: "user",
-        content: prompt,
+        content: options.novelGenerationRequest?.summary ?? prompt,
+        novelGenerationRequest: options.novelGenerationRequest,
         attachedReferences: tokens,
         attachedReferences: tokens,
       };
       };
       const initialSources = tokens.map(
       const initialSources = tokens.map(
@@ -1818,16 +1890,9 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
                 }));
                 }));
               },
               },
               getOutlineConversations: () =>
               getOutlineConversations: () =>
-                useOutlineChatStore
-                  .getState()
-                  .conversations.map((conversation) => ({
-                    id: conversation.id,
-                    title: conversation.title,
-                    messages: conversation.messages.map((message) => ({
-                      role: message.role,
-                      content: message.content,
-                    })),
-                  })),
+                mapOutlineConversationsForModel(
+                  useOutlineChatStore.getState().conversations,
+                ),
               llmConfig: effectiveLlmConfig,
               llmConfig: effectiveLlmConfig,
               disabledTools: mergeDisabledTools(
               disabledTools: mergeDisabledTools(
                 disableWriteTools
                 disableWriteTools
@@ -1874,7 +1939,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
               onToolResult: () => {},
               onToolResult: () => {},
               onToolError: () => {},
               onToolError: () => {},
               onToolEvent: (event) => {
               onToolEvent: (event) => {
-                if (!isCurrentRun()) return;
+                if (!isCurrentRun()) return { started: true, sent: false };
                 if (!historyPlan.showToolProcess) {
                 if (!historyPlan.showToolProcess) {
                   hiddenToolCalls = applyAgentToolEvent(hiddenToolCalls, event);
                   hiddenToolCalls = applyAgentToolEvent(hiddenToolCalls, event);
                   return;
                   return;
@@ -1962,7 +2027,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             plan: subAgentPlan,
             plan: subAgentPlan,
             maxConcurrency,
             maxConcurrency,
             onStatusChange: (event) => {
             onStatusChange: (event) => {
-              if (!isCurrentRun()) return;
+              if (!isCurrentRun()) return { started: true, sent: false };
               updateOutlineMultiAgentItem(convId, assistantId, event.agentId, (agent) => ({
               updateOutlineMultiAgentItem(convId, assistantId, event.agentId, (agent) => ({
                 ...agent,
                 ...agent,
                 status: event.status === "completed"
                 status: event.status === "completed"
@@ -2037,10 +2102,12 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
                 ...run,
                 ...run,
                 mode: "single-agent-fallback",
                 mode: "single-agent-fallback",
                 status: "fallback",
                 status: "fallback",
-                merge: {
-                  status: "skipped",
-                  summary: "已回退为单 Agent 生成。",
-                },
+                merge: run.merge?.status === "error"
+                  ? run.merge
+                  : {
+                      status: "skipped",
+                      summary: "\u5df2\u56de\u9000\u4e3a\u5355 Agent \u751f\u6210\u3002",
+                    },
                 fallbackReason: run.fallbackReason ?? "多 Agent 不可用或部分子 Agent 失败,已自动回退。",
                 fallbackReason: run.fallbackReason ?? "多 Agent 不可用或部分子 Agent 失败,已自动回退。",
               }) : run);
               }) : run);
               if (isCurrentRun()) {
               if (isCurrentRun()) {
@@ -2087,12 +2154,29 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
                   ].join("\n"),
                   ].join("\n"),
                 },
                 },
               ];
               ];
-              const mergeRun = await runOutlineAgentOnce(mergeMessages, {
-                skillNames: options.preferredSkillNames,
-                disableWriteTools: true,
-                streamToUser: true,
-                statusText: "多 Agent 已完成,正在合并大纲结果...",
-              });
+              let mergeRun: { text: string; record: AgentRunRecord };
+              try {
+                mergeRun = await runOutlineAgentOnce(mergeMessages, {
+                  skillNames: options.preferredSkillNames,
+                  disableWriteTools: true,
+                  streamToUser: true,
+                  statusText: "\u591a Agent \u5df2\u5b8c\u6210\uff0c\u6b63\u5728\u5408\u5e76\u5927\u7eb2\u7ed3\u679c...",
+                });
+              } catch (error) {
+                if (isCurrentRun()) {
+                  const message = error instanceof Error ? error.message : String(error);
+                  updateOutlineMultiAgentRun(convId, assistantId, (run) => run ? ({
+                    ...run,
+                    merge: {
+                      ...run.merge,
+                      status: "error",
+                      error: message,
+                      finishedAt: Date.now(),
+                    },
+                  }) : run);
+                }
+                throw error;
+              }
               if (!isCurrentRun()) throw new Error("aborted");
               if (!isCurrentRun()) throw new Error("aborted");
               updateOutlineMultiAgentRun(convId, assistantId, (run) => run ? ({
               updateOutlineMultiAgentRun(convId, assistantId, (run) => run ? ({
                 ...run,
                 ...run,
@@ -2106,7 +2190,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
               return mergeRun.text || "AI大纲未返回内容。";
               return mergeRun.text || "AI大纲未返回内容。";
             },
             },
           });
           });
-          if (!isCurrentRun()) return;
+          if (!isCurrentRun()) return { started: true, sent: false };
           updateOutlineMultiAgentRun(convId, assistantId, (run) => {
           updateOutlineMultiAgentRun(convId, assistantId, (run) => {
             if (!run) return run;
             if (!run) return run;
             const successful = new Set(multiAgentResult.successfulAgents);
             const successful = new Set(multiAgentResult.successfulAgents);
@@ -2126,7 +2210,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
                 }
                 }
                 return agent;
                 return agent;
               }),
               }),
-              merge: multiAgentResult.mode === "multi-agent"
+              merge: multiAgentResult.mode === "multi-agent" || run.merge?.status === "error"
                 ? run.merge
                 ? run.merge
                 : {
                 : {
                     status: "skipped",
                     status: "skipped",
@@ -2139,7 +2223,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           finalText = await runSingleAgentFallback();
           finalText = await runSingleAgentFallback();
         }
         }
 
 
-        if (!isCurrentRun()) return;
+        if (!isCurrentRun()) return { started: true, sent: false };
 
 
         const finalSources = Array.from(
         const finalSources = Array.from(
           new Set([
           new Set([
@@ -2153,7 +2237,28 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           allowFallback: options.intentPhase === "generation",
           allowFallback: options.intentPhase === "generation",
           completedModule: intentContextsRef.current[capturedConvId]?.title || "当前模块",
           completedModule: intentContextsRef.current[capturedConvId]?.title || "当前模块",
         });
         });
-        const finalContent = nextStepExtraction.cleanText || "AI大纲未返回内容。";
+        const cleanFinalContent = nextStepExtraction.cleanText || "AI大纲未返回内容。";
+        const structuredMarkdownEnabled = options.intentPhase === "generation"
+          || options.novelGenerationRequest !== undefined;
+        const finalContent = await finalizeStructuredMarkdownMessage(
+          cleanFinalContent,
+          {
+            enabled: structuredMarkdownEnabled,
+            repairWithAi: ({ content, maxTokens }) => repairMarkdownFormatWithAi({
+              content,
+              llmConfig: effectiveLlmConfig,
+              signal: controller.signal,
+              maxTokens,
+            }),
+            onFailure: () => {
+              if (!isCurrentRun()) return { started: true, sent: false };
+              toast.info("Markdown 格式自动修复未完全通过,已保留内容最完整的版本。", {
+                dedupeKey: "outline-markdown-quality-incomplete",
+              });
+            },
+          },
+        );
+        if (!isCurrentRun()) return { started: true, sent: false };
         const visibleToolCalls = allToolCalls.length ? allToolCalls : [];
         const visibleToolCalls = allToolCalls.length ? allToolCalls : [];
         const shouldShowToolProcess =
         const shouldShowToolProcess =
           historyPlan.showToolProcess ||
           historyPlan.showToolProcess ||
@@ -2217,10 +2322,10 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             { role: "assistant", content: finalContent },
             { role: "assistant", content: finalContent },
           ]),
           ]),
         };
         };
-        if (!isCurrentRun()) return;
+        if (!isCurrentRun()) return { started: true, sent: false };
         setConversationContextSummary(convId, nextContextSummaryPayload.contextSummary);
         setConversationContextSummary(convId, nextContextSummaryPayload.contextSummary);
         await handleAutoSaveOutlineRequests(capturedConvId, finalContent, isCurrentRun);
         await handleAutoSaveOutlineRequests(capturedConvId, finalContent, isCurrentRun);
-        if (!isCurrentRun()) return;
+        if (!isCurrentRun()) return { started: true, sent: false };
         const firstUser = useOutlineChatStore
         const firstUser = useOutlineChatStore
           .getState()
           .getState()
           .conversations.find((conversation) => conversation.id === convId)
           .conversations.find((conversation) => conversation.id === convId)
@@ -2254,10 +2359,11 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             intentPhase: "generation",
             intentPhase: "generation",
           });
           });
         }
         }
+        return { started: true, sent: true };
       } catch (err) {
       } catch (err) {
         const errorMsg = err instanceof Error ? err.message : String(err);
         const errorMsg = err instanceof Error ? err.message : String(err);
         const aborted = controller.signal.aborted || errorMsg.toLowerCase().includes("aborted");
         const aborted = controller.signal.aborted || errorMsg.toLowerCase().includes("aborted");
-        if (aborted || !isCurrentRun()) return;
+        if (aborted || !isCurrentRun()) return { started: true, sent: false };
         const partial = useOutlineChatStore.getState().getStreamingContent(capturedConvId);
         const partial = useOutlineChatStore.getState().getStreamingContent(capturedConvId);
         if (partial) {
         if (partial) {
           updateOutlineAssistantMessage(convId, assistantId, (message) => ({
           updateOutlineAssistantMessage(convId, assistantId, (message) => ({
@@ -2303,6 +2409,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           });
           });
         }
         }
         void useOutlineChatStore.getState().saveToDisk();
         void useOutlineChatStore.getState().saveToDisk();
+        return { started: true, sent: false };
       } finally {
       } finally {
         outlineConversationRunRegistry.remove(capturedConvId, controller);
         outlineConversationRunRegistry.remove(capturedConvId, controller);
         if (isCurrentRun()) setCapturedWorkflowStage("idle");
         if (isCurrentRun()) setCapturedWorkflowStage("idle");
@@ -2314,6 +2421,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
       llmConfig,
       llmConfig,
       novelConfig,
       novelConfig,
       providerConfigs,
       providerConfigs,
+      effectiveOutlineModelId,
       activeConv,
       activeConv,
       activeConversationId,
       activeConversationId,
       createConversation,
       createConversation,
@@ -2351,22 +2459,68 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
   );
   );
 
 
   const handleSendMessage = useCallback(
   const handleSendMessage = useCallback(
-    (text: string, options?: { intentPhase?: "intent_analysis" | "generation" | "waiting_user_input"; scope?: string }) => {
+    async (text: string, options?: { intentPhase?: "intent_analysis" | "generation" | "waiting_user_input"; scope?: string }) => {
       const capturedConvId = activeConversationId;
       const capturedConvId = activeConversationId;
-      if (!capturedConvId) return;
-      if (options?.intentPhase === "generation") {
-        if (activeConversationId && canTransitionOutlineWorkflow(outlineWorkflowStage, "sufficiency_check")) {
-          setOutlineWorkflowStages((stages) => setOutlineSessionValue(stages, activeConversationId, "sufficiency_check"));
+      if (!capturedConvId) {
+        toast.info("当前没有可用的大纲会话,请先新建会话。", { dedupeKey: "outline-next-step:no-conversation" });
+        return false;
+      }
+      if (!canStartConversationRun(capturedConvId)) {
+        const currentRunning = useOutlineChatStore.getState().runStates[capturedConvId]?.status === "running";
+        toast.info(currentRunning
+          ? "当前会话正在生成,请等待生成完成后再选择下一步。"
+          : "大纲 AI 会话最多同时运行 3 个任务,请等待任一任务结束后再发送。", {
+          dedupeKey: `outline-next-step:busy:${capturedConvId}`,
+        });
+        return false;
+      }
+      try {
+        if (options?.intentPhase === "generation") {
+          if (canTransitionOutlineWorkflow(outlineWorkflowStage, "sufficiency_check")) {
+            setOutlineWorkflowStages((stages) => setOutlineSessionValue(stages, capturedConvId, "sufficiency_check"));
+          }
+          const scope = options.scope || text;
+          const intentContext = intentContextsRef.current[capturedConvId] ?? { title: "", hint: "" };
+          const generationPrompt = buildGenerationPrompt(intentContext.title, intentContext.hint, scope, intentContext.outputMode);
+          const references = outlineReferenceTokens;
+          const result = await handleSend(generationPrompt, references, { conversationId: capturedConvId, intentPhase: "generation", clearDraft: false });
+          if (result.sent) {
+            if (shouldClearOutlineReferences({
+              invocationConversationId: capturedConvId,
+              activeConversationId: useOutlineChatStore.getState().activeConversationId,
+              sentReferences: references,
+              currentReferences: outlineReferenceTokensRef.current,
+            })) {
+              outlineReferenceTokensRef.current = [];
+              setOutlineReferenceTokens([]);
+            }
+            return true;
+          }
+        } else {
+          const references = outlineReferenceTokens;
+          const result = await handleSend(text, references, { conversationId: capturedConvId, intentPhase: options?.intentPhase, clearDraft: false });
+          if (result.sent) {
+            if (shouldClearOutlineReferences({
+              invocationConversationId: capturedConvId,
+              activeConversationId: useOutlineChatStore.getState().activeConversationId,
+              sentReferences: references,
+              currentReferences: outlineReferenceTokensRef.current,
+            })) {
+              outlineReferenceTokensRef.current = [];
+              setOutlineReferenceTokens([]);
+            }
+            return true;
+          }
         }
         }
-        const scope = options.scope || text;
-        const intentContext = intentContextsRef.current[capturedConvId] ?? { title: "", hint: "" };
-        const generationPrompt = buildGenerationPrompt(intentContext.title, intentContext.hint, scope, intentContext.outputMode);
-        void handleSend(generationPrompt, [], { conversationId: capturedConvId, intentPhase: "generation" });
-      } else {
-        void handleSend(text, [], { conversationId: capturedConvId, intentPhase: options?.intentPhase });
+      } catch {
+        // Promise reject is handled below; the card finally block restores busy and disabled state.
       }
       }
+      toast.info("发送失败,推荐操作已恢复,请稍后重试。", {
+        dedupeKey: `outline-next-step:send-failed:${capturedConvId}`,
+      });
+      return false;
     },
     },
-    [activeConversationId, handleSend, outlineWorkflowStage],
+    [activeConversationId, canStartConversationRun, handleSend, outlineReferenceTokens, outlineWorkflowStage],
   );
   );
 
 
   const handleFocusInput = useCallback(() => {
   const handleFocusInput = useCallback(() => {
@@ -2380,10 +2534,12 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
 
 
   const handleSubmitOutlineWizard = useCallback(
   const handleSubmitOutlineWizard = useCallback(
     (request: OutlineWizardRequest) => {
     (request: OutlineWizardRequest) => {
-      void handleSend(buildOutlineWizardMultiAgentPrompt(request), [], {
+      const modelContent = buildOutlineWizardMultiAgentPrompt(request);
+      void handleSend(modelContent, outlineReferenceTokensRef.current, {
         disableWriteTools: true,
         disableWriteTools: true,
         preferredSkillNames: getOutlineWizardSkillNames(request),
         preferredSkillNames: getOutlineWizardSkillNames(request),
         enableMultiAgent: true,
         enableMultiAgent: true,
+        novelGenerationRequest: createNovelGenerationRequestPackage(request, modelContent),
       });
       });
     },
     },
     [handleSend],
     [handleSend],
@@ -2428,15 +2584,14 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         novelConfig,
         novelConfig,
         "writing",
         "writing",
       );
       );
-      if (activeConv?.modelId) {
+      if (effectiveOutlineModelId) {
         effectiveLlmConfig = resolveModelConfig(
         effectiveLlmConfig = resolveModelConfig(
-          activeConv.modelId,
+          effectiveOutlineModelId,
           effectiveLlmConfig,
           effectiveLlmConfig,
           providerConfigs,
           providerConfigs,
         );
         );
       }
       }
-      const effectiveModelId =
-        activeConv?.modelId || effectiveLlmConfig.model || "";
+      const effectiveModelId = effectiveOutlineModelId || effectiveLlmConfig.model || "";
       if (!hasUsableLlm(effectiveLlmConfig, providerConfigs)) {
       if (!hasUsableLlm(effectiveLlmConfig, providerConfigs)) {
         addMessage(activeConversationId, {
         addMessage(activeConversationId, {
           id: crypto.randomUUID(),
           id: crypto.randomUUID(),
@@ -2486,23 +2641,9 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
       userScrolledUpRef.current = false;
       userScrolledUpRef.current = false;
 
 
       try {
       try {
-        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,
-          );
+        const regenerationInput = buildOutlineRegenerationInput(targetMessages);
+        const lastUserRequest = regenerationInput.request;
+        const historyMessages = regenerationInput.history satisfies AgentMessage[];
         let result = "";
         let result = "";
         const assistantId = crypto.randomUUID();
         const assistantId = crypto.randomUUID();
 
 
@@ -2548,16 +2689,9 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
               }));
               }));
             },
             },
             getOutlineConversations: () =>
             getOutlineConversations: () =>
-              useOutlineChatStore
-                .getState()
-                .conversations.map((conversation) => ({
-                  id: conversation.id,
-                  title: conversation.title,
-                  messages: conversation.messages.map((message) => ({
-                    role: message.role,
-                    content: message.content,
-                  })),
-                })),
+              mapOutlineConversationsForModel(
+                useOutlineChatStore.getState().conversations,
+              ),
             llmConfig: effectiveLlmConfig,
             llmConfig: effectiveLlmConfig,
             disabledTools: OUTLINE_CHAT_DISABLED_TOOLS,
             disabledTools: OUTLINE_CHAT_DISABLED_TOOLS,
           },
           },
@@ -2621,7 +2755,20 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           result || record.finalText || "AI大纲未返回内容。",
           result || record.finalText || "AI大纲未返回内容。",
           { allowFallback: true, completedModule: "当前模块" },
           { allowFallback: true, completedModule: "当前模块" },
         );
         );
-        const finalContent = nextStepExtraction.cleanText || "AI大纲未返回内容。";
+        const cleanFinalContent = nextStepExtraction.cleanText || "AI大纲未返回内容。";
+        const finalContent = await finalizeStructuredMarkdownMessage(cleanFinalContent, {
+          enabled: regenerationInput.structuredGeneration,
+          repairWithAi: ({ content, maxTokens }) => repairMarkdownFormatWithAi({
+            content,
+            llmConfig: effectiveLlmConfig,
+            signal: controller.signal,
+            maxTokens,
+          }),
+          onFailure: () => toast.info("Markdown 格式自动修复未完全通过,已保留内容最完整的版本。", {
+            dedupeKey: "outline-markdown-quality-incomplete",
+          }),
+        });
+        if (!isCurrentRun()) return;
         updateOutlineAssistantMessage(
         updateOutlineAssistantMessage(
           capturedConvId,
           capturedConvId,
           assistantId,
           assistantId,
@@ -2675,6 +2822,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
       llmConfig,
       llmConfig,
       novelConfig,
       novelConfig,
       providerConfigs,
       providerConfigs,
+      effectiveOutlineModelId,
       activeConv,
       activeConv,
       activeConversationId,
       activeConversationId,
       addMessage,
       addMessage,
@@ -3111,15 +3259,24 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
                   onRegenerate={handleRegenerate}
                   onRegenerate={handleRegenerate}
                   onConfirmToolSave={handleConfirmToolSave}
                   onConfirmToolSave={handleConfirmToolSave}
                   onRejectTool={handleRejectTool}
                   onRejectTool={handleRejectTool}
-                  onGenerateSection={handleGenerateSection}
                   onSendMessage={handleSendMessage}
                   onSendMessage={handleSendMessage}
+                  nextStepDisabled={submitDisabled}
+                  generationContext={activeMessages.slice(0, i).some((message) => message.role === "user" && Boolean(message.novelGenerationRequest))
+                    || Boolean(msg.nextStepRecommendation?.completedModule && /^#{1,6}\s/m.test(msg.content))}
+                  nextStepDisabledReason={isStreaming
+                    ? "当前会话正在生成,请等待生成完成后再选择下一步。"
+                    : submitDisabledReason}
                   onFocusInput={handleFocusInput}
                   onFocusInput={handleFocusInput}
                 />
                 />
               ) : (
               ) : (
                 <>
                 <>
-                  <span className="block whitespace-pre-wrap break-words">
-                    {msg.content}
-                  </span>
+                  {msg.novelGenerationRequest ? (
+                    <NovelGenerationRequestMessage request={msg.novelGenerationRequest} />
+                  ) : (
+                    <span className="block whitespace-pre-wrap break-words">
+                      {msg.content}
+                    </span>
+                  )}
                   {msg.attachedReferences &&
                   {msg.attachedReferences &&
                   msg.attachedReferences.length > 0 ? (
                   msg.attachedReferences.length > 0 ? (
                     <div className="mt-1 flex flex-wrap gap-1">
                     <div className="mt-1 flex flex-wrap gap-1">
@@ -3149,11 +3306,6 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
 
 
       {/* Input */}
       {/* Input */}
       <div className="shrink-0 border-t px-3 py-2">
       <div className="shrink-0 border-t px-3 py-2">
-        {isStreaming && (
-          <div className="mb-2 animate-pulse rounded-md border border-sky-200 bg-sky-50 px-3 py-2 text-xs text-sky-700 dark:border-sky-800 dark:bg-sky-950/30 dark:text-sky-300">
-            <span className="font-medium">正在生成...</span>
-          </div>
-        )}
         <div className="mb-2 flex items-center justify-between gap-2">
         <div className="mb-2 flex items-center justify-between gap-2">
           <p className="text-xs text-muted-foreground">
           <p className="text-xs text-muted-foreground">
             通过固定选项生成大纲需求,再交给 AI 分析和追问
             通过固定选项生成大纲需求,再交给 AI 分析和追问
@@ -3177,9 +3329,13 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           placeholder="输入关于大纲的问题..."
           placeholder="输入关于大纲的问题..."
           onChange={(text, tokens) => {
           onChange={(text, tokens) => {
             setInputValue(text);
             setInputValue(text);
+            outlineReferenceTokensRef.current = tokens;
+            setOutlineReferenceTokens(tokens);
+          }}
+          onTokensChange={(tokens) => {
+            outlineReferenceTokensRef.current = tokens;
             setOutlineReferenceTokens(tokens);
             setOutlineReferenceTokens(tokens);
           }}
           }}
-          onTokensChange={setOutlineReferenceTokens}
           onSubmit={handleSend}
           onSubmit={handleSend}
           onAtTrigger={() => setReferencePickerOpen(true)}
           onAtTrigger={() => setReferencePickerOpen(true)}
           insertTokensRef={insertReferenceTokensRef}
           insertTokensRef={insertReferenceTokensRef}
@@ -3197,9 +3353,11 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
                 value={localModelId}
                 value={localModelId}
                 onChange={(value) => {
                 onChange={(value) => {
                   setLocalModelId(value);
                   setLocalModelId(value);
+                  setAiOutlineModel(value);
                   if (activeConversationId) {
                   if (activeConversationId) {
                     setConversationModel(activeConversationId, value);
                     setConversationModel(activeConversationId, value);
                   }
                   }
+                  persistOutlineModel(value);
                 }}
                 }}
                 disabled={false}
                 disabled={false}
               />
               />

+ 275 - 13
src/components/sources/outline-multi-agent-panel.spec.tsx

@@ -1,3 +1,6 @@
+// @vitest-environment jsdom
+import { act } from "react"
+import { createRoot } from "react-dom/client"
 import { renderToStaticMarkup } from "react-dom/server"
 import { renderToStaticMarkup } from "react-dom/server"
 import { describe, expect, it } from "vitest"
 import { describe, expect, it } from "vitest"
 import type { OutlineMultiAgentRunState } from "@/stores/outline-chat-store"
 import type { OutlineMultiAgentRunState } from "@/stores/outline-chat-store"
@@ -39,19 +42,10 @@ describe("OutlineMultiAgentPanel", () => {
 
 
     const html = renderToStaticMarkup(<OutlineMultiAgentPanel run={run} />)
     const html = renderToStaticMarkup(<OutlineMultiAgentPanel run={run} />)
 
 
-    expect(html).toContain("多 Agent 大纲生成")
-    expect(html).toContain("已回退")
-    expect(html).toContain("最大并发:3")
-    expect(html).toContain("大纲 Agent")
-    expect(html).toContain("负责主线结构、卷纲和章节骨架。")
-    expect(html).toContain("outline-master-builder")
-    expect(html).toContain("已完成主线大纲骨架。")
-    expect(html).toContain("题材 Agent")
-    expect(html).toContain("male-xuanhuan-xianxia")
-    expect(html).toContain("结构化输出 JSON 解析失败")
-    expect(html).toContain("回退原因")
-    expect(html).toContain("多 Agent 失败数量超过阈值:2/3")
-    expect(html).toContain("题材 Agent:结构化输出 JSON 解析失败")
+    expect(html).toContain("\u591a Agent \u751f\u6210\u5931\u8d25\uff0c\u5df2\u81ea\u52a8\u5207\u6362\u4e3a\u666e\u901a\u751f\u6210\u3002")
+    expect(html).toContain("\u67e5\u770b\u8be6\u60c5")
+    expect(html).not.toContain("outline-master-builder")
+    expect(html).not.toContain("\u7ed3\u6784\u5316\u8f93\u51fa JSON \u89e3\u6790\u5931\u8d25")
   })
   })
   it("显示等待和重试状态,不显示槽位", () => {
   it("显示等待和重试状态,不显示槽位", () => {
     const run: OutlineMultiAgentRunState = {
     const run: OutlineMultiAgentRunState = {
@@ -86,5 +80,273 @@ describe("OutlineMultiAgentPanel", () => {
     expect(html).toContain("(1/1)")
     expect(html).toContain("(1/1)")
     expect(html).not.toContain("槽位")
     expect(html).not.toContain("槽位")
   })
   })
+  it("\u5931\u8d25\u56de\u9000\u9ed8\u8ba4\u53ea\u663e\u793a\u7b80\u77ed\u6458\u8981\uff0c\u8be6\u60c5\u539f\u5730\u5c55\u5f00\u4e14\u4e0d\u6cc4\u9732\u5b8c\u6574\u5904\u7406\u5185\u5bb9", async () => {
+    ;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+    const host = document.createElement("div")
+    document.body.appendChild(host)
+    const root = createRoot(host)
+    const run: OutlineMultiAgentRunState = {
+      mode: "single-agent-fallback",
+      status: "fallback",
+      maxConcurrency: 3,
+      fallbackReason: "\u591a Agent \u6ca1\u6709\u4efb\u4f55\u6210\u529f\u7ed3\u679c\uff0c\u5df2\u964d\u7ea7\u4e3a\u5355 Agent\u3002\nFALLBACK_REASON_SECRET\n\u5b8c\u6574\u7cfb\u7edf\u63d0\u793a\u8bcd",
+      failureDetails: [
+        "\u5927\u7eb2 Agent\uff1a\u4e0a\u6e38\u670d\u52a1\u4e0d\u53ef\u7528\nFAILURE_DETAILS_SECRET_A\n\u8bf7\u6c42\u4f53\u79d8\u5bc6",
+        "\u5927\u7eb2 Agent\uff1a\u4e0a\u6e38\u670d\u52a1\u4e0d\u53ef\u7528\nFAILURE_DETAILS_SECRET_B",
+        "\u9898\u6750 Agent\uff1a\u683c\u5f0f\u9519\u8bef",
+        "\u89d2\u8272 Agent\uff1a\u8d85\u65f6",
+        "\u8bbe\u5b9a Agent\uff1a\u8d85\u65f6",
+        "\u4f0f\u7b14 Agent\uff1a\u8d85\u65f6",
+        "\u7b2c\u516d\u9879 Agent\uff1a\u4e0d\u5e94\u5c55\u793a",
+      ],
+      agents: [{
+        id: "outline-agent",
+        name: "\u5927\u7eb2 Agent",
+        kind: "outline",
+        skillNames: ["outline-master-builder"],
+        taskPrompt: "\u68b3\u7406\u603b\u7eb2\u4e0e\u7ae0\u8282\u9aa8\u67b6\u3002\n## \u5b8c\u6574\u7cfb\u7edf\u63d0\u793a\u8bcd\n\u4e0d\u5f97\u51fa\u73b0\u5728\u754c\u9762\u4e2d",
+        status: "error",
+        error: `\u8c03\u7528\u5931\u8d25\uff1a${"\u9519".repeat(200)}\nAGENT_ERROR_SECRET\nAgent \u5b8c\u6574\u5185\u5bb9`,
+      }],
+      merge: {
+        status: "skipped",
+        summary: "\u5df2\u56de\u9000\u4e3a\u5355 Agent \u751f\u6210\u3002",
+        error: "\u5408\u5e76\u8f93\u51fa\u5931\u8d25\nMERGE_ERROR_SECRET\n\u8bf7\u6c42\u4f53\u5b8c\u6574\u5185\u5bb9",
+      },
+    }
+
+    await act(async () => root.render(<OutlineMultiAgentPanel run={run} />))
+    expect(host.textContent).toContain("\u591a Agent \u751f\u6210\u5931\u8d25\uff0c\u5df2\u81ea\u52a8\u5207\u6362\u4e3a\u666e\u901a\u751f\u6210\u3002")
+    expect(host.textContent).not.toContain("\u5927\u7eb2 Agent")
+    expect(host.textContent).not.toContain("outline-master-builder")
+    expect(host.textContent).not.toContain("\u4e0a\u6e38\u670d\u52a1\u4e0d\u53ef\u7528")
+    expect(host.textContent).not.toContain("\u5b8c\u6574\u7cfb\u7edf\u63d0\u793a\u8bcd")
+    const toggle = Array.from(host.querySelectorAll("button")).find((button) => button.textContent?.includes("\u67e5\u770b\u8be6\u60c5"))
+    expect(toggle).toBeDefined()
+    expect(toggle?.getAttribute("aria-expanded")).toBe("false")
+    expect(toggle?.getAttribute("aria-controls")).toBeNull()
+    expect(host.querySelector('[role="region"]')).toBeNull()
+    for (const collapsedSecret of [
+      "FALLBACK_REASON_SECRET",
+      "FAILURE_DETAILS_SECRET_A",
+      "AGENT_ERROR_SECRET",
+      "MERGE_ERROR_SECRET",
+    ]) {
+      expect(host.textContent).not.toContain(collapsedSecret)
+    }
+
+    await act(async () => toggle?.click())
+    expect(toggle?.getAttribute("aria-expanded")).toBe("true")
+    const detailsId = toggle?.getAttribute("aria-controls")
+    expect(detailsId).toBeTruthy()
+    const details = detailsId ? host.querySelector<HTMLElement>(`#${detailsId}`) : null
+    expect(details).not.toBeNull()
+    expect(details?.getAttribute("role")).toBe("region")
+    expect(details?.className).toContain("overflow-y-auto")
+    expect(details?.className).toContain("max-h-[min(24rem,55vh)]")
+    expect(host.textContent).toContain("Agent \u72b6\u6001")
+    expect(host.textContent).toContain("\u5931\u8d25\u539f\u56e0")
+    expect(host.textContent).toContain("\u4efb\u52a1\u6458\u8981")
+    expect(host.textContent).toContain("Skill")
+    expect(host.textContent).toContain("\u5408\u5e76\u72b6\u6001")
+    expect(host.textContent).toContain("\u68b3\u7406\u603b\u7eb2\u4e0e\u7ae0\u8282\u9aa8\u67b6\u3002")
+    expect(host.textContent).not.toContain("\u5b8c\u6574\u7cfb\u7edf\u63d0\u793a\u8bcd")
+    expect(host.textContent).not.toContain("\u4e0d\u5f97\u51fa\u73b0\u5728\u754c\u9762\u4e2d")
+    expect(host.textContent).toContain("\u591a Agent \u6ca1\u6709\u4efb\u4f55\u6210\u529f\u7ed3\u679c\uff0c\u5df2\u964d\u7ea7\u4e3a\u5355 Agent\u3002")
+    expect(host.textContent).toContain("\u5408\u5e76\u8f93\u51fa\u5931\u8d25")
+    expect(host.textContent).toContain("\u9898\u6750 Agent\uff1a\u683c\u5f0f\u9519\u8bef")
+    expect(host.textContent).toContain("\u4f0f\u7b14 Agent\uff1a\u8d85\u65f6")
+    expect(host.textContent).not.toContain("\u7b2c\u516d\u9879 Agent\uff1a\u4e0d\u5e94\u5c55\u793a")
+    expect(host.textContent?.match(/\u5927\u7eb2 Agent\uff1a\u4e0a\u6e38\u670d\u52a1\u4e0d\u53ef\u7528/g)).toHaveLength(1)
+    expect(host.textContent).not.toContain("\u9519".repeat(170))
+    expect(host.textContent).toContain("...")
+    for (const secret of [
+      "FALLBACK_REASON_SECRET",
+      "FAILURE_DETAILS_SECRET_A",
+      "FAILURE_DETAILS_SECRET_B",
+      "AGENT_ERROR_SECRET",
+      "MERGE_ERROR_SECRET",
+      "\u8bf7\u6c42\u4f53\u79d8\u5bc6",
+      "\u5b8c\u6574\u7cfb\u7edf\u63d0\u793a\u8bcd",
+      "Agent \u5b8c\u6574\u5185\u5bb9",
+      "\u8bf7\u6c42\u4f53\u5b8c\u6574\u5185\u5bb9",
+    ]) {
+      expect(host.textContent).not.toContain(secret)
+    }
+
+    await act(async () => root.unmount())
+    host.remove()
+  })
+  it("fallbackReason\u3001failureDetails\u3001agent.error \u548c merge.error \u7edf\u4e00\u8131\u654f\u3001\u53bb\u91cd\u5e76\u9650\u5236\u6761\u6570", async () => {
+    const host = document.createElement("div")
+    document.body.appendChild(host)
+    const root = createRoot(host)
+    const duplicate = "\u91cd\u590d\u8bca\u65ad\nDUPLICATE_SECRET"
+    const run: OutlineMultiAgentRunState = {
+      mode: "single-agent-fallback",
+      status: "fallback",
+      maxConcurrency: 3,
+      fallbackReason: "\u603b\u4f53\u56de\u9000\u539f\u56e0\nFALLBACK_SECRET\nSYSTEM_PROMPT_BODY",
+      failureDetails: [
+        duplicate,
+        "\u91cd\u590d\u8bca\u65ad\nSECOND_DUPLICATE_SECRET",
+        "\u8bca\u65ad\u4e8c",
+        "\u8bca\u65ad\u4e09",
+        "\u8bca\u65ad\u56db",
+        "\u8bca\u65ad\u4e94",
+        "\u8bca\u65ad\u516d\u4e0d\u5e94\u5c55\u793a",
+      ],
+      agents: [{
+        id: "failed-agent",
+        name: "\u5931\u8d25 Agent",
+        kind: "outline",
+        skillNames: ["outline-master-builder"],
+        taskPrompt: "\u4efb\u52a1\u6458\u8981\nTASK_BODY_SECRET",
+        status: "error",
+        error: "Agent \u8c03\u7528\u5931\u8d25\nAGENT_ERROR_SECRET\nAGENT_FULL_CONTENT",
+      }],
+      merge: {
+        status: "error",
+        error: "\u5408\u5e76\u5931\u8d25\nMERGE_ERROR_SECRET\nREQUEST_BODY_SECRET",
+      },
+    }
+
+    await act(async () => root.render(<OutlineMultiAgentPanel run={run} />))
+    const toggle = Array.from(host.querySelectorAll("button")).find((button) => button.textContent?.includes("\u67e5\u770b\u8be6\u60c5"))
+    await act(async () => toggle?.click())
+
+    expect(host.textContent).toContain("\u603b\u4f53\u56de\u9000\u539f\u56e0")
+    expect(host.textContent).toContain("Agent \u8c03\u7528\u5931\u8d25")
+    expect(host.textContent).toContain("\u5408\u5e76\u5931\u8d25")
+    expect(host.textContent?.match(/\u91cd\u590d\u8bca\u65ad/g)).toHaveLength(1)
+    expect(host.textContent).not.toContain("\u8bca\u65ad\u516d\u4e0d\u5e94\u5c55\u793a")
+    for (const secret of [
+      "FALLBACK_SECRET",
+      "SYSTEM_PROMPT_BODY",
+      "DUPLICATE_SECRET",
+      "SECOND_DUPLICATE_SECRET",
+      "TASK_BODY_SECRET",
+      "AGENT_ERROR_SECRET",
+      "AGENT_FULL_CONTENT",
+      "MERGE_ERROR_SECRET",
+      "REQUEST_BODY_SECRET",
+    ]) {
+      expect(host.textContent).not.toContain(secret)
+    }
+
+    await act(async () => root.unmount())
+    host.remove()
+  })
+  it("\u975e\u56de\u9000\u9762\u677f\u4e5f\u4e0d\u80fd\u7ed5\u8fc7\u7edf\u4e00\u8bca\u65ad\u8131\u654f", () => {
+    const run: OutlineMultiAgentRunState = {
+      mode: "multi-agent",
+      status: "done",
+      maxConcurrency: 2,
+      fallbackReason: "\u517c\u5bb9\u56de\u9000\u539f\u56e0\nNORMAL_FALLBACK_SECRET",
+      failureDetails: [
+        "\u91cd\u590d\u5931\u8d25\nNORMAL_DETAIL_SECRET_A",
+        "\u91cd\u590d\u5931\u8d25\nNORMAL_DETAIL_SECRET_B",
+      ],
+      agents: [{
+        id: "normal-agent",
+        name: "\u666e\u901a Agent",
+        kind: "outline",
+        skillNames: ["outline-master-builder"],
+        taskPrompt: "\u666e\u901a\u4efb\u52a1\u6458\u8981\nNORMAL_TASK_SECRET",
+        status: "error",
+        summary: "\u5904\u7406\u6458\u8981\nNORMAL_SUMMARY_SECRET",
+        error: "\u666e\u901a Agent \u5931\u8d25\nNORMAL_AGENT_ERROR_SECRET",
+      }],
+      merge: {
+        status: "error",
+        summary: "\u5408\u5e76\u6458\u8981\nNORMAL_MERGE_SUMMARY_SECRET",
+        error: "\u5408\u5e76\u8bca\u65ad\u5931\u8d25\nNORMAL_MERGE_ERROR_SECRET",
+      },
+    }
+
+    const html = renderToStaticMarkup(<OutlineMultiAgentPanel run={run} />)
+
+    expect(html).toContain("\u666e\u901a Agent \u5931\u8d25")
+    expect(html).toContain("\u5408\u5e76\u8bca\u65ad\u5931\u8d25")
+    expect(html.match(/\u91cd\u590d\u5931\u8d25/g)).toHaveLength(1)
+    for (const secret of [
+      "NORMAL_FALLBACK_SECRET",
+      "NORMAL_DETAIL_SECRET_A",
+      "NORMAL_DETAIL_SECRET_B",
+      "NORMAL_TASK_SECRET",
+      "NORMAL_SUMMARY_SECRET",
+      "NORMAL_AGENT_ERROR_SECRET",
+      "NORMAL_MERGE_SUMMARY_SECRET",
+      "NORMAL_MERGE_ERROR_SECRET",
+    ]) {
+      expect(html).not.toContain(secret)
+    }
+  })
+  it("\u8bca\u65ad\u6458\u8981\u652f\u6301\u56db\u79cd\u6362\u884c\u5206\u9694\u5e76\u63a9\u7801\u5355\u884c\u51ed\u636e\u4e0e URL query \u51ed\u636e", async () => {
+    const host = document.createElement("div")
+    document.body.appendChild(host)
+    const root = createRoot(host)
+    const run: OutlineMultiAgentRunState = {
+      mode: "single-agent-fallback",
+      status: "fallback",
+      maxConcurrency: 2,
+      fallbackReason: "Authorization: Bearer auth-secret\rFALLBACK_CR_BODY",
+      failureDetails: [
+        "URL https://example.test/path?api_key=query-key&token=query-token&safe=1\nFAILURE_LF_BODY",
+        "API key: api-key-value\u2028FAILURE_LS_BODY",
+        "token=token-value\u2029FAILURE_PS_BODY",
+        "password: pass-value secret=secret-value\rFAILURE_CR_BODY",
+      ],
+      agents: [{
+        id: "credential-agent",
+        name: "\u51ed\u636e Agent",
+        kind: "outline",
+        skillNames: [],
+        taskPrompt: "\u4efb\u52a1\u6458\u8981",
+        status: "error",
+        error: "Bearer agent-bearer\u2028AGENT_LS_BODY",
+      }],
+      merge: {
+        status: "error",
+        error: "Authorization=merge-authorization\u2029MERGE_PS_BODY",
+      },
+    }
+
+    await act(async () => root.render(<OutlineMultiAgentPanel run={run} />))
+    const toggle = Array.from(host.querySelectorAll("button")).find((button) => button.textContent?.includes("\u67e5\u770b\u8be6\u60c5"))
+    await act(async () => toggle?.click())
+
+    expect(host.textContent).toContain("Authorization: Bearer ***")
+    expect(host.textContent).toContain("api_key=***")
+    expect(host.textContent).toContain("token=***")
+    expect(host.textContent).toContain("safe=1")
+    expect(host.textContent).toContain("API key: ***")
+    expect(host.textContent).toContain("password: ***")
+    expect(host.textContent).toContain("secret=***")
+    expect(host.textContent).toContain("Bearer ***")
+    for (const hidden of [
+      "auth-secret",
+      "query-key",
+      "query-token",
+      "api-key-value",
+      "token-value",
+      "pass-value",
+      "secret-value",
+      "agent-bearer",
+      "merge-authorization",
+      "FALLBACK_CR_BODY",
+      "FAILURE_LF_BODY",
+      "FAILURE_LS_BODY",
+      "FAILURE_PS_BODY",
+      "FAILURE_CR_BODY",
+      "AGENT_LS_BODY",
+      "MERGE_PS_BODY",
+    ]) {
+      expect(host.textContent).not.toContain(hidden)
+    }
+
+    await act(async () => root.unmount())
+    host.remove()
+  })
 
 
 })
 })

+ 142 - 11
src/components/sources/outline-multi-agent-panel.tsx

@@ -1,3 +1,4 @@
+import { useId, useState } from "react"
 import {
 import {
   AlertTriangle,
   AlertTriangle,
   CheckCircle2,
   CheckCircle2,
@@ -63,11 +64,141 @@ function formatSkillNames(skillNames: string[]): string {
   return skillNames.length > 0 ? skillNames.join("、") : "未指定"
   return skillNames.length > 0 ? skillNames.join("、") : "未指定"
 }
 }
 
 
+const FALLBACK_SUMMARY = "\u591a Agent \u751f\u6210\u5931\u8d25\uff0c\u5df2\u81ea\u52a8\u5207\u6362\u4e3a\u666e\u901a\u751f\u6210\u3002"
+const MAX_DIAGNOSTIC_TEXT_LENGTH = 160
+const MAX_FAILURE_DETAIL_COUNT = 5
+
+function maskDiagnosticCredentials(value: string): string {
+  return value
+    .replace(/(["'])(authorization|api[ _-]?key|access[ _-]?token|refresh[ _-]?token|token|password|passwd|secret)\1(\s*:\s*)(["'])[^"']*\4/gi, "$1$2$1$3$4***$4")
+    .replace(/\b(authorization)\b(\s*[:=]\s*)(bearer\s+)?([^\s&#,;]+)/gi, (_match, key: string, separator: string, scheme?: string) => (
+      `${key}${separator}${scheme ? "Bearer " : ""}***`
+    ))
+    .replace(/\b(bearer)(\s+)([^\s&#,;]+)/gi, "$1$2***")
+    .replace(/\b(api[ _-]?key|access[ _-]?token|refresh[ _-]?token|token|password|passwd|secret)\b(\s*[:=]\s*)([^\s&#,;}]+)/gi, "$1$2***")
+}
+
+function summarizeDiagnosticText(value: string | undefined, fallback: string): string {
+  const firstLine = value
+    ?.split(/[\r\n\u2028\u2029]/)
+    .map((line) => line.trim())
+    .find(Boolean)
+  if (!firstLine) return fallback
+  const masked = maskDiagnosticCredentials(firstLine)
+  return masked.length > MAX_DIAGNOSTIC_TEXT_LENGTH
+    ? `${masked.slice(0, MAX_DIAGNOSTIC_TEXT_LENGTH - 3)}...`
+    : masked
+}
+
+function summarizeFailureDetails(values: string[] | undefined): string[] {
+  const summaries: string[] = []
+  const seen = new Set<string>()
+  for (const value of values ?? []) {
+    const summary = summarizeDiagnosticText(value, "")
+    if (!summary || seen.has(summary)) continue
+    seen.add(summary)
+    summaries.push(summary)
+    if (summaries.length >= MAX_FAILURE_DETAIL_COUNT) break
+  }
+  return summaries
+}
+
 export function OutlineMultiAgentPanel({ run }: OutlineMultiAgentPanelProps) {
 export function OutlineMultiAgentPanel({ run }: OutlineMultiAgentPanelProps) {
+  const [showFallbackDetails, setShowFallbackDetails] = useState(false)
+  const generatedId = useId().replace(/[^a-zA-Z0-9_-]/g, "")
+  const fallbackDetailsId = `outline-multi-agent-fallback-details-${generatedId}`
   if (!run) return null
   if (!run) return null
 
 
   const finishedCount = run.agents.filter((agent) => agent.status === "done").length
   const finishedCount = run.agents.filter((agent) => agent.status === "done").length
   const failedCount = run.agents.filter((agent) => agent.status === "error").length
   const failedCount = run.agents.filter((agent) => agent.status === "error").length
+  const isFallback = run.status === "fallback" || run.mode === "single-agent-fallback"
+  const fallbackReasonSummary = run.fallbackReason
+    ? summarizeDiagnosticText(run.fallbackReason, "\u56de\u9000\u539f\u56e0\u672a\u63d0\u4f9b")
+    : undefined
+  const failureDetailSummaries = summarizeFailureDetails(run.failureDetails)
+  const mergeErrorSummary = run.merge?.error
+    ? summarizeDiagnosticText(run.merge.error, "\u5408\u5e76\u5931\u8d25")
+    : undefined
+
+  if (isFallback) {
+    return (
+      <div className="mb-2 min-w-0 max-w-full overflow-hidden rounded-md border border-amber-200/80 bg-amber-50/55 px-3 py-2.5 text-xs dark:border-amber-900/45 dark:bg-amber-950/15">
+        <div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
+          <div className="flex min-w-0 items-center gap-2 text-[13px] text-amber-950 dark:text-amber-100">
+            <AlertTriangle className="h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" />
+            <span className="min-w-0 break-words [overflow-wrap:anywhere]">{FALLBACK_SUMMARY}</span>
+          </div>
+          <button
+            type="button"
+            className="inline-flex shrink-0 items-center gap-1 rounded px-1.5 py-1 text-xs font-medium text-amber-800 hover:bg-amber-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring dark:text-amber-200 dark:hover:bg-amber-950/50"
+            aria-expanded={showFallbackDetails}
+            aria-controls={showFallbackDetails ? fallbackDetailsId : undefined}
+            onClick={() => setShowFallbackDetails((value) => !value)}
+          >
+            <ChevronRight className={cn("h-3.5 w-3.5 transition-transform", showFallbackDetails && "rotate-90")} />
+            {showFallbackDetails ? "\u6536\u8d77\u8be6\u60c5" : "\u67e5\u770b\u8be6\u60c5"}
+          </button>
+        </div>
+
+        {showFallbackDetails ? (
+          <div
+            id={fallbackDetailsId}
+            role="region"
+            aria-label={"\u591a Agent \u5931\u8d25\u8bca\u65ad\u8be6\u60c5"}
+            className="mt-2 max-h-[min(24rem,55vh)] min-w-0 max-w-full space-y-2 overflow-y-auto overscroll-contain rounded-md border border-amber-200/80 bg-background/80 p-2.5 [overflow-wrap:anywhere] dark:border-amber-900/45"
+          >
+            <div className="grid gap-1 text-muted-foreground sm:grid-cols-2">
+              <div><span className="font-medium text-foreground">{"Agent \u72b6\u6001\uff1a"}</span>{"\u5b8c\u6210 "}{finishedCount}/{run.agents.length}{"\uff0c\u5931\u8d25 "}{failedCount}</div>
+              <div><span className="font-medium text-foreground">{"\u5408\u5e76\u72b6\u6001\uff1a"}</span>{statusLabel(run.merge?.status ?? "skipped")}</div>
+              {mergeErrorSummary ? (
+                <div className="sm:col-span-2"><span className="font-medium text-foreground">{"\u5408\u5e76\u5931\u8d25\u539f\u56e0\uff1a"}</span><span className="break-words text-red-600 dark:text-red-400">{mergeErrorSummary}</span></div>
+              ) : null}
+            </div>
+            {run.agents.map((agent) => (
+              <div key={agent.id} className="min-w-0 rounded-md border border-border/70 bg-background px-2.5 py-2">
+                <div className="flex min-w-0 flex-wrap items-center justify-between gap-1.5">
+                  <span className="min-w-0 break-words font-medium text-foreground">{agent.name}</span>
+                  <span className={cn("inline-flex shrink-0 items-center gap-1 text-muted-foreground", agent.status === "error" && "text-red-600 dark:text-red-400")}>
+                    <StatusIcon status={agent.status} />
+                    {statusLabel(agent.status)}
+                  </span>
+                </div>
+                <dl className="mt-1.5 grid min-w-0 gap-1 leading-5 text-muted-foreground">
+                  <div className="grid min-w-0 grid-cols-[4rem_minmax(0,1fr)] gap-2">
+                    <dt>{"\u4efb\u52a1\u6458\u8981"}</dt>
+                    <dd className="min-w-0 break-words text-foreground/85">{summarizeDiagnosticText(agent.taskPrompt, "\u672a\u63d0\u4f9b")}</dd>
+                  </div>
+                  <div className="grid min-w-0 grid-cols-[4rem_minmax(0,1fr)] gap-2">
+                    <dt>Skill</dt>
+                    <dd className="min-w-0 break-words text-foreground/85">{formatSkillNames(agent.skillNames)}</dd>
+                  </div>
+                  {agent.error ? (
+                    <div className="grid min-w-0 grid-cols-[4rem_minmax(0,1fr)] gap-2">
+                      <dt>{"\u5931\u8d25\u539f\u56e0"}</dt>
+                      <dd className="min-w-0 break-words text-red-600 dark:text-red-400">{summarizeDiagnosticText(agent.error, "\u6267\u884c\u5931\u8d25")}</dd>
+                    </div>
+                  ) : null}
+                </dl>
+              </div>
+            ))}
+            {fallbackReasonSummary ? (
+              <div className="min-w-0 rounded-md border border-amber-200 bg-amber-50/70 px-2.5 py-2 leading-5 text-amber-900 dark:border-amber-900/40 dark:bg-amber-950/20 dark:text-amber-200">
+                <span className="font-medium">{"\u5931\u8d25\u539f\u56e0\uff1a"}</span>
+                <span className="break-words">{fallbackReasonSummary}</span>
+                {failureDetailSummaries.length > 0 ? (
+                  <ul className="mt-1 list-disc space-y-0.5 pl-4">
+                    {failureDetailSummaries.map((detail) => (
+                      <li key={detail} className="break-words">{detail}</li>
+                    ))}
+                  </ul>
+                ) : null}
+              </div>
+            ) : null}
+          </div>
+        ) : null}
+      </div>
+    )
+  }
 
 
   return (
   return (
     <div className="mb-2 max-w-full overflow-hidden rounded-md border border-sky-200/70 bg-sky-50/45 p-3 text-xs dark:border-sky-900/45 dark:bg-sky-950/15">
     <div className="mb-2 max-w-full overflow-hidden rounded-md border border-sky-200/70 bg-sky-50/45 p-3 text-xs dark:border-sky-900/45 dark:bg-sky-950/15">
@@ -111,7 +242,7 @@ export function OutlineMultiAgentPanel({ run }: OutlineMultiAgentPanelProps) {
             <div className="mt-1.5 grid gap-1.5 pl-5 leading-5 text-muted-foreground">
             <div className="mt-1.5 grid gap-1.5 pl-5 leading-5 text-muted-foreground">
               <div className="grid min-w-0 grid-cols-[3rem_minmax(0,1fr)] gap-2">
               <div className="grid min-w-0 grid-cols-[3rem_minmax(0,1fr)] gap-2">
                 <span>任务</span>
                 <span>任务</span>
-                <span className="min-w-0 break-words text-foreground/85">{agent.taskPrompt}</span>
+                <span className="min-w-0 break-words text-foreground/85">{summarizeDiagnosticText(agent.taskPrompt, "\u672a\u63d0\u4f9b")}</span>
               </div>
               </div>
               <div className="grid min-w-0 grid-cols-[3rem_minmax(0,1fr)] gap-2">
               <div className="grid min-w-0 grid-cols-[3rem_minmax(0,1fr)] gap-2">
                 <span>Skill</span>
                 <span>Skill</span>
@@ -120,13 +251,13 @@ export function OutlineMultiAgentPanel({ run }: OutlineMultiAgentPanelProps) {
               {agent.summary ? (
               {agent.summary ? (
                 <div className="grid min-w-0 grid-cols-[3rem_minmax(0,1fr)] gap-2">
                 <div className="grid min-w-0 grid-cols-[3rem_minmax(0,1fr)] gap-2">
                   <span>结果</span>
                   <span>结果</span>
-                  <span className="min-w-0 break-words text-emerald-700 dark:text-emerald-300">{agent.summary}</span>
+                  <span className="min-w-0 break-words text-emerald-700 dark:text-emerald-300">{summarizeDiagnosticText(agent.summary, "\u672a\u63d0\u4f9b")}</span>
                 </div>
                 </div>
               ) : null}
               ) : null}
               {agent.error ? (
               {agent.error ? (
                 <div className="grid min-w-0 grid-cols-[3rem_minmax(0,1fr)] gap-2">
                 <div className="grid min-w-0 grid-cols-[3rem_minmax(0,1fr)] gap-2">
                   <span>错误</span>
                   <span>错误</span>
-                  <span className="min-w-0 break-words text-red-600 dark:text-red-400">{agent.error}</span>
+                  <span className="min-w-0 break-words text-red-600 dark:text-red-400">{summarizeDiagnosticText(agent.error, "\u6267\u884c\u5931\u8d25")}</span>
                 </div>
                 </div>
               ) : null}
               ) : null}
             </div>
             </div>
@@ -144,22 +275,22 @@ export function OutlineMultiAgentPanel({ run }: OutlineMultiAgentPanelProps) {
             </span>
             </span>
           </div>
           </div>
           {run.merge.summary ? (
           {run.merge.summary ? (
-            <div className="mt-1 break-words leading-5 text-muted-foreground">{run.merge.summary}</div>
+            <div className="mt-1 break-words leading-5 text-muted-foreground">{summarizeDiagnosticText(run.merge.summary, "\u672a\u63d0\u4f9b")}</div>
           ) : null}
           ) : null}
           {run.merge.error ? (
           {run.merge.error ? (
-            <div className="mt-1 break-words leading-5 text-red-600 dark:text-red-400">{run.merge.error}</div>
+            <div className="mt-1 break-words leading-5 text-red-600 dark:text-red-400">{summarizeDiagnosticText(run.merge.error, "\u5408\u5e76\u5931\u8d25")}</div>
           ) : null}
           ) : null}
         </div>
         </div>
       ) : null}
       ) : null}
 
 
-      {run.fallbackReason ? (
+      {fallbackReasonSummary ? (
         <div className="mt-2 rounded-md border border-amber-200 bg-amber-50/70 px-2.5 py-2 leading-5 text-amber-900 dark:border-amber-900/40 dark:bg-amber-950/20 dark:text-amber-200">
         <div className="mt-2 rounded-md border border-amber-200 bg-amber-50/70 px-2.5 py-2 leading-5 text-amber-900 dark:border-amber-900/40 dark:bg-amber-950/20 dark:text-amber-200">
-          <span className="font-medium">回退原因:</span>
-          {run.fallbackReason}
-          {run.failureDetails?.length ? (
+          <span className="font-medium">{"\u56de\u9000\u539f\u56e0\uff1a"}</span>
+          {fallbackReasonSummary}
+          {failureDetailSummaries.length > 0 ? (
             <ul className="mt-1 list-disc space-y-0.5 pl-4">
             <ul className="mt-1 list-disc space-y-0.5 pl-4">
-              {run.failureDetails.map((detail, index) => (
-                <li key={`${detail}-${index}`} className="break-words">{detail}</li>
+              {failureDetailSummaries.map((detail) => (
+                <li key={detail} className="break-words">{detail}</li>
               ))}
               ))}
             </ul>
             </ul>
           ) : null}
           ) : null}

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

@@ -0,0 +1,109 @@
+// @vitest-environment jsdom
+import { act } from "react"
+import { createRoot } from "react-dom/client"
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
+
+import { NextStepCard } from "./outline-next-step-card"
+
+const roots: Array<{ root: ReturnType<typeof createRoot>; container: HTMLDivElement }> = []
+
+beforeEach(() => {
+  ;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+})
+
+async function renderCard(props: Partial<React.ComponentProps<typeof NextStepCard>> = {}) {
+  const container = document.createElement("div")
+  document.body.appendChild(container)
+  const root = createRoot(container)
+  roots.push({ root, container })
+  const onSelectRecommendation = props.onSelectRecommendation ?? vi.fn(async () => true)
+  await act(async () => {
+    root.render(
+      <NextStepCard
+        recommendation={{ recommendations: [
+          { id: "A", label: "继续完善人物弧光", reason: "人物动机仍可加强" },
+          { id: "B", label: "检查伏笔闭环", reason: "避免遗漏" },
+        ] }}
+        onSelectRecommendation={onSelectRecommendation}
+        {...props}
+      />,
+    )
+  })
+  return { container, onSelectRecommendation }
+}
+
+afterEach(async () => {
+  while (roots.length) {
+    const mounted = roots.pop()!
+    await act(async () => mounted.root.unmount())
+    mounted.container.remove()
+  }
+})
+
+describe("NextStepCard", () => {
+  it("点击推荐项时把普通文本 label 原样交给当前会话发送行为,并在完成前防止重复点击", async () => {
+    let finish!: (value: boolean) => void
+    const pending = new Promise<boolean>((resolve) => { finish = resolve })
+    const onSelectRecommendation = vi.fn(() => pending)
+    const { container } = await renderCard({ onSelectRecommendation })
+    const button = Array.from(container.querySelectorAll("button"))[0] as HTMLButtonElement
+
+    await act(async () => {
+      button.click()
+      button.click()
+      await Promise.resolve()
+    })
+
+    expect(onSelectRecommendation).toHaveBeenCalledTimes(1)
+    expect(onSelectRecommendation).toHaveBeenCalledWith("A", "继续完善人物弧光")
+    expect(button.disabled).toBe(true)
+    expect(button.getAttribute("aria-busy")).toBe("true")
+
+    await act(async () => finish(true))
+    expect(button.disabled).toBe(false)
+    expect(button.getAttribute("aria-busy")).toBeNull()
+  })
+
+  it("发送失败后恢复按钮,允许用户再次点击", async () => {
+    const onSelectRecommendation = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true)
+    const { container } = await renderCard({ onSelectRecommendation })
+    const button = Array.from(container.querySelectorAll("button"))[0] as HTMLButtonElement
+
+    await act(async () => { button.click(); await Promise.resolve(); await Promise.resolve() })
+    expect(button.disabled).toBe(false)
+    await act(async () => { button.click(); await Promise.resolve(); await Promise.resolve() })
+    expect(onSelectRecommendation).toHaveBeenCalledTimes(2)
+  })
+
+  it("当前会话生成中时禁用全部推荐按钮并提供中文原因", async () => {
+    const { container, onSelectRecommendation } = await renderCard({
+      disabled: true,
+      disabledReason: "当前会话正在生成,请等待生成完成后再选择下一步。",
+    })
+    const buttons = Array.from(container.querySelectorAll("button")) as HTMLButtonElement[]
+
+    expect(buttons.every((button) => button.disabled)).toBe(true)
+    expect(buttons.every((button) => button.title === "当前会话正在生成,请等待生成完成后再选择下一步。")).toBe(true)
+    await act(async () => buttons[0].click())
+    expect(onSelectRecommendation).not.toHaveBeenCalled()
+  })
+  it("HTML/Markdown label stays plain text and is returned unchanged", async () => {
+    const label = "<script>alert(1)</script> **\u7ee7\u7eed**"
+    const onSelectRecommendation = vi.fn(async () => true)
+    const { container } = await renderCard({
+      recommendation: { recommendations: [{ id: "safe", label, reason: "plain text" }] },
+      onSelectRecommendation,
+    })
+    const button = container.querySelector("button") as HTMLButtonElement
+
+    expect(container.querySelector("script")).toBeNull()
+    expect(button.textContent).toContain(label)
+    await act(async () => {
+      button.click()
+      await Promise.resolve()
+      await Promise.resolve()
+    })
+    expect(onSelectRecommendation).toHaveBeenCalledWith("safe", label)
+  })
+
+})

+ 44 - 14
src/components/sources/outline-next-step-card.tsx

@@ -1,29 +1,59 @@
+import { useRef, useState } from "react"
+
 import type { NextStepRecommendation } from "@/lib/novel/outline-next-step"
 import type { NextStepRecommendation } from "@/lib/novel/outline-next-step"
 
 
 interface NextStepCardProps {
 interface NextStepCardProps {
   recommendation: NextStepRecommendation
   recommendation: NextStepRecommendation
-  onSelectRecommendation: (recId: string, label: string) => void
+  onSelectRecommendation: (recId: string, label: string) => Promise<boolean>
+  disabled?: boolean
+  disabledReason?: string
 }
 }
 
 
-export function NextStepCard({ recommendation, onSelectRecommendation }: NextStepCardProps) {
+export function NextStepCard({
+  recommendation,
+  onSelectRecommendation,
+  disabled = false,
+  disabledReason,
+}: NextStepCardProps) {
+  const [busyRecommendationId, setBusyRecommendationId] = useState<string | null>(null)
+  const sendingRef = useRef(false)
   if (!recommendation.recommendations || recommendation.recommendations.length === 0) return null
   if (!recommendation.recommendations || recommendation.recommendations.length === 0) return null
 
 
+  const selectRecommendation = async (recId: string, label: string) => {
+    if (disabled || sendingRef.current) return
+    sendingRef.current = true
+    setBusyRecommendationId(recId)
+    try {
+      await onSelectRecommendation(recId, label)
+    } finally {
+      sendingRef.current = false
+      setBusyRecommendationId(null)
+    }
+  }
+
   return (
   return (
     <div className="mt-3 rounded-md border border-sky-500/30 bg-sky-50/30 p-3 dark:bg-sky-950/10">
     <div className="mt-3 rounded-md border border-sky-500/30 bg-sky-50/30 p-3 dark:bg-sky-950/10">
       <div className="text-sm font-medium mb-2">接下来想做什么?</div>
       <div className="text-sm font-medium mb-2">接下来想做什么?</div>
       <div className="space-y-2">
       <div className="space-y-2">
-        {recommendation.recommendations.map((rec) => (
-          <button
-            key={rec.id}
-            className="w-full rounded-md border px-3 py-2 text-left text-sm hover:bg-accent transition-colors"
-            onClick={() => onSelectRecommendation(rec.id, rec.label)}
-          >
-            <div className="font-medium">{rec.label}</div>
-            {rec.reason ? (
-              <div className="text-xs text-muted-foreground">{rec.reason}</div>
-            ) : null}
-          </button>
-        ))}
+        {recommendation.recommendations.map((rec) => {
+          const busy = busyRecommendationId === rec.id
+          return (
+            <button
+              key={rec.id}
+              type="button"
+              className="w-full rounded-md border px-3 py-2 text-left text-sm hover:bg-accent transition-colors disabled:cursor-not-allowed disabled:opacity-50"
+              onClick={() => void selectRecommendation(rec.id, rec.label)}
+              disabled={disabled || busyRecommendationId !== null}
+              title={disabled ? disabledReason : undefined}
+              aria-busy={busy || undefined}
+            >
+              <div className="font-medium">{rec.label}</div>
+              {rec.reason ? (
+                <div className="text-xs text-muted-foreground">{rec.reason}</div>
+              ) : null}
+            </button>
+          )
+        })}
       </div>
       </div>
     </div>
     </div>
   )
   )

+ 45 - 0
src/components/sources/outline-wizard-dialog.spec.tsx

@@ -4,6 +4,7 @@ import { act } from "react"
 import { createRoot, type Root } from "react-dom/client"
 import { createRoot, type Root } from "react-dom/client"
 import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
 import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
 import { OutlineWizardDialog } from "./outline-wizard-dialog"
 import { OutlineWizardDialog } from "./outline-wizard-dialog"
+import { createNovelGenerationRequestPackage } from "@/lib/novel/novel-generation-request-package"
 
 
 ;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT =
 ;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT =
   true
   true
@@ -98,4 +99,48 @@ describe("OutlineWizardDialog", () => {
     })
     })
     expect(onOpenChange).toHaveBeenCalledWith(false)
     expect(onOpenChange).toHaveBeenCalledWith(false)
   })
   })
+  it("marks real initial values as implicit", async () => {
+    const { createDefaultOutlineWizardRequest } = await import("./outline-wizard-dialog")
+    const request = createDefaultOutlineWizardRequest()
+    expect(request.sellingPoints).toEqual(["AI 根据灵感推荐"])
+    expect(request).toMatchObject({ length: "long", channel: "male", narrative: "thirdPerson", explicit: {} })
+  })
+
+  it("clears explicit genre fields when channel derives a new genre", async () => {
+    const onSubmit = vi.fn()
+    await act(async () => root.render(<OutlineWizardDialog open onOpenChange={() => {}} onSubmit={onSubmit} />))
+    const select = document.body.querySelector("select") as HTMLSelectElement
+    await act(async () => {
+      const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value")?.set
+      setter?.call(select, "custom")
+      select.dispatchEvent(new Event("change", { bubbles: true }))
+    })
+    const custom = document.body.querySelector("#outline-wizard-custom-genre") as HTMLInputElement
+    await act(async () => {
+      const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set
+      setter?.call(custom, "自定义题材")
+      custom.dispatchEvent(new Event("input", { bubbles: true }))
+      findButton(document.body, "女频").click()
+    })
+    const textarea = document.body.querySelector("textarea") as HTMLTextAreaElement
+    await act(async () => {
+      const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set
+      setter?.call(textarea, "灵感")
+      textarea.dispatchEvent(new Event("input", { bubbles: true }))
+      findButton(document.body, "确定生成").click()
+    })
+    expect(onSubmit.mock.calls[0][0].explicit.genre).toBeUndefined()
+    expect(onSubmit.mock.calls[0][0].explicit.customGenre).toBeUndefined()
+    expect(createNovelGenerationRequestPackage(onSubmit.mock.calls[0][0], "model").details.join("\n")).not.toContain("题材类型")
+    await act(async () => {
+      const currentSelect = document.body.querySelector("select") as HTMLSelectElement
+      const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value")?.set
+      setter?.call(currentSelect, currentSelect.options[1].value)
+      currentSelect.dispatchEvent(new Event("change", { bubbles: true }))
+    })
+    await act(async () => findButton(document.body, "确定生成").click())
+    expect(onSubmit.mock.calls[1][0].explicit.genre).toBe(true)
+    expect(createNovelGenerationRequestPackage(onSubmit.mock.calls[1][0], "model").details.join("\n")).toContain("题材类型")
+  })
+
 })
 })

+ 28 - 15
src/components/sources/outline-wizard-dialog.tsx

@@ -24,6 +24,7 @@ import {
   type OutlineWizardChannel,
   type OutlineWizardChannel,
   type OutlineWizardOption,
   type OutlineWizardOption,
   type OutlineWizardRequest,
   type OutlineWizardRequest,
+  type OutlineWizardExplicitField,
 } from "@/lib/novel/outline-wizard"
 } from "@/lib/novel/outline-wizard"
 
 
 interface OutlineWizardDialogProps {
 interface OutlineWizardDialogProps {
@@ -36,7 +37,7 @@ function firstGenre(channel: OutlineWizardChannel): string {
   return getOutlineWizardGenres(channel)[0]?.value ?? "custom"
   return getOutlineWizardGenres(channel)[0]?.value ?? "custom"
 }
 }
 
 
-function createDefaultRequest(): OutlineWizardRequest {
+export function createDefaultOutlineWizardRequest(): OutlineWizardRequest {
   return {
   return {
     task: "newBook",
     task: "newBook",
     length: "long",
     length: "long",
@@ -49,6 +50,7 @@ function createDefaultRequest(): OutlineWizardRequest {
     scale: "",
     scale: "",
     narrative: "thirdPerson",
     narrative: "thirdPerson",
     materialSource: "none",
     materialSource: "none",
+    explicit: {},
   }
   }
 }
 }
 
 
@@ -95,7 +97,7 @@ export function OutlineWizardDialog({
   onSubmit,
   onSubmit,
 }: OutlineWizardDialogProps) {
 }: OutlineWizardDialogProps) {
   const [request, setRequest] = useState<OutlineWizardRequest>(() =>
   const [request, setRequest] = useState<OutlineWizardRequest>(() =>
-    createDefaultRequest(),
+    createDefaultOutlineWizardRequest(),
   )
   )
   const [error, setError] = useState("")
   const [error, setError] = useState("")
   const genreOptions = useMemo(
   const genreOptions = useMemo(
@@ -105,12 +107,22 @@ export function OutlineWizardDialog({
 
 
   useEffect(() => {
   useEffect(() => {
     if (!open) return
     if (!open) return
-    setRequest(createDefaultRequest())
+    setRequest(createDefaultOutlineWizardRequest())
     setError("")
     setError("")
   }, [open])
   }, [open])
 
 
-  function updateRequest(next: Partial<OutlineWizardRequest>) {
-    setRequest((current) => ({ ...current, ...next }))
+  function updateRequest(
+    next: Partial<OutlineWizardRequest>,
+    explicitFields: OutlineWizardExplicitField[] = [],
+  ) {
+    setRequest((current) => ({
+      ...current,
+      ...next,
+      explicit: {
+        ...current.explicit,
+        ...Object.fromEntries(explicitFields.map((field) => [field, true])),
+      },
+    }))
     setError("")
     setError("")
   }
   }
 
 
@@ -120,6 +132,7 @@ export function OutlineWizardDialog({
       channel,
       channel,
       genre: firstGenre(channel),
       genre: firstGenre(channel),
       customGenre: "",
       customGenre: "",
+      explicit: { ...current.explicit, channel: true, genre: undefined, customGenre: undefined },
     }))
     }))
     setError("")
     setError("")
   }
   }
@@ -168,14 +181,14 @@ export function OutlineWizardDialog({
               label="生成任务"
               label="生成任务"
               options={OUTLINE_WIZARD_TASK_OPTIONS}
               options={OUTLINE_WIZARD_TASK_OPTIONS}
               value={request.task}
               value={request.task}
-              onChange={(task) => updateRequest({ task })}
+              onChange={(task) => updateRequest({ task }, ["task"])}
             />
             />
 
 
             <OptionGroup
             <OptionGroup
               label="篇幅类型"
               label="篇幅类型"
               options={OUTLINE_WIZARD_LENGTH_OPTIONS}
               options={OUTLINE_WIZARD_LENGTH_OPTIONS}
               value={request.length}
               value={request.length}
-              onChange={(length) => updateRequest({ length })}
+              onChange={(length) => updateRequest({ length }, ["length"])}
             />
             />
 
 
             <OptionGroup
             <OptionGroup
@@ -191,7 +204,7 @@ export function OutlineWizardDialog({
                 <select
                 <select
                   id="outline-wizard-genre"
                   id="outline-wizard-genre"
                   value={request.genre}
                   value={request.genre}
-                  onChange={(event) => updateRequest({ genre: event.target.value })}
+                  onChange={(event) => updateRequest({ genre: event.target.value }, ["genre"])}
                   className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
                   className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
                 >
                 >
                   {genreOptions.map((genre) => (
                   {genreOptions.map((genre) => (
@@ -207,7 +220,7 @@ export function OutlineWizardDialog({
                   id="outline-wizard-scale"
                   id="outline-wizard-scale"
                   value={request.scale}
                   value={request.scale}
                   placeholder="例如:100章左右、30万字、AI 根据题材判断"
                   placeholder="例如:100章左右、30万字、AI 根据题材判断"
-                  onChange={(event) => updateRequest({ scale: event.target.value })}
+                  onChange={(event) => updateRequest({ scale: event.target.value }, ["scale"])}
                 />
                 />
               </div>
               </div>
             </div>
             </div>
@@ -220,7 +233,7 @@ export function OutlineWizardDialog({
                   value={request.customGenre}
                   value={request.customGenre}
                   placeholder="请输入你想要的题材"
                   placeholder="请输入你想要的题材"
                   onChange={(event) =>
                   onChange={(event) =>
-                    updateRequest({ customGenre: event.target.value })
+                    updateRequest({ customGenre: event.target.value }, ["customGenre"])
                   }
                   }
                 />
                 />
               </div>
               </div>
@@ -236,7 +249,7 @@ export function OutlineWizardDialog({
                 placeholder="写下故事灵感、主角处境、爽点方向,或说明要分析/修改/补全的处理要求"
                 placeholder="写下故事灵感、主角处境、爽点方向,或说明要分析/修改/补全的处理要求"
                 className="min-h-28 resize-y"
                 className="min-h-28 resize-y"
                 onChange={(event) =>
                 onChange={(event) =>
-                  updateRequest({ inspiration: event.target.value })
+                  updateRequest({ inspiration: event.target.value }, ["inspiration"])
                 }
                 }
               />
               />
             </div>
             </div>
@@ -260,7 +273,7 @@ export function OutlineWizardDialog({
                           request.sellingPoints,
                           request.sellingPoints,
                           sellingPoint,
                           sellingPoint,
                         ),
                         ),
-                      })
+                      }, ["sellingPoints"])
                     }
                     }
                   >
                   >
                     {sellingPoint}
                     {sellingPoint}
@@ -283,7 +296,7 @@ export function OutlineWizardDialog({
                       onChange={() =>
                       onChange={() =>
                         updateRequest({
                         updateRequest({
                           targets: toggleListValue(request.targets, target),
                           targets: toggleListValue(request.targets, target),
-                        })
+                        }, ["targets"])
                       }
                       }
                       className="h-4 w-4 accent-primary"
                       className="h-4 w-4 accent-primary"
                     />
                     />
@@ -297,14 +310,14 @@ export function OutlineWizardDialog({
               label="叙事方式"
               label="叙事方式"
               options={OUTLINE_WIZARD_NARRATIVE_OPTIONS}
               options={OUTLINE_WIZARD_NARRATIVE_OPTIONS}
               value={request.narrative}
               value={request.narrative}
-              onChange={(narrative) => updateRequest({ narrative })}
+              onChange={(narrative) => updateRequest({ narrative }, ["narrative"])}
             />
             />
 
 
             <OptionGroup
             <OptionGroup
               label="已有资料来源"
               label="已有资料来源"
               options={OUTLINE_WIZARD_MATERIAL_OPTIONS}
               options={OUTLINE_WIZARD_MATERIAL_OPTIONS}
               value={request.materialSource}
               value={request.materialSource}
-              onChange={(materialSource) => updateRequest({ materialSource })}
+              onChange={(materialSource) => updateRequest({ materialSource }, ["materialSource"])}
             />
             />
 
 
             {error ? (
             {error ? (

+ 44 - 0
src/lib/ai-outline-model-initialization.spec.ts

@@ -0,0 +1,44 @@
+import { beforeEach, describe, expect, it, vi } from "vitest"
+
+const persistenceMocks = vi.hoisted(() => ({
+  loadAiOutlineModel: vi.fn<() => Promise<string | null>>(),
+}))
+
+vi.mock("@/lib/project-store", () => ({
+  loadAiOutlineModel: persistenceMocks.loadAiOutlineModel,
+}))
+
+import { initializeAiOutlineModelFromStorage } from "@/lib/ai-outline-model-initialization"
+import { useWikiStore } from "@/stores/wiki-store"
+
+describe("AI outline model initialization", () => {
+  beforeEach(() => {
+    persistenceMocks.loadAiOutlineModel.mockReset()
+    useWikiStore.setState({ aiOutlineModel: "" })
+  })
+
+  it("does not overwrite a user selection made while the stored value is loading", async () => {
+    let resolveLoad!: (value: string | null) => void
+    persistenceMocks.loadAiOutlineModel.mockReturnValue(new Promise((resolve) => {
+      resolveLoad = resolve
+    }))
+
+    const revisionBeforeLoad = useWikiStore.getState().aiOutlineModelRevision
+    const initialization = initializeAiOutlineModelFromStorage()
+    useWikiStore.getState().setAiOutlineModel("openai/new-selection")
+    expect(useWikiStore.getState().aiOutlineModelRevision).toBe(revisionBeforeLoad + 1)
+    resolveLoad("openai/old-stored-selection")
+    await initialization
+
+    expect(useWikiStore.getState().aiOutlineModel).toBe("openai/new-selection")
+  })
+
+  it("applies the stored model when no newer selection is made", async () => {
+    persistenceMocks.loadAiOutlineModel.mockResolvedValue("openai/stored-selection")
+
+    await initializeAiOutlineModelFromStorage()
+
+    expect(useWikiStore.getState().aiOutlineModel).toBe("openai/stored-selection")
+  })
+
+})

+ 12 - 0
src/lib/ai-outline-model-initialization.ts

@@ -0,0 +1,12 @@
+import { loadAiOutlineModel } from "@/lib/project-store"
+import { useWikiStore } from "@/stores/wiki-store"
+
+export async function initializeAiOutlineModelFromStorage(): Promise<void> {
+  const expectedRevision = useWikiStore.getState().aiOutlineModelRevision
+  const storedModel = await loadAiOutlineModel()
+  if (!storedModel) return
+
+  const currentState = useWikiStore.getState()
+  if (currentState.aiOutlineModelRevision !== expectedRevision) return
+  useWikiStore.setState({ aiOutlineModel: storedModel })
+}

+ 65 - 0
src/lib/llm-model-keys.spec.ts

@@ -0,0 +1,65 @@
+import { describe, expect, it } from "vitest"
+
+import { getStableAvailableModelKey } from "@/lib/llm-model-keys"
+import type { ProviderConfigs } from "@/stores/wiki-store"
+
+function saved(id: string, name = id) {
+  return { id, model: id, name, createdAt: 1 }
+}
+
+describe("stable available model keys", () => {
+  it("treats a slash-containing legacy model id as a whole when its prefix is not a provider", () => {
+    const configs: ProviderConfigs = {
+      openai: { enabled: true, apiKey: "key", savedModels: [saved("vendor/family/model-v1")] },
+    }
+
+    expect(getStableAvailableModelKey("vendor/family/model-v1", configs))
+      .toBe("openai/vendor/family/model-v1")
+  })
+
+  it("uses provider/model parsing when the slash prefix is a real provider id", () => {
+    const configs: ProviderConfigs = {
+      vendor: { enabled: true, apiKey: "key", savedModels: [saved("family/model-v1")] },
+      openai: { enabled: true, apiKey: "key", savedModels: [saved("vendor/family/model-v1")] },
+    }
+
+    expect(getStableAvailableModelKey("vendor/family/model-v1", configs))
+      .toBe("vendor/family/model-v1")
+  })
+
+  it("uses the existing available-provider order for duplicate legacy model ids", () => {
+    const configs: ProviderConfigs = {
+      "custom-first": { enabled: true, apiKey: "key", savedModels: [saved("shared/model")] },
+      anthropic: { enabled: true, apiKey: "key", savedModels: [saved("shared/model")] },
+      openai: { enabled: true, apiKey: "key", savedModels: [saved("shared/model")] },
+    }
+
+    expect(getStableAvailableModelKey("shared/model", configs))
+      .toBe("anthropic/shared/model")
+  })
+
+  it("falls back to a full legacy model id when the real provider prefix is disabled", () => {
+    const legacyModelId = "anthropic/claude-3-7-sonnet"
+    const configs: ProviderConfigs = {
+      anthropic: { enabled: false, apiKey: "old-key", savedModels: [saved("claude-3-7-sonnet")] },
+      openrouter: { enabled: true, apiKey: "key", savedModels: [saved(legacyModelId)] },
+    }
+
+    expect(getStableAvailableModelKey(legacyModelId, configs))
+      .toBe("openrouter/anthropic/claude-3-7-sonnet")
+  })
+
+  it("uses stable available-provider order when multiple other providers store the full legacy id", () => {
+    const legacyModelId = "anthropic/claude-3-7-sonnet"
+    const configs: ProviderConfigs = {
+      anthropic: { enabled: true, apiKey: "key", savedModels: [saved("another-model")] },
+      "custom-first": { enabled: true, apiKey: "key", savedModels: [saved(legacyModelId)] },
+      openrouter: { enabled: true, apiKey: "key", savedModels: [saved(legacyModelId)] },
+      groq: { enabled: true, apiKey: "key", savedModels: [saved(legacyModelId)] },
+    }
+
+    expect(getStableAvailableModelKey(legacyModelId, configs))
+      .toBe("openrouter/anthropic/claude-3-7-sonnet")
+  })
+
+})

+ 55 - 24
src/lib/llm-model-keys.ts

@@ -1,34 +1,65 @@
-import type { ProviderConfigs } from "@/stores/wiki-store"
+import type { ProviderConfigs, ProviderOverride } from "@/stores/wiki-store"
+
+function isProviderAvailable(providerId: string, config: ProviderOverride): boolean {
+  return providerId.startsWith("custom-")
+    ? config.enabled !== false
+    : config.enabled === true
+}
+
+function getAvailableProviderEntries(
+  providerConfigs: ProviderConfigs,
+): Array<[string, ProviderOverride]> {
+  const entries = Object.entries(providerConfigs)
+  return [
+    ...entries.filter(([providerId, config]) =>
+      !providerId.startsWith("custom-") && isProviderAvailable(providerId, config)),
+    ...entries.filter(([providerId, config]) =>
+      providerId.startsWith("custom-") && isProviderAvailable(providerId, config)),
+  ]
+}
 
 
 export function hasAvailableModels(providerConfigs: ProviderConfigs): boolean {
 export function hasAvailableModels(providerConfigs: ProviderConfigs): boolean {
-  for (const key of Object.keys(providerConfigs)) {
-    const config = providerConfigs[key]
-    if (key.startsWith("custom-")) {
-      if (config.enabled === false) continue
-    } else {
-      if (config.enabled !== true) continue
-    }
-    if (config.savedModels && config.savedModels.length > 0) {
-      return true
-    }
-  }
-  return false
+  return getAvailableProviderEntries(providerConfigs)
+    .some(([, config]) => (config.savedModels?.length ?? 0) > 0)
 }
 }
 
 
 export function getFirstAvailableModelKey(providerConfigs: ProviderConfigs): string {
 export function getFirstAvailableModelKey(providerConfigs: ProviderConfigs): string {
-  for (const key of Object.keys(providerConfigs)) {
-    if (key.startsWith("custom-")) continue
-    const config = providerConfigs[key]
-    if (config.enabled !== true) continue
+  for (const [providerId, config] of getAvailableProviderEntries(providerConfigs)) {
     const first = config.savedModels?.[0]
     const first = config.savedModels?.[0]
-    if (first) return `${key}/${first.model}`
+    if (first) return `${providerId}/${first.model}`
   }
   }
-  for (const key of Object.keys(providerConfigs)) {
-    if (!key.startsWith("custom-")) continue
-    const config = providerConfigs[key]
-    if (config.enabled === false) continue
-    const first = config.savedModels?.[0]
-    if (first) return `${key}/${first.model}`
+  return ""
+}
+
+export function getStableAvailableModelKey(
+  targetModel: string,
+  providerConfigs: ProviderConfigs,
+): string {
+  const trimmed = targetModel.trim()
+  if (!trimmed) return ""
+
+  let exactProviderId: string | null = null
+  const slashIdx = trimmed.indexOf("/")
+  if (slashIdx > 0) {
+    const providerId = trimmed.slice(0, slashIdx)
+    if (Object.prototype.hasOwnProperty.call(providerConfigs, providerId)) {
+      exactProviderId = providerId
+      const modelId = trimmed.slice(slashIdx + 1)
+      const config = providerConfigs[providerId]
+      if (
+        isProviderAvailable(providerId, config)
+        && config.savedModels?.some((model) => model.model === modelId)
+      ) {
+        return `${providerId}/${modelId}`
+      }
+    }
+  }
+
+  for (const [providerId, config] of getAvailableProviderEntries(providerConfigs)) {
+    if (providerId === exactProviderId) continue
+    if (config.savedModels?.some((model) => model.model === trimmed)) {
+      return `${providerId}/${trimmed}`
+    }
   }
   }
   return ""
   return ""
 }
 }

+ 29 - 0
src/lib/novel/markdown-quality-ai-repair.spec.ts

@@ -0,0 +1,29 @@
+import { describe, expect, it, vi } from "vitest"
+
+import type { streamChat } from "@/lib/llm-client"
+import { repairMarkdownFormatWithAi } from "./markdown-quality-ai-repair"
+
+describe("repairMarkdownFormatWithAi", () => {
+  it("把正文和动态 max_tokens 传给 streamChat 并聚合输出", async () => {
+    const stream = vi.fn<typeof streamChat>(async (_config, messages, callbacks, _signal, overrides) => {
+      expect(messages).toHaveLength(1)
+      expect(messages[0]?.role).toBe("user")
+      expect(messages[0]?.content).toContain("# 人物设定")
+      expect(overrides).toEqual({ temperature: 0, max_tokens: 768 })
+      callbacks.onToken("# 人物")
+      callbacks.onToken("设定")
+      callbacks.onDone()
+    })
+
+    const result = await repairMarkdownFormatWithAi({
+      content: "# 人物设定",
+      llmConfig: {} as Parameters<typeof streamChat>[0],
+      signal: new AbortController().signal,
+      maxTokens: 768,
+      stream,
+    })
+
+    expect(result).toBe("# 人物设定")
+    expect(stream).toHaveBeenCalledTimes(1)
+  })
+})

+ 38 - 0
src/lib/novel/markdown-quality-ai-repair.ts

@@ -0,0 +1,38 @@
+import { streamChat } from "@/lib/llm-client"
+import {
+  buildMarkdownFormatRepairMessages,
+  buildMarkdownRepairRequestOverrides,
+} from "./markdown-quality-finalizer"
+
+export interface RepairMarkdownFormatWithAiOptions {
+  content: string
+  llmConfig: Parameters<typeof streamChat>[0]
+  signal: AbortSignal
+  maxTokens: number
+  stream?: typeof streamChat
+}
+
+export async function repairMarkdownFormatWithAi(
+  options: RepairMarkdownFormatWithAiOptions,
+): Promise<string> {
+  const stream = options.stream ?? streamChat
+  let result = ""
+  let repairError: Error | null = null
+  await stream(
+    options.llmConfig,
+    buildMarkdownFormatRepairMessages(options.content),
+    {
+      onToken: (token) => {
+        result += token
+      },
+      onDone: () => {},
+      onError: (error) => {
+        repairError = error
+      },
+    },
+    options.signal,
+    buildMarkdownRepairRequestOverrides(options.maxTokens),
+  )
+  if (repairError) throw repairError
+  return result.trim()
+}

+ 212 - 0
src/lib/novel/markdown-quality-finalizer.spec.ts

@@ -0,0 +1,212 @@
+import { describe, expect, it, vi } from "vitest"
+
+import {
+  buildMarkdownFormatRepairMessages,
+  buildMarkdownRepairRequestOverrides,
+  estimateMarkdownRepairTokens,
+  finalizeStructuredMarkdownMessage,
+  MARKDOWN_AI_REPAIR_MAX_INPUT_TOKENS,
+  MARKDOWN_AI_REPAIR_PROMPT_TOKEN_OVERHEAD,
+  planMarkdownAiRepair,
+} from "./markdown-quality-finalizer"
+
+describe("finalizeStructuredMarkdownMessage", () => {
+  it("全文 markdown 围栏即使只有一个标题也必须本地去除围栏", async () => {
+    const repairWithAi = vi.fn<(content: string) => Promise<string>>()
+
+    const finalMessage = await finalizeStructuredMarkdownMessage(
+      "```markdown\n# 唯一标题\n```",
+      { enabled: true, repairWithAi, onFailure: vi.fn() },
+    )
+
+    expect(finalMessage).toBe("# 唯一标题")
+    expect(repairWithAi).not.toHaveBeenCalled()
+  })
+
+  it("让全文 markdown 围栏进入本地修复并返回最终消息正文", async () => {
+    const repairWithAi = vi.fn<(content: string) => Promise<string>>()
+    const source = "```markdown\n人物设定\n\n- 姓名:林川\n- 目标:复仇\n```"
+
+    const finalMessage = await finalizeStructuredMarkdownMessage(source, {
+      enabled: true,
+      repairWithAi,
+      onFailure: vi.fn(),
+    })
+
+    expect(finalMessage).toBe("# 人物设定\n\n- 姓名:林川\n- 目标:复仇")
+    expect(repairWithAi).not.toHaveBeenCalled()
+  })
+
+  it("保留尾部合法保存协议且只修复协议前正文", async () => {
+    const protocol = '```json\n{"outlineSaveRequest":{"fileName":"人物.md"}}\n```'
+    const source = `\`\`\`markdown\n人物设定\n\n- 姓名:林川\n- 目标:复仇\n\`\`\`\n\n${protocol}`
+
+    const finalMessage = await finalizeStructuredMarkdownMessage(source, {
+      enabled: true,
+      repairWithAi: vi.fn(),
+      onFailure: vi.fn(),
+    })
+
+    expect(finalMessage).toBe(`# 人物设定\n\n- 姓名:林川\n- 目标:复仇\n\n${protocol}`)
+  })
+
+  it.each([
+    { name: "单换行", separator: "\n", newline: "\n" },
+    { name: "CRLF", separator: "\r\n", newline: "\r\n" },
+    { name: "紧邻", separator: "", newline: "\n" },
+  ])("$name 保存协议不进入 AI 输入", async ({ separator, newline }) => {
+    const body = "| 人物 | 目标 |\n| --- | --- |\n| 林川 | 复仇 |"
+    const protocol = [
+      "```json",
+      '{"outlineSaveRequest":{"fileName":"人物.md"}}',
+      "```",
+    ].join(newline)
+    const repairWithAi = vi.fn(async ({ content }: { content: string }) => content)
+
+    const finalMessage = await finalizeStructuredMarkdownMessage(
+      `${body}${separator}${protocol}`,
+      { enabled: true, repairWithAi, onFailure: vi.fn() },
+    )
+
+    expect(repairWithAi).toHaveBeenCalledTimes(1)
+    expect(repairWithAi).toHaveBeenCalledWith({
+      content: body,
+      maxTokens: expect.any(Number),
+    })
+    expect(repairWithAi.mock.calls[0]?.[0]).not.toContain("outlineSaveRequest")
+    expect(finalMessage.endsWith(protocol)).toBe(true)
+  })
+
+  it("普通问答禁用时原样返回且不调用 AI", async () => {
+    const repairWithAi = vi.fn(async () => "不应调用")
+    const source = "建议先确认人物动机,再决定后续情节。"
+
+    await expect(finalizeStructuredMarkdownMessage(source, {
+      enabled: false,
+      repairWithAi,
+      onFailure: vi.fn(),
+    })).resolves.toBe(source)
+    expect(repairWithAi).not.toHaveBeenCalled()
+  })
+
+  it("本地候选不完整时以首次原文调用 AI 且最多一次", async () => {
+    const source = "| 人物 | 目标 |\n| --- | --- |\n| 林川 | 复仇 |"
+    const repairWithAi = vi.fn(async () => source)
+
+    await finalizeStructuredMarkdownMessage(source, {
+      enabled: true,
+      repairWithAi,
+      onFailure: vi.fn(),
+    })
+
+    expect(repairWithAi).toHaveBeenCalledTimes(1)
+    expect(repairWithAi).toHaveBeenCalledWith({
+      content: source,
+      maxTokens: expect.any(Number),
+    })
+  })
+
+  it("第三检仍失败时保留严格完整且问题更少的候选", async () => {
+    const source = "- **林川\n- 目标:复仇"
+    const aiCandidate = "# **林川\n- 目标:复仇"
+    const onFailure = vi.fn()
+
+    const finalMessage = await finalizeStructuredMarkdownMessage(source, {
+      enabled: true,
+      repairWithAi: vi.fn(async () => aiCandidate),
+      onFailure,
+    })
+
+    expect(finalMessage).toBe(aiCandidate)
+    expect(onFailure).toHaveBeenCalledTimes(1)
+  })
+
+  it("AI 删除任一表格行时拒绝覆盖并保留首次原文", async () => {
+    const source = "| 人物 | 目标 |\n| --- | --- |\n| 林川 | 复仇 |\n| 苏晚 | 守城 |"
+    const droppedRow = "# 人物目标\n| --- | --- |\n| 林川 | 复仇 |"
+
+    const finalMessage = await finalizeStructuredMarkdownMessage(source, {
+      enabled: true,
+      repairWithAi: vi.fn(async () => droppedRow),
+      onFailure: vi.fn(),
+    })
+
+    expect(finalMessage).toBe(source)
+  })
+})
+
+describe("Markdown AI repair token budget", () => {
+  it("按中文、英文单词、符号和换行在本地估算 token", () => {
+    expect(estimateMarkdownRepairTokens("中文abcde!\n")).toBe(5)
+  })
+
+  it("emoji 和罕见 Unicode 至少按每个码点一个 token 估算", () => {
+    expect(estimateMarkdownRepairTokens("😀".repeat(10))).toBeGreaterThanOrEqual(10)
+    expect(estimateMarkdownRepairTokens("🜁𐍈§※")).toBeGreaterThanOrEqual(4)
+  })
+
+  it("密集标点不做三分之一折算", () => {
+    expect(estimateMarkdownRepairTokens("!!??※§"))
+      .toBeGreaterThanOrEqual(6)
+  })
+
+  it("4680 emoji 必须超过输入预算并旁路 AI", () => {
+    const plan = planMarkdownAiRepair("😀".repeat(4680))
+
+    expect(plan.estimatedInputTokens).toBeGreaterThan(MARKDOWN_AI_REPAIR_MAX_INPUT_TOKENS)
+    expect(plan.shouldCallAi).toBe(false)
+  })
+
+  it("输入预算边界内允许 AI,超出一个 token 立即旁路", () => {
+    const maxBodyTokens = MARKDOWN_AI_REPAIR_MAX_INPUT_TOKENS
+      - MARKDOWN_AI_REPAIR_PROMPT_TOKEN_OVERHEAD
+
+    expect(planMarkdownAiRepair("中".repeat(maxBodyTokens)).shouldCallAi).toBe(true)
+    expect(planMarkdownAiRepair("中".repeat(maxBodyTokens + 1)).shouldCallAi).toBe(false)
+  })
+
+  it("输出预算随正文增长且限制在 256 到 4096", () => {
+    expect(planMarkdownAiRepair("短正文").maxOutputTokens).toBe(256)
+    const largePlan = planMarkdownAiRepair("中".repeat(3000))
+    expect(largePlan.maxOutputTokens).toBeGreaterThanOrEqual(3000)
+    expect(largePlan.maxOutputTokens).toBeLessThanOrEqual(4096)
+  })
+
+  it("把动态输出预算映射到 streamChat 支持的 max_tokens", () => {
+    expect(buildMarkdownRepairRequestOverrides(768)).toEqual({
+      temperature: 0,
+      max_tokens: 768,
+    })
+  })
+
+  it("超长结构化正文直接旁路 AI", async () => {
+    const maxBodyTokens = MARKDOWN_AI_REPAIR_MAX_INPUT_TOKENS
+      - MARKDOWN_AI_REPAIR_PROMPT_TOKEN_OVERHEAD
+    const source = `| 人物 | 目标 |\n| --- | --- |\n| 林川 | ${"中".repeat(maxBodyTokens + 1)} |`
+    const repairWithAi = vi.fn(async () => "不应调用")
+    const onFailure = vi.fn()
+
+    const finalMessage = await finalizeStructuredMarkdownMessage(source, {
+      enabled: true,
+      repairWithAi,
+      onFailure,
+    })
+
+    expect(repairWithAi).not.toHaveBeenCalled()
+    expect(finalMessage).toBe(source)
+    expect(onFailure).toHaveBeenCalledTimes(1)
+  })
+})
+
+describe("buildMarkdownFormatRepairMessages", () => {
+  it("只生成一条包含短规则和正文的用户消息", () => {
+    const body = "# 人物设定\n\n林川决定复仇。"
+    const messages = buildMarkdownFormatRepairMessages(body)
+
+    expect(messages).toHaveLength(1)
+    expect(messages[0]?.role).toBe("user")
+    expect(messages[0]?.content).toContain("只修复下列正文的 Markdown 格式")
+    expect(messages[0]?.content).toContain("只返回修复后的正文")
+    expect(messages[0]?.content.endsWith(body)).toBe(true)
+  })
+})

+ 194 - 0
src/lib/novel/markdown-quality-finalizer.ts

@@ -0,0 +1,194 @@
+import {
+  hasMarkdownContentChanged,
+  inspectStructuredMarkdown,
+  isStructuredMarkdownMaterial,
+  isWholeDocumentMarkdownFence,
+  repairStructuredMarkdownLocally,
+  type MarkdownQualityInspection,
+} from "./markdown-quality-pipeline"
+
+export interface MarkdownRepairMessage {
+  role: "user"
+  content: string
+}
+
+export interface MarkdownAiRepairRequest {
+  content: string
+  maxTokens: number
+}
+
+export interface FinalizeStructuredMarkdownOptions {
+  enabled: boolean
+  repairWithAi: (request: MarkdownAiRepairRequest) => Promise<string>
+  onFailure: () => void
+}
+
+export const MARKDOWN_AI_REPAIR_MAX_INPUT_TOKENS = 3200
+export const MARKDOWN_AI_REPAIR_PROMPT_TOKEN_OVERHEAD = 80
+const MARKDOWN_AI_REPAIR_MIN_OUTPUT_TOKENS = 256
+const MARKDOWN_AI_REPAIR_MAX_OUTPUT_TOKENS = 4096
+
+export function estimateMarkdownRepairTokens(text: string): number {
+  if (!text.trim()) return 0
+  let tokens = 0
+  const withoutEnglishWords = text.replace(
+    /[A-Za-z0-9]+(?:['-][A-Za-z0-9]+)?/g,
+    (word) => {
+      tokens += Math.max(1, Math.ceil(word.length / 5))
+      return " ".repeat(word.length)
+    },
+  )
+  for (const character of withoutEnglishWords) {
+    if (character === "\n") {
+      tokens += 1
+      continue
+    }
+    if (/\s/u.test(character)) continue
+    // CJK?emoji??? Unicode ????????? Unicode ?????
+    tokens += 1
+  }
+  return tokens
+}
+
+export function planMarkdownAiRepair(content: string): {
+  estimatedInputTokens: number
+  maxOutputTokens: number
+  shouldCallAi: boolean
+} {
+  const bodyTokens = estimateMarkdownRepairTokens(content)
+  const estimatedInputTokens = bodyTokens + MARKDOWN_AI_REPAIR_PROMPT_TOKEN_OVERHEAD
+  return {
+    estimatedInputTokens,
+    maxOutputTokens: Math.min(
+      MARKDOWN_AI_REPAIR_MAX_OUTPUT_TOKENS,
+      Math.max(
+        MARKDOWN_AI_REPAIR_MIN_OUTPUT_TOKENS,
+        Math.ceil(bodyTokens * 1.1) + 128,
+      ),
+    ),
+    shouldCallAi: estimatedInputTokens <= MARKDOWN_AI_REPAIR_MAX_INPUT_TOKENS,
+  }
+}
+
+export function buildMarkdownRepairRequestOverrides(maxTokens: number): {
+  temperature: number
+  max_tokens: number
+} {
+  return { temperature: 0, max_tokens: maxTokens }
+}
+
+interface MarkdownMessageParts {
+  body: string
+  protocol: string
+}
+
+function splitTrailingSaveProtocol(content: string): MarkdownMessageParts {
+  const trimmedEnd = content.trimEnd()
+  const match = /(```json[ \t]*(?:\r?\n)?([\s\S]*?)```)[ \t]*$/i.exec(trimmedEnd)
+  if (!match || match.index === undefined) {
+    return { body: content.replace(/\r\n?/g, "\n").trim(), protocol: "" }
+  }
+
+  try {
+    const payload = JSON.parse(match[2]?.trim() ?? "") as Record<string, unknown>
+    if (!("outlineSaveRequest" in payload) && !("outlineSaveRequests" in payload)) {
+      return { body: content.replace(/\r\n?/g, "\n").trim(), protocol: "" }
+    }
+  } catch {
+    return { body: content.replace(/\r\n?/g, "\n").trim(), protocol: "" }
+  }
+
+  return {
+    body: trimmedEnd.slice(0, match.index).replace(/\r\n?/g, "\n").trim(),
+    protocol: match[1] ?? "",
+  }
+}
+
+function joinMessageParts(body: string, protocol: string): string {
+  return protocol ? `${body.trim()}\n\n${protocol}` : body.trim()
+}
+
+export function buildMarkdownFormatRepairMessages(
+  content: string,
+): MarkdownRepairMessage[] {
+  return [{
+    role: "user",
+    content: [
+      "只修复下列正文的 Markdown 格式,不增删、改写或总结内容。",
+      "规则:保留一级标题;** 必须成对;不要用代码围栏包裹全文;表格保留或补齐分隔行。",
+      "只返回修复后的正文。",
+      content,
+    ].join("\n\n"),
+  }]
+}
+
+export async function finalizeStructuredMarkdownMessage(
+  messageContent: string,
+  options: FinalizeStructuredMarkdownOptions,
+): Promise<string> {
+  if (!options.enabled) return messageContent
+
+  const parts = splitTrailingSaveProtocol(messageContent)
+  const originalBody = parts.body
+  if (
+    !isWholeDocumentMarkdownFence(originalBody)
+    && !isStructuredMarkdownMaterial(originalBody)
+  ) return messageContent
+
+  const initialInspection = inspectStructuredMarkdown(originalBody)
+  if (initialInspection.valid) return messageContent
+
+  const localContent = repairStructuredMarkdownLocally(originalBody)
+  const localInspection = inspectStructuredMarkdown(localContent)
+  const localContentChanged = hasMarkdownContentChanged(originalBody, localContent)
+  if (localInspection.valid && !localContentChanged) {
+    return joinMessageParts(localContent, parts.protocol)
+  }
+
+  const candidates: Array<{
+    content: string
+    inspection: MarkdownQualityInspection
+  }> = [{ content: originalBody, inspection: initialInspection }]
+  if (!localContentChanged) {
+    candidates.push({ content: localContent, inspection: localInspection })
+  }
+
+  const aiInput = localContentChanged ? originalBody : localContent
+  const aiPlan = planMarkdownAiRepair(aiInput)
+  if (!aiPlan.shouldCallAi) {
+    const best = candidates.reduce((current, candidate) =>
+      candidate.inspection.issues.length < current.inspection.issues.length
+        ? candidate
+        : current,
+    )
+    options.onFailure()
+    return joinMessageParts(best.content, parts.protocol)
+  }
+
+  let aiContent = aiInput
+  try {
+    aiContent = (await options.repairWithAi({
+      content: aiInput,
+      maxTokens: aiPlan.maxOutputTokens,
+    })).trim()
+  } catch {
+    // AI 格式修复失败时仍执行第三检,并保留已有完整候选。
+  }
+
+  const aiInspection = inspectStructuredMarkdown(aiContent)
+  const aiContentChanged = hasMarkdownContentChanged(originalBody, aiContent)
+  if (aiInspection.valid && !aiContentChanged) {
+    return joinMessageParts(aiContent, parts.protocol)
+  }
+  if (!aiContentChanged) {
+    candidates.push({ content: aiContent, inspection: aiInspection })
+  }
+
+  const best = candidates.reduce((current, candidate) =>
+    candidate.inspection.issues.length < current.inspection.issues.length
+      ? candidate
+      : current,
+  )
+  options.onFailure()
+  return joinMessageParts(best.content, parts.protocol)
+}

+ 192 - 0
src/lib/novel/markdown-quality-pipeline.spec.ts

@@ -0,0 +1,192 @@
+import { describe, expect, it } from "vitest"
+
+import {
+  hasMarkdownContentChanged,
+  inspectStructuredMarkdown,
+  isStructuredMarkdownMaterial,
+  repairStructuredMarkdownLocally,
+} from "./markdown-quality-pipeline"
+
+describe("markdown quality pipeline", () => {
+  it("仅把有多项 Markdown 结构证据的正文识别为结构化资料", () => {
+    expect(isStructuredMarkdownMaterial("# 人物设定\n\n- 姓名:林川\n- 目标:复仇")).toBe(true)
+    expect(isStructuredMarkdownMaterial("人物设定\n\n- 姓名:林川\n- 目标:复仇")).toBe(true)
+    expect(isStructuredMarkdownMaterial("这个问题建议先确认人物动机,再决定后续情节。")).toBe(false)
+  })
+
+  it("检查一级标题、成对加粗和全文代码围栏", () => {
+    const result = inspectStructuredMarkdown("```markdown\n人物设定\n\n**姓名:林川\n- 目标:复仇\n```")
+
+    expect(result.valid).toBe(false)
+    expect(result.issues.map((issue) => issue.code)).toEqual([
+      "whole-document-code-fence",
+      "missing-title",
+      "unpaired-bold",
+    ])
+  })
+
+  it("已有 Markdown 表格缺少分隔行时报告问题", () => {
+    const result = inspectStructuredMarkdown("# 人物表\n\n| 人物 | 目标 |\n| 林川 | 复仇 |")
+
+    expect(result.issues.map((issue) => issue.code)).toContain("missing-table-separator")
+  })
+
+  it("保留已有表格分隔行且不重复报告", () => {
+    const result = inspectStructuredMarkdown("# 人物表\n\n| 人物 | 目标 |\n| :--- | ---: |\n| 林川 | 复仇 |")
+
+    expect(result.valid).toBe(true)
+    expect(result.issues).toEqual([])
+  })
+
+  it("已有表格分隔行后的多条数据不会被误报", () => {
+    const result = inspectStructuredMarkdown(
+      "# 人物表\n\n| 人物 | 目标 |\n| --- | --- |\n| 林川 | 复仇 |\n| 苏晚 | 守城 |",
+    )
+
+    expect(result.valid).toBe(true)
+  })
+
+  it("缺失分隔行的多条数据只插入一行分隔符", () => {
+    const repaired = repairStructuredMarkdownLocally(
+      "# 人物表\n\n| 人物 | 目标 |\n| 林川 | 复仇 |\n| 苏晚 | 守城 |",
+    )
+
+    expect(repaired.match(/^\| --- \| --- \|$/gm)).toHaveLength(1)
+    expect(inspectStructuredMarkdown(repaired).valid).toBe(true)
+  })
+
+  it("确定性本地修复只调整 Markdown 标记并保留正文", () => {
+    const source = "```markdown\n人物设定\n\n**姓名:林川\n\n| 人物 | 目标 |\n| 林川 | 复仇 |\n```"
+
+    expect(repairStructuredMarkdownLocally(source)).toBe(
+      "# 人物设定\n\n姓名:林川\n\n| 人物 | 目标 |\n| --- | --- |\n| 林川 | 复仇 |",
+    )
+  })
+
+  it("本地格式修复不被误判为内容丢失", () => {
+    const source = "```markdown\n人物设定\n\n**姓名:林川\n\n| 人物 | 目标 |\n| 林川 | 复仇 |\n```"
+    const repaired = "# 人物设定\n\n姓名:林川\n\n| 人物 | 目标 |\n| --- | --- |\n| 林川 | 复仇 |"
+
+    expect(hasMarkdownContentChanged(source, repaired)).toBe(false)
+  })
+
+  it("候选版本明显删除原文段落时拒绝覆盖", () => {
+    const source = "# 总纲\n\n## 第一幕\n林川失去家园并踏上复仇之路。\n\n## 第二幕\n林川发现仇人来自王都。\n\n## 第三幕\n林川在决战中揭开身世。"
+    const truncated = "# 总纲\n\n## 第一幕\n林川失去家园并踏上复仇之路。"
+
+    expect(hasMarkdownContentChanged(source, truncated)).toBe(true)
+  })
+  it("即使只删除一个短事实也判定内容已变化", () => {
+    const source = "# 人物\n\n姓名:林川\n目标:复仇"
+    const candidate = "# 人物\n\n姓名:林川"
+
+    expect(hasMarkdownContentChanged(source, candidate)).toBe(true)
+  })
+
+  it("仅调整 Markdown 标记和表格分隔行时仍视为完整", () => {
+    const source = "```markdown\n人物表\n\n| 人物 | 目标 |\n| 林川 | 复仇 |\n```"
+    const candidate = "# 人物表\n\n| 人物 | 目标 |\n| --- | --- |\n| **林川** | 复仇 |"
+
+    expect(hasMarkdownContentChanged(source, candidate)).toBe(false)
+  })
+
+  it("加粗检查忽略 fenced code 中的双星号", () => {
+    const source = "# 文件规则\n\n```text\n**/*.md\n**not-bold\n```"
+
+    expect(inspectStructuredMarkdown(source).valid).toBe(true)
+    expect(repairStructuredMarkdownLocally(source)).toBe(source)
+  })
+
+  it("加粗检查忽略 inline code 且保留代码原文", () => {
+    const source = "# 文件规则\n\n使用 `**/*.md` 匹配文件,**重点**保持成对。"
+
+    expect(inspectStructuredMarkdown(source).valid).toBe(true)
+    expect(repairStructuredMarkdownLocally(source)).toBe(source)
+  })
+
+  it("glob 双星号不是加粗标记且不得被修复", () => {
+    const source = "# 文件规则\n\n匹配 **/*.md 文件。"
+
+    expect(inspectStructuredMarkdown(source).valid).toBe(true)
+    expect(repairStructuredMarkdownLocally(source)).toBe(source)
+  })
+
+  it("字面管道符变化必须判定内容变化", () => {
+    expect(hasMarkdownContentChanged(
+      "# 规则\n\n逻辑表达式 A|B 不可改。",
+      "# 规则\n\n逻辑表达式 AB 不可改。",
+    )).toBe(true)
+  })
+
+  it("非表格上下文的分隔样式行不得被归一化删除", () => {
+    expect(hasMarkdownContentChanged(
+      "# 规则\n\n字面内容:\n| --- |\n结束。",
+      "# 规则\n\n字面内容:\n结束。",
+    )).toBe(true)
+  })
+
+  it("代码中的字面标点变化必须判定内容变化", () => {
+    expect(hasMarkdownContentChanged(
+      "# 示例\n\n```text\nA|B\n---\n```",
+      "# 示例\n\n```text\nAB\n```",
+    )).toBe(true)
+  })
+
+  it("代码中的双星号变化必须判定内容变化", () => {
+    expect(hasMarkdownContentChanged(
+      "# 示例\n\n```text\n**value\n```",
+      "# 示例\n\n```text\nvalue\n```",
+    )).toBe(true)
+  })
+
+  it("glob 双星号变化必须判定内容变化", () => {
+    expect(hasMarkdownContentChanged(
+      "# 规则\n\n匹配 **/*.md 文件。",
+      "# 规则\n\n匹配 /*.md 文件。",
+    )).toBe(true)
+  })
+
+  it("全文非 markdown 代码围栏中的标点变化必须拒绝", () => {
+    expect(hasMarkdownContentChanged(
+      "```text\n**value\n```",
+      "```text\nvalue\n```",
+    )).toBe(true)
+  })
+
+  it("带尾随文本的反引号行不能关闭 fenced code", () => {
+    const source = "# 示例\n\n```text\n代码\n```not-a-close\n**literal\n```"
+
+    expect(inspectStructuredMarkdown(source).valid).toBe(true)
+    expect(repairStructuredMarkdownLocally(source)).toBe(source)
+  })
+
+  it("较短反引号行不能关闭更长的 fenced code", () => {
+    const source = "# 示例\n\n````text\n代码\n```\n**literal\n````"
+
+    expect(inspectStructuredMarkdown(source).valid).toBe(true)
+    expect(repairStructuredMarkdownLocally(source)).toBe(source)
+  })
+
+  it("表格解析忽略转义管道符和 inline code 管道符", () => {
+    const source = "# 规则表\n\n| 模式 | 说明 |\n| `a|b` | 匹配 A\\|B |"
+
+    expect(inspectStructuredMarkdown(source).issues.map((issue) => issue.code))
+      .toContain("missing-table-separator")
+  })
+
+  it("含转义和 inline code 管道符的表格可确定性补分隔行", () => {
+    const source = "# 规则表\n\n| 模式 | 说明 |\n| `a|b` | 匹配 A\\|B |"
+    const expected = "# 规则表\n\n| 模式 | 说明 |\n| --- | --- |\n| `a|b` | 匹配 A\\|B |"
+
+    expect(repairStructuredMarkdownLocally(source)).toBe(expected)
+    expect(inspectStructuredMarkdown(expected).valid).toBe(true)
+  })
+
+  it("真实表格分隔行差异在复杂 cell 下仍保持内容完整", () => {
+    const source = "# 规则表\n\n| 模式 | 说明 |\n| `a|b` | 匹配 A\\|B |"
+    const candidate = "# 规则表\n\n| 模式 | 说明 |\n| --- | --- |\n| `a|b` | 匹配 A\\|B |"
+
+    expect(hasMarkdownContentChanged(source, candidate)).toBe(false)
+  })
+
+})

+ 373 - 0
src/lib/novel/markdown-quality-pipeline.ts

@@ -0,0 +1,373 @@
+export type MarkdownQualityIssueCode =
+  | "whole-document-code-fence"
+  | "missing-title"
+  | "unpaired-bold"
+  | "missing-table-separator"
+
+export interface MarkdownQualityIssue {
+  code: MarkdownQualityIssueCode
+  message: string
+}
+
+export interface MarkdownQualityInspection {
+  valid: boolean
+  issues: MarkdownQualityIssue[]
+}
+
+const TABLE_SEPARATOR_CELL = /^:?-{3,}:?$/
+
+function normalizeMarkdown(content: string): string {
+  return content.replace(/\r\n?/g, "\n").trim()
+}
+
+function splitWholeDocumentFence(content: string): {
+  fenced: boolean
+  body: string
+} {
+  const normalized = normalizeMarkdown(content)
+  const lines = normalized.split("\n")
+  if (
+    lines.length >= 3
+    && /^```[\w-]*\s*$/.test(lines[0] ?? "")
+    && /^```\s*$/.test(lines[lines.length - 1] ?? "")
+  ) {
+    return { fenced: true, body: lines.slice(1, -1).join("\n").trim() }
+  }
+  return { fenced: false, body: normalized }
+}
+
+export function isWholeDocumentMarkdownFence(content: string): boolean {
+  const lines = normalizeMarkdown(content).split("\n")
+  return lines.length >= 3
+    && /^```(?:markdown|md)\s*$/i.test(lines[0] ?? "")
+    && /^```\s*$/.test(lines[lines.length - 1] ?? "")
+}
+
+function markdownCodeMask(content: string): boolean[] {
+  const mask = Array.from({ length: content.length }, () => false)
+  let offset = 0
+  let fenceMarker = ""
+  for (const lineWithNewline of content.match(/.*(?:\n|$)/g) ?? []) {
+    if (!lineWithNewline) continue
+    const line = lineWithNewline.endsWith("\n")
+      ? lineWithNewline.slice(0, -1)
+      : lineWithNewline
+    const openingFence = /^\s*(`{3,}|~{3,})/.exec(line)?.[1] ?? ""
+    if (fenceMarker) {
+      const trimmed = line.trim()
+      const markerCharacter = fenceMarker[0] ?? ""
+      const isClosingFence = trimmed.length >= fenceMarker.length
+        && Array.from(trimmed).every((character) => character === markerCharacter)
+      for (let index = offset; index < offset + lineWithNewline.length; index += 1) {
+        mask[index] = true
+      }
+      if (isClosingFence) fenceMarker = ""
+      offset += lineWithNewline.length
+      continue
+    }
+    if (openingFence) {
+      for (let index = offset; index < offset + lineWithNewline.length; index += 1) {
+        mask[index] = true
+      }
+      fenceMarker = openingFence
+      offset += lineWithNewline.length
+      continue
+    }
+
+    let cursor = 0
+    while (cursor < line.length) {
+      if (line[cursor] !== "`") {
+        cursor += 1
+        continue
+      }
+      let markerLength = 1
+      while (line[cursor + markerLength] === "`") markerLength += 1
+      const marker = "`".repeat(markerLength)
+      const closeIndex = line.indexOf(marker, cursor + markerLength)
+      const end = closeIndex < 0 ? line.length : closeIndex + markerLength
+      for (let index = offset + cursor; index < offset + end; index += 1) {
+        mask[index] = true
+      }
+      cursor = end
+    }
+    offset += lineWithNewline.length
+  }
+  return mask
+}
+
+function boldMarkerIndices(content: string): number[] {
+  const codeMask = markdownCodeMask(content)
+  const indices: number[] = []
+  for (let index = 0; index < content.length - 1; index += 1) {
+    if (codeMask[index] || codeMask[index + 1]) continue
+    if (content[index] !== "*" || content[index + 1] !== "*") continue
+    if (content[index - 1] === "\\" || content[index + 2] === "/") continue
+    indices.push(index)
+    index += 1
+  }
+  return indices
+}
+
+function tableCells(line: string): string[] | null {
+  const trimmed = line.trim()
+  if (!trimmed.startsWith("|") || !trimmed.endsWith("|")) return null
+
+  const body = trimmed.slice(1, -1)
+  const cells: string[] = []
+  let current = ""
+  let inlineCodeMarkerLength = 0
+  for (let index = 0; index < body.length; index += 1) {
+    const character = body[index] ?? ""
+    if (character === "`") {
+      let markerLength = 1
+      while (body[index + markerLength] === "`") markerLength += 1
+      let precedingBackslashes = 0
+      for (let cursor = index - 1; cursor >= 0 && body[cursor] === "\\"; cursor -= 1) {
+        precedingBackslashes += 1
+      }
+      if (precedingBackslashes % 2 === 0) {
+        if (inlineCodeMarkerLength === 0) inlineCodeMarkerLength = markerLength
+        else if (inlineCodeMarkerLength === markerLength) inlineCodeMarkerLength = 0
+      }
+      current += "`".repeat(markerLength)
+      index += markerLength - 1
+      continue
+    }
+    if (character === "|" && inlineCodeMarkerLength === 0) {
+      let precedingBackslashes = 0
+      for (let cursor = index - 1; cursor >= 0 && body[cursor] === "\\"; cursor -= 1) {
+        precedingBackslashes += 1
+      }
+      if (precedingBackslashes % 2 === 0) {
+        cells.push(current.trim())
+        current = ""
+        continue
+      }
+    }
+    current += character
+  }
+  cells.push(current.trim())
+  return cells.length >= 2 ? cells : null
+}
+
+function isTableSeparator(line: string, expectedCells?: number): boolean {
+  const cells = tableCells(line)
+  return Boolean(
+    cells
+    && (expectedCells === undefined || cells.length === expectedCells)
+    && cells.every((cell) => TABLE_SEPARATOR_CELL.test(cell)),
+  )
+}
+
+function hasMissingTableSeparator(content: string): boolean {
+  const lines = content.split("\n")
+  for (let index = 0; index < lines.length - 1; index += 1) {
+    const headerCells = tableCells(lines[index] ?? "")
+    const nextCells = tableCells(lines[index + 1] ?? "")
+    if (
+      headerCells
+      && tableCells(lines[index - 1] ?? "") === null
+      && !isTableSeparator(lines[index] ?? "", headerCells.length)
+      && nextCells
+      && headerCells.length === nextCells.length
+      && !isTableSeparator(lines[index + 1] ?? "", headerCells.length)
+    ) {
+      return true
+    }
+  }
+  return false
+}
+
+export function isStructuredMarkdownMaterial(content: string): boolean {
+  const body = splitWholeDocumentFence(content).body
+  const lines = body.split("\n")
+  const hasHeading = lines.some((line) => /^#{1,6}\s+\S/.test(line.trim()))
+  const listLineCount = lines.filter((line) => /^\s*(?:[-*+] |\d+[.)]\s+)/.test(line)).length
+  const tableLineCount = lines.filter((line) => tableCells(line) !== null).length
+  const boldLabelCount = lines.filter((line) => /\*\*[^*\n]+\*\*/.test(line)).length
+  const evidenceCount = Number(hasHeading) + Number(listLineCount >= 2)
+    + Number(tableLineCount >= 2) + Number(boldLabelCount >= 2)
+  return evidenceCount >= 2 || listLineCount >= 2 || tableLineCount >= 2
+}
+
+export function inspectStructuredMarkdown(content: string): MarkdownQualityInspection {
+  const fenced = splitWholeDocumentFence(content)
+  const issues: MarkdownQualityIssue[] = []
+  if (fenced.fenced) {
+    issues.push({ code: "whole-document-code-fence", message: "不要使用代码围栏包裹全文" })
+  }
+  if (!/^#\s+\S/m.test(fenced.body)) {
+    issues.push({ code: "missing-title", message: "缺少一级标题" })
+  }
+  const boldMarkerCount = boldMarkerIndices(fenced.body).length
+  if (boldMarkerCount % 2 !== 0) {
+    issues.push({ code: "unpaired-bold", message: "存在未成对的加粗标记" })
+  }
+  if (hasMissingTableSeparator(fenced.body)) {
+    issues.push({ code: "missing-table-separator", message: "Markdown 表格缺少分隔行" })
+  }
+  return { valid: issues.length === 0, issues }
+}
+
+function repairTitle(content: string): string {
+  if (/^#\s+\S/m.test(content)) return content
+  const lines = content.split("\n")
+  const firstContentIndex = lines.findIndex((line) => line.trim().length > 0)
+  if (firstContentIndex < 0) return "# 结构化资料"
+  const firstLine = lines[firstContentIndex]?.trim() ?? ""
+  if (/^#{2,6}\s+\S/.test(firstLine)) {
+    lines[firstContentIndex] = firstLine.replace(/^#{2,6}/, "#")
+    return lines.join("\n")
+  }
+  if (
+    firstLine.length <= 80
+    && !/^\s*(?:[-*+] |\d+[.)]\s+)/.test(firstLine)
+    && tableCells(firstLine) === null
+  ) {
+    lines[firstContentIndex] = `# ${firstLine}`
+    return lines.join("\n")
+  }
+  return `# 结构化资料\n\n${content}`
+}
+
+function repairUnpairedBold(content: string): string {
+  const markerIndices = boldMarkerIndices(content)
+  if (markerIndices.length % 2 === 0) return content
+  const lastMarkerIndex = markerIndices[markerIndices.length - 1]
+  if (lastMarkerIndex === undefined) return content
+  return `${content.slice(0, lastMarkerIndex)}${content.slice(lastMarkerIndex + 2)}`
+}
+
+function repairTableSeparators(content: string): string {
+  const lines = content.split("\n")
+  const repaired: string[] = []
+  for (let index = 0; index < lines.length; index += 1) {
+    const line = lines[index] ?? ""
+    repaired.push(line)
+    const headerCells = tableCells(line)
+    const nextCells = tableCells(lines[index + 1] ?? "")
+    if (
+      headerCells
+      && tableCells(lines[index - 1] ?? "") === null
+      && !isTableSeparator(line, headerCells.length)
+      && nextCells
+      && headerCells.length === nextCells.length
+      && !isTableSeparator(lines[index + 1] ?? "", headerCells.length)
+    ) {
+      repaired.push(`| ${headerCells.map(() => "---").join(" | ")} |`)
+    }
+  }
+  return repaired.join("\n")
+}
+
+export function repairStructuredMarkdownLocally(content: string): string {
+  const body = splitWholeDocumentFence(content).body
+  return repairTableSeparators(repairUnpairedBold(repairTitle(body))).trim()
+}
+
+function realTableSeparatorIndices(
+  lines: string[],
+  codeLines: Set<number>,
+): Set<number> {
+  const indices = new Set<number>()
+  for (let index = 1; index < lines.length - 1; index += 1) {
+    if (codeLines.has(index - 1) || codeLines.has(index) || codeLines.has(index + 1)) {
+      continue
+    }
+    const separatorCells = tableCells(lines[index] ?? "")
+    const headerCells = tableCells(lines[index - 1] ?? "")
+    const dataCells = tableCells(lines[index + 1] ?? "")
+    if (
+      separatorCells
+      && headerCells
+      && dataCells
+      && separatorCells.length === headerCells.length
+      && separatorCells.length === dataCells.length
+      && isTableSeparator(lines[index] ?? "", separatorCells.length)
+    ) {
+      indices.add(index)
+    }
+  }
+  return indices
+}
+
+function markdownCodeLineIndices(_content: string, lines: string[]): Set<number> {
+  const codeLines = new Set<number>()
+  let fenceMarker = ""
+  lines.forEach((line, index) => {
+    const openingFence = /^\s*(`{3,}|~{3,})/.exec(line)?.[1] ?? ""
+    if (fenceMarker) {
+      codeLines.add(index)
+      const trimmed = line.trim()
+      const markerCharacter = fenceMarker[0] ?? ""
+      const isClosingFence = trimmed.length >= fenceMarker.length
+        && Array.from(trimmed).every((character) => character === markerCharacter)
+      if (isClosingFence) fenceMarker = ""
+      return
+    }
+    if (openingFence) {
+      codeLines.add(index)
+      fenceMarker = openingFence
+    }
+  })
+  return codeLines
+}
+
+function protectMarkdownCode(content: string): string {
+  const mask = markdownCodeMask(content)
+  let protectedContent = ""
+  let index = 0
+  while (index < content.length) {
+    if (!mask[index]) {
+      protectedContent += content[index]
+      index += 1
+      continue
+    }
+    let end = index + 1
+    while (end < content.length && mask[end]) end += 1
+    const encoded = Array.from(content.slice(index, end))
+      .map((character) => character.codePointAt(0)?.toString(16) ?? "")
+      .join(".")
+    protectedContent += `\uE000${encoded}\uE001`
+    index = end
+  }
+  return protectedContent
+}
+
+function removeRecognizedBoldMarkers(content: string): string {
+  const markerStarts = new Set(boldMarkerIndices(content))
+  let result = ""
+  for (let index = 0; index < content.length; index += 1) {
+    if (markerStarts.has(index)) {
+      index += 1
+      continue
+    }
+    result += content[index]
+  }
+  return result
+}
+
+function normalizeMarkdownContentForIntegrity(content: string): string {
+  const body = isWholeDocumentMarkdownFence(content)
+    ? splitWholeDocumentFence(content).body
+    : normalizeMarkdown(content)
+  const lines = body.split("\n")
+  const codeLines = markdownCodeLineIndices(body, lines)
+  const tableSeparators = realTableSeparatorIndices(lines, codeLines)
+  const withoutTableSeparators = lines
+    .filter((_, index) => !tableSeparators.has(index))
+    .join("\n")
+  return removeRecognizedBoldMarkers(protectMarkdownCode(withoutTableSeparators))
+    .replace(/^\s*#{1,6}\s+/gm, "")
+    .replace(/^\s*(?:[-*+] |\d+[.)]\s+|>\s*)/gm, "")
+    .replace(/\s+/g, "")
+}
+
+export function hasMarkdownContentChanged(
+  original: string,
+  candidate: string,
+): boolean {
+  return normalizeMarkdownContentForIntegrity(original)
+    !== normalizeMarkdownContentForIntegrity(candidate)
+}
+

+ 12 - 0
src/lib/novel/model-resolver.ts

@@ -2,6 +2,7 @@ import { useWikiStore, type LlmConfig, type NovelConfig, type ProviderOverride }
 import { LLM_PRESETS } from "@/components/settings/llm-presets"
 import { LLM_PRESETS } from "@/components/settings/llm-presets"
 import { resolveConfig } from "@/components/settings/preset-resolver"
 import { resolveConfig } from "@/components/settings/preset-resolver"
 import { hasUsableLlm } from "@/lib/has-usable-llm"
 import { hasUsableLlm } from "@/lib/has-usable-llm"
+import { getStableAvailableModelKey } from "@/lib/llm-model-keys"
 
 
 export type NovelTaskType = "writing" | "review" | "summary" | "extract" | "lint" | "deAi"
 export type NovelTaskType = "writing" | "review" | "summary" | "extract" | "lint" | "deAi"
 
 
@@ -85,6 +86,17 @@ export function resolveModelConfig(
   return { ...baseConfig, model: targetModel }
   return { ...baseConfig, model: targetModel }
 }
 }
 
 
+export function resolveUsableModelKey(
+  targetModel: string,
+  baseConfig: LlmConfig,
+  providerConfigs: Record<string, ProviderOverride>,
+): string {
+  const stableModelKey = getStableAvailableModelKey(targetModel, providerConfigs)
+  if (!stableModelKey) return ""
+  const resolved = resolveModelConfig(stableModelKey, baseConfig, providerConfigs)
+  return isConfigUsable(resolved, providerConfigs) ? stableModelKey : ""
+}
+
 function resolveProjectDefaultLlmModel(): string {
 function resolveProjectDefaultLlmModel(): string {
   const { novelConfig, defaultLlmModel } = useWikiStore.getState()
   const { novelConfig, defaultLlmModel } = useWikiStore.getState()
   return novelConfig.defaultLlmModel?.trim() || defaultLlmModel?.trim() || ""
   return novelConfig.defaultLlmModel?.trim() || defaultLlmModel?.trim() || ""

+ 66 - 0
src/lib/novel/novel-generation-request-package.spec.ts

@@ -0,0 +1,66 @@
+import { describe, expect, it } from "vitest"
+import {
+  buildOutlineRegenerationInput,
+  createNovelGenerationRequestPackage,
+  getNovelGenerationModelContent,
+  getOutlineMessageModelContent,
+  isExplicitStructuredGenerationFollowUp,
+  mapOutlineConversationsForModel,
+  mapOutlineMessagesForModel,
+} from "./novel-generation-request-package"
+import type { OutlineWizardRequest } from "./outline-wizard"
+
+const request: OutlineWizardRequest = {
+  task: "newBook", length: "long", channel: "male", genre: "xuanhuan",
+  customGenre: "", inspiration: "?????????????", sellingPoints: ["????"],
+  targets: ["??", "????"], scale: "120?", narrative: "thirdPerson", materialSource: "none",
+}
+
+describe("???????", () => {
+  it("??????????????", () => {
+    const value = createNovelGenerationRequestPackage(request, "??????")
+    expect(value.version).toBe(1)
+    expect(value.summary).not.toBe("??????")
+    expect(value.details.length).toBeGreaterThan(0)
+    expect(getNovelGenerationModelContent(value)).toBe("??????")
+    expect(getOutlineMessageModelContent({ content: value.summary, novelGenerationRequest: value })).toBe("??????")
+  })
+
+  it("?????????????????????", () => {
+    const value = createNovelGenerationRequestPackage(request, "??????")
+    expect(mapOutlineMessagesForModel([
+      { role: "user", content: value.summary, novelGenerationRequest: value },
+      { role: "assistant", content: "", isAgentRunning: true },
+      { role: "assistant", content: "??" },
+    ])).toEqual([{ role: "user", content: "??????" }, { role: "assistant", content: "??" }])
+  })
+
+  it("????????????", () => {
+    const value = createNovelGenerationRequestPackage(request, "??????")
+    const messages = [
+      { role: "user" as const, content: value.summary, novelGenerationRequest: value },
+      { role: "assistant" as const, content: "??" },
+      { role: "user" as const, content: "????" },
+    ]
+    expect(buildOutlineRegenerationInput(messages)).toEqual({
+      request: "????",
+      history: [{ role: "user", content: "??????" }, { role: "assistant", content: "??" }],
+      structuredGeneration: false,
+    })
+    expect(mapOutlineConversationsForModel([{ id: "c1", title: "??", messages }])[0].messages[0].content).toBe("??????")
+  })
+
+  it("??????????????????????????", () => {
+    expect(isExplicitStructuredGenerationFollowUp("\u751f\u6210\u4eba\u7269\u8bbe\u5b9a", { generationContext: false })).toBe(true)
+    expect(isExplicitStructuredGenerationFollowUp("\u751f\u6210\u4e16\u754c\u89c2", { generationContext: false })).toBe(true)
+    expect(isExplicitStructuredGenerationFollowUp("\u89e3\u91ca\u4e00\u4e0b\u8fd9\u4e2a\u8bbe\u5b9a", { generationContext: false })).toBe(false)
+    expect(isExplicitStructuredGenerationFollowUp("\u7ee7\u7eed\u5b8c\u5584\u4eba\u7269\u5173\u7cfb", { generationContext: true })).toBe(true)
+  })
+
+  it("??????????????????????", () => {
+    const generationRequest = createNovelGenerationRequestPackage(request, "???????")
+    expect(buildOutlineRegenerationInput([
+      { role: "user", content: generationRequest.summary, novelGenerationRequest: generationRequest },
+    ])).toEqual({ request: "???????", history: [], structuredGeneration: true })
+  })
+})

+ 144 - 0
src/lib/novel/novel-generation-request-package.ts

@@ -0,0 +1,144 @@
+import {
+  OUTLINE_WIZARD_CHANNEL_OPTIONS,
+  OUTLINE_WIZARD_LENGTH_OPTIONS,
+  OUTLINE_WIZARD_MATERIAL_OPTIONS,
+  OUTLINE_WIZARD_NARRATIVE_OPTIONS,
+  OUTLINE_WIZARD_TASK_OPTIONS,
+  getOutlineWizardGenreLabel,
+  type OutlineWizardOption,
+  type OutlineWizardRequest,
+} from "./outline-wizard"
+
+export interface NovelGenerationRequestPackage {
+  version: 1
+  summary: string
+  details: string[]
+  modelContent: string
+}
+
+type OutlineModelMessage = {
+  role: "user" | "assistant"
+  content: string
+  novelGenerationRequest?: NovelGenerationRequestPackage
+  isAgentRunning?: boolean
+}
+
+type OutlineConversationLike = {
+  id: string
+  title: string
+  messages: OutlineModelMessage[]
+}
+
+function label<T extends string>(options: OutlineWizardOption<T>[], value: T): string {
+  return options.find((option) => option.value === value)?.label ?? value
+}
+
+export function createNovelGenerationRequestPackage(
+  request: OutlineWizardRequest,
+  modelContent: string,
+): NovelGenerationRequestPackage {
+  const task = label(OUTLINE_WIZARD_TASK_OPTIONS, request.task)
+  const length = label(OUTLINE_WIZARD_LENGTH_OPTIONS, request.length)
+  const channel = label(OUTLINE_WIZARD_CHANNEL_OPTIONS, request.channel)
+  const genre = getOutlineWizardGenreLabel(request)
+  const explicit = request.explicit ?? {}
+  const details = [
+    explicit.task ? `生成任务:${task}` : "",
+    explicit.length ? `篇幅类型:${length}` : "",
+    explicit.channel ? `频道方向:${channel}` : "",
+    explicit.genre || explicit.customGenre ? `题材类型:${genre}` : "",
+    request.inspiration.trim() ? `故事灵感/处理要求:${request.inspiration.trim()}` : "",
+    explicit.sellingPoints && request.sellingPoints.length
+      ? `核心卖点:${request.sellingPoints.join("、")}`
+      : "",
+    explicit.targets && request.targets.length ? `生成目标:${request.targets.join("、")}` : "",
+    explicit.scale && request.scale.trim() ? `作品规模:${request.scale.trim()}` : "",
+    explicit.narrative
+      ? `叙事要求:${label(OUTLINE_WIZARD_NARRATIVE_OPTIONS, request.narrative)}`
+      : "",
+    explicit.materialSource
+      ? `已有资料:${label(OUTLINE_WIZARD_MATERIAL_OPTIONS, request.materialSource)}`
+      : "",
+  ].filter(Boolean)
+  return {
+    version: 1,
+    summary: `${task} · ${genre} · ${request.targets.join("、")}`,
+    details,
+    modelContent,
+  }
+}
+
+export function getNovelGenerationModelContent(request: NovelGenerationRequestPackage): string {
+  return request.modelContent
+}
+
+export function getOutlineMessageModelContent(message: {
+  content: string
+  novelGenerationRequest?: NovelGenerationRequestPackage
+}): string {
+  return message.novelGenerationRequest
+    ? getNovelGenerationModelContent(message.novelGenerationRequest)
+    : message.content
+}
+
+export function mapOutlineMessagesForModel(messages: OutlineModelMessage[]): Array<{ role: "user" | "assistant"; content: string }> {
+  return messages
+    .filter((message) => message.content.trim() && !message.isAgentRunning)
+    .map((message) => ({ role: message.role, content: getOutlineMessageModelContent(message) }))
+}
+
+export function buildOutlineRegenerationInput(messages: OutlineModelMessage[]): {
+  request: string
+  history: Array<{ role: "user" | "assistant"; content: string }>
+  structuredGeneration: boolean
+} {
+  const available = messages.filter((message) => message.content.trim() && !message.isAgentRunning)
+  let lastUserIndex = -1
+  for (let index = available.length - 1; index >= 0; index -= 1) {
+    if (available[index].role === "user") {
+      lastUserIndex = index
+      break
+    }
+  }
+  if (lastUserIndex < 0) {
+    return { request: "请基于已有大纲重新生成。", history: mapOutlineMessagesForModel(available), structuredGeneration: true }
+  }
+  return {
+    request: getOutlineMessageModelContent(available[lastUserIndex]),
+    structuredGeneration: Boolean(available[lastUserIndex].novelGenerationRequest) || isExplicitStructuredGenerationFollowUp(available[lastUserIndex].content, { generationContext: false }),
+    history: mapOutlineMessagesForModel(available.filter((_, index) => index !== lastUserIndex)),
+  }
+}
+
+export function mapOutlineConversationsForModel<T extends OutlineConversationLike>(conversations: T[]) {
+  return conversations.map((conversation) => ({
+    id: conversation.id,
+    title: conversation.title,
+    messages: mapOutlineMessagesForModel(conversation.messages),
+  }))
+}
+
+export function isNovelGenerationRequestPackage(value: unknown): value is NovelGenerationRequestPackage {
+  if (!value || typeof value !== "object") return false
+  const candidate = value as Partial<NovelGenerationRequestPackage>
+  return candidate.version === 1
+    && typeof candidate.summary === "string"
+    && Array.isArray(candidate.details)
+    && candidate.details.every((detail) => typeof detail === "string")
+    && typeof candidate.modelContent === "string"
+}
+
+
+export function isExplicitStructuredGenerationFollowUp(
+  label: string,
+  options: { generationContext: boolean },
+): boolean {
+  const normalized = label.replace(/\s+/g, "").trim()
+  if (!normalized) return false
+  const structuredTarget = /(\u4eba\u7269(?:\u8bbe\u5b9a|\u5c0f\u4f20|\u5173\u7cfb|\u5f27\u5149)?|\u89d2\u8272(?:\u8bbe\u5b9a|\u5c0f\u4f20|\u5173\u7cfb|\u5f27\u5149)?|\u4e16\u754c\u89c2|\u5927\u7eb2|\u603b\u7eb2|\u5206\u5377|\u7ae0\u8282\u89c4\u5212|\u5267\u60c5|\u6545\u4e8b\u7ebf|\u4f0f\u7b14|\u52bf\u529b|\u5730\u70b9\u8bbe\u5b9a|\u573a\u666f\u8bbe\u5b9a|\u5f53\u524d\u6a21\u5757)/
+  const explicitGeneration = /(\u751f\u6210|\u521b\u4f5c|\u64b0\u5199|\u7f16\u5199|\u8bbe\u8ba1|\u6784\u5efa|\u5236\u5b9a|\u4ea7\u51fa)/
+  if (explicitGeneration.test(normalized) && structuredTarget.test(normalized)) return true
+  return options.generationContext
+    && /(\u7ee7\u7eed|\u5b8c\u5584|\u8865\u5145|\u6269\u5199|\u7ec6\u5316|\u6df1\u5316)/.test(normalized)
+    && structuredTarget.test(normalized)
+}

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

@@ -12,6 +12,11 @@ describe("AI 大纲多会话副作用隔离", () => {
     expect(canApplyOutlineRunEffect(states, "a", "new")).toBe(true)
     expect(canApplyOutlineRunEffect(states, "a", "new")).toBe(true)
   })
   })
 
 
+  it("停止后晚到回调不能覆盖最终状态", () => {
+    const stoppedStates = { a: { status: "idle" as const, updatedAt: 3 } }
+    expect(canApplyOutlineRunEffect(stoppedStates, "a", "stopped-run")).toBe(false)
+  })
+
   it("后台 follow-up 不清除用户当前 B 会话草稿", () => {
   it("后台 follow-up 不清除用户当前 B 会话草稿", () => {
     expect(shouldClearOutlineDraft({ clearDraft: true, invocationConversationId: "a", activeConversationId: "b" })).toBe(false)
     expect(shouldClearOutlineDraft({ clearDraft: true, invocationConversationId: "a", activeConversationId: "b" })).toBe(false)
     expect(shouldClearOutlineDraft({ clearDraft: false, invocationConversationId: "b", activeConversationId: "b" })).toBe(false)
     expect(shouldClearOutlineDraft({ clearDraft: false, invocationConversationId: "b", activeConversationId: "b" })).toBe(false)

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

@@ -17,6 +17,18 @@ export function shouldClearOutlineDraft(input: {
   return input.clearDraft && input.invocationConversationId === input.activeConversationId
   return input.clearDraft && input.invocationConversationId === input.activeConversationId
 }
 }
 
 
+export function shouldClearOutlineReferences(input: {
+  invocationConversationId: string | null
+  activeConversationId: string | null
+  sentReferences: ReadonlyArray<{ id: string }>
+  currentReferences: ReadonlyArray<{ id: string }>
+}): boolean {
+  if (input.invocationConversationId !== input.activeConversationId) return false
+  if (input.sentReferences.length !== input.currentReferences.length) return false
+  const currentIds = new Set(input.currentReferences.map((reference) => reference.id))
+  return input.sentReferences.every((reference) => currentIds.has(reference.id))
+}
+
 export function setOutlineSessionValue<T>(
 export function setOutlineSessionValue<T>(
   values: Record<string, T>,
   values: Record<string, T>,
   conversationId: string,
   conversationId: string,

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

@@ -98,7 +98,6 @@ export function cleanNextStepArtifacts(text: string): string {
   const start = cleaned.search(START_PATTERN)
   const start = cleaned.search(START_PATTERN)
   if (start >= 0) cleaned = cleaned.slice(0, start)
   if (start >= 0) cleaned = cleaned.slice(0, start)
   cleaned = cleaned.replace(END_PATTERN, "").replace(NAKED_MARKER_PATTERN, "")
   cleaned = cleaned.replace(END_PATTERN, "").replace(NAKED_MARKER_PATTERN, "")
-  cleaned = cleaned.replace(/```(?:json)?\s*$/i, "")
   return cleaned.trim()
   return cleaned.trim()
 }
 }
 
 

+ 3 - 0
src/lib/novel/outline-wizard.ts

@@ -27,6 +27,8 @@ export interface OutlineWizardGenreOption {
   label: string
   label: string
 }
 }
 
 
+export type OutlineWizardExplicitField = "task" | "length" | "channel" | "genre" | "customGenre" | "inspiration" | "sellingPoints" | "targets" | "scale" | "narrative" | "materialSource"
+
 export interface OutlineWizardRequest {
 export interface OutlineWizardRequest {
   task: OutlineWizardTask
   task: OutlineWizardTask
   length: OutlineWizardLength
   length: OutlineWizardLength
@@ -39,6 +41,7 @@ export interface OutlineWizardRequest {
   scale: string
   scale: string
   narrative: OutlineWizardNarrative
   narrative: OutlineWizardNarrative
   materialSource: OutlineWizardMaterialSource
   materialSource: OutlineWizardMaterialSource
+  explicit?: Partial<Record<OutlineWizardExplicitField, boolean>>
 }
 }
 
 
 export const OUTLINE_WIZARD_TASK_OPTIONS: OutlineWizardOption<OutlineWizardTask>[] = [
 export const OUTLINE_WIZARD_TASK_OPTIONS: OutlineWizardOption<OutlineWizardTask>[] = [

+ 74 - 0
src/lib/project-store.spec.ts

@@ -0,0 +1,74 @@
+import { beforeEach, describe, expect, it, vi } from "vitest"
+
+const storeMocks = vi.hoisted(() => {
+  const values = new Map<string, unknown>()
+  return {
+    values,
+    get: vi.fn(async (key: string) => values.get(key)),
+    set: vi.fn(async (key: string, value: unknown) => { values.set(key, value) }),
+  }
+})
+
+vi.mock("@/lib/web-store", () => ({
+  getStore: vi.fn(async () => ({ get: storeMocks.get, set: storeMocks.set })),
+}))
+
+import {
+  loadAiOutlineModel,
+  saveAiOutlineModel,
+} from "@/lib/project-store"
+
+describe("AI outline model persistence", () => {
+  beforeEach(() => {
+    storeMocks.values.clear()
+    storeMocks.get.mockClear()
+    storeMocks.set.mockClear()
+  })
+
+  it("saves and restores a stable provider/model id under the outline-only key", async () => {
+    await saveAiOutlineModel("openai/gpt-4o")
+
+    expect(storeMocks.set).toHaveBeenCalledWith("aiOutlineModel", "openai/gpt-4o")
+    expect(storeMocks.values.has("aiChatModel")).toBe(false)
+    expect(storeMocks.values.has("defaultLlmModel")).toBe(false)
+    await expect(loadAiOutlineModel()).resolves.toBe("openai/gpt-4o")
+  })
+
+  it("keeps the latest model on disk when pending writes finish in reverse order", async () => {
+    const pendingWrites: Array<{ value: unknown; resolve: () => void }> = []
+    storeMocks.set.mockImplementation(async (key: string, value: unknown) => {
+      await new Promise<void>((resolve) => {
+        pendingWrites.push({
+          value,
+          resolve: () => {
+            storeMocks.values.set(key, value)
+            resolve()
+          },
+        })
+      })
+    })
+
+    const fallbackSave = saveAiOutlineModel("openai/fallback-model")
+    const manualSave = saveAiOutlineModel("anthropic/manual-model")
+    for (let attempt = 0; attempt < 20 && pendingWrites.length < 2; attempt += 1) {
+      await Promise.resolve()
+    }
+    expect(pendingWrites.map((write) => write.value)).toEqual([
+      "openai/fallback-model",
+      "anthropic/manual-model",
+    ])
+
+    pendingWrites[1].resolve()
+    await Promise.resolve()
+    pendingWrites[0].resolve()
+    for (let attempt = 0; attempt < 20 && pendingWrites.length < 3; attempt += 1) {
+      await Promise.resolve()
+    }
+    expect(pendingWrites[2]?.value).toBe("anthropic/manual-model")
+    pendingWrites[2].resolve()
+    await Promise.all([fallbackSave, manualSave])
+
+    expect(storeMocks.values.get("aiOutlineModel")).toBe("anthropic/manual-model")
+  })
+
+})

+ 21 - 0
src/lib/project-store.ts

@@ -48,6 +48,9 @@ export async function addToRecentProjects(
 
 
 const LLM_CONFIG_KEY = "llmConfig"
 const LLM_CONFIG_KEY = "llmConfig"
 const AI_CHAT_MODEL_KEY = "aiChatModel"
 const AI_CHAT_MODEL_KEY = "aiChatModel"
+const AI_OUTLINE_MODEL_KEY = "aiOutlineModel"
+let aiOutlineModelSaveRevision = 0
+let latestAiOutlineModel = ""
 const DEFAULT_LLM_MODEL_KEY = "defaultLlmModel"
 const DEFAULT_LLM_MODEL_KEY = "defaultLlmModel"
 const PROVIDER_CONFIGS_KEY = "providerConfigs"
 const PROVIDER_CONFIGS_KEY = "providerConfigs"
 const ACTIVE_PRESET_KEY = "activePresetId"
 const ACTIVE_PRESET_KEY = "activePresetId"
@@ -72,6 +75,24 @@ export async function loadAiChatModel(): Promise<string | null> {
   return (await store.get<string>(AI_CHAT_MODEL_KEY)) ?? null
   return (await store.get<string>(AI_CHAT_MODEL_KEY)) ?? null
 }
 }
 
 
+export async function saveAiOutlineModel(model: string): Promise<void> {
+  const writeRevision = ++aiOutlineModelSaveRevision
+  latestAiOutlineModel = model
+  const store = await getStore()
+  await store.set(AI_OUTLINE_MODEL_KEY, model)
+
+  let persistedRevision = writeRevision
+  while (persistedRevision !== aiOutlineModelSaveRevision) {
+    persistedRevision = aiOutlineModelSaveRevision
+    await store.set(AI_OUTLINE_MODEL_KEY, latestAiOutlineModel)
+  }
+}
+
+export async function loadAiOutlineModel(): Promise<string | null> {
+  const store = await getStore()
+  return (await store.get<string>(AI_OUTLINE_MODEL_KEY)) ?? null
+}
+
 export async function saveDefaultLlmModel(model: string): Promise<void> {
 export async function saveDefaultLlmModel(model: string): Promise<void> {
   const store = await getStore()
   const store = await getStore()
   await store.set(DEFAULT_LLM_MODEL_KEY, model)
   await store.set(DEFAULT_LLM_MODEL_KEY, model)

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

@@ -6,6 +6,8 @@ vi.mock("@/commands/fs", () => fsMocks)
 import type { OutlineChatConversation } from "./outline-chat-store"
 import type { OutlineChatConversation } from "./outline-chat-store"
 import { useOutlineChatStore } from "./outline-chat-store"
 import { useOutlineChatStore } from "./outline-chat-store"
 import { useWikiStore } from "./wiki-store"
 import { useWikiStore } from "./wiki-store"
+import { createNovelGenerationRequestPackage, getOutlineMessageModelContent } from "@/lib/novel/novel-generation-request-package"
+import type { OutlineWizardRequest } from "@/lib/novel/outline-wizard"
 
 
 function conversation(id: string): OutlineChatConversation {
 function conversation(id: string): OutlineChatConversation {
   return { id, title: id, createdAt: 10, updatedAt: 10, messages: [] }
   return { id, title: id, createdAt: 10, updatedAt: 10, messages: [] }
@@ -154,4 +156,44 @@ describe("outline-chat-store", () => {
       conversations: [], activeConversationId: null, pendingReferenceTokens: [], loaded: true,
       conversations: [], activeConversationId: null, pendingReferenceTokens: [], loaded: true,
     })
     })
   })
   })
+  it("persists structured model content and reloads legacy messages", async () => {
+    useWikiStore.setState({ project: { name: "??", path: "C:/Book" } })
+    const request: OutlineWizardRequest = {
+      task: "newBook", length: "auto", channel: "auto", genre: "auto", customGenre: "",
+      inspiration: "persisted inspiration", sellingPoints: [], targets: ["outline"], scale: "",
+      narrative: "auto", materialSource: "none",
+    }
+    const structured = createNovelGenerationRequestPackage(request, "full model workflow")
+    const stored = conversation("persisted")
+    stored.messages = [
+      { id: "new", role: "user", content: structured.summary, novelGenerationRequest: structured },
+      { id: "old", role: "user", content: "legacy body" },
+    ]
+    useOutlineChatStore.setState({ conversations: [stored], activeConversationId: "persisted" })
+    await useOutlineChatStore.getState().saveToDisk()
+    const savedJson = fsMocks.writeFile.mock.calls[0][1]
+    fsMocks.readFile.mockResolvedValue(savedJson)
+    useOutlineChatStore.setState({ conversations: [], activeConversationId: null })
+    await useOutlineChatStore.getState().loadFromDisk()
+    const messages = useOutlineChatStore.getState().conversations[0].messages
+    expect(messages[0].content).toBe(structured.summary)
+    expect(getOutlineMessageModelContent(messages[0])).toBe("full model workflow")
+    expect(getOutlineMessageModelContent(messages[1])).toBe("legacy body")
+  })
+
+  it.each([
+    { version: 2, summary: "??", details: ["??"], modelContent: "??" },
+    { version: 1, summary: "??", details: ["??"] },
+    { version: 1, summary: "??", details: "??", modelContent: "??" },
+  ])("ignores invalid persisted novel request package %#", async (invalidPackage) => {
+    useWikiStore.setState({ project: { name: "??", path: "C:/Book" } })
+    const stored = conversation("invalid")
+    stored.messages = [{ id: "u", role: "user", content: "????", novelGenerationRequest: invalidPackage as never }]
+    fsMocks.readFile.mockResolvedValue(JSON.stringify({ conversations: [stored], activeConversationId: "invalid" }))
+    await useOutlineChatStore.getState().loadFromDisk()
+    const message = useOutlineChatStore.getState().conversations[0].messages[0]
+    expect(message.novelGenerationRequest).toBeUndefined()
+    expect(getOutlineMessageModelContent(message)).toBe("????")
+  })
+
 })
 })

+ 8 - 0
src/stores/outline-chat-store.ts

@@ -6,6 +6,7 @@ import type { ReferenceToken } from "@/lib/reference/types"
 import { useWikiStore } from "@/stores/wiki-store"
 import { useWikiStore } from "@/stores/wiki-store"
 import type { IntentClarityResult } from "@/lib/novel/outline-intent-clarity"
 import type { IntentClarityResult } from "@/lib/novel/outline-intent-clarity"
 import type { NextStepRecommendation } from "@/lib/novel/outline-next-step"
 import type { NextStepRecommendation } from "@/lib/novel/outline-next-step"
+import { isNovelGenerationRequestPackage, type NovelGenerationRequestPackage } from "@/lib/novel/novel-generation-request-package"
 import {
 import {
   canStartConversationRun as canStartRun,
   canStartConversationRun as canStartRun,
   failConversationRun as createFailedRunState,
   failConversationRun as createFailedRunState,
@@ -79,6 +80,7 @@ export interface OutlineChatMessage {
   intentPhase?: "intent_analysis" | "generation" | "waiting_user_input"
   intentPhase?: "intent_analysis" | "generation" | "waiting_user_input"
   intentClarityResult?: IntentClarityResult | null
   intentClarityResult?: IntentClarityResult | null
   nextStepRecommendation?: NextStepRecommendation | null
   nextStepRecommendation?: NextStepRecommendation | null
+  novelGenerationRequest?: NovelGenerationRequestPackage
 }
 }
 
 
 export interface OutlineChatConversation {
 export interface OutlineChatConversation {
@@ -364,6 +366,12 @@ export const useOutlineChatStore = create<OutlineChatState>((set, get) => {
       const conversations = (data.conversations ?? []).map((conversation) => ({
       const conversations = (data.conversations ?? []).map((conversation) => ({
         ...conversation,
         ...conversation,
         updatedAt: conversation.updatedAt ?? conversation.createdAt ?? Date.now(),
         updatedAt: conversation.updatedAt ?? conversation.createdAt ?? Date.now(),
+        messages: conversation.messages.map((message) => ({
+          ...message,
+          novelGenerationRequest: isNovelGenerationRequestPackage(message.novelGenerationRequest)
+            ? message.novelGenerationRequest
+            : undefined,
+        })),
       }))
       }))
       const conversationIds = new Set(conversations.map((conversation) => conversation.id))
       const conversationIds = new Set(conversations.map((conversation) => conversation.id))
       const runStates = Object.fromEntries(
       const runStates = Object.fromEntries(

+ 22 - 0
src/stores/wiki-store.ai-outline-model.spec.ts

@@ -0,0 +1,22 @@
+import { beforeEach, describe, expect, it } from "vitest"
+
+import { useWikiStore } from "@/stores/wiki-store"
+
+describe("wiki-store AI outline model", () => {
+  beforeEach(() => {
+    useWikiStore.setState({
+      aiChatModel: "openai/chat-model",
+      aiOutlineModel: "",
+      defaultLlmModel: "openai/default-model",
+    })
+  })
+
+  it("updates only the global outline model without changing chat or defaults", () => {
+    useWikiStore.getState().setAiOutlineModel("anthropic/claude-sonnet")
+
+    const state = useWikiStore.getState()
+    expect(state.aiOutlineModel).toBe("anthropic/claude-sonnet")
+    expect(state.aiChatModel).toBe("openai/chat-model")
+    expect(state.defaultLlmModel).toBe("openai/default-model")
+  })
+})

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

@@ -572,6 +572,9 @@ interface WikiState {
   refreshGraph: (() => void) | null
   refreshGraph: (() => void) | null
   llmConfig: LlmConfig
   llmConfig: LlmConfig
   aiChatModel: string
   aiChatModel: string
+  /** Dedicated global AI outline model key; isolated from AI chat. */
+  aiOutlineModel: string
+  aiOutlineModelRevision: number
   /** 默认模型(工作流):拆文库、导入队列、去重、角色 aura 等。章节/大纲记忆摄取见 novelConfig.extractModel */
   /** 默认模型(工作流):拆文库、导入队列、去重、角色 aura 等。章节/大纲记忆摄取见 novelConfig.extractModel */
   defaultLlmModel: string
   defaultLlmModel: string
   /** Per-provider-preset stored overrides (API key, model, endpoint, …). */
   /** Per-provider-preset stored overrides (API key, model, endpoint, …). */
@@ -647,6 +650,7 @@ interface WikiState {
   setRefreshGraph: (refreshGraph: (() => void) | null) => void
   setRefreshGraph: (refreshGraph: (() => void) | null) => void
   setLlmConfig: (config: LlmConfig) => void
   setLlmConfig: (config: LlmConfig) => void
   setAiChatModel: (model: string) => void
   setAiChatModel: (model: string) => void
+  setAiOutlineModel: (model: string) => void
   setDefaultLlmModel: (model: string) => void
   setDefaultLlmModel: (model: string) => void
   setProviderConfigs: (configs: ProviderConfigs) => void
   setProviderConfigs: (configs: ProviderConfigs) => void
   setActivePresetId: (id: string | null) => void
   setActivePresetId: (id: string | null) => void
@@ -733,6 +737,8 @@ export const useWikiStore = create<WikiState>((set) => ({
     localCliIsolation: false,
     localCliIsolation: false,
   },
   },
   aiChatModel: "",
   aiChatModel: "",
+  aiOutlineModel: "",
+  aiOutlineModelRevision: 0,
   defaultLlmModel: "",
   defaultLlmModel: "",
   providerConfigs: {},
   providerConfigs: {},
   activePresetId: null,
   activePresetId: null,
@@ -901,6 +907,10 @@ export const useWikiStore = create<WikiState>((set) => ({
 
 
   setLlmConfig: (llmConfig) => set({ llmConfig }),
   setLlmConfig: (llmConfig) => set({ llmConfig }),
   setAiChatModel: (aiChatModel) => set({ aiChatModel }),
   setAiChatModel: (aiChatModel) => set({ aiChatModel }),
+  setAiOutlineModel: (aiOutlineModel) => set((state) => ({
+    aiOutlineModel,
+    aiOutlineModelRevision: state.aiOutlineModelRevision + 1,
+  })),
   setDefaultLlmModel: (defaultLlmModel) => set({ defaultLlmModel }),
   setDefaultLlmModel: (defaultLlmModel) => set({ defaultLlmModel }),
   setProviderConfigs: (providerConfigs) => set({ providerConfigs }),
   setProviderConfigs: (providerConfigs) => set({ providerConfigs }),
   setActivePresetId: (activePresetId) => set({ activePresetId }),
   setActivePresetId: (activePresetId) => set({ activePresetId }),