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

Merge PR #23: 文件同步重构、磁盘轮询、LLM模型配置、macOS支持等

# Conflicts:
#	package.json
#	src/App.tsx
#	src/lib/changelog.spec.ts
#	src/lib/novel/deep-chapter-generation.ts
#	src/lib/novel/outline-generation.ts
#	src/stores/wiki-store.ts
Mochocyang 2 месяцев назад
Родитель
Сommit
96cc3f0ff5
100 измененных файлов с 14873 добавлено и 2772 удалено
  1. 227 0
      (1) 分析文档并添加功能.md
  2. 49 0
      .agents/AGENTS.md
  3. 154 10
      .github/workflows/build.yml
  4. 3 0
      .gitignore
  5. 172 0
      @引用与内容发送AI-PRD.md
  6. 234 0
      @引用与内容发送AI-实现规划.md
  7. 823 0
      AI-Agent架构-PRD.md
  8. 349 0
      AI-Agent架构-问答记录.md
  9. 348 0
      AI会话ReAct主架构-设计计划PRD.md
  10. 1103 0
      AI会话工作流与测试标准整理.md
  11. 677 0
      LICENSE
  12. BIN
      QM-LOGO.png
  13. 18 4
      README.md
  14. 0 22
      cangzhuseceshiban-分支说明.md
  15. 0 1066
      docs/superpowers/plans/2026-06-29-de-ai-skill-library.md
  16. 0 160
      docs/superpowers/specs/2026-06-29-de-ai-skill-library-design.md
  17. 0 125
      docs/superpowers/specs/2026-06-30-outline-chat-bottom-controls-design.md
  18. 17 0
      jinengku-xinjianskill-xiufu-分支说明.md
  19. 0 43
      jinengku-youhua-分支说明.md
  20. 142 0
      juqingtuiyanshi-duozhinengti-分支说明.md
  21. 341 0
      juqingtuiyanshiqi-设计文档.md
  22. 979 0
      juqingtuiyanshisanqi-实施计划.md
  23. 229 0
      juqingtuiyanshisanqi-设计文档.md
  24. 585 0
      juqingtuiyanshiwuqi-实施计划.md
  25. 415 0
      juqingtuiyanshiwuqi-设计文档.md
  26. 300 273
      package-lock.json
  27. 32 31
      package.json
  28. BIN
      qmai-portable-screenshot.png
  29. BIN
      qmai-runtime-screenshot.png
  30. BIN
      remote-changelog.ts
  31. 94 0
      scripts/capture-qmai-window.py
  32. 0 0
      scripts/dev/notes/jinengku.md
  33. 8 3
      scripts/dev/test-api-403.js
  34. 8 3
      scripts/dev/test-api.js
  35. 0 0
      scripts/dev/验证修复.js
  36. 0 26
      shurukuanggaizao.md
  37. 0 0
      skills/de-ai-writing/references/novel-de-ai-rules.md
  38. 37 37
      src-tauri/Cargo.lock
  39. 4 4
      src-tauri/Cargo.toml
  40. 3023 0
      src-tauri/gen/schemas/macOS-schema.json
  41. BIN
      src-tauri/pdfium/libpdfium.dylib
  42. 19 320
      src-tauri/src/commands/file_sync.rs
  43. 36 8
      src-tauri/src/commands/fs.rs
  44. 1 1
      src-tauri/tauri.conf.json
  45. 132 96
      src/App.tsx
  46. 0 0
      src/assets/QM-LOGO-original.png
  47. 55 2
      src/commands/fs.ts
  48. 3 1
      src/components/chat/chat-panel.tsx
  49. 3 2
      src/components/editor/frontmatter-panel.tsx
  50. 32 7
      src/components/editor/wiki-editor.tsx
  51. 2 1
      src/components/editor/wiki-reader.tsx
  52. 6 10
      src/components/layout/app-layout.tsx
  53. 177 52
      src/components/layout/preview-panel.tsx
  54. 9 5
      src/components/lint/lint-view.tsx
  55. 11 18
      src/components/novel/book-analysis-result-viewer.tsx
  56. 9 7
      src/components/novel/book-analysis-view.tsx
  57. 6 9
      src/components/novel/hooks/use-character-extraction.ts
  58. 4 1
      src/components/novel/hooks/use-character-recognition.ts
  59. 5 2
      src/components/novel/hooks/use-library-operations.ts
  60. 10 1
      src/components/novel/outline-editor.tsx
  61. 546 0
      src/components/novel/story-simulation/branch-compare-view.tsx
  62. 330 0
      src/components/novel/story-simulation/detective-board-panel.tsx
  63. 3 3
      src/components/settings/llm-wiki-model-settings.spec.ts
  64. 50 24
      src/components/settings/sections/changelog-section.tsx
  65. 492 108
      src/components/settings/sections/maintenance-section.tsx
  66. 162 68
      src/components/settings/sections/novel-section.tsx
  67. 35 11
      src/components/settings/settings-view.tsx
  68. 1 1
      src/components/sources/outline-chat-panel.tsx
  69. 421 0
      src/hooks/use-simulation-worker.ts
  70. 32 14
      src/i18n/en.json
  71. 29 12
      src/i18n/zh.json
  72. 20 0
      src/lib/app-update-support.ts
  73. 2 1
      src/lib/app-updater.ts
  74. 3 2
      src/lib/changelog.spec.ts
  75. 29 0
      src/lib/changelog.ts
  76. 88 8
      src/lib/dedup-queue.ts
  77. 254 0
      src/lib/dedup-runner.spec.ts
  78. 122 31
      src/lib/dedup-runner.ts
  79. 105 0
      src/lib/dedup-scan-cache.ts
  80. 47 0
      src/lib/editor-disk-sync.spec.ts
  81. 22 0
      src/lib/editor-disk-sync.ts
  82. 34 0
      src/lib/llm-model-keys.ts
  83. 85 0
      src/lib/novel/chapter-path-sync.spec.ts
  84. 60 0
      src/lib/novel/chapter-path-sync.ts
  85. 28 0
      src/lib/novel/classification/classification.story-sim.md
  86. 6 2
      src/lib/novel/deep-chapter-generation.ts
  87. 89 32
      src/lib/novel/model-resolver.ts
  88. 12 7
      src/lib/novel/novel-model-test.ts
  89. 3 3
      src/lib/novel/outline-generation.ts
  90. 270 0
      src/lib/novel/story-simulation/spread-rumor.spec.ts
  91. 3 23
      src/lib/project-file-sync.ts
  92. 81 0
      src/lib/project-file-tree-refresh.ts
  93. 2 5
      src/lib/project-refresh.ts
  94. 20 3
      src/lib/project-store.ts
  95. 3 1
      src/lib/source-lifecycle.ts
  96. 6 0
      src/lib/update-error-message.ts
  97. 106 59
      src/lib/wiki-page-resolver.ts
  98. 37 4
      src/stores/wiki-store.ts
  99. 344 0
      src/workers/simulation-worker.ts
  100. BIN
      staged-files.txt

+ 227 - 0
(1) 分析文档并添加功能.md

@@ -0,0 +1,227 @@
+# 分析文档并添加功能
+
+> **Workspace:** c:\QMAI_C\QMAI-main
+
+---
+
+**User**
+
+`c:\QMAI_C\QMAI-main\docs\superpowers\plans\2026-06-30-agent-yinyongji.md` `c:\QMAI_C\QMAI-main\docs\superpowers\specs\2026-06-30-agent-yinyongji-design.md` 分析这两个文档,按照这两个文档内容开始完成这个功能添加。
+
+**Assistant**
+
+我先读取这两个文档,然后使用 `brainstorming` 技能分析并开始规划实现。
+
+I will first read the two planning/specification documents to clarify the remaining tasks and current codebase state.
+
+I have reviewed the plan and design documents. Now I will check the current Git state and verify the completed files before continuing with the remaining Task 1.11–1.14.
+
+在动手实现前,有两个需要确认的点:
+
+1. **测试依赖问题**:任务要求使用 `@testing-library/react + vitest` 写测试,但当前项目的 `node_modules` 和 `package.json` 中都没有 `@testing-library/react`。项目其他组件测试用的是 `react-dom/server` 或 `createRoot + act` + `// @vitest-environment jsdom` 模式。  
+   请问:我是否应该安装 `@testing-library/react` 作为 dev dependency?还是沿用项目现有的测试写法(不引入新依赖)?
+
+2. **工具数量问题**:任务说明里提到“覆盖所有 17 个内置工具名”,但目前 `src/lib/agent/tools/index.ts` 实际注册了 15 个工具(read*6、search*1、list*5、write*3、apply_skill*1)。  
+   请问:描述函数只需要覆盖当前实际存在的这 15 个名称,还是需要额外预留 2 个未注册的工具名?
+
+**Status:** DONE
+
+**Commits:**
+
+- `5a5dcbb` feat(chat): add AgentToolCallMessage UI component
+
+**Test summary:** `pnpm vitest run src/components/chat/agent-tool-call-message.spec.tsx` — 6 tests passed; `pnpm typecheck` — passed.
+
+**Concerns:** 测试运行时有 `act(...)` 环境警告,但全部测试通过,与项目现有 `chat-input.resize-interaction.spec.tsx` 使用方式一致,不影响功能验证。
+
+**Report file:** `c:\QMAI_C\QMAI-main\.superpowers\sdd\task-1.11-report.md`
+
+### Spec Compliance
+
+- ❌ Issues found
+- ⚠️ Cannot verify from diff:brief 声明的“17 个内置工具名”在 diff 中只对应 15 个;已核对代码库 `src/lib/agent/tools/index.ts` 实际仅注册 15 个工具,因此 17 个的说法与代码库现状不符。
+
+### Strengths
+
+- 组件严格按 brief 实现了空数组/undefined 返回 null、蓝色左侧边框、浅蓝背景、“AI 工具调用”标题、可展开结果、错误红色样式等核心要求(`src/components/chat/agent-tool-call-message.tsx:355-374`)。
+- 图标规范符合要求:读取 `BookOpen`、写入 `Pencil`、行动 `Zap`、错误 `XCircle`,均来自 `lucide-react`,无 emoji。
+- 面向用户文本为中文,无新依赖。
+- 测试覆盖了工具名/描述渲染、错误样式、空数组、展开折叠、描述函数覆盖率和未知工具回退,6 个用例全部通过。
+- `getToolCallDescription` 已覆盖代码库中实际注册的全部 15 个内置工具,并为未知工具名提供合理回退。
+
+### Issues
+
+#### Critical (Must Fix)
+
+- 无。
+
+#### Important (Should Fix)
+
+- **工具名数量与 brief 声明不符**:brief 要求覆盖“所有 17 个内置工具名”,但组件仅处理 15 个(`src/components/chat/agent-tool-call-message.tsx:247`)。虽然代码库实际只注册了 15 个,diff 无法证明存在“pre-answered”同意按 15 个处理的记录,建议补充说明或更新 brief。
+- **测试框架与 brief 要求不符**:brief 明确要求“使用 `@testing-library/react` + vitest”,但测试使用项目现有 `createRoot + act` 模式(`src/components/chat/agent-tool-call-message.spec.tsx:28`)。经查 `package.json` 确实未安装 `@testing-library/react`,且项目约束“不引入新依赖”,因此当前选择可理解,但仍与 brief 字面要求冲突。
+
+#### Minor (Nice to Have)
+
+- **额外 UI 元素**:标题添加了非 brief 要求的调用次数 “(n 次)”(`src/components/chat/agent-tool-call-message.tsx:364`),每行添加了非要求的耗时 duration(`src/components/chat/agent-tool-call-message.tsx:332`)。功能无害,但属于顺手增加。
+- **测试断言可更全面**:未断言具体图标种类(BookOpen/Pencil/Zap/XCircle)以及容器蓝色边框/浅蓝背景样式。
+- **描述函数参数回退不一致**:`read_outline_history` 仅读取 `conversationId`,不像 `read_chat_history` 做 `||` 回退;`read_chapter`/`read_outline` 使用 `name || path`,若两者都缺失会显示 `undefined`。
+
+### Assessment
+
+**Task quality:** Approved
+
+**Reasoning:** 组件功能完整、测试通过、类型检查通过、未引入新依赖,且覆盖了代码库中实际存在的全部工具。发现的两个 Important 问题本质上是 brief 与项目现状(15 个工具、无 `@testing-library/react`)的冲突,当前实现是在项目约束下的合理取舍,没有破坏旧功能或过度
+
+I will start by reading the task brief and examining the relevant files.
+
+**Status:** DONE
+
+**Commits:**
+
+- `2c72607` feat(chat): integrate AgentToolCallMessage and thinking indicator in assistant messages
+
+**测试汇总:**
+
+- `pnpm typecheck` 通过
+- `pnpm vitest run src/components/chat/chat-message` 通过(8 个测试全部通过)
+
+**顾虑:** 无
+
+**报告文件路径:** `c:\QMAI_C\QMAI-main\.superpowers\sdd\task-1.12-report.md`
+
+我将继续执行 agent-diji 分支上的 Agent 工具调用与 @引用功能开发。先读取规划文档和当前状态。
+
+状态:DONE
+
+测试总结:`pnpm typecheck` 通过,无新增类型错误;`src/stores/chat-store.test.ts` 5 个测试全部通过。
+
+顾虑:无。本次仅扩展 TypeScript 接口,未引入运行时逻辑;旧 persisted 消息缺少新增字段时视为 `undefined`,向后兼容。`isAgentRunning` 的不落盘依赖业务层仅在流式占位消息上设置,当前实现已满足。
+
+### Spec Compliance
+
+- ✅ Spec compliant。所有需求已在 diff 中实现:
+  - 新建 `src/lib/reference/types.ts`,包含 `ReferenceCategory` 与 `ReferenceToken`,字段与 brief 完全一致。
+  - `src/stores/chat-store.ts` 的 `DisplayMessage` 已包含 `agentToolCalls`、`isAgentRunning`、`attachedReferences` 三个可选字段。
+  - `src/stores/outline-chat-store.ts` 的 `OutlineChatMessage` 已新增同样的三个可选字段及对应类型导入。
+  - 未修改 `src/lib/persist.ts` 与 save/load 函数,未引入运行时逻辑或无关改动。
+- ⚠️ Cannot verify from diff:
+  - `chat-message.tsx` 中对 `message.agentToolCalls` / `message.isAgentRunning` 的类型错误是否已消除(该文件不在 diff 中)。
+  - `isAgentRunning` 在实际运行中是否仅用于流式占位消息、最终不落盘(依赖业务逻辑保证,diff 无法验证)。
+
+### Strengths
+
+- 改动范围极小且精确:仅新增一个类型文件,并在两个接口中追加可选字段,无无关重构。
+- 使用 `import type` 导入类型,避免运行时循环依赖与打包副作用。
+- 所有新增字段均为可选,旧 persisted 数据缺少这些字段时自然为 `undefined`,满足向后兼容要求。
+- `ReferenceToken` 类型独立收口在 `src/lib/reference/types.ts`,职责清晰,便于后续引用。
+
+### Issues
+
+#### Critical (Must Fix)
+
+无。
+
+#### Important (Should Fix)
+
+无。
+
+#### Minor (Nice to Have)
+
+无。
+
+### Assessment
+
+**Task quality:** Approved
+
+**Reasoning:** 该实现以最小改动完成了所有类型扩展要求,新增字段均为可选、未破坏现有结构,也未引入无关代码或运行时依赖;唯一无法从 diff 直接验证的是 `chat-message.tsx` 的类型消除与 `isAgentRunning` 的运行时落盘行为,但这两点均依赖 diff 外的既有代码逻辑,且 brief 已说明无需
+
+状态:**DONE**
+
+测试总结:`pnpm typecheck` 通过;`src/hooks/use-agent-config.spec.ts` 3 个测试通过;`src/lib/agent/` 33 个测试无回归。
+
+主要 concern:
+
+- 现有 `AgentConfig` 接口要求 `llmConfig`,brief 中只写了 `modelId`。为兼顾类型约束与 brief 意图,我在 `AgentConfig` 中新增了可选 `modelId`,并让 `buildAgentConfig` 通过 `options.llmConfig` 提供完整配置。
+- 全量 `pnpm test:mocks` 中仅有 `src/lib/changelog.spec.ts` 因项目版本已升至 `2.2.31` 而失败,与本次改动无关。
+- 不支持工具调用的模型前缀列表(`o1`、`o3-mini`、`deepseek-reasoner`、`claude-code`、`codex-cli`)为当前维护集合,后续有新增无工具能力模型时需同步更新。
+
+详细报告已写入:`c:\QMAI_C\QMAI-main\.super
+
+### Spec Compliance
+
+- ❌ Issues found:
+  - `use-agent-config.ts:98-99` 从 `useWikiStore` 读取了 `llmConfig`、`providerConfigs`,超出 brief 指定的 `aiChatModel`、`project?.path`、`dataVersion`。
+  - `use-agent-config.ts:82` 引入并使用 `resolveModelConfig`,brief 未提及。
+  - `use-agent-config.ts:102、138-148` 从 `useChatStore` 读取 `messages` 并过滤,brief 仅要求读取 `conversations`。
+  - `config.ts:42-44` 在 `BuildAgentConfigOptions` 中扩展 `llmConfig`,brief 未要求。
+  - `src/lib/agent/types.ts:38-39` 修改现有接口新增可选 `modelId`,改动安全但不在 brief 文件列表中。
+- ⚠️ Cannot verify from diff:
+  - brief 要求“损坏时走 normalize”,实现仅在 catch 中置 `skillConfig` 为 null,`loadDeAiSkillConfig` 内部是否包含 normalize 逻辑无法从 diff 确认。
+  - brief 要求的 `AgentConfig` 返回形 `{ ..., modelId }` 与现有 `AgentConfig` 必须包含 `llmConfig` 存在冲突,实现通过 options 传入 `llmConfig` 解决,但 brief 未说明该取舍。
+
+### Strengths
+
+- `modelSupportsTools` 将前缀集中在 `config.ts`,并同时检查完整标识与 `providerId/modelId` 中的模型名部分。
+- `buildAgentConfig` 先 `registry.clear()` 再 `registerAllBuiltInTools`,符合每次都清空重注册的要求。
+- `useMemo` 依赖项完整,回调使用 `useCallback` 保持引用稳定,可避免 hooks 间循环。
+- 测试覆盖了 brief 要求的三种场景,并使用 `vi.doMock` 正确 mock 了 store 与 `loadDeAiSkillConfig`。
+- 新增的 `AgentConfig.modelId` 为可选字段,不会破坏已有调用方。
+
+### Issues
+
+#### Important (plan-mandated)
+
+- `src/hooks/use-agent-config.ts:98-99`:额外读取 `llmConfig`、`providerConfigs`,与 brief 列出的 `useWikiStore` 消费字段不符。
+- `src/hooks/use-agent-config.ts:82`:引入 `resolveModelConfig` 解析完整模型配置,brief 未提及且未说明需要。
+- `src/hooks/use-agent-config.ts:102、138-148`:读取 `useChatStore` 的 `messages` 数组,brief 只要求 `conversations`;虽然实际 `ChatState` 确实将消息单独存储,但实现偏离 brief。
+- `src/lib/agent/config.ts:42-44`:`BuildAgentConfigOptions` 扩展 `llmConfig` 以满足现有 `AgentConfig` 类型,但 brief 未要求该字段。
+
+#### Minor (Nice to Have)
+
+- 测试未验证 `buildAgentConfig` 是否每次调用都执行 `registry.clear()`。
+- 实现未对 `loadDeAiSkillConfig` 的 normalize 行为做显式处理,若 brief 要求在外层 normalize,当前实现不足。
+
+### Assessment
+
+**Task quality:** Needs fixes
+
+**Reasoning:** 核心逻辑与 Agent 运行需求一致,`buildAgentConfig` 清空重注册、前缀集中管理、测试覆盖均正确;但实现明显超出 brief 范围,额外依赖 `llmConfig`、`providerConfigs`、`resolveModelConfig` 以及 `chat-store` 的 `messages`,需要与 brief 对齐或确认这些
+
+我先读取这两个规划/设计文档和当前的进度文件,确认已完成内容以及接下来需要实现的任务。
+
+I need to continue implementing the Agent/@引用 feature. Based on the current state, Branch 1 (agent-diji) has completed Tasks 1.1-1.14, but final verification hasn't been run yet. Since we have a written implementation plan and need to execute it step by step, I will invoke the `executing-plans` skill to proceed.
+
+正在使用 skill: executing-plans。
+
+我已经加载了两个规划/设计文档。当前进度显示分支 1(`agent-diji`)已完成核心框架与配置集成,但收尾验证尚未执行。后续还需要继续实现引用弹窗、输入组件,并把 chat-panel 和 outline-chat-panel 全面接入 Agent。
+
+我先检查当前 Git 分支,然后创建任务清单并按计划推进。
+
+---
+
+## 20260701-030810 进度更新
+
+**当前分支:** `yinyong-rongqi`
+
+**已完成:** 分支 2(引用容器)Task 2.1-2.7
+
+- 新增/补全引用类型、引用 Provider、引用解析器。
+- 新增 `ReferenceChip`、`ReferencePickerDialog`、`ReferenceInput`。
+- 知识树章节右键菜单新增“发送到AI会话”和“发送到AI大纲”入口。
+- 右键入口只生成并发送 `ReferenceToken` 给上层,不自动发送消息,符合设计文档约束。
+
+**验证:**
+
+- `npm exec -- vitest run src/lib/reference/providers.spec.ts src/lib/reference/resolve.spec.ts src/components/reference/ReferenceChip.spec.tsx src/components/reference/ReferencePickerDialog.spec.tsx src/components/reference/ReferenceInput.spec.tsx src/components/layout/knowledge-tree.long-press.test.tsx`:通过,6 个测试文件、33 个用例。
+- `npm run typecheck`:通过。
+- `npm run build`:通过。
+- `npm run dev -- --host 127.0.0.1`:源码启动正常,Vite ready。
+- `npm run build:portable`:打包成功,生成 `release-portable\QMaiWrite.exe`。
+- `npm run test:mocks`:仍有 1 个既有失败,位于 `src/lib/changelog.spec.ts`,原因是测试断言未包含当前 `2.2.31` 版本;该问题在 Task 1.14 阶段已有记录,与本次引用容器改动无关。
+
+**下一步:**
+
+- 进入分支 3 `agent-duihua`:将 AI 会话面板输入区替换为 `ReferenceInput`,接入 `ReferencePickerDialog`,并把 chat-panel 的发送流程统一到 Agent。
+
+---
+

+ 49 - 0
.agents/AGENTS.md

@@ -0,0 +1,49 @@
+# 项目基本逻辑
+
+## 产品定位
+
+QMAI 是**长篇小说记忆型 AI 写作桌面系统**(Tauri 2 + React 19 + TypeScript),不是普通聊天写作工具。目标场景:200 万~300 万字连载,解决 AI 遗忘前文、人设崩坏、时间线混乱、伏笔丢失。
+
+核心理念:**写前自动提取上下文 → 写后自动沉淀章节记忆 → 图谱追踪关系变化 → 审查防崩坏 → 人工确认定稿**。
+
+## 核心工作流(不可违背)
+
+1. **写前**:`buildContextPack()` 组装上下文包,按优先级裁剪 token(`src/lib/novel/context-engine.ts`)
+2. **生成**:LLM 输出默认为**草稿**,不写入正式记忆库
+3. **写后**:用户确认正式保存 → `ingestChapter()` 章节摄取 → 生成快照 JSON + 更新向量索引 + 增量更新图谱
+4. **审查**:六维审稿 + 连贯性 Lint + 角色一致性检查,草稿隔离直到人工确认
+
+## 上下文包优先级
+
+```
+用户指定 > 章节细纲 > 上一章结尾 > Canon 正史 > 人物状态 > 伏笔 > 最近摘要 > 正文片段 > 图谱 > 向量/关键词检索
+```
+
+修改上下文逻辑时,保持 `SECTION_PRIORITY` 顺序,并考虑 token 预算二次裁剪。
+
+## 代码分层
+
+| 层级 | 路径 | 职责 |
+|------|------|------|
+| UI | `src/components/` | React 组件,不含核心业务 |
+| 状态 | `src/stores/` | Zustand(wiki-store、review-store 等) |
+| 小说引擎 | `src/lib/novel/` | 记忆、上下文、摄取、审查、图谱、拆书 |
+| 通用工具 | `src/lib/` | LLM 客户端、搜索、嵌入、持久化 |
+| 后端 | `src-tauri/` | 文件系统、向量存储、PDF、进程 |
+
+小说相关逻辑优先放 `src/lib/novel/`,通过 `mod.ts` 导出;不要散落在 UI 组件里。
+
+## 数据与隔离原则
+
+- 本地存储:项目目录 = Markdown(章节正文)+ JSON(快照/状态)+ LanceDB(向量)
+- **草稿 ≠ 正式章节**:未确认内容不得触发摄取、不得污染记忆库
+- 角色认知(knows / does_not_know)必须在校验和上下文中保持一致
+- 图谱节点/边来自章节摄取快照,增量更新而非全量重建
+
+## 改动时的检查清单
+
+- 是否破坏草稿隔离?
+- 是否影响上下文包优先级或 token 预算?
+- 正式章节保存路径是否仍触发摄取 pipeline?
+- 新增 LLM 调用是否走 `resolveNovelModel()` / `resolveReviewModel()`?
+- UI 改动是否只需调 store,而非复制业务逻辑?

+ 154 - 10
.github/workflows/build.yml

@@ -5,13 +5,19 @@ on:
     tags:
       - "v*"
   workflow_dispatch:
+    inputs:
+      release_tag:
+        description: "目标 Release Tag(手动触发时上传 Linux 版本到指定 Release)"
+        required: true
+        default: "v2.2.31"
 
 permissions:
   contents: write
 
 jobs:
-  build:
+  build-release:
     name: Build ${{ matrix.display_label }}
+    if: github.event_name == 'push'
     runs-on: ${{ matrix.runner }}
     env:
       CARGO_PROFILE_RELEASE_LTO: "false"
@@ -54,6 +60,17 @@ jobs:
               src-tauri/target/**/release/bundle/dmg/*.dmg
               release-assets/*.app.tar.gz
 
+          - label: linux-x64
+            display_label: Linux x64
+            runner: ubuntu-22.04
+            rust_targets: ""
+            tauri_args: "--bundles appimage,deb"
+            pdfium_asset: "pdfium-linux-x64.tgz"
+            pdfium_library: "libpdfium.so"
+            artifact_globs: |
+              src-tauri/target/**/release/bundle/appimage/*.AppImage
+              src-tauri/target/**/release/bundle/deb/*.deb
+
     steps:
       - name: Checkout
         uses: actions/checkout@v4
@@ -124,7 +141,6 @@ jobs:
         run: npm ci
 
       - name: Prepare release notes
-        if: github.event_name == 'push'
         shell: bash
         run: |
           set -euo pipefail
@@ -133,7 +149,6 @@ jobs:
           node scripts/release-notes.mjs "$version" --out release-assets/release-notes.txt
 
       - name: Ensure GitHub Release exists
-        if: github.event_name == 'push'
         env:
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
         shell: bash
@@ -154,7 +169,7 @@ jobs:
         run: npx tauri build ${{ matrix.tauri_args }}
 
       - name: Publish Windows updater manifest
-        if: github.event_name == 'push' && matrix.label == 'windows-x64'
+        if: matrix.label == 'windows-x64'
         env:
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
           TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
@@ -237,15 +252,40 @@ jobs:
           fi
           tar -C "$(dirname "$app_path")" -czf "release-assets/QMaiWrite_${version}_${system_label}.app.tar.gz" "$(basename "$app_path")"
 
-      - name: Attach macOS app tarball to release
-        if: github.event_name == 'push' && runner.os == 'macOS'
+      - name: Package Linux release assets
+        if: runner.os == 'Linux'
+        shell: bash
+        run: |
+          set -euo pipefail
+          version="$(node -p "require('./package.json').version")"
+          mkdir -p release-assets
+
+          appimage_path="$(find src-tauri/target -path '*/release/bundle/appimage/*.AppImage' -print -quit)"
+          if [ -n "$appimage_path" ]; then
+            cp "$appimage_path" "release-assets/QMaiWrite_${version}_linux_X64.AppImage"
+          fi
+
+          deb_path="$(find src-tauri/target -path '*/release/bundle/deb/*.deb' -print -quit)"
+          if [ -n "$deb_path" ]; then
+            cp "$deb_path" "release-assets/QMaiWrite_${version}_linux_X64.deb"
+          fi
+
+      - name: Attach macOS assets to release
+        if: runner.os == 'macOS'
         env:
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
         shell: bash
         run: gh release upload "${{ github.ref_name }}" release-assets/QMaiWrite_*_macOS_* --clobber
 
+      - name: Attach Linux assets to release
+        if: runner.os == 'Linux'
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+        shell: bash
+        run: gh release upload "${{ github.ref_name }}" release-assets/QMaiWrite_*_linux_* --clobber
+
       - name: Build Windows portable
-        if: github.event_name == 'push' && matrix.label == 'windows-x64'
+        if: matrix.label == 'windows-x64'
         shell: pwsh
         run: |
           $ErrorActionPreference = "Stop"
@@ -261,17 +301,121 @@ jobs:
           Copy-Item $portableExe $assetPath -Force
 
       - name: Attach Windows portable to release
-        if: github.event_name == 'push' && matrix.label == 'windows-x64'
+        if: matrix.label == 'windows-x64'
         env:
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
         shell: bash
         run: gh release upload "${{ github.ref_name }}" release-assets/QMaiWrite_*_portable.exe --clobber
 
       - name: Upload bundles as workflow artifacts
-        if: github.event_name == 'workflow_dispatch'
+        if: failure()
         uses: actions/upload-artifact@v4
         with:
           name: qmai-${{ matrix.label }}
           path: ${{ matrix.artifact_globs }}
           if-no-files-found: warn
-          retention-days: 14
+          retention-days: 14
+
+  build-linux-only:
+    name: Build Linux x64 (manual)
+    if: github.event_name == 'workflow_dispatch'
+    runs-on: ubuntu-22.04
+    env:
+      CARGO_PROFILE_RELEASE_LTO: "false"
+      CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "16"
+      CARGO_PROFILE_RELEASE_OPT_LEVEL: "1"
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v4
+
+      - name: Install Rust stable
+        uses: dtolnay/rust-toolchain@stable
+
+      - name: Rust cache
+        uses: Swatinem/rust-cache@v2
+        with:
+          workspaces: src-tauri
+
+      - name: Install Linux dependencies
+        run: |
+          sudo apt-get update
+          sudo apt-get install -y \
+            build-essential \
+            curl \
+            file \
+            libayatana-appindicator3-dev \
+            librsvg2-dev \
+            libssl-dev \
+            libwebkit2gtk-4.1-dev \
+            libxdo-dev \
+            patchelf \
+            protobuf-compiler \
+            wget \
+            xdg-utils
+
+      - name: Install PDFium binary
+        shell: bash
+        run: |
+          set -euo pipefail
+          mkdir -p "$RUNNER_TEMP/pdfium" src-tauri/pdfium
+          curl -L --fail --retry 5 --retry-delay 2 \
+            -o "$RUNNER_TEMP/pdfium.tgz" \
+            "https://github.com/bblanchon/pdfium-binaries/releases/latest/download/pdfium-linux-x64.tgz"
+          tar -xzf "$RUNNER_TEMP/pdfium.tgz" -C "$RUNNER_TEMP/pdfium"
+          cp "$RUNNER_TEMP/pdfium/lib/libpdfium.so" "src-tauri/pdfium/libpdfium.so"
+          ls -la src-tauri/pdfium
+
+      - name: Setup Node.js
+        uses: actions/setup-node@v4
+        with:
+          node-version: 20
+          cache: npm
+
+      - name: Install frontend dependencies
+        run: npm ci
+
+      - name: Build Tauri app
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
+          TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
+        run: npx tauri build --bundles appimage,deb
+
+      - name: Package Linux release assets
+        shell: bash
+        run: |
+          set -euo pipefail
+          version="$(node -p "require('./package.json').version")"
+          mkdir -p release-assets
+
+          appimage_path="$(find src-tauri/target -path '*/release/bundle/appimage/*.AppImage' -print -quit)"
+          if [ -n "$appimage_path" ]; then
+            cp "$appimage_path" "release-assets/QMaiWrite_${version}_linux_X64.AppImage"
+          fi
+
+          deb_path="$(find src-tauri/target -path '*/release/bundle/deb/*.deb' -print -quit)"
+          if [ -n "$deb_path" ]; then
+            cp "$deb_path" "release-assets/QMaiWrite_${version}_linux_X64.deb"
+          fi
+
+      - name: Upload to target release
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+        shell: bash
+        run: |
+          set -euo pipefail
+          TAG="${{ inputs.release_tag }}"
+          echo "上传到 Release: $TAG"
+          gh release upload "$TAG" release-assets/QMaiWrite_*_linux_* --clobber
+          echo "上传完成!"
+
+      - name: Upload bundles as workflow artifacts
+        if: always()
+        uses: actions/upload-artifact@v4
+        with:
+          name: qmai-linux-x64
+          path: |
+            src-tauri/target/**/release/bundle/appimage/*.AppImage
+            src-tauri/target/**/release/bundle/deb/*.deb
+          if-no-files-found: warn
+          retention-days: 14

+ 3 - 0
.gitignore

@@ -82,3 +82,6 @@ De-AI-Prompt-Enhancer-Writer-Booster-SKILL-main.zip
 
 # === 杂项 ===
 /1.txt
+
+# === 开发调试产物(勿提交根目录)===
+/test-mocks-*-report.json

+ 172 - 0
@引用与内容发送AI-PRD.md

@@ -0,0 +1,172 @@
+# PRD:@ 引用与内容发送到 AI
+
+> 产品需求文档(PRD)。本文档定义「是什么」「为什么」「怎么处理」,
+> 具体的实现步骤由后续「实现规划」文档承载。
+> 状态:PRD 已定稿,待编写实现规划。
+
+---
+
+## 一、需求来源(原始问题)
+
+用户提出三个核心功能,要求 AI 能结合用户主动选择的各类内容生成连贯的小说内容:
+
+### 功能一:章节右键菜单增强
+1. 选中任意章节后右键调出上下文菜单,新增「发送到AI会话」选项。
+2. 点击后,系统将**完整章节内容**发送至 AI 会话界面,但**只显示对应的章节名称**。
+3. 当点击 AI 会话历史记录时,显示所有 AI 对话内容,用户可选择某条 AI 对话记录,AI 便能读取这些对话内容。
+4. AI 会话界面接收到章节内容后,需以特殊视觉样式显示(与普通文本区分),系统自动读取并记忆当前发送的完整章节内容;用户后续输入提示词时,AI 需将记忆的章节内容与新提示词结合生成回应。
+5. **不止 AI 会话,AI 大纲也需要加入这些内容。**
+
+### 功能二:独立功能按钮与弹窗
+1. 在应用界面合适位置添加一个独立功能按钮,图标为 @。
+2. 点击 @ 按钮弹出模态窗口,窗口布局参考示例图,包含可选择内容类别:章节、记忆库、大纲、推演室、技能库、AI对话历史记录、AI大纲对话记录等。
+3. 每个类别需**实时读取并显示**其对应存储的实际内容列表。
+4. 用户可在弹窗中选择一个或多个内容项;选择完成点击确认,所选内容以 @ 标识发送至 AI 会话。
+5. 示例格式:`@第1章-无我绝响 @曙光组织 分析这个章节和大纲,结合这个大纲中的人物角色按照@编号归途(故事框架)去生成故事内容`
+
+### 功能三:@ 符号快捷调用机制
+1. 在 AI 会话输入框中,当用户输入「@」符号时,自动触发弹窗显示。
+2. 弹窗内容与独立按钮打开的弹窗保持一致。
+3. 用户可通过键盘或鼠标选择所需内容项,插入到当前输入的提示词中。
+
+### 核心功能要求
+- AI 需能正确解析所有通过上述方式发送的特殊格式内容(@ 标识的章节、大纲等)。
+- 系统需建立上下文记忆机制,确保 AI 能结合用户选择的各类内容生成连贯小说内容。
+- 所有新增功能需与现有系统无缝集成,保持界面风格统一和操作流畅性。
+- 分析 AI 会话和 AI 大纲这两个功能如何实现。
+
+### 用户确认的关键决策
+- **发送粒度**:将章节标题发送进来,但 AI 读取的是全文。会话中只显示章节名称。
+- **历史记录语义**:跨会话引用——选中某条历史对话后,其内容作为上下文注入当前对话(新增能力)。
+- **推进范围**:三个功能 + AI 会话 / AI 大纲两处面板,全部都要做并落实规划。
+- **@ 令牌形态**:可解析引用令牌。
+
+---
+
+## 二、背景与现状
+
+经对 `src/components/chat/`、`src/components/sources/outline-chat-panel.tsx`、`src/components/layout/knowledge-tree.tsx`、`src/components/editor/wiki-reader.tsx` 及对应 store 的调研,确认:
+
+### 2.1 AI 会话
+- 已有会话历史列表(新建/切换/删除/重命名),数据模型为 `Conversation` + `DisplayMessage` 分离存储,持久化于 `.qmai/chats/`。
+- prompt 上下文目前是**自动检索**得到的(searchWiki → 图扩展 → 选页面拼进 system prompt),**没有**「用户主动 @ 引用某章节/大纲」的机制。
+- `ChatInput` 是 `<textarea>` 受控组件,`onSend` 只回传纯文本,无 @ 解析、无 insert 接口。
+
+### 2.2 AI 大纲
+- 与 AI 会话**完全独立**(独立 store/UI/handleSend),持久化于 `.qmai/outline-chats.json`。
+- 同样**无 @ 机制、无用户主动引用入口**。会话历史已有切换/删除。
+
+### 2.3 章节与右键菜单
+- 章节内容是磁盘 `.md`,靠 `readFile(path)` 读全文。
+- 右键菜单 `pageMenu` 仅含:新建/重命名/移动/打开文件位置——**无「发送到 AI」项**。
+- `wiki-reader.tsx` 纯只读渲染,**全项目无 `getSelection`/选区逻辑**。
+- **全项目无「章节→对话/大纲」跨面板内容传递机制**。
+
+### 2.4 内容类别数据源(已确认)
+| 类别 | 数据源 |
+|---|---|
+| 章节 | `<projectPath>/wiki/chapters/*.md` |
+| 记忆库 | `<projectPath>/wiki/memory/*.md` |
+| 大纲 | `<projectPath>/wiki/outlines/*.md` |
+| 推演室 | `<projectPath>/.qmai/simulations/`(story-simulation 框架与推演结果) |
+| 技能库 | 以 `skill-library-view.tsx` 实际数据源为准(执行前确认) |
+| AI 对话历史 | `useChatStore.conversations` |
+| AI 大纲历史 | `useOutlineChatStore.conversations` |
+
+---
+
+## 三、核心问题与处理决策
+
+### 3.1 问题一:现有上下文是「自动检索」,无法满足「用户主动指定」
+
+**问题**:现有 AI 会话/大纲的上下文由检索流水线自动生成,用户无法精确指定「我要 AI 参考第1章和这条大纲」。
+
+**处理**:新增一层「用户显式引用上下文」机制,与自动检索并存。引入 `ReferenceToken`(引用令牌)数据结构,用户通过三个入口(右键/按钮/@触发)选定内容后生成令牌,提交时解析令牌还原全文注入 system prompt。
+
+**关键原则**:对话中**只显示标题**,AI 实际读取全文——令牌携带标题与定位信息(路径/历史会话 id),渲染时显示标题,提交时按定位取全文。
+
+### 3.2 问题二:@ 引用项在输入框里以什么形态存在?
+
+**讨论**:两个方案。
+
+| 方案 | 实现 | 视觉 | 风险 |
+|---|---|---|---|
+| A. textarea + 芯片条 | 令牌以 `@【标题】` 文本插入 textarea,输入框上方渲染芯片条 | 芯片条 + 输入框内文本令牌 | 低,不动现有输入逻辑 |
+| B. 富文本内联芯片 | 重做输入框为 `contenteditable`,令牌内联为不可分割芯片节点 | 输入框内直接是芯片 | 高,需重写 IME/resize/autosize/粘贴 |
+
+**方案 A 的硬伤**:
+1. 双份表示——芯片条与输入框文本是同一引用两份表示,困惑该看/改哪个。
+2. 令牌易碎——光标插入 `@【标题】` 中间删字会损坏令牌,解析失败/错位。
+3. 视觉杂乱——多条 `@【...】` 混在提示词白话中,长标题占行。
+4. 删除歧义——删芯片还是删文本需双向同步,bug 来源持续。
+
+**方案 B 的优势**:
+1. 芯片是不可分割原子节点,光标进不去,删就整体删——符合「引用项」心智模型。
+2. 无重复表示,引用与提示词在一个流里视觉干净。
+3. 主流做法——Cursor / ChatGPT 的 @、飞书 / Notion 的 mention 都是这个形态,符合用户预期。
+
+**决策:采用方案 B(富文本内联芯片)**。用户已确认。
+
+**工程约束(重要)**:B 不在现有 `ChatInput` 上魔改,而是**另起一个支持 reference 的新输入组件作为基座**,老 `ChatInput` 能复用的逻辑(resize 策略、IME 判断、autosize)抽出来复用。即「重做一个干净的」,而非「在脏实现上打补丁」。
+
+### 3.3 问题三:如何让 AI「记忆」引用内容并保持上下文连贯?
+
+**处理**:引用随会话持久化。
+- `Conversation` 增 `attachedReferences`(会话级记忆,跨消息持续)。
+- `DisplayMessage` / `OutlineChatMessage` 增 `attachedReferences`(消息级,记录该条 user 消息引用了什么)。
+- 提交时合并「会话级 + 本次消息令牌解析结果」取全文注入。随 `.qmai/chats/`、`.qmai/outline-chats.json` 自动落盘,重开项目仍在。
+
+### 3.4 问题四:跨会话引用历史对话
+
+**处理**:内容类别含 `ai-chat-history` / `ai-outline-history`,`resolveTokenToText` 读取被引用历史会话的全部 messages 拼接为对话文本注入当前对话。即用户在当前对话选中某条历史记录,AI 即可读到其内容。大批量历史对话需截断/摘要防撑爆上下文。
+
+---
+
+## 四、功能定义(PRD 级)
+
+### F1. 章节右键发送到 AI 会话 / AI 大纲
+- **触发**:章节树节点右键 →「发送到AI会话」/「发送到AI大纲」。
+- **行为**:
+  1. 读取该章全文与标题。
+  2. 生成 `ReferenceToken(category:"chapter")`。
+  3. 写入目标面板当前会话的 `attachedReferences`,令牌以内联芯片进入输入框。
+  4. 自动切换激活目标面板。
+- **呈现**:会话消息中引用以芯片(标题)展示,**不展示全文**;AI 读取全文。
+- **范围**:AI 会话与 AI 大纲两处均支持。
+
+### F2. 独立 @ 按钮与内容选择弹窗
+- **触发**:AI 会话输入区、AI 大纲输入区各放一个 @ 图标按钮,点击弹出模态。
+- **弹窗结构**:左侧类别 Tab(章节/记忆库/大纲/推演室/技能库/AI对话历史/AI大纲历史),右侧实时列表(从对应数据源读取)+ 多选 + 搜索 + 确认/取消。
+- **确认行为**:所选内容生成令牌,以内联芯片插入输入框,并写入会话 `attachedReferences`。
+
+### F3. @ 快捷输入触发
+- **触发**:输入框内用户键入「@」时,自动打开与 F2 相同的弹窗(默认类别)。
+- **确认行为**:选定后插入内联芯片,并吞掉触发的「@」。复用 F2 弹窗。
+
+### F4. 引用解析与 AI 注入(核心机制)
+- **提交时**:解析输入框内联芯片(会话级 + 本条消息令牌)→ `resolveTokenToText` 取全文 → 拼成「用户引用的素材」块注入 system prompt。
+- **注入点**:AI 会话 `handleSend` 三分支(编辑/章节生成/普通对话)+ AI 大纲 `handleSend`(`loadOutlineContext` 之外附加)。与现有自动检索上下文并列。
+- **跨会话引用**:历史记录类令牌解析为该会话全部对话文本注入。
+
+---
+
+## 五、非功能要求
+- 与现有系统无缝集成,界面风格统一,操作流畅。
+- 旧功能(自动检索、章节生成/续写、大纲生成、原右键菜单项)不回退。
+- 所有面向用户提示语、弹窗、确认信息使用中文。
+- token 预算控制:引用全文注入需限制数量/截断,避免超 `maxContextSize`。
+- 遵循项目分支策略:独立分支、拼音命名、分支说明文档、每分支可打包。
+
+---
+
+## 六、范围与不做项
+- **本次涉及**:F1/F2/F3/F4 × AI 会话 + AI 大纲两处面板;方案 B 富文本输入组件基座。
+- **不做**:wiki-reader 选区能力(需求粒度已定为整章标题+全文,选区列为后续可选)。
+- **不做**:未提交 git、不上传 GitHub、不改版本号。
+
+---
+
+## 七、后续动作
+- 基于本 PRD 编写「实现规划」文档(含架构、数据结构、模块设计、分支拆分、文件清单、验证方案、风险)。
+- 实现规划中芯片方案统一按 **B(富文本内联芯片,新组件基座)** 编写。
+- 执行前确认技能库数据源(`skill-library-view.tsx`)这一待定项。

+ 234 - 0
@引用与内容发送AI-实现规划.md

@@ -0,0 +1,234 @@
+# @ 引用与内容发送到 AI — 实现规划
+
+> 本规划基于 `@引用与内容发送AI-PRD.md`,芯片方案采用 **B(富文本内联芯片,新组件基座)**。
+> 覆盖功能一/二/三/F4 × AI 会话与 AI 大纲两处面板。
+> 状态:规划阶段,待执行。遵循 AGENTS.md(独立分支、拼音命名、不破坏旧功能、外科手术式改动)。
+
+---
+
+## 一、调研结论(现状,已确认)
+
+### 1.1 AI 会话(chat-panel + chat-store)
+- 数据模型:`Conversation{id,title,deAiMode,inputDraft}` 与 `DisplayMessage{id,role,content,conversationId,references?}` 分离存储,靠 `conversationId` 关联,已有会话历史列表。
+- prompt 组装入口:`handleSend(text)`(`chat-panel.tsx:387`),`onSend={handleSend}`(`:1535`)。三分支:
+  - 章节编辑:直接读章节原文拼进 prompt(`:503-560`)
+  - 章节生成:调 `buildContextPack`(context-engine,`:864`)
+  - 普通对话:**自动检索流水线**——`searchWiki`→图扩展→选页面拼进 system prompt(`:734-880`)
+- 持久化:`lib/persist.ts` 的 `saveChatHistory`/`loadChatHistory` 整会话落盘到 `.qmai/chat-history.json` + 每会话 `.qmai/chats/<id>.json`,`App.tsx:339-340` 启动恢复。
+- 关键结论:普通对话上下文是**自动检索**得到,**无**「用户主动 @ 引用」机制;`ChatInput` 是 `<textarea>` 受控组件,`onSend` 只回传纯文本,**无 @ 解析、无 insert 接口**(`chat-input.tsx:35,304-308`)。
+
+### 1.2 AI 大纲(outline-chat-panel + outline-chat-store)
+- 与 AI 会话**完全独立**:独立 store/UI/`handleSend`,不复用 chat 组件。
+- 数据模型:`OutlineChatConversation{id,title,messages[],modelId}` 内嵌消息,持久化 `<projectPath>/.qmai/outline-chats.json`,有 `loadFromDisk/saveToDisk`。
+- prompt 组装:`handleSend`(`outline-chat-panel.tsx:365`)→ `loadOutlineContext(project.path)` 读大纲文件拼 system prompt(`:390,440`)。
+- 关键结论:同样**无 @ 机制、无用户主动引用入口**。会话历史已有切换/删除。
+
+### 1.3 章节与右键菜单(knowledge-tree + wiki-reader)
+- 章节内容是磁盘 `.md`,靠 `readFile(path)`(`fs.ts:10`)读全文。
+- 右键菜单 `pageMenu`(`knowledge-tree.tsx:1279-1388`)项只有:新建/重命名/移动/打开文件位置——**无「发送到 AI」项**。
+- `wiki-reader.tsx` 纯只读渲染,**全项目无 `getSelection`/选区逻辑**。
+- **全项目无「章节→对话/大纲」跨面板内容传递机制**。
+
+### 1.4 可复用的现有逻辑(确认新基座不复用 `ChatInput` DOM,只复用纯逻辑)
+- `chat-input-resize.ts`:纯计算模块——`clampResizableInputHeight`/`getResizeBoundsForElement`/`createResizeContext`/`resolveMaxHeightFromContext`。与 DOM 解耦,新基座直接 import 复用。
+- `@/lib/keyboard-utils` 的 `isImeComposing`:IME 合成判断,新基座复用。
+- `ui/dialog.tsx`:弹窗基座,`ReferencePickerDialog` 复用。
+
+### 1.5 内容类别数据源(已确认)
+- 记忆库:`<pp>/wiki/memory/*.md`(`memory-center.ts:286`、`story-extractor.ts:231`)
+- 推演室:story-simulation,框架/推演结果存 `<pp>/.qmai/simulations/`(`framework-store.ts`)
+- 章节:`<pp>/wiki/chapters/*.md`;大纲:`<pp>/wiki/outlines/*.md`
+- AI 对话历史:`useChatStore.conversations`;AI 大纲历史:`useOutlineChatStore.conversations`
+- 技能库:需在执行分支1前以 `skill-library-view.tsx` 确认确切数据源(待确认项)
+
+---
+
+## 二、目标与成功标准
+
+**目标**:在 AI 会话与 AI 大纲两处面板建立统一的「@ 引用内容」机制,覆盖三个入口(章节右键发送、独立 @ 按钮弹窗、输入框 @ 快捷触发)。被引用内容在对话中**只显示标题**(内联芯片),AI 实际读取全文,并随会话持久化记忆,供后续提示词结合使用。
+
+**芯片实现采用方案 B**:富文本内联芯片——重做一个支持 reference 的新输入组件基座,引用令牌是不可分割原子节点;老 `ChatInput` 能复用的逻辑(resize 计算、IME 判断)抽出来复用,**不在老组件上打补丁**。
+
+**成功标准**:
+1. 章节树右键可将整章以「显示标题、AI 读全文」方式送入 AI 会话/AI 大纲。
+2. 独立 @ 按钮与输入框 @ 触发复用同一选内容弹窗,可选章节/记忆库/大纲/推演室/技能库/AI对话历史/AI大纲历史,支持多选。
+3. 选定内容以内联芯片插入输入框(不可分割原子节点),提交时解析为全文注入 AI 请求。
+4. 引用记忆随会话持久化;切换/重开项目后仍可读取历史对话内容(跨会话引用)。
+5. AI 会话与 AI 大纲两处都具备上述能力,旧功能(自动检索、章节生成、续写等)不回退;老 `ChatInput` 现有 resize/IME/autosize 行为在新基座中保持一致。
+6. 完成打包,更新日志记录,未提交 git(待用户确认后再提交)。
+
+---
+
+## 三、总体架构
+
+新增一层「用户显式引用上下文」机制,与现有「自动检索上下文」并存:
+
+```
+[内容源providers] → [ReferencePickerDialog选内容] → [ReferenceToken令牌]
+   → 富文本输入框内联为原子芯片节点 → 并存入会话.attachedReferences(持久化)
+   → 提交时序列化芯片→resolve全文 → 注入 system prompt
+```
+
+三个入口(右键/按钮/@触发)共用同一弹窗与令牌体系;AI 会话与 AI 大纲各有独立注入点,共用 `reference` lib 与富文本输入组件。
+
+---
+
+## 四、数据结构设计(新增 `src/lib/reference/types.ts`)
+
+```ts
+export type ReferenceCategory =
+  | "chapter" | "memory" | "outline" | "deduction"
+  | "skill" | "ai-chat-history" | "ai-outline-history"
+
+export interface ReferenceToken {
+  id: string              // 唯一 id
+  category: ReferenceCategory
+  title: string           // 显示标题,如「第1章-无我绝响」
+  path?: string           // 磁盘路径(章节/大纲/记忆/技能)
+  refId?: string          // 历史会话 id(历史记录类)
+  preview?: string        // 简短预览(可选,列表展示用)
+}
+```
+
+**持久化挂载点**:
+- `src/stores/chat-store.ts` → `Conversation` 增 `attachedReferences?: ReferenceToken[]`(会话级记忆);`DisplayMessage` 增 `attachedReferences?: ReferenceToken[]`(消息级)。随 `.qmai/chats/<id>.json` 自动落盘,复用 `lib/persist.ts`。
+- `src/stores/outline-chat-store.ts` → `OutlineChatConversation` 与 `OutlineChatMessage` 增 `attachedReferences?: ReferenceToken[]`。随 `.qmai/outline-chats.json` 落盘。
+- 持久化结构存的是 `ReferenceToken`(含标题/路径/refId),**不存全文**——全文提交时现取,避免落盘膨胀与内容漂移。
+
+---
+
+## 五、内容类别与数据源(providers,新增 `src/lib/reference/providers.ts`)
+
+| 类别 | 数据源 | 读取方式 |
+|---|---|---|
+| chapter | `<pp>/wiki/chapters/*.md` | 复用 `knowledge-tree` 的 `parsePageInfo` 列出 + `readFile` 取全文 |
+| memory | `<pp>/wiki/memory/*.md` | 列目录 + `readFile`(参照 `memory-center.ts:286`、`story-extractor.ts:231`) |
+| outline | `<pp>/wiki/outlines/*.md` | 列目录 + `readFile` |
+| deduction | `<pp>/.qmai/simulations/` | 复用 `framework-store` 的框架与推演结果读取 |
+| skill | 技能库 | 以 `skill-library-view.tsx` 实际数据源为准(待确认) |
+| ai-chat-history | `useChatStore.conversations` | 直接读 store,展开该会话 messages |
+| ai-outline-history | `useOutlineChatStore.conversations` | 直接读 store |
+
+**API**:`listReferenceItems(category): Promise<{title, path?, refId?, preview}[]>`(实时读取)。`resolveTokenToText(token): Promise<string>`(按 category 分派取全文;历史记录类拼接该会话所有 messages)。
+
+---
+
+## 六、核心模块设计
+
+1. `src/lib/reference/types.ts` — 类型定义(见上)。
+2. `src/lib/reference/providers.ts` — `listReferenceItems(category)` 实时列出各类别内容。
+3. `src/lib/reference/resolve.ts` — `resolveTokenToText(token)` 还原全文;`resolveTokensBatch(tokens)` 批量取文并拼装「用户引用的素材」块(含截断/预算控制)。
+4. `src/components/reference/ReferencePickerDialog.tsx` — 统一选内容弹窗:左侧类别 Tab,右侧实时列表 + 多选 + 搜索 + 确认/取消。复用 `ui/dialog.tsx`。三个入口共用。
+5. `src/components/reference/ReferenceChip.tsx` — 单个内联芯片(标题 + × 删除),作为富文本输入框的原子节点单元,也用于消息区只读展示。
+
+### 6.1 富文本输入组件基座(方案 B 核心,新增 `src/components/reference/ReferenceInput.tsx`)
+
+**设计要点**:
+- 基于 `contenteditable` 的富文本输入容器,引用令牌渲染为**不可分割的原子芯片节点**(`contentEditable="false"` 的 span 包裹 `ReferenceChip`),光标无法进入芯片内部,删就整体删。
+- 芯片节点与普通文本节点共存于一个编辑流;芯片之间/之后的普通文本即用户提示词。
+- **复用现有纯逻辑**:`chat-input-resize.ts` 的 resize 计算(`clampResizableInputHeight`/`createResizeContext`/`resolveMaxHeightFromContext`)、`@/lib/keyboard-utils` 的 `isImeComposing`。新基座自带 autosize(按 contenteditable scrollHeight)与拖拽 resize(复用 resize 计算 + 新写 DOM 适配)。
+- **关键能力**:
+  - `onChange(plainText: string, tokens: ReferenceToken[])`:序列化编辑区为纯文本(芯片处产出占位标记 `@【标题】`)+ 当前所有芯片 tokens,供上层组装。
+  - `insertTokens(tokens: ReferenceToken[])`:在光标处插入芯片节点(右键发送/按钮选择/快捷触发调用)。
+  - `onAtTrigger`:检测用户键入「@」时回调(打开弹窗)。
+  - IME 合成期不触发 @、不插入芯片;回车提交(非 Shift)。
+- **与老 `ChatInput` 的关系**:老 `ChatInput` 保留不删(AGENTS.md:不删已有函数),AI 会话/大纲切换到新 `ReferenceInput`;老组件可保留供其他用途或后续清理,本规划不动它。
+
+### 6.2 序列化与解析(新增 `src/lib/reference/tokenizer.ts`)
+- `serializeContent(tokens: ReferenceToken[], plainText: string): string`:把芯片位置在纯文本里标为 `@【标题】`,用于持久化 user 消息内容(回看可读)。
+- `extractTokensFromText(text: string, availableRefs: ReferenceToken[]): ReferenceToken[]`:从 `@【...】` 反查 availableRefs 还原 tokens(处理回退/重生成场景)。
+- 注:常态下 tokens 由 `ReferenceInput.onChange` 直接给出(无需文本解析);文本解析仅用于持久化消息回看/重生成。
+
+---
+
+## 七、三大功能落地拆解
+
+### 功能一:章节右键发送到 AI 会话/AI 大纲
+- `knowledge-tree.tsx` 的 `pageMenu`(:1279-1388)新增两项:「发送到AI会话」「发送到AI大纲」。
+- 点击 → `readFile(章节path)` 取标题 → 构造 `ReferenceToken(category:"chapter")` → 写入目标 store 当前会话 `attachedReferences` → 调目标面板 `ReferenceInput.insertTokens([token])` 插入芯片 → 自动切换激活目标面板。
+- 会话只显示章节名(芯片标题),AI 读全文。
+- 消息区(`chat-message.tsx`/`outline-chat-panel.tsx` 消息渲染)增加引用芯片只读展示:`DisplayMessage.attachedReferences` 渲染为一排 `ReferenceChip`(无 ×)。
+- 本阶段不做 wiki-reader 选区(需求粒度已定为整章标题+全文)。选区列为后续可选。
+
+### 功能二:独立 @ 按钮
+- chat 输入区(`chat-panel.tsx:1535` 附近)与 outline 输入区各加一个 @ 图标按钮。
+- 点击 → 打开 `ReferencePickerDialog` → 多选确认 → `ReferenceInput.insertTokens(tokens)` 插入内联芯片 + 写入会话 `attachedReferences`。
+
+### 功能三:@ 快捷输入触发
+- `ReferenceInput` 的 `onAtTrigger` 回调打开同一 `ReferencePickerDialog`(默认类别)。
+- 选择后 `insertTokens(tokens)` 插入芯片并吞掉触发的「@」。复用功能二弹窗。
+
+### F4. 引用解析与 AI 注入(核心机制)
+- **提交时**:`ReferenceInput.onChange` 给出 `(plainText, tokens)`;合并会话级 `attachedReferences`(跨消息持续)+ 本次 tokens → `resolveTokensBatch` 取全文 → 拼成「用户引用的素材」块注入 system prompt。
+- **注入点**:
+  - AI 会话 `handleSend`(`chat-panel.tsx:387`)三分支(编辑/章节生成/普通对话)各自 system prompt 拼装处接入引用素材块。
+  - AI 大纲 `handleSend`(`outline-chat-panel.tsx:365/440`)在 `loadOutlineContext` 之外附加引用素材块。
+- user 消息落库时携带 `attachedReferences`(消息级)。
+- **跨会话引用**:category `ai-chat-history`/`ai-outline-history` 的 token,`resolveTokenToText` 读取该历史会话全部 messages 拼为对话文本注入。大批量需截断/摘要防撑爆上下文。
+
+---
+
+## 八、分支策略(遵循 AGENTS.md)
+
+| 分支 | 拼音名 | 功能 | 依赖 |
+|---|---|---|---|
+| 1 | `yinyongjizhi` | @ 引用地基:types/providers/resolve/tokenizer + 弹窗 + 芯片 + 富文本输入基座 `ReferenceInput`(复用 resize/IME) + store 字段 + chat/outline 切换到新输入组件 + 注入骨架 | 无 |
+| 2 | `zhangjieyoujianfasong` | 章节右键「发送到AI会话/大纲」+ 会话内引用消息渲染 | 分支1 |
+| 3 | `atrukou` | 独立 @ 按钮 + 输入框 @ 快捷触发(复用分支1弹窗 + insertTokens) | 分支1 |
+
+每分支根目录建 `<拼音名>-分支说明.md`,记录使用要求与每次更新内容、是否提交。main 保持稳定可打包;每分支完成后跑源码+旧功能回归测试→打包→记录更新日志→再考虑合并。
+
+---
+
+## 九、文件改动清单
+
+**新增**:
+- `src/lib/reference/types.ts`、`providers.ts`、`resolve.ts`、`tokenizer.ts`
+- `src/components/reference/ReferencePickerDialog.tsx`、`ReferenceChip.tsx`、`ReferenceInput.tsx`
+- `yinyongjizhi-分支说明.md`、`zhangjieyoujianfasong-分支说明.md`、`atrukou-分支说明.md`
+- 对应 spec 测试(`*.spec.tsx`,遵循项目既有测试风格;重点测 `ReferenceInput` 的序列化/插入/IME/删除原子性、tokenizer 解析、resolve 全文还原)
+
+**修改**:
+- `src/stores/chat-store.ts` — `Conversation`/`DisplayMessage` 增 `attachedReferences`
+- `src/stores/outline-chat-store.ts` — `OutlineChatConversation`/`OutlineChatMessage` 增 `attachedReferences` + action
+- `src/components/chat/chat-panel.tsx` — handleSend 注入引用素材块;输入区改用 `ReferenceInput`;消息渲染引用芯片;@ 按钮
+- `src/components/chat/chat-message.tsx` — 引用芯片只读展示
+- `src/components/sources/outline-chat-panel.tsx` — handleSend 注入;输入区改用 `ReferenceInput`;@ 按钮;@ 触发;消息渲染
+- `src/components/layout/knowledge-tree.tsx` — pageMenu 增「发送到AI会话」「发送到AI大纲」两项
+- i18n(`src/i18n/zh.json` 等)— 新增中文文案
+
+**不改**:`src/components/chat/chat-input.tsx`(老组件保留,不删不改)。
+
+---
+
+## 十、验证方案
+1. 启动源码(`pnpm tauri dev` 或项目命令)。
+2. 旧功能回归:AI 会话普通问答、章节生成/续写、章节编辑模式、AI 大纲生成、章节树右键原菜单项、老输入组件的 resize/IME 行为在新基座一致。
+3. 新功能:
+   - 右键发送→会话内联芯片显示标题而非全文→AI 回复体现读到全文。
+   - @ 按钮弹窗列出各类别实时内容;多选→内联芯片插入。
+   - @ 快捷触发;中文输入法合成期不误触发。
+   - 芯片原子性:光标进不去、删就整体删、复制粘贴不损坏。
+   - 切换/重开项目引用仍在;跨会话引用历史对话内容能被 AI 读到。
+   - token 预算:引用过多/全文过大时截断不报错。
+4. `pnpm typecheck` + `pnpm test`(含新增 spec)+ `pnpm lint`。
+5. 打包(沿用项目命令,版本号不变),更新 `GenxinLOG/更新日志.md`。
+
+---
+
+## 十一、风险与限制
+- **富文本输入回归面大**:`ReferenceInput` 是新基座,IME/光标/选区/粘贴/autosize 需充分测试,是本规划最大风险点。缓解:分1内独立建组件并写足 spec,先单测再接入面板;接入后面对面回归输入交互。
+- **contenteditable 跨 WebView 差异**:Tauri WebView 的 contenteditable 行为需实测(光标定位、粘贴去格式)。缓解:限定关键路径行为,异常有兜底。
+- **技能库数据源**:需在执行分支1前从 `skill-library-view.tsx` 确认确切技能列表来源。
+- **token 预算**:引用全文注入增加 prompt 长度,需限制单次引用数量/全文截断,避免超 `maxContextSize`(复用 `computeContextBudget` 思路)。
+- **历史会话全文**很大时:跨会话引用需截断或摘要,防止撑爆上下文。
+- 三分支顺序执行,分支2/3依赖分支1地基稳定。
+- 老 `ChatInput` 保留不删,可能产生短暂双组件并存;分支1接入完成后老组件无引用,后续可按 AGENTS.md 经用户同意后清理。
+
+---
+
+## 十二、本次不涉及
+- 未提交 git(待用户明确要求)。
+- 不上传 GitHub、不改版本号(沿用当前 2.2.x)。
+- wiki-reader 选区能力列为后续可选,本规划不含。
+- 不删除、不改动老 `chat-input.tsx`。

+ 823 - 0
AI-Agent架构-PRD.md

@@ -0,0 +1,823 @@
+# AI Agent 工具架构 PRD(检索·分类·灵魂)
+
+> 版本:v1.1
+> 日期:2026-07-01
+> 状态:评审决策已完成,待按分支实施
+> 关联文档:`AI-Agent架构-问答记录.md`、`soul.md`、`AGENTS.md`
+
+## 变更摘要(v1.0 → v1.1)
+
+- 修订 4 处对现状代码描述的偏差(见 §1.2 与 §7.3 修订)。
+- 按评审决策固化 7 项架构选型(见 §11 评审决策记录)。
+- 补充 14 项缺失设计(见 §12 v1.1 补充章节):retrieval 分卷、迁移路径、记忆体系边界、流式 UX、上下文追踪与工具调用 UI 合一、ChapterSnapshot 投影关系、剧情推演室分支隔离、默认路由表维护、目标章节解析、多灵魂边界、测试基础设施、token 预算、跟随 AI 模型耦合、错误恢复可感知。
+- 执行模型从"管道前置 + Agent 兜底"修订为"Agent 统一基底 + 管道前置插件"(见 §3 修订与 §11 决策 1)。
+- 功能范围优先级不变,但落地方式按 P0 任务拆分独立分支(见 §13 实施路线图)。
+
+---
+
+## 一、背景与目标
+
+### 1.1 现状
+
+QMaiWrite 当前已有一套近似架构在运行,但分散在多个模块、概念未显式化:
+
+- 意图识别:`src/lib/novel/task-router.ts` 的 `routeTask`(正则打分,14 种 `NovelTaskIntent`)
+- 上下文组装:`src/lib/novel/context-engine.ts` 的 `buildContextPack` + `contextPackToPrompt`(19 个数据源并行加载 → 合并 → token 裁剪)
+- 灵魂约束:`soul.md` + `src/lib/novel/soul-doc.ts`(每次小说模式请求加载,priority 3)
+- 快照/记忆:`src/lib/novel/chapter-ingest.ts` 的 `ChapterSnapshot`
+- 执行:`src/lib/agent/runner.ts` 的 `AgentRunner`(多轮工具调用循环)
+
+### 1.2 问题
+
+1. **检索/分类/灵魂这三个概念只存在于代码运行时,用户不可见、不可编辑、不可手动维护。**
+2. 19 个数据源全量并行加载,未按意图精准路由,无关上下文污染生成。
+3. 修改类意图(改写/润色)与生成类意图走同一条 `AgentRunner` 流,无确认环节,误判即直接落盘。
+4. 意图置信度仅被写入 `buildTaskDirective` 系统提示(`task-router.ts:394`),**未用于执行路径分支拦截**,低置信度意图仍默认走普通生成流,容易跑偏。
+5. 大纲生成路径已通过 `safeBuildOutlineContextPack`(`outline-generation.ts:228`)调用 `buildContextPack` 并使用 `pack.soulDoc`(`outline-generation.ts:148`),但 `generateOutlineFile`(`outline-generation.ts:413-446`)等二级入口尚未统一接入,仍存在漏载 soul.md 的子路径风险。
+6. `contextPackToPrompt`(`context-engine.ts:1071-1073`)超预算时按 **40% 头 : 60% 尾(即 2:3)** 头尾切字符串,会把伏笔/设定关键句从中间劈开,伤一致性。(v1.0 文档原写"4:1"为实测偏差,已更正。)
+7. 生成返回内容靠 prompt 软约束,模型常带"以下是为你生成的章节……"等废话。
+
+### 1.3 目标
+
+把现有内部机制提升为**三个独立的、可命名、可维护、用户可编辑的产物文件**,并建立完整的「意图识别 → 检索路由 → 上下文删减 → 约束生成 → 结果收敛」管道,同时为复杂/盲区场景保留 agent 自主探索能力。
+
+---
+
+## 二、核心设计
+
+### 2.1 三个磁盘文件(用户可读可编辑)
+
+| 文件 | 位置 | 作用 | 性质 |
+|------|------|------|------|
+| `retrieval.md` | 项目根 | 章节检索索引:每章 300 字梗概 + 结构化字段 | 预生成 + 增量更新 |
+| `classification.md` | 项目根 | 意图→数据源路由表(必载/选载/禁载) | 静态配置,随意图类型维护 |
+| `soul.md` | 项目根(已有) | 生成内容总约束(作品级气质/叙事/语言/禁区) | 人定性约束 |
+
+> ⚠️ 角色灵魂(`characterAuras`)和风格规则(去AI味 skill)**不并入 `soul.md`**,仍单独成文件。角色数量随故事增长,合并会撑爆上下文。`soul.md` 只放作品级总则。
+
+### 2.2 检索文件 retrieval.md 规格
+
+**结构**:每章一个条目,分为系统自动区和人工补充区:
+
+```
+## 第N章 - <章节中文标题>
+<!-- qmai:auto:start -->
+- 摘要:<300 字故事梗概,描述整章发展>
+- 人物状态变化:<A 现在的身份/位置/情绪;B 的...>
+- 伏笔变化:<新埋伏笔X;回收伏笔Y;伏笔Z状态更新>
+- 时间线事件:<时间点1-事件;时间点2-事件>
+- 结尾钩子:<本章结尾留下的悬念/压力>
+- 文件路径:<指向该章 wiki markdown 的相对路径>
+- 正文修改时间戳:<ISO 时间,辅助校验用>
+- 正文哈希:<sourceHash,用于判断正文是否变化>
+- 索引状态:<有效 / 可能过期 / 与正文冲突>
+<!-- qmai:auto:end -->
+
+<!-- qmai:manual:start -->
+- 人工备注:<用户手动补充的隐藏设定、写作提醒、伏笔意图>
+- 后续提醒:<用户不希望自动摘要覆盖的创作判断>
+<!-- qmai:manual:end -->
+```
+
+**要点**:
+- 300 字摘要只作主索引(故事梗概),**不替代结构化字段**。纯散文摘要覆盖不了"该不该回收伏笔"这类硬约束。
+- 结构化字段是生成准确性的关键,`ChapterSnapshot` 已有这些字段(`chapter-ingest.ts`),直接复用。
+- 自动区由系统生成和覆盖;人工补充区只由用户维护,系统增量更新时**不得覆盖**。
+- 文件路径指向该章实际的 wiki markdown 文件,AI 需要完整原文时按路径二次读取。
+- 过期判断以 `sourceHash` 为主、正文修改时间戳为辅。时间戳可能被系统或同步工具影响,不能作为唯一依据。
+
+### 2.3 分类文件 classification.md 规格
+
+**不是静态分类目录,而是意图路由表**:
+
+```
+## 意图路由表
+
+### write_chapter / continue_chapter(续写下一章)
+- 必载:大纲、最近剧情摘要、上一章结尾、当前人物状态、当前伏笔状态、项目灵魂
+- 选载:时间线、角色认知状态、角色灵魂、相关设定
+- 禁载:生成大纲意图的大纲原文全量
+
+### generate_outline(生成大纲)
+- 必载:项目灵魂、世界观设定、卷宗划分
+- 选载:已有章节摘要
+- 禁载:章节正文、章节快照细节
+
+### modify_content(修改内容)
+- 必载:目标章节正文、项目灵魂、修改反馈
+- 选载:该章前后文摘要、相关人物状态
+- 禁载:无关章节正文
+
+### review_chapter / lint_chapter(审稿/去AI味)
+- 必载:目标章节正文、项目灵魂、去AI味 skill
+- 选载:写作风格规则
+- 禁载:无关章节细节
+
+### general_chat(普通对话)
+- 必载:项目灵魂
+- 选载:按关键词检索结果
+- 禁载:全量快照
+```
+
+**文件头要求**:
+```
+---
+classificationVersion: 1
+intents:
+  - write_chapter
+  - continue_chapter
+  - rewrite_chapter
+  - polish_chapter
+  - review_chapter
+  - lint_chapter
+  - generate_outline
+  - search_plot
+  - extract_memory
+  - character_query
+  - foreshadowing_query
+  - timeline_query
+  - setting_query
+  - general_chat
+---
+```
+
+**要点**:
+- 静态分类目录对生成准确性没帮助,按意图精准装载才能避免无关上下文污染。
+- 读取失败、版本缺失、意图缺失或用户误改格式时,系统必须回退到内置默认路由,不能让 AI 会话完全失效。
+- 回退时在会话或内部追踪中提示:`classification.md 读取失败,已使用默认路由。`
+
+### 2.4 灵魂文件 soul.md(已有,保持现状 + 路径校验)
+
+- 保持作品级总则定位,现有内容不改动。
+- `soul.md` 是**每个小说项目目录下的运行期文件**(非代码仓库根文件),由 `readSoulDoc(projectPath)`(`soul-doc.ts`)从 `${projectPath}/soul.md` 读取。
+- 大纲生成、章节生成、修改内容、审稿路径都必须能读取 `soul.md`。当前 `safeBuildOutlineContextPack`(`outline-generation.ts:228`)已接入 `buildContextPack` 与 `pack.soulDoc`,但 `generateOutlineFile` 等二级生成入口需统一接入,作为回归验证项。
+
+---
+
+## 三、AI 会话流程(Agent 统一基底 + 管道前置插件)
+
+> v1.1 修订:执行模型从"管道前置 + Agent 兜底"调整为"Agent 统一基底 + 管道前置插件"。`AgentRunner` 作为唯一执行引擎,管道(意图识别 → 检索路由 → 上下文删减 → 结果收敛)作为 Agent 入口前的上下文收敛阶段挂载。所有执行路径统一走 Agent 工具调用 UI(决策 1 与决策 4),不再有"管道优先 / Agent 降级"二选一。
+
+### 3.1 主流程
+
+```
+用户发送请求
+   ↓
+① 提取请求关键词(tokenizeQuery,已有)
+   ↓
+② 识别用户意图(routeTask,已有)
+   ↓
+③ 判断置信度 ── 低于阈值 → 反向问用户澄清(不硬猜)
+   ↓ 置信度达标
+④ 查 classification.md 路由表,确定必载/选载/禁载数据源
+   ↓
+⑤ 按④的范围读检索文件,分析哪些章节/条目包含用户意图
+   ↓
+⑥ 读取命中条目(章节内容/大纲/记忆库/图谱等)
+   ↓
+⑦ 意图匹配校验:当前意图是生成章节/大纲/修改内容?
+   ├─ 不匹配内容删减(先删低优先级整段,再删段内细节)
+   └─ 留下有用信息
+   ↓
+⑧ 结合用户要用的 skill + SOUL.md 约束,生成内容
+   ↓
+⑨ 结果强约束收敛(见 3.3)
+   ↓
+⑩ 返回用户
+```
+
+### 3.1.1 上下文追踪记录(与工具调用 UI 合一)
+
+> v1.1 修订(决策 4):上下文追踪记录与工具调用 UI 共享同一数据模型,**合二为一**。项目记忆已要求"工具调用过程需在UI中完整展示,包括调用内容和状态",本节定义的字段既是内部追踪记录,也是工具调用 UI 的渲染数据源,不再分两份。
+
+每次 AI 会话在小说模式下都应生成一份追踪记录,作为工具调用 UI 的数据源,可折叠查看,不混入最终正文:
+
+| 字段 | 说明 |
+|------|------|
+| `intent` | 本次识别出的意图 |
+| `confidence` | 意图置信度 |
+| `routeSource` | 使用 `classification.md` 还是内置默认路由(含两层路由合并结果,见决策 6) |
+| `loadedSources` | 实际加载的数据源 |
+| `blockedSources` | 因意图路由被禁载的数据源 |
+| `retrievalHits` | 命中的章节索引、记忆、图谱或大纲条目 |
+| `trimmedSections` | 因预算或意图不匹配被删减的上下文段 |
+| `contextBudget` | 本次 token 预算与实际占用(见 §12.12) |
+| `toolCalls` | Agent 执行过程中各工具调用明细(名称、入参、结果、耗时、状态) |
+| `resultProtocol` | 结果解析器校验意图输出协议的过程(见 §3.3) |
+| `fallbackReason` | 路由回退、解析重试、人工区冲突等触发原因 |
+
+该记录用于定位问题:生成错误时可以判断是意图识别错、分类路由错、检索命中错、上下文裁剪错、token 预算超限错、还是模型输出没有收敛。工具调用 UI 直接消费该记录,按时间线卡片展示。
+
+### 3.2 Agent 自主探索(统一执行基底下的盲区拓展)
+
+> v1.1 修订:原"Agent 兜底降级"语义取消,Agent 始终是统执行基底。下列场景不再"降级到 Agent",而是 Agent 在管道前置收敛后**额外自主调用工具**拓展探索。
+
+| 触发条件 | Agent 行为 |
+|---------|---------|
+| 意图置信度低于阈值 | 反向问用户澄清(见 §3.1 主流程③),不进入 Agent 自主探索 |
+| 前置检索未命中关键信息 | Agent 自主调用 `read-chapter` / `search-chapters` / `search-memory` 等工具补全盲区 |
+| 用户请求跨多意图 | Agent 拆解为多步工具调用链,每步独立走结果解析器 |
+| 用户请求需要外部信息 | Agent 调用注册的扩展工具(项目记忆中的"Agent工具框架需设计为通用框架,支持工具注册") |
+
+**约束**:Agent 自主调用工具的过程在工具调用 UI 中完整展示(决策 4),调用结果仍受结果解析器校验,未通过协议校验不得入库(见 §3.3 与 §5.4)。
+
+### 3.3 结果强约束收敛
+
+| 意图 | 返回内容 |
+|------|---------|
+| write_chapter / continue_chapter | 仅:章节标题 + 章节正文 |
+| generate_outline | 仅:大纲标题 + 大纲正文 |
+| modify_content | diff 预览 + 弹窗确认(见 3.4),确认后落盘 |
+| review_chapter / lint_chapter | 审查报告 + 修订建议 |
+
+**强约束实现**:不靠 prompt 软提示,改为生成后结构化解析:
+1. 解析 frontmatter + 正文结构。
+2. 解析失败 → 剥头尾非正文段落,重试。
+3. 仍失败 → 标记并提示用户,不直接落盘。
+
+**按意图输出协议**:
+
+```ts
+type ChapterGenerationResult = {
+  type: "chapter"
+  title: string
+  body: string
+}
+
+type OutlineGenerationResult = {
+  type: "outline"
+  title: string
+  body: string
+}
+
+type ModifyContentResult = {
+  type: "modify"
+  targetFile: string
+  originalContent: string
+  proposedContent: string
+  diff: string
+}
+
+type ReviewResult = {
+  type: "review"
+  issues: Array<{ severity: "高" | "中" | "低"; message: string }>
+  suggestions: string[]
+}
+```
+
+解析器不只负责剥掉"以下是..."这类废话,还要判断结果是否符合当前意图的协议。不符合协议时,不允许直接进入保存、入库或记忆更新流程。
+
+### 3.4 修改内容弹窗确认(codex 式)
+
+`modify_content` 意图族执行流程:
+
+```
+识别为修改意图
+   ↓
+生成 diff 预览(不落盘)
+   ↓
+弹窗展示:原内容 / 修改后内容 / diff 高亮
+   ↓
+用户选择:确认 / 编辑后确认 / 取消
+   ├─ 确认 → 落盘
+   ├─ 编辑后确认 → 套用用户编辑后落盘
+   └─ 取消 → 不落盘,返回会话
+```
+
+**新增意图族**:`modify_content` 是父级安全闸门,不完全替代现有具体意图。`rewrite_chapter`、`polish_chapter`、扩写、删减、纠错等仍保留为子意图或参数,用于决定上下文范围、提示词和弹窗文案;但只要属于修改落盘类任务,都必须先经过 `modify_content` 确认闸门。
+
+---
+
+## 四、上下文删减策略(两级裁剪)
+
+### 4.1 当前问题
+
+`contextPackToPrompt`(`context-engine.ts:1025`)超预算时按 4:1 头尾切字符串,会把伏笔关键句或人物状态段从中间切开。
+
+### 4.2 新策略
+
+**第一级:按 SECTION_PRIORITY 删整段**
+- 优先级低的整段先删(如"写作风格" priority 17 先于"当前伏笔状态" priority 10)。
+- 必载段(大纲/灵魂/当前任务)受保护,不删。
+
+**第二级:段内删细节**
+- 整段都删掉还不够时,高优先级段内按"摘要头 + 关键结构化字段 + 结尾钩子"保留,删中间冗余。
+
+**不再用头尾切字符串。**
+
+### 4.3 派生字段(保留现状)
+
+以下派生字段继续由 `buildContextPack` 生成:
+- `chapterGoal`:从大纲提取本章目标
+- `mustDo`:章节目标 + 前章结尾 + 待回收伏笔
+- `mustAvoid`:正史 + 时间线 + 人设约束
+- `nextChapterAdvice`:下一章推进建议
+
+> 若 SOUL.md 或用户指令要求返回"下一章建议",则生成结果末尾附该字段;否则不附。**按意图严格收敛**——生成章节只返回标题+正文,不留下一章建议在正文里。
+
+---
+
+## 五、数据一致性(同步闸门)
+
+### 5.1 retrieval.md 同步
+
+| 触发事件 | 动作 |
+|---------|------|
+| 章节 ingestion(`chapter-ingest.ts`)| 重新生成该章自动区,增量更新 retrieval.md,保留人工补充区 |
+| 用户手动编辑章节正文并保存 | 校验 `sourceHash` 和正文修改时间戳,标记 retrieval.md 该条目过期,提示或自动重新生成 |
+| 用户删除章节 | 从 retrieval.md 移除该条目 |
+| 启动时加载 | 校验所有条目 `sourceHash`,过期条目标记 `可能过期` 或静默重生 |
+| 用户手动编辑 retrieval.md 人工区 | 永久保留,不被自动更新覆盖 |
+
+### 5.2 classification.md 同步
+
+- 相对稳定,随 `NovelTaskIntent` 枚举变更手动维护。
+- 文件头注明版本号和对应枚举值,代码读取时校验版本。
+
+### 5.3 soul.md 同步
+
+- 项目级人定性文件,不自动更新内容。
+- 加载时校验非空,空则提示用户先写灵魂。
+
+### 5.4 生成章节到记忆入库生命周期
+
+生成内容必须按以下生命周期推进,避免草稿、废稿或未确认修改提前进入正史记忆:
+
+```
+AI 生成章节
+  → 结果解析器校验输出协议
+  → 用户确认保存或执行明确保存动作
+  → 写入章节文件
+  → 章节 ingestion
+  → 生成 ChapterSnapshot
+  → 更新 retrieval.md 自动区
+  → 更新记忆库 / 伏笔 / 时间线 / 图谱
+```
+
+未确认的 AI 回复只属于会话草稿,不得进入 `retrieval.md`、记忆库、伏笔追踪、时间线或图谱。
+
+### 5.5 冲突检测
+
+当章节正文、`retrieval.md`、记忆库、伏笔追踪或时间线之间出现不一致时,系统不得静默混用冲突内容。
+
+| 冲突类型 | 处理方式 |
+|---------|---------|
+| 章节正文 hash 与 retrieval.md 不一致 | 标记该章节索引过期,优先以章节正文为准 |
+| retrieval.md 与记忆库字段冲突 | 提示用户重新摄取该章节或重建派生记忆 |
+| 用户人工区与自动区冲突 | 保留人工区,不自动覆盖;在上下文追踪中标记冲突 |
+| classification.md 禁载了必需数据源 | 使用内置默认路由补齐必需项,并记录回退原因 |
+
+---
+
+## 六、功能范围与优先级
+
+### P0(必做,一致性命根)
+
+| # | 任务 | 影响文件 |
+|---|------|---------|
+| 1 | retrieval.md 生成与增量更新机制(自动区/人工区/sourceHash) | `chapter-ingest.ts`、新增 `retrieval-index.ts` |
+| 2 | classification.md 路由表读取与意图→数据源映射,失败时回退默认路由 | `context-engine.ts`、新增 `classification-router.ts` |
+| 3 | 意图置信度阈值 + 低置信反向问用户 | `task-router.ts`、`chat-panel.tsx` |
+| 4 | modify_content 父级安全闸门 + 修改内容弹窗确认(codex 式) | `chat-panel.tsx`、新增 `modify-confirm-dialog.tsx` |
+| 5 | 大纲生成路径验证 soul.md 全程加载 | `outline-generation.ts` |
+| 6 | 两级裁剪替换头尾切 | `context-engine.ts`(`contextPackToPrompt`) |
+| 7 | 生成章节到保存、摄取、检索索引、记忆入库的生命周期约束 | `chat-panel.tsx`、`chapter-ingest.ts` |
+
+### P1(必做,质量护栏)
+
+| # | 任务 | 影响文件 |
+|---|------|---------|
+| 8 | 结果强约束收敛(结构化解析 + 剥头尾重试 + 意图输出协议) | 新增 `result-parser.ts`,`runner.ts` / `deep-chapter-generation.ts` 调用 |
+| 9 | 上下文追踪记录(意图、路由、命中、裁剪、兜底原因) | 新增 `context-trace.ts`、`chat-panel.tsx` |
+| 10 | 冲突检测(正文/retrieval/记忆库/人工区) | `retrieval-index.ts`、`chapter-ingest.ts` |
+| 11 | Agent 兜底降级判定(置信度/未命中/跨意图) | `chat-panel.tsx` 路由分发处 |
+
+### P2(增强)
+
+| # | 任务 | 影响文件 |
+|---|------|---------|
+| 12 | retrieval.md 过期标记 UI | `chat-panel.tsx` / 工具栏 |
+| 13 | classification.md 版本校验与升级提示 | `classification-router.ts` |
+| 14 | 上下文追踪记录可折叠查看 UI | `chat-panel.tsx` |
+
+---
+
+## 七、清单与变更点
+
+### 7.1 新增文件
+
+| 文件 | 作用 |
+|------|------|
+| `${projectPath}/retrieval.md` | 章节检索索引(用户可编辑) |
+| `${projectPath}/classification.md` | 意图→数据源路由表(用户可编辑) |
+| `src/lib/novel/retrieval-index.ts` | retrieval.md 读写 + 增量更新 |
+| `src/lib/novel/classification-router.ts` | classification.md 读取 + 意图映射 |
+| `src/lib/novel/result-parser.ts` | 生成结果结构化解析 + 剥头尾 |
+| `src/lib/novel/context-trace.ts` | AI 会话上下文追踪记录类型、生成与格式化 |
+| `src/components/chat/modify-confirm-dialog.tsx` | 修改确认弹窗 |
+
+### 7.2 修改文件
+
+| 文件 | 改动点 |
+|------|--------|
+| `src/lib/novel/context-engine.ts` | 按意图路由装载(替全量并行);`contextPackToPrompt` 两级裁剪 |
+| `src/lib/novel/task-router.ts` | 新增 `modify_content` 父级意图标记;置信度阈值出口 |
+| `src/lib/novel/outline-generation.ts` | 验证大纲生成前加载 soul.md |
+| `src/components/chat/chat-panel.tsx` | 路由分发加 modify 确认闸门 + agent 降级判定 + 上下文追踪 |
+| `src/lib/novel/chapter-ingest.ts` | ingestion 完成后触发 retrieval.md 增量更新,保留人工区并写入 sourceHash |
+
+### 7.3 不改动
+
+- `soul.md` 内容保持现状。
+- `ChapterSnapshot` 数据结构复用,不改动。
+- `AgentRunner` 工具调用体系保留,并升级为统一执行基底(见 §11 决策 1 与 §3 修订)。
+- 19 个数据源 (`context-data-sources.ts`) 保留,按分类路由按需调用而非全量。
+- 去 AI 味 skill 体系不重构:`de-ai-skill-library.ts` 已实现完整 CRUD(5 内置 + 项目级自定义 + 备份恢复 + 旧版迁移),不再回退到 raw import 模式;`de-ai-adapter.ts` 的场景化智能选择(web-novel/popular-science/commentary/translation/default)保持现状,仅按意图路由决定是否加载 skill 内容。
+
+---
+
+## 八、成功标准
+
+1. 用户可在项目根看到并手动编辑 `retrieval.md`、`classification.md`、`soul.md` 三个文件。
+2. 续写/大纲/审稿等简单意图走线性管道,每次生成上下文可预测、可复现。
+3. 修改类意图必须弹窗确认后才落盘,取消则不落盘。
+4. 底置信度意图反向问用户,不硬猜跑偏。
+5. 大纲生成带 soul.md,与正文气质一致。
+6. 超预算裁剪不再切字符串,按两级策略保留伏笔/设定关键句。
+7. 生成结果按意图严格收敛,不混入分析文本或废话。
+8. 复杂/盲区场景可降级 agent 自主探索。
+9. 未确认保存的 AI 草稿不会进入 retrieval.md、记忆库、伏笔追踪、时间线或图谱。
+10. `retrieval.md` 自动更新时不会覆盖用户人工补充区。
+11. 每次小说模式 AI 会话都能追踪意图、路由、加载源、命中内容和裁剪内容。
+12. 不破坏现有续写、深度生成、大纲生成、去AI味等功能(旧功能不回退)。
+
+---
+
+## 九、测试与验证用例
+
+1. **章节生成收敛**:输入“生成下一章”,最终结果只包含章节标题和正文,不包含分析说明、客套话或下一章建议。
+2. **低置信度反问**:输入“这段再处理一下”,若无法确定是润色、改写还是续写,系统先反问用户,不直接生成。
+3. **修改不落盘**:输入“润色当前章节”,系统生成 diff 预览并弹窗确认;用户取消后,章节文件不变。
+4. **索引过期检测**:手动修改章节正文后,`retrieval.md` 对应条目通过 `sourceHash` 被标记为过期。
+5. **人工区不覆盖**:用户在 `retrieval.md` 人工补充区写入隐藏设定,重新 ingestion 后人工区内容仍保留。
+6. **分类兜底**:破坏 `classification.md` 格式后,AI 会话使用内置默认路由,并记录回退原因。
+7. **冲突提示**:章节正文与检索索引不一致时,系统优先以正文为准,并提示重新生成索引或重建派生记忆。
+8. **上下文追踪**:一次生成完成后可查看本次意图、置信度、加载源、禁载源、检索命中、裁剪段和 Agent 兜底原因。
+
+---
+
+## 十、风险与限制
+
+1. **retrieval.md 一致性是最大风险点**:依赖变更触发增量更新,若触发遗漏则索引过期。缓解:启动时全量校验 `sourceHash` + 生成时标记过期。
+2. **Agent 自主探索仍面临风格漂移**:多轮工具调用可能让生成节奏跑偏。缓解:Agent 仍受 soul.md 约束 + 结果强约束收敛 + 工具调用 UI 透传。
+3. **classification.md 维护成本**:新增意图需同步更新路由表。缓解:版本校验 + 升级提示 + 默认路由表与模板同源(决策 5)。
+4. **弹窗确认增加交互步骤**:修改流变重。缓解:仅 `modify_content` 意图族弹窗,生成类不弹。
+5. **人工补充区可能和自动区冲突**:用户人工判断可能与系统摘要不一致。缓解:不覆盖人工区,但在上下文追踪中标记冲突。
+6. **retrieval 分卷后跨卷检索复杂度**:决策 2 引入分卷后,跨卷伏笔查询需要合并多卷结果。缓解:主索引 `retrieval/index.md` 维护全局伏笔/人物/时间线倒排指针。
+7. **两层路由表合并冲突**:决策 6 引入项目级 + 分支级两层路由表,分支补丁可能与项目路由矛盾。缓解:合并时分支级只允许"追加意图"和"收窄禁载",不允许"放宽禁载",冲突时优先项目级。
+8. **token 预算与多灵魂边界叠加**:决策要求作品级 soul.md + 角色灵魂 + 自定义灵魂 + 去 AI 味 skill 共存,长篇作品可能超预算。缓解:按 §12.12 token 预算分配表倒序裁剪。
+
+---
+
+## 十一、评审决策记录(v1.1 固化)
+
+> 本节为评审阶段对 7 个争议点的固化决策,作为后续实现的不可变约束。决策原则:以软件最大化效果为优先,不考虑 token 消耗与最小化修改。
+
+### 决策 1:执行基底 = Agent 统一基底 + 管道前置插件
+
+- `AgentRunner`([src/lib/agent/runner.ts](file:///c:/QMAI_C/QMAI-main/src/lib/agent/runner.ts))作为唯一执行引擎,所有小说模式 AI 会话统一走 Agent 工具调用循环。
+- 管道(意图识别 → 检索路由 → 上下文删减 → 结果收敛)作为 Agent 入口前的上下文收敛阶段挂载,不作为平行的"另一条流"。
+- 工具调用 UI 统一展示所有执行路径,包括管道前置阶段的"虚拟工具调用"(如 `route-task`、`load-context`、`trim-context`)。
+- 取消 v1.0 中"管道优先 + Agent 兜底降级"语义,避免二选一的执行模型分裂。
+
+**当前分支落地度(2026-07-01 核查)**:决策 1 共 5 个核心点,1 个已实现(有缺口),3 个部分实现,1 个未实现。详细落地度对照与 M1 阶段补齐清单见 §14。当前 `gongjudiaoyongyouhua` 分支的 Agent 基底(runner/types/registry/events/result/UI 装配链路)已扎实可用,M1 阶段主要工作是补齐管道前置插件化、虚拟工具、UI 状态、降级路径这 4 个缺口。
+
+### 决策 2:retrieval.md 形态 = 主索引文件 + 分卷详情文件
+
+- `${projectPath}/retrieval/index.md`:主索引,仅存全局章节清单、伏笔倒排、人物倒排、时间线倒排、各章定位指针。
+- `${projectPath}/retrieval/vol-{N}.md`:按卷宗或每 50 章一段分卷,存该卷章节的自动区 + 人工补充区完整条目。
+- 分卷阈值:单卷超过 50 章或单文件超过 80KB 时自动拆分。
+- 启动加载时只读主索引,按需懒加载分卷。
+
+### 决策 3:retrieval.md 自动区 = ChapterSnapshot 的只读投影
+
+- `ChapterSnapshot`(`chapter-ingest.ts:65-100`)是数据真源,retrieval.md 自动区是它的 markdown 序列化视图。
+- 写入路径单向:ingestion → 生成 ChapterSnapshot → 投影到 retrieval.md 自动区。**禁止反向写**。
+- 用户在 retrieval.md 自动区的手改会在下次 ingestion 时被覆盖(自动区本来就是只读视图);用户的创作判断必须写在人工补充区。
+- 这避免了 v1.0 隐含的双写不一致风险。
+
+### 决策 4:上下文追踪 = 工具调用 UI 数据源(合二为一)
+
+- 上下文追踪记录(§3.1.1)与工具调用 UI 共享同一数据模型。
+- 项目记忆要求"工具调用过程需在UI中完整展示,包括调用内容和状态"——本字段集就是该 UI 的数据源。
+- 不再分"内部追踪记录(不开源)"和"工具调用 UI 数据(开源)"两份。
+- UI 形态:时间线卡片,每张卡展示一次工具调用(含虚拟工具与真实工具),支持折叠。
+
+### 决策 5:默认路由表位置 = classification.md 模板 + 代码读取同一份
+
+- `src/lib/novel/classification-router.ts` 内置的默认路由表常量,与 `${projectPath}/classification.md` 初始模板内容**完全一致同源**。
+- 模板生成命令 `npx qmai init-classification` 从代码常量直接导出,避免代码与模板漂移。
+- classification.md 文件损坏或读取失败时,直接从代码常量回退,并提示用户"已使用内置默认路由,可执行 init-classification 重建文件"。
+- v1.0 中"代码硬编码默认值 + 模板另一份"的双源问题被根除。
+
+### 决策 6:classification.md 与剧情推演室分支 = 项目级 + 分支级两层路由表
+
+- `${projectPath}/classification.md`:项目级路由,对应 main 分支 14 意图。所有项目必有此文件。
+- `${projectPath}/classification.{feature}.md`:分支级路由补丁,命名按分支特征,如 `classification.story-sim.md`。
+- 加载时合并语义:分支级只允许"追加意图"和"收窄禁载",不允许"放宽项目级禁载"。冲突时项目级优先。
+- main 分支代码读取时若分支级文件不存在则静默跳过,不报错。
+- 剧情推演室分支独立维护 `classification.story-sim.md`,不合并 main 分支(符合"剧情推演室绝对不合并 main"的项目记忆约束)。
+
+### 决策 7:retrieval.md 人工区编辑入口 = 软件内专用编辑面板
+
+- 不依赖外部编辑器手动改 markdown,提供软件内专用面板。
+- 面板按章节卡片展示,每张卡分"自动区(只读折叠)"和"人工补充区(可编辑)"。
+- 前端校验 `<!-- qmai:manual:start -->` / `<!-- qmai:manual:end -->` 标签结构,格式错误时禁止保存并提示用户。
+- 编辑后原子写入对应分卷文件,不触碰自动区。
+- 项目记忆要求"生成章节时默认总是输出标题"等规则在面板中也可视化提示。
+
+---
+
+## 十二、v1.1 补充章节(14 项缺失设计)
+
+### §12.1 retrieval.md 体量与性能策略
+
+- **加载策略**:会话启动只读 `retrieval/index.md` 主索引;按意图路由命中后懒加载对应分卷。
+- **检索方案**:主索引维护三类倒排指针——伏笔倒排(伏笔名 → 出现章节列表)、人物倒排(人物名 → 状态变化章节列表)、时间线倒排(时间点 → 章节列表)。命中后按指针定位分卷。
+- **缓存**:单次会话内已加载分卷缓存在内存,会话结束释放。
+- **超阈值告警**:分卷数超过 5 个时,UI 提示用户考虑归档旧卷。
+
+### §12.2 存量项目迁移路径
+
+- **首次启用检测**:项目根无 `retrieval/index.md` 且存在 `wiki/chapters/` 时,触发迁移流程。
+- **批量 ingestion**:弹出迁移向导弹窗,显示章节数量和预计耗时,提供"全量迁移 / 选择卷宗迁移 / 稍后迁移"三选项。
+- **进度展示**:迁移过程在工具调用 UI 中以批量任务卡片展示,可中断、可续传。
+- **失败处理**:单章失败标记为"待重试",不阻塞其他章节;全量完成后输出失败清单。
+- **回滚**:迁移前自动备份原 `wiki/chapters/` 的索引文件,迁移失败可一键恢复。
+- **存量数据兼容**:现有 `ChapterSnapshot` 数据可直接投影,无需重新生成。
+
+### §12.3 与现有记忆体系的边界(数据源归属矩阵)
+
+| 字段 | 真源 | 派生消费方 | 同步方向 |
+|------|------|-----------|---------|
+| 章节摘要 | `ChapterSnapshot.summary` | retrieval.md 自动区、记忆中心 | Snapshot → retrieval(投影) |
+| 人物状态变化 | `ChapterSnapshot.characterStateChanges` | retrieval.md 自动区、记忆中心、图谱节点 | Snapshot → 三方派生 |
+| 伏笔变化 | `ChapterSnapshot.foreshadowingChanges` | retrieval.md 自动区、伏笔追踪 | Snapshot → 两方派生 |
+| 时间线事件 | `ChapterSnapshot.timelineEvents` | retrieval.md 自动区、时间线 | Snapshot → 两方派生 |
+| 结尾钩子 | `ChapterSnapshot.endingHook` | retrieval.md 自动区 | Snapshot → retrieval |
+| 人工备注 | retrieval.md 人工补充区 | (不派生) | 用户 → retrieval(单向) |
+| 作品级总则 | `soul.md` | 所有生成路径 | 用户 → soul.md(单向) |
+| 角色灵魂 | `characterAuras`(独立文件) | 角色相关生成路径 | 用户 → characterAuras |
+| 自定义灵魂 | 用户配置 | 自定义灵魂生效路径 | 用户 → 自定义灵魂 |
+| 去 AI 味 skill | `de-ai-skill-library.ts` 配置 | 写章节/润色路径 | 用户 → skill 配置 |
+
+**冲突规则**:当 retrieval.md 自动区与记忆中心字段冲突时,以 ChapterSnapshot 为准( Snapshot 是真源),同步触发派生方重建。当用户在记忆中心手改字段时,标记该字段为"用户覆盖",回写 ChapterSnapshot 后再投影到 retrieval.md,禁止跳过 Snapshot 直接改 retrieval.md。
+
+### §12.4 流式 UX 与结果强约束的协同
+
+- **流式阶段**:仅显示"💭 正在生成中..."提示(符合项目记忆),不展示实际生成内容;但工具调用 UI 仍实时展示 Agent 各工具调用进度。
+- **流式结束**:累积完整输出后,先送入结果解析器(§3.3)做协议校验,校验通过才展示给用户。
+- **解析重试**:解析失败时剥头尾重试,重试异步进行,UI 显示"正在校验输出格式..."。
+- **失败提示**:解析仍失败时,UI 显示"输出不符合 {意图} 协议,已标记,未自动保存",并展示原始输出供用户手动处理。
+- **修改类意图例外**:modify_content 流式结束后不直接展示,先 diff 计算,进入 codex 式弹窗(§3.4)。
+
+### §12.5 工具调用 UI 与上下文追踪的合并实现
+
+- **数据结构**:单一 `ContextTrace` 类型,同时供 UI 渲染和内部追踪。
+- **UI 组件**:新增 `src/components/chat/tool-call-timeline.tsx`,按时间线渲染 `toolCalls` 数组。
+- **虚拟工具**:管道前置阶段的 `route-task`、`load-context`、`trim-context` 等封装为虚拟工具调用,与真实工具调用统一展示。
+- **折叠策略**:默认折叠详情,仅显示工具名和耗时;点击展开看入参和结果。
+- **持久化**:随对话历史持久化,重新打开会话可回看追踪。
+
+### §12.6 retrieval.md 自动区与 ChapterSnapshot 的单向投影
+
+- **投影函数**:`projectSnapshotToRetrievalEntry(snapshot: ChapterSnapshot): RetrievalEntry`,纯函数,无副作用。
+- **触发时机**:`chapter-ingest.ts` 完成 Snapshot 生成后立即调用投影函数,写入对应分卷自动区。
+- **字段映射**:Snapshot 的 20+ 字段按 PRD §2.2 的格式映射到 markdown 自动区结构。
+- **不可逆**:投影是单向的,retrieval.md 自动区内容不会被读回 ChapterSnapshot。
+- **版本兼容**:投影函数版本号写入自动区头部注释,未来 Snapshot 结构变更时支持迁移。
+
+### §12.7 意图识别扩展性与剧情推演室分支隔离
+
+- **main 分支意图集**:14 种 NovelTaskIntent(`task-router.ts:6-21`)。
+- **剧情推演室分支扩展**:在 `feature-story-simulation` 分支新增 `story_framework_generate`、`multi_agent_simulate`、`character_interview` 等意图,**仅在推演室分支代码中注册**。
+- **classification 路由对应**:推演室分支的 `classification.story-sim.md`(决策 6)只在该分支生效,main 分支读取不到则不加载。
+- **回退保护**:main 分支遇推演室意图关键字时(如"故事框架"),不识别为推演室意图,按 `general_chat` 处理并提示"此功能在剧情推演室测试版可用"。
+- **回归保证**:main 分支构建必须不包含推演室意图注册代码(CI 校验)。
+
+### §12.8 默认路由表的测试与维护
+
+- **真源**:`src/lib/novel/classification-router.ts` 的 `DEFAULT_INTENT_ROUTES` 常量。
+- **模板导出**:`scripts/export-classification-template.ts` 从常量生成 `classification.md` 模板。
+- **14 意图全表**:常量中明确给出每个意图的必载/选载/禁载数据源清单(共 14 条),覆盖 task-router.ts 全部意图。
+- **单元测试**:`tests/novel/classification-router.test.ts` 验证:
+  - 14 意图各自由必载项不空
+  - 禁载项不含必载项(无矛盾)
+  - 模板导出与常量一致
+  - 损坏文件回退到常量
+- **版本校验**:常量含 `CLASSIFICATION_VERSION`,与 classification.md 文件头 `classificationVersion` 比对,不匹配时提示升级。
+
+### §12.9 修改意图的目标章节解析
+
+- **解析顺序**:
+  1. 用户消息中明确章节号(如"第 12 章"、"第十二章"、"12 章")→ 代码正则提取
+  2. 当前打开的章节(`selectedFile` 包含 `/wiki/chapters/`)
+  3. 最近一次生成/Reading 的章节(持久化记录)
+  4. AI 会话最近一次引用的章节
+- **失败处理**:四步都未命中时,**不进入 modify_content 流程**,反向问用户"请明确要修改的章节"。
+- **章节号不可信 LLM**:符合项目记忆"普通对话模式下需正确解析目标章节号,由代码强制计算章节号,不再信任 LLM 输出"。
+- **多章节修改**:用户一次请求涉及多章时,按章节拆分为多个 modify_content 子任务,各自走 diff 确认弹窗。
+
+### §12.10 多灵魂边界与 token 预算分配
+
+- **五层灵魂体系**:
+  1. `soul.md`(作品级总则)— priority 3,必载
+  2. `characterAuras`(角色灵魂,对象级)— priority 9,按命中角色选载
+  3. 自定义灵魂(用户配置)— 按意图选载
+  4. 灵魂绑定(项目记忆提及)— 按人物关系选载
+  5. 去 AI 味 skill(`de-ai-skill-library.ts`,5 内置 + 项目自定义)— 按意图选载
+- **互斥关系**:自定义灵魂与 `soul.md` 冲突时,自定义灵魂优先(用户最近意图);角色灵魂与 `soul.md` 冲突时,`soul.md` 优先(作品级压角色级)。
+- **token 预算分配**(见 §12.12 详细表)。
+
+### §12.11 单元测试与回归测试基础设施
+
+- **新增测试目录**:`tests/novel/`
+- **测试文件清单**:
+  - `tests/novel/retrieval-index.test.ts`:retrieval.md 读写、增量更新、人工区保护、sourceHash 过期
+  - `tests/novel/classification-router.test.ts`:路由表读取、合并、回退、版本校验
+  - `tests/novel/result-parser.test.ts`:意图输出协议解析、剥头尾重试、失败标记
+  - `tests/novel/modify-confirm.test.tsx`:修改弹窗确认、编辑后确认、取消不落盘
+  - `tests/novel/context-trace.test.ts`:追踪记录字段完整性、与工具调用 UI 一致性
+  - `tests/novel/chapter-lifecycle.test.ts`:生成→确认→入库→投影→记忆同步全链路
+- **回归测试**:`tests/novel/regression/` 下保留现有续写、深度生成、大纲生成、去 AI 味、审查中心的 snapshot 用例,每次改动后全跑。
+- **测试命令**:`npm run test:novel` 单跑小说模块,`npm run test` 全量。
+- **CI 卡点**:P0 任务分支合并前必须全绿。
+
+### §12.12 token 预算分配表
+
+> v1.1 新增。用户在评审时明确"不思考 token 消耗",但工程上必须有兜底策略,否则小窗口模型会爆。
+
+- **预算来源**:当前选中模型的 `maxContextTokens`(跟随 AI 会话模型复选框状态,决策要求模型选择器禁用时取 aiChatModel)。
+- **分配比例**(按必载/选载倒序):
+
+| 优先级 | 项目 | 预留占比 |
+|--------|------|---------|
+| 1 | 系统提示 + 工具定义 + Agent 推理预留 | 15% |
+| 2 | `soul.md`(作品级总则) | 5% |
+| 3 | 必载段:大纲、最近剧情摘要、当前任务上下文 | 30% |
+| 4 | 必载段:上一章结尾、当前人物状态、当前伏笔状态 | 20% |
+| 5 | 选载段:时间线、角色认知状态、命中角色灵魂 | 15% |
+| 6 | 选载段:相关设定、去 AI 味 skill、自定义灵魂 | 10% |
+| 7 | 历史对话缓冲 | 5% |
+
+- **超预算裁剪**:按优先级倒序删,先删 7、再删 6……必载段保护到 3 级,3 级以下不裁。
+- **裁剪记录**:每次裁剪写入 `ContextTrace.trimmedSections`,UI 可查看被删段。
+- **模型不支持工具调用**:直接提示"当前模型不支持工具调用,请切换模型"(符合项目记忆"不做降级处理"),不进入 Agent 流程。
+
+### §12.13 与"跟随 AI 会话模型"复选框的耦合
+
+- **复选框状态读取**:路由表加载时读取"跟随 AI 会话模型"复选框状态(项目记忆要求复选框形式)。
+- **勾选时**:模型选择器禁用(项目记忆要求),token 预算按 `aiChatModel` 的 `maxContextTokens` 计算(项目记忆要求 aiChatModel 优先于 defaultLlmModel)。
+- **未勾选时**:用户可手动选模型,token 预算按所选模型计算。
+- **路由表动态调整**:模型上下文窗口 < 16K 时,自动收窄选载段(5、6 级)必载范围;窗口 < 8K 时,禁用 Agent 自主探索(避免多轮调用爆窗口)。
+- **模型不支持工具调用**:classification 路由跳过依赖工具调用的意图(如 search_plot、character_query),降级为 general_chat 处理。
+
+### §12.14 错误恢复与用户可感知策略
+
+- **错误分级**:
+
+| 级别 | 触发场景 | 用户感知方式 |
+|------|---------|------------|
+| 致命 | `soul.md` 加载失败、retrieval 主索引全损、classification 全部路由失效 | UI 强提示弹窗(中文),阻断本次会话,引导修复 |
+| 重要 | 单章 retrieval 过期、单数据源加载失败、解析重试失败 | UI 顶部黄色横幅提示,会话可继续,记录到 ContextTrace |
+| 一般 | 单字段缺失、人工区格式轻微异常、选载段裁剪 | 仅写入 ContextTrace,UI 折叠展示,不打扰用户 |
+
+- **回退提示文案**(中文示例):
+  - 致命:"项目灵魂文件 soul.md 读取失败,请检查文件是否存在或内容为空。本次会话已暂停。"
+  - 重要:"第 12 章检索索引已过期(正文已修改),已标记为待重生。本次生成可能基于旧摘要,建议重生索引后再生成。"
+  - 一般:"部分次要上下文因预算限制被裁剪,详见工具调用时间线。"
+- **修复引导**:致命级错误弹窗提供"打开文件位置"、"从备份恢复"、"重新生成索引"等快捷操作按钮。
+
+---
+
+## 十三、实施路线图(在当前分支串行推进)
+
+> v1.1 修订(用户决策):不再新建独立分支,全部 P0/P1 任务在当前 `gongjudiaoyongyouhua` 分支串行推进。该分支已有 Agent 工具调用框架的初步落地(见 §11 决策 1 现状说明与 §14 当前分支落地度对照),可作为统一基底继续叠加。
+
+### 13.1 推进顺序与里程碑
+
+| 阶段 | P0/P1 任务 | 依赖 | 验收闸门 |
+|------|---------|------|---------|
+| M1 | 决策 1 补齐:管道前置插件化 + 虚拟工具 + UI 状态补齐 | 复用现有 runner/types/registry | 旧功能回归全绿 + typecheck/build 全绿 |
+| M2 | P0-7 生成章节到入库生命周期约束 | M1 | 旧功能回归全绿 + typecheck/build 全绿 |
+| M3 | P0-4 modify_content 父级闸门 + codex 弹窗 | M2 | 旧功能回归全绿 + typecheck/build 全绿 |
+| M4 | P0-6 两级裁剪替换头尾切 | M2 | 旧功能回归全绿 + typecheck/build 全绿 |
+| M5 | P0-3 意图置信度反向问 | M2 | 旧功能回归全绿 + typecheck/build 全绿 |
+| M6 | P0-5 大纲路径 soul.md 全入口校验 | M2 | 旧功能回归全绿 + typecheck/build 全绿 |
+| M7 | P0-1 retrieval.md 主索引+分卷+投影 | M2 | 旧功能回归全绿 + typecheck/build 全绿 |
+| M8 | P0-2 classification.md 两层路由+默认同源 | M7 | 旧功能回归全绿 + typecheck/build 全绿 |
+| M9 | P1-9 上下文追踪 UI + 工具调用时间线完善 | M1-M8 | 旧功能回归全绿 + typecheck/build 全绿 |
+
+### 13.2 单阶段标准流程
+
+> v1.1 修订(用户决策):不需要每个阶段都打包测试。打包节奏改为"按需打包"——用户在每个里程碑完成后自行决定是否需要便携版测试,而非强制每阶段打包。
+
+1. **修改前**:在该分支根的 `gongjudiaoyongyouhua-分支说明.md` 中"本次更新"下新增条目,列明本阶段目标、成功标准、影响文件。
+2. **实现**:外科手术式改动,不顺手重构无关代码。
+3. **测试**:新增对应单元测试 + 跑 `tests/novel/regression/` 旧功能回归。
+4. **验证**:`npm run typecheck` + `npm run test:mocks` + `npm run build` 全绿。
+5. **源码启动**:`npm run dev` 验证软件可正常运行。
+6. **更新日志**:在 `GenxinLOG/更新日志.md` 追加变更(不上传 GitHub)。
+7. **分支说明文档**:在 `gongjudiaoyongyouhua-分支说明.md` 记录改动点、测试结果、是否合并。
+8. **按需打包**:用户明确要求打包时才执行 `npm run build:portable`,否则继续下一阶段。
+
+### 13.3 顺序依赖说明
+
+- **M1 必须最先做**:决策 1 的"管道前置插件化 + 虚拟工具"是当前分支最大缺口,后续 P0 任务(特别是 modify 闸门、置信度反向问、两级裁剪)都依赖这套插件链。
+- **M2 必须在 M3-M7 之前**:生命周期约束是后续所有修改/生成类任务的入库闸门。
+- **M3-M6 可按任意顺序推进**:四个任务互相独立,但必须串行做(不再并行,因用户要求不创建新分支)。
+- **M7 必须在 M8 之前**:retrieval.md 是 classification 路由的检索底层数据,先有检索才能路由。
+- **M9 最后做**:上下文追踪 UI 完善建立在前 8 个阶段全部稳定之上。
+
+### 13.4 风险控制
+
+- **当前分支长期不合并 main**:本分支暂不合并 main,直到所有 P0/P1 完成 + 用户测试通过。期间若 main 有更新,需要单独评估回 merge 风险。
+- **大改动阶段单独跑全量回归**:M7(retrieval)和 M8(classification)改动最大,必须跑全量 `npm run test:mocks` 而非只跑模块测试。
+- **打包节奏按需**:用户决策不需要每阶段打包。打包仅在用户明确要求时执行,否则串行推进下一阶段,避免无效打包开销。
+- **剧情推演室隔离**:本路线图完全不涉及剧情推演室。`feature-story-simulation` 分支独立推进,决策 6 已为其预留 `classification.story-sim.md` 接口。
+
+### 13.5 不进入本路线图的内容
+
+- 剧情推演室相关功能:在 `feature-story-simulation` 分支独立推进,绝对不合并 main。
+- P2 增强(retrieval 过期 UI、classification 版本校验、上下文追踪折叠 UI 细节):P0/P1 全部合并后再排期。
+- 去 AI 味 skill 重构:当前 `de-ai-skill-library.ts` 已稳定,不重构。
+
+### 13.6 等待用户确认事项
+
+本路线图为待执行计划,**当前 M1 阶段尚未启动代码改动**。等待用户对以下事项确认:
+
+1. 推进顺序是否认可(特别是 M1 决策 1 补齐最先做、M2 生命周期其次做的安排)。
+2. 是否同意"每阶段必须打包便携版给我测试"的工作节奏。
+3. 7 项决策是否全部认可(详见 §11)。
+4. M1 阶段的具体实现方案是否需要我进一步细化为代码级 spec(涉及新建 `agent/pipeline.ts`、扩展 `ToolCategory` 增加 `"virtual"`、3 个虚拟工具文件、UI 状态补齐等共约 10 个文件的改动)。
+
+---
+
+## 十四、当前分支落地度对照(v1.1 新增)
+
+> 本节基于 2026-07-01 对 `gongjudiaoyongyouhua` 分支代码的只读核查,对照 §11 决策 1 的 5 个核心点评估落地程度,作为 M1 阶段的开工依据。
+
+### 14.1 决策 1 五核心点落地度
+
+| 核心点 | 状态 | 当前实现依据 | M1 需补齐 |
+|--------|------|------------|----------|
+| **1. 统一基底** | 已实现(有缺口) | [runner.ts](file:///c:/QMAI_C/QMAI-main/src/lib/agent/runner.ts) 多轮循环 + 事件流 + 注册表 + 确认闸门 + 压缩齐全;但 [chat-panel.tsx#L975-L1240](file:///c:/QMAI_C/QMAI-main/src/components/chat/chat-panel.tsx#L975-L1240) `handleContinueUnfinished` 绕过 AgentRunner 直接 `streamChat` | 把 `handleContinueUnfinished` 收回 AgentRunner 路径 |
+| **2. 管道前置插件化** | 部分实现 | 存在"预 Agent 管道"事实(routeTask → buildContextPack → goldenThree → soulDialog → deAiSkill),但全部硬编码在 [chat-panel.tsx](file:///c:/QMAI_C/QMAI-main/src/components/chat/chat-panel.tsx) 内,无插件接口 | 新建 `agent/pipeline.ts`,把硬编码链路改为可配置插件链,`AgentConfig` 增加 `prePlugins` 字段 |
+| **3. 虚拟工具** | 未实现 | 无 `route_task` / `load_context` / `trim_context` 注册项;上下文加载完全在 UI 层一次性塞 systemPrompt | 新建 3 个虚拟工具文件,扩展 [types.ts](file:///c:/QMAI_C/QMAI-main/src/lib/agent/types.ts) `ToolCategory` 增加 `"virtual"` 分类 |
+| **4. UI 统一** | 部分实现 | [agent-tool-call-message.tsx](file:///c:/QMAI_C/QMAI-main/src/components/chat/agent-tool-call-message.tsx) 已统一渲染时间线、区分 read/write/action 配色、支持折叠;但 `running` 状态无标识,写入确认有"灵魂 Dialog + 工具待确认"两条未统一流程 | 补 `running` 行 spinner + "完成"徽章;合并灵魂 Dialog 与工具确认 |
+| **5. 取消降级语义** | 部分实现 | `AbortSignal` 贯穿 runner 与 `tool.execute`;但模型不支持工具调用时直接拒绝,无降级路径;工具执行中 cancel 靠 30s 超时 race | 补"模型不支持工具时降级到纯 streamChat"路径;abort 后保留"已取消"状态而非笼统 error |
+
+### 14.2 可直接复用的模块(M1 不动)
+
+| 模块 | 文件 | 复用理由 |
+|------|------|---------|
+| AgentRunner 基底 | [runner.ts](file:///c:/QMAI_C/QMAI-main/src/lib/agent/runner.ts) | 循环/事件流/确认闸门/压缩齐全 |
+| 类型系统 | [types.ts](file:///c:/QMAI_C/QMAI-main/src/lib/agent/types.ts) | `Tool`/`AgentToolEvent`/`AgentRunRecord` 扩展即可 |
+| 工具注册表 | [registry.ts](file:///c:/QMAI_C/QMAI-main/src/lib/agent/registry.ts) | `ToolRegistry` 通用,虚拟工具也能注册 |
+| 事件折叠 | [tool-events.ts](file:///c:/QMAI_C/QMAI-main/src/lib/agent/tool-events.ts) | `applyAgentToolEvent` 成熟 |
+| 结果压缩 | [tool-result.ts](file:///c:/QMAI_C/QMAI-main/src/lib/agent/tool-result.ts) | 6000 字阈值压缩 + UI 保留原文 |
+| 写入三件套 | [write-chapter.ts](file:///c:/QMAI_C/QMAI-main/src/lib/agent/tools/write-chapter.ts)、write-memory.ts、write-outline-node.ts | 读回验证逻辑保留 |
+| 读取类 11 工具 | [tools/](file:///c:/QMAI_C/QMAI-main/src/lib/agent/tools) | read_chapter/read_outline/read_memory/search_chapters 等直接复用 |
+| 工具调用 UI | [agent-tool-call-message.tsx](file:///c:/QMAI_C/QMAI-main/src/components/chat/agent-tool-call-message.tsx) | 时间线/折叠/配色继续用 |
+| Agent 装配链路 | [config.ts](file:///c:/QMAI_C/QMAI-main/src/lib/agent/config.ts) + [use-agent-config.ts](file:///c:/QMAI_C/QMAI-main/src/hooks/use-agent-config.ts) | 完整可用 |
+
+### 14.3 M1 阶段需新建/改造的模块清单
+
+1. **新建** `src/lib/agent/pipeline.ts`:PrePlugin 接口与插件链执行器
+2. **新建** `src/lib/agent/tools/route-task.ts`:路由任务工具(包装现有 `routeTask` 函数)
+3. **新建** `src/lib/agent/tools/load-context.ts`:上下文加载工具(包装现有 `buildContextPack`)
+4. **新建** `src/lib/agent/tools/trim-context.ts`:上下文裁剪工具(包装现有 `contextPackToPrompt` 两级裁剪)
+5. **改造** [types.ts](file:///c:/QMAI_C/QMAI-main/src/lib/agent/types.ts):`ToolCategory` 增加 `"virtual"`;`AgentToolEvent` 增加 `preview` 字段(用于真实写入预览);可选增加 `reasoning` 字段
+6. **改造** [runner.ts](file:///c:/QMAI_C/QMAI-main/src/lib/agent/runner.ts):confirm 工具闸门处生成真实写入预览而非固定提示;模型不支持工具时降级到 streamChat;abort 后置"已取消"状态
+7. **改造** [chat-panel.tsx](file:///c:/QMAI_C/QMAI-main/src/components/chat/chat-panel.tsx):
+   - `handleSend` 改为构造 PrePlugin 链 → 调 AgentRunner
+   - `handleContinueUnfinished` 收回 AgentRunner 路径
+   - 写入确认 Dialog 与工具 `approval_required` 合并为统一面板
+8. **改造** [agent-tool-call-message.tsx](file:///c:/QMAI_C/QMAI-main/src/components/chat/agent-tool-call-message.tsx):
+   - `running` 行加 spinner + "运行中"文字
+   - `done` 加"完成"徽章
+   - 写入类工具展示真实预览/diff
+9. **新建** `tests/agent/pipeline.test.ts`:PrePlugin 链测试
+10. **新建** `tests/agent/virtual-tools.test.ts`:3 个虚拟工具测试
+
+### 14.4 M1 阶段成功标准
+
+- [ ] `route_task` / `load_context` / `trim_context` 三个虚拟工具注册到 registry 并可被模型调用
+- [ ] `chat-panel.handleSend` 中硬编码的 routeTask → buildContextPack → goldenThree → soulDialog → deAiSkill 链改为 PrePlugin 链
+- [ ] `handleContinueUnfinished` 走 AgentRunner,工具调用 UI 一致
+- [ ] 工具调用 UI `running` 状态有 spinner、"完成"有徽章
+- [ ] 写入类工具 confirm 闸门展示真实预览而非固定文本
+- [ ] 模型不支持工具调用时降级 streamChat,UI 提示"当前模型不支持工具调用,已切换为普通对话模式"
+- [ ] abort 后 toolCallRecord 状态为"已取消"而非"错误"
+- [ ] 旧功能回归全绿(续写、深度生成、大纲生成、AI 会话所有意图、审查中心、记忆中心等)
+- [ ] 便携版打包成功并由用户测试通过

+ 349 - 0
AI-Agent架构-问答记录.md

@@ -0,0 +1,349 @@
+# AI Agent 工具架构 · 问答记录
+
+> 日期:2026-07-01
+> 关联文档:`AI-Agent架构-PRD.md`
+> 用途:记录本次架构讨论的原始问答,供任意 Agent 读取以理解决策背景与上下文。
+
+---
+
+## 一、用户原始构想
+
+用户提出了软件 Agent 工具的新方向,核心构想如下:
+
+### 1.1 三个磁盘文件
+
+软件目录下应包含三个文件:
+
+1. **检索文件**:包含章节中的摘要信息,每章以 **300 字** 描述整章故事发展,便于 AI 读取摘要信息。
+2. **分类文件**:对不同信息做不同分类,方便 AI 更好检索、更省 token。
+3. **SOUL.md 文档**:类似 AGENTS.md,约束生成内容。
+
+### 1.2 AI 会话流程(用户原话整理)
+
+```
+用户发送请求
+  → 提取请求关键词
+  → 识别用户意图
+  → 搜索检索文件,分析哪些文件包含用户意图
+  → 读取意图文件(意图文件包含章节内容/大纲内容/记忆库/图谱等)
+  → 分析文件内容是否和用户意图匹配(判断生成的是章节/大纲/修改内容等)
+  → 删减掉不匹配内容,留下有用信息
+  → 根据有用信息结合用户要使用的 skill 以及 SOUL.md 约束内容去生成故事内容
+  → 返回结果
+```
+
+### 1.3 约束与返回规则
+
+- 若 SOUL.md 要求返回结果包含"下一章故事的建议",则生成内容须包含。
+- 但返回内容须按意图严格收敛:
+  - 生成下一章 → 只返回章节标题 + 章节正文。
+  - 生成大纲 → 只返回大纲标题 + 大纲正文。
+- 修改内容 → 弹窗用户确认是否修改(codex 式弹窗)。
+
+### 1.4 用户提出的问题
+
+> "你觉得当前软件 agent 工具方向这样还有什么要修改的吗?"
+
+---
+
+## 二、Agent 调研结论(基于现有代码)
+
+Agent 对 QMaiWrite 现有代码做了深入调研,确认:**用户描述的架构其实已经有一套近似实现正在运行,只是概念未显式化、未收敛成三个独立文件。**
+
+### 2.1 现有实现对应关系
+
+| 用户构想 | 现有实现 | 位置 |
+|---------|---------|------|
+| 检索文件(摘要) | `ChapterSnapshot`(含 summary + 结构化字段) | `src/lib/novel/chapter-ingest.ts` |
+| 分类文件 | 19 个数据源 + `DataSourceRegistry`(但全量并行加载,无意图路由) | `src/lib/novel/context-data-sources.ts` |
+| SOUL.md | `soul.md` + `soul-doc.ts`(每次小说模式请求加载,priority 3) | 项目根 + `src/lib/novel/soul-doc.ts` |
+| 提取关键词 | `tokenizeQuery`(规则抽取 2 字以上 token) | `src/lib/novel/context-engine.ts:705` |
+| 识别意图 | `routeTask`(正则打分,14 种 intent) | `src/lib/novel/task-router.ts:182` |
+| 删减不匹配内容 | `contextPackToPrompt` 的 token 裁剪(4:1 头尾切字符串) | `src/lib/novel/context-engine.ts:1025` |
+| 读取意图文件 | `buildContextPack` 加载 17 字段 ContextPack | `src/lib/novel/context-engine.ts:63` |
+| 生成 | `AgentRunner` 多轮工具调用 / `runDeepChapterGeneration` | `src/lib/agent/runner.ts` / `deep-chapter-generation.ts` |
+| skill | `de-ai-writing/SKILL.md`(raw import 进 prompt) | `src/lib/novel/de-ai-adapter.ts` |
+
+### 2.2 现有意图枚举(14 种)
+
+`NovelTaskIntent`(`task-router.ts:6-20`):
+`write_chapter` / `continue_chapter` / `rewrite_chapter` / `polish_chapter` / `review_chapter` / `lint_chapter` / `generate_outline` / `search_plot` / `extract_memory` / `character_query` / `foreshadowing_query` / `timeline_query` / `setting_query` / `general_chat`
+
+### 2.3 关键缺口(现状问题)
+
+1. 19 个数据源全量并行加载,未按意图精准路由。
+2. 修改类意图(改写/润色)与生成走同一条流,无确认环节。
+3. 意图置信度有返回但仅写入系统提示(`task-router.ts:394`),未用于执行路径分支拦截,低置信度默认走普通流。
+4. 大纲生成主路径已接入 `buildContextPack` 与 `pack.soulDoc`(`outline-generation.ts:228/148`),但二级生成入口 `generateOutlineFile`(`outline-generation.ts:413-446`)未统一接入,仍有漏载风险。
+5. 超预算裁剪按 40%:60% 头尾切字符串(`context-engine.ts:1071-1073`),可能从中间劈开伏笔关键句。
+6. 生成返回靠 prompt 软约束,常带废话。
+7. `routeTask` 是纯函数诊断,意图→处理模块的映射靠 `chat-panel.tsx` 硬编码 if/else。
+
+---
+
+## 三、Agent 提出的修改建议(9 条)
+
+逐条说明,含理由。
+
+### 建议 1:检索文件保留 300 字梗概 + 结构化字段单独列出
+
+**用户原设想**:检索文件每章 300 字摘要。
+**问题**:纯散文摘要覆盖不了"该不该回收伏笔""人物这章后变成了什么状态"这类硬约束。现有 `ChapterSnapshot` 里的结构化字段(人物状态变化/伏笔变化/时间线事件/结尾钩子)是生成准确性的关键。
+**建议**:300 字摘要作主索引,结构化字段单独列出。否则会回退设定一致性。
+
+### 建议 2:分类文件做成"意图→数据源"路由表,而非静态目录
+
+**用户原设想**:分类文件方便 AI 检索、省 token。
+**问题**:能让 AI 检索更准的是"按意图只加载需要的分类",不是分类文件本身更省。现在 19 个数据源全量并行加载恰恰费 token。
+**建议**:分类文件做成一张"意图→必载/选载/禁载"路由表。
+
+### 建议 3:SOUL.md 保持作品级总则,角色灵魂/风格规则不并入
+
+**问题**:角色 token 随数量增长,全合并进 SOUL.md 会撑爆上下文。
+**建议**:SOUL.md 只放作品级总则,角色灵魂和去AI味 skill 单独成文件。`SECTION_PRIORITY` 已把三者分开(灵魂 priority 3、角色灵魂 priority 9),别合并。
+
+### 建议 4:新增 modify_content 意图族 + codex 式弹窗确认
+
+**用户要求**:修改内容弹窗确认。
+**问题**:现在改写/润色跟生成走同一条 `AgentRunner` 流,无确认环节,误判即落盘。
+**建议**:新增 `modify_content` 意图族,命中后先产出 diff 预览、弹窗确认、用户确认才落盘。是新增能力不是重构。
+
+### 建议 5:置信度低于阈值时反向问用户
+
+**问题**:`routeTask` 返回 `confidence` 但分发逻辑没用它。意图不清时(如"这一段再润色一下"既可能是改写也可能是续写)默认走普通流,可能跑偏。
+**建议**:置信度低于阈值时反过来问用户,不硬猜。与"改内容要弹窗"精神一致。
+
+### 建议 6:返回内容收敛做成强约束,不靠 prompt 软提示
+
+**用户要求**:生成章节只返回标题+正文。
+**问题**:现在靠 system prompt 软约束,模型常带"以下是为你生成的章节……"等废话。
+**建议**:生成后做结构化解析(已有 frontmatter 体系),解析失败剥头尾再试。
+
+### 建议 7:大纲生成路径补上 soul.md
+
+**问题**:`outline-generation.ts:422` 不调 `buildContextPack`,生成大纲时不带 soul.md,大纲气质与正文割裂。
+**建议**:在该路径补上 soul 加载。
+
+### 建议 8:裁剪策略改成两级,停止头尾切字符串
+
+**问题**:`contextPackToPrompt` 超预算时 4:1 头尾切,会切掉伏笔/设定关键句。
+**建议**:第一级按 SECTION_PRIORITY 删低优先级整段;第二级段内删细节保留"摘要头+关键字段+结尾钩子"。
+
+### 建议 9:执行模型用混合(管道前置 + Agent 兜底 + 置信度切换)
+
+**问题**:用户描述的是线性管道,现有 `AgentRunner` 是多轮循环,两者冲突。纯管道放弃 agent 自主检索;纯 agent 让 token/风格不可控。
+**建议**:简单/高置信度意图走管道拿稳定一致;复杂/低置信度/盲区降级 agent 拿灵活。中间靠置信度阈值切换。
+
+---
+
+## 四、用户关键决策
+
+### 4.1 关于 token 和改动量
+
+用户明确:**不讨论 token 问题,不谈论最小改动,按方案本身优势更强的方向判断。**
+
+### 4.2 关于架构基底
+
+提供三个选项:纯线性管道 / Agent 循环为主 / 混合。
+用户选择要求:按"哪个方案本身优势更强"判断。
+
+**Agent 结论**:推荐**混合(管道前置 + Agent 兜底)**,理由:
+- 管道守"一致性命根子"——小说最怕伏笔丟、人物崩、风格飘。管道把上下文前置收敛死,每次生成确定性输入。
+- Agent 守"盲区命门"——生成中途遇信息盲区,agent 可自主 read-chapter/search-chapters 救火。
+- 两者职责不同,不该用同一执行模型硬扛。
+- 现有代码已是混合雏形(`AgentRunner` + `buildContextPack`),只是边界没划清。
+
+### 4.3 关于三文件形态
+
+提供三个选项:独立磁盘文件 / 现有数据源重组 / 混合(索引文件+动态生成)。
+用户选择:**独立磁盘文件**(用户可读可编辑)。
+
+**配套要求**(Agent 提出):独立磁盘文件必须配套"同步闸门",否则埋雷:
+- `retrieval.md`:章节变更需触发增量更新,读取时以正文 hash 校验为主、修改时间戳为辅;自动区可覆盖,人工补充区不得覆盖。
+- `classification.md`:随意图类型手动维护,文件头注版本号;读取失败时必须回退内置默认路由。
+- `soul.md`:保持作品级约束定位;大纲、章节、修改、审稿路径都要验证不会漏载。
+
+---
+
+## 五、最终共识
+
+1. 三个独立磁盘文件方案:`retrieval.md` / `classification.md` / `soul.md`。
+2. 执行模型:混合(管道前置 + Agent 兜底 + 置信度切换)。
+3. 9 条修改建议全部纳入 PRD。
+4. 后续专业优化补充纳入 PRD:人工补充区、正文 hash、上下文追踪、输出协议、生成入库生命周期、默认路由兜底、冲突检测和固定测试用例。
+5. 下一步按 PRD 执行,暂不建分支(用户明确要求先出文档)。
+
+详见 `AI-Agent架构-PRD.md`。
+
+---
+
+## 六、后续专业优化共识(2026-07-01 追加)
+
+用户进一步询问这套文档对当前小说是否帮助大、还有什么要优化。Agent 从长篇小说一致性、可控性和可追踪性角度补充以下共识:
+
+### 6.1 retrieval.md 必须区分自动区和人工补充区
+
+`retrieval.md` 不能只做系统自动摘要。长篇小说里,用户会手动补充隐藏设定、伏笔意图、人物真实动机、后续写作提醒。系统更新章节索引时只能覆盖自动区,不能覆盖人工补充区,否则会破坏用户的创作判断。
+
+### 6.2 过期判断以正文 hash 为主
+
+只用修改时间戳不够稳。同步工具、系统复制、手动恢复都可能改变时间戳或保留旧时间戳。`retrieval.md` 每章条目应记录正文 `sourceHash`,读取时通过 hash 判断章节正文是否变化,时间戳只作为辅助信息。
+
+### 6.3 每次 AI 会话要有上下文追踪记录
+
+AI 生成错误时,必须能追踪本次识别的意图、置信度、使用了 `classification.md` 还是默认路由、加载了哪些数据源、排除了哪些数据源、命中了哪些章节、哪些上下文被裁剪、是否进入 Agent 兜底。该记录不混入正文,可以作为内部调试信息或折叠面板展示。
+
+### 6.4 modify_content 是父级安全闸门
+
+`modify_content` 不应完全替代 `rewrite_chapter` / `polish_chapter`。更合理的方式是:`modify_content` 负责所有会落盘的修改类任务确认闸门;`rewrite_chapter`、`polish_chapter`、扩写、删减、纠错作为子意图,决定上下文范围、提示词和弹窗文案。
+
+### 6.5 结果收敛要升级为按意图输出协议
+
+不能只靠 prompt 要求"只返回章节"。应定义章节生成、大纲生成、修改内容、审稿等不同意图的结构化输出协议。解析器要验证输出是否符合协议,不符合时不能直接保存或入库。
+
+### 6.6 生成章节到记忆入库必须有生命周期
+
+流程应为:AI 生成章节 → 结果解析器校验 → 用户确认保存 → 写入章节文件 → 章节摄取 → 生成快照 → 更新 `retrieval.md` → 更新记忆库/伏笔/时间线/图谱。未确认的 AI 会话草稿不能进入正史记忆。
+
+### 6.7 classification.md 必须有默认兜底路由
+
+用户可能误改 `classification.md`,也可能新增意图但忘记维护路由。读取失败、版本错误或意图缺失时,系统必须使用内置默认路由,并记录回退原因,不能让 AI 会话直接失效。
+
+### 6.8 要做章节正文、检索索引和记忆库的冲突检测
+
+当章节正文、`retrieval.md`、记忆库、伏笔追踪、时间线之间不一致时,系统不能静默混用冲突内容。应优先以章节正文为准,并提示用户重新生成索引或重建派生记忆。
+
+### 6.9 测试标准要变成固定用例
+
+PRD 需要补充可执行验证:章节生成只返回标题和正文、低置信度会反问、润色不直接落盘、章节改动后索引过期、人工补充区不被覆盖、分类文件损坏时启用默认路由、冲突可提示、上下文追踪可查看。
+
+---
+
+## 七、术语对照(用户用语 ↔ 代码概念)
+
+| 用户用语 | 代码概念 | 文件位置 |
+|---------|---------|---------|
+| 检索文件 | 章节快照索引 | `chapter-ingest.ts` → `retrieval.md`(新增) |
+| 分类文件 | 数据源路由表 | `context-data-sources.ts` → `classification.md`(新增) |
+| SOUL.md | 项目灵魂文档 | `soul-doc.ts` / `soul.md`(已有) |
+| 提取关键词 | `tokenizeQuery` | 定义在 `@/lib/search`,在 `context-engine.ts:705/754/890` 调用 |
+| 识别意图 | `routeTask` | `task-router.ts:182` |
+| 意图文件 | `ContextPack` 17 字段 | `context-engine.ts:39` |
+| 删减不匹配 | token 裁剪(待改两级) | `context-engine.ts:1025` |
+| skill | 去AI味技能 | `de-ai-adapter.ts` / `de-ai-skill-library.ts` |
+| 生成 | `AgentRunner` / 深度生成 | `runner.ts` / `deep-chapter-generation.ts` |
+| 人工补充区 | 用户维护的章节创作备注 | `retrieval.md`(新增) |
+| 正文 hash | 检索索引过期校验 | `retrieval-index.ts`(新增) |
+| 上下文追踪 | AI 会话调试记录 | `context-trace.ts`(新增) |
+| 输出协议 | 按意图验证生成结果结构 | `result-parser.ts`(新增) |
+| 修改确认闸门 | 修改类任务落盘前确认 | `modify-confirm-dialog.tsx`(新增) |
+
+---
+
+## 八、评审决策与 v1.1 修订记录(2026-07-01 追加)
+
+### 8.1 评审缘起
+
+用户在 PRD v1.0 定稿后追加询问:"这套文档对当前小说是否帮助大、还有什么要优化?"。Agent 从长篇小说一致性、可控性、可追踪性角度做了专业评审,并对 v1.0 描述的代码现状做了一次只读核查。
+
+### 8.2 评审发现的事实偏差(已修订)
+
+| # | v1.0 描述 | 实际代码 | 处理 |
+|---|---------|---------|------|
+| 1 | 意图置信度未被使用 | `task-router.ts:394` 已写入 `buildTaskDirective` 系统提示,仅未用于分支拦截 | PRD §1.2 问题 4 已修订 |
+| 2 | `contextPackToPrompt` 按 4:1 头尾切 | 实测为 40%:60%(即 2:3),位于 `context-engine.ts:1071-1073` | PRD §1.2 问题 6 已修订 |
+| 3 | 大纲路径(`outline-generation.ts:422`)不调 `buildContextPack` | 228 行 `safeBuildOutlineContextPack` 已调用,148 行已用 `pack.soulDoc`;422 行的 `generateOutlineFile` 是二级入口 | PRD §1.2 问题 5 与 §2.4 已修订 |
+| 4 | 去 AI 味 skill 是 raw import | `de-ai-skill-library.ts` 已实现完整 CRUD(5 内置 + 项目级自定义 + 备份恢复 + 旧版迁移),`de-ai-adapter.ts` 已实现场景化智能选择 | PRD §7.3 不改动清单已修订 |
+| 5 | `tokenizeQuery` 在 `context-engine.ts:705` | 该行是调用点,定义在 `@/lib/search` | 问答记录 §七 术语对照已修订 |
+
+### 8.3 评审发现的缺失设计(共 14 项,已补充至 PRD §12)
+
+1. retrieval.md 体量与性能策略(PRD §12.1)
+2. 存量项目迁移路径(PRD §12.2)
+3. 与现有记忆体系的边界(数据源归属矩阵)(PRD §12.3)
+4. 流式 UX 与结果强约束的协同(PRD §12.4)
+5. 工具调用 UI 与上下文追踪的合并实现(PRD §12.5)
+6. retrieval.md 自动区与 ChapterSnapshot 的单向投影(PRD §12.6)
+7. 意图识别扩展性与剧情推演室分支隔离(PRD §12.7)
+8. 默认路由表的测试与维护(PRD §12.8)
+9. 修改意图的目标章节解析(PRD §12.9)
+10. 多灵魂边界与 token 预算分配(PRD §12.10)
+11. 单元测试与回归测试基础设施(PRD §12.11)
+12. token 预算分配表(PRD §12.12)
+13. 与"跟随 AI 会话模型"复选框的耦合(PRD §12.13)
+14. 错误恢复与用户可感知策略(PRD §12.14)
+
+### 8.4 评审决策(7 项争议点固化)
+
+用户授权:"对于需要我拍板的内容,按照你的理解去选中,选择的时候不思考软件 token 消耗,不思考最小化修改,以软件最大化效果为主。"
+
+| # | 争议点 | 决策 | 决策理由摘要 |
+|---|--------|------|------------|
+| 1 | 执行基底 | **Agent 统一基底 + 管道前置插件** | 项目记忆要求"Agent工具框架需设计为通用框架";统一基底后所有执行路径 UI 统一、工具注册统一、未来扩展(多智能体)天然兼容 |
+| 2 | retrieval.md 形态 | **主索引文件 + 分卷详情文件** | 200+ 章单文件 300KB+ 会卡顿;分卷后主索引做倒排指针,分卷按 50 章拆 |
+| 3 | retrieval.md 自动区与 ChapterSnapshot 关系 | **自动区是 Snapshot 的只读投影,单一真源** | 禁止双写,避免不一致;Snapshot 是数据源,自动区是它的 markdown 序列化视图 |
+| 4 | 上下文追踪 vs 工具调用 UI | **合二为一** | 项目记忆要求"工具调用过程需在UI中完整展示";追踪记录就是 UI 数据源,避免双份调试信息 |
+| 5 | 默认路由表位置 | **classification.md 模板 + 代码读取同一份** | 单一真源,模板从代码常量导出,损坏时直接回退常量 |
+| 6 | classification.md 与剧情推演室分支 | **项目级 + 分支级两层路由表** | 项目记忆要求"剧情推演室绝对不合并 main";两层路由允许推演室分支独立维护补丁 |
+| 7 | retrieval.md 人工区编辑入口 | **软件内专用编辑面板** | 200 章 300KB 单文件手编体验差;专用面板带结构化校验,防误改格式 |
+
+详细决策内容见 PRD §11。
+
+### 8.5 执行模型修订
+
+PRD v1.0 第三章"线性管道 + Agent 兜底"语义取消,修订为"Agent 统一基底 + 管道前置插件":
+
+- v1.0 中"管道优先 / Agent 降级"二选一的执行模型分裂被根除。
+- Agent 始终是唯一执行引擎,管道作为 Agent 入口前的上下文收敛阶段挂载。
+- 工具调用 UI 统一展示所有执行路径,包括管道前置阶段的"虚拟工具调用"(`route-task`、`load-context`、`trim-context`)。
+- 原"Agent 兜底触发条件"改为"Agent 自主探索触发条件",不再降级语义。
+
+### 8.6 实施路线图(在当前分支串行推进)
+
+用户决策:不再新建独立分支,全部 P0/P1 任务在当前 `gongjudiaoyongyouhua` 分支串行推进。该分支已有 Agent 工具调用框架的初步落地(runner/types/registry/events/result/UI 装配链路完整),可直接作为统一基底继续叠加。
+
+推进顺序按 9 个里程碑(M1-M9)串行执行。打包节奏改为"按需打包"——用户决策不需要每阶段都打包,仅在用户明确要求时才执行便携版打包,否则串行推进下一阶段:
+
+| 阶段 | P0/P1 任务 | 依赖 |
+|------|---------|------|
+| M1 | 决策 1 补齐:管道前置插件化 + 虚拟工具 + UI 状态补齐 | 复用现有 runner/types/registry |
+| M2 | P0-7 生成章节到入库生命周期约束 | M1 |
+| M3 | P0-4 modify_content 父级闸门 + codex 弹窗 | M2 |
+| M4 | P0-6 两级裁剪替换头尾切 | M2 |
+| M5 | P0-3 意图置信度反向问 | M2 |
+| M6 | P0-5 大纲路径 soul.md 全入口校验 | M2 |
+| M7 | P0-1 retrieval.md 主索引+分卷+投影 | M2 |
+| M8 | P0-2 classification.md 两层路由+默认同源 | M7 |
+| M9 | P1-9 上下文追踪 UI + 工具调用时间线完善 | M1-M8 |
+
+详细规划见 PRD §13 与 §14(当前分支落地度对照)。
+
+### 8.7 当前分支落地度对照
+
+基于 2026-07-01 对 `gongjudiaoyongyouhua` 分支代码的只读核查,决策 1 的 5 个核心点落地度如下:
+
+| 核心点 | 状态 | 说明 |
+|--------|------|------|
+| 1. 统一基底 | 已实现(有缺口) | `handleContinueUnfinished` 仍绕过 AgentRunner |
+| 2. 管道前置插件化 | 部分实现 | 管道存在但硬编码在 `chat-panel.tsx`,无插件接口 |
+| 3. 虚拟工具 | 未实现 | 无 `route_task`/`load_context`/`trim_context` 注册项 |
+| 4. UI 统一 | 部分实现 | 缺 `running` 状态标识与"完成"徽章,写入确认两流程未统一 |
+| 5. 取消降级语义 | 部分实现 | 模型不支持工具时直接拒绝,无降级路径 |
+
+M1 阶段需新建 `agent/pipeline.ts` + 3 个虚拟工具文件,改造 `types.ts`/`runner.ts`/`chat-panel.tsx`/`agent-tool-call-message.tsx`,共约 10 个文件改动。详细清单见 PRD §14.3。
+
+### 8.8 评审小结
+
+- **帮助度评估**:PRD v1.0 方向正确,对长篇小说一致性命根子有强支撑(三文件显式化、modify 闸门、两级裁剪、生命周期约束、置信度反问均为命根级修复);但有 4 处事实偏差需修订,14 项缺失需补充。
+- **v1.1 状态**:偏差已修订,缺失已补充,7 项决策已固化,实施路线图已就绪并适配到当前分支。
+- **未启动代码改动**:严格遵循 AGENTS.md,等待用户对 §九 待确认事项拍板后启动 M1。
+
+---
+
+## 九、后续待用户确认事项
+
+1. 推进顺序是否认可(特别是 M1 决策 1 补齐最先做、M2 生命周期其次做的安排)。
+2. 是否同意"每阶段必须打包便携版给我测试"的工作节奏。
+3. 7 项评审决策是否全部认可(详见 PRD §11,特别是决策 1 Agent 统一基底,当前分支已部分落地)。
+4. M1 阶段的具体实现方案是否需要我进一步细化为代码级 spec(涉及新建 `agent/pipeline.ts`、扩展 `ToolCategory` 增加 `"virtual"`、3 个虚拟工具文件、UI 状态补齐等共约 10 个文件的改动)。
+5. 是否需要在当前 `gongjudiaoyongyouhua` 分支提交本次 PRD 与问答记录的文档修订(仅文档变更,无代码改动)。

+ 348 - 0
AI会话ReAct主架构-设计计划PRD.md

@@ -0,0 +1,348 @@
+# AI 会话 ReAct 主架构设计计划 PRD
+
+> 版本:v1.0  
+> 日期:2026-07-03  
+> 状态:设计计划  
+> 适用分支:`gongjudiaoyongyouhua`  
+> 关联文档:`AI-Agent架构-PRD.md`、`AI会话工作流与测试标准整理.md`、`AGENTS.md`
+
+## 一、背景
+
+当前 AI 会话已经具备 AgentRunner、多轮工具调用、Skill、MCP、web search、能力选择、章节多任务生成循环等基础能力,但架构主控关系还不够清晰。
+
+当前最大问题是:部分复杂任务,尤其章节生成,仍由 `chat-panel` 根据任务路由直接进入 `runDeepChapterGeneration` 这类固定工作流。这样虽然能展示阶段,但本质上还是软件主控流程,不是类似 Codex 的 ReAct Agent 自主分析、调用工具、观察结果、继续决策。
+
+本 PRD 的目标是明确下一阶段 AI 会话重构方向:**AI 会话以单 Agent + ReAct 为主线,Workflow 作为 ReAct 可调用的结构化工具存在。**
+
+## 二、核心结论
+
+本软件当前主线采用以下架构组合:
+
+1. **AI 会话主控:单 Agent + ReAct 工具调用。**
+2. **标准 / 严格模式:在 ReAct 内引入 Plan Execute。**
+3. **Skill、MCP、web search:走 Router Skill / capability selector。**
+4. **Workflow / Graph:作为 ReAct 可调用的工具或子流程,不作为 AI 会话顶层主控。**
+5. **多 Agent 和 Blackboard:暂不进入 AI 会话主线,留给剧情推演室或大型协作功能。**
+
+## 三、目标
+
+### 3.1 产品目标
+
+1. AI 会话更接近 Codex 的工作方式:先分析,再选择工具,再观察结果,再继续执行。
+2. 用户能看到 AI 为什么这样做、调用了哪些工具、每一步结果是什么。
+3. 章节生成、改写、审稿等复杂流程仍保持稳定,但从“固定分支执行”升级为“Agent 主动调用 workflow 工具”。
+4. 快速、标准、严格三档模式有清晰差异,而不是只改提示词。
+5. Skill、MCP、web search 能统一进入能力选择层,避免模型凭空假装使用外部能力。
+
+### 3.2 技术目标
+
+1. `chat-panel` 不再承担 AI 执行编排主责,只负责 UI、输入、流式展示和用户交互。
+2. 新增或抽离 `runAiChatSession` / `AgentChatSession`,承接一次 AI 会话完整执行。
+3. `AgentRunner` 继续作为 ReAct 循环核心。
+4. `runDeepChapterGeneration` 包装成 Agent Tool,例如 `run_chapter_workflow`。
+5. 工具调用、workflow 子步骤、MCP、web search 使用统一事件模型展示。
+
+## 四、非目标
+
+1. 本阶段不引入多 Agent 协作主线。
+2. 本阶段不引入 Blackboard 全局共享状态。
+3. 本阶段不重写所有小说生成逻辑。
+4. 本阶段不取消现有章节 workflow,只调整它在架构中的位置。
+5. 本阶段不让模型绕过写入确认直接修改项目文件。
+
+## 五、目标架构
+
+### 5.1 总体链路
+
+```text
+用户输入
+ -> ChatPanel
+ -> runAiChatSession
+ -> Task Router
+ -> Capability Selector
+ -> ReAct AgentRunner
+      -> 分析任务
+      -> 选择工具
+      -> 调用项目读取工具 / Skill / MCP / web search / workflow tool
+      -> 观察工具结果
+      -> 继续决策
+      -> 输出最终结果
+```
+
+### 5.2 Workflow 在新架构中的位置
+
+```text
+ReAct AgentRunner
+ -> 调用 run_chapter_workflow
+      -> 读取上下文
+      -> 生成写作任务书
+      -> 生成正文初稿
+      -> AI 审稿
+      -> 自动返修
+      -> 简单审查与去AI味
+      -> 返回最终正文
+ -> Agent 观察 workflow 结果
+ -> Agent 输出正文或交付说明
+```
+
+关键原则:**Workflow 是工具,不是 AI 会话主控。**
+
+## 六、模块设计
+
+### 6.1 ChatPanel
+
+职责:
+1. 管理输入框、引用、按钮、流式展示。
+2. 把用户请求交给 `runAiChatSession`。
+3. 展示 `AgentToolEvent`、上下文追踪和最终内容。
+4. 处理保存草稿、继续未完成、停止生成等 UI 行为。
+
+不再负责:
+1. 直接判断章节任务并执行 `runDeepChapterGeneration`。
+2. 直接拼装复杂 Agent 执行链。
+3. 直接决定 Skill、MCP、web search 是否启用。
+
+### 6.2 runAiChatSession
+
+职责:
+1. 接收用户输入、当前会话、项目路径、选中文件、AI 模式。
+2. 调用 Task Router 识别意图。
+3. 调用 Capability Selector 选择本轮可用能力。
+4. 构建 Agent system prompt、工具注册表和执行策略。
+5. 启动 `AgentRunner`。
+6. 统一处理断点、取消、错误、事件转发和最终结果。
+
+### 6.3 AgentRunner
+
+职责:
+1. 保持单 Agent + ReAct 多轮循环。
+2. 每轮调用 LLM。
+3. 接收工具调用。
+4. 执行工具并把结果返回模型。
+5. 超过轮次、取消、工具失败时输出明确中文错误。
+
+需要增强:
+1. 支持 Plan Execute 策略。
+2. 支持工具父子事件,例如 `run_chapter_workflow` 下挂多个子步骤。
+3. 支持更清晰的 round trace,便于 UI 展示“分析、行动、观察”。
+
+### 6.4 Capability Selector
+
+职责:
+1. 根据任务意图、模式、项目状态和用户引用,选择本轮可用能力。
+2. 选择范围包括内置工具、写作 Skill、MCP、web search、workflow tools。
+3. 输出 selected capabilities 给 Agent prompt 和 trace UI。
+
+原则:
+1. Selector 只筛选能力,不替代 Agent 决策。
+2. Agent 只能调用本轮被允许的工具。
+3. 未启用或不可用的 MCP / web search 不能被模型伪装成已经使用。
+
+### 6.5 Workflow Tool Adapter
+
+职责:
+1. 把现有 `runDeepChapterGeneration` 包装成 Agent Tool。
+2. 对外暴露工具名,例如 `run_chapter_workflow`。
+3. 把内部 workflow event 转换成统一 `AgentToolEvent`。
+4. 返回最终正文、任务书、审稿结果、是否返修等结构化结果。
+
+建议工具参数:
+
+```ts
+{
+  intent: "write_chapter" | "continue_chapter" | "rewrite_chapter" | "polish_chapter"
+  userRequest: string
+  chapterNumber?: number
+  workflowMode: "fast" | "standard" | "strict"
+  selectedFile?: string
+}
+```
+
+### 6.6 Plan Execute
+
+适用范围:
+1. 标准模式:复杂写作任务先生成简短计划,再执行。
+2. 严格模式:必须生成计划,执行后必须审查和必要返修。
+3. 快速模式:不强制计划,除非 Agent 判断必须澄清。
+
+计划内容应该简短、可执行、可追踪,不应该变成大段说明文。
+
+建议结构:
+
+```text
+计划:
+1. 读取目标章节上下文。
+2. 确认本章必须完成的剧情目标。
+3. 生成正文。
+4. 自检是否承接上一章、是否违背设定。
+5. 输出最终正文。
+```
+
+## 七、模式设计
+
+### 7.1 快速模式
+
+目标:低延迟、少步骤、快速输出。
+
+策略:
+1. ReAct 可直接调用必要工具。
+2. 不强制 Plan Execute。
+3. 章节 workflow 可跳过 AI 审稿、自动返修和最终去AI味。
+4. 工具轮次较少。
+
+### 7.2 标准模式
+
+目标:平衡质量和速度。
+
+策略:
+1. 对章节生成、改写、复杂分析引入简短 Plan Execute。
+2. 允许调用 Skill、项目读取工具、必要 MCP / web search。
+3. 章节 workflow 跳过深度 AI 审稿,但保留最终简单审查与去AI味。
+4. 输出前做基础结果校验。
+
+### 7.3 严格模式
+
+目标:质量优先、可解释、可复查。
+
+策略:
+1. 强制 Plan Execute。
+2. 必须明确读取哪些上下文。
+3. 允许更完整的 Skill / MCP / web search 能力。
+4. 章节 workflow 执行审稿、返修、返修后复审、最终去AI味。
+5. 输出前做结果协议校验和风险提示。
+
+## 八、工具体系
+
+### 8.1 工具分类
+
+1. 读取工具:章节、大纲、记忆、推演、历史会话。
+2. 搜索工具:项目内搜索、web search。
+3. Skill 工具:写作 Skill、去AI味 Skill、用户自定义 Skill。
+4. MCP 工具:外部知识、外部检索、外部分析。
+5. Workflow 工具:章节生成、章节改写、审稿返修、资料整理。
+6. 写入工具:保存草稿、写入章节、写入记忆、写入大纲。
+
+### 8.2 权限原则
+
+1. 读取、搜索、分析类工具可自动执行。
+2. 写入类工具必须用户确认。
+3. Workflow 生成正文可以自动执行,但落盘保存必须确认。
+4. MCP 如果没有真实连接,必须返回中文降级信息,不能伪造结果。
+
+## 九、事件与可视化
+
+### 9.1 统一事件模型
+
+所有工具和 workflow 子步骤统一使用 `AgentToolEvent`。
+
+建议扩展字段:
+
+```ts
+{
+  type: "call_started" | "result" | "error" | "approval_required" | "cancelled"
+  callId: string
+  parentCallId?: string
+  name: string
+  params: Record<string, unknown>
+  result?: string
+  preview?: string
+  timestamp: number
+}
+```
+
+### 9.2 展示原则
+
+1. 用户能看到 Agent 调用了什么工具。
+2. 用户能看到 workflow 内部子步骤。
+3. 最终正文区域不混入工具日志。
+4. 工具失败要能定位到具体步骤。
+
+## 十、实施路线
+
+### 阶段 1:抽离 AI 会话执行器
+
+目标:先把 `chat-panel` 中的 AI 执行逻辑抽到 `runAiChatSession`,不改变现有行为。
+
+交付:
+1. 新增 `src/lib/agent/ai-chat-session.ts`。
+2. `chat-panel` 调用 session runner。
+3. 现有测试通过。
+
+### 阶段 2:Workflow 工具化
+
+目标:把 `runDeepChapterGeneration` 包装成 ReAct 工具。
+
+交付:
+1. 新增 `run_chapter_workflow` 工具。
+2. Agent 可主动调用章节 workflow。
+3. 移除 `chat-panel` 中直接执行章节 workflow 的特殊分支。
+4. 工具事件能展示 workflow 子步骤。
+
+### 阶段 3:能力选择统一
+
+目标:Skill、MCP、web search、workflow tools 都通过 Capability Selector 进入本轮工具集。
+
+交付:
+1. Capability registry 增加 workflow tool capability。
+2. Selector 按 fast / standard / strict 输出不同能力。
+3. Trace UI 展示本轮选择了哪些能力和原因。
+
+### 阶段 4:Plan Execute 策略
+
+目标:标准 / 严格模式真正具备计划执行行为。
+
+交付:
+1. Agent prompt 增加 Plan Execute 协议。
+2. 标准模式对复杂任务生成简短计划。
+3. 严格模式强制计划、执行、审查。
+4. 计划内容和执行结果可追踪。
+
+### 阶段 5:错误恢复与断点统一
+
+目标:普通 ReAct 工具、workflow 工具、MCP 工具都能统一停止、恢复和报错。
+
+交付:
+1. 统一 breakpoint 结构。
+2. 继续未完成优先恢复 Agent 会话状态。
+3. workflow 内部 checkpoint 能挂到父工具调用下。
+
+## 十一、验收标准
+
+1. AI 会话所有任务都先进入 ReAct AgentRunner。
+2. 章节生成由 Agent 调用 `run_chapter_workflow`,不再由 `chat-panel` 直接分支执行。
+3. 快速、标准、严格三种模式在工具选择、计划要求、审查强度上有真实差异。
+4. Skill、MCP、web search、workflow tools 均由 Capability Selector 管理。
+5. 多 Agent 和 Blackboard 不进入 AI 会话主线。
+6. 工具调用和 workflow 子步骤在 UI 中可见。
+7. 最终正文不混入工具日志、计划说明或执行摘要。
+8. 写入项目文件仍必须用户确认。
+9. 原有章节生成、续写、改写、润色功能不回退。
+
+## 十二、风险与约束
+
+1. 直接把 workflow 工具交给模型调用,可能出现模型不调用或调用参数不完整的问题,需要工具描述和参数 schema 足够明确。
+2. 标准 / 严格模式如果计划过长,会拖慢输出并污染正文,需要限制计划长度。
+3. 父子工具事件会增加 UI 和 trace 复杂度,需要先做最小可用版本。
+4. `chat-panel` 当前文件较重,抽离时必须分阶段进行,避免一次性大重构。
+5. 模型不支持工具调用时仍需要保留普通对话降级路径。
+
+## 十三、推荐优先级
+
+P0:
+1. 抽离 `runAiChatSession`。
+2. 把章节 workflow 包成工具。
+3. 移除 `chat-panel` 章节特殊执行分支。
+
+P1:
+1. Capability Selector 纳入 workflow tools。
+2. 统一 workflow 子步骤事件。
+3. 标准 / 严格模式 Plan Execute。
+
+P2:
+1. 断点恢复统一。
+2. Trace UI 父子层级优化。
+3. 剧情推演室再引入多 Agent 或 Blackboard。
+
+## 十四、最终架构一句话
+
+AI 会话以 **单 Agent + ReAct** 为主线;标准 / 严格模式在 ReAct 中启用 **Plan Execute**;Skill、MCP、web search 和 workflow 由 **Capability Selector** 控制;章节生成等固定流程作为 **Workflow Tool** 被 Agent 调用;多 Agent 和 Blackboard 暂不进入主线。

+ 1103 - 0
AI会话工作流与测试标准整理.md

@@ -0,0 +1,1103 @@
+# AI 会话工作流与能力扩展设计整理
+
+整理时间:2026-07-02
+
+适用分支:`gongjudiaoyongyouhua`
+
+## 一、文档目标
+
+本文用于整理当前 AI 会话后续应该如何演进,重点回答几个问题:
+
+1. 当前 AI 会话和 Codex 会话有什么区别。
+2. 小说 AI 会话应该借鉴 Codex 哪些能力。
+3. 快速、标准、严格三种模式应该如何设计。
+4. 三档模式是否需要配套 Skill。
+5. AI 会话是否需要外部搜索工具。
+6. 后续增加 MCP 和用户自定义 Skill 时,AI 会话是否能承接。
+7. 面向普通小说用户,完整的 AI 创作流程应该是什么。
+
+核心结论:
+
+```text
+AI 会话不应该只是“聊天框 + 模型回复”,而应该变成小说创作 Agent。
+它需要能读取资料、选择 Skill、调用工具、使用外部搜索、使用 MCP、生成计划、执行正文、做质量自检,并在写入前让用户确认。
+```
+
+## 二、当前 AI 会话和 Codex 会话的区别
+
+### 1. 目标不同
+
+当前软件 AI 会话目标:
+
+- 写小说。
+- 改章节。
+- 生成大纲。
+- 分析剧情。
+- 读取小说资料。
+- 维护作品上下文。
+
+Codex 会话目标:
+
+- 修改软件代码。
+- 阅读工程文件。
+- 运行测试和构建。
+- 修复 bug。
+- 管理工程交付。
+
+### 2. 上下文来源不同
+
+软件 AI 会话读取:
+
+- 章节。
+- 大纲。
+- 记忆。
+- 设定。
+- 推演。
+- `soul.md`。
+- `classification` 路由。
+- retrieval 索引。
+- 去 AI 味 Skill。
+- 后续用户自定义 Skill。
+- 后续外部搜索结果。
+- 后续 MCP 外部知识工具。
+
+Codex 读取:
+
+- 源代码。
+- 测试文件。
+- 配置文件。
+- git 状态。
+- 终端命令输出。
+- 项目开发规范。
+
+### 3. 工具权限不同
+
+软件 AI 会话工具偏小说创作:
+
+- 读取章节。
+- 读取大纲。
+- 读取记忆。
+- 搜索章节。
+- 写章节草稿。
+- 写大纲节点。
+- 写记忆。
+- 应用 Skill。
+- 后续调用外部搜索工具。
+- 后续调用 graphRAG、knowledgeGraph 等 MCP。
+
+Codex 工具偏开发:
+
+- 搜索代码。
+- 修改文件。
+- 运行命令。
+- 跑测试。
+- 构建项目。
+- 检查 git diff。
+
+### 4. 写入安全边界不同
+
+软件 AI 会话防止的是:
+
+- AI 误改章节。
+- AI 误覆盖大纲。
+- AI 把无效内容写入正式库。
+- AI 未经确认修改用户作品。
+- AI 未经确认把草稿同步进记忆、retrieval、伏笔、时间线或图谱。
+
+Codex 防止的是:
+
+- AI 误改代码。
+- AI 删除文件。
+- AI 执行高风险命令。
+- AI 未经用户同意提交或合并。
+
+### 5. 结果形态不同
+
+软件 AI 会话输出:
+
+- 小说正文。
+- 大纲正文。
+- 润色结果。
+- 审稿建议。
+- 剧情分析。
+- 创作计划。
+- 写后自检结果。
+
+Codex 输出:
+
+- 修改了哪些文件。
+- 修复了什么问题。
+- 运行了哪些测试。
+- 是否构建成功。
+- 是否提交 git。
+- 还存在哪些风险。
+
+## 三、当前 AI 会话应该借鉴 Codex 的能力
+
+### 1. 先计划,再执行
+
+Codex 在复杂任务中会先分析和规划。小说生成也应该这样。
+
+对于“生成下一章”这类任务,建议流程是:
+
+```text
+用户请求生成下一章
+→ AI 分析上一章、主线、人物、伏笔
+→ AI 生成本章创作计划
+→ 用户确认或修改计划
+→ AI 按计划写正文
+→ AI 自检
+→ 用户确认保存
+```
+
+这对小说小白尤其重要,因为新手最容易遇到的问题不是不会写句子,而是不知道下一章该怎么推进。
+
+### 2. 执行过程可见
+
+Codex 会展示自己做了哪些操作。小说 AI 也应该展示:
+
+- 识别出的意图。
+- 使用的模式。
+- 使用的 Skill。
+- 调用的工具。
+- 外部搜索的关键词和结果来源。
+- 调用的 MCP。
+- 读取了哪些资料。
+- 排除了哪些资料。
+- 哪些伏笔被推进。
+- 哪些设定被使用。
+
+但这些内容不应该混入最终小说正文,应该放在折叠的“创作过程”中。
+
+### 3. 写入前确认
+
+Codex 修改文件前后会让用户知道变更。小说 AI 修改章节也应该展示:
+
+- 原文。
+- 修改后。
+- 修改原因。
+- 差异对比。
+- 确认。
+- 编辑后确认。
+- 取消。
+
+### 4. 完成后自检
+
+Codex 完成代码后会跑测试。小说 AI 写完章节后也应该做“剧情测试”:
+
+- 是否承接上一章。
+- 人物动机是否合理。
+- 主线是否推进。
+- 冲突是否足够。
+- 伏笔是否处理。
+- 是否违背 `soul.md`。
+- 是否符合章节输出协议。
+
+### 5. 从中断点继续
+
+Codex 可以延续上下文继续工作。小说 AI 也应该能记录:
+
+- 当前任务是什么。
+- 已完成哪些阶段。
+- 使用了哪些 Skill。
+- 使用了哪些工具。
+- 是否调用了外部搜索。
+- 调用了哪些 MCP。
+- 卡在哪一步。
+- 下一步应该做什么。
+
+这样“继续未完成”就不会只是简单续写,而是从任务断点恢复。
+
+## 四、快速、标准、严格三档 AI 会话模式
+
+### 1. 模式定位
+
+快速、标准、严格不应该做成三个不同的 AI,而应该做成同一个 AI 会话的三档执行深度。
+
+核心原则:
+
+```text
+快速 = 少分析,不是少确认
+标准 = 有计划,有基础自检
+严格 = 强计划,强确认,强自检
+```
+
+三种模式区别:
+
+| 模式 | 适合用户 | 核心目标 | 用户感受 |
+| --- | --- | --- | --- |
+| 快速 | 想马上得到结果的人 | 少步骤、少打断、直接产出 | 像普通 AI 聊天 |
+| 标准 | 大多数普通小说用户 | 兼顾质量和效率 | 有必要的规划和确认 |
+| 严格 | 小说小白、长篇主线、重要章节 | 像 Codex 一样分步骤验证 | 更稳、更可控、更慢 |
+
+推荐默认使用标准模式。
+
+原因是:
+
+- 快速模式容易让小说小白跳过剧情规划。
+- 严格模式质量更稳,但步骤多,长期使用会累。
+- 标准模式最适合日常写作,既能避免跑偏,又不会让用户觉得繁琐。
+
+### 2. 生成下一章时的流程区别
+
+快速模式:
+
+```text
+用户:帮我写下一章
+→ AI 读取最少必要资料
+→ 直接生成正文
+→ 简单检查格式
+→ 用户手动决定是否保存
+```
+
+标准模式:
+
+```text
+用户:帮我写下一章
+→ AI 读取上一章、大纲、soul.md、必要记忆
+→ 生成简短章节计划
+→ 用户确认 / 修改 / 跳过计划
+→ AI 写正文
+→ 基础剧情自检
+→ 用户确认保存
+```
+
+严格模式:
+
+```text
+用户:帮我写下一章
+→ AI 明确任务意图
+→ classification 路由
+→ 读取大纲、上一章、人物状态、伏笔、记忆、soul.md
+→ 读取用户启用的结构类 Skill
+→ 必要时调用 graphRAG / knowledgeGraph MCP
+→ 分析主线、冲突、人物动机、章节承接
+→ 输出章节计划
+→ 用户必须确认计划
+→ AI 写正文
+→ 剧情自检
+→ 格式校验
+→ 保存前确认
+→ 保存后再进入记忆 / retrieval / 伏笔系统
+```
+
+### 3. 三档模式控制的能力维度
+
+| 维度 | 快速 | 标准 | 严格 |
+| --- | --- | --- | --- |
+| 意图识别 | 简单识别 | 正常识别 | 低置信度必须澄清 |
+| 资料读取 | 最少资料 | 任务相关资料 | 更完整资料 |
+| Skill 使用 | 少量必要 Skill | 常用创作 Skill | 完整工作流 Skill |
+| 外部搜索 | 明确要求才搜索 | 任务需要时搜索 | 关键任务主动建议搜索 |
+| MCP 使用 | 默认不主动调用 | 任务需要时调用 | 关键任务主动检索 |
+| 创作计划 | 默认不强制 | 复杂任务先计划 | 必须先计划 |
+| 用户确认 | 写入前确认 | 计划 / 写入确认 | 每个关键阶段确认 |
+| 自检 | 轻量格式检查 | 基础剧情自检 | 完整剧情测试 |
+| 过程展示 | 简略 | 折叠展示 | 详细追踪 |
+
+### 4. 不能被模式绕过的安全边界
+
+快速模式不能变成不安全模式。
+
+无论用户选择哪种模式,只要涉及正式写入,都必须确认:
+
+- 写入章节。
+- 覆盖章节。
+- 修改大纲。
+- 写入记忆。
+- 更新正式资料库。
+- 影响 retrieval、伏笔、时间线、图谱。
+- 把外部搜索结果写入项目资料库。
+- 调用具有写入能力的 MCP。
+
+三档模式只控制分析深度、读取范围、Skill 选择、外部搜索频率、MCP 调用频率、确认频率和自检强度,不允许绕过写入安全。
+
+### 5. 在当前软件中的落地建议
+
+当前软件已有深度模式入口,建议不要继续用一个布尔开关表示深度,而是升级为 AI 会话执行模式:
+
+```ts
+aiWorkflowMode: "fast" | "standard" | "strict"
+```
+
+界面展示为:
+
+```text
+快速 | 标准 | 严格
+```
+
+迁移方式:
+
+- 旧的深度模式关闭:默认映射为标准。
+- 旧的深度模式开启:映射为严格。
+- 新增快速模式,用于轻量聊天、灵感生成、简单润色。
+
+这样可以复用现有深度模式基础,不需要重做 AI 会话系统。
+
+## 五、三档模式下的 Skill 体系
+
+### 1. 为什么必须补充 Skill
+
+快速、标准、严格三档模式只能决定 AI 会话的执行深度,不能直接保证小说质量。
+
+如果没有对应 Skill,三档模式只能控制:
+
+- 是否先计划。
+- 是否多读取资料。
+- 是否多做确认。
+- 是否展示更多过程。
+
+但小说真正需要稳定提升的是:
+
+- 下一章如何推进。
+- 人物为什么这样行动。
+- 本章冲突是否足够。
+- 伏笔有没有推进或遗漏。
+- 章节是否承接前文。
+- 正文是否符合故事主线和作品风格。
+
+所以三档模式必须搭配一组小说创作工作流 Skill。
+
+### 2. 当前已有 Skill 的边界
+
+当前软件已经有去 AI 味 Skill。
+
+它适合处理:
+
+- 删除 AI 腔。
+- 打破机械句式。
+- 让文字更自然。
+- 保留剧情事实和人物关系。
+
+但它不适合承担全部小说工作流。
+
+去 AI 味 Skill 解决的是“文字表达问题”,不是“故事结构问题”。如果只依赖去 AI 味 Skill,AI 可能写得更自然,但仍然会出现剧情空、人物动机弱、伏笔丢失、章节断层等问题。
+
+### 3. 建议补充的核心 Skill
+
+建议补充以下 Skill:
+
+- 章节承接 Skill:检查上一章结尾、人物状态、未解决事件和必须承接的情绪。
+- 下一章计划 Skill:生成本章目标、冲突、人物、关键事件、伏笔和结尾钩子。
+- 主线检查 Skill:确认本章是否服务于主线,避免漂亮但无效的内容。
+- 人物动机 Skill:检查人物行为是否合理,避免角色像工具人。
+- 冲突升级 Skill:保证本章有矛盾、选择、代价和压力。
+- 伏笔管理 Skill:判断哪些伏笔该推进、隐藏、回收或暂缓。
+- 节奏检查 Skill:避免章节过慢、过散、场景堆叠或中段塌陷。
+- 结尾钩子 Skill:提供悬念、反转、危机升级、情绪余韵等结尾方案。
+- 剧情自检 Skill:正文生成后检查承接、主线、动机、冲突、伏笔和风格。
+- 正文输出协议 Skill:保证最终回复只输出正文,不混入过程。
+
+### 4. 三档模式分别加载哪些 Skill
+
+快速模式:
+
+```text
+正文输出协议 Skill
++ 可选去 AI 味 Skill
+```
+
+标准模式:
+
+```text
+章节承接 Skill
++ 下一章计划 Skill
++ 人物动机 Skill
++ 冲突升级 Skill
++ 剧情自检 Skill
++ 正文输出协议 Skill
++ 去 AI 味 Skill
+```
+
+严格模式:
+
+```text
+章节承接 Skill
++ 主线检查 Skill
++ 下一章计划 Skill
++ 人物动机 Skill
++ 冲突升级 Skill
++ 伏笔管理 Skill
++ 节奏检查 Skill
++ 结尾钩子 Skill
++ 剧情自检 Skill
++ 正文输出协议 Skill
++ 去 AI 味 Skill
+```
+
+### 5. Skill 不能无脑全加载
+
+Skill 不是越多越好。
+
+如果每次会话都加载全部 Skill,会带来几个问题:
+
+- 上下文变重。
+- 回复变啰嗦。
+- AI 容易把分析混入正文。
+- 简单任务变慢。
+- 用户会觉得流程太繁琐。
+
+正确方式应该是:
+
+```text
+用户意图
+→ 三档模式
+→ classification 路由
+→ 自动选择本次需要的 Skill
+→ 执行创作流程
+→ 最终正文和过程分离
+```
+
+## 六、AI 会话外部搜索工具层
+
+### 1. 当前是否有外部搜索能力
+
+当前 AI 会话里没有真正的外部搜索工具。
+
+现有工具中有 `search_chapters`,但它只面向本地章节搜索,不是联网搜索。并且当前实现还是基础占位,只提示 AI 结合章节列表进一步读取相关章节全文。
+
+因此,当用户在 AI 会话中说:
+
+```text
+你去搜索一下关于黄蓉的信息
+```
+
+当前软件不会真正联网搜索黄蓉。它最多可能:
+
+- 根据模型自身已有知识回答。
+- 在本地章节中搜索“黄蓉”。
+- 如果本地项目没有相关资料,就无法获取外部内容。
+
+这会带来误导:用户以为 AI 已经搜索了外部资料,但实际可能只是模型凭记忆回答。
+
+### 2. 为什么外部搜索要独立出来
+
+外部搜索不应该混在 MCP 里一起处理。
+
+原因是:
+
+- 搜索是普通用户最容易理解的能力。
+- 搜索可以先复用现有 Web Search 配置。
+- 搜索是只读能力,安全风险低于 MCP 写入工具。
+- graphRAG、knowledgeGraph 更偏外部知识系统,搜索更偏公开网页资料。
+
+所以外部搜索应作为 AI Capability 能力层中的独立能力:
+
+```text
+Built-in Tools
+User Skills
+Web Search Tools
+MCP Tools
+```
+
+### 3. 建议新增的搜索工具
+
+建议给 AI 会话新增三个工具。
+
+#### web_search
+
+用途:搜索公开网页资料。
+
+输入:
+
+- 查询关键词。
+- 可选语言。
+- 可选搜索数量。
+- 可选搜索范围。
+
+输出:
+
+- 标题。
+- 摘要。
+- 链接。
+- 来源。
+- 时间。
+
+权限:
+
+```text
+read,自动执行。
+```
+
+#### read_web_page
+
+用途:读取指定 URL 的正文内容。
+
+输入:
+
+- URL。
+- 可选最大字符数。
+
+输出:
+
+- 页面标题。
+- 提取正文。
+- 来源链接。
+- 截断说明。
+
+权限:
+
+```text
+read,自动执行,但需要限制单次读取数量和长度。
+```
+
+#### summarize_search_results
+
+用途:把搜索结果压缩成适合进入 AI 会话上下文的资料块。
+
+输入:
+
+- 搜索结果列表。
+- 用户问题。
+- 目标用途。
+
+输出:
+
+- 关键信息。
+- 来源列表。
+- 不确定内容。
+- 不应使用的低可信信息。
+
+权限:
+
+```text
+action,自动执行。
+```
+
+### 4. 搜索工具与三档模式的关系
+
+快速模式:
+
+- 只有用户明确说“搜索、联网查、查一下资料”时才调用。
+- 默认不主动搜索。
+- 搜索结果只做简短摘要。
+
+标准模式:
+
+- 用户问外部人物、典故、真实资料、同人角色资料时可以调用。
+- 搜索后应说明资料来源。
+- 不把搜索结果自动写入项目资料库。
+
+严格模式:
+
+- 涉及真实资料、历史人物、同人角色、已有 IP 人物、外部设定时,应主动建议搜索。
+- 搜索结果需要进入上下文追踪。
+- 若用户要把搜索结果保存为设定、记忆或资料,必须确认后写入。
+
+### 5. 搜索工具与写入安全
+
+外部搜索本身是只读能力,可以自动执行。
+
+但以下行为必须确认:
+
+- 把搜索结果写入项目资料库。
+- 把搜索结果写入记忆。
+- 把搜索结果写入大纲。
+- 用搜索结果覆盖已有设定。
+- 把搜索结果加入 retrieval、伏笔、时间线或图谱。
+
+也就是说:
+
+```text
+搜索可以自动执行,保存必须用户确认。
+```
+
+### 6. 搜索工具与上下文追踪
+
+AI 会话应在“创作过程”或“生成详情”中记录:
+
+- 是否使用外部搜索。
+- 搜索关键词。
+- 使用的搜索提供商。
+- 返回了多少条结果。
+- 实际采用了哪些结果。
+- 哪些结果被排除。
+- 是否读取了网页正文。
+- 搜索失败或未配置搜索时的降级原因。
+
+这样用户能分清:
+
+```text
+AI 是真的搜索了资料,还是仅根据模型已有知识回答。
+```
+
+### 7. 搜索失败时的降级
+
+如果用户要求搜索,但搜索未配置或失败,软件不应假装已经搜索。
+
+应明确提示:
+
+```text
+当前未配置外部搜索,无法联网查询。我可以基于模型已有知识回答,或你可以先配置 Web Search 后重试。
+```
+
+如果搜索 API 超时或报错,应提示:
+
+```text
+外部搜索失败,本次未使用联网资料。
+```
+
+## 七、MCP 与用户自定义 Skill 的承接设计
+
+### 1. 当前是否已经预留位置
+
+当前 AI 会话已经有一部分承接基础,但还不完整。
+
+已经具备的基础:
+
+- `AgentRunner`:可以跑工具调用循环。
+- `ToolRegistry`:可以注册新工具。
+- `Tool` 抽象:已有 `read`、`write`、`action`、`virtual` 分类。
+- `apply_skill` 工具:可以读取当前去 AI 味 Skill 内容。
+- 工具调用追踪:可以展示工具执行过程。
+- 写入确认机制:写入类工具需要确认。
+
+这些说明当前架构不是封闭的,后续接 MCP 和用户 Skill 有基础。
+
+但当前还缺少:
+
+- 通用 Skill 类型系统。
+- 用户上传 Skill 的通用管理。
+- Skill 适用阶段配置。
+- Skill 与快速 / 标准 / 严格模式的绑定。
+- Skill 与 classification 路由的绑定。
+- MCP Server 配置管理。
+- MCP 工具 schema 转换层。
+- MCP 工具权限分类。
+- MCP 调用失败后的降级策略。
+
+所以结论是:
+
+```text
+当前有 Agent 工具层扩展基础,但还没有完整的 MCP 和通用 Skill 接口层。
+```
+
+### 2. 用户上传写作 Skill 能否使用
+
+例如用户上传一个写小说 Skill:
+
+```text
+三翻四抖:
+三次转折,四次震惊。
+每一章至少设置三次局势变化,四次信息冲击。
+```
+
+当前低配方式可以使用:
+
+```text
+用户上传 Skill
+→ AI 会话通过 apply_skill 读取模板
+→ 模型按模板尝试生成
+```
+
+但这还不够产品化。
+
+当前限制:
+
+- 它会被当成去 AI 味 Skill 一类。
+- 没有 Skill 类型区分。
+- 没有绑定到“计划阶段”“正文生成阶段”“自检阶段”。
+- 没有按快速、标准、严格自动选择。
+- 不能稳定组合多个 Skill 形成工作流。
+- UI 上用户可能误解,因为入口仍偏“去 AI 味”。
+
+更合理的方式是把 Skill 升级成通用结构:
+
+```ts
+type SkillKind =
+  | "style"
+  | "structure"
+  | "planning"
+  | "review"
+  | "rewrite"
+  | "output"
+  | "knowledge"
+```
+
+“三翻四抖”应该属于:
+
+```text
+structure + planning
+```
+
+它应该在章节计划阶段和剧情自检阶段使用,而不是只在最后润色阶段使用。
+
+### 3. MCP 能否承接 graphRAG / knowledgeGraph
+
+当前不能直接承接,但 Agent 工具层可以改造成承接 MCP。
+
+正确方式是增加 MCP Adapter:
+
+```text
+MCP Server
+→ MCP Adapter
+→ QMai Tool
+→ ToolRegistry
+→ AgentRunner
+→ AI 会话调用
+→ 工具追踪展示
+```
+
+例如 graphRAG 或 knowledgeGraph 可以映射成这些工具:
+
+```text
+search_knowledge_graph
+query_graph_rag
+get_character_relation
+find_plot_conflict
+lookup_world_setting
+get_timeline_event
+find_foreshadowing_nodes
+```
+
+这些工具大多数属于 `read` 或 `action`。
+
+如果 MCP 工具会写入外部知识库、更新图谱、修改记忆,则必须属于 `write`,并且走确认。
+
+### 4. 推荐的统一能力层
+
+后续不要把内置工具、用户 Skill、MCP 分散处理,建议抽象成统一能力层:
+
+```text
+AI Capability 能力层
+```
+
+能力层包含三类:
+
+```text
+1. Built-in Tools
+   章节、大纲、记忆、搜索、写入
+
+2. User Skills
+   用户上传的写作方法、剧情模板、风格规则
+
+3. Web Search Tools
+   外部搜索、网页读取、搜索结果摘要
+
+4. MCP Tools
+   graphRAG、knowledgeGraph、外部检索、外部资料库
+```
+
+AI 会话流程变成:
+
+```text
+用户输入
+→ 意图识别
+→ 快速 / 标准 / 严格模式
+→ classification 路由
+→ 选择 Skill
+→ 选择是否外部搜索
+→ 选择 MCP / 内置工具
+→ 执行 Agent 流程
+→ 过程追踪
+→ 写入确认
+```
+
+### 5. MCP 和 Skill 的权限边界
+
+用户 Skill 只能提供规则、模板和写作方法,不能直接绕过保存确认。
+
+MCP 工具必须按权限分类:
+
+| MCP 类型 | 示例 | 权限 |
+| --- | --- | --- |
+| 只读检索 | graphRAG 查询、知识图谱查询 | 自动执行 |
+| 分析计算 | 关系分析、冲突分析、时间线检查 | 自动执行 |
+| 生成建议 | 生成剧情方案、生成结构建议 | 自动执行 |
+| 写入修改 | 写入图谱、更新记忆、修改外部库 | 必须确认 |
+| 高风险操作 | 删除节点、覆盖资料、批量改写 | 必须确认并强提示 |
+
+任何外部能力都不能突破软件已有安全边界。
+
+### 6. 在三档模式中的 MCP 使用规则
+
+快速模式:
+
+- 默认不主动调用 MCP。
+- 用户明确要求查资料时才调用。
+- 只调用低成本、只读类 MCP。
+
+标准模式:
+
+- 写下一章、分析剧情、检查人物关系时,可以调用必要 MCP。
+- 优先调用与当前任务相关的知识工具。
+- 调用结果需要压缩后进入上下文。
+
+严格模式:
+
+- 关键章节、主线重构、长篇一致性检查时,应主动使用 graphRAG / knowledgeGraph。
+- 需要记录调用来源、命中内容和未使用内容。
+- 写入类 MCP 必须进入确认流程。
+
+### 7. 最终判断
+
+当前 AI 会话能承接未来 MCP 和用户 Skill,但需要补一个正式接口层。
+
+最推荐的路线:
+
+```text
+第一步:把去 AI 味 Skill 升级为通用 Skill 基础结构
+第二步:给 Skill 增加类型、适用阶段、适用模式
+第三步:增加 AI Capability 能力层
+第四步:把 MCP Tool 通过 Adapter 注册进 ToolRegistry
+第五步:增加 AI 会话外部搜索工具
+第六步:让 classification 路由决定本次启用哪些 Skill、外部搜索和 MCP
+第七步:把 Skill / 外部搜索 / MCP 使用过程写入上下文追踪
+```
+
+这样 AI 会话才能真正承接:
+
+- 用户上传的写作 Skill。
+- 三翻四抖这类剧情结构 Skill。
+- 外部搜索。
+- graphRAG。
+- knowledgeGraph。
+- 外部资料库。
+- 后续更多创作工具。
+
+## 八、面向小说小白最应该补的功能
+
+### 1. 章节创作计划
+
+当用户说“帮我写下一章”时,AI 先生成:
+
+- 本章目标。
+- 本章冲突。
+- 出场人物。
+- 人物动机。
+- 关键事件。
+- 情绪变化。
+- 伏笔推进。
+- 结尾钩子。
+- 不能违背的设定。
+- 预计字数。
+
+用户确认后再写正文。
+
+### 2. 故事主线检查
+
+用户可以问:
+
+```text
+我现在的故事主线清楚吗?
+```
+
+AI 应返回:
+
+- 当前主线。
+- 主角目标。
+- 主要阻碍。
+- 反派或压力来源。
+- 阶段性目标。
+- 当前最大问题。
+- 下一步建议。
+
+### 3. 章节承接检查
+
+写下一章前,AI 应分析:
+
+- 上一章结尾发生了什么。
+- 当前章节必须承接什么。
+- 哪些人物状态不能丢。
+- 哪些伏笔不能忘。
+- 哪些情绪需要延续。
+
+### 4. 人物动机检查
+
+AI 应检查:
+
+- 人物为什么这样做。
+- 人物此刻想要什么。
+- 人物害怕失去什么。
+- 行为是否符合前文。
+- 是否像工具人。
+
+### 5. 冲突强度检查
+
+AI 应检查:
+
+- 本章有没有冲突。
+- 冲突是否升级。
+- 主角是否面临选择。
+- 主角是否付出代价。
+- 读者是否有继续阅读的动力。
+
+### 6. 伏笔管理
+
+AI 应提示:
+
+- 本章适合推进哪些伏笔。
+- 哪些伏笔太久没处理。
+- 哪些伏笔不该提前揭示。
+- 哪些伏笔已经可以回收。
+
+### 7. 结尾钩子建议
+
+AI 可以提供不同结尾方案:
+
+- 强悬念结尾。
+- 情绪余韵结尾。
+- 反转结尾。
+- 危机升级结尾。
+- 人物关系变化结尾。
+
+用户选择后再生成正文。
+
+## 九、推荐的 AI 会话完整流程
+
+推荐把“生成下一章”设计成以下流程:
+
+```text
+1. 用户输入:帮我写下一章
+
+2. 任务识别
+   - 判断是写章节、改章节、生成大纲还是分析剧情
+
+3. 模式判断
+   - 快速:少步骤,直接产出
+   - 标准:先短计划,再正文
+   - 严格:完整分析,确认后执行
+
+4. 能力选择
+   - 根据 classification 路由选择资料
+   - 根据任务选择 Skill
+   - 根据任务选择是否外部搜索
+   - 根据任务选择 MCP / 内置工具
+
+5. 资料读取
+   - 读取 soul.md
+   - 读取大纲
+   - 读取上一章
+   - 读取人物状态
+   - 读取伏笔和时间线
+   - 必要时调用外部搜索
+   - 必要时调用 graphRAG / knowledgeGraph
+
+6. 剧情分析
+   - 分析上一章结尾
+   - 分析当前主线
+   - 分析人物动机
+   - 分析本章应推进的冲突
+
+7. 生成创作计划
+   - 本章目标
+   - 冲突
+   - 场景顺序
+   - 情绪曲线
+   - 伏笔安排
+   - 结尾钩子
+
+8. 用户确认计划
+   - 确认写作
+   - 修改计划
+   - 重新生成
+   - 跳过计划
+
+9. 正文生成
+   - 只输出章节标题和正文
+   - 不输出分析废话
+
+10. 写后自检
+    - 剧情承接
+    - 人物动机
+    - 伏笔
+    - 节奏
+    - soul.md 风格
+    - 是否可保存
+
+11. 保存确认
+    - 用户确认后进入章节库
+    - 取消则只保留在会话中
+```
+
+## 十、后续优先级建议
+
+### 第一优先级:章节生成先出计划
+
+这是最适合小说小白的能力。
+
+它能解决:
+
+- AI 容易跑偏。
+- 新手不知道下一章怎么推进。
+- 生成内容漂亮但剧情空。
+
+### 第二优先级:写后剧情自检
+
+让 AI 像 Codex 跑测试一样,对章节做剧情验证。
+
+建议自检项:
+
+- 剧情承接。
+- 人物动机。
+- 冲突强度。
+- 节奏。
+- 伏笔。
+- `soul.md` 风格。
+
+### 第三优先级:通用 Skill 体系
+
+当前 Skill 偏去 AI 味,后续需要升级为通用 Skill:
+
+- 用户可上传。
+- 可分类。
+- 可绑定任务阶段。
+- 可绑定三档模式。
+- 可被 classification 路由选择。
+
+### 第四优先级:AI 会话外部搜索工具
+
+当前 AI 会话没有真正的联网搜索能力。
+
+建议先新增:
+
+- `web_search`。
+- `read_web_page`。
+- `summarize_search_results`。
+
+并接入现有 Web Search 配置,让用户明确知道本次是否真正搜索了外部资料。
+
+### 第五优先级:MCP Adapter
+
+为了承接 graphRAG、knowledgeGraph 和外部资料库,需要 MCP Adapter:
+
+- 连接 MCP Server。
+- 读取 MCP 工具 schema。
+- 转换为 QMai Tool。
+- 注册到 ToolRegistry。
+- 加入工具追踪。
+- 写入类 MCP 走确认。
+
+### 第六优先级:classification.md 创建和编辑入口
+
+当前普通用户很难找到或创建 `classification.md`。
+
+建议增加:
+
+- 创建默认 classification.md。
+- 打开 classification.md。
+- 检查格式。
+- 恢复默认。
+- 一键升级。
+
+### 第七优先级:创作过程和正文分离
+
+最终正文应干净可用。
+
+分析、计划、自检、资料读取、Skill 使用、外部搜索、MCP 调用过程应该放到折叠区域,不混入正文。
+
+## 十一、总结
+
+当前 AI 会话已经具备 Agent 工具调用、上下文追踪、写入确认、classification 路由、结果校验等基础能力。
+
+下一步最值得完善的是:
+
+1. 把深度模式升级为快速、标准、严格三档执行模式。
+2. 把去 AI 味 Skill 升级为通用 Skill 体系。
+3. 给用户上传 Skill 增加类型、阶段、模式和路由规则。
+4. 增加 AI 会话外部搜索工具,避免用户误以为模型记忆就是搜索结果。
+5. 增加 MCP Adapter,让 graphRAG、knowledgeGraph 等外部能力能进入 AI 会话。
+6. 所有 Skill、外部搜索和 MCP 使用过程都进入上下文追踪。
+7. 所有写入类操作继续保持用户确认。
+
+对小说软件来说,最终目标不是让 AI 变成“回答机器”,而是变成一个能帮助普通用户梳理故事、规划剧情、调用资料、搜索外部信息、使用技能、生成正文、检查质量、确认保存的小说创作代理。

+ 677 - 0
LICENSE

@@ -0,0 +1,677 @@
+QMAI (青幕AI写作) — Copyright (C) 2024-2026 Mochocyang
+Based on LLM Wiki — Copyright (C) 2024-2026 Yong Su
+
+                    GNU GENERAL PUBLIC LICENSE
+                       Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+                            Preamble
+
+  The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
+
+  The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works.  By contrast,
+the GNU General Public License is intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.  We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors.  You can apply it to
+your programs, too.
+
+  When we speak of free software, we are referring to freedom, not
+price.  Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+  To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights.  Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
+
+  For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received.  You must make sure that they, too, receive
+or can get the source code.  And you must show them these terms so they
+know their rights.
+
+  Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+  For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software.  For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+  Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so.  This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software.  The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable.  Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products.  If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
+
+  Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary.  To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
+
+  The precise terms and conditions for copying, distribution and
+modification follow.
+
+                       TERMS AND CONDITIONS
+
+  0. Definitions.
+
+  "This License" refers to version 3 of the GNU General Public License.
+
+  "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+  "The Program" refers to any copyrightable work licensed under this
+License.  Each licensee is addressed as "you".  "Licensees" and
+"recipients" may be individuals or organizations.
+
+  To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy.  The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+  A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+  To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy.  Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+  To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies.  Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+  An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License.  If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+  1. Source Code.
+
+  The "source code" for a work means the preferred form of the work
+for making modifications to it.  "Object code" means any non-source
+form of a work.
+
+  A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+  The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form.  A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+  The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities.  However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work.  For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+  The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+  The Corresponding Source for a work in source code form is that
+same work.
+
+  2. Basic Permissions.
+
+  All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met.  This License explicitly affirms your unlimited
+permission to run the unmodified Program.  The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work.  This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+  You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force.  You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright.  Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+  Conveying under any other circumstances is permitted solely under
+the conditions stated below.  Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+  3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+  No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+  When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+  4. Conveying Verbatim Copies.
+
+  You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+  You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+  5. Conveying Modified Source Versions.
+
+  You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+    a) The work must carry prominent notices stating that you modified
+    it, and giving a relevant date.
+
+    b) The work must carry prominent notices stating that it is
+    released under this License and any conditions added under section
+    7.  This requirement modifies the requirement in section 4 to
+    "keep intact all notices".
+
+    c) You must license the entire work, as a whole, under this
+    License to anyone who comes into possession of a copy.  This
+    License will therefore apply, along with any applicable section 7
+    additional terms, to the whole of the work, and all its parts,
+    regardless of how they are packaged.  This License gives no
+    permission to license the work in any other way, but it does not
+    invalidate such permission if you have separately received it.
+
+    d) If the work has interactive user interfaces, each must display
+    Appropriate Legal Notices; however, if the Program has interactive
+    interfaces that do not display Appropriate Legal Notices, your
+    work need not make them do so.
+
+  A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit.  Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+  6. Conveying Non-Source Forms.
+
+  You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+    a) Convey the object code in, or embodied in, a physical product
+    (including a physical distribution medium), accompanied by the
+    Corresponding Source fixed on a durable physical medium
+    customarily used for software interchange.
+
+    b) Convey the object code in, or embodied in, a physical product
+    (including a physical distribution medium), accompanied by a
+    written offer, valid for at least three years and valid for as
+    long as you offer spare parts or customer support for that product
+    model, to give anyone who possesses the object code either (1) a
+    copy of the Corresponding Source for all the software in the
+    product that is covered by this License, on a durable physical
+    medium customarily used for software interchange, for a price no
+    more than your reasonable cost of physically performing this
+    conveying of source, or (2) access to copy the
+    Corresponding Source from a network server at no charge.
+
+    c) Convey individual copies of the object code with a copy of the
+    written offer to provide the Corresponding Source.  This
+    alternative is allowed only occasionally and noncommercially, and
+    only if you received the object code with such an offer, in accord
+    with subsection 6b.
+
+    d) Convey the object code by offering access from a designated
+    place (gratis or for a charge), and offer equivalent access to the
+    Corresponding Source in the same way through the same place at no
+    further charge.  You need not require recipients to copy the
+    Corresponding Source along with the object code.  If the place to
+    copy the object code is a network server, the Corresponding Source
+    may be on a different server (operated by you or a third party)
+    that supports equivalent copying facilities, provided you maintain
+    clear directions next to the object code saying where to find the
+    Corresponding Source.  Regardless of what server hosts the
+    Corresponding Source, you remain obligated to ensure that it is
+    available for as long as needed to satisfy these requirements.
+
+    e) Convey the object code using peer-to-peer transmission, provided
+    you inform other peers where the object code and Corresponding
+    Source of the work are being offered to the general public at no
+    charge under subsection 6d.
+
+  A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+  A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling.  In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage.  For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product.  A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+  "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source.  The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+  If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information.  But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+  The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed.  Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+  Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+  7. Additional Terms.
+
+  "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law.  If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+  When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it.  (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.)  You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+  Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+    a) Disclaiming warranty or limiting liability differently from the
+    terms of sections 15 and 16 of this License; or
+
+    b) Requiring preservation of specified reasonable legal notices or
+    author attributions in that material or in the Appropriate Legal
+    Notices displayed by works containing it; or
+
+    c) Prohibiting misrepresentation of the origin of that material, or
+    requiring that modified versions of such material be marked in
+    reasonable ways as different from the original version; or
+
+    d) Limiting the use for publicity purposes of names of licensors or
+    authors of the material; or
+
+    e) Declining to grant rights under trademark law for use of some
+    trade names, trademarks, or service marks; or
+
+    f) Requiring indemnification of licensors and authors of that
+    material by anyone who conveys the material (or modified versions of
+    it) with contractual assumptions of liability to the recipient, for
+    any liability that these contractual assumptions directly impose on
+    those licensors and authors.
+
+  All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10.  If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term.  If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+  If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+  Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+  8. Termination.
+
+  You may not propagate or modify a covered work except as expressly
+provided under this License.  Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+  However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+  Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+  Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License.  If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+  9. Acceptance Not Required for Having Copies.
+
+  You are not required to accept this License in order to receive or
+run a copy of the Program.  Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance.  However,
+nothing other than this License grants you permission to propagate or
+modify any covered work.  These actions infringe copyright if you do
+not accept this License.  Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+  10. Automatic Licensing of Downstream Recipients.
+
+  Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License.  You are not responsible
+for enforcing compliance by third parties with this License.
+
+  An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations.  If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+  You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License.  For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+  11. Patents.
+
+  A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based.  The
+work thus licensed is called the contributor's "contributor version".
+
+  A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version.  For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+  Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+  In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement).  To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+  If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients.  "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+  If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+  A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License.  You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+  Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+  12. No Surrender of Others' Freedom.
+
+  If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License.  If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all.  For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+  13. Use with the GNU Affero General Public License.
+
+  Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU Affero General Public License into a single
+combined work, and to convey the resulting work.  The terms of this
+License will continue to apply to the part which is the covered work,
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+  14. Revised Versions of this License.
+
+  The Free Software Foundation may publish revised and/or new versions of
+the GNU General Public License from time to time.  Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+  Each version is given a distinguishing version number.  If the
+Program specifies that a certain numbered version of the GNU General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation.  If the Program does not specify a version number of the
+GNU General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+  If the Program specifies that a proxy can decide which future
+versions of the GNU General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+  Later license versions may give you additional or different
+permissions.  However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+  15. Disclaimer of Warranty.
+
+  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+  16. Limitation of Liability.
+
+  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+  17. Interpretation of Sections 15 and 16.
+
+  If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+                     END OF TERMS AND CONDITIONS
+
+            How to Apply These Terms to Your New Programs
+
+  If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+  To do so, attach the following notices to the program.  It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+    <one line to give the program's name and a brief idea of what it does.>
+    Copyright (C) <year>  <name of author>
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with this program.  If not, see <https://www.gnu.org/licenses/>.
+
+Also add information on how to contact you by electronic and paper mail.
+
+  If the program does terminal interaction, make it output a short
+notice like this when it starts in an interactive mode:
+
+    <program>  Copyright (C) <year>  <name of author>
+    This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+    This is free software, and you are welcome to redistribute it
+    under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License.  Of course, your program's commands
+might be different; for a GUI interface, you would use an "about box".
+
+  You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU GPL, see
+<https://www.gnu.org/licenses/>.
+
+  The GNU General Public License does not permit incorporating your program
+into proprietary programs.  If your program is a subroutine library, you
+may consider it more useful to permit linking proprietary applications with
+the library.  If this is what you want to do, use the GNU Lesser General
+Public License instead of this License.  But first, please read
+<https://www.gnu.org/licenses/why-not-lgpl.html>.


+ 18 - 4
README.md

@@ -1,5 +1,5 @@
 <p align="center">
-  <img src="QM-LOGO.png" width="120" alt="青幕AI写作 Logo" />
+  <img src="src/assets/QM-LOGO.png" width="120" alt="青幕AI写作 Logo" />
 </p>
 
 <h1 align="center">青幕AI写作(QMAI)</h1>
@@ -12,9 +12,9 @@
   <a href="https://github.com/Mochocyang/QMAI/releases">
     <img src="https://img.shields.io/github/v/release/Mochocyang/QMAI?style=flat-square" alt="Release" />
   </a>
-  <img src="https://img.shields.io/badge/version-2.2.22-blue?style=flat-square" alt="Version" />
+  <img src="https://img.shields.io/badge/version-2.2.32-blue?style=flat-square" alt="Version" />
   <img src="https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-blue?style=flat-square" alt="Platform" />
-  <img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="License" />
+  <img src="https://img.shields.io/badge/license-GPL--3.0-blue?style=flat-square" alt="License" />
 </p>
 
 ---
@@ -347,6 +347,20 @@ AI 生成的章节默认为草稿状态。草稿支持预览、编辑、重新
 
 前往 [GitHub Releases](https://github.com/Mochocyang/QMAI/releases) 下载最新版本安装包。
 
+**macOS 用户须知**
+
+Release 包未做 Apple 公证,首次打开可能提示「已损坏」或无法运行。将 `.app` 拖入「应用程序」后,在终端执行:
+
+```bash
+codesign --force --deep --sign - /Applications/QMaiWrite.app
+xattr -dr com.apple.quarantine /Applications/QMaiWrite.app
+```
+
+- 第一行:用 ad-hoc 签名让系统允许本地运行
+- 第二行:移除下载时附加的隔离标记
+
+macOS 暂不支持应用内自动更新,新版本请手动从 Releases 下载安装。
+
 ### 基本使用流程
 
 1. **新建小说项目**:指定项目名称、题材、目标字数
@@ -452,7 +466,7 @@ npm run build:github-release
 
 ## 许可证
 
-本项目采用 [MIT License](LICENSE) 开源许可证
+本项目采用 [GNU General Public License v3.0](LICENSE) 开源许可证(基于 [LLM Wiki](https://github.com/nashsu/llm_wiki) 衍生开发)
 
 ---
 

+ 0 - 22
cangzhuseceshiban-分支说明.md

@@ -1,22 +0,0 @@
-# 东方美学配色测试版分支说明
-
-## 使用要求
-
-- 本分支新增「东方美学配色测试版」全局视觉风格选择。
-- 设置页保留两层选择:第一层仍是自动、浅色、深色、深蓝护眼;第二层是视觉风格。
-- 原有经典配色必须保留,可以在设置中切回「经典原版」。
-- 默认视觉风格为经典原版。
-- 东方美学方案按主题色、背景色、辅助色三色关系设计。
-- 不修改 AI、文件同步、模型配置、项目数据等业务逻辑。
-
-## 更新记录
-
-- 20260630:开始实现苍苍竹色测试版视觉风格。
-- 20260630:新增视觉风格状态、设置页入口、持久化、启动加载应用和 `visual-cangzhu` 独立 CSS 变量层;原经典配色保持可切回。
-- 20260630:完成 `test:mocks`、`typecheck`、`build`、`build:portable` 和源码 dev server 冒烟验证。
-- 20260630:按「东方美学配色方案.html」继续加入天青釉色、青瓷墨韵、云山黛色、苍苍竹色、月白黛蓝、古墨流金 6 套视觉风格;经典原版仍为默认值。
-- 20260630:完成新增多方案后的 `test:mocks`、`typecheck`、`build`、`build:portable` 和源码 dev server 冒烟验证。
-
-## 提交状态
-
-- 未提交。

+ 0 - 1066
docs/superpowers/plans/2026-06-29-de-ai-skill-library.md

@@ -1,1066 +0,0 @@
-# De-AI Skill Library Implementation Plan
-
-> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** Build a first-version "技能库" that manages multiple de-AI skills and lets AI chat, whole-chapter de-AI, and selected-text de-AI call the chosen skill.
-
-**Architecture:** Add a focused de-AI skill library module responsible for built-in templates, project config read/write, legacy `de-ai-skill.txt` compatibility, and skill resolution. Add a new top-level view for managing skills, then wire that resolved skill into the existing chat and chapter rewrite flows without changing unrelated writing workflows.
-
-**Tech Stack:** React, TypeScript, Zustand, Vitest, Tauri file commands, existing local i18n JSON, existing sidebar preference system.
-
----
-
-## File Structure
-
-- Create `src/lib/novel/de-ai-skill-library.ts`: typed de-AI skill model, built-in templates, project config normalization, file read/write, available/default skill resolution.
-- Create `src/lib/novel/de-ai-skill-library.spec.ts`: unit tests for config normalization, legacy compatibility, disabled skill behavior, default fallback, create/update/delete helpers.
-- Create `src/components/skill-library/skill-library-view.tsx`: "技能库" page with left skill list and right editor.
-- Create `src/components/skill-library/de-ai-skill-picker.tsx`: reusable popover/dropdown for AI chat and chapter actions.
-- Create `src/components/skill-library/skill-library-view.spec.tsx`: component-level tests for create/edit/delete/toggle/default/copy.
-- Modify `src/lib/novel/de-ai-adapter.ts`: accept resolved skill content and provide helpers for skill-based system message construction.
-- Modify `src/stores/chat-store.ts`: add per-conversation `selectedDeAiSkillId?: string | null` and setter.
-- Modify `src/lib/persist.ts`: normalize loaded conversations so old data gets `selectedDeAiSkillId: undefined`.
-- Modify `src/components/chat/chat-panel.tsx`: add input-bottom skill icon and inject selected/project-default skill as a system rule.
-- Modify `src/components/layout/preview-panel.tsx`: replace immediate whole-chapter and selected-text de-AI calls with skill picker selection, then call existing preview flows.
-- Modify `src/lib/sidebar-nav-preferences.ts`: add `skillLibrary` to configurable nav order.
-- Modify `src/stores/wiki-store.ts`: add active view `"skillLibrary"`.
-- Modify `src/components/layout/icon-sidebar.tsx`: add skill library icon item.
-- Modify `src/components/layout/content-area.tsx`: lazy-load `SkillLibraryView`.
-- Modify `src/components/settings/sections/interface-section.tsx`: include skill library in left sidebar visibility settings through existing label map.
-- Modify `src/i18n/zh.json` and `src/i18n/en.json`: add labels and user-facing Chinese/English strings.
-
-## Task 1: De-AI Skill Library Data Layer
-
-**Files:**
-- Create: `src/lib/novel/de-ai-skill-library.ts`
-- Create: `src/lib/novel/de-ai-skill-library.spec.ts`
-- Modify: `src/lib/novel/de-ai-adapter.ts`
-
-- [ ] **Step 1: Write failing tests for default built-ins and config fallback**
-
-Create `src/lib/novel/de-ai-skill-library.spec.ts` with these cases:
-
-```ts
-import { describe, expect, it, vi, beforeEach } from "vitest"
-import {
-  BUILT_IN_DE_AI_SKILLS,
-  DEFAULT_DE_AI_SKILL_ID,
-  normalizeDeAiSkillConfig,
-  resolveAvailableDeAiSkills,
-  resolveEffectiveDeAiSkill,
-} from "./de-ai-skill-library"
-
-describe("de-ai skill library", () => {
-  beforeEach(() => {
-    vi.restoreAllMocks()
-  })
-
-  it("ships five built-in de-AI skills", () => {
-    expect(BUILT_IN_DE_AI_SKILLS.map((skill) => skill.id)).toEqual([
-      "built-in:comprehensive",
-      "built-in:reduce-explanation",
-      "built-in:dialogue-natural",
-      "built-in:break-regularity",
-      "built-in:literary-retain",
-    ])
-  })
-
-  it("normalizes an empty config to the built-in comprehensive skill", () => {
-    expect(normalizeDeAiSkillConfig(null)).toEqual({
-      version: 1,
-      defaultSkillId: DEFAULT_DE_AI_SKILL_ID,
-      disabledSkillIds: [],
-      projectSkills: [],
-    })
-  })
-
-  it("filters disabled skills from available skills", () => {
-    const config = normalizeDeAiSkillConfig({
-      disabledSkillIds: ["built-in:comprehensive", "built-in:dialogue-natural"],
-    })
-    const available = resolveAvailableDeAiSkills(config)
-    expect(available.some((skill) => skill.id === "built-in:comprehensive")).toBe(false)
-    expect(available.some((skill) => skill.id === "built-in:reduce-explanation")).toBe(true)
-  })
-
-  it("falls back when selected skill is disabled", () => {
-    const config = normalizeDeAiSkillConfig({
-      defaultSkillId: "built-in:comprehensive",
-      disabledSkillIds: ["built-in:comprehensive"],
-    })
-    const skill = resolveEffectiveDeAiSkill(config, "built-in:comprehensive")
-    expect(skill?.id).toBe("built-in:reduce-explanation")
-  })
-})
-```
-
-- [ ] **Step 2: Run the failing test**
-
-Run:
-
-```bash
-npm run test:mocks -- src/lib/novel/de-ai-skill-library.spec.ts
-```
-
-Expected: FAIL because `src/lib/novel/de-ai-skill-library.ts` does not exist.
-
-- [ ] **Step 3: Implement the data module**
-
-Create `src/lib/novel/de-ai-skill-library.ts` with these exports:
-
-```ts
-import { readFile, writeFile } from "@/commands/fs"
-import { join } from "@tauri-apps/api/path"
-import defaultDeAiSkill from "../../../skills/de-ai-writing/SKILL.md?raw"
-
-export type DeAiSkillSource = "built-in" | "project" | "legacy"
-
-export interface DeAiSkill {
-  id: string
-  name: string
-  description: string
-  templateId: string
-  content: string
-  source: DeAiSkillSource
-  createdAt?: number
-  updatedAt?: number
-}
-
-export interface DeAiSkillConfig {
-  version: 1
-  defaultSkillId: string
-  disabledSkillIds: string[]
-  projectSkills: DeAiSkill[]
-}
-
-export const DEFAULT_DE_AI_SKILL_ID = "built-in:comprehensive"
-
-const baseSkill = defaultDeAiSkill.trim()
-
-export const BUILT_IN_DE_AI_SKILLS: DeAiSkill[] = [
-  {
-    id: "built-in:comprehensive",
-    name: "综合去AI味",
-    description: "综合减少解释腔、模板句式和机械总结。",
-    templateId: "comprehensive",
-    content: baseSkill,
-    source: "built-in",
-  },
-  {
-    id: "built-in:reduce-explanation",
-    name: "减少解释腔",
-    description: "重点删掉动机解释、情绪总结和重复说明。",
-    templateId: "reduce-explanation",
-    content: `${baseSkill}\n\n## 本技能重点\n优先删减解释腔、总结腔、过度说明和“他之所以这样做”的直白动机解释。`,
-    source: "built-in",
-  },
-  {
-    id: "built-in:dialogue-natural",
-    name: "对话口语化",
-    description: "让人物对话更像真人说话,减少书面腔。",
-    templateId: "dialogue-natural",
-    content: `${baseSkill}\n\n## 本技能重点\n优先处理对话,让人物说半句话、停顿、回避、打断和带有个人口癖。`,
-    source: "built-in",
-  },
-  {
-    id: "built-in:break-regularity",
-    name: "打破工整句式",
-    description: "打散整齐段落、排比句和模板化起承转合。",
-    templateId: "break-regularity",
-    content: `${baseSkill}\n\n## 本技能重点\n优先打破工整句式、固定段落长度、机械排比和连续相同主谓结构。`,
-    source: "built-in",
-  },
-  {
-    id: "built-in:literary-retain",
-    name: "保留文艺感",
-    description: "去除AI味时保留必要修辞、氛围和文学质感。",
-    templateId: "literary-retain",
-    content: `${baseSkill}\n\n## 本技能重点\n去除AI味时不要硬压缩到干瘪,保留必要意象、氛围、节奏和文艺表达。`,
-    source: "built-in",
-  },
-]
-
-const BUILT_IN_IDS = new Set(BUILT_IN_DE_AI_SKILLS.map((skill) => skill.id))
-
-function uniqueStrings(values: unknown): string[] {
-  if (!Array.isArray(values)) return []
-  const result: string[] = []
-  for (const value of values) {
-    if (typeof value === "string" && value.trim() && !result.includes(value)) {
-      result.push(value)
-    }
-  }
-  return result
-}
-
-function normalizeProjectSkill(value: unknown): DeAiSkill | null {
-  if (!value || typeof value !== "object") return null
-  const raw = value as Partial<DeAiSkill>
-  const id = typeof raw.id === "string" && raw.id.startsWith("project:") ? raw.id : `project:${Date.now()}`
-  const name = typeof raw.name === "string" ? raw.name.trim() : ""
-  const content = typeof raw.content === "string" ? raw.content.trim() : ""
-  if (!name || !content) return null
-  return {
-    id,
-    name,
-    description: typeof raw.description === "string" ? raw.description : "",
-    templateId: typeof raw.templateId === "string" ? raw.templateId : "custom",
-    content,
-    source: "project",
-    createdAt: typeof raw.createdAt === "number" ? raw.createdAt : Date.now(),
-    updatedAt: typeof raw.updatedAt === "number" ? raw.updatedAt : Date.now(),
-  }
-}
-
-export function normalizeDeAiSkillConfig(value: unknown): DeAiSkillConfig {
-  const raw = value && typeof value === "object" ? value as Partial<DeAiSkillConfig> : {}
-  const projectSkills = Array.isArray(raw.projectSkills)
-    ? raw.projectSkills.map(normalizeProjectSkill).filter((skill): skill is DeAiSkill => Boolean(skill))
-    : []
-  const disabledSkillIds = uniqueStrings(raw.disabledSkillIds)
-  const knownIds = new Set([...BUILT_IN_IDS, ...projectSkills.map((skill) => skill.id)])
-  const requestedDefault = typeof raw.defaultSkillId === "string" ? raw.defaultSkillId : DEFAULT_DE_AI_SKILL_ID
-  const defaultSkillId = knownIds.has(requestedDefault) ? requestedDefault : DEFAULT_DE_AI_SKILL_ID
-  return {
-    version: 1,
-    defaultSkillId,
-    disabledSkillIds,
-    projectSkills,
-  }
-}
-
-export function getAllDeAiSkills(config: DeAiSkillConfig): DeAiSkill[] {
-  return [...config.projectSkills, ...BUILT_IN_DE_AI_SKILLS]
-}
-
-export function resolveAvailableDeAiSkills(config: DeAiSkillConfig): DeAiSkill[] {
-  const disabled = new Set(config.disabledSkillIds)
-  return getAllDeAiSkills(config).filter((skill) => !disabled.has(skill.id))
-}
-
-export function resolveEffectiveDeAiSkill(
-  config: DeAiSkillConfig,
-  selectedSkillId?: string | null,
-): DeAiSkill | null {
-  if (selectedSkillId === null) return null
-  const available = resolveAvailableDeAiSkills(config)
-  if (available.length === 0) return null
-  const requested = selectedSkillId ?? config.defaultSkillId
-  return available.find((skill) => skill.id === requested) ?? available[0]
-}
-
-export async function loadDeAiSkillConfig(projectPath: string | null | undefined): Promise<DeAiSkillConfig> {
-  if (!projectPath) return normalizeDeAiSkillConfig(null)
-  try {
-    const configPath = await join(projectPath, "de-ai-skills.json")
-    const content = await readFile(configPath)
-    return normalizeDeAiSkillConfig(JSON.parse(content))
-  } catch {
-    try {
-      const legacyPath = await join(projectPath, "de-ai-skill.txt")
-      const legacyContent = (await readFile(legacyPath)).trim()
-      if (!legacyContent) return normalizeDeAiSkillConfig(null)
-      const legacySkill: DeAiSkill = {
-        id: "project:legacy-de-ai-skill",
-        name: "旧版自定义去AI味 Skill",
-        description: "从旧版 de-ai-skill.txt 读取的项目规则。",
-        templateId: "legacy",
-        content: legacyContent,
-        source: "legacy",
-      }
-      return normalizeDeAiSkillConfig({
-        defaultSkillId: legacySkill.id,
-        projectSkills: [legacySkill],
-      })
-    } catch {
-      return normalizeDeAiSkillConfig(null)
-    }
-  }
-}
-
-export async function saveDeAiSkillConfig(projectPath: string, config: DeAiSkillConfig): Promise<void> {
-  const configPath = await join(projectPath, "de-ai-skills.json")
-  await writeFile(configPath, JSON.stringify(normalizeDeAiSkillConfig(config), null, 2))
-}
-```
-
-- [ ] **Step 4: Update adapter to expose skill-based prompts**
-
-Modify `src/lib/novel/de-ai-adapter.ts` so existing callers can keep using `buildDeAiRewriteMessages(content, customSkill)`, and add:
-
-```ts
-export function buildDeAiSkillSystemPrompt(skillContent: string | null | undefined): string {
-  return buildQmQuaiSystemPrompt(skillContent || undefined)
-}
-```
-
-- [ ] **Step 5: Run the data tests**
-
-Run:
-
-```bash
-npm run test:mocks -- src/lib/novel/de-ai-skill-library.spec.ts
-```
-
-Expected: PASS.
-
-- [ ] **Step 6: Commit**
-
-```bash
-git add src/lib/novel/de-ai-skill-library.ts src/lib/novel/de-ai-skill-library.spec.ts src/lib/novel/de-ai-adapter.ts
-git commit -m "feat: add de-ai skill library data model"
-```
-
-## Task 2: Add Skill Library Navigation and Route
-
-**Files:**
-- Modify: `src/lib/sidebar-nav-preferences.ts`
-- Modify: `src/stores/wiki-store.ts`
-- Modify: `src/components/layout/icon-sidebar.tsx`
-- Modify: `src/components/layout/content-area.tsx`
-- Create: `src/components/skill-library/skill-library-view.tsx`
-- Modify: `src/i18n/zh.json`
-- Modify: `src/i18n/en.json`
-- Test: `src/lib/sidebar-nav-preferences.spec.ts`
-- Test: `src/components/settings/interface-sidebar-nav.spec.ts`
-
-- [ ] **Step 1: Write failing nav tests**
-
-Extend `src/lib/sidebar-nav-preferences.spec.ts` with:
-
-```ts
-it("includes skill library in the configurable sidebar order", () => {
-  expect(DEFAULT_SIDEBAR_NAV_ORDER).toContain("skillLibrary")
-  expect(normalizeSidebarNavConfig({ order: ["wiki"] }).order).toContain("skillLibrary")
-})
-```
-
-Extend `src/components/settings/interface-sidebar-nav.spec.ts` to assert the interface settings source references the label:
-
-```ts
-expect(interfaceSectionSource).toContain("skillLibrary")
-```
-
-- [ ] **Step 2: Run failing tests**
-
-Run:
-
-```bash
-npm run test:mocks -- src/lib/sidebar-nav-preferences.spec.ts src/components/settings/interface-sidebar-nav.spec.ts
-```
-
-Expected: FAIL because `skillLibrary` is not in the nav model.
-
-- [ ] **Step 3: Update nav model and store active view**
-
-In `src/lib/sidebar-nav-preferences.ts`, insert `"skillLibrary"` after `"soul"` in `DEFAULT_SIDEBAR_NAV_ORDER`.
-
-In `src/stores/wiki-store.ts`, extend `activeView` union with `"skillLibrary"`.
-
-In `src/components/layout/icon-sidebar.tsx`, import `WandSparkles` from `lucide-react` and add:
-
-```ts
-{ id: "skillLibrary", view: "skillLibrary", icon: WandSparkles, labelKey: "novel.nav.skillLibrary" },
-```
-
-In `src/components/layout/content-area.tsx`, add lazy import:
-
-```ts
-const SkillLibraryView = lazy(async () => {
-  const mod = await import("@/components/skill-library/skill-library-view")
-  return { default: mod.SkillLibraryView }
-})
-```
-
-Add `case "skillLibrary"` that renders `SkillLibraryView` inside `Suspense`.
-
-- [ ] **Step 4: Add placeholder skill library view**
-
-Create `src/components/skill-library/skill-library-view.tsx`:
-
-```tsx
-export function SkillLibraryView() {
-  return (
-    <div className="flex h-full flex-col overflow-hidden">
-      <div className="border-b px-5 py-4">
-        <h1 className="text-lg font-semibold">技能库</h1>
-        <p className="mt-1 text-sm text-muted-foreground">管理当前项目可用的去AI味技能。</p>
-      </div>
-    </div>
-  )
-}
-```
-
-- [ ] **Step 5: Add i18n labels**
-
-In `src/i18n/zh.json`, add `novel.nav.skillLibrary: "技能库"`.
-
-In `src/i18n/en.json`, add `novel.nav.skillLibrary: "Skill Library"`.
-
-- [ ] **Step 6: Run nav tests**
-
-Run:
-
-```bash
-npm run test:mocks -- src/lib/sidebar-nav-preferences.spec.ts src/components/settings/interface-sidebar-nav.spec.ts
-```
-
-Expected: PASS.
-
-- [ ] **Step 7: Commit**
-
-```bash
-git add src/lib/sidebar-nav-preferences.ts src/stores/wiki-store.ts src/components/layout/icon-sidebar.tsx src/components/layout/content-area.tsx src/components/skill-library/skill-library-view.tsx src/i18n/zh.json src/i18n/en.json src/lib/sidebar-nav-preferences.spec.ts src/components/settings/interface-sidebar-nav.spec.ts
-git commit -m "feat: add skill library navigation entry"
-```
-
-## Task 3: Build Skill Library Management UI
-
-**Files:**
-- Modify: `src/components/skill-library/skill-library-view.tsx`
-- Create: `src/components/skill-library/skill-library-view.spec.tsx`
-- Modify: `src/lib/novel/de-ai-skill-library.ts`
-
-- [ ] **Step 1: Add helper functions and tests**
-
-Add tests to `src/lib/novel/de-ai-skill-library.spec.ts`:
-
-```ts
-import {
-  createProjectDeAiSkillFromTemplate,
-  deleteProjectDeAiSkill,
-  setDeAiSkillEnabled,
-  setDefaultDeAiSkill,
-  updateProjectDeAiSkill,
-} from "./de-ai-skill-library"
-
-it("creates a project skill from a built-in template", () => {
-  const config = normalizeDeAiSkillConfig(null)
-  const next = createProjectDeAiSkillFromTemplate(config, "built-in:reduce-explanation", 1000)
-  expect(next.projectSkills).toHaveLength(1)
-  expect(next.projectSkills[0].source).toBe("project")
-  expect(next.projectSkills[0].name).toContain("减少解释腔")
-})
-
-it("updates and deletes project skills without deleting built-ins", () => {
-  const created = createProjectDeAiSkillFromTemplate(normalizeDeAiSkillConfig(null), "built-in:dialogue-natural", 1000)
-  const id = created.projectSkills[0].id
-  const updated = updateProjectDeAiSkill(created, id, { name: "对话规则", content: "只输出正文" }, 2000)
-  expect(updated.projectSkills[0].name).toBe("对话规则")
-  const deleted = deleteProjectDeAiSkill(updated, id)
-  expect(deleted.projectSkills).toHaveLength(0)
-  expect(deleteProjectDeAiSkill(deleted, "built-in:comprehensive")).toEqual(deleted)
-})
-
-it("disables a default skill and moves default to an available skill", () => {
-  const config = normalizeDeAiSkillConfig({ defaultSkillId: "built-in:comprehensive" })
-  const next = setDeAiSkillEnabled(config, "built-in:comprehensive", false)
-  expect(next.disabledSkillIds).toContain("built-in:comprehensive")
-  expect(next.defaultSkillId).toBe("built-in:reduce-explanation")
-  expect(setDefaultDeAiSkill(next, "built-in:dialogue-natural").defaultSkillId).toBe("built-in:dialogue-natural")
-})
-```
-
-- [ ] **Step 2: Implement helper functions**
-
-In `src/lib/novel/de-ai-skill-library.ts`, add:
-
-```ts
-export function createProjectDeAiSkillFromTemplate(
-  config: DeAiSkillConfig,
-  templateId: string,
-  now = Date.now(),
-): DeAiSkillConfig {
-  const template = getAllDeAiSkills(config).find((skill) => skill.id === templateId) ?? BUILT_IN_DE_AI_SKILLS[0]
-  const skill: DeAiSkill = {
-    id: `project:${now}`,
-    name: `${template.name}副本`,
-    description: template.description,
-    templateId: template.templateId,
-    content: template.content,
-    source: "project",
-    createdAt: now,
-    updatedAt: now,
-  }
-  return normalizeDeAiSkillConfig({
-    ...config,
-    defaultSkillId: skill.id,
-    projectSkills: [skill, ...config.projectSkills],
-  })
-}
-
-export function updateProjectDeAiSkill(
-  config: DeAiSkillConfig,
-  skillId: string,
-  patch: Pick<Partial<DeAiSkill>, "name" | "description" | "content">,
-  now = Date.now(),
-): DeAiSkillConfig {
-  return normalizeDeAiSkillConfig({
-    ...config,
-    projectSkills: config.projectSkills.map((skill) =>
-      skill.id === skillId
-        ? { ...skill, ...patch, source: "project", updatedAt: now }
-        : skill
-    ),
-  })
-}
-
-export function setDefaultDeAiSkill(config: DeAiSkillConfig, skillId: string): DeAiSkillConfig {
-  return normalizeDeAiSkillConfig({ ...config, defaultSkillId: skillId })
-}
-
-export function setDeAiSkillEnabled(config: DeAiSkillConfig, skillId: string, enabled: boolean): DeAiSkillConfig {
-  const disabledSkillIds = enabled
-    ? config.disabledSkillIds.filter((id) => id !== skillId)
-    : [...new Set([...config.disabledSkillIds, skillId])]
-  const normalized = normalizeDeAiSkillConfig({ ...config, disabledSkillIds })
-  if (normalized.defaultSkillId === skillId && !enabled) {
-    const fallback = resolveAvailableDeAiSkills(normalized)[0]
-    return normalizeDeAiSkillConfig({ ...normalized, defaultSkillId: fallback?.id ?? normalized.defaultSkillId })
-  }
-  return normalized
-}
-
-export function deleteProjectDeAiSkill(config: DeAiSkillConfig, skillId: string): DeAiSkillConfig {
-  if (!skillId.startsWith("project:")) return config
-  const projectSkills = config.projectSkills.filter((skill) => skill.id !== skillId)
-  const next = normalizeDeAiSkillConfig({ ...config, projectSkills })
-  if (next.defaultSkillId === skillId || !getAllDeAiSkills(next).some((skill) => skill.id === next.defaultSkillId)) {
-    const fallback = resolveAvailableDeAiSkills(next)[0]
-    return normalizeDeAiSkillConfig({ ...next, defaultSkillId: fallback?.id ?? DEFAULT_DE_AI_SKILL_ID })
-  }
-  return next
-}
-```
-
-- [ ] **Step 3: Implement the page UI**
-
-Replace the placeholder `SkillLibraryView` with a component that:
-
-- loads `loadDeAiSkillConfig(project.path)` in `useEffect`;
-- renders a left list from `getAllDeAiSkills(config)`;
-- shows a checkbox per skill using `setDeAiSkillEnabled`;
-- shows "默认" when `skill.id === config.defaultSkillId`;
-- renders text inputs for name and description and a textarea for content;
-- disables editing for `source === "built-in"`;
-- saves through `saveDeAiSkillConfig(project.path, nextConfig)`;
-- validates Chinese messages `"技能名称不能为空"` and `"技能规则不能为空"`;
-- offers "复制为项目技能", "设为默认", and "删除" actions.
-
-Use these stable test labels:
-
-```tsx
-data-testid="skill-library-view"
-data-testid="skill-list"
-data-testid="skill-name-input"
-data-testid="skill-description-input"
-data-testid="skill-content-input"
-data-testid="skill-save-button"
-data-testid="skill-copy-button"
-data-testid="skill-default-button"
-data-testid="skill-delete-button"
-```
-
-- [ ] **Step 4: Add component tests**
-
-Create `src/components/skill-library/skill-library-view.spec.tsx` using existing testing patterns and mock `@/commands/fs` so it returns a project config. Cover:
-
-```ts
-it("renders built-in skills and selected detail editor", async () => {
-  render(<SkillLibraryView />)
-  expect(await screen.findByText("综合去AI味")).toBeInTheDocument()
-  expect(screen.getByTestId("skill-library-view")).toBeInTheDocument()
-})
-
-it("prevents saving an empty project skill name", async () => {
-  render(<SkillLibraryView />)
-  await userEvent.clear(await screen.findByTestId("skill-name-input"))
-  await userEvent.click(screen.getByTestId("skill-save-button"))
-  expect(await screen.findByText("技能名称不能为空")).toBeInTheDocument()
-})
-```
-
-- [ ] **Step 5: Run tests**
-
-Run:
-
-```bash
-npm run test:mocks -- src/lib/novel/de-ai-skill-library.spec.ts src/components/skill-library/skill-library-view.spec.tsx
-```
-
-Expected: PASS.
-
-- [ ] **Step 6: Commit**
-
-```bash
-git add src/lib/novel/de-ai-skill-library.ts src/lib/novel/de-ai-skill-library.spec.ts src/components/skill-library/skill-library-view.tsx src/components/skill-library/skill-library-view.spec.tsx
-git commit -m "feat: add de-ai skill library management UI"
-```
-
-## Task 4: Persist Per-Conversation Skill Selection
-
-**Files:**
-- Modify: `src/stores/chat-store.ts`
-- Modify: `src/lib/persist.ts`
-- Test: add or update existing chat persistence/store tests if present.
-
-- [ ] **Step 1: Update conversation type and setter**
-
-In `src/stores/chat-store.ts`, add to `Conversation`:
-
-```ts
-selectedDeAiSkillId?: string | null
-```
-
-Add to `ChatState`:
-
-```ts
-setConversationDeAiSkillId: (id: string, skillId: string | null | undefined) => void
-```
-
-Initialize new conversations with:
-
-```ts
-selectedDeAiSkillId: undefined,
-```
-
-Implement setter:
-
-```ts
-setConversationDeAiSkillId: (id, selectedDeAiSkillId) =>
-  set((state) => ({
-    conversations: state.conversations.map((c) =>
-      c.id === id ? { ...c, selectedDeAiSkillId, updatedAt: Date.now() } : c
-    ),
-  })),
-```
-
-- [ ] **Step 2: Normalize loaded conversation data**
-
-In `src/lib/persist.ts`, add a local helper:
-
-```ts
-function normalizeConversation(conv: Conversation): Conversation {
-  return {
-    ...conv,
-    deAiMode: Boolean(conv.deAiMode),
-    selectedDeAiSkillId:
-      conv.selectedDeAiSkillId === null || typeof conv.selectedDeAiSkillId === "string"
-        ? conv.selectedDeAiSkillId
-        : undefined,
-  }
-}
-```
-
-Apply it to conversations loaded from both new and old chat formats.
-
-- [ ] **Step 3: Run typecheck**
-
-Run:
-
-```bash
-npm run typecheck
-```
-
-Expected: PASS.
-
-- [ ] **Step 4: Commit**
-
-```bash
-git add src/stores/chat-store.ts src/lib/persist.ts
-git commit -m "feat: persist chat de-ai skill selection"
-```
-
-## Task 5: AI Chat Skill Picker and System Injection
-
-**Files:**
-- Create: `src/components/skill-library/de-ai-skill-picker.tsx`
-- Modify: `src/components/chat/chat-panel.tsx`
-- Test: update or add chat panel tests.
-
-- [ ] **Step 1: Create reusable picker**
-
-Create `src/components/skill-library/de-ai-skill-picker.tsx`:
-
-```tsx
-import { useEffect, useState } from "react"
-import { WandSparkles, X } from "lucide-react"
-import { useWikiStore } from "@/stores/wiki-store"
-import {
-  loadDeAiSkillConfig,
-  resolveAvailableDeAiSkills,
-  resolveEffectiveDeAiSkill,
-  type DeAiSkill,
-} from "@/lib/novel/de-ai-skill-library"
-
-interface DeAiSkillPickerProps {
-  value?: string | null
-  onChange: (skillId: string | null | undefined) => void
-  includeDisableOption?: boolean
-  buttonLabel?: string
-}
-
-export function DeAiSkillPicker({ value, onChange, includeDisableOption = true, buttonLabel }: DeAiSkillPickerProps) {
-  const project = useWikiStore((s) => s.project)
-  const [open, setOpen] = useState(false)
-  const [skills, setSkills] = useState<DeAiSkill[]>([])
-  const [effectiveName, setEffectiveName] = useState("技能")
-
-  useEffect(() => {
-    let cancelled = false
-    loadDeAiSkillConfig(project?.path)
-      .then((config) => {
-        if (cancelled) return
-        const available = resolveAvailableDeAiSkills(config)
-        setSkills(available)
-        setEffectiveName(resolveEffectiveDeAiSkill(config, value)?.name ?? "未启用")
-      })
-      .catch(() => {
-        if (!cancelled) {
-          setSkills([])
-          setEffectiveName("未启用")
-        }
-      })
-    return () => {
-      cancelled = true
-    }
-  }, [project?.path, value])
-
-  return (
-    <div className="relative">
-      <button
-        type="button"
-        onClick={() => setOpen((next) => !next)}
-        className="flex h-8 items-center gap-1 rounded-md px-2 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
-        title="使用 / 调用命令和技能"
-      >
-        <WandSparkles className="h-4 w-4" />
-        <span className="max-w-[8rem] truncate">{buttonLabel ?? effectiveName}</span>
-      </button>
-      {open ? (
-        <div className="absolute bottom-9 left-0 z-50 w-64 rounded-md border bg-popover p-1 text-sm shadow-lg">
-          {skills.length === 0 ? (
-            <div className="px-2 py-2 text-xs text-muted-foreground">暂无可用去AI味技能</div>
-          ) : skills.map((skill) => (
-            <button
-              key={skill.id}
-              type="button"
-              className="block w-full rounded px-2 py-2 text-left hover:bg-accent"
-              onClick={() => {
-                onChange(skill.id)
-                setOpen(false)
-              }}
-            >
-              <div className="truncate text-sm">{skill.name}</div>
-              <div className="truncate text-xs text-muted-foreground">{skill.description}</div>
-            </button>
-          ))}
-          {includeDisableOption ? (
-            <button
-              type="button"
-              className="mt-1 flex w-full items-center gap-2 rounded px-2 py-2 text-left text-muted-foreground hover:bg-accent"
-              onClick={() => {
-                onChange(null)
-                setOpen(false)
-              }}
-            >
-              <X className="h-3.5 w-3.5" />
-              关闭去AI味技能
-            </button>
-          ) : null}
-        </div>
-      ) : null}
-    </div>
-  )
-}
-```
-
-- [ ] **Step 2: Wire picker into chat input controls**
-
-In `src/components/chat/chat-panel.tsx`, import `DeAiSkillPicker`, `loadDeAiSkillConfig`, `resolveEffectiveDeAiSkill`, and `buildDeAiSkillSystemPrompt`.
-
-Read from chat store:
-
-```ts
-const setConversationDeAiSkillId = useChatStore((s) => s.setConversationDeAiSkillId)
-const activeConversation = activeConversationId
-  ? conversations.find((conversation) => conversation.id === activeConversationId)
-  : null
-```
-
-Add `DeAiSkillPicker` near the input bottom left controls:
-
-```tsx
-<DeAiSkillPicker
-  value={activeConversation?.selectedDeAiSkillId}
-  onChange={(skillId) => {
-    const convId = useChatStore.getState().activeConversationId
-    if (convId) setConversationDeAiSkillId(convId, skillId)
-  }}
-/>
-```
-
-- [ ] **Step 3: Inject the selected skill as a system rule**
-
-Before building final `llmMessages`, resolve the skill:
-
-```ts
-const activeConv = useChatStore.getState().conversations.find(c => c.id === capturedConvId)
-const skillConfig = await loadDeAiSkillConfig(project?.path ?? null)
-const effectiveSkill = resolveEffectiveDeAiSkill(skillConfig, activeConv?.selectedDeAiSkillId)
-if (effectiveSkill) {
-  systemMessages.push({
-    role: "system",
-    content: buildDeAiSkillSystemPrompt(effectiveSkill.content),
-  })
-}
-```
-
-Keep existing `deAiMode` compatibility for this task. If both are active, skill injection should be the primary behavior and the old `injectDeAiDirective` block should only run when no effective skill is selected.
-
-- [ ] **Step 4: Run focused chat tests and typecheck**
-
-Run:
-
-```bash
-npm run test:mocks -- src/components/chat/chat-layout.spec.ts src/components/chat/chat-input.resize-interaction.spec.tsx
-npm run typecheck
-```
-
-Expected: PASS.
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add src/components/skill-library/de-ai-skill-picker.tsx src/components/chat/chat-panel.tsx src/stores/chat-store.ts src/lib/novel/de-ai-adapter.ts
-git commit -m "feat: add de-ai skill picker to chat"
-```
-
-## Task 6: Chapter Whole-Text and Selection Skill Picker
-
-**Files:**
-- Modify: `src/components/layout/preview-panel.tsx`
-- Modify: `src/components/editor/wiki-editor.tsx` only if a trigger coordinate or anchor is needed.
-- Test: `src/components/editor/wiki-editor.immersive.spec.tsx`
-- Test: `src/components/novel/text-transform-preview-dialog.spec.tsx`
-
-- [ ] **Step 1: Refactor whole-chapter de-AI handler to accept a skill**
-
-In `preview-panel.tsx`, change:
-
-```ts
-const handleDeAiProcess = useCallback(async () => {
-```
-
-to:
-
-```ts
-const runWholeChapterDeAi = useCallback(async (skillContent: string) => {
-```
-
-Remove the `loadSmartDeAiSkill` call inside it and pass `skillContent` to:
-
-```ts
-buildDeAiRewriteMessages(source, skillContent)
-```
-
-- [ ] **Step 2: Refactor selected-text de-AI handler to accept a skill**
-
-Split the current `handleSelectionAction` into:
-
-```ts
-const runSelectionTransform = useCallback(async (
-  action: ChapterSelectionAction,
-  selection: ChapterBodySelection,
-  skillContent?: string,
-) => {
-```
-
-Use:
-
-```ts
-action === "polish"
-  ? buildPolishSelectionMessages(selection.text)
-  : buildDeAiRewriteMessages(selection.text, skillContent)
-```
-
-- [ ] **Step 3: Add skill picker state to preview panel**
-
-Add state:
-
-```ts
-const [deAiSkillPickerOpen, setDeAiSkillPickerOpen] = useState(false)
-const [pendingSelectionForDeAi, setPendingSelectionForDeAi] = useState<ChapterBodySelection | null>(null)
-```
-
-Add helper:
-
-```ts
-async function handlePickedDeAiSkill(skillId: string) {
-  const config = await loadDeAiSkillConfig(project?.path ?? null)
-  const skill = resolveEffectiveDeAiSkill(config, skillId)
-  if (!skill) {
-    setSaveStatus("暂无可用去AI味技能")
-    return
-  }
-  if (pendingSelectionForDeAi) {
-    const selection = pendingSelectionForDeAi
-    setPendingSelectionForDeAi(null)
-    await runSelectionTransform("de-ai", selection, skill.content)
-    return
-  }
-  await runWholeChapterDeAi(skill.content)
-}
-```
-
-- [ ] **Step 4: Show picker when user clicks de-AI**
-
-For whole chapter buttons, replace `void handleDeAiProcess()` with:
-
-```ts
-setPendingSelectionForDeAi(null)
-setDeAiSkillPickerOpen(true)
-```
-
-For selected text in `handleSelectionAction`, when `action === "de-ai"`:
-
-```ts
-setPendingSelectionForDeAi(selection)
-setDeAiSkillPickerOpen(true)
-return
-```
-
-Render a compact popover using `DeAiSkillPicker` or a small local list from the same library. Use `includeDisableOption={false}` for chapter actions because selecting a skill should call it immediately.
-
-- [ ] **Step 5: Preserve existing preview confirmation**
-
-Confirm these existing flows still run:
-
-- whole chapter sets `deAiSourceContent`, `deAiCandidateContent`, and `deAiPreviewOpen`;
-- selected text sets `selectionTransformSourceContent`, `selectionTransformCandidateContent`, and `selectionTransformOpen`;
-- file switch protection via `selectedFileRef.current !== actionFile` remains in place.
-
-- [ ] **Step 6: Run focused tests and typecheck**
-
-Run:
-
-```bash
-npm run test:mocks -- src/components/editor/wiki-editor.immersive.spec.tsx src/components/novel/text-transform-preview-dialog.spec.tsx
-npm run typecheck
-```
-
-Expected: PASS.
-
-- [ ] **Step 7: Commit**
-
-```bash
-git add src/components/layout/preview-panel.tsx src/components/skill-library/de-ai-skill-picker.tsx
-git commit -m "feat: choose de-ai skill for chapter rewrites"
-```
-
-## Task 7: Remove Old Single-Skill Editor from Soul Entry
-
-**Files:**
-- Modify: `src/components/novel/soul-view.tsx`
-- Modify: `src/components/layout/soul-sidebar-panel.tsx`
-- Keep: `src/components/novel/de-ai-skill-editor.tsx`
-
-- [ ] **Step 1: Remove the project soul sidebar de-AI entry**
-
-In `src/components/layout/soul-sidebar-panel.tsx`, remove the button that sets `selectedSoulId("de-ai-skill")`.
-
-- [ ] **Step 2: Remove special rendering in SoulView**
-
-In `src/components/novel/soul-view.tsx`, remove:
-
-```tsx
-if (selectedSoulId === "de-ai-skill") {
-  return (
-    <div className="flex h-full overflow-y-auto p-6">
-      <div className="mx-auto max-w-3xl">
-        <DeAiSkillEditor />
-      </div>
-    </div>
-  )
-}
-```
-
-Do not delete `de-ai-skill-editor.tsx` in this task. Legacy cleanup should be handled separately after the new skill library is verified.
-
-- [ ] **Step 3: Run typecheck**
-
-Run:
-
-```bash
-npm run typecheck
-```
-
-Expected: PASS.
-
-- [ ] **Step 4: Commit**
-
-```bash
-git add src/components/novel/soul-view.tsx src/components/layout/soul-sidebar-panel.tsx
-git commit -m "refactor: move de-ai skill management out of soul"
-```
-
-## Task 8: Final Verification and Packaging Gate
-
-**Files:**
-- No planned code edits.
-
-- [ ] **Step 1: Run focused tests**
-
-Run:
-
-```bash
-npm run test:mocks -- src/lib/novel/de-ai-skill-library.spec.ts src/lib/sidebar-nav-preferences.spec.ts src/components/settings/interface-sidebar-nav.spec.ts src/components/skill-library/skill-library-view.spec.tsx src/components/chat/chat-layout.spec.ts src/components/editor/wiki-editor.immersive.spec.tsx src/components/novel/text-transform-preview-dialog.spec.tsx
-```
-
-Expected: all listed tests pass.
-
-- [ ] **Step 2: Run typecheck**
-
-Run:
-
-```bash
-npm run typecheck
-```
-
-Expected: PASS.
-
-- [ ] **Step 3: Manual verification in dev app**
-
-Run:
-
-```bash
-npm run dev
-```
-
-Open the Vite URL printed by the command. Verify:
-
-- left nav shows 技能库;
-- 技能库 can be hidden and sorted from interface settings;
-- 技能库 lists five built-ins;
-- project skill can be created from a template and saved;
-- disabling a skill hides it from chat and chapter pickers;
-- AI chat picker selection persists inside the current conversation;
-- switching conversations uses project default when the new conversation has no selected skill;
-- whole chapter de-AI asks for a skill, then opens existing preview;
-- selected-text de-AI asks for a skill, then opens existing selection preview.
-
-- [ ] **Step 4: Package only if user requests packaging**
-
-The AGENTS rules normally require packaging after implementation, but the user has previously distinguished GitHub push and packaging. At implementation time, ask whether to package if the current user instruction does not explicitly say to package.
-
-- [ ] **Step 5: Final status**
-
-Report:
-
-- feature complete or not;
-- files changed;
-- tests run;
-- source run/manual verification;
-- package status;
-- git commit status;
-- known risks.

+ 0 - 160
docs/superpowers/specs/2026-06-29-de-ai-skill-library-design.md

@@ -1,160 +0,0 @@
-# 去AI味技能库设计
-
-## 目标
-
-新增一级入口「技能库」,第一版只管理去AI味技能。用户可以使用内置预设,也可以为当前项目手动添加多个去AI味技能;在 AI 会话、整章去AI味、选中文本去AI味时选择并调用指定技能。
-
-本设计不实现通用 Skill 库,只解决多个去AI味 Skill 的管理和调用。
-
-## 成功标准
-
-- 左侧主导航新增「技能库」入口,并纳入现有排序和隐藏能力。
-- 技能库可管理内置去AI味技能和项目自定义去AI味技能。
-- 用户可通过模板创建项目技能,也可手动编辑名称、说明和规则正文。
-- 每个技能都有开关;关闭后不出现在 AI 会话和章节去AI味选择列表里。
-- 当前项目记住默认去AI味技能。
-- AI 会话输入框底部提供技能图标,选中技能后当前会话持续生效。
-- 普通聊天和写作工作流都会注入当前会话选中的去AI味技能。
-- 整章去AI味和选中文本去AI味点击后先选择技能,再调用模型,并继续走现有预览确认流程。
-- 保留旧版 `de-ai-skill.txt`,并兼容为一个项目技能来源。
-
-## 范围
-
-第一版包含:
-
-- 技能库一级页面。
-- 5 个内置模板:综合去AI味、减少解释腔、对话口语化、打破工整句式、保留文艺感。
-- 项目级去AI味技能增删改查。
-- 内置技能关闭、复制为项目技能。
-- 项目默认技能。
-- AI 会话技能选择和注入。
-- 章节整章与选中文本去AI味技能选择。
-
-第一版不包含:
-
-- 通用 Skill 库。
-- 润色、审稿、写作风格等其他技能类型。
-- Skill 市场、导入导出。
-- AI 自动生成 Skill。
-- 删除或迁移清理旧版 `de-ai-skill.txt`。
-
-## 界面设计
-
-左侧主导航新增「技能库」,和章节库、大纲、灵魂、小说图谱同级。它不是主题、设置、切换项目,因此应纳入现有左侧功能栏排序和隐藏设置。
-
-技能库页面采用左侧列表加右侧详情编辑:
-
-- 左侧列表展示所有去AI味技能。
-- 每项展示名称、简短说明、来源标记:内置或项目。
-- 每项提供开关;关闭后隐藏于调用入口。
-- 当前项目默认技能显示“默认”标记。
-- 左侧顶部提供“新建技能”按钮。
-- 右侧编辑当前选中技能的名称、说明、模板来源和规则正文。
-- 内置技能不可直接编辑正文,只能关闭或复制为项目技能。
-- 项目技能可编辑、设为默认和删除。
-
-新建技能时先选择模板。选择模板后自动填入名称、说明和规则正文,用户可修改后保存为项目技能。
-
-## 数据设计
-
-项目根目录新增配置文件:
-
-```json
-{
-  "version": 1,
-  "defaultSkillId": "built-in:comprehensive",
-  "disabledSkillIds": [],
-  "projectSkills": [
-    {
-      "id": "project:example",
-      "name": "我的去AI规则",
-      "description": "适合当前作品的去AI味处理",
-      "templateId": "reduce-explanation",
-      "content": "完整 Skill 规则正文",
-      "createdAt": 123456789,
-      "updatedAt": 123456789
-    }
-  ]
-}
-```
-
-内置技能由软件代码提供,不写入项目文件。项目文件只保存项目技能、默认技能和关闭状态。
-
-推荐内置技能 ID:
-
-- `built-in:comprehensive`
-- `built-in:reduce-explanation`
-- `built-in:dialogue-natural`
-- `built-in:break-regularity`
-- `built-in:literary-retain`
-
-兼容旧版:
-
-- 如果项目存在 `de-ai-skill.txt` 且没有 `de-ai-skills.json`,系统把它识别为一个项目技能。
-- 该技能可命名为“旧版自定义去AI味 Skill”。
-- 默认技能优先指向旧版自定义技能。
-- 不自动删除 `de-ai-skill.txt`。
-
-AI 会话数据增加:
-
-- `selectedDeAiSkillId?: string | null`
-
-含义:
-
-- 有值时,当前会话使用该技能。
-- `null` 表示本会话关闭去AI味技能。
-- 未设置时,使用项目默认技能。
-
-## 调用流程
-
-AI 会话:
-
-1. 输入框底部显示技能图标。
-2. 点击后展示已开启的去AI味技能,以及“关闭去AI味技能”。
-3. 用户选择技能后,当前会话持续使用。
-4. 换会话时,如果新会话没有单独选择过,则使用项目默认技能。
-5. 发送消息时,如果当前会话有可用技能,把技能正文作为系统规则注入。
-6. 普通聊天和章节生成、续写、改写等工作流都使用该技能。
-
-整章去AI味:
-
-1. 用户点击章节顶部“去AI味”。
-2. 弹出已开启技能列表。
-3. 用户点击技能后立即调用模型处理整章正文。
-4. 结果进入现有左右对比预览。
-5. 用户确认后替换正文,取消则不修改。
-
-选中文本去AI味:
-
-1. 用户选中文本并点击浮动工具栏“去AI味”。
-2. 弹出已开启技能列表。
-3. 用户点击技能后立即调用模型处理选中片段。
-4. 结果进入现有片段预览。
-5. 用户确认后只替换选中片段。
-
-## 回退规则
-
-- 没有项目配置时使用内置“综合去AI味”。
-- 项目默认技能被关闭或删除时,自动回退到第一个开启的内置技能。
-- 会话选中的项目技能被删除后,再次发送时回退到项目默认技能。
-- 所有技能都被关闭时,调用入口显示“暂无可用去AI味技能”,不发起模型调用。
-
-## 错误处理
-
-- 技能名称为空时禁止保存,提示“技能名称不能为空”。
-- 技能规则为空时禁止保存,提示“技能规则不能为空”。
-- 删除项目默认技能前弹出确认;删除后自动切换默认技能。
-- 关闭项目默认技能后自动选择下一个可用技能作为默认。
-- 内置技能不能删除。
-- 模型未配置时沿用现有模型不可用提示,不启动去AI味处理。
-- 整章或选中文本处理期间切换文件时,沿用现有文件保护,不把结果写入错误文件。
-
-## 测试计划
-
-- 配置读写:默认配置、旧版 `de-ai-skill.txt` 兼容、禁用技能、默认技能回退。
-- 技能库页面:新增、编辑、删除、开关、设为默认、内置复制为项目技能。
-- AI 会话:选择技能后普通聊天注入规则;换会话按项目默认恢复;关闭本会话技能后不注入。
-- 章节整章去AI味:选择技能后使用对应规则生成消息,预览确认后替换正文。
-- 选中文本去AI味:选择技能后只替换选区。
-- 左侧导航:技能库入口可排序、可隐藏。
-

+ 0 - 125
docs/superpowers/specs/2026-06-30-outline-chat-bottom-controls-design.md

@@ -1,125 +0,0 @@
-# AI 大纲输入区底栏控制设计
-
-日期:2026-06-30
-
-## 目标
-
-优化 AI 大纲输入框的控制区布局,解决当前“章节细纲、人物小传、组织势力设定、金手指与能力体系、伏笔计划、地点设定”等长文字按钮占用空间的问题,同时把输入框内左上角的停靠图标移动到底栏右侧,让底栏控制集中、紧凑、与整体主题一致。
-
-## 成功标准
-
-1. AI 大纲输入框上方不再常驻显示一排长文字生成按钮。
-2. 停靠图标移动到底栏右侧,不再漂在输入框内容区左上角。
-3. 底栏右侧顺序固定为:停靠图标、大纲生成图标、模型选择、发送按钮。
-4. 大纲生成图标点击后弹出完整文字菜单,菜单项包括:
-   章节细纲、人物小传、组织势力设定、金手指与能力体系、伏笔计划、地点设定。
-5. 点击菜单项后继续调用现有大纲生成流程,不改变 AI 工作流和提示词配置。
-6. 加号按钮颜色改为跟随主题,不再固定使用突兀的绿色。
-7. 修改不影响 AI 会话输入框、模型选择、发送、停止生成、停靠控制等既有功能。
-
-## 范围
-
-本次只调整 AI 大纲面板输入区与头部加号按钮的展示方式。核心生成配置、模型解析、流式生成、保存为大纲、会话历史等逻辑不做重构。
-
-预计后续实现涉及:
-
-1. `src/components/sources/outline-chat-panel.tsx`
-2. `src/components/sources/outline-chat-panel.spec.tsx`
-
-如发现停靠图标来自共享 `ChatInput` 的布局能力,才在保持兼容的前提下最小调整 `src/components/chat/chat-input.tsx` 及对应测试。
-
-## 推荐方案
-
-采用“底栏右侧图标组 + 弹出文字菜单”的方案。
-
-默认底栏右侧布局:
-
-```text
-[停靠图标] [大纲生成图标] [模型选择] [发送]
-```
-
-大纲生成图标建议使用 `ListPlus`。它比纯闪光类图标更接近“大纲、列表、模块生成”的含义。图标按钮本身不显示文字,只通过 `aria-label`、`title` 或 Tooltip 提供“生成大纲模块”的说明。
-
-点击大纲生成图标后,在按钮上方弹出紧凑菜单:
-
-```text
-章节细纲
-人物小传
-组织势力设定
-金手指与能力体系
-伏笔计划
-地点设定
-```
-
-菜单项显示“图标 + 中文名称”,鼠标悬停显示简短说明。点击菜单项后关闭菜单,并调用现有的 `handleGenerateSection(config.title, config.requestHint)`。
-
-## 组件设计
-
-### 底栏控制
-
-AI 大纲面板继续复用 `ChatInput` 作为输入组件。大纲面板通过 `leftControls` 或 `rightControls` 传入控制按钮,但最终视觉上要把停靠、大纲生成、模型选择、发送归到同一底栏右侧控制组。
-
-如果 `ChatInput` 当前把停靠控件固定渲染到输入区左侧,则需要把停靠控件改为可由调用方控制位置,或在 AI 大纲面板中传入右侧停靠控制。该调整必须保持普通 AI 会话输入框现有布局不回退。
-
-### 大纲生成菜单
-
-新增一个局部菜单状态,例如 `outlineMenuOpen`。菜单打开后:
-
-1. 点击任一菜单项,执行对应生成并关闭菜单。
-2. 生成中禁用菜单项,避免重复触发。
-3. 点击外部或再次点击图标关闭菜单。
-4. 键盘可通过 Escape 关闭菜单。
-
-菜单数据继续使用 `OUTLINE_SECTION_GENERATION_CONFIGS`,不复制生成配置,避免后续配置不同步。
-
-### 加号按钮
-
-AI 大纲会话头部的新建会话按钮保留功能,但颜色从固定 `emerald` 改为主题跟随样式:
-
-```text
-border-border
-bg-accent/60
-text-foreground 或 text-primary
-hover:bg-accent
-```
-
-这样在东方美学配色和原有主题中都能自然适配。
-
-## 数据流
-
-1. 用户点击底栏右侧大纲生成图标。
-2. 面板打开由 `OUTLINE_SECTION_GENERATION_CONFIGS` 渲染的菜单。
-3. 用户选择一个大纲模块。
-4. 调用现有 `handleGenerateSection(title, requestHint)`。
-5. `handleGenerateSection` 继续调用 `handleSend`,进入现有 AI 大纲生成工作流。
-
-该流程只改变入口展示,不改变生成链路。
-
-## 错误与边界处理
-
-1. 生成中禁用大纲生成菜单,避免重复请求。
-2. 无可用模型时仍沿用现有无模型提示。
-3. 窄宽度下模型选择保持固定窄宽,图标按钮不换成文字。
-4. 菜单不应遮挡发送按钮,也不应撑高输入框。
-5. 所有可见提示语使用中文。
-
-## 测试计划
-
-1. 单元测试确认 AI 大纲面板不再常驻显示长文字生成按钮。
-2. 单元测试确认存在大纲生成图标按钮,并具备中文 `aria-label`。
-3. 单元测试确认点击大纲生成图标后出现完整中文菜单项。
-4. 单元测试确认点击菜单项会触发原有发送流程。
-5. 单元测试确认新建大纲会话加号按钮不再使用固定 `emerald` 样式。
-6. 手动验证底栏顺序为:停靠图标、大纲生成图标、模型选择、发送。
-
-## 风险
-
-主要风险在于 `ChatInput` 是共享组件。如果停靠图标位置由 `ChatInput` 内部固定控制,调整时必须避免影响普通 AI 会话输入框。实现时优先在 AI 大纲面板局部组合控制项;只有局部方案无法完成时,才对 `ChatInput` 增加兼容参数。
-
-## 非目标
-
-1. 不修改大纲生成提示词。
-2. 不修改模型选择逻辑。
-3. 不修改 AI 会话历史存储。
-4. 不新增新的大纲模块类型。
-5. 不改变普通 AI 会话输入框的既有功能。

+ 17 - 0
jinengku-xinjianskill-xiufu-分支说明.md

@@ -0,0 +1,17 @@
+# 技能库新建 Skill 修复分支说明
+
+## 使用要求
+
+- 本分支只处理技能库“新建技能”误用复制逻辑,以及删除“复制为项目技能”入口。
+- 不修改去AI味 Skill 调用、章节处理、模型设置、打包脚本等无关逻辑。
+- 新建 Skill 必须创建空白项目 Skill,不得复制任何内置 Skill 的专业规则正文。
+
+## 更新记录
+
+- 20260630:修复“新建技能”点击后显示为复制内置技能的问题,改为创建“新建去AI味 Skill”空白项目 Skill,并提供基础自定义规则模板。
+- 20260630:删除详情页“复制为项目技能”按钮,移除底层按模板复制项目 Skill 的函数,避免无用复制能力继续保留。
+- 20260630:新增回归测试覆盖空白新建、复制入口不可见、底层不复制内置提示词;已通过相关测试、类型检查、构建和 Windows 本地便携版打包。
+
+## 提交状态
+
+- 未提交。

+ 0 - 43
jinengku-youhua-分支说明.md

@@ -1,43 +0,0 @@
-# 技能库优化分支说明
-
-## 使用要求
-
-- 本分支只处理技能库稳定性和第一版高风险 BUG。
-- 不改动 AI 模型配置、章节生成、文件同步、打包脚本等无关逻辑。
-- 技能库优化优先保证现有项目 Skill 不丢失、不被损坏配置覆盖。
-- 完成后需要运行技能库聚焦测试、全量 mock 测试、类型检查、构建和便携版打包。
-
-## 更新记录
-
-- 20260630:创建 `jinengku-youhua` 分支,开始修复技能库未保存保护、损坏配置、无项目保存、调用入口刷新和默认技能禁用关系。
-- 20260630:新增技能库边界回归测试,覆盖未保存保护、无项目保存、损坏配置、调用入口刷新和默认技能禁用关系。
-- 20260630:完成技能库稳定性修复,并通过聚焦测试、全量 mock 测试、类型检查、构建、源码运行冒烟验证和便携版打包。
-- 20260630:继续补强技能库配置恢复能力,保存前自动备份有效配置,损坏配置时支持从备份恢复或重新创建默认配置。
-- 20260630:优化技能库左右面板配置加载方式,左侧列表和右侧详情页共享同一次加载结果,减少重复读取项目配置。
-- 20260630:新增第二阶段回归测试,覆盖保存前备份、备份恢复、重新创建默认配置、共享加载和损坏配置恢复界面;已通过聚焦测试、全量 mock 测试、类型检查、构建、源码运行冒烟验证和便携版打包。
-- 20260630:继续优化调用入口一致性,聊天输入框和章节预览的去AI味 Skill 选择入口复用同一组选项列表、错误态和“已修改”标记。
-- 20260630:新增调用入口回归测试,覆盖配置损坏提示和共享列表展示;已通过技能库聚焦测试、全量 mock 测试、类型检查和构建;按用户要求本轮未打包。
-- 20260630:继续优化配置写入安全性,主配置保存、备份恢复和重新创建配置全部改用 `writeFileAtomic`,同一项目路径保存流程串行执行,避免并发写入交叉。
-- 20260630:新增配置写入回归测试,覆盖主配置原子写和同项目并发保存顺序;已通过技能库聚焦测试、全量 mock 测试、类型检查、构建和源码运行冒烟验证;按用户要求本轮未打包。
-- 20260630:继续优化 5 个内置去AI味 Skill 的实际内容,综合、减少解释腔、对话口语化、打破工整句式、保留文艺感均改为独立专用规则,不再共用同一份完整底稿。
-- 20260630:新增内置 Skill 内容差异回归测试,要求每个内置 Skill 都有专用规则章节,并限制内容前缀高度重复;已通过技能库聚焦测试、全量 mock 测试、类型检查、构建和源码运行冒烟验证;按用户要求本轮未打包。
-- 20260630:继续优化去AI味 Skill 调用入口,章节工具栏显示当前实际使用的 Skill,章节弹层和 AI 会话底部选择器标记“当前 / 默认 / 已修改”;AI 会话发送时如果去AI味配置损坏,会跳过本次 Skill 注入并显示中文警告,不再中断发送。
-- 20260630:新增章节入口和聊天发送安全加载回归测试;已通过章节/聊天聚焦测试、技能库聚焦测试、全量 mock 测试、类型检查、构建和源码运行冒烟验证;按用户要求本轮未打包、未提交。
-- 20260630:继续优化章节去AI味 Skill 调用体验,项目配置新增 `lastChapterDeAiSkillId`,章节入口会记住上次选择的 Skill;旧配置和旧备份恢复时会自动补齐新字段,保持向后兼容。
-- 20260630:AI 会话去AI味配置损坏提示改为独立显示在聊天输入区上方,不再复用章节保存状态;已通过章节/聊天/技能库聚焦测试、全量 mock 测试、类型检查、构建和源码运行冒烟验证;按用户要求本轮未打包、未提交。
-- 20260630:继续优化去AI味 Skill 选择体验,选择器支持外点关闭、Esc 关闭、上下键切换、Enter 选择,并为当前项增加视觉高亮和 `aria-current`。
-- 20260630:抽取 `useDeAiSkillOptions` 共享 Hook,统一章节入口和 AI 会话选择器的配置加载、当前项、默认项、已修改标记、错误提示和章节上次选择回退;章节记忆 Skill 写入失败改为弱提示,不再误导为正文处理失败。
-- 20260630:新增共享 Hook、选择器交互和章节弱提示回归测试;已通过聚焦测试、相关模块测试、全量 mock 测试、类型检查、构建和源码运行冒烟验证;按用户要求本轮未打包、未提交。
-- 20260630:继续优化章节去AI味 Skill 细节,切换项目时重置章节本地 Skill 选择,记忆 Skill 失败弱提示会在后续处理状态中保留,选择器打开后自动聚焦列表;已通过聚焦测试、相关模块测试、全量 mock 测试、类型检查、构建和源码运行冒烟验证;按用户要求本轮未打包、未提交。
-- 20260630:继续优化章节去AI味 Skill 弹层,支持点击外部关闭并清空待处理选中文本上下文;已通过章节面板聚焦测试、技能库/章节/聊天相关模块测试、类型检查、构建和源码运行冒烟验证;全量 mock 当前仍受 `src/lib/ingest-queue.integration.test.ts` 并行等待超时影响,单跑该文件通过;按用户要求本轮未打包、未提交。
-- 20260630:按用户要求先完成 Windows 本地便携版打包供测试使用,产物为 `release-portable/QMaiWrite.exe`,版本 2.2.30,大小 149,215,744 字节;本轮未提交,未上传 GitHub。
-- 20260630:按 B 方案升级 5 个内置去AI味 Skill 为专业编辑工作流,并修复章节顶部 Skill 弹层固定右上角、AI 会话底部 Skill 选择器受滚动工具栏裁切导致无法选择的问题;已通过相关回归测试、类型检查、构建和便携版打包,产物版本 2.2.30;本轮未提交,未上传 GitHub。
-- 20260630:将 AI 会话底部去AI味 Skill 入口改为仅显示图标,悬停显示当前 Skill 说明;章节顶部去AI味按钮不变;已通过相关回归测试、类型检查、构建和便携版打包,产物版本 2.2.30;本轮未提交,未上传 GitHub。
-- 20260630:将 AI 会话底部模型选择框改为固定窄宽度,长模型名在按钮内截断显示;下拉列表宽度保持可读,不影响模型选择。已通过模型选择框/聊天面板相关回归测试、类型检查、前端构建和 Windows 本地便携版打包,产物版本 2.2.30,大小 149,219,840 字节;本轮未提交,未上传 GitHub。
-
-- 20260630:章节库新增多选移动到卷能力,支持勾选多个章节、自定义选择、全选章节后批量移动到指定卷;右键移动保留单章能力,并在右键章节属于勾选集合时批量移动勾选章节。已通过章节树聚焦测试、章节格式/侧栏/i18n 相关测试、类型检查、前端构建和 Windows 本地便携版打包,产物版本 2.2.30,大小 149,219,840 字节;本轮未提交,未上传 GitHub。
-- 20260630:优化章节库批量移动交互,默认不再常驻显示复选框,改为点击“批量”后进入批量模式;批量模式下显示复选框、全选、已选数量、移动选中到卷和取消,移动完成后自动退出并清空选择。已通过章节树聚焦测试、章节格式/侧栏/i18n 相关测试、类型检查、前端构建和 Windows 本地便携版打包,产物版本 2.2.30,大小 149,219,840 字节;本轮未提交,未上传 GitHub。
-- 20260630:将章节库“批量”入口从章节列表顶部移入右键菜单的“移动到卷”子菜单;默认状态不显示复选框,展开“移动到卷”后点击“批量”才进入批量模式。已通过章节树聚焦测试、章节格式/侧栏/i18n 相关测试、类型检查、前端构建和 Windows 本地便携版打包,产物版本 2.2.30,大小 149,219,840 字节;本轮未提交,未上传 GitHub。
-## 提交状态
-
-- 未提交。

+ 142 - 0
juqingtuiyanshi-duozhinengti-分支说明.md

@@ -0,0 +1,142 @@
+# juqingtuiyanshi-duozhinengti 分支说明
+
+## 分支目标
+
+本分支用于实现剧情推演室多 Agent 第一期架构底座:新增轻量黑板和多 Agent 轮次编排层,并接入现有推演流程。
+
+## 使用要求
+
+1. 不删除现有剧情推演室功能。
+2. 不大改 UI。
+3. 不接 MCP/web search。
+4. 不引入导演/审查/投票等额外 LLM 调用。
+5. 所有新增行为先写测试并确认失败,再实现。
+6. 修改完成后必须验证、打包,并记录本文件和 `GenxinLOG/更新日志.md`。
+
+## 20260703-143532
+
+问题分析:
+1. 用户反馈“继续未完成任务”弹窗总是弹出。
+2. 代码追踪确认:`ChatPanel` 挂载时只要 `loadTaskBreakpoint(projectPath)` 读到 `.qm/breakpoint.json` 中的断点对象,就会打开断点恢复弹窗。
+3. 取消弹窗会清理旧断点,但确认恢复只关闭弹窗并发送恢复提示,没有先清理旧断点;如果恢复过程被中断、失败或后续没有走到成功清理,下次打开仍会读到旧断点并再次弹出。
+4. 弹窗复用 `ModifyConfirmDialog`,断点类型也显示“确认保存”,容易让用户误以为这是保存文件弹窗。
+
+实现内容:
+1. `confirmBreakpointResume` 在确认恢复时先关闭弹窗、清空 `lastBreakpoint`,并调用 `clearTaskBreakpoint(projectPath)` 清理磁盘旧断点,再发送恢复提示。
+2. `ModifyConfirmDialog` 增加按类型配置的确认按钮文案,断点类型显示“继续执行”,其他写入/修改类弹窗仍显示“确认保存”。
+3. `chat-panel-mount` 测试工具支持注入模拟断点,并补齐 `getLastQueryPages` mock,避免确认恢复后渲染引用面板时报测试 mock 缺失。
+4. 新增 ChatPanel mount 回归测试,覆盖断点弹窗按钮文案和确认恢复时清理旧断点。
+
+验证记录:
+1. RED:新增断点弹窗文案和确认恢复清理测试后,当前代码分别因“没有继续执行按钮”和“clearTaskBreakpoint 未调用”失败。
+2. GREEN:实现修复后,`npm.cmd run test:mocks -- src/components/chat/chat-panel.mount.spec.tsx`:1 个测试文件、5 个用例通过,6 个 todo。
+3. 相邻验证:`npm.cmd run test:mocks -- src/components/chat/chat-panel.mount.spec.tsx src/components/chat/chat-panel.spec.tsx src/lib/agent/runner.spec.ts src/lib/agent/task-breakpoint.spec.ts`:4 个测试文件、82 个用例通过,6 个 todo。
+4. `npm.cmd run typecheck`:通过。
+5. `npm.cmd run test:mocks`:347 个测试文件、2599 个用例通过,6 个 todo。
+6. `npm.cmd run build`:通过;保留既有 dynamic import、chunk size、plugin timings 警告。
+7. 源码启动烟测:`http://127.0.0.1:5179/` 返回 200,页面包含 React root。
+8. `npm.cmd run build:portable`:通过,产物 `release-portable\QMaiWrite.exe`,版本 `2.2.31`,大小 `149403648` 字节。
+
+Git 状态:本轮未提交 git,未合并 main。
+
+## 20260703-141801
+
+问题分析:
+1. 用户多次遇到“模型只输出了思考内容,但没有输出正文”。
+2. 代码追踪确认:`llm-client.ts` 正确识别到了流式响应里只有 `reasoning` / `reasoning_content`,没有 `content`。
+3. 章节生成会把 reasoning token 用于进度显示,但最终正文只接受 `content`,所以用户会先看到思考片段,最后仍报“没有正文”。
+4. 根因不是应用侧 `max_tokens`,而是当前兼容接口/推理模型在该轮调用里没有从思考流切换到正文流;上层 Agent/工作流也没有像 Codex 一样自动换参数重试。
+
+实现内容:
+1. 新增 `src/lib/reasoning-retry.ts`,统一识别 reasoning-only 错误,并提供 `reasoning: off` 的兜底覆盖参数。
+2. `src/lib/agent/runner.ts` 在 ReAct 单轮模型调用遇到 reasoning-only 时,自动清空本轮临时输出并用非推理模式重试一次。
+3. `src/lib/novel/deep-chapter-generation.ts` 在章节/深度章节生成的当前阶段遇到 reasoning-only 时,自动切换非推理模式重试当前阶段,不重启整条工作流。
+4. 新增 `src/lib/agent/runner.spec.ts` 与 `src/lib/novel/deep-chapter-generation.spec.ts` 回归测试,覆盖外层 Agent 和章节阶段的兜底路径。
+
+验证记录:
+1. RED:新增两个 reasoning-only 兜底测试后,当前代码分别因“只调用一次模型”和“直接抛错”失败。
+2. GREEN:实现兜底后,`npm.cmd run test:mocks -- src/lib/agent/runner.spec.ts src/lib/novel/deep-chapter-generation.spec.ts`:2 个测试文件、40 个用例通过。
+3. 相邻验证:`npm.cmd run test:mocks -- src/lib/agent/runner.spec.ts src/lib/novel/deep-chapter-generation.spec.ts src/lib/llm-client.test.ts src/lib/reasoning-detector.test.ts src/lib/llm-providers.test.ts src/lib/llm-providers.spec.ts`:6 个测试文件、100 个用例通过。
+4. `npm.cmd run typecheck`:通过。
+5. `npm.cmd run test:mocks`:347 个测试文件、2597 个用例通过,6 个 todo。
+6. `npm.cmd run build`:通过;保留既有 dynamic import、chunk size、plugin timings 警告。
+7. 源码启动烟测:`http://127.0.0.1:5179/` 返回 200,页面包含 React root。
+8. `npm.cmd run build:portable`:通过,产物 `release-portable\QMaiWrite.exe`,版本 `2.2.31`,大小 `149403648` 字节。
+
+Git 状态:本轮未提交 git,未合并 main。
+
+## 20260703-130147
+
+计划内容:
+1. 新增 `multi-agent-orchestrator.ts`。
+2. 新增 `multi-agent-orchestrator.spec.ts`。
+3. 在 `simulation-engine.ts` 中接入 orchestrator。
+4. 保持现有实时事件流、报告、草稿、采访流程不回退。
+
+Git 状态:已创建分支,尚未提交。
+
+## 20260703-131504
+
+实现内容:
+1. 新增 `src/lib/novel/story-simulation/multi-agent-orchestrator.ts`。
+2. 新增 `src/lib/novel/story-simulation/multi-agent-orchestrator.spec.ts`。
+3. `SimulationBlackboard` 支持 activeAgents、events、publicEvents、visibleEventsByAgent、roundPlans。
+4. `recordBlackboardEvent` 根据 `TimelineEvent.observableBy` 写入全局事件、角色可见事件和公共事件。
+5. `planMultiAgentRound` 根据模式的 `agentSubsetRatio` 生成每轮 Agent 行动计划。
+6. `simulation-engine.ts` 接入 orchestrator:轮次角色选择交给 `planMultiAgentRound`,直接行动、react 反应和系统随机事件都写入 blackboard。
+
+验证记录:
+1. RED/GREEN:orchestrator 模块缺失测试先失败后通过。
+2. RED/GREEN:`simulation-engine` 接线守卫测试先失败后通过。
+3. `npx.cmd vitest run src/lib/novel/story-simulation/multi-agent-orchestrator.spec.ts`:1 个测试文件、6 个用例通过。
+4. `npx.cmd vitest run src/lib/novel/story-simulation/multi-agent-orchestrator.spec.ts src/lib/novel/deep-chapter-generation.spec.ts`:2 个测试文件、31 个用例通过。
+5. `npm.cmd run typecheck`:通过。
+6. `npm.cmd run test:mocks`:345 个测试文件、2584 个用例通过,6 个 todo。
+7. `npm.cmd run build`:通过;保留既有 Vite dynamic import、chunk size、plugin timings 警告。
+8. 源码启动:`http://127.0.0.1:5179/` 返回 200,验证后端口无监听残留。
+9. `npm.cmd run build:portable`:第一次出现一次性 Vite HTML emit 路径异常,重跑通过;产物 `release-portable\QMaiWrite.exe`,版本 `2.2.31`,大小 `149403648` 字节。
+
+Git 状态:本轮未提交 git,未合并 main。
+
+## 20260703-135904
+
+实现内容:
+1. 在 `types.ts` 中新增推演过程观察快照类型:`SimulationDebugTrace`、`SimulationDebugAgent`、`SimulationDebugVisibleEvent`。
+2. 在 `multi-agent-orchestrator.ts` 中新增 `createBlackboardDebugTrace`,输出 blackboard 统计、候选 Agent、本轮行动 Agent、角色可见事件摘要和最近事件。
+3. 在 `simulation-engine.ts` 中新增可选 `onDebugTrace` 回调;每轮计划生成后、直接行动/反应/系统事件写入 blackboard 后都会发出调试快照。
+4. 在 `story-simulation-store.ts` 中新增 `debugTraces` 状态和写入/清空方法。
+5. 在 `story-simulation-view.tsx` 的推演中面板增加“时间线 / 过程观察”切换,过程观察区展示 Blackboard、Agent 调度和可见事件。
+6. 更新实施计划文档,加入 Phase 3 推演过程观察面板任务。
+
+验证记录:
+1. RED/GREEN:调试快照 helper、store trace、engine/source、UI source guard 测试先失败后通过。
+2. `npx.cmd vitest run src/lib/novel/story-simulation/multi-agent-orchestrator.spec.ts src/stores/story-simulation-store.spec.ts src/components/novel/story-simulation/story-simulation-view.debug.spec.ts`:3 个测试文件、17 个用例通过。
+3. `npx.cmd vitest run src/lib/novel/story-simulation/multi-agent-orchestrator.spec.ts src/stores/story-simulation-store.spec.ts src/components/novel/story-simulation/story-simulation-view.debug.spec.ts src/lib/novel/deep-chapter-generation.spec.ts`:4 个测试文件、42 个用例通过。
+4. `npm.cmd run typecheck`:通过。
+5. `npm.cmd run test:mocks`:347 个测试文件、2595 个用例通过,6 个 todo。
+6. `npm.cmd run build`:通过;保留既有 Vite dynamic import、chunk size、plugin timings 警告。
+7. 源码启动:`http://127.0.0.1:5179/` 返回 200,验证后端口无监听残留。
+8. `npm.cmd run build:portable`:通过,产物 `release-portable\QMaiWrite.exe`,版本 `2.2.31`,大小 `149403648` 字节。
+
+Git 状态:本轮未提交 git,未合并 main。
+
+## 20260703-133222
+
+实现内容:
+1. `SimulationBlackboard` 增加 `allAgents`,把全量角色名册和当前轮次行动集分开维护。
+2. 新增 `selectNodeAgentCandidates`,节点角色候选从全量角色名册选择,避免上一节点缩窄 `activeAgents` 后导致后续节点角色丢失。
+3. 新增 `getBlackboardVisibleEvents`,Agent 决策 prompt 只读取 blackboard 中对自己可见的事件,并支持最新事件数量限制。
+4. `simulation-engine.ts` 接入 `selectNodeAgentCandidates` 和 `getBlackboardVisibleEvents`,Agent 可见上下文不再从 `state.timelineEvents` 直接构造。
+5. 更新设计文档和实施计划,记录 Phase 2 blackboard 上下文接管范围。
+
+验证记录:
+1. RED/GREEN:全量角色名册、节点候选选择、可见事件过滤、最新事件限制和 `simulation-engine.ts` 接线守卫测试先失败后通过。
+2. `npx.cmd vitest run src/lib/novel/story-simulation/multi-agent-orchestrator.spec.ts`:1 个测试文件、11 个用例通过。
+3. `npx.cmd vitest run src/lib/novel/story-simulation/multi-agent-orchestrator.spec.ts src/lib/novel/deep-chapter-generation.spec.ts`:2 个测试文件、36 个用例通过。
+4. `npm.cmd run typecheck`:通过。
+5. `npm.cmd run test:mocks`:345 个测试文件、2589 个用例通过,6 个 todo。
+6. `npm.cmd run build`:通过;保留既有 Vite dynamic import、chunk size、plugin timings 警告。
+7. 源码启动:`http://127.0.0.1:5179/` 返回 200,验证后端口无监听残留。
+8. `npm.cmd run build:portable`:第一次出现一次性 Vite HTML emit 路径异常,单独 `npm.cmd run build` 通过后重跑 portable 成功;产物 `release-portable\QMaiWrite.exe`,版本 `2.2.31`,大小 `149403648` 字节。
+
+Git 状态:本轮未提交 git,未合并 main。

+ 341 - 0
juqingtuiyanshiqi-设计文档.md

@@ -0,0 +1,341 @@
+# 剧情推演室四期 — 设计文档
+
+> 分支:`juqingtuiyanshierqi`(在三期基础上叠加)
+> 日期:2026-07-04
+
+## 一、背景与目标
+
+在剧情推演室三期基础上,继续增强可视化与交互能力,新增 4 个方向:
+
+1. **传闻传播链可视化**:展示传闻来源、传播路径和验证状态
+2. **角色关系图动态更新**:实时展示角色间好感度变化的关系图
+3. **多结局分支对比与推荐**:支持保存多个分支结果,自动对比并推荐
+4. **调查线索板**:展示各角色已知的秘密/线索及其关联
+
+## 二、任务 10:传闻传播链可视化
+
+### 2.1 目标
+
+在推演室过程观察面板中新增「传闻」Tab,展示所有传闻及其传播链路。
+
+### 2.2 面板结构
+
+```
+┌─ 传闻 ───────────────────────────────────────┐
+│  [全部] [未验证] [已验证] [已证伪]           │
+├──────────────────┬───────────────────────────┤
+│ 传闻列表(左)   │ 传播链详情(右)          │
+│                  │                           │
+│ 🟡 听说李四...   │ 【传闻内容】              │
+│    源: 事件-12   │ 听说李四偷偷修炼了禁术    │
+│    失真度: 0.2   │                           │
+│    3人相信       │ 【传播链时间线】          │
+│                  │ ○ 第2轮 传闻生成          │
+│ 🔵 据说王五...   │    来源: 张三与李四对话   │
+│    源: 事件-25   │                           │
+│    失真度: 0.5   │ ○ 第3轮 角色可见          │
+│    1人验证       │    张三看到 ✓             │
+│                  │    王五看到 ✓             │
+│ 🟢 传言赵六...   │                           │
+│    源: 事件-33   │ ○ 第4轮 调查验证          │
+│    失真度: 0.1   │    张三调查 → 确认属实 ✓  │
+│    2人相信       │                           │
+└──────────────────┴───────────────────────────┘
+```
+
+### 2.3 详细设计
+
+**数据来源**(全部复用已有数据结构,不新增后端字段):
+- 传闻列表:`blackboard.rumors`
+- 可见性:`blackboard.visibleRumorsByAgent`
+- 验证状态:`rumor.verifiedBy`
+- 源事件:`rumor.sourceId` → 从 `blackboard.events` 中查找
+
+**传闻卡片信息**:
+- 内容摘要(前 30 字 + 省略号)
+- 源事件描述(通过 sourceId 查找)
+- 生成轮次
+- 失真度(颜色标识:绿<0.3 / 黄0.3-0.6 / 红>0.6)
+- 状态标签:未验证 / 已验证 / 已证伪(按 verifiedBy 中结果判定,取第一个验证结果)
+- 相信人数 / 验证人数
+
+**传播链时间线**:
+1. 传闻生成节点(轮次 N):显示来源事件摘要
+2. 角色可见节点(轮次 N+1):列出所有看到这条传闻的角色
+3. 调查验证节点(轮次 N+M):显示哪些角色进行了调查,结果如何
+
+**筛选功能**:
+- 顶部筛选标签:全部 / 未验证 / 已验证 / 已证伪
+
+### 2.4 改动文件
+
+- `src/components/story-simulation/story-simulation-view.tsx` — 新增「传闻」Tab
+- 新增 `src/components/story-simulation/rumor-propagation-panel.tsx` — 传闻传播面板组件
+
+### 2.5 风险
+
+- 传闻的 sourceId 可能为 null(无明确源事件),需处理"来源不明"情况
+- 传播链只展示初始传播,不追踪二次传播(Agent 主动转告他人),四期先简化
+
+## 三、任务 11:角色关系图动态更新
+
+### 3.1 目标
+
+在过程观察面板新增「关系」Tab,用关系图实时展示角色间好感度变化。
+
+### 3.2 面板结构
+
+```
+┌─ 角色关系 ───────────────────────────────────┐
+│  [关系图] [列表视图]   自动刷新: ☑           │
+├─────────────────────────────────────────────┤
+│                                             │
+│           ○ 张三                            │
+│          /  \                               │
+│     友好/   \敌对                           │
+│        /    \                               │
+│    ○ 李四───○ 王五                          │
+│        中立                                 │
+│                                             │
+│  图例:  ███ 友好  ███ 中立  ███ 敌对        │
+│                                             │
+└─────────────────────────────────────────────┘
+```
+
+### 3.3 详细设计
+
+**技术选型**:复用项目已有的 `cytoscape.js`(已 vendor 打包)
+
+**节点样式**:
+- 圆形节点,显示角色名字
+- 节点大小:按角色重要性(involvedCharacters 中的角色更大)
+- 悬停显示角色简介
+
+**连线样式**:
+| 好感度 | 等级 | 颜色 | 粗细 |
+|---|---|---|---|
+| >= 60 | 亲密盟友 | #10b981 (绿) | 4px |
+| 20-60 | 友好 | #34d399 (浅绿) | 3px |
+| -20~20 | 中立 | #9ca3af (灰) | 2px |
+| -60~-20 | 敌对 | #f87171 (浅红) | 3px |
+| <= -60 | 死敌 | #ef4444 (红) | 4px |
+| 无交互 | 未接触 | 虚线 灰色 | 1px |
+
+**交互**:
+- 拖动节点调整位置
+- 滚轮缩放
+- 点击节点高亮该角色的所有连线
+- 悬停连线显示具体好感度数值
+
+**数据更新**:
+- 每轮事件后自动刷新关系图数据
+- 从各 Agent 的 `memory.sentiments` 中读取好感度
+- 双向关系取两个方向的平均值(A 对 B 的好感 + B 对 A 的好感)/ 2
+
+**备用视图**:
+- 如果 cytoscape 加载有问题,提供列表视图作为 fallback
+- 列表视图:角色对列表,显示双方名字、好感度数值、关系标签
+
+### 3.4 改动文件
+
+- `src/components/story-simulation/story-simulation-view.tsx` — 新增「关系」Tab
+- 新增 `src/components/story-simulation/relationship-graph-panel.tsx` — 关系图组件
+
+### 3.5 风险
+
+- cytoscape bundle 较大,但项目已 vendor 打包,应该可直接 import
+- 关系图布局算法:用 cose 布局,节点多时可能重叠
+- 双向 sentiments 可能不对称(A 喜欢 B 但 B 不喜欢 A),取平均是简化处理
+
+## 四、任务 12:多结局分支对比与推荐
+
+### 4.1 目标
+
+推演室支持保存多个分支结果,自动对比各分支并给出推荐。
+
+### 4.2 面板结构
+
+**分支列表面板**:
+```
+┌─ 分支管理 ───────────────────────────────────┐
+│  [+ 保存当前结果为新分支]                    │
+├─────────────────────────────────────────────┤
+│ 🏆 分支A (推荐)  综合评分: 4.2              │
+│    自由涌现模式 · 2026-07-04 10:30          │
+│    [查看] [重命名] [删除] [对比]            │
+├─────────────────────────────────────────────┤
+│ 分支B              综合评分: 3.8              │
+│    事件驱动模式 · 2026-07-04 10:15          │
+│    [查看] [重命名] [删除] [对比]            │
+├─────────────────────────────────────────────┤
+│ 分支C              综合评分: 3.5              │
+│    决策树模式 · 2026-07-04 09:45            │
+│    [查看] [重命名] [删除] [对比]            │
+└─────────────────────────────────────────────┘
+```
+
+**对比视图**:
+```
+┌─ 分支对比 ───────────────────────────────────┐
+│  分支A (推荐) vs 分支B                       │
+├───────────────┬───────────────┬─────────────┤
+│  维度         │  分支A        │ 分支B        │
+├───────────────┼───────────────┼─────────────┤
+│ 剧情张力      │  ████████ 4.2 │ ██████ 3.5   │
+│ 节奏把控      │  ███████ 3.8  │ ██████ 3.6   │
+│ 角色发挥      │  ████████ 4.5 │ █████ 3.0    │
+│ 人物弧光      │  ██████ 3.5   │ ███████ 4.0  │
+│ 信息密度      │  ███████ 4.0  │ ██████ 3.5   │
+│ 情感共鸣      │  ███████ 3.8  │ █████ 3.2    │
+│ 逻辑自洽      │  ████████ 4.3 │ ███████ 4.1  │
+├───────────────┴───────────────┴─────────────┤
+│ 【推荐理由】                                 │
+│ 分支A在角色发挥和剧情张力上明显优于分支B,    │
+│ 推荐选择分支A作为主线。                      │
+└─────────────────────────────────────────────┘
+```
+
+### 4.3 详细设计
+
+**分支数据结构**:
+```typescript
+interface SimulationBranch {
+  id: string
+  name: string
+  frameworkId: string
+  mode: SimulationMode
+  createdAt: string
+  timelineEvents: TimelineEvent[]
+  rumors: RumorEvent[]
+  finalAgents: NovelAgent[]
+  directorEvaluations: DirectorEvaluation[]  // 各节点导演评分
+  overallScore: number
+  scoreDetails: {
+    avgDirectorScore: number
+    eventCount: number
+    characterDiversity: number
+    plotProgression: number
+  }
+}
+```
+
+**综合评分算法**:
+- `avgDirectorScore * 0.5` — 导演评分均值(权重最高)
+- `eventCount / 20 * 0.2` — 事件丰富度(归一化到 0-5)
+- `characterDiversity * 0.15` — 角色参与度(有行为的角色比例)
+- `plotProgression * 0.15` — 剧情推进度(达成目标的节点比例)
+
+**存储方式**:
+- 内存存储:`story-simulation-store` 中维护 branches 数组
+- 不持久化到本地(页面刷新后丢失),四期先做内存版
+- 分支数量上限:10 个(超出提示用户删除旧的)
+
+**核心操作**:
+- 保存分支:将当前仿真状态快照为分支
+- 切换查看:加载某个分支的事件和状态到主视图
+- 重命名:修改分支名称
+- 删除:移除分支
+- 对比:选中 2-3 个分支,显示对比表格和推荐理由
+
+### 4.4 改动文件
+
+- `src/stores/story-simulation-store.ts` — 新增 branches 状态和操作方法
+- `src/components/story-simulation/story-simulation-view.tsx` — 新增分支面板入口
+- 新增 `src/components/story-simulation/branch-manager-panel.tsx` — 分支管理面板
+- 新增 `src/components/story-simulation/branch-compare-view.tsx` — 分支对比视图
+
+### 4.5 风险
+
+- 内存占用:每个分支保存完整事件数据,10 个分支可能几十 MB。短期可接受。
+- 切换分支需要重新渲染主视图,需确保状态切换流畅
+- 综合评分算法是启发式的,可能不够准确,但作为参考够用
+
+## 五、任务 13:调查线索板
+
+### 5.1 目标
+
+在过程观察面板新增「线索板」Tab,展示各角色已知的秘密/线索及其关联。
+
+### 5.2 面板结构
+
+```
+┌─ 线索板 ────────────────────────────────────┐
+│  [张三] [李四] [王五] [全部角色]             │
+├─────────────────────────────────────────────┤
+│  ┌──────┐  ┌──────┐  ┌──────┐             │
+│  │ 🟢已证 │  │ 🟡传闻 │  │ 🔵调查 │             │
+│  │ 李四修 │  │ 王五藏 │  │ 赵六私 │             │
+│  │ 炼禁术 │  │ 了宝 │  │ 通敌国 │             │
+│  │ 第3轮  │  │ 第5轮  │  │ 第7轮  │             │
+│  └──────┘  └──────┘  └──────┘             │
+│      \         |         /                 │
+│       \        |        /                  │
+│         ┌──────┐                           │
+│         │ 🟢已证 │                           │
+│         │ 城主是 │                           │
+│         │ 幕后黑手│                          │
+│         │ 第8轮  │                           │
+│         └──────┘                           │
+│                                             │
+│ 【选中线索详情】                             │
+│  内容:李四偷偷修炼禁术                      │
+│  来源:亲眼所见(第3轮,后山相遇)           │
+│  相关角色:李四、张三                        │
+│  关联线索:城主是幕后黑手                    │
+└─────────────────────────────────────────────┘
+```
+
+### 5.3 详细设计
+
+**数据来源**:
+- `agent.memory.knownSecrets` — 已知秘密(已证实的线索)
+- `blackboard.visibleRumorsByAgent.get(agentId)` — 听到的传闻(待验证线索)
+- `agent.memory.observedEvents` — 观察到的重要事件(筛选 pushPlot 类型)
+
+**线索卡片分类**:
+| 类型 | 标记 | 颜色 | 来源 |
+|---|---|---|---|
+| 亲眼所见 | 🟢 已证实 | 绿色边框 | observedEvents 中的公开事件 |
+| 调查证实 | 🔵 已验证 | 蓝色边框 | investigate confirmed 的传闻 |
+| 传闻得知 | 🟡 待验证 | 黄色边框 | visibleRumors 中未验证的传闻 |
+| 他人告知 | 🟣 二手信息 | 紫色边框 | inquire/speak 获得的信息 |
+
+**关联计算**:
+- 简化版:按关键词重叠度计算(两条线索共享 2 个以上关键词则有关联)
+- 用虚线连接有关联的线索卡片
+- 点击某条线索,高亮其关联线索
+
+**角色切换**:
+- 顶部角色 tabs,点击切换查看不同角色的线索
+- 「全部角色」视图:所有角色的线索放在一起,用颜色区分所属角色
+
+### 5.4 改动文件
+
+- `src/components/story-simulation/story-simulation-view.tsx` — 新增「线索板」Tab
+- 新增 `src/components/story-simulation/clue-board-panel.tsx` — 线索板组件
+
+### 5.5 风险
+
+- 已知秘密可能很多,卡片墙布局需要考虑滚动和自适应
+- 关键词关联算法简单,可能有不准确的关联
+- 线索卡片信息密度高,需注意排版不要太拥挤
+
+## 六、整体改动清单
+
+| 任务 | 新增文件 | 修改文件 |
+|---|---|---|
+| 10 传闻传播链 | `rumor-propagation-panel.tsx` | `story-simulation-view.tsx` |
+| 11 角色关系图 | `relationship-graph-panel.tsx` | `story-simulation-view.tsx` |
+| 12 多结局分支 | `branch-manager-panel.tsx`, `branch-compare-view.tsx` | `story-simulation-store.ts`, `story-simulation-view.tsx` |
+| 13 调查线索板 | `clue-board-panel.tsx` | `story-simulation-view.tsx` |
+
+**合计**:新增 5 个组件 + 修改 2 个文件
+
+## 七、风险与注意事项
+
+1. **cytoscape 体积**:关系图依赖 cytoscape,已 vendor 打包,确认可以直接 import
+2. **四期都是 UI 工作**:不改动核心仿真引擎,风险较低
+3. **不影响核心功能**:所有新增都是可视化面板,不影响仿真逻辑
+4. **内存分支不持久化**:页面刷新丢失,四期先做内存版,后续可加持久化
+5. **面板过多**:过程观察面板会有较多 Tab(事件/日志 + 传闻 + 关系 + 线索 + 分支),注意 Tab 排版
+6. **向后兼容**:所有改动都是增量,不修改已有组件的 props 和行为

+ 979 - 0
juqingtuiyanshisanqi-实施计划.md

@@ -0,0 +1,979 @@
+# 剧情推演室四期 Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 在推演室过程观察面板中新增 4 个可视化 Tab(传闻传播链、角色关系图、调查线索板),以及多结局分支管理与对比功能。
+
+**Architecture:** 全部为 UI 层增量,不改动核心仿真引擎。4 个面板组件独立,通过 props 接收 store 中的数据。多结局分支在 store 层新增状态管理。
+
+**Tech Stack:** React + TypeScript + Zustand + Tailwind CSS + cytoscape.js(已 vendor 打包)
+
+## Global Constraints
+
+- 分支:`juqingtuiyanshierqi`(在三期基础上叠加)
+- 所有面向用户的提示语使用中文
+- 不删除已有函数和组件
+- 不顺手重构无关代码
+- 保持与现有代码风格一致
+- 4 个任务全部为 UI/交互增强,不改动仿真引擎逻辑
+- cytoscape 从 vendor 包引入,不新增依赖
+
+---
+
+## Task 10: 传闻传播链可视化面板
+
+**Files:**
+- Modify: `src/components/novel/story-simulation/story-simulation-view.tsx` — 过程观察面板加 Tab 切换,新增「传闻」Tab
+- Create: `src/components/novel/story-simulation/rumor-propagation-panel.tsx` — 传闻传播面板组件
+- Test: (纯 UI 组件,不写单测,通过 dev 模式手动验证)
+
+**Interfaces:**
+- Consumes:
+  - `blackboard.rumors: RumorEvent[]` — 全部传闻
+  - `blackboard.visibleRumorsByAgent: Map<string, RumorEvent[]>` — 各角色可见传闻
+  - `blackboard.allAgents: Map<string, NovelAgent>` — 所有角色
+  - `events: TimelineEvent[]` — 时间线事件(用于找源事件)
+- Produces:
+  - `RumorPropagationPanel` React 组件,props: `{ rumors, visibleRumorsByAgent, agents, events }`
+
+- [ ] **Step 1: 确认数据来源**
+
+先读 `types.ts` 确认 `RumorEvent` 结构,读 `story-simulation-view.tsx` 确认过程观察面板的 Tab 切换方式。
+
+- [ ] **Step 2: 创建 RumorPropagationPanel 组件骨架**
+
+```tsx
+// src/components/novel/story-simulation/rumor-propagation-panel.tsx
+import { useState, useMemo } from "react"
+import type { RumorEvent, NovelAgent, TimelineEvent } from "@/lib/novel/story-simulation/types"
+
+type RumorFilter = "all" | "unverified" | "confirmed" | "debunked"
+
+interface RumorPropagationPanelProps {
+  rumors: RumorEvent[]
+  visibleRumorsByAgent: Map<string, RumorEvent[]>
+  agents: Map<string, NovelAgent>
+  events: TimelineEvent[]
+}
+
+export function RumorPropagationPanel({ rumors, visibleRumorsByAgent, agents, events }: RumorPropagationPanelProps) {
+  const [filter, setFilter] = useState<RumorFilter>("all")
+  const [selectedRumorId, setSelectedRumorId] = useState<string | null>(null)
+
+  const filteredRumors = useMemo(() => {
+    return rumors.filter((r) => {
+      if (filter === "all") return true
+      if (filter === "unverified") return r.verifiedBy.length === 0
+      if (filter === "confirmed") return r.verifiedBy.length > 0 && r.believedBy.length > 0
+      if (filter === "debunked") return r.verifiedBy.length > 0 && r.believedBy.length === 0
+      return true
+    })
+  }, [rumors, filter])
+
+  const selectedRumor = useMemo(
+    () => rumors.find((r) => r.id === selectedRumorId) || null,
+    [rumors, selectedRumorId],
+  )
+
+  if (rumors.length === 0) {
+    return (
+      <div className="flex flex-1 items-center justify-center rounded-lg border bg-muted/30 p-6 text-xs text-muted-foreground">
+        暂无传闻数据
+      </div>
+    )
+  }
+
+  return (
+    <div className="flex min-h-0 flex-1 gap-3">
+      {/* 左侧:传闻列表 */}
+      <div className="flex w-1/3 flex-col gap-2 overflow-hidden">
+        {/* 筛选标签 */}
+        <div className="flex gap-1 text-xs">
+          {[
+            { key: "all", label: "全部" },
+            { key: "unverified", label: "未验证" },
+            { key: "confirmed", label: "已验证" },
+            { key: "debunked", label: "已证伪" },
+          ].map((f) => (
+            <button
+              key={f.key}
+              type="button"
+              className={`rounded px-2 py-1 ${filter === f.key ? "bg-primary text-primary-foreground" : "bg-muted hover:bg-muted/80"}`}
+              onClick={() => setFilter(f.key as RumorFilter)}
+            >
+              {f.label}
+            </button>
+          ))}
+        </div>
+        {/* 传闻卡片列表 */}
+        <div className="flex-1 space-y-2 overflow-y-auto">
+          {filteredRumors.map((rumor) => (
+            <div
+              key={rumor.id}
+              className={`cursor-pointer rounded-md border p-2 text-xs ${
+                selectedRumorId === rumor.id ? "border-primary bg-primary/5" : "bg-background/70 hover:bg-background"
+              }`}
+              onClick={() => setSelectedRumorId(rumor.id)}
+            >
+              <div className="mb-1 flex items-center gap-1">
+                <span
+                  className={`rounded px-1 py-0.5 text-[10px] ${
+                    rumor.distortion < 0.3
+                      ? "bg-green-100 text-green-700"
+                      : rumor.distortion < 0.6
+                        ? "bg-yellow-100 text-yellow-700"
+                        : "bg-red-100 text-red-700"
+                  }`}
+                >
+                  失真 {Math.round(rumor.distortion * 100)}%
+                </span>
+                <span className="text-[10px] text-muted-foreground">R{rumor.round + 1}</span>
+              </div>
+              <div className="line-clamp-2">{rumor.content}</div>
+              <div className="mt-1 text-[10px] text-muted-foreground">
+                {rumor.believedBy.length} 人相信 · {rumor.verifiedBy.length} 人验证
+              </div>
+            </div>
+          ))}
+        </div>
+      </div>
+
+      {/* 右侧:传播链详情 */}
+      <div className="flex-1 overflow-y-auto rounded-md border bg-background/50 p-3">
+        {!selectedRumor ? (
+          <div className="flex h-full items-center justify-center text-xs text-muted-foreground">
+            点击左侧传闻查看传播链
+          </div>
+        ) : (
+          <div className="space-y-3 text-xs">
+            <div>
+              <div className="mb-1 text-[11px] font-medium text-muted-foreground">传闻内容</div>
+              <div className="rounded bg-muted/50 p-2 text-sm">{selectedRumor.content}</div>
+            </div>
+
+            <div>
+              <div className="mb-2 text-[11px] font-medium text-muted-foreground">传播链时间线</div>
+              <div className="space-y-2">
+                {/* 第 N 轮:生成 */}
+                <div className="flex gap-2">
+                  <div className="flex flex-col items-center">
+                    <div className="h-3 w-3 rounded-full bg-primary" />
+                    <div className="w-px flex-1 bg-border" />
+                  </div>
+                  <div className="pb-3">
+                    <div className="text-[11px] text-muted-foreground">第 {selectedRumor.round + 1} 轮 · 传闻生成</div>
+                    <div className="mt-0.5">
+                      {selectedRumor.sourceId
+                        ? `来源事件: ${events.find((e) => e.id === selectedRumor.sourceId)?.content?.slice(0, 30) || "未知"}...`
+                        : "来源不明"}
+                    </div>
+                  </div>
+                </div>
+
+                {/* 角色可见 */}
+                {Array.from(visibleRumorsByAgent.entries())
+                  .filter(([_, rs]) => rs.some((r) => r.id === selectedRumor.id))
+                  .map(([agentId, _]) => {
+                    const agent = agents.get(agentId)
+                    const isVerified = selectedRumor.verifiedBy.includes(agentId)
+                    const believes = selectedRumor.believedBy.includes(agentId)
+                    return (
+                      <div key={agentId} className="flex gap-2">
+                        <div className="flex flex-col items-center">
+                          <div
+                            className={`h-3 w-3 rounded-full ${
+                              isVerified
+                                ? believes
+                                  ? "bg-green-500"
+                                  : "bg-red-500"
+                                : "bg-muted-foreground"
+                            }`}
+                          />
+                          <div className="w-px flex-1 bg-border" />
+                        </div>
+                        <div className="pb-3">
+                          <div className="text-[11px] text-muted-foreground">
+                            {agent?.name || agentId} 看到传闻
+                          </div>
+                          {isVerified && (
+                            <div className="mt-0.5">
+                              调查结果:
+                              <span className={believes ? "text-green-600" : "text-red-600"}>
+                                {believes ? "确认属实" : "证伪"}
+                              </span>
+                            </div>
+                          )}
+                        </div>
+                      </div>
+                    )
+                  })}
+              </div>
+            </div>
+          </div>
+        )}
+      </div>
+    </div>
+  )
+}
+```
+
+- [ ] **Step 3: 在 story-simulation-view.tsx 中集成**
+
+1. 在 `SimulationInProgressPanel` 组件的 props 中新增所需数据(rumors, visibleRumorsByAgent, agents)
+2. 过程观察面板的 Tab 从 `timeline | debug` 扩展为 5 个 Tab,新增子 Tab 切换
+
+**具体改动**:
+- `activeStreamView` 保持 `timeline | debug`(顶层 Tab)
+- 在 debug 视图内,`ProcessDebugPanel` 内部加二级 Tab:概览 / 传闻 / 关系 / 线索
+- 或者:直接把顶层 Tab 扩展为 5 个
+
+推荐方案:在 `ProcessDebugPanel` 内部加二级 Tab,保持顶层「时间线 / 过程观察」不变。
+
+- [ ] **Step 4: 给 ProcessDebugPanel 传数据**
+
+从 store 获取 rumors 和 agents 数据,传给 `ProcessDebugPanel`,再传给 `RumorPropagationPanel`。
+
+数据来源:
+- 目前 store 中没有 rumors 和 allAgents,需要从 debugTraces 的最新一条中提取
+- 或者:在 store 中新增 `currentBlackboard` 状态,仿真过程中实时更新
+
+**简化方案**:从 `latestTrace.blackboard` 中提取数据(需要确认 SimulationDebugTrace 中有没有完整 rumors 数据)。如果没有,新增字段到 trace 里。
+
+先检查 `SimulationDebugTrace` 的结构,确认能否获取 rumors 和 agents。
+
+- [ ] **Step 5: 验证**
+
+Run: `npm run dev`
+- 打开推演室,开启仿真
+- 切换到过程观察 → 传闻 Tab
+- 确认传闻列表显示正确
+- 点击传闻卡片,右侧传播链正常显示
+
+Run: `npx tsc --noEmit`
+Expected: 0 errors
+
+---
+
+## Task 11: 角色关系图动态更新
+
+**Files:**
+- Create: `src/components/novel/story-simulation/relationship-graph-panel.tsx` — 关系图组件
+- Modify: `src/components/novel/story-simulation/story-simulation-view.tsx` — 集成「关系」Tab
+
+**Interfaces:**
+- Consumes:
+  - `agents: Map<string, NovelAgent>` — 所有角色及其 sentiments
+- Produces:
+  - `RelationshipGraphPanel` React 组件,props: `{ agents }`
+
+- [ ] **Step 1: 确认 cytoscape 可用方式**
+
+检查项目中 cytoscape 的引入方式(vendor 打包),确认 import 语句。
+
+- [ ] **Step 2: 创建 RelationshipGraphPanel 组件**
+
+```tsx
+// src/components/novel/story-simulation/relationship-graph-panel.tsx
+import { useEffect, useRef } from "react"
+import cytoscape from "cytoscape"
+import type { NovelAgent } from "@/lib/novel/story-simulation/types"
+
+interface RelationshipGraphPanelProps {
+  agents: Map<string, NovelAgent>
+}
+
+function sentimentToColor(value: number): string {
+  if (value >= 60) return "#10b981" // 深绿 - 亲密盟友
+  if (value >= 20) return "#34d399" // 浅绿 - 友好
+  if (value >= -20) return "#9ca3af" // 灰 - 中立
+  if (value >= -60) return "#f87171" // 浅红 - 敌对
+  return "#ef4444" // 深红 - 死敌
+}
+
+function sentimentToWidth(value: number): number {
+  const abs = Math.abs(value)
+  return Math.max(1, Math.min(5, abs / 25 + 1))
+}
+
+function sentimentToLabel(value: number): string {
+  if (value >= 60) return "亲密盟友"
+  if (value >= 20) return "友好"
+  if (value >= -20) return "中立"
+  if (value >= -60) return "敌对"
+  return "死敌"
+}
+
+export function RelationshipGraphPanel({ agents }: RelationshipGraphPanelProps) {
+  const containerRef = useRef<HTMLDivElement>(null)
+  const cyRef = useRef<cytoscape.Core | null>(null)
+
+  useEffect(() => {
+    if (!containerRef.current) return
+
+    // 构建节点和边
+    const nodes: cytoscape.ElementDefinition[] = []
+    const edges: cytoscape.ElementDefinition[] = []
+
+    agents.forEach((agent, id) => {
+      nodes.push({
+        data: { id, label: agent.name },
+      })
+    })
+
+    // 收集所有关系对(去重,A-B 和 B-A 只取一次)
+    const seenPairs = new Set<string>()
+    agents.forEach((agent, id) => {
+      agent.memory.sentiments.forEach((value, targetId) => {
+        const pairKey = [id, targetId].sort().join("-")
+        if (seenPairs.has(pairKey)) return
+        seenPairs.add(pairKey)
+
+        // 双向取平均
+        const reverseValue = agents.get(targetId)?.memory.sentiments.get(id) ?? value
+        const avgValue = (value + reverseValue) / 2
+
+        edges.push({
+          data: {
+            id: pairKey,
+            source: id,
+            target: targetId,
+            label: sentimentToLabel(avgValue),
+            color: sentimentToColor(avgValue),
+            width: sentimentToWidth(avgValue),
+            value: Math.round(avgValue),
+          },
+        })
+      })
+    })
+
+    // 初始化或更新 cytoscape
+    if (!cyRef.current) {
+      cyRef.current = cytoscape({
+        container: containerRef.current,
+        elements: [...nodes, ...edges],
+        style: [
+          {
+            selector: "node",
+            style: {
+              "background-color": "#6366f1",
+              "label": "data(label)",
+              "text-valign": "center",
+              "text-halign": "center",
+              "color": "#fff",
+              "font-size": "11px",
+              "width": "40px",
+              "height": "40px",
+            },
+          },
+          {
+            selector: "edge",
+            style: {
+              "width": "data(width)",
+              "line-color": "data(color)",
+              "target-arrow-color": "data(color)",
+              "target-arrow-shape": "none",
+              "curve-style": "bezier",
+              "label": "data(label)",
+              "font-size": "9px",
+              "text-rotation": "autorotate",
+              "color": "#6b7280",
+            },
+          },
+        ],
+        layout: {
+          name: "cose",
+          animate: true,
+          animationDuration: 500,
+        },
+        wheelSensitivity: 0.2,
+      })
+    } else {
+      // 更新数据
+      cyRef.current.elements().remove()
+      cyRef.current.add([...nodes, ...edges])
+      cyRef.current.layout({ name: "cose", animate: true, animationDuration: 300 }).run()
+    }
+
+    return () => {
+      // 组件卸载时不销毁,由 useEffect 下次更新复用
+    }
+  }, [agents])
+
+  return (
+    <div className="flex flex-1 flex-col gap-2">
+      {/* 图例 */}
+      <div className="flex flex-wrap items-center gap-3 text-[10px] text-muted-foreground">
+        <span className="flex items-center gap-1">
+          <span className="inline-block h-2 w-4 rounded bg-emerald-500" />
+          友好
+        </span>
+        <span className="flex items-center gap-1">
+          <span className="inline-block h-2 w-4 rounded bg-gray-400" />
+          中立
+        </span>
+        <span className="flex items-center gap-1">
+          <span className="inline-block h-2 w-4 rounded bg-red-500" />
+          敌对
+        </span>
+      </div>
+      {/* 关系图容器 */}
+      <div
+        ref={containerRef}
+        className="min-h-0 flex-1 rounded-md border bg-background/50"
+        style={{ minHeight: "300px" }}
+      />
+    </div>
+  )
+}
+```
+
+- [ ] **Step 3: 在 ProcessDebugPanel 中集成「关系」Tab**
+
+在 ProcessDebugPanel 内的二级 Tab 中新增「关系」,点击显示 RelationshipGraphPanel。
+
+- [ ] **Step 4: 验证**
+
+Run: `npm run dev`
+- 打开推演室,开启仿真
+- 切换到过程观察 → 关系 Tab
+- 确认关系图正常渲染,节点和连线显示正确
+- 拖动节点、缩放功能正常
+
+Run: `npx tsc --noEmit`
+Expected: 0 errors
+
+**注意事项**:
+- cytoscape 需要 DOM 容器有确定的高度,确保 flex 布局下容器能正确获取高度
+- agents 更新时可能触发频繁重绘,考虑加 debounce 或只在每轮结束时更新
+
+---
+
+## Task 12: 多结局分支对比与推荐
+
+**Files:**
+- Modify: `src/stores/story-simulation-store.ts` — 新增 branches 状态和方法
+- Create: `src/components/novel/story-simulation/branch-manager-panel.tsx` — 分支管理面板
+- Modify: `src/components/novel/story-simulation/story-simulation-view.tsx` — 集成分支面板入口
+- Test: (UI 组件不写单测,store 方法可写测试)
+
+**Interfaces:**
+- Consumes: 当前仿真状态(timelineEvents, agents, directorEvaluations 等)
+- Produces:
+  - `SimulationBranch` 类型
+  - store 方法:`saveBranch(name)`, `deleteBranch(id)`, `renameBranch(id, name)`, `switchToBranch(id)`, `getBranchScore(id)`
+  - `BranchManagerPanel` React 组件
+
+- [ ] **Step 1: 在 types.ts 中新增 SimulationBranch 类型**
+
+```typescript
+export interface SimulationBranch {
+  id: string
+  name: string
+  frameworkId: string
+  mode: SimulationMode
+  createdAt: string
+  timelineEvents: TimelineEvent[]
+  rumors: RumorEvent[]
+  finalAgentSnapshots: { agentId: string; name: string; knownSecrets: string[]; sentiments: [string, number][] }[]
+  directorEvaluations: DirectorEvaluation[]
+  overallScore: number
+  scoreDetails: {
+    avgDirectorScore: number
+    eventCount: number
+    characterDiversity: number
+    plotProgression: number
+  }
+}
+```
+
+- [ ] **Step 2: 在 store 中新增分支管理状态**
+
+新增状态字段:
+```typescript
+branches: SimulationBranch[]
+activeBranchId: string | null
+```
+
+新增方法:
+```typescript
+saveCurrentAsBranch: (name: string) => void
+deleteBranch: (id: string) => void
+renameBranch: (id: string, name: string) => void
+switchToBranch: (id: string) => void
+clearBranches: () => void
+```
+
+**综合评分计算函数**(纯函数,便于测试):
+```typescript
+export function calculateBranchScore(
+  directorEvaluations: DirectorEvaluation[],
+  eventCount: number,
+  activeAgentCount: number,
+  totalAgentCount: number,
+  goalProgress: number, // 0-1
+): { overallScore: number; details: {...} } {
+  const avgDir = directorEvaluations.length > 0
+    ? directorEvaluations.reduce((s, e) => s + e.totalScore, 0) / directorEvaluations.length
+    : 3.0
+  const eventScore = Math.min(5, eventCount / 4) // 20 条事件 = 5 分
+  const charScore = totalAgentCount > 0 ? (activeAgentCount / totalAgentCount) * 5 : 3
+  const plotScore = goalProgress * 5
+
+  const overall = avgDir * 0.5 + eventScore * 0.2 + charScore * 0.15 + plotScore * 0.15
+  return {
+    overallScore: Math.round(overall * 10) / 10,
+    details: {
+      avgDirectorScore: Math.round(avgDir * 10) / 10,
+      eventCount,
+      characterDiversity: Math.round(charScore * 10) / 10,
+      plotProgression: Math.round(plotScore * 10) / 10,
+    },
+  }
+}
+```
+
+- [ ] **Step 3: 创建 BranchManagerPanel 组件**
+
+```tsx
+// src/components/novel/story-simulation/branch-manager-panel.tsx
+import { useState } from "react"
+import type { SimulationBranch } from "@/lib/novel/story-simulation/types"
+
+interface BranchManagerPanelProps {
+  branches: SimulationBranch[]
+  activeBranchId: string | null
+  onSaveBranch: (name: string) => void
+  onDeleteBranch: (id: string) => void
+  onRenameBranch: (id: string, name: string) => void
+  onSwitchBranch: (id: string) => void
+  onCompare?: (ids: string[]) => void
+}
+
+export function BranchManagerPanel({
+  branches,
+  activeBranchId,
+  onSaveBranch,
+  onDeleteBranch,
+  onRenameBranch,
+  onSwitchBranch,
+}: BranchManagerPanelProps) {
+  const [newName, setNewName] = useState("")
+  const [editingId, setEditingId] = useState<string | null>(null)
+  const [editName, setEditName] = useState("")
+
+  const sortedBranches = [...branches].sort((a, b) => b.overallScore - a.overallScore)
+  const bestScore = sortedBranches[0]?.overallScore ?? 0
+
+  return (
+    <div className="flex flex-1 flex-col gap-3">
+      {/* 保存新分支 */}
+      <div className="flex gap-2">
+        <input
+          type="text"
+          value={newName}
+          onChange={(e) => setNewName(e.target.value)}
+          placeholder="分支名称"
+          className="flex-1 rounded border border-input bg-background px-2 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
+        />
+        <button
+          type="button"
+          className="rounded bg-primary px-3 py-1 text-xs text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
+          onClick={() => {
+            if (newName.trim()) {
+              onSaveBranch(newName.trim())
+              setNewName("")
+            }
+          }}
+          disabled={!newName.trim() || branches.length >= 10}
+        >
+          保存当前结果
+        </button>
+      </div>
+
+      {branches.length >= 10 && (
+        <div className="text-[10px] text-amber-600">分支数量已达上限(10 个),请删除旧分支后再保存</div>
+      )}
+
+      {/* 分支列表 */}
+      <div className="flex-1 space-y-2 overflow-y-auto">
+        {sortedBranches.length === 0 ? (
+          <div className="py-8 text-center text-xs text-muted-foreground">暂无保存的分支</div>
+        ) : (
+          sortedBranches.map((branch, idx) => (
+            <div
+              key={branch.id}
+              className={`rounded-md border p-2 text-xs ${
+                activeBranchId === branch.id ? "border-primary bg-primary/5" : "bg-background/70"
+              }`}
+            >
+              <div className="mb-1 flex items-center gap-2">
+                {idx === 0 && branches.length > 1 && (
+                  <span className="rounded bg-amber-100 px-1 py-0.5 text-[10px] text-amber-700">推荐</span>
+                )}
+                {editingId === branch.id ? (
+                  <input
+                    type="text"
+                    value={editName}
+                    onChange={(e) => setEditName(e.target.value)}
+                    onBlur={() => {
+                      if (editName.trim()) onRenameBranch(branch.id, editName.trim())
+                      setEditingId(null)
+                    }}
+                    onKeyDown={(e) => {
+                      if (e.key === "Enter") {
+                        if (editName.trim()) onRenameBranch(branch.id, editName.trim())
+                        setEditingId(null)
+                      }
+                    }}
+                    className="flex-1 rounded border px-1 py-0.5 text-xs outline-none focus:ring-1 focus:ring-ring"
+                    autoFocus
+                  />
+                ) : (
+                  <span className="flex-1 font-medium">{branch.name}</span>
+                )}
+                <span className="text-[11px] font-semibold text-primary">{branch.overallScore.toFixed(1)}</span>
+              </div>
+              <div className="mb-2 text-[10px] text-muted-foreground">
+                {branch.mode === "free" ? "自由涌现" : branch.mode === "event-driven" ? "事件驱动" : "决策树"} · {new Date(branch.createdAt).toLocaleString()}
+              </div>
+              <div className="flex gap-1">
+                <button
+                  type="button"
+                  className="rounded bg-muted px-2 py-0.5 text-[10px] hover:bg-muted/80"
+                  onClick={() => onSwitchBranch(branch.id)}
+                >
+                  查看
+                </button>
+                <button
+                  type="button"
+                  className="rounded bg-muted px-2 py-0.5 text-[10px] hover:bg-muted/80"
+                  onClick={() => {
+                    setEditingId(branch.id)
+                    setEditName(branch.name)
+                  }}
+                >
+                  重命名
+                </button>
+                <button
+                  type="button"
+                  className="rounded bg-red-100 px-2 py-0.5 text-[10px] text-red-700 hover:bg-red-200"
+                  onClick={() => {
+                    if (confirm(`确定删除分支"${branch.name}"?`)) {
+                      onDeleteBranch(branch.id)
+                    }
+                  }}
+                >
+                  删除
+                </button>
+              </div>
+            </div>
+          ))
+        )}
+      </div>
+    </div>
+  )
+}
+```
+
+- [ ] **Step 4: 在推演室主视图中集成分支面板**
+
+在推演完成后的报告阶段或推演过程中,侧边栏新增「分支管理」入口。
+
+具体集成位置:在 `story-simulation-view.tsx` 中,找到右侧面板区域,新增 BranchManagerPanel。
+
+- [ ] **Step 5: 验证**
+
+Run: `npm run dev`
+- 打开推演室,完成一次仿真
+- 保存当前结果为分支 A
+- 再做一次仿真,保存为分支 B
+- 确认分支列表正确排序,最高分的标「推荐」
+- 切换查看不同分支,时间线正确切换
+- 重命名、删除功能正常
+
+Run: `npx tsc --noEmit`
+Expected: 0 errors
+
+---
+
+## Task 13: 调查线索板
+
+**Files:**
+- Create: `src/components/novel/story-simulation/clue-board-panel.tsx` — 线索板组件
+- Modify: `src/components/novel/story-simulation/story-simulation-view.tsx` — 集成「线索」Tab
+
+**Interfaces:**
+- Consumes:
+  - `agents: Map<string, NovelAgent>` — 所有角色(knownSecrets, observedEvents, sentiments)
+  - `visibleRumorsByAgent: Map<string, RumorEvent[]>` — 各角色可见传闻
+  - `rumors: RumorEvent[]` — 全部传闻(用于找验证状态)
+- Produces:
+  - `ClueBoardPanel` React 组件,props: `{ agents, visibleRumorsByAgent, rumors }`
+
+- [ ] **Step 1: 创建 ClueBoardPanel 组件**
+
+```tsx
+// src/components/novel/story-simulation/clue-board-panel.tsx
+import { useState, useMemo } from "react"
+import type { NovelAgent, RumorEvent } from "@/lib/novel/story-simulation/types"
+
+type ClueType = "observed" | "confirmed" | "rumor" | "told"
+
+interface ClueItem {
+  id: string
+  content: string
+  type: ClueType
+  source: string
+  round: number
+  agentId: string
+  agentName: string
+}
+
+interface ClueBoardPanelProps {
+  agents: Map<string, NovelAgent>
+  visibleRumorsByAgent: Map<string, RumorEvent[]>
+  rumors: RumorEvent[]
+}
+
+function clueTypeLabel(type: ClueType): string {
+  switch (type) {
+    case "observed": return "亲眼所见"
+    case "confirmed": return "调查证实"
+    case "rumor": return "传闻得知"
+    case "told": return "他人告知"
+  }
+}
+
+function clueTypeColor(type: ClueType): string {
+  switch (type) {
+    case "observed": return "border-green-400 bg-green-50 text-green-700"
+    case "confirmed": return "border-blue-400 bg-blue-50 text-blue-700"
+    case "rumor": return "border-yellow-400 bg-yellow-50 text-yellow-700"
+    case "told": return "border-purple-400 bg-purple-50 text-purple-700"
+  }
+}
+
+export function ClueBoardPanel({ agents, visibleRumorsByAgent, rumors }: ClueBoardPanelProps) {
+  const [selectedAgentId, setSelectedAgentId] = useState<string | null>(
+    agents.size > 0 ? agents.keys().next().value : null,
+  )
+  const [selectedClueId, setSelectedClueId] = useState<string | null>(null)
+
+  // 构建所有线索
+  const cluesByAgent = useMemo(() => {
+    const result = new Map<string, ClueItem[]>()
+
+    agents.forEach((agent, agentId) => {
+      const clues: ClueItem[] = []
+
+      // 1. knownSecrets → 已证实线索
+      agent.memory.knownSecrets.forEach((secret, idx) => {
+        clues.push({
+          id: `${agentId}-secret-${idx}`,
+          content: secret,
+          type: "confirmed",
+          source: "调查证实",
+          round: 0,
+          agentId,
+          agentName: agent.name,
+        })
+      })
+
+      // 2. 未验证的传闻 → 待验证线索
+      const visible = visibleRumorsByAgent.get(agentId) ?? []
+      visible.forEach((rumor) => {
+        const isVerified = rumor.verifiedBy.includes(agentId)
+        if (!isVerified) {
+          clues.push({
+            id: `${agentId}-rumor-${rumor.id}`,
+            content: rumor.content,
+            type: "rumor",
+            source: `第 ${rumor.round + 1} 轮听到`,
+            round: rumor.round,
+            agentId,
+            agentName: agent.name,
+          })
+        }
+      })
+
+      result.set(agentId, clues)
+    })
+
+    return result
+  }, [agents, visibleRumorsByAgent])
+
+  const currentClues = selectedAgentId ? cluesByAgent.get(selectedAgentId) ?? [] : []
+
+  // 计算关联(简化版:关键词重叠)
+  const relatedClues = useMemo(() => {
+    if (!selectedClueId || !selectedAgentId) return []
+    const selected = currentClues.find((c) => c.id === selectedClueId)
+    if (!selected) return []
+
+    const selectedWords = new Set(
+      selected.content.split(/\s+|,|。|?|!|、|的|了|是|在|有/).filter((w) => w.length >= 2),
+    )
+    if (selectedWords.size === 0) return []
+
+    return currentClues
+      .filter((c) => c.id !== selectedClueId)
+      .map((c) => {
+        const cWords = new Set(
+          c.content.split(/\s+|,|。|?|!|、|的|了|是|在|有/).filter((w) => w.length >= 2),
+        )
+        let overlap = 0
+        for (const w of selectedWords) {
+          if (cWords.has(w)) overlap++
+        }
+        return { clue: c, score: overlap / Math.max(selectedWords.size, cWords.size) }
+      })
+      .filter(({ score }) => score >= 0.2)
+      .sort((a, b) => b.score - a.score)
+      .slice(0, 5)
+  }, [selectedClueId, selectedAgentId, currentClues])
+
+  return (
+    <div className="flex min-h-0 flex-1 flex-col gap-2">
+      {/* 角色 tabs */}
+      <div className="flex flex-wrap gap-1 border-b pb-2">
+        {Array.from(agents.values()).map((agent) => (
+          <button
+            key={agent.characterId}
+            type="button"
+            className={`rounded px-2 py-1 text-xs ${
+              selectedAgentId === agent.characterId
+                ? "bg-primary text-primary-foreground"
+                : "bg-muted hover:bg-muted/80"
+            }`}
+            onClick={() => {
+              setSelectedAgentId(agent.characterId)
+              setSelectedClueId(null)
+            }}
+          >
+            {agent.name}
+            <span className="ml-1 text-[10px] opacity-70">({cluesByAgent.get(agent.characterId)?.length ?? 0})</span>
+          </button>
+        ))}
+      </div>
+
+      {/* 类型图例 */}
+      <div className="flex flex-wrap gap-2 text-[10px] text-muted-foreground">
+        <span className="rounded border border-green-300 bg-green-50 px-1.5 py-0.5 text-green-700">🟢 亲眼所见</span>
+        <span className="rounded border border-blue-300 bg-blue-50 px-1.5 py-0.5 text-blue-700">🔵 调查证实</span>
+        <span className="rounded border border-yellow-300 bg-yellow-50 px-1.5 py-0.5 text-yellow-700">🟡 传闻得知</span>
+        <span className="rounded border border-purple-300 bg-purple-50 px-1.5 py-0.5 text-purple-700">🟣 他人告知</span>
+      </div>
+
+      {/* 线索卡片墙 */}
+      <div className="flex-1 overflow-y-auto">
+        {currentClues.length === 0 ? (
+          <div className="py-8 text-center text-xs text-muted-foreground">该角色暂无线索</div>
+        ) : (
+          <div className="grid grid-cols-2 gap-2 md:grid-cols-3 lg:grid-cols-4">
+            {currentClues.map((clue) => (
+              <div
+                key={clue.id}
+                className={`cursor-pointer rounded-md border p-2 text-xs ${
+                  selectedClueId === clue.id
+                    ? "border-primary ring-1 ring-primary"
+                    : `${clueTypeColor(clue.type)} border-opacity-60 hover:border-opacity-100`
+                }`}
+                onClick={() => setSelectedClueId(selectedClueId === clue.id ? null : clue.id)}
+              >
+                <div className="mb-1 flex items-center justify-between">
+                  <span className="text-[10px] font-medium">{clueTypeLabel(clue.type)}</span>
+                  <span className="text-[10px] opacity-70">R{clue.round + 1}</span>
+                </div>
+                <div className="line-clamp-3">{clue.content}</div>
+              </div>
+            ))}
+          </div>
+        )}
+      </div>
+
+      {/* 选中线索详情 + 关联 */}
+      {selectedClueId && (
+        <div className="max-h-40 overflow-y-auto rounded-md border bg-muted/20 p-2 text-xs">
+          <div className="mb-1 text-[11px] font-medium text-muted-foreground">选中线索</div>
+          <div className="mb-2">{currentClues.find((c) => c.id === selectedClueId)?.content}</div>
+          {relatedClues.length > 0 && (
+            <>
+              <div className="mb-1 text-[11px] font-medium text-muted-foreground">关联线索</div>
+              <div className="space-y-1">
+                {relatedClues.map(({ clue, score }) => (
+                  <div
+                    key={clue.id}
+                    className="cursor-pointer rounded bg-background/70 px-2 py-1 hover:bg-background"
+                    onClick={() => setSelectedClueId(clue.id)}
+                  >
+                    <span className="text-[10px] text-muted-foreground">({Math.round(score * 100)}%) </span>
+                    <span className="line-clamp-1">{clue.content}</span>
+                  </div>
+                ))}
+              </div>
+            </>
+          )}
+        </div>
+      )}
+    </div>
+  )
+}
+```
+
+- [ ] **Step 2: 在 ProcessDebugPanel 中集成「线索」Tab**
+
+在 ProcessDebugPanel 的二级 Tab 中新增「线索」Tab,点击显示 ClueBoardPanel。
+
+- [ ] **Step 3: 验证**
+
+Run: `npm run dev`
+- 打开推演室,开启仿真
+- 切换到过程观察 → 线索 Tab
+- 切换不同角色,确认线索数量正确
+- 点击线索卡片,查看详情和关联线索
+
+Run: `npx tsc --noEmit`
+Expected: 0 errors
+
+---
+
+## Task 14: 最终集成 + 打包验证
+
+**Files:**
+- Modify: `GenxinLOG/更新日志.md` — 添加四期更新内容
+
+- [ ] **Step 1: 4 个面板全部集成后,做一次完整的 dev 模式测试**
+
+Run: `npm run dev`
+手动验证:
+- 时间线 Tab 正常
+- 过程观察 → 概览 Tab 正常
+- 过程观察 → 传闻 Tab 正常
+- 过程观察 → 关系 Tab 正常
+- 过程观察 → 线索 Tab 正常
+- 分支管理面板正常(保存/切换/删除)
+- 各 Tab 切换流畅,无报错
+
+- [ ] **Step 2: 全量 typecheck**
+
+Run: `npx tsc --noEmit`
+Expected: 0 errors
+
+- [ ] **Step 3: 全量测试**
+
+Run: `npx vitest run src/lib/novel/story-simulation/`
+Expected: 全部通过
+
+- [ ] **Step 4: build**
+
+Run: `npm run build`
+Expected: build 成功(chunk 大小警告可以忽略)
+
+- [ ] **Step 5: 打包便携版**
+
+Run: `npm run build:portable`
+Expected: `release-portable/QMaiWrite.exe` 生成成功
+
+- [ ] **Step 6: 更新日志**
+
+在 `GenxinLOG/更新日志.md` 顶部添加四期更新内容。

+ 229 - 0
juqingtuiyanshisanqi-设计文档.md

@@ -0,0 +1,229 @@
+# 剧情推演室三期 — 设计文档
+
+> 分支:`juqingtuiyanshierqi`(在二期基础上叠加)
+> 日期:2026-07-04
+
+## 一、背景与目标
+
+在剧情推演室二期 6 项能力基础上,继续增强 3 个方向:
+
+1. **investigate 工具验证传闻反馈闭环**:让 ReAct 的 investigate 工具真正能验证传闻,结果写入 Agent 记忆
+2. **导演 Agent 多维度评分**:从 3 维度扩展为 7 维度综合评分
+3. **事件池按节点阶段差异化生成**:动态事件池按起/承/转/合四阶段分类
+
+## 二、任务 7:investigate 工具验证传闻反馈闭环
+
+### 2.1 目标
+
+Agent 通过 ReAct 调用 `investigate` 工具验证传闻真伪,验证结果立即写入 Agent 记忆,形成闭环。
+
+### 2.2 方案
+
+采用**当场判定真伪**方案:基于 `rumor.distortion` 概率当场判定,零额外 LLM 调用。
+
+### 2.3 详细设计
+
+#### investigate 工具定义
+
+```typescript
+// sim-agent-tools.ts
+{
+  name: "investigate",
+  description: "调查验证某条传闻的真伪。传入要调查的传闻描述或编号。",
+  parameters: {
+    type: "object",
+    properties: {
+      rumorDescription: { type: "string", description: "要调查的传闻内容描述" }
+    },
+    required: ["rumorDescription"]
+  }
+}
+```
+
+#### 验证逻辑
+
+1. 从 `blackboard.rumors` 中找到与描述最匹配的 `RumorEvent`(基于文本相似度匹配前 3 条,取最相似的)
+2. 基于 `rumor.distortion` 判定真伪程度:
+   - distortion < 0.3 → 90% 真 / 10% 部分真
+   - distortion 0.3 - 0.6 → 40% 真 / 30% 部分真 / 30% 假
+   - distortion > 0.6 → 20% 真 / 30% 部分真 / 50% 假
+3. 生成验证结果描述(如"经过多方打听,确认属实"/"经查证,此为误传"/"调查后发现半真半假")
+4. 写入 Agent 记忆:
+   - **真** → 加入 `agentMemory.knownSecrets`,`rumor.verifiedBy` 加入 agentId,`rumor.believedBy` 加入 agentId
+   - **假** → 加入 `agentMemory.observedEvents`(类型为"investigation_result",标注为已证伪),从该 Agent 可见传闻中移除
+   - **部分真** → 同时写入 knownSecrets(修正后的真实内容,基于 sourceSecret 还原)和 observedEvents(调查记录)
+
+#### 数据结构变更
+
+```typescript
+// types.ts - RumorEvent 新增字段
+interface RumorEvent extends TimelineEvent {
+  // ... 已有字段 ...
+  verifiedBy: string[];  // 已验证此传闻的角色 ID 列表
+}
+```
+
+#### 改动文件
+
+- `src/lib/novel/story-simulation/types.ts`:RumorEvent 加 `verifiedBy`
+- `src/lib/novel/story-simulation/sim-agent-tools.ts`:新增 investigate 工具
+- `src/lib/novel/story-simulation/multi-agent-orchestrator.ts`:新增 `verifyRumor` 函数
+
+#### 测试用例
+
+- investigate 能正确找到匹配的传闻
+- 低 distortion 传闻高概率判定为真
+- 高 distortion 传闻高概率判定为假
+- 验证为真后写入 knownSecrets 和 verifiedBy
+- 验证为假后从 Agent 可见传闻中移除
+- 验证为部分真时同时写入修正内容和调查记录
+
+## 三、任务 8:导演 Agent 多维度评分
+
+### 3.1 目标
+
+导演 Agent 评估从 3 维度扩展为 7 维度综合评分,提供更全面的剧情质量反馈。
+
+### 3.2 7 个评分维度
+
+| 维度 key | 维度名称 | 说明 | 分值 |
+|---|---|---|---|
+| `tension` | 剧情张力 | 冲突强度、悬念感、读者期待感 | 1-5 |
+| `pace` | 节奏把控 | 节奏松紧是否得当,是否拖沓或仓促 | 1-5 |
+| `characterUtilization` | 角色发挥 | 各角色是否有足够戏份和存在感 | 1-5 |
+| `characterArc` | 人物弧光 | 角色是否有成长、变化或转变 | 1-5 |
+| `infoDensity` | 信息密度 | 信息量是否充足,是否空洞 | 1-5 |
+| `emotionalResonance` | 情感共鸣 | 能否引发读者情感投入 | 1-5 |
+| `logicConsistency` | 逻辑自洽 | 情节发展是否合理,有无矛盾 | 1-5 |
+
+### 3.3 输出格式
+
+```typescript
+interface DirectorEvaluation {
+  scores: {
+    tension: number;
+    pace: number;
+    characterUtilization: number;
+    characterArc: number;
+    infoDensity: number;
+    emotionalResonance: number;
+    logicConsistency: number;
+  };
+  totalScore: number;        // 7 项加权平均
+  highlights: string[];      // 亮点(2-3 条)
+  issues: string[];          // 问题(1-2 条)
+  suggestion: string;        // 综合建议
+  shouldInjectEvent: boolean; // 是否注入转折事件
+  injectedEvent?: string;    // 注入事件描述
+}
+```
+
+### 3.4 注入条件保持不变
+
+- `totalScore < 3.0` → 注入
+- 或 `pace === 1`(节奏极差)→ 注入
+- 其余不注入
+
+### 3.5 改动文件
+
+- `src/lib/novel/story-simulation/director-agent.ts`:扩展 `DirectorEvaluation` 类型,更新 prompt,更新解析逻辑
+- `src/lib/novel/story-simulation/director-agent.spec.ts`:更新测试用例
+- (可选)`src/components/story-simulation/story-simulation-view.tsx`:过程观察面板展示导演 7 维评分
+
+### 3.6 测试用例
+
+- LLM 返回 7 维度分数能正确解析
+- totalScore 为 7 项加权平均
+- 注入条件判断正确
+- 低总分时触发注入
+- 高总分时不注入
+
+## 四、任务 9:事件池按节点阶段差异化生成
+
+### 4.1 目标
+
+动态事件池按剧情四阶段(起/承/转/合)分类,不同节点从对应阶段池中抽取。
+
+### 4.2 四阶段定义
+
+| 阶段 | key | 对应节点位置 | 事件特点 |
+|---|---|---|---|
+| 起 | `setup` | 第 1 个节点 | 背景介绍、人物登场、日常事件、环境铺垫 |
+| 承 | `rising` | 中间节点(第 2 到 N-2) | 冲突升级、线索浮现、关系变化、误会加深 |
+| 转 | `climax` | 倒数第 2 个节点(或 75% 位置之后) | 重大变故、真相揭露、危机爆发、抉择时刻 |
+| 合 | `resolution` | 最后 1 个节点 | 结局揭晓、余波、人物归位、伏笔回收 |
+
+### 4.3 生成逻辑
+
+仿真开始时,一次 LLM 调用生成 24 个事件,按阶段分类(每阶段 6 个)。
+
+Prompt 中明确要求 LLM 按四阶段输出,每个阶段 6 条。
+
+#### 节点阶段判定规则
+
+```typescript
+function getNodeStage(nodeIndex: number, totalNodes: number): 'setup' | 'rising' | 'climax' | 'resolution' {
+  if (nodeIndex === 0) return 'setup';
+  if (nodeIndex === totalNodes - 1) return 'resolution';
+  if (nodeIndex >= Math.floor(totalNodes * 0.75)) return 'climax';
+  return 'rising';
+}
+```
+
+#### 抽取与回退
+
+1. 优先从当前阶段池中随机抽取(不重复)
+2. 当前阶段池耗尽 → 从全局池(所有剩余事件)中抽取
+3. 全局池也耗尽 → 回退固定 8 条事件
+
+### 4.4 数据结构变更
+
+```typescript
+// types.ts
+interface SimulatedEvent {
+  // ... 已有字段 ...
+  stage?: 'setup' | 'rising' | 'climax' | 'resolution';
+}
+
+interface DynamicEventPool {
+  all: SimulatedEvent[];
+  byStage: {
+    setup: SimulatedEvent[];
+    rising: SimulatedEvent[];
+    climax: SimulatedEvent[];
+    resolution: SimulatedEvent[];
+  };
+  usedIds: Set<string>;
+}
+```
+
+### 4.5 改动文件
+
+- `src/lib/novel/story-simulation/types.ts`:SimulatedEvent 加 `stage`,DynamicEventPool 加 `byStage`
+- `src/lib/novel/story-simulation/event-pool-generator.ts`:prompt 加阶段分类要求,输出按 stage 分组
+- `src/lib/novel/story-simulation/simulation-engine.ts`:`generateRandomEvent` 按节点阶段选池
+
+### 4.6 测试用例
+
+- LLM 生成事件按四阶段分类正确
+- 节点阶段判定规则正确(边界节点)
+- 各阶段池独立抽取不重复
+- 阶段池耗尽后回退全局池
+- 全局池耗尽后回退固定事件
+
+## 五、整体改动清单
+
+| 任务 | 新增文件 | 修改文件 |
+|---|---|---|
+| 7 investigate 闭环 | 0 | types.ts, sim-agent-tools.ts, multi-agent-orchestrator.ts + 1 测试 |
+| 8 导演多维度 | 0 | director-agent.ts + 1 测试(+可选 UI) |
+| 9 阶段事件池 | 0 | types.ts, event-pool-generator.ts, simulation-engine.ts + 1 测试 |
+
+**合计**:修改约 7 个源文件 + 3 个测试文件
+
+## 六、风险与注意事项
+
+1. **investigate 匹配准确性**:传闻匹配基于文本相似度,可能匹配不准。可先用简单 includes 匹配 + 前 3 模糊匹配兜底。
+2. **导演 prompt 长度增加**:7 维度描述会增加 prompt 长度,但仍在可控范围内(一次调用)。
+3. **事件池生成 prompt 变复杂**:要求 LLM 按四阶段输出,可能偶尔格式不规范。需加强解析容错。
+4. **不破坏二期功能**:所有改动均为增量,不修改已有函数签名和返回结构。

+ 585 - 0
juqingtuiyanshiwuqi-实施计划.md

@@ -0,0 +1,585 @@
+# 剧情推演室五期 Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 在四期基础上新增 5 项能力:传闻主动传播、完整分支对比、侦探板自由布局、时间滑块回放、Web Worker 性能优化。
+
+**Architecture:** 引擎层(Task 15)→ 数据层(Task 18)→ UI 层(Task 16/17)→ 性能层(Task 19)。每层独立可测试。
+
+**Tech Stack:** React + TypeScript + Zustand + Tailwind CSS + Web Worker (Vite 原生)
+
+## Global Constraints
+
+- 分支:`juqingtuiyanshierqi`(在四期基础上叠加)
+- 所有面向用户的提示语使用中文
+- 不删除已有函数和组件
+- 不顺手重构无关代码
+- 保持与现有代码风格一致
+- 每个任务独立验证(typecheck + 相关测试)
+- 不新增第三方图表库,雷达图用原生 SVG 手绘
+- 侦探板不使用 cytoscape,全部手写拖拽/连线/画布
+
+---
+
+## Task 15: 传闻主动传播行动
+
+**Files:**
+- Modify: `src/lib/novel/story-simulation/types.ts` — RumorEvent 加 parentId/spreadBy/generation 等字段,AgentMemory 加 rumorCredibility
+- Modify: `src/lib/novel/story-simulation/multi-agent-orchestrator.ts` — spreadRumor 函数 + 传播逻辑
+- Modify: `src/lib/novel/story-simulation/sim-agent-tools.ts` — 新增 spread_rumor 工具
+- Modify: `src/components/novel/story-simulation/rumor-propagation-panel.tsx` — 升级为传播树
+- Create: `src/lib/novel/story-simulation/spread-rumor.spec.ts` — 测试
+
+**Interfaces:**
+- Consumes: RumorEvent, NovelAgent, SimulationBlackboard
+- Produces:
+  - `spreadRumor(blackboard, sourceRumorId, spreaderId, targetId, message): { newRumor, targetBelieved }`
+  - `buildRumorFamilyTree(rumors): RumorTreeNode[]` — 构建传播树
+  - ReAct 工具 `spread_rumor`
+
+- [ ] **Step 1: 扩展 RumorEvent 类型**
+
+在 types.ts 的 RumorEvent 中新增:
+```typescript
+export interface RumorEvent {
+  // ... 已有字段
+  parentId?: string
+  spreadBy?: string
+  spreadRound?: number
+  generation: number
+}
+```
+
+同时在 AgentMemory 中新增:
+```typescript
+export interface AgentMemory {
+  // ... 已有字段
+  rumorCredibility: number  // 0-1,初始 0.5
+}
+```
+
+在 createNovelAgent 等初始化函数中,给 rumorCredibility 设默认值 0.5。
+rumors 数组中新创建的 RumorEvent 默认 generation = 0。
+
+- [ ] **Step 2: 实现 spreadRumor 函数**
+
+在 multi-agent-orchestrator.ts 中新增:
+```typescript
+export function spreadRumor(
+  blackboard: SimulationBlackboard,
+  sourceRumorId: string,
+  spreaderId: string,
+  targetId: string,
+  message: string,
+): { newRumor: RumorEvent | null; targetBelieved: boolean }
+```
+
+实现逻辑:
+1. 找到源传闻
+2. 检查传播者是否知道这条传闻,目标是否存在
+3. 生成新传闻:
+   - id: `rumor-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
+   - content: message || 源传闻 content
+   - distortion: Math.min(1, sourceRumor.distortion + 0.1 + Math.random() * 0.2)
+   - generation: sourceRumor.generation + 1
+   - parentId: sourceRumorId
+   - spreadBy: spreaderId
+   - spreadRound: 当前轮次(从 blackboard 或事件中获取)
+   - observableBy: [targetId]
+   - believedBy: []
+   - verifiedBy: []
+4. 加入 blackboard.rumors 和 visibleRumorsByAgent.get(targetId)
+5. 计算目标是否相信:
+   - baseBelief = (1 - newRumor.distortion) * 0.6
+   - spreader = blackboard.allAgents.get(spreaderId)
+   - target = blackboard.allAgents.get(targetId)
+   - credibilityBonus = (spreader.memory.rumorCredibility - 0.5) * 0.4
+   - sentimentBonus = (target.memory.sentiments.get(spreaderId) ?? 0) / 200  // -0.5 到 0.5
+   - finalProb = Math.max(0.1, Math.min(0.9, baseBelief + credibilityBonus + sentimentBonus))
+   - believed = Math.random() < finalProb
+   - 如果 believed,加入 believedBy
+6. 返回结果
+
+- [ ] **Step 3: ReAct 工具集新增 spread_rumor**
+
+在 sim-agent-tools.ts 的 agentTools 中新增:
+```typescript
+spread_rumor: {
+  description: "把一条你知道的传闻告诉另一个角色。可以加上你自己的描述和评价。",
+  parameters: z.object({
+    rumorId: z.string().describe("要传播的传闻 ID"),
+    targetAgentId: z.string().describe("告诉谁(角色 ID)"),
+    message: z.string().describe("你说的内容(对传闻的描述/补充/评价)"),
+  }),
+  execute: async (params, context) => {
+    const result = spreadRumor(context.blackboard, params.rumorId, context.agent.characterId, params.targetAgentId, params.message)
+    if (!result.newRumor) return { success: false, reason: "传播失败" }
+    return {
+      success: true,
+      newRumorId: result.newRumor.id,
+      targetBelieved: result.targetBelieved,
+      newDistortion: result.newRumor.distortion,
+    }
+  }
+}
+```
+
+- [ ] **Step 4: 传播链面板升级为树形展示**
+
+改造 rumor-propagation-panel.tsx:
+- 左侧列表保持不变
+- 右侧传播链从线性时间线改为树形结构
+- 显示传播代次:原始传闻 → 第1代传播 → 第2代...
+- 每个节点显示:内容摘要、传播者、轮次、失真度颜色
+- 用缩进 + 竖线表示层级,不用复杂的图形库
+
+辅助函数 buildRumorFamilyTree:
+```typescript
+interface RumorTreeNode {
+  rumor: RumorEvent
+  children: RumorTreeNode[]
+  spreaderName?: string
+}
+
+function buildRumorFamilyTree(rumors: RumorEvent[], targetRumorId: string): RumorTreeNode | null
+```
+
+- [ ] **Step 5: 测试**
+
+新建 spread-rumor.spec.ts,覆盖:
+- 基本传播功能(生成新传闻、加入目标可见列表)
+- 失真度增加(新传闻 distortion >= 源传闻)
+- generation 递增
+- parentId/spreadBy 正确
+- 边界情况:传播不存在的传闻、目标不存在
+- 可信度影响相信概率(高可信度 vs 低可信度)
+
+- [ ] **Step 6: 验证**
+  - `npx vitest run src/lib/novel/story-simulation/spread-rumor.spec.ts` → 全部通过
+  - `npx tsc --build --pretty` → 0 errors
+
+---
+
+## Task 18: 时间滑块 + 状态回放
+
+**先做 Task 18 再做 Task 16/17**,因为历史快照数据是 UI 层的基础。
+
+**Files:**
+- Modify: `src/stores/story-simulation-store.ts` — 新增 history 状态、回放控制方法
+- Modify: `src/components/novel/story-simulation/relationship-graph-panel.tsx` — 集成回放控件
+- Modify: `src/components/novel/story-simulation/story-simulation-view.tsx` — 回放联动
+- Test: (store 逻辑写单测,UI 不写)
+
+**Interfaces:**
+- Consumes: 当前仿真状态(每轮结束时的快照)
+- Produces:
+  - `history: SimulationHistoryEntry[]`
+  - `historyIndex: number` (-1 = 实时)
+  - `setHistoryIndex(index): void`
+  - `togglePlayback(): void`
+  - `playbackSpeed: number`
+
+- [ ] **Step 1: 定义历史快照类型**
+
+在 types.ts 或 store 中定义:
+```typescript
+export interface SimulationHistoryEntry {
+  round: number
+  nodeIndex: number
+  nodeTitle: string
+  agentStates: Record<string, {
+    sentiments: [string, number][]
+    knownSecrets: string[]
+    observedEvents: string[]
+  }>
+  eventCount: number
+  rumorCount: number
+}
+```
+
+注意:用普通对象而不是 Map/Set,便于序列化和回放。
+
+- [ ] **Step 2: 在 store 中新增历史记录状态**
+
+新增状态:
+```typescript
+history: SimulationHistoryEntry[]
+historyIndex: number  // -1 = 实时模式,>= 0 = 回放第 index 条
+isPlaying: boolean
+playbackSpeed: number  // 0.5 / 1 / 2 / 4
+```
+
+新增方法:
+```typescript
+addHistoryEntry: (entry: SimulationHistoryEntry) => void
+setHistoryIndex: (index: number) => void
+togglePlayback: () => void
+setPlaybackSpeed: (speed: number) => void
+clearHistory: () => void
+```
+
+**回放时的派生数据**:
+回放模式下,UI 组件需要读取历史状态而不是实时状态。
+在 store 中加 derived 方法或在组件中用 useMemo 计算:
+```typescript
+// 返回当前应该展示的状态(回放 or 实时)
+getDisplayAgents(): Map<string, NovelAgent>
+getDisplayEvents(): TimelineEvent[]
+getDisplayRumors(): RumorEvent[]
+```
+
+简化方案:在组件中自己判断 historyIndex,从 history 中取数据。不改动 store 中的实时数据。
+
+- [ ] **Step 3: 在仿真回调中添加历史快照**
+
+在 story-simulation-view.tsx 的回调中,每轮事件处理完成后调用 `addHistoryEntry`。
+从 currentAgents 中提取 sentiments、knownSecrets、observedEvents。
+
+- [ ] **Step 4: 关系图面板加回放控件**
+
+在 relationship-graph-panel.tsx 顶部加回放控制条:
+- 左:后退一步
+- 中:播放/暂停
+- 右:前进一步
+- 速度选择:0.5x / 1x / 2x / 4x
+- 滑块:拖动跳转到任意轮次
+- 实时模式按钮
+
+播放实现:用 setInterval 或 requestAnimationFrame,按速度递增 historyIndex。
+
+- [ ] **Step 5: 各面板联动回放**
+
+确保以下面板在回放模式下显示历史数据:
+- 关系图:从历史快照取 sentiments
+- 线索板:从历史快照取 knownSecrets
+- 时间线:只显示到当前轮次的事件
+- 传闻面板:只显示到当前轮次的传闻
+
+简化:先只让关系图支持回放,其他面板后续再加。
+Task 18 只做关系图回放,验证机制可行后再扩展。
+
+- [ ] **Step 6: 验证**
+  - `npx tsc --build --pretty` → 0 errors
+  - `npm run dev` → 手动测试回放功能
+
+---
+
+## Task 16: 完整分支对比页面
+
+**Files:**
+- Create: `src/components/novel/story-simulation/branch-compare-view.tsx` — 完整对比页面
+- Modify: `src/components/novel/story-simulation/branch-manager-panel.tsx` — 加对比复选框
+- Modify: `src/stores/story-simulation-store.ts` — 加 compareBranchIds, isCompareMode
+
+**Interfaces:**
+- Consumes: SimulationBranch[]
+- Produces:
+  - 雷达图组件(SVG 手绘)
+  - 时间线并排对比组件
+  - 关键差异分析函数
+
+- [ ] **Step 1: Store 新增对比状态**
+
+在 store 中新增:
+```typescript
+compareBranchIds: string[]
+isCompareMode: boolean
+
+setCompareMode: (enabled: boolean) => void
+toggleCompareBranch: (branchId: string) => void
+clearCompareSelection: () => void
+```
+
+- [ ] **Step 2: 分支管理面板加对比功能**
+
+在 branch-manager-panel.tsx 中:
+- 每个分支卡片加复选框(点击选中用于对比)
+- 底部加「对比选中的分支」按钮(选中 2-3 个时启用)
+- 点击按钮进入对比模式
+
+- [ ] **Step 3: 创建对比页面组件**
+
+新建 branch-compare-view.tsx,三个 Tab:
+1. **评分对比**(默认):SVG 雷达图 + 各维度分数表
+2. **时间线对比**:2-3 列并排时间线
+3. **关键差异**:自动分析的差异列表
+
+顶部:返回按钮 + 分支名称标签
+
+雷达图实现(原生 SVG):
+- 7 边形网格背景
+- 2-3 条多边形线
+- 图例可点击显隐
+- 纯 SVG,不依赖任何库
+
+时间线并排:
+- flex 布局,2-3 列
+- 每列是一个分支的事件列表
+- 事件卡片样式复用时间线的卡片样式
+
+关键差异分析(纯函数):
+```typescript
+function analyzeBranchDiff(branches: SimulationBranch[]): BranchDiff
+```
+
+分析内容:
+- 各维度评分差异
+- 独有事件数量
+- 角色结局差异(好感度对比)
+- 从第几轮开始明显分岔
+
+- [ ] **Step 4: 集成到主视图**
+
+在 story-simulation-view.tsx 中:
+- isCompareMode 为 true 时显示对比页面
+- 对比页面占满过程观察区域
+- 顶部返回按钮退出对比模式
+
+- [ ] **Step 5: 验证**
+  - `npx tsc --build --pretty` → 0 errors
+  - `npm run dev` → 手动测试对比功能
+
+---
+
+## Task 17: 侦探板自由布局
+
+**Files:**
+- Create: `src/components/novel/story-simulation/detective-board-panel.tsx` — 侦探板组件
+- Modify: `src/stores/story-simulation-store.ts` — 加侦探板状态
+- Modify: `src/components/novel/story-simulation/story-simulation-view.tsx` — 集成侦探板
+
+**Interfaces:**
+- Consumes: currentAgents, currentRumors, timelineEvents
+- Produces:
+  - 画布系统(平移/缩放)
+  - 可拖拽卡片
+  - SVG 连线
+  - 自动布局算法
+
+**这是五期工作量最大的任务,拆成 3 个子步骤实现:**
+
+### Subtask 17a: 画布系统 + 卡片拖拽
+
+- [ ] **Step 1: 定义数据类型**
+
+```typescript
+interface DetectiveBoardState {
+  cards: DetectiveCard[]
+  connections: DetectiveConnection[]
+  viewport: { x: number; y: number; scale: number }
+  selectedCardId: string | null
+  selectedConnectionId: string | null
+}
+
+interface DetectiveCard {
+  id: string
+  type: "clue" | "character" | "event"
+  title: string
+  content: string
+  x: number
+  y: number
+  width: number
+  sourceId?: string
+}
+
+interface DetectiveConnection {
+  id: string
+  fromCardId: string
+  toCardId: string
+  type: "related" | "cause" | "contradiction" | "proves" | "disproves"
+  label?: string
+}
+```
+
+- [ ] **Step 2: 实现画布容器**
+
+画布组件:
+- 外层容器:overflow: hidden,position: relative
+- 内层内容层:transform: translate(x, y) scale(scale)
+- 鼠标滚轮:以鼠标位置为中心缩放
+- 鼠标拖拽空白区域:平移画布
+- 缩放范围:0.3 - 2.0
+
+- [ ] **Step 3: 实现可拖拽卡片**
+
+卡片组件:
+- position: absolute,left/top 由 x/y 决定
+- 鼠标按下卡片标题栏:开始拖拽
+- 鼠标移动:更新卡片位置(用 transform 性能更好)
+- 鼠标抬起:结束拖拽
+- 点击卡片:选中(高亮边框)
+- 不同类型卡片不同颜色边框
+
+- [ ] **Step 4: 自动生成初始卡片**
+
+从 agent/rumor/event 数据自动生成卡片:
+- 每个角色 → 角色卡片
+- 每个已知秘密 → 线索卡片
+- 每个关键事件 → 事件卡片
+- 初始位置:简单的网格布局
+
+### Subtask 17b: SVG 连线系统
+
+- [ ] **Step 1: SVG 连线层**
+
+- SVG 元素覆盖整个画布,position: absolute, pointer-events: none
+- 每条连线是一个 path 元素
+- 贝塞尔曲线连接两张卡片的中心
+- 不同类型连线不同颜色
+
+- [ ] **Step 2: 手动连线**
+
+- 卡片边缘加连线手柄(小圆圈)
+- 拖拽手柄到另一张卡片 → 创建连线
+- 连线时显示临时线跟随鼠标
+
+- [ ] **Step 3: 连线操作**
+
+- 点击连线选中
+- 删除键删除选中的连线
+- 右键或双击编辑连线类型和标签
+
+### Subtask 17c: 自动布局 + 工具栏
+
+- [ ] **Step 1: 简化力导向布局算法**
+
+```typescript
+function autoLayout(cards, connections): { id: string; x: number; y: number }[]
+```
+
+算法(约 50 行):
+- 节点间排斥力(距离越近斥力越大)
+- 有连线的节点间吸引力
+- 迭代 50-100 次
+- 冷却因子:每次迭代步长减小
+
+- [ ] **Step 2: 工具栏**
+
+顶部工具栏:
+- 自动布局按钮
+- 重置视图按钮
+- 缩放显示(+ / - / 100%)
+- 删除选中按钮
+
+- [ ] **Step 3: 集成到过程观察面板**
+
+替换线索板 Tab 的内容为侦探板。
+或者保留线索板,新增「侦探板」Tab(这样 Tab 就太多了,还是替换吧)。
+
+替换「线索」Tab 为「侦探板」。
+
+- [ ] **Step 4: 验证**
+  - `npx tsc --build --pretty` → 0 errors
+  - `npm run dev` → 手动测试拖拽、连线、缩放、平移
+
+---
+
+## Task 19: Web Worker 性能优化
+
+**Files:**
+- Create: `src/workers/simulation-worker.ts` — Worker 入口
+- Create: `src/hooks/use-simulation-worker.ts` — Worker 封装 Hook
+- Modify: 侦探板、分支对比 — 改用 Worker 计算
+
+**Interfaces:**
+- Consumes: 线索列表、分支数据、卡片数据
+- Produces: 关联计算结果、差异分析结果、布局坐标
+
+- [ ] **Step 1: 创建 Worker 文件**
+
+新建 `src/workers/simulation-worker.ts`:
+```typescript
+import type { WorkerMessage, WorkerResult } from "@/lib/novel/story-simulation/types"
+
+self.onmessage = (e: MessageEvent<WorkerMessage>) => {
+  const { id, type, payload } = e.data
+  let result: WorkerResult["payload"]
+
+  switch (type) {
+    case "calc-clue-relations":
+      result = calcClueRelations(payload.clues)
+      break
+    case "calc-branch-diff":
+      result = calcBranchDiff(payload.branchA, payload.branchB)
+      break
+    case "calc-board-layout":
+      result = calcBoardLayout(payload.cards, payload.connections)
+      break
+  }
+
+  self.postMessage({ id, type, payload: result })
+}
+```
+
+把对应的计算函数移到 Worker 中(或在 Worker 中重新实现)。
+
+- [ ] **Step 2: Worker 消息类型定义**
+
+在 types.ts 中新增:
+```typescript
+export interface WorkerRequest {
+  id: string
+  type: "calc-clue-relations" | "calc-branch-diff" | "calc-board-layout"
+  payload: any
+}
+
+export interface WorkerResponse {
+  id: string
+  type: string
+  payload: any
+}
+```
+
+- [ ] **Step 3: 封装 useSimulationWorker Hook**
+
+新建 `src/hooks/use-simulation-worker.ts`:
+```typescript
+export function useSimulationWorker() {
+  const calcClueRelations = async (clues) => { ... }
+  const calcBranchDiff = async (a, b) => { ... }
+  const calcBoardLayout = async (cards, connections) => { ... }
+}
+```
+
+内部实现:
+- 创建 Worker 实例(单例)
+- 用 Map 存待处理请求(id → resolve)
+- 每次调用生成唯一 id,postMessage,返回 Promise
+- onmessage 中根据 id 找到对应 resolve 并调用
+
+- [ ] **Step 4: 迁移计算到 Worker**
+
+把以下计算移到 Worker:
+- 侦探板:自动布局计算
+- 分支对比:差异分析
+- (线索关联如果还需要的话)
+
+主线程只负责渲染,计算全部交给 Worker。
+
+- [ ] **Step 5: 降级策略**
+
+Worker 加载失败时,降级为主线程计算(用 setTimeout 分批处理)。
+不影响功能使用,只是性能差点。
+
+- [ ] **Step 6: 验证**
+  - `npx tsc --build --pretty` → 0 errors
+  - `npm run dev` → 手动测试功能正常,控制台无 Worker 错误
+
+---
+
+## Task 20: 最终验证 + 更新日志 + 打包
+
+- [ ] **Step 1: 全量 typecheck**
+  - `npx tsc --build --pretty` → 0 errors
+
+- [ ] **Step 2: 核心测试**
+  - `npx vitest run src/lib/novel/story-simulation/` → 全部通过
+
+- [ ] **Step 3: vite build**
+  - `npm run build` → build 成功
+
+- [ ] **Step 4: 打包便携版**
+  - `npm run build:portable` → 生成 release-portable/QMaiWrite.exe
+
+- [ ] **Step 5: 更新日志**
+  - 在 GenxinLOG/更新日志.md 顶部添加五期更新内容

+ 415 - 0
juqingtuiyanshiwuqi-设计文档.md

@@ -0,0 +1,415 @@
+# 剧情推演室五期 — 设计文档
+
+> 分支:`juqingtuiyanshierqi`(在四期基础上叠加)
+> 日期:2026-07-04
+> 范围:5 个任务(引擎 + UI + 性能)
+
+## 一、背景与目标
+
+在剧情推演室四期(可视化增强)基础上,继续深化 5 个方向:
+
+1. **传闻主动传播行动**:Agent 可主动选择传播传闻,形成多代传播链
+2. **完整分支对比页面**:多分支并排对比,含雷达图、时间线对比、差异分析
+3. **侦探板自由布局**:线索卡片自由拖拽、手动连线、画布缩放平移
+4. **时间滑块 + 状态回放**:拖动滑块回到任意轮次,查看当时的关系状态
+5. **Web Worker 性能优化**:计算密集型操作移到 Worker,避免阻塞主线程
+
+## 二、Task 15:传闻主动传播行动
+
+### 2.1 目标
+
+Agent 可以主动选择「传播传闻」行动,将自己知道的传闻告诉其他角色,形成多代传播链。
+
+### 2.2 ReAct 工具新增
+
+在 `sim-agent-tools.ts` 中新增 `spread_rumor` 工具:
+
+```
+工具名称:spread_rumor
+工具描述:把一条你知道的传闻告诉另一个角色。你可以选择告诉谁、说什么内容。
+工具参数:
+  - rumorId: string  要传播的传闻 ID
+  - targetAgentId: string  目标角色 ID
+  - message: string  你对传闻的描述/补充/评价
+返回:传播结果(目标是否相信、失真度变化)
+```
+
+### 2.3 传播效果逻辑
+
+在 `multi-agent-orchestrator.ts` 中实现 `spreadRumor` 函数:
+
+**输入**:
+- 源传闻(sourceRumor)
+- 传播者 Agent ID(spreaderId)
+- 目标 Agent ID(targetId)
+- 传播者附加描述(message)
+
+**处理**:
+1. **生成新传闻**:
+   - 新传闻 ID:`rumor-${Date.now()}-${random}`
+   - 新内容:传播者 message 与原内容融合(取 message,如为空则用原内容)
+   - 失真度调整:`newDistortion = Math.min(1, oldDistortion + 0.1~0.3 随机)`
+   - 传播代次:`generation = oldGeneration + 1`
+   - 父传闻 ID:`parentId = sourceRumor.id`
+   - 传播者:`spreadBy = spreaderId`
+   - 传播轮次:`spreadRound = currentRound`
+
+2. **目标角色接收**:
+   - 将新传闻加入目标角色的可见传闻
+   - 初始相信概率计算:
+     - 如果传播者是目标信任的人(好感度高),相信概率 +20%
+     - 如果传播者可信度低(之前传播过假消息),相信概率 -20%
+     - 基础相信概率 = `(1 - distortion) * 0.6`
+   - 按概率决定目标是否相信
+
+3. **传播者信誉系统**(简化版):
+   - 每个 Agent 有 `rumorCredibility: number`(初始 0.5)
+   - 传播的传闻被证实为真 → credibility +0.1
+   - 传播的传闻被证实为假 → credibility -0.2
+   - 影响其他人对该 Agent 传播的传闻的相信概率
+
+### 2.4 数据结构改动
+
+```typescript
+interface RumorEvent {
+  // ... 已有字段
+  parentId?: string              // 父传闻 ID
+  spreadBy?: string              // 谁传播的
+  spreadRound?: number           // 第几轮传播的
+  generation: number             // 传播代次(0=原始,1=第1轮传播...)
+}
+
+interface AgentMemory {
+  // ... 已有字段
+  rumorCredibility: number       // 传播传闻的可信度(0-1)
+}
+```
+
+### 2.5 UI 升级:传播链树
+
+`rumor-propagation-panel.tsx` 升级:
+- 传播链从线性时间线改为树形结构
+- 显示多代传播关系(原始 → 第1代 → 第2代...)
+- 每条传闻标注:传播者、传播轮次、代次
+- 失真度用颜色渐变表示(越传越失真颜色越红)
+
+树形展示用缩进 + 连接线的方式,不用复杂的图形库。
+
+### 2.6 改动文件
+
+- `src/lib/novel/story-simulation/types.ts` — RumorEvent 加字段,AgentMemory 加 rumorCredibility
+- `src/lib/novel/story-simulation/sim-agent-tools.ts` — 新增 spread_rumor 工具
+- `src/lib/novel/story-simulation/multi-agent-orchestrator.ts` — spreadRumor 函数
+- `src/components/novel/story-simulation/rumor-propagation-panel.tsx` — 升级为传播树
+- `src/lib/novel/story-simulation/spread-rumor.spec.ts` — 新增测试
+
+## 三、Task 16:完整分支对比页面
+
+### 3.1 目标
+
+选中 2-3 个分支后,进入完整对比页面,从多维度对比分析。
+
+### 3.2 页面结构
+
+```
+┌─ 分支对比 ─────────────────────────────────────┐
+│  ← 返回分支管理  [分支A] vs [分支B]            │
+├───────────┬───────────┬───────────────────────┤
+│ 评分对比   │ 时间线对比 │ 关键差异              │
+│ (雷达图)   │ (并排)    │ (列表)                │
+├───────────┴───────────┴───────────────────────┤
+│ 【推荐结论】                                    │
+│  综合推荐分支 A,理由:...                      │
+└───────────────────────────────────────────────┘
+```
+
+### 3.3 三个 Tab 详情
+
+**Tab 1:评分对比(雷达图)**
+- 7 维导演评分雷达图:剧情张力、节奏把控、角色发挥、人物弧光、信息密度、情感共鸣、逻辑自洽
+- 2-3 条线分别代表不同分支
+- 图例可点击显隐
+- 用原生 SVG 手绘雷达图(不新增图表库依赖)
+
+**Tab 2:时间线并排对比**
+- 2-3 列并排时间线
+- 每列显示一个分支的事件流
+- 相同事件(内容相似度高)标绿色边框
+- 独有事件标黄色边框
+- 点击事件可以查看详情
+
+**Tab 3:关键差异**
+自动分析并列出:
+- 角色结局差异(好感度对比、已知秘密对比)
+- 事件数量与类型对比
+- 剧情走向分歧点(从第几轮开始分岔)
+- 评分差异最大的维度
+
+### 3.4 推荐结论生成
+
+基于评分差异自动生成文字推荐:
+- 综合评分高的为推荐
+- 列出 2-3 条主要优势
+- 提示劣势分支的潜在问题
+
+### 3.5 状态管理
+
+在 store 中新增:
+```typescript
+compareBranchIds: string[]  // 选中用于对比的分支 ID 列表
+isCompareMode: boolean      // 是否在对比模式
+```
+
+### 3.6 改动文件
+
+- 新增 `src/components/novel/story-simulation/branch-compare-view.tsx` — 完整对比页面
+- 修改 `src/components/novel/story-simulation/branch-manager-panel.tsx` — 加「对比」复选框和按钮
+- 修改 `src/stores/story-simulation-store.ts` — 加对比状态
+
+## 四、Task 17:侦探板自由布局
+
+### 4.1 目标
+
+线索板升级为侦探板风格:卡片可自由拖拽、手动连线、画布缩放平移。
+
+### 4.2 核心功能
+
+**1. 画布系统**
+- 无限画布(可平移、缩放)
+- 缩放范围:0.3x - 2x
+- 拖拽画布平移
+- 滚轮缩放(以鼠标位置为中心)
+
+**2. 卡片系统**
+- 线索卡片、角色卡片、事件卡片三种类型
+- 卡片可自由拖动
+- 卡片可选中、可删除
+- 卡片显示:标题、类型标签、内容摘要
+- 不同类型卡片不同颜色
+
+**3. 连线系统**
+- 手动连线:从卡片边缘拖出连线到另一张卡片
+- 连线类型:关联、因果、矛盾、证实、证伪
+- 连线上可加文字标签
+- 连线可选中、可删除
+
+**4. 布局操作**
+- 自动布局:一键整理卡片位置(力导向算法简化版)
+- 重置视图:回到初始位置
+- 选中卡片对齐:左对齐、垂直居中等
+
+**5. 持久化**
+- 卡片位置、连线信息存在 store 中
+- 切换分支时重置
+
+### 4.3 技术实现
+
+**不使用 cytoscape**,全部手写:
+- 画布:transform + translate/scale 实现
+- 拖拽:mousedown / mousemove / mouseup 事件
+- 连线:SVG path + 贝塞尔曲线
+- 自动布局:简单的排斥力 + 吸引力算法(50 行代码足够)
+
+**性能优化**:
+- requestAnimationFrame 统一渲染
+- 卡片多时用 CSS transform 而非 top/left
+- 连线用 SVG 元素,不重绘全部
+
+### 4.4 数据结构
+
+```typescript
+interface DetectiveBoard {
+  cards: DetectiveCard[]
+  connections: DetectiveConnection[]
+  viewport: { x: number; y: number; scale: number }
+}
+
+interface DetectiveCard {
+  id: string
+  type: "clue" | "character" | "event"
+  title: string
+  content: string
+  x: number
+  y: number
+  width: number
+  sourceId?: string  // 对应原始数据 ID
+}
+
+interface DetectiveConnection {
+  id: string
+  fromCardId: string
+  toCardId: string
+  type: "related" | "cause" | "contradiction" | "proves" | "disproves"
+  label?: string
+}
+```
+
+### 4.5 改动文件
+
+- 重写 `src/components/novel/story-simulation/clue-board-panel.tsx` → 侦探板
+- 或新增 `detective-board-panel.tsx`,保留旧线索板作为备选
+- 修改 `story-simulation-store.ts` — 加侦探板状态
+- 修改 `story-simulation-view.tsx` — 集成侦探板
+
+## 五、Task 18:时间滑块 + 状态回放
+
+### 5.1 目标
+
+可以拖动时间滑块回到任意轮次,查看当时的关系状态、已知秘密、事件进展。
+
+### 5.2 历史快照系统
+
+**数据结构**:
+```typescript
+interface SimulationHistoryEntry {
+  round: number
+  nodeIndex: number
+  nodeTitle: string
+  agentStates: Map<string, {
+    sentiments: Map<string, number>
+    knownSecrets: Set<string>
+    observedEvents: string[]
+  }>
+  eventCount: number
+  rumorCount: number
+}
+```
+
+**存储方式**:
+- 在 `story-simulation-store` 中新增 `history: SimulationHistoryEntry[]`
+- 每轮事件处理完成后,保存当前状态快照
+- 最多保存 50 轮(超出取最早的丢弃)
+- 只存增量还是全量?全量存,简单可靠,50 轮内存可接受
+
+### 5.3 回放控制
+
+**UI 组件**:
+```
+┌─ 回放控制 ────────────────────────────────────┐
+│ ◀◀  │  ▶/⏸  │ ▶▶   速度: 1x │  实时模式    │
+│ ○────●────────○──────○──────○               │
+│ R1   R3       R5     R7     R9              │
+│  ↑ 当前第 3 轮 / 共 9 轮                     │
+└──────────────────────────────────────────────┘
+```
+
+**功能**:
+- 滑块拖动:实时跳转到对应轮次
+- 播放/暂停:自动从当前位置播放
+- 前进/后退:单步前进后退
+- 速度调节:0.5x / 1x / 2x / 4x
+- 实时模式:退出回放,跟随最新状态
+- 轮次标记:滑块上标记节点边界
+
+### 5.4 回放影响范围
+
+回放时更新哪些面板?
+- ✅ 关系图(显示当时的关系状态)
+- ✅ 线索板(显示当时已知的线索)
+- ✅ 时间线(显示到该轮为止的事件)
+- ✅ 传闻面板(显示当时的传闻)
+- ❌ 不影响分支管理
+- ❌ 不影响仿真引擎(只读回放,不修改引擎状态)
+
+### 5.5 改动文件
+
+- `src/stores/story-simulation-store.ts` — 新增 history 状态、回放控制方法
+- `src/components/novel/story-simulation/relationship-graph-panel.tsx` — 集成回放控件
+- `src/components/novel/story-simulation/timeline-panel.tsx`(如果有独立的话)— 回放适配
+- 修改 `story-simulation-view.tsx` — 回放状态联动各面板
+
+## 六、Task 19:Web Worker 性能优化
+
+### 6.1 目标
+
+把计算密集型操作移到 Web Worker,避免阻塞主线程,保证 UI 流畅。
+
+### 6.2 识别可移到 Worker 的操作
+
+| 操作 | 计算量 | 是否移 Worker | 说明 |
+|---|---|---|---|
+| 线索关联计算 | 中 | ✅ | 纯数据处理,无 DOM |
+| 分支差异对比 | 中 | ✅ | 纯数据处理 |
+| 侦探板自动布局 | 中 | ✅ | 纯计算 |
+| 雷达图数据预处理 | 小 | ❌ | 不值得 |
+| 时间轴快照计算 | 小 | ❌ | 不值得 |
+| cytoscape 布局 | 大 | ❌ | cytoscape 依赖 DOM |
+
+### 6.3 Worker 设计
+
+**文件结构**:
+```
+src/workers/
+  └── simulation-worker.ts      // Worker 入口
+```
+
+**Worker 支持的消息类型**:
+```typescript
+type WorkerMessage =
+  | { type: "calc-clue-relations"; payload: { clues: ClueItem[] } }
+  | { type: "calc-branch-diff"; payload: { branchA: SimulationBranch; branchB: SimulationBranch } }
+  | { type: "calc-board-layout"; payload: { cards: DetectiveCard[]; connections: DetectiveConnection[] } }
+```
+
+**返回类型**:
+```typescript
+type WorkerResult =
+  | { type: "clue-relations"; payload: { pairs: [string, string, number][] } }
+  | { type: "branch-diff"; payload: DiffAnalysis }
+  | { type: "board-layout"; payload: { cards: { id: string; x: number; y: number }[] } }
+```
+
+### 6.4 Vite Worker 配置
+
+Vite 原生支持 Web Worker:
+```typescript
+import SimulationWorker from "@/workers/simulation-worker?worker"
+
+const worker = new SimulationWorker()
+worker.postMessage({ type: "calc-clue-relations", payload: ... })
+worker.onmessage = (e) => { ... }
+```
+
+### 6.5 封装 Hook
+
+创建 `useSimulationWorker` hook,封装 Worker 通信:
+```typescript
+function useSimulationWorker() {
+  const calcClueRelations = (clues: ClueItem[]) => Promise<...>
+  const calcBranchDiff = (a: Branch, b: Branch) => Promise<...>
+  const calcBoardLayout = (cards, connections) => Promise<...>
+}
+```
+
+内部用 request id 匹配请求响应,支持并发请求。
+
+### 6.6 降级策略
+
+Worker 加载失败或不支持时,降级为主线程计算(用 setTimeout 分批处理,避免长时间阻塞)。
+
+### 6.7 改动文件
+
+- 新增 `src/workers/simulation-worker.ts` — Worker 入口
+- 新增 `src/hooks/use-simulation-worker.ts` — Worker 封装 Hook
+- 修改线索板、分支对比、侦探板 — 使用 Worker 计算
+
+## 七、整体改动清单
+
+| 任务 | 新增文件 | 修改文件 |
+|---|---|---|
+| 15 主动传播 | 1 测试 | types.ts / sim-agent-tools.ts / orchestrator / rumor-panel |
+| 16 分支对比 | 1 组件 | branch-manager-panel / store |
+| 17 侦探板 | 1 组件(重写) | store / main-view |
+| 18 时间回放 | - | store / relationship-graph / main-view |
+| 19 Web Worker | 1 Worker + 1 Hook | 线索板 / 分支对比 / 侦探板 |
+
+**合计**:新增约 6 个文件,修改约 10 个文件
+
+## 八、风险与注意事项
+
+1. **侦探板工作量最大**:拖拽 + 连线 + 画布,bug 多,调试难
+2. **引擎改动需谨慎**:主动传播涉及仿真引擎,需充分测试防止回归
+3. **Web Worker 增加复杂度**:异步通信、状态同步、调试困难
+4. **内存占用**:时间快照 + 侦探板状态,内存可能上升
+5. **向后兼容**:所有改动不破坏已有功能
+6. **分批验证**:5 个任务虽然一起做,但每个任务独立验证,不等到最后

Разница между файлами не показана из-за своего большого размера
+ 300 - 273
package-lock.json


+ 32 - 31
package.json

@@ -1,7 +1,8 @@
 {
-  "name": "llm-wiki",
+  "name": "qmai",
   "private": true,
   "version": "2.2.32",
+  "license": "GPL-3.0-or-later",
   "type": "module",
   "scripts": {
     "dev": "vite",
@@ -17,62 +18,62 @@
     "build:github-release": "powershell -NoProfile -ExecutionPolicy Bypass -Command \"if (-not $env:TAURI_SIGNING_PRIVATE_KEY_PATH) { $env:TAURI_SIGNING_PRIVATE_KEY_PATH = Join-Path $env:USERPROFILE '.tauri\\qmai-updater.key' }; if (-not (Test-Path $env:TAURI_SIGNING_PRIVATE_KEY_PATH)) { throw '缺少 TAURI_SIGNING_PRIVATE_KEY_PATH 对应的签名私钥文件' }; if ($null -eq $env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD) { $env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD = '' }; $env:CARGO_PROFILE_RELEASE_LTO='false'; $env:CARGO_PROFILE_RELEASE_CODEGEN_UNITS='16'; $env:CARGO_PROFILE_RELEASE_OPT_LEVEL='1'; npx tauri build; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; node scripts/build-portable.mjs; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; node scripts/prepare-github-release.mjs\""
   },
   "dependencies": {
-    "@base-ui/react": "^1.3.0",
+    "@base-ui/react": "^1.6.0",
     "@dnd-kit/core": "^6.3.1",
     "@dnd-kit/sortable": "^8.0.0",
     "@dnd-kit/utilities": "^3.2.2",
-    "@fontsource-variable/geist": "^5.2.8",
+    "@fontsource-variable/geist": "^5.2.9",
     "@milkdown/kit": "^7.20.0",
     "@milkdown/plugin-math": "^7.5.9",
-    "@milkdown/react": "^7.20.0",
-    "@milkdown/theme-nord": "^7.20.0",
+    "@milkdown/react": "^7.21.2",
+    "@milkdown/theme-nord": "^7.21.2",
     "@react-sigma/core": "^5.0.6",
     "@sigma/edge-curve": "^3.1.0",
-    "@tailwindcss/vite": "^4.3.1",
-    "@tauri-apps/api": "^2.11.0",
+    "@tailwindcss/vite": "^4.3.2",
+    "@tauri-apps/api": "^2.11.1",
     "@tauri-apps/plugin-dialog": "^2.7.1",
     "@tauri-apps/plugin-http": "^2.5.9",
     "@tauri-apps/plugin-opener": "^2.5.4",
     "@tauri-apps/plugin-store": "^2.4.3",
-    "@tauri-apps/plugin-updater": "^2.9.1",
+    "@tauri-apps/plugin-updater": "^2.10.1",
     "@types/js-yaml": "^4.0.9",
     "class-variance-authority": "^0.7.1",
     "clsx": "^2.1.1",
     "graphology": "^0.26.0",
     "graphology-communities-louvain": "^2.0.2",
     "graphology-layout-forceatlas2": "^0.10.1",
-    "i18next": "^26.0.3",
-    "js-yaml": "^4.1.1",
-    "katex": "^0.16.45",
-    "lucide-react": "1.7.0",
-    "mermaid": "^11.14.0",
-    "opencc-js": "^1.3.1",
+    "i18next": "^26.3.4",
+    "js-yaml": "^4.3.0",
+    "katex": "^0.16.47",
+    "lucide-react": "^1.23.0",
+    "mermaid": "^11.16.0",
+    "opencc-js": "^1.4.0",
     "pinyin-pro": "^3.28.1",
-    "react": "^19.0.0",
-    "react-dom": "^19.0.0",
-    "react-i18next": "^17.0.2",
+    "react": "^19.2.7",
+    "react-dom": "^19.2.7",
+    "react-i18next": "^17.0.8",
     "react-markdown": "^10.1.0",
-    "react-resizable-panels": "^4.9.0",
+    "react-resizable-panels": "^4.12.1",
     "rehype-katex": "^7.0.1",
     "remark-gfm": "^4.0.1",
     "remark-math": "^6.0.0",
-    "shadcn": "^4.1.2",
-    "sigma": "^3.0.2",
-    "tailwind-merge": "^3.5.0",
+    "shadcn": "^4.13.0",
+    "sigma": "^3.0.3",
+    "tailwind-merge": "^3.6.0",
     "tailwindcss": "^4.3.1",
     "tw-animate-css": "^1.4.0",
-    "zustand": "^5.0.12"
+    "zustand": "^5.0.14"
   },
   "devDependencies": {
-    "@tauri-apps/cli": "^2.11.1",
-    "@types/node": "^25.5.2",
-    "@types/react": "^19.0.0",
-    "@types/react-dom": "^19.0.0",
-    "@vitejs/plugin-react": "^6.0.1",
-    "fast-check": "^4.7.0",
+    "@tauri-apps/cli": "^2.11.4",
+    "@types/node": "^25.9.4",
+    "@types/react": "^19.2.17",
+    "@types/react-dom": "^19.2.3",
+    "@vitejs/plugin-react": "^6.0.3",
+    "fast-check": "^4.8.0",
     "jsdom": "^29.1.1",
-    "typescript": "^5.7.3",
-    "vite": "^8.0.0",
-    "vitest": "^4.1.4"
+    "typescript": "^5.9.3",
+    "vite": "^8.1.3",
+    "vitest": "^4.1.9"
   }
 }

BIN
qmai-portable-screenshot.png


BIN
qmai-runtime-screenshot.png


BIN
remote-changelog.ts


+ 94 - 0
scripts/capture-qmai-window.py

@@ -0,0 +1,94 @@
+import win32gui
+import win32ui
+import win32con
+from ctypes import windll
+from PIL import Image
+import time
+import sys
+
+TARGET_TITLES = ['青幕AI写作', 'qingmuai', 'qmai', 'com.qingmuai', 'AI 小说创作工作台', 'AI Novel Production Engine', 'Novel Production Engine']
+
+def find_main_window():
+    candidates = []
+    def callback(hwnd, extra):
+        if not win32gui.IsWindowVisible(hwnd):
+            return
+        title = win32gui.GetWindowText(hwnd)
+        cls = win32gui.GetClassName(hwnd)
+        for t in TARGET_TITLES:
+            if t.lower() in title.lower() or t.lower() in cls.lower():
+                rect = win32gui.GetWindowRect(hwnd)
+                width = rect[2] - rect[0]
+                height = rect[3] - rect[1]
+                if width > 200 and height > 200:
+                    candidates.append((hwnd, title, cls, width * height))
+                break
+    win32gui.EnumWindows(callback, None)
+    if not candidates:
+        return None
+    # 选择面积最大的窗口
+    candidates.sort(key=lambda x: x[3], reverse=True)
+    return candidates[0][0]
+
+def capture_window(hwnd, save_path):
+    win32gui.ShowWindow(hwnd, win32con.SW_RESTORE)
+    win32gui.SetForegroundWindow(hwnd)
+    time.sleep(1.5)
+
+    rect = win32gui.GetWindowRect(hwnd)
+    x, y, x2, y2 = rect
+    width = x2 - x
+    height = y2 - y
+    print(f'Window rect: {rect}, size: {width}x{height}')
+
+    if width <= 0 or height <= 0:
+        print('Invalid window size')
+        return False
+
+    hwndDC = win32gui.GetWindowDC(hwnd)
+    mfcDC = win32ui.CreateDCFromHandle(hwndDC)
+    saveDC = mfcDC.CreateCompatibleDC()
+    saveBitMap = win32ui.CreateBitmap()
+    saveBitMap.CreateCompatibleBitmap(mfcDC, width, height)
+    saveDC.SelectObject(saveBitMap)
+
+    result = windll.user32.PrintWindow(hwnd, saveDC.GetSafeHdc(), 2)
+    print('PrintWindow result:', result)
+
+    bmpinfo = saveBitMap.GetInfo()
+    bmpstr = saveBitMap.GetBitmapBits(True)
+
+    im = Image.frombuffer(
+        'RGB',
+        (bmpinfo['bmWidth'], bmpinfo['bmHeight']),
+        bmpstr, 'raw', 'BGRX', 0, 1)
+
+    im.save(save_path)
+    print(f'Saved screenshot to {save_path}')
+
+    win32gui.DeleteObject(saveBitMap.GetHandle())
+    saveDC.DeleteDC()
+    mfcDC.DeleteDC()
+    win32gui.ReleaseDC(hwnd, hwndDC)
+    return True
+
+def main():
+    hwnd = None
+    for i in range(30):
+        hwnd = find_main_window()
+        print(f'Try {i+1}: hwnd={hwnd}')
+        if hwnd:
+            break
+        time.sleep(1.0)
+
+    if hwnd is None:
+        print('Could not find QMAI main window')
+        sys.exit(1)
+
+    save_path = sys.argv[1] if len(sys.argv) > 1 else r'c:\QMAI_C\QMAI-main\qmai-runtime-screenshot.png'
+    ok = capture_window(hwnd, save_path)
+    if not ok:
+        sys.exit(1)
+
+if __name__ == '__main__':
+    main()

+ 0 - 0
jinengku.md → scripts/dev/notes/jinengku.md


+ 8 - 3
test-api-403.js → scripts/dev/test-api-403.js

@@ -1,6 +1,11 @@
-// 测试不同 headers 组合找出 403 的原因
-const API_KEY = "ah-52ff02ce8f7c1e6028e3bec33ba488a92cdaec659756c8cee92c2b4ef5ee83fe";
-const BASE_URL = "https://hub.linux.do/v1";
+// 测试不同 headers 组合找出 403 的原因(本地调试:API_KEY=xxx node scripts/dev/test-api-403.js)
+const API_KEY = process.env.API_KEY ?? "";
+const BASE_URL = process.env.BASE_URL ?? "https://hub.linux.do/v1";
+
+if (!API_KEY) {
+  console.error("请设置环境变量 API_KEY");
+  process.exit(1);
+}
 
 async function testWithHeaders(testName, headers) {
   console.log(`\n=== ${testName} ===`);

+ 8 - 3
test-api.js → scripts/dev/test-api.js

@@ -1,6 +1,11 @@
-// 测试 linux.do 的 API 接口
-const API_KEY = "ah-52ff02ce8f7c1e6028e3bec33ba488a92cdaec659756c8cee92c2b4ef5ee83fe";
-const BASE_URL = "https://hub.linux.do/v1";
+// 测试 linux.do 的 API 接口(本地调试:API_KEY=xxx node scripts/dev/test-api.js)
+const API_KEY = process.env.API_KEY ?? "";
+const BASE_URL = process.env.BASE_URL ?? "https://hub.linux.do/v1";
+
+if (!API_KEY) {
+  console.error("请设置环境变量 API_KEY");
+  process.exit(1);
+}
 
 async function testModelList() {
   console.log("=== 测试模型列表接口 ===");

+ 0 - 0
验证修复.js → scripts/dev/验证修复.js


+ 0 - 26
shurukuanggaizao.md

@@ -1,26 +0,0 @@
-# 输入框改版分支(shurukuanggaizao)
-
-## 分支用途
-
-将 AI 会话与 AI 大纲的输入框改为图 1 风格的紧凑圆角容器设计,把所有操作控件(深度模式、故事框架/剧情推演、模型选择器、发送按钮)收纳进输入框内部。
-
-## 设计要求
-
-- 统一 AI 会话和 AI 大纲输入框的视觉样式。
-- 发送按钮使用向上箭头图标。
-- 保留手动拖拽调整输入框高度。
-- 不破坏现有 resize 测试与业务逻辑。
-
-## 更新记录
-
-### 2026-06-28
-
-- 创建分支 `shurukuanggaizao`。
-- 完成需求确认与高保真效果图。
-- 编写设计文档:`docs/superpowers/specs/2026-06-28-ai-chat-input-redesign-design.md`。
-- 状态:已提交设计文档到本分支,尚未进入代码实现。
-
-## 提交状态
-
-- 设计文档:已提交(`shurukuanggaizao` 分支)。
-- 代码实现:未开始。

+ 0 - 0
去AI味Skill规则.md → skills/de-ai-writing/references/novel-de-ai-rules.md


+ 37 - 37
src-tauri/Cargo.lock

@@ -4440,43 +4440,6 @@ version = "1.0.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
 
-[[package]]
-name = "llm-wiki"
-version = "2.2.30"
-dependencies = [
- "arrow-array",
- "arrow-schema",
- "base64 0.22.1",
- "calamine",
- "cfb",
- "chrono",
- "docx-rs",
- "encoding_rs",
- "futures",
- "image",
- "lancedb",
- "md-5",
- "notify",
- "pdfium-render",
- "serde",
- "serde_json",
- "sha2",
- "tauri",
- "tauri-build",
- "tauri-plugin-dialog",
- "tauri-plugin-http",
- "tauri-plugin-opener",
- "tauri-plugin-single-instance",
- "tauri-plugin-store",
- "tauri-plugin-updater",
- "tokio",
- "toml 0.8.2",
- "uuid",
- "walkdir",
- "which",
- "zip 2.4.2",
-]
-
 [[package]]
 name = "lock_api"
 version = "0.4.14"
@@ -5763,6 +5726,43 @@ version = "0.1.29"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
 
+[[package]]
+name = "qmai"
+version = "2.2.32"
+dependencies = [
+ "arrow-array",
+ "arrow-schema",
+ "base64 0.22.1",
+ "calamine",
+ "cfb",
+ "chrono",
+ "docx-rs",
+ "encoding_rs",
+ "futures",
+ "image",
+ "lancedb",
+ "md-5",
+ "notify",
+ "pdfium-render",
+ "serde",
+ "serde_json",
+ "sha2",
+ "tauri",
+ "tauri-build",
+ "tauri-plugin-dialog",
+ "tauri-plugin-http",
+ "tauri-plugin-opener",
+ "tauri-plugin-single-instance",
+ "tauri-plugin-store",
+ "tauri-plugin-updater",
+ "tokio",
+ "toml 0.8.2",
+ "uuid",
+ "walkdir",
+ "which",
+ "zip 2.4.2",
+]
+
 [[package]]
 name = "quick-error"
 version = "2.0.1"

+ 4 - 4
src-tauri/Cargo.toml

@@ -1,8 +1,8 @@
 [package]
-name = "llm-wiki"
-version = "2.2.30"
-description = "LLM Wiki - A personal knowledge base for LLM concepts"
-authors = []
+name = "qmai"
+version = "2.2.32"
+description = "QMAI - AI writing system for long-form novels"
+authors = ["Mochocyang"]
 edition = "2021"
 default-run = "qmai"
 

+ 3023 - 0
src-tauri/gen/schemas/macOS-schema.json

@@ -0,0 +1,3023 @@
+{
+  "$schema": "http://json-schema.org/draft-07/schema#",
+  "title": "CapabilityFile",
+  "description": "Capability formats accepted in a capability file.",
+  "anyOf": [
+    {
+      "description": "A single capability.",
+      "allOf": [
+        {
+          "$ref": "#/definitions/Capability"
+        }
+      ]
+    },
+    {
+      "description": "A list of capabilities.",
+      "type": "array",
+      "items": {
+        "$ref": "#/definitions/Capability"
+      }
+    },
+    {
+      "description": "A list of capabilities.",
+      "type": "object",
+      "required": [
+        "capabilities"
+      ],
+      "properties": {
+        "capabilities": {
+          "description": "The list of capabilities.",
+          "type": "array",
+          "items": {
+            "$ref": "#/definitions/Capability"
+          }
+        }
+      }
+    }
+  ],
+  "definitions": {
+    "Capability": {
+      "description": "A grouping and boundary mechanism developers can use to isolate access to the IPC layer.\n\nIt controls application windows' and webviews' fine grained access to the Tauri core, application, or plugin commands. If a webview or its window is not matching any capability then it has no access to the IPC layer at all.\n\nThis can be done to create groups of windows, based on their required system access, which can reduce impact of frontend vulnerabilities in less privileged windows. Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`. A Window can have none, one, or multiple associated capabilities.\n\n## Example\n\n```json { \"identifier\": \"main-user-files-write\", \"description\": \"This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.\", \"windows\": [ \"main\" ], \"permissions\": [ \"core:default\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] }, ], \"platforms\": [\"macOS\",\"windows\"] } ```",
+      "type": "object",
+      "required": [
+        "identifier",
+        "permissions"
+      ],
+      "properties": {
+        "identifier": {
+          "description": "Identifier of the capability.\n\n## Example\n\n`main-user-files-write`",
+          "type": "string"
+        },
+        "description": {
+          "description": "Description of what the capability is intended to allow on associated windows.\n\nIt should contain a description of what the grouped permissions should allow.\n\n## Example\n\nThis capability allows the `main` window access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.",
+          "default": "",
+          "type": "string"
+        },
+        "remote": {
+          "description": "Configure remote URLs that can use the capability permissions.\n\nThis setting is optional and defaults to not being set, as our default use case is that the content is served from our local application.\n\n:::caution Make sure you understand the security implications of providing remote sources with local system access. :::\n\n## Example\n\n```json { \"urls\": [\"https://*.mydomain.dev\"] } ```",
+          "anyOf": [
+            {
+              "$ref": "#/definitions/CapabilityRemote"
+            },
+            {
+              "type": "null"
+            }
+          ]
+        },
+        "local": {
+          "description": "Whether this capability is enabled for local app URLs or not. Defaults to `true`.",
+          "default": true,
+          "type": "boolean"
+        },
+        "windows": {
+          "description": "List of windows that are affected by this capability. Can be a glob pattern.\n\nIf a window label matches any of the patterns in this list, the capability will be enabled on all the webviews of that window, regardless of the value of [`Self::webviews`].\n\nOn multiwebview windows, prefer specifying [`Self::webviews`] and omitting [`Self::windows`] for a fine grained access control.\n\n## Example\n\n`[\"main\"]`",
+          "type": "array",
+          "items": {
+            "type": "string"
+          }
+        },
+        "webviews": {
+          "description": "List of webviews that are affected by this capability. Can be a glob pattern.\n\nThe capability will be enabled on all the webviews whose label matches any of the patterns in this list, regardless of whether the webview's window label matches a pattern in [`Self::windows`].\n\n## Example\n\n`[\"sub-webview-one\", \"sub-webview-two\"]`",
+          "type": "array",
+          "items": {
+            "type": "string"
+          }
+        },
+        "permissions": {
+          "description": "List of permissions attached to this capability.\n\nMust include the plugin name as prefix in the form of `${plugin-name}:${permission-name}`. For commands directly implemented in the application itself only `${permission-name}` is required.\n\n## Example\n\n```json [ \"core:default\", \"shell:allow-open\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] } ] ```",
+          "type": "array",
+          "items": {
+            "$ref": "#/definitions/PermissionEntry"
+          },
+          "uniqueItems": true
+        },
+        "platforms": {
+          "description": "Limit which target platforms this capability applies to.\n\nBy default all platforms are targeted.\n\n## Example\n\n`[\"macOS\",\"windows\"]`",
+          "type": [
+            "array",
+            "null"
+          ],
+          "items": {
+            "$ref": "#/definitions/Target"
+          }
+        }
+      }
+    },
+    "CapabilityRemote": {
+      "description": "Configuration for remote URLs that are associated with the capability.",
+      "type": "object",
+      "required": [
+        "urls"
+      ],
+      "properties": {
+        "urls": {
+          "description": "Remote domains this capability refers to using the [URLPattern standard](https://urlpattern.spec.whatwg.org/).\n\n## Examples\n\n- \"https://*.mydomain.dev\": allows subdomains of mydomain.dev - \"https://mydomain.dev/api/*\": allows any subpath of mydomain.dev/api",
+          "type": "array",
+          "items": {
+            "type": "string"
+          }
+        }
+      }
+    },
+    "PermissionEntry": {
+      "description": "An entry for a permission value in a [`Capability`] can be either a raw permission [`Identifier`] or an object that references a permission and extends its scope.",
+      "anyOf": [
+        {
+          "description": "Reference a permission or permission set by identifier.",
+          "allOf": [
+            {
+              "$ref": "#/definitions/Identifier"
+            }
+          ]
+        },
+        {
+          "description": "Reference a permission or permission set by identifier and extends its scope.",
+          "type": "object",
+          "allOf": [
+            {
+              "if": {
+                "properties": {
+                  "identifier": {
+                    "anyOf": [
+                      {
+                        "description": "This permission set configures what kind of\nfetch operations are available from the http plugin.\n\nThis enables all fetch operations but does not\nallow explicitly any origins to be fetched. This needs to\nbe manually configured before usage.\n\n#### Granted Permissions\n\nAll fetch operations are enabled.\n\n\n#### This default permission set includes:\n\n- `allow-fetch`\n- `allow-fetch-cancel`\n- `allow-fetch-send`\n- `allow-fetch-read-body`\n- `allow-fetch-cancel-body`",
+                        "type": "string",
+                        "const": "http:default",
+                        "markdownDescription": "This permission set configures what kind of\nfetch operations are available from the http plugin.\n\nThis enables all fetch operations but does not\nallow explicitly any origins to be fetched. This needs to\nbe manually configured before usage.\n\n#### Granted Permissions\n\nAll fetch operations are enabled.\n\n\n#### This default permission set includes:\n\n- `allow-fetch`\n- `allow-fetch-cancel`\n- `allow-fetch-send`\n- `allow-fetch-read-body`\n- `allow-fetch-cancel-body`"
+                      },
+                      {
+                        "description": "Enables the fetch command without any pre-configured scope.",
+                        "type": "string",
+                        "const": "http:allow-fetch",
+                        "markdownDescription": "Enables the fetch command without any pre-configured scope."
+                      },
+                      {
+                        "description": "Enables the fetch_cancel command without any pre-configured scope.",
+                        "type": "string",
+                        "const": "http:allow-fetch-cancel",
+                        "markdownDescription": "Enables the fetch_cancel command without any pre-configured scope."
+                      },
+                      {
+                        "description": "Enables the fetch_cancel_body command without any pre-configured scope.",
+                        "type": "string",
+                        "const": "http:allow-fetch-cancel-body",
+                        "markdownDescription": "Enables the fetch_cancel_body command without any pre-configured scope."
+                      },
+                      {
+                        "description": "Enables the fetch_read_body command without any pre-configured scope.",
+                        "type": "string",
+                        "const": "http:allow-fetch-read-body",
+                        "markdownDescription": "Enables the fetch_read_body command without any pre-configured scope."
+                      },
+                      {
+                        "description": "Enables the fetch_send command without any pre-configured scope.",
+                        "type": "string",
+                        "const": "http:allow-fetch-send",
+                        "markdownDescription": "Enables the fetch_send command without any pre-configured scope."
+                      },
+                      {
+                        "description": "Denies the fetch command without any pre-configured scope.",
+                        "type": "string",
+                        "const": "http:deny-fetch",
+                        "markdownDescription": "Denies the fetch command without any pre-configured scope."
+                      },
+                      {
+                        "description": "Denies the fetch_cancel command without any pre-configured scope.",
+                        "type": "string",
+                        "const": "http:deny-fetch-cancel",
+                        "markdownDescription": "Denies the fetch_cancel command without any pre-configured scope."
+                      },
+                      {
+                        "description": "Denies the fetch_cancel_body command without any pre-configured scope.",
+                        "type": "string",
+                        "const": "http:deny-fetch-cancel-body",
+                        "markdownDescription": "Denies the fetch_cancel_body command without any pre-configured scope."
+                      },
+                      {
+                        "description": "Denies the fetch_read_body command without any pre-configured scope.",
+                        "type": "string",
+                        "const": "http:deny-fetch-read-body",
+                        "markdownDescription": "Denies the fetch_read_body command without any pre-configured scope."
+                      },
+                      {
+                        "description": "Denies the fetch_send command without any pre-configured scope.",
+                        "type": "string",
+                        "const": "http:deny-fetch-send",
+                        "markdownDescription": "Denies the fetch_send command without any pre-configured scope."
+                      }
+                    ]
+                  }
+                }
+              },
+              "then": {
+                "properties": {
+                  "allow": {
+                    "items": {
+                      "title": "HttpScopeEntry",
+                      "description": "HTTP scope entry.",
+                      "anyOf": [
+                        {
+                          "description": "A URL that can be accessed by the webview when using the HTTP APIs. Wildcards can be used following the URL pattern standard.\n\nSee [the URL Pattern spec](https://urlpattern.spec.whatwg.org/) for more information.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin on port 443\n\n- \"https://*:*\" : allows all HTTPS origin on any port\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"",
+                          "type": "string"
+                        },
+                        {
+                          "type": "object",
+                          "required": [
+                            "url"
+                          ],
+                          "properties": {
+                            "url": {
+                              "description": "A URL that can be accessed by the webview when using the HTTP APIs. Wildcards can be used following the URL pattern standard.\n\nSee [the URL Pattern spec](https://urlpattern.spec.whatwg.org/) for more information.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin on port 443\n\n- \"https://*:*\" : allows all HTTPS origin on any port\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"",
+                              "type": "string"
+                            }
+                          }
+                        }
+                      ]
+                    }
+                  },
+                  "deny": {
+                    "items": {
+                      "title": "HttpScopeEntry",
+                      "description": "HTTP scope entry.",
+                      "anyOf": [
+                        {
+                          "description": "A URL that can be accessed by the webview when using the HTTP APIs. Wildcards can be used following the URL pattern standard.\n\nSee [the URL Pattern spec](https://urlpattern.spec.whatwg.org/) for more information.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin on port 443\n\n- \"https://*:*\" : allows all HTTPS origin on any port\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"",
+                          "type": "string"
+                        },
+                        {
+                          "type": "object",
+                          "required": [
+                            "url"
+                          ],
+                          "properties": {
+                            "url": {
+                              "description": "A URL that can be accessed by the webview when using the HTTP APIs. Wildcards can be used following the URL pattern standard.\n\nSee [the URL Pattern spec](https://urlpattern.spec.whatwg.org/) for more information.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin on port 443\n\n- \"https://*:*\" : allows all HTTPS origin on any port\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"",
+                              "type": "string"
+                            }
+                          }
+                        }
+                      ]
+                    }
+                  }
+                }
+              },
+              "properties": {
+                "identifier": {
+                  "description": "Identifier of the permission or permission set.",
+                  "allOf": [
+                    {
+                      "$ref": "#/definitions/Identifier"
+                    }
+                  ]
+                }
+              }
+            },
+            {
+              "if": {
+                "properties": {
+                  "identifier": {
+                    "anyOf": [
+                      {
+                        "description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
+                        "type": "string",
+                        "const": "opener:default",
+                        "markdownDescription": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`"
+                      },
+                      {
+                        "description": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.",
+                        "type": "string",
+                        "const": "opener:allow-default-urls",
+                        "markdownDescription": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application."
+                      },
+                      {
+                        "description": "Enables the open_path command without any pre-configured scope.",
+                        "type": "string",
+                        "const": "opener:allow-open-path",
+                        "markdownDescription": "Enables the open_path command without any pre-configured scope."
+                      },
+                      {
+                        "description": "Enables the open_url command without any pre-configured scope.",
+                        "type": "string",
+                        "const": "opener:allow-open-url",
+                        "markdownDescription": "Enables the open_url command without any pre-configured scope."
+                      },
+                      {
+                        "description": "Enables the reveal_item_in_dir command without any pre-configured scope.",
+                        "type": "string",
+                        "const": "opener:allow-reveal-item-in-dir",
+                        "markdownDescription": "Enables the reveal_item_in_dir command without any pre-configured scope."
+                      },
+                      {
+                        "description": "Denies the open_path command without any pre-configured scope.",
+                        "type": "string",
+                        "const": "opener:deny-open-path",
+                        "markdownDescription": "Denies the open_path command without any pre-configured scope."
+                      },
+                      {
+                        "description": "Denies the open_url command without any pre-configured scope.",
+                        "type": "string",
+                        "const": "opener:deny-open-url",
+                        "markdownDescription": "Denies the open_url command without any pre-configured scope."
+                      },
+                      {
+                        "description": "Denies the reveal_item_in_dir command without any pre-configured scope.",
+                        "type": "string",
+                        "const": "opener:deny-reveal-item-in-dir",
+                        "markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope."
+                      }
+                    ]
+                  }
+                }
+              },
+              "then": {
+                "properties": {
+                  "allow": {
+                    "items": {
+                      "title": "OpenerScopeEntry",
+                      "description": "Opener scope entry.",
+                      "anyOf": [
+                        {
+                          "type": "object",
+                          "required": [
+                            "url"
+                          ],
+                          "properties": {
+                            "app": {
+                              "description": "An application to open this url with, for example: firefox.",
+                              "allOf": [
+                                {
+                                  "$ref": "#/definitions/Application"
+                                }
+                              ]
+                            },
+                            "url": {
+                              "description": "A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"",
+                              "type": "string"
+                            }
+                          }
+                        },
+                        {
+                          "type": "object",
+                          "required": [
+                            "path"
+                          ],
+                          "properties": {
+                            "app": {
+                              "description": "An application to open this path with, for example: xdg-open.",
+                              "allOf": [
+                                {
+                                  "$ref": "#/definitions/Application"
+                                }
+                              ]
+                            },
+                            "path": {
+                              "description": "A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.",
+                              "type": "string"
+                            }
+                          }
+                        }
+                      ]
+                    }
+                  },
+                  "deny": {
+                    "items": {
+                      "title": "OpenerScopeEntry",
+                      "description": "Opener scope entry.",
+                      "anyOf": [
+                        {
+                          "type": "object",
+                          "required": [
+                            "url"
+                          ],
+                          "properties": {
+                            "app": {
+                              "description": "An application to open this url with, for example: firefox.",
+                              "allOf": [
+                                {
+                                  "$ref": "#/definitions/Application"
+                                }
+                              ]
+                            },
+                            "url": {
+                              "description": "A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"",
+                              "type": "string"
+                            }
+                          }
+                        },
+                        {
+                          "type": "object",
+                          "required": [
+                            "path"
+                          ],
+                          "properties": {
+                            "app": {
+                              "description": "An application to open this path with, for example: xdg-open.",
+                              "allOf": [
+                                {
+                                  "$ref": "#/definitions/Application"
+                                }
+                              ]
+                            },
+                            "path": {
+                              "description": "A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.",
+                              "type": "string"
+                            }
+                          }
+                        }
+                      ]
+                    }
+                  }
+                }
+              },
+              "properties": {
+                "identifier": {
+                  "description": "Identifier of the permission or permission set.",
+                  "allOf": [
+                    {
+                      "$ref": "#/definitions/Identifier"
+                    }
+                  ]
+                }
+              }
+            },
+            {
+              "properties": {
+                "identifier": {
+                  "description": "Identifier of the permission or permission set.",
+                  "allOf": [
+                    {
+                      "$ref": "#/definitions/Identifier"
+                    }
+                  ]
+                },
+                "allow": {
+                  "description": "Data that defines what is allowed by the scope.",
+                  "type": [
+                    "array",
+                    "null"
+                  ],
+                  "items": {
+                    "$ref": "#/definitions/Value"
+                  }
+                },
+                "deny": {
+                  "description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.",
+                  "type": [
+                    "array",
+                    "null"
+                  ],
+                  "items": {
+                    "$ref": "#/definitions/Value"
+                  }
+                }
+              }
+            }
+          ],
+          "required": [
+            "identifier"
+          ]
+        }
+      ]
+    },
+    "Identifier": {
+      "description": "Permission identifier",
+      "oneOf": [
+        {
+          "description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`",
+          "type": "string",
+          "const": "core:default",
+          "markdownDescription": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`"
+        },
+        {
+          "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-supports-multiple-windows`",
+          "type": "string",
+          "const": "core:app:default",
+          "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-supports-multiple-windows`"
+        },
+        {
+          "description": "Enables the app_hide command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:app:allow-app-hide",
+          "markdownDescription": "Enables the app_hide command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the app_show command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:app:allow-app-show",
+          "markdownDescription": "Enables the app_show command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the bundle_type command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:app:allow-bundle-type",
+          "markdownDescription": "Enables the bundle_type command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the default_window_icon command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:app:allow-default-window-icon",
+          "markdownDescription": "Enables the default_window_icon command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the fetch_data_store_identifiers command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:app:allow-fetch-data-store-identifiers",
+          "markdownDescription": "Enables the fetch_data_store_identifiers command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the identifier command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:app:allow-identifier",
+          "markdownDescription": "Enables the identifier command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the name command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:app:allow-name",
+          "markdownDescription": "Enables the name command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the register_listener command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:app:allow-register-listener",
+          "markdownDescription": "Enables the register_listener command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the remove_data_store command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:app:allow-remove-data-store",
+          "markdownDescription": "Enables the remove_data_store command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the remove_listener command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:app:allow-remove-listener",
+          "markdownDescription": "Enables the remove_listener command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the set_app_theme command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:app:allow-set-app-theme",
+          "markdownDescription": "Enables the set_app_theme command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the set_dock_visibility command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:app:allow-set-dock-visibility",
+          "markdownDescription": "Enables the set_dock_visibility command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the supports_multiple_windows command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:app:allow-supports-multiple-windows",
+          "markdownDescription": "Enables the supports_multiple_windows command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the tauri_version command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:app:allow-tauri-version",
+          "markdownDescription": "Enables the tauri_version command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the version command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:app:allow-version",
+          "markdownDescription": "Enables the version command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the app_hide command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:app:deny-app-hide",
+          "markdownDescription": "Denies the app_hide command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the app_show command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:app:deny-app-show",
+          "markdownDescription": "Denies the app_show command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the bundle_type command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:app:deny-bundle-type",
+          "markdownDescription": "Denies the bundle_type command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the default_window_icon command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:app:deny-default-window-icon",
+          "markdownDescription": "Denies the default_window_icon command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the fetch_data_store_identifiers command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:app:deny-fetch-data-store-identifiers",
+          "markdownDescription": "Denies the fetch_data_store_identifiers command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the identifier command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:app:deny-identifier",
+          "markdownDescription": "Denies the identifier command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the name command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:app:deny-name",
+          "markdownDescription": "Denies the name command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the register_listener command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:app:deny-register-listener",
+          "markdownDescription": "Denies the register_listener command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the remove_data_store command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:app:deny-remove-data-store",
+          "markdownDescription": "Denies the remove_data_store command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the remove_listener command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:app:deny-remove-listener",
+          "markdownDescription": "Denies the remove_listener command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the set_app_theme command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:app:deny-set-app-theme",
+          "markdownDescription": "Denies the set_app_theme command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the set_dock_visibility command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:app:deny-set-dock-visibility",
+          "markdownDescription": "Denies the set_dock_visibility command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the supports_multiple_windows command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:app:deny-supports-multiple-windows",
+          "markdownDescription": "Denies the supports_multiple_windows command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the tauri_version command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:app:deny-tauri-version",
+          "markdownDescription": "Denies the tauri_version command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the version command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:app:deny-version",
+          "markdownDescription": "Denies the version command without any pre-configured scope."
+        },
+        {
+          "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-listen`\n- `allow-unlisten`\n- `allow-emit`\n- `allow-emit-to`",
+          "type": "string",
+          "const": "core:event:default",
+          "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-listen`\n- `allow-unlisten`\n- `allow-emit`\n- `allow-emit-to`"
+        },
+        {
+          "description": "Enables the emit command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:event:allow-emit",
+          "markdownDescription": "Enables the emit command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the emit_to command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:event:allow-emit-to",
+          "markdownDescription": "Enables the emit_to command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the listen command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:event:allow-listen",
+          "markdownDescription": "Enables the listen command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the unlisten command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:event:allow-unlisten",
+          "markdownDescription": "Enables the unlisten command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the emit command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:event:deny-emit",
+          "markdownDescription": "Denies the emit command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the emit_to command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:event:deny-emit-to",
+          "markdownDescription": "Denies the emit_to command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the listen command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:event:deny-listen",
+          "markdownDescription": "Denies the listen command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the unlisten command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:event:deny-unlisten",
+          "markdownDescription": "Denies the unlisten command without any pre-configured scope."
+        },
+        {
+          "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-from-bytes`\n- `allow-from-path`\n- `allow-rgba`\n- `allow-size`",
+          "type": "string",
+          "const": "core:image:default",
+          "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-from-bytes`\n- `allow-from-path`\n- `allow-rgba`\n- `allow-size`"
+        },
+        {
+          "description": "Enables the from_bytes command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:image:allow-from-bytes",
+          "markdownDescription": "Enables the from_bytes command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the from_path command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:image:allow-from-path",
+          "markdownDescription": "Enables the from_path command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the new command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:image:allow-new",
+          "markdownDescription": "Enables the new command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the rgba command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:image:allow-rgba",
+          "markdownDescription": "Enables the rgba command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the size command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:image:allow-size",
+          "markdownDescription": "Enables the size command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the from_bytes command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:image:deny-from-bytes",
+          "markdownDescription": "Denies the from_bytes command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the from_path command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:image:deny-from-path",
+          "markdownDescription": "Denies the from_path command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the new command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:image:deny-new",
+          "markdownDescription": "Denies the new command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the rgba command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:image:deny-rgba",
+          "markdownDescription": "Denies the rgba command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the size command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:image:deny-size",
+          "markdownDescription": "Denies the size command without any pre-configured scope."
+        },
+        {
+          "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-append`\n- `allow-prepend`\n- `allow-insert`\n- `allow-remove`\n- `allow-remove-at`\n- `allow-items`\n- `allow-get`\n- `allow-popup`\n- `allow-create-default`\n- `allow-set-as-app-menu`\n- `allow-set-as-window-menu`\n- `allow-text`\n- `allow-set-text`\n- `allow-is-enabled`\n- `allow-set-enabled`\n- `allow-set-accelerator`\n- `allow-set-as-windows-menu-for-nsapp`\n- `allow-set-as-help-menu-for-nsapp`\n- `allow-is-checked`\n- `allow-set-checked`\n- `allow-set-icon`",
+          "type": "string",
+          "const": "core:menu:default",
+          "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-append`\n- `allow-prepend`\n- `allow-insert`\n- `allow-remove`\n- `allow-remove-at`\n- `allow-items`\n- `allow-get`\n- `allow-popup`\n- `allow-create-default`\n- `allow-set-as-app-menu`\n- `allow-set-as-window-menu`\n- `allow-text`\n- `allow-set-text`\n- `allow-is-enabled`\n- `allow-set-enabled`\n- `allow-set-accelerator`\n- `allow-set-as-windows-menu-for-nsapp`\n- `allow-set-as-help-menu-for-nsapp`\n- `allow-is-checked`\n- `allow-set-checked`\n- `allow-set-icon`"
+        },
+        {
+          "description": "Enables the append command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:menu:allow-append",
+          "markdownDescription": "Enables the append command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the create_default command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:menu:allow-create-default",
+          "markdownDescription": "Enables the create_default command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the get command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:menu:allow-get",
+          "markdownDescription": "Enables the get command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the insert command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:menu:allow-insert",
+          "markdownDescription": "Enables the insert command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the is_checked command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:menu:allow-is-checked",
+          "markdownDescription": "Enables the is_checked command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the is_enabled command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:menu:allow-is-enabled",
+          "markdownDescription": "Enables the is_enabled command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the items command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:menu:allow-items",
+          "markdownDescription": "Enables the items command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the new command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:menu:allow-new",
+          "markdownDescription": "Enables the new command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the popup command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:menu:allow-popup",
+          "markdownDescription": "Enables the popup command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the prepend command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:menu:allow-prepend",
+          "markdownDescription": "Enables the prepend command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the remove command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:menu:allow-remove",
+          "markdownDescription": "Enables the remove command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the remove_at command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:menu:allow-remove-at",
+          "markdownDescription": "Enables the remove_at command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the set_accelerator command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:menu:allow-set-accelerator",
+          "markdownDescription": "Enables the set_accelerator command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the set_as_app_menu command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:menu:allow-set-as-app-menu",
+          "markdownDescription": "Enables the set_as_app_menu command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:menu:allow-set-as-help-menu-for-nsapp",
+          "markdownDescription": "Enables the set_as_help_menu_for_nsapp command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the set_as_window_menu command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:menu:allow-set-as-window-menu",
+          "markdownDescription": "Enables the set_as_window_menu command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:menu:allow-set-as-windows-menu-for-nsapp",
+          "markdownDescription": "Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the set_checked command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:menu:allow-set-checked",
+          "markdownDescription": "Enables the set_checked command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the set_enabled command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:menu:allow-set-enabled",
+          "markdownDescription": "Enables the set_enabled command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the set_icon command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:menu:allow-set-icon",
+          "markdownDescription": "Enables the set_icon command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the set_text command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:menu:allow-set-text",
+          "markdownDescription": "Enables the set_text command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the text command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:menu:allow-text",
+          "markdownDescription": "Enables the text command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the append command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:menu:deny-append",
+          "markdownDescription": "Denies the append command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the create_default command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:menu:deny-create-default",
+          "markdownDescription": "Denies the create_default command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the get command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:menu:deny-get",
+          "markdownDescription": "Denies the get command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the insert command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:menu:deny-insert",
+          "markdownDescription": "Denies the insert command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the is_checked command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:menu:deny-is-checked",
+          "markdownDescription": "Denies the is_checked command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the is_enabled command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:menu:deny-is-enabled",
+          "markdownDescription": "Denies the is_enabled command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the items command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:menu:deny-items",
+          "markdownDescription": "Denies the items command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the new command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:menu:deny-new",
+          "markdownDescription": "Denies the new command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the popup command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:menu:deny-popup",
+          "markdownDescription": "Denies the popup command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the prepend command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:menu:deny-prepend",
+          "markdownDescription": "Denies the prepend command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the remove command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:menu:deny-remove",
+          "markdownDescription": "Denies the remove command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the remove_at command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:menu:deny-remove-at",
+          "markdownDescription": "Denies the remove_at command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the set_accelerator command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:menu:deny-set-accelerator",
+          "markdownDescription": "Denies the set_accelerator command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the set_as_app_menu command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:menu:deny-set-as-app-menu",
+          "markdownDescription": "Denies the set_as_app_menu command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:menu:deny-set-as-help-menu-for-nsapp",
+          "markdownDescription": "Denies the set_as_help_menu_for_nsapp command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the set_as_window_menu command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:menu:deny-set-as-window-menu",
+          "markdownDescription": "Denies the set_as_window_menu command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:menu:deny-set-as-windows-menu-for-nsapp",
+          "markdownDescription": "Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the set_checked command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:menu:deny-set-checked",
+          "markdownDescription": "Denies the set_checked command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the set_enabled command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:menu:deny-set-enabled",
+          "markdownDescription": "Denies the set_enabled command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the set_icon command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:menu:deny-set-icon",
+          "markdownDescription": "Denies the set_icon command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the set_text command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:menu:deny-set-text",
+          "markdownDescription": "Denies the set_text command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the text command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:menu:deny-text",
+          "markdownDescription": "Denies the text command without any pre-configured scope."
+        },
+        {
+          "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-resolve-directory`\n- `allow-resolve`\n- `allow-normalize`\n- `allow-join`\n- `allow-dirname`\n- `allow-extname`\n- `allow-basename`\n- `allow-is-absolute`",
+          "type": "string",
+          "const": "core:path:default",
+          "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-resolve-directory`\n- `allow-resolve`\n- `allow-normalize`\n- `allow-join`\n- `allow-dirname`\n- `allow-extname`\n- `allow-basename`\n- `allow-is-absolute`"
+        },
+        {
+          "description": "Enables the basename command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:path:allow-basename",
+          "markdownDescription": "Enables the basename command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the dirname command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:path:allow-dirname",
+          "markdownDescription": "Enables the dirname command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the extname command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:path:allow-extname",
+          "markdownDescription": "Enables the extname command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the is_absolute command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:path:allow-is-absolute",
+          "markdownDescription": "Enables the is_absolute command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the join command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:path:allow-join",
+          "markdownDescription": "Enables the join command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the normalize command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:path:allow-normalize",
+          "markdownDescription": "Enables the normalize command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the resolve command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:path:allow-resolve",
+          "markdownDescription": "Enables the resolve command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the resolve_directory command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:path:allow-resolve-directory",
+          "markdownDescription": "Enables the resolve_directory command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the basename command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:path:deny-basename",
+          "markdownDescription": "Denies the basename command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the dirname command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:path:deny-dirname",
+          "markdownDescription": "Denies the dirname command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the extname command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:path:deny-extname",
+          "markdownDescription": "Denies the extname command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the is_absolute command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:path:deny-is-absolute",
+          "markdownDescription": "Denies the is_absolute command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the join command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:path:deny-join",
+          "markdownDescription": "Denies the join command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the normalize command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:path:deny-normalize",
+          "markdownDescription": "Denies the normalize command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the resolve command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:path:deny-resolve",
+          "markdownDescription": "Denies the resolve command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the resolve_directory command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:path:deny-resolve-directory",
+          "markdownDescription": "Denies the resolve_directory command without any pre-configured scope."
+        },
+        {
+          "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-close`",
+          "type": "string",
+          "const": "core:resources:default",
+          "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-close`"
+        },
+        {
+          "description": "Enables the close command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:resources:allow-close",
+          "markdownDescription": "Enables the close command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the close command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:resources:deny-close",
+          "markdownDescription": "Denies the close command without any pre-configured scope."
+        },
+        {
+          "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-icon-with-as-template`\n- `allow-set-show-menu-on-left-click`",
+          "type": "string",
+          "const": "core:tray:default",
+          "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-icon-with-as-template`\n- `allow-set-show-menu-on-left-click`"
+        },
+        {
+          "description": "Enables the get_by_id command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:tray:allow-get-by-id",
+          "markdownDescription": "Enables the get_by_id command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the new command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:tray:allow-new",
+          "markdownDescription": "Enables the new command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the remove_by_id command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:tray:allow-remove-by-id",
+          "markdownDescription": "Enables the remove_by_id command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the set_icon command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:tray:allow-set-icon",
+          "markdownDescription": "Enables the set_icon command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the set_icon_as_template command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:tray:allow-set-icon-as-template",
+          "markdownDescription": "Enables the set_icon_as_template command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the set_icon_with_as_template command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:tray:allow-set-icon-with-as-template",
+          "markdownDescription": "Enables the set_icon_with_as_template command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the set_menu command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:tray:allow-set-menu",
+          "markdownDescription": "Enables the set_menu command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the set_show_menu_on_left_click command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:tray:allow-set-show-menu-on-left-click",
+          "markdownDescription": "Enables the set_show_menu_on_left_click command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the set_temp_dir_path command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:tray:allow-set-temp-dir-path",
+          "markdownDescription": "Enables the set_temp_dir_path command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the set_title command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:tray:allow-set-title",
+          "markdownDescription": "Enables the set_title command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the set_tooltip command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:tray:allow-set-tooltip",
+          "markdownDescription": "Enables the set_tooltip command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the set_visible command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:tray:allow-set-visible",
+          "markdownDescription": "Enables the set_visible command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the get_by_id command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:tray:deny-get-by-id",
+          "markdownDescription": "Denies the get_by_id command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the new command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:tray:deny-new",
+          "markdownDescription": "Denies the new command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the remove_by_id command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:tray:deny-remove-by-id",
+          "markdownDescription": "Denies the remove_by_id command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the set_icon command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:tray:deny-set-icon",
+          "markdownDescription": "Denies the set_icon command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the set_icon_as_template command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:tray:deny-set-icon-as-template",
+          "markdownDescription": "Denies the set_icon_as_template command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the set_icon_with_as_template command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:tray:deny-set-icon-with-as-template",
+          "markdownDescription": "Denies the set_icon_with_as_template command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the set_menu command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:tray:deny-set-menu",
+          "markdownDescription": "Denies the set_menu command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the set_show_menu_on_left_click command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:tray:deny-set-show-menu-on-left-click",
+          "markdownDescription": "Denies the set_show_menu_on_left_click command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the set_temp_dir_path command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:tray:deny-set-temp-dir-path",
+          "markdownDescription": "Denies the set_temp_dir_path command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the set_title command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:tray:deny-set-title",
+          "markdownDescription": "Denies the set_title command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the set_tooltip command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:tray:deny-set-tooltip",
+          "markdownDescription": "Denies the set_tooltip command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the set_visible command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:tray:deny-set-visible",
+          "markdownDescription": "Denies the set_visible command without any pre-configured scope."
+        },
+        {
+          "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-webviews`\n- `allow-webview-position`\n- `allow-webview-size`\n- `allow-internal-toggle-devtools`",
+          "type": "string",
+          "const": "core:webview:default",
+          "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-webviews`\n- `allow-webview-position`\n- `allow-webview-size`\n- `allow-internal-toggle-devtools`"
+        },
+        {
+          "description": "Enables the clear_all_browsing_data command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:webview:allow-clear-all-browsing-data",
+          "markdownDescription": "Enables the clear_all_browsing_data command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the create_webview command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:webview:allow-create-webview",
+          "markdownDescription": "Enables the create_webview command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the create_webview_window command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:webview:allow-create-webview-window",
+          "markdownDescription": "Enables the create_webview_window command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the get_all_webviews command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:webview:allow-get-all-webviews",
+          "markdownDescription": "Enables the get_all_webviews command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the internal_toggle_devtools command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:webview:allow-internal-toggle-devtools",
+          "markdownDescription": "Enables the internal_toggle_devtools command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the print command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:webview:allow-print",
+          "markdownDescription": "Enables the print command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the reparent command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:webview:allow-reparent",
+          "markdownDescription": "Enables the reparent command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the set_webview_auto_resize command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:webview:allow-set-webview-auto-resize",
+          "markdownDescription": "Enables the set_webview_auto_resize command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the set_webview_background_color command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:webview:allow-set-webview-background-color",
+          "markdownDescription": "Enables the set_webview_background_color command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the set_webview_focus command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:webview:allow-set-webview-focus",
+          "markdownDescription": "Enables the set_webview_focus command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the set_webview_position command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:webview:allow-set-webview-position",
+          "markdownDescription": "Enables the set_webview_position command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the set_webview_size command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:webview:allow-set-webview-size",
+          "markdownDescription": "Enables the set_webview_size command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the set_webview_zoom command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:webview:allow-set-webview-zoom",
+          "markdownDescription": "Enables the set_webview_zoom command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the webview_close command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:webview:allow-webview-close",
+          "markdownDescription": "Enables the webview_close command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the webview_hide command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:webview:allow-webview-hide",
+          "markdownDescription": "Enables the webview_hide command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the webview_position command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:webview:allow-webview-position",
+          "markdownDescription": "Enables the webview_position command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the webview_show command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:webview:allow-webview-show",
+          "markdownDescription": "Enables the webview_show command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the webview_size command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:webview:allow-webview-size",
+          "markdownDescription": "Enables the webview_size command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the clear_all_browsing_data command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:webview:deny-clear-all-browsing-data",
+          "markdownDescription": "Denies the clear_all_browsing_data command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the create_webview command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:webview:deny-create-webview",
+          "markdownDescription": "Denies the create_webview command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the create_webview_window command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:webview:deny-create-webview-window",
+          "markdownDescription": "Denies the create_webview_window command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the get_all_webviews command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:webview:deny-get-all-webviews",
+          "markdownDescription": "Denies the get_all_webviews command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the internal_toggle_devtools command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:webview:deny-internal-toggle-devtools",
+          "markdownDescription": "Denies the internal_toggle_devtools command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the print command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:webview:deny-print",
+          "markdownDescription": "Denies the print command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the reparent command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:webview:deny-reparent",
+          "markdownDescription": "Denies the reparent command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the set_webview_auto_resize command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:webview:deny-set-webview-auto-resize",
+          "markdownDescription": "Denies the set_webview_auto_resize command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the set_webview_background_color command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:webview:deny-set-webview-background-color",
+          "markdownDescription": "Denies the set_webview_background_color command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the set_webview_focus command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:webview:deny-set-webview-focus",
+          "markdownDescription": "Denies the set_webview_focus command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the set_webview_position command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:webview:deny-set-webview-position",
+          "markdownDescription": "Denies the set_webview_position command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the set_webview_size command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:webview:deny-set-webview-size",
+          "markdownDescription": "Denies the set_webview_size command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the set_webview_zoom command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:webview:deny-set-webview-zoom",
+          "markdownDescription": "Denies the set_webview_zoom command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the webview_close command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:webview:deny-webview-close",
+          "markdownDescription": "Denies the webview_close command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the webview_hide command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:webview:deny-webview-hide",
+          "markdownDescription": "Denies the webview_hide command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the webview_position command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:webview:deny-webview-position",
+          "markdownDescription": "Denies the webview_position command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the webview_show command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:webview:deny-webview-show",
+          "markdownDescription": "Denies the webview_show command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the webview_size command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:webview:deny-webview-size",
+          "markdownDescription": "Denies the webview_size command without any pre-configured scope."
+        },
+        {
+          "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-activity-name`\n- `allow-scene-identifier`\n- `allow-internal-toggle-maximize`",
+          "type": "string",
+          "const": "core:window:default",
+          "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-activity-name`\n- `allow-scene-identifier`\n- `allow-internal-toggle-maximize`"
+        },
+        {
+          "description": "Enables the activity_name command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-activity-name",
+          "markdownDescription": "Enables the activity_name command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the available_monitors command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-available-monitors",
+          "markdownDescription": "Enables the available_monitors command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the center command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-center",
+          "markdownDescription": "Enables the center command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the close command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-close",
+          "markdownDescription": "Enables the close command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the create command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-create",
+          "markdownDescription": "Enables the create command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the current_monitor command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-current-monitor",
+          "markdownDescription": "Enables the current_monitor command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the cursor_position command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-cursor-position",
+          "markdownDescription": "Enables the cursor_position command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the destroy command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-destroy",
+          "markdownDescription": "Enables the destroy command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the get_all_windows command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-get-all-windows",
+          "markdownDescription": "Enables the get_all_windows command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the hide command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-hide",
+          "markdownDescription": "Enables the hide command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the inner_position command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-inner-position",
+          "markdownDescription": "Enables the inner_position command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the inner_size command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-inner-size",
+          "markdownDescription": "Enables the inner_size command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the internal_toggle_maximize command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-internal-toggle-maximize",
+          "markdownDescription": "Enables the internal_toggle_maximize command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the is_always_on_top command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-is-always-on-top",
+          "markdownDescription": "Enables the is_always_on_top command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the is_closable command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-is-closable",
+          "markdownDescription": "Enables the is_closable command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the is_decorated command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-is-decorated",
+          "markdownDescription": "Enables the is_decorated command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the is_enabled command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-is-enabled",
+          "markdownDescription": "Enables the is_enabled command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the is_focused command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-is-focused",
+          "markdownDescription": "Enables the is_focused command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the is_fullscreen command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-is-fullscreen",
+          "markdownDescription": "Enables the is_fullscreen command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the is_maximizable command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-is-maximizable",
+          "markdownDescription": "Enables the is_maximizable command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the is_maximized command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-is-maximized",
+          "markdownDescription": "Enables the is_maximized command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the is_minimizable command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-is-minimizable",
+          "markdownDescription": "Enables the is_minimizable command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the is_minimized command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-is-minimized",
+          "markdownDescription": "Enables the is_minimized command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the is_resizable command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-is-resizable",
+          "markdownDescription": "Enables the is_resizable command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the is_visible command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-is-visible",
+          "markdownDescription": "Enables the is_visible command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the maximize command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-maximize",
+          "markdownDescription": "Enables the maximize command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the minimize command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-minimize",
+          "markdownDescription": "Enables the minimize command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the monitor_from_point command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-monitor-from-point",
+          "markdownDescription": "Enables the monitor_from_point command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the outer_position command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-outer-position",
+          "markdownDescription": "Enables the outer_position command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the outer_size command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-outer-size",
+          "markdownDescription": "Enables the outer_size command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the primary_monitor command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-primary-monitor",
+          "markdownDescription": "Enables the primary_monitor command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the request_user_attention command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-request-user-attention",
+          "markdownDescription": "Enables the request_user_attention command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the scale_factor command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-scale-factor",
+          "markdownDescription": "Enables the scale_factor command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the scene_identifier command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-scene-identifier",
+          "markdownDescription": "Enables the scene_identifier command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the set_always_on_bottom command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-set-always-on-bottom",
+          "markdownDescription": "Enables the set_always_on_bottom command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the set_always_on_top command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-set-always-on-top",
+          "markdownDescription": "Enables the set_always_on_top command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the set_background_color command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-set-background-color",
+          "markdownDescription": "Enables the set_background_color command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the set_badge_count command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-set-badge-count",
+          "markdownDescription": "Enables the set_badge_count command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the set_badge_label command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-set-badge-label",
+          "markdownDescription": "Enables the set_badge_label command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the set_closable command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-set-closable",
+          "markdownDescription": "Enables the set_closable command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the set_content_protected command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-set-content-protected",
+          "markdownDescription": "Enables the set_content_protected command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the set_cursor_grab command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-set-cursor-grab",
+          "markdownDescription": "Enables the set_cursor_grab command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the set_cursor_icon command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-set-cursor-icon",
+          "markdownDescription": "Enables the set_cursor_icon command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the set_cursor_position command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-set-cursor-position",
+          "markdownDescription": "Enables the set_cursor_position command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the set_cursor_visible command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-set-cursor-visible",
+          "markdownDescription": "Enables the set_cursor_visible command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the set_decorations command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-set-decorations",
+          "markdownDescription": "Enables the set_decorations command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the set_effects command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-set-effects",
+          "markdownDescription": "Enables the set_effects command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the set_enabled command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-set-enabled",
+          "markdownDescription": "Enables the set_enabled command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the set_focus command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-set-focus",
+          "markdownDescription": "Enables the set_focus command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the set_focusable command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-set-focusable",
+          "markdownDescription": "Enables the set_focusable command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the set_fullscreen command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-set-fullscreen",
+          "markdownDescription": "Enables the set_fullscreen command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the set_icon command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-set-icon",
+          "markdownDescription": "Enables the set_icon command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the set_ignore_cursor_events command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-set-ignore-cursor-events",
+          "markdownDescription": "Enables the set_ignore_cursor_events command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the set_max_size command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-set-max-size",
+          "markdownDescription": "Enables the set_max_size command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the set_maximizable command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-set-maximizable",
+          "markdownDescription": "Enables the set_maximizable command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the set_min_size command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-set-min-size",
+          "markdownDescription": "Enables the set_min_size command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the set_minimizable command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-set-minimizable",
+          "markdownDescription": "Enables the set_minimizable command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the set_overlay_icon command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-set-overlay-icon",
+          "markdownDescription": "Enables the set_overlay_icon command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the set_position command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-set-position",
+          "markdownDescription": "Enables the set_position command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the set_progress_bar command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-set-progress-bar",
+          "markdownDescription": "Enables the set_progress_bar command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the set_resizable command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-set-resizable",
+          "markdownDescription": "Enables the set_resizable command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the set_shadow command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-set-shadow",
+          "markdownDescription": "Enables the set_shadow command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the set_simple_fullscreen command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-set-simple-fullscreen",
+          "markdownDescription": "Enables the set_simple_fullscreen command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the set_size command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-set-size",
+          "markdownDescription": "Enables the set_size command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the set_size_constraints command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-set-size-constraints",
+          "markdownDescription": "Enables the set_size_constraints command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the set_skip_taskbar command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-set-skip-taskbar",
+          "markdownDescription": "Enables the set_skip_taskbar command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the set_theme command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-set-theme",
+          "markdownDescription": "Enables the set_theme command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the set_title command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-set-title",
+          "markdownDescription": "Enables the set_title command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the set_title_bar_style command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-set-title-bar-style",
+          "markdownDescription": "Enables the set_title_bar_style command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the set_visible_on_all_workspaces command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-set-visible-on-all-workspaces",
+          "markdownDescription": "Enables the set_visible_on_all_workspaces command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the show command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-show",
+          "markdownDescription": "Enables the show command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the start_dragging command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-start-dragging",
+          "markdownDescription": "Enables the start_dragging command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the start_resize_dragging command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-start-resize-dragging",
+          "markdownDescription": "Enables the start_resize_dragging command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the theme command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-theme",
+          "markdownDescription": "Enables the theme command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the title command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-title",
+          "markdownDescription": "Enables the title command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the toggle_maximize command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-toggle-maximize",
+          "markdownDescription": "Enables the toggle_maximize command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the unmaximize command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-unmaximize",
+          "markdownDescription": "Enables the unmaximize command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the unminimize command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:allow-unminimize",
+          "markdownDescription": "Enables the unminimize command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the activity_name command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-activity-name",
+          "markdownDescription": "Denies the activity_name command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the available_monitors command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-available-monitors",
+          "markdownDescription": "Denies the available_monitors command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the center command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-center",
+          "markdownDescription": "Denies the center command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the close command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-close",
+          "markdownDescription": "Denies the close command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the create command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-create",
+          "markdownDescription": "Denies the create command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the current_monitor command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-current-monitor",
+          "markdownDescription": "Denies the current_monitor command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the cursor_position command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-cursor-position",
+          "markdownDescription": "Denies the cursor_position command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the destroy command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-destroy",
+          "markdownDescription": "Denies the destroy command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the get_all_windows command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-get-all-windows",
+          "markdownDescription": "Denies the get_all_windows command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the hide command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-hide",
+          "markdownDescription": "Denies the hide command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the inner_position command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-inner-position",
+          "markdownDescription": "Denies the inner_position command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the inner_size command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-inner-size",
+          "markdownDescription": "Denies the inner_size command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the internal_toggle_maximize command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-internal-toggle-maximize",
+          "markdownDescription": "Denies the internal_toggle_maximize command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the is_always_on_top command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-is-always-on-top",
+          "markdownDescription": "Denies the is_always_on_top command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the is_closable command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-is-closable",
+          "markdownDescription": "Denies the is_closable command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the is_decorated command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-is-decorated",
+          "markdownDescription": "Denies the is_decorated command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the is_enabled command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-is-enabled",
+          "markdownDescription": "Denies the is_enabled command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the is_focused command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-is-focused",
+          "markdownDescription": "Denies the is_focused command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the is_fullscreen command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-is-fullscreen",
+          "markdownDescription": "Denies the is_fullscreen command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the is_maximizable command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-is-maximizable",
+          "markdownDescription": "Denies the is_maximizable command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the is_maximized command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-is-maximized",
+          "markdownDescription": "Denies the is_maximized command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the is_minimizable command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-is-minimizable",
+          "markdownDescription": "Denies the is_minimizable command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the is_minimized command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-is-minimized",
+          "markdownDescription": "Denies the is_minimized command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the is_resizable command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-is-resizable",
+          "markdownDescription": "Denies the is_resizable command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the is_visible command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-is-visible",
+          "markdownDescription": "Denies the is_visible command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the maximize command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-maximize",
+          "markdownDescription": "Denies the maximize command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the minimize command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-minimize",
+          "markdownDescription": "Denies the minimize command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the monitor_from_point command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-monitor-from-point",
+          "markdownDescription": "Denies the monitor_from_point command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the outer_position command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-outer-position",
+          "markdownDescription": "Denies the outer_position command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the outer_size command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-outer-size",
+          "markdownDescription": "Denies the outer_size command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the primary_monitor command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-primary-monitor",
+          "markdownDescription": "Denies the primary_monitor command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the request_user_attention command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-request-user-attention",
+          "markdownDescription": "Denies the request_user_attention command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the scale_factor command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-scale-factor",
+          "markdownDescription": "Denies the scale_factor command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the scene_identifier command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-scene-identifier",
+          "markdownDescription": "Denies the scene_identifier command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the set_always_on_bottom command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-set-always-on-bottom",
+          "markdownDescription": "Denies the set_always_on_bottom command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the set_always_on_top command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-set-always-on-top",
+          "markdownDescription": "Denies the set_always_on_top command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the set_background_color command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-set-background-color",
+          "markdownDescription": "Denies the set_background_color command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the set_badge_count command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-set-badge-count",
+          "markdownDescription": "Denies the set_badge_count command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the set_badge_label command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-set-badge-label",
+          "markdownDescription": "Denies the set_badge_label command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the set_closable command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-set-closable",
+          "markdownDescription": "Denies the set_closable command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the set_content_protected command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-set-content-protected",
+          "markdownDescription": "Denies the set_content_protected command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the set_cursor_grab command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-set-cursor-grab",
+          "markdownDescription": "Denies the set_cursor_grab command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the set_cursor_icon command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-set-cursor-icon",
+          "markdownDescription": "Denies the set_cursor_icon command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the set_cursor_position command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-set-cursor-position",
+          "markdownDescription": "Denies the set_cursor_position command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the set_cursor_visible command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-set-cursor-visible",
+          "markdownDescription": "Denies the set_cursor_visible command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the set_decorations command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-set-decorations",
+          "markdownDescription": "Denies the set_decorations command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the set_effects command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-set-effects",
+          "markdownDescription": "Denies the set_effects command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the set_enabled command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-set-enabled",
+          "markdownDescription": "Denies the set_enabled command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the set_focus command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-set-focus",
+          "markdownDescription": "Denies the set_focus command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the set_focusable command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-set-focusable",
+          "markdownDescription": "Denies the set_focusable command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the set_fullscreen command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-set-fullscreen",
+          "markdownDescription": "Denies the set_fullscreen command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the set_icon command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-set-icon",
+          "markdownDescription": "Denies the set_icon command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the set_ignore_cursor_events command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-set-ignore-cursor-events",
+          "markdownDescription": "Denies the set_ignore_cursor_events command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the set_max_size command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-set-max-size",
+          "markdownDescription": "Denies the set_max_size command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the set_maximizable command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-set-maximizable",
+          "markdownDescription": "Denies the set_maximizable command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the set_min_size command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-set-min-size",
+          "markdownDescription": "Denies the set_min_size command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the set_minimizable command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-set-minimizable",
+          "markdownDescription": "Denies the set_minimizable command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the set_overlay_icon command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-set-overlay-icon",
+          "markdownDescription": "Denies the set_overlay_icon command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the set_position command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-set-position",
+          "markdownDescription": "Denies the set_position command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the set_progress_bar command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-set-progress-bar",
+          "markdownDescription": "Denies the set_progress_bar command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the set_resizable command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-set-resizable",
+          "markdownDescription": "Denies the set_resizable command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the set_shadow command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-set-shadow",
+          "markdownDescription": "Denies the set_shadow command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the set_simple_fullscreen command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-set-simple-fullscreen",
+          "markdownDescription": "Denies the set_simple_fullscreen command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the set_size command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-set-size",
+          "markdownDescription": "Denies the set_size command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the set_size_constraints command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-set-size-constraints",
+          "markdownDescription": "Denies the set_size_constraints command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the set_skip_taskbar command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-set-skip-taskbar",
+          "markdownDescription": "Denies the set_skip_taskbar command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the set_theme command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-set-theme",
+          "markdownDescription": "Denies the set_theme command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the set_title command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-set-title",
+          "markdownDescription": "Denies the set_title command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the set_title_bar_style command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-set-title-bar-style",
+          "markdownDescription": "Denies the set_title_bar_style command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the set_visible_on_all_workspaces command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-set-visible-on-all-workspaces",
+          "markdownDescription": "Denies the set_visible_on_all_workspaces command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the show command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-show",
+          "markdownDescription": "Denies the show command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the start_dragging command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-start-dragging",
+          "markdownDescription": "Denies the start_dragging command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the start_resize_dragging command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-start-resize-dragging",
+          "markdownDescription": "Denies the start_resize_dragging command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the theme command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-theme",
+          "markdownDescription": "Denies the theme command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the title command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-title",
+          "markdownDescription": "Denies the title command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the toggle_maximize command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-toggle-maximize",
+          "markdownDescription": "Denies the toggle_maximize command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the unmaximize command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-unmaximize",
+          "markdownDescription": "Denies the unmaximize command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the unminimize command without any pre-configured scope.",
+          "type": "string",
+          "const": "core:window:deny-unminimize",
+          "markdownDescription": "Denies the unminimize command without any pre-configured scope."
+        },
+        {
+          "description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`",
+          "type": "string",
+          "const": "dialog:default",
+          "markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`"
+        },
+        {
+          "description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
+          "type": "string",
+          "const": "dialog:allow-ask",
+          "markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
+        },
+        {
+          "description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
+          "type": "string",
+          "const": "dialog:allow-confirm",
+          "markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
+        },
+        {
+          "description": "Enables the message command without any pre-configured scope.",
+          "type": "string",
+          "const": "dialog:allow-message",
+          "markdownDescription": "Enables the message command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the open command without any pre-configured scope.",
+          "type": "string",
+          "const": "dialog:allow-open",
+          "markdownDescription": "Enables the open command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the save command without any pre-configured scope.",
+          "type": "string",
+          "const": "dialog:allow-save",
+          "markdownDescription": "Enables the save command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
+          "type": "string",
+          "const": "dialog:deny-ask",
+          "markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
+        },
+        {
+          "description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
+          "type": "string",
+          "const": "dialog:deny-confirm",
+          "markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
+        },
+        {
+          "description": "Denies the message command without any pre-configured scope.",
+          "type": "string",
+          "const": "dialog:deny-message",
+          "markdownDescription": "Denies the message command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the open command without any pre-configured scope.",
+          "type": "string",
+          "const": "dialog:deny-open",
+          "markdownDescription": "Denies the open command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the save command without any pre-configured scope.",
+          "type": "string",
+          "const": "dialog:deny-save",
+          "markdownDescription": "Denies the save command without any pre-configured scope."
+        },
+        {
+          "description": "This permission set configures what kind of\nfetch operations are available from the http plugin.\n\nThis enables all fetch operations but does not\nallow explicitly any origins to be fetched. This needs to\nbe manually configured before usage.\n\n#### Granted Permissions\n\nAll fetch operations are enabled.\n\n\n#### This default permission set includes:\n\n- `allow-fetch`\n- `allow-fetch-cancel`\n- `allow-fetch-send`\n- `allow-fetch-read-body`\n- `allow-fetch-cancel-body`",
+          "type": "string",
+          "const": "http:default",
+          "markdownDescription": "This permission set configures what kind of\nfetch operations are available from the http plugin.\n\nThis enables all fetch operations but does not\nallow explicitly any origins to be fetched. This needs to\nbe manually configured before usage.\n\n#### Granted Permissions\n\nAll fetch operations are enabled.\n\n\n#### This default permission set includes:\n\n- `allow-fetch`\n- `allow-fetch-cancel`\n- `allow-fetch-send`\n- `allow-fetch-read-body`\n- `allow-fetch-cancel-body`"
+        },
+        {
+          "description": "Enables the fetch command without any pre-configured scope.",
+          "type": "string",
+          "const": "http:allow-fetch",
+          "markdownDescription": "Enables the fetch command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the fetch_cancel command without any pre-configured scope.",
+          "type": "string",
+          "const": "http:allow-fetch-cancel",
+          "markdownDescription": "Enables the fetch_cancel command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the fetch_cancel_body command without any pre-configured scope.",
+          "type": "string",
+          "const": "http:allow-fetch-cancel-body",
+          "markdownDescription": "Enables the fetch_cancel_body command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the fetch_read_body command without any pre-configured scope.",
+          "type": "string",
+          "const": "http:allow-fetch-read-body",
+          "markdownDescription": "Enables the fetch_read_body command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the fetch_send command without any pre-configured scope.",
+          "type": "string",
+          "const": "http:allow-fetch-send",
+          "markdownDescription": "Enables the fetch_send command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the fetch command without any pre-configured scope.",
+          "type": "string",
+          "const": "http:deny-fetch",
+          "markdownDescription": "Denies the fetch command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the fetch_cancel command without any pre-configured scope.",
+          "type": "string",
+          "const": "http:deny-fetch-cancel",
+          "markdownDescription": "Denies the fetch_cancel command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the fetch_cancel_body command without any pre-configured scope.",
+          "type": "string",
+          "const": "http:deny-fetch-cancel-body",
+          "markdownDescription": "Denies the fetch_cancel_body command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the fetch_read_body command without any pre-configured scope.",
+          "type": "string",
+          "const": "http:deny-fetch-read-body",
+          "markdownDescription": "Denies the fetch_read_body command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the fetch_send command without any pre-configured scope.",
+          "type": "string",
+          "const": "http:deny-fetch-send",
+          "markdownDescription": "Denies the fetch_send command without any pre-configured scope."
+        },
+        {
+          "description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
+          "type": "string",
+          "const": "opener:default",
+          "markdownDescription": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`"
+        },
+        {
+          "description": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.",
+          "type": "string",
+          "const": "opener:allow-default-urls",
+          "markdownDescription": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application."
+        },
+        {
+          "description": "Enables the open_path command without any pre-configured scope.",
+          "type": "string",
+          "const": "opener:allow-open-path",
+          "markdownDescription": "Enables the open_path command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the open_url command without any pre-configured scope.",
+          "type": "string",
+          "const": "opener:allow-open-url",
+          "markdownDescription": "Enables the open_url command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the reveal_item_in_dir command without any pre-configured scope.",
+          "type": "string",
+          "const": "opener:allow-reveal-item-in-dir",
+          "markdownDescription": "Enables the reveal_item_in_dir command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the open_path command without any pre-configured scope.",
+          "type": "string",
+          "const": "opener:deny-open-path",
+          "markdownDescription": "Denies the open_path command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the open_url command without any pre-configured scope.",
+          "type": "string",
+          "const": "opener:deny-open-url",
+          "markdownDescription": "Denies the open_url command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the reveal_item_in_dir command without any pre-configured scope.",
+          "type": "string",
+          "const": "opener:deny-reveal-item-in-dir",
+          "markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope."
+        },
+        {
+          "description": "This permission set configures what kind of\noperations are available from the store plugin.\n\n#### Granted Permissions\n\nAll operations are enabled by default.\n\n\n#### This default permission set includes:\n\n- `allow-load`\n- `allow-get-store`\n- `allow-set`\n- `allow-get`\n- `allow-has`\n- `allow-delete`\n- `allow-clear`\n- `allow-reset`\n- `allow-keys`\n- `allow-values`\n- `allow-entries`\n- `allow-length`\n- `allow-reload`\n- `allow-save`",
+          "type": "string",
+          "const": "store:default",
+          "markdownDescription": "This permission set configures what kind of\noperations are available from the store plugin.\n\n#### Granted Permissions\n\nAll operations are enabled by default.\n\n\n#### This default permission set includes:\n\n- `allow-load`\n- `allow-get-store`\n- `allow-set`\n- `allow-get`\n- `allow-has`\n- `allow-delete`\n- `allow-clear`\n- `allow-reset`\n- `allow-keys`\n- `allow-values`\n- `allow-entries`\n- `allow-length`\n- `allow-reload`\n- `allow-save`"
+        },
+        {
+          "description": "Enables the clear command without any pre-configured scope.",
+          "type": "string",
+          "const": "store:allow-clear",
+          "markdownDescription": "Enables the clear command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the delete command without any pre-configured scope.",
+          "type": "string",
+          "const": "store:allow-delete",
+          "markdownDescription": "Enables the delete command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the entries command without any pre-configured scope.",
+          "type": "string",
+          "const": "store:allow-entries",
+          "markdownDescription": "Enables the entries command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the get command without any pre-configured scope.",
+          "type": "string",
+          "const": "store:allow-get",
+          "markdownDescription": "Enables the get command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the get_store command without any pre-configured scope.",
+          "type": "string",
+          "const": "store:allow-get-store",
+          "markdownDescription": "Enables the get_store command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the has command without any pre-configured scope.",
+          "type": "string",
+          "const": "store:allow-has",
+          "markdownDescription": "Enables the has command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the keys command without any pre-configured scope.",
+          "type": "string",
+          "const": "store:allow-keys",
+          "markdownDescription": "Enables the keys command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the length command without any pre-configured scope.",
+          "type": "string",
+          "const": "store:allow-length",
+          "markdownDescription": "Enables the length command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the load command without any pre-configured scope.",
+          "type": "string",
+          "const": "store:allow-load",
+          "markdownDescription": "Enables the load command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the reload command without any pre-configured scope.",
+          "type": "string",
+          "const": "store:allow-reload",
+          "markdownDescription": "Enables the reload command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the reset command without any pre-configured scope.",
+          "type": "string",
+          "const": "store:allow-reset",
+          "markdownDescription": "Enables the reset command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the save command without any pre-configured scope.",
+          "type": "string",
+          "const": "store:allow-save",
+          "markdownDescription": "Enables the save command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the set command without any pre-configured scope.",
+          "type": "string",
+          "const": "store:allow-set",
+          "markdownDescription": "Enables the set command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the values command without any pre-configured scope.",
+          "type": "string",
+          "const": "store:allow-values",
+          "markdownDescription": "Enables the values command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the clear command without any pre-configured scope.",
+          "type": "string",
+          "const": "store:deny-clear",
+          "markdownDescription": "Denies the clear command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the delete command without any pre-configured scope.",
+          "type": "string",
+          "const": "store:deny-delete",
+          "markdownDescription": "Denies the delete command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the entries command without any pre-configured scope.",
+          "type": "string",
+          "const": "store:deny-entries",
+          "markdownDescription": "Denies the entries command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the get command without any pre-configured scope.",
+          "type": "string",
+          "const": "store:deny-get",
+          "markdownDescription": "Denies the get command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the get_store command without any pre-configured scope.",
+          "type": "string",
+          "const": "store:deny-get-store",
+          "markdownDescription": "Denies the get_store command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the has command without any pre-configured scope.",
+          "type": "string",
+          "const": "store:deny-has",
+          "markdownDescription": "Denies the has command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the keys command without any pre-configured scope.",
+          "type": "string",
+          "const": "store:deny-keys",
+          "markdownDescription": "Denies the keys command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the length command without any pre-configured scope.",
+          "type": "string",
+          "const": "store:deny-length",
+          "markdownDescription": "Denies the length command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the load command without any pre-configured scope.",
+          "type": "string",
+          "const": "store:deny-load",
+          "markdownDescription": "Denies the load command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the reload command without any pre-configured scope.",
+          "type": "string",
+          "const": "store:deny-reload",
+          "markdownDescription": "Denies the reload command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the reset command without any pre-configured scope.",
+          "type": "string",
+          "const": "store:deny-reset",
+          "markdownDescription": "Denies the reset command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the save command without any pre-configured scope.",
+          "type": "string",
+          "const": "store:deny-save",
+          "markdownDescription": "Denies the save command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the set command without any pre-configured scope.",
+          "type": "string",
+          "const": "store:deny-set",
+          "markdownDescription": "Denies the set command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the values command without any pre-configured scope.",
+          "type": "string",
+          "const": "store:deny-values",
+          "markdownDescription": "Denies the values command without any pre-configured scope."
+        },
+        {
+          "description": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`",
+          "type": "string",
+          "const": "updater:default",
+          "markdownDescription": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`"
+        },
+        {
+          "description": "Enables the check command without any pre-configured scope.",
+          "type": "string",
+          "const": "updater:allow-check",
+          "markdownDescription": "Enables the check command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the download command without any pre-configured scope.",
+          "type": "string",
+          "const": "updater:allow-download",
+          "markdownDescription": "Enables the download command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the download_and_install command without any pre-configured scope.",
+          "type": "string",
+          "const": "updater:allow-download-and-install",
+          "markdownDescription": "Enables the download_and_install command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the install command without any pre-configured scope.",
+          "type": "string",
+          "const": "updater:allow-install",
+          "markdownDescription": "Enables the install command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the check command without any pre-configured scope.",
+          "type": "string",
+          "const": "updater:deny-check",
+          "markdownDescription": "Denies the check command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the download command without any pre-configured scope.",
+          "type": "string",
+          "const": "updater:deny-download",
+          "markdownDescription": "Denies the download command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the download_and_install command without any pre-configured scope.",
+          "type": "string",
+          "const": "updater:deny-download-and-install",
+          "markdownDescription": "Denies the download_and_install command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the install command without any pre-configured scope.",
+          "type": "string",
+          "const": "updater:deny-install",
+          "markdownDescription": "Denies the install command without any pre-configured scope."
+        }
+      ]
+    },
+    "Value": {
+      "description": "All supported ACL values.",
+      "anyOf": [
+        {
+          "description": "Represents a null JSON value.",
+          "type": "null"
+        },
+        {
+          "description": "Represents a [`bool`].",
+          "type": "boolean"
+        },
+        {
+          "description": "Represents a valid ACL [`Number`].",
+          "allOf": [
+            {
+              "$ref": "#/definitions/Number"
+            }
+          ]
+        },
+        {
+          "description": "Represents a [`String`].",
+          "type": "string"
+        },
+        {
+          "description": "Represents a list of other [`Value`]s.",
+          "type": "array",
+          "items": {
+            "$ref": "#/definitions/Value"
+          }
+        },
+        {
+          "description": "Represents a map of [`String`] keys to [`Value`]s.",
+          "type": "object",
+          "additionalProperties": {
+            "$ref": "#/definitions/Value"
+          }
+        }
+      ]
+    },
+    "Number": {
+      "description": "A valid ACL number.",
+      "anyOf": [
+        {
+          "description": "Represents an [`i64`].",
+          "type": "integer",
+          "format": "int64"
+        },
+        {
+          "description": "Represents a [`f64`].",
+          "type": "number",
+          "format": "double"
+        }
+      ]
+    },
+    "Target": {
+      "description": "Platform target.",
+      "oneOf": [
+        {
+          "description": "MacOS.",
+          "type": "string",
+          "enum": [
+            "macOS"
+          ]
+        },
+        {
+          "description": "Windows.",
+          "type": "string",
+          "enum": [
+            "windows"
+          ]
+        },
+        {
+          "description": "Linux.",
+          "type": "string",
+          "enum": [
+            "linux"
+          ]
+        },
+        {
+          "description": "Android.",
+          "type": "string",
+          "enum": [
+            "android"
+          ]
+        },
+        {
+          "description": "iOS.",
+          "type": "string",
+          "enum": [
+            "iOS"
+          ]
+        }
+      ]
+    },
+    "Application": {
+      "description": "Opener scope application.",
+      "anyOf": [
+        {
+          "description": "Open in default application.",
+          "type": "null"
+        },
+        {
+          "description": "If true, allow open with any application.",
+          "type": "boolean"
+        },
+        {
+          "description": "Allow specific application to open with.",
+          "type": "string"
+        }
+      ]
+    }
+  }
+}

BIN
src-tauri/pdfium/libpdfium.dylib


+ 19 - 320
src-tauri/src/commands/file_sync.rs

@@ -181,11 +181,10 @@ fn normalize_source_watch_config(config: Option<SourceWatchConfig>) -> SourceWat
     config
 }
 
-#[derive(Debug, Clone, Serialize)]
-#[serde(rename_all = "camelCase")]
-struct FileSyncPayload {
-    project_id: String,
-    tasks: Vec<FileChangeTask>,
+fn make_event_emitter(app: AppHandle) -> EventEmitter {
+    Box::new(move |event, payload| {
+        let _ = app.emit(event, payload);
+    })
 }
 
 #[tauri::command]
@@ -196,140 +195,18 @@ pub fn start_project_file_watcher(
     project_path: String,
     source_watch_config: Option<SourceWatchConfig>,
 ) -> Result<FileChangeQueue, String> {
-    run_guarded("start_project_file_watcher", || {
-        let root = PathBuf::from(project_path);
-        let source_watch_config = normalize_source_watch_config(source_watch_config);
-        let watcher_generation = WATCHER_GENERATION.fetch_add(1, Ordering::SeqCst) + 1;
-        ensure_sync_dir(&root)?;
-        with_queue_lock(&root, || reset_processing_tasks(&root, &project_id))?;
-        enqueue_rescan_changes(&root, &project_id, &source_watch_config)?;
-        process_queue(&app, &root, &project_id)?;
-
-        let (tx, rx) = mpsc::sync_channel::<PathBuf>(8_192);
-        let app_for_thread = app.clone();
-        let root_for_thread = root.clone();
-        let project_for_thread = project_id.clone();
-        let config_for_thread = source_watch_config.clone();
-        std::thread::spawn(move || {
-            let mut pending = BTreeSet::<PathBuf>::new();
-            let mut last_periodic_rescan = now_ms();
-            loop {
-                match rx.recv_timeout(Duration::from_millis(700)) {
-                    Ok(path) => {
-                        pending.insert(path);
-                        while let Ok(path) = rx.try_recv() {
-                            pending.insert(path);
-                        }
-                    }
-                    Err(mpsc::RecvTimeoutError::Timeout) => {
-                        // 检查 channel 溢出标志:如果溢出则将 root 加入 pending 触发全量重扫描
-                        if OVERFLOW_FLAG.swap(false, Ordering::Relaxed) {
-                            pending.insert(root_for_thread.clone());
-                        }
-                        if pending.is_empty() {
-                            maybe_periodic_rescan(
-                                &app_for_thread,
-                                &root_for_thread,
-                                &project_for_thread,
-                                &config_for_thread,
-                                watcher_generation,
-                                &mut last_periodic_rescan,
-                            );
-                            continue;
-                        }
-                        let paths = pending.iter().cloned().collect::<Vec<_>>();
-                        pending.clear();
-                        let result = std::panic::catch_unwind(AssertUnwindSafe(|| {
-                            handle_changed_paths(
-                                &app_for_thread,
-                                &root_for_thread,
-                                &project_for_thread,
-                                &config_for_thread,
-                                watcher_generation,
-                                paths,
-                            )
-                        }));
-                        match result {
-                            Ok(Ok(())) => {}
-                            Ok(Err(err)) => eprintln!("[file-sync] change handling failed: {err}"),
-                            Err(_) => eprintln!("[file-sync] watcher worker recovered from panic"),
-                        }
-                        maybe_periodic_rescan(
-                            &app_for_thread,
-                            &root_for_thread,
-                            &project_for_thread,
-                            &config_for_thread,
-                            watcher_generation,
-                            &mut last_periodic_rescan,
-                        );
-                    }
-                    Err(mpsc::RecvTimeoutError::Disconnected) => break,
-                }
-            }
-        });
-
-        let tx_for_watcher = tx.clone();
-        let root_for_error = root.clone();
-        let mut watcher = RecommendedWatcher::new(
-            move |res: notify::Result<Event>| {
-                match res {
-                    Ok(event) => {
-                        for path in event.paths {
-                            if tx_for_watcher.try_send(path).is_err() {
-                                // channel 溢出:设置标志而非依赖 try_send(root),
-                                // 因为 channel 已满时第二个 try_send 也会失败
-                                OVERFLOW_FLAG.store(true, Ordering::Relaxed);
-                                break;
-                            }
-                        }
-                    }
-                    Err(err) => {
-                        eprintln!("[file-sync] watcher error; scheduling rescan: {err}");
-                        let _ = tx_for_watcher.try_send(root_for_error.clone());
-                    }
-                }
-            },
-            Config::default(),
-        )
-        .map_err(|e| format!("Failed to create file watcher: {e}"))?;
-        watcher
-            .watch(&root, RecursiveMode::Recursive)
-            .map_err(|e| format!("Failed to watch '{}': {e}", root.display()))?;
-        for rel in ["raw/sources", "QM", "wiki"] {
-            let path = root.join(rel);
-            if path.exists() {
-                if let Err(err) = watcher.watch(&path, RecursiveMode::Recursive) {
-                    eprintln!(
-                        "[file-sync] failed to add supplemental watch '{}': {err}",
-                        path.display()
-                    );
-                }
-            }
-        }
-
-        {
-            let mut inner = state.inner.lock().map_err(|_| "file sync state poisoned")?;
-            inner.watcher = Some(watcher);
-            inner.project_id = Some(project_id.clone());
-            inner.project_path = Some(root.clone());
-        }
-
-        let queue = with_queue_lock(&root, || read_queue(&root))?;
-        emit_queue(&app, &project_id, &queue);
-        Ok(queue)
-    })
+    do_start_project_file_watcher(
+        &state,
+        project_id,
+        project_path,
+        source_watch_config,
+        make_event_emitter(app),
+    )
 }
 
 #[tauri::command]
 pub fn stop_project_file_watcher(state: State<FileSyncState>) -> Result<(), String> {
-    run_guarded("stop_project_file_watcher", || {
-        WATCHER_GENERATION.fetch_add(1, Ordering::SeqCst);
-        let mut inner = state.inner.lock().map_err(|_| "file sync state poisoned")?;
-        inner.watcher = None;
-        inner.project_id = None;
-        inner.project_path = None;
-        Ok(())
-    })
+    do_stop_project_file_watcher(&state)
 }
 
 #[tauri::command]
@@ -339,27 +216,13 @@ pub fn rescan_project_files(
     project_path: String,
     source_watch_config: Option<SourceWatchConfig>,
 ) -> Result<FileChangeRescanResult, String> {
-    run_guarded("rescan_project_files", || {
-        let root = PathBuf::from(project_path);
-        let source_watch_config = normalize_source_watch_config(source_watch_config);
-        ensure_sync_dir(&root)?;
-        enqueue_rescan_changes(&root, &project_id, &source_watch_config)?;
-        let changed_tasks = process_queue(&app, &root, &project_id)?;
-        let queue = with_queue_lock(&root, || read_queue(&root))?;
-        emit_queue(&app, &project_id, &queue);
-        Ok(FileChangeRescanResult {
-            queue,
-            changed_tasks,
-        })
-    })
+    let emit = Arc::new(make_event_emitter(app));
+    do_rescan_project_files(project_id, project_path, source_watch_config, &emit)
 }
 
 #[tauri::command]
 pub fn get_file_change_queue(project_path: String) -> Result<FileChangeQueue, String> {
-    run_guarded("get_file_change_queue", || {
-        let root = PathBuf::from(project_path);
-        with_queue_lock(&root, || read_queue(&root))
-    })
+    do_get_file_change_queue(project_path)
 }
 
 #[tauri::command]
@@ -369,27 +232,8 @@ pub fn retry_file_change_task(
     project_path: String,
     task_id: String,
 ) -> Result<FileChangeQueue, String> {
-    run_guarded("retry_file_change_task", || {
-        let root = PathBuf::from(project_path);
-        with_queue_lock(&root, || {
-            let mut queue = read_queue(&root)?;
-            let now = now_ms();
-            for task in &mut queue.tasks {
-                if task.id == task_id && task.project_id == project_id {
-                    task.status = FileChangeStatus::Pending;
-                    task.error = None;
-                    task.retry_count = 0;
-                    task.needs_rerun = false;
-                    task.updated_at = now;
-                }
-            }
-            write_queue(&root, &queue)
-        })?;
-        process_queue(&app, &root, &project_id)?;
-        let queue = with_queue_lock(&root, || read_queue(&root))?;
-        emit_queue(&app, &project_id, &queue);
-        Ok(queue)
-    })
+    let emit = Arc::new(make_event_emitter(app));
+    do_retry_file_change_task(project_id, project_path, task_id, &emit)
 }
 
 #[tauri::command]
@@ -399,19 +243,8 @@ pub fn ignore_file_change_task(
     project_path: String,
     task_id: String,
 ) -> Result<FileChangeQueue, String> {
-    run_guarded("ignore_file_change_task", || {
-        let root = PathBuf::from(project_path);
-        let queue = with_queue_lock(&root, || {
-            let mut queue = read_queue(&root)?;
-            queue
-                .tasks
-                .retain(|task| !(task.id == task_id && task.project_id == project_id));
-            write_queue(&root, &queue)?;
-            read_queue(&root)
-        })?;
-        emit_queue(&app, &project_id, &queue);
-        Ok(queue)
-    })
+    let emit = Arc::new(make_event_emitter(app));
+    do_ignore_file_change_task(project_id, project_path, task_id, &emit)
 }
 
 pub fn mark_app_write_path(path: &Path) {
@@ -425,112 +258,6 @@ pub fn mark_app_write_path(path: &Path) {
     ignores.insert(key, now + APP_WRITE_IGNORE_MS);
 }
 
-fn handle_changed_paths(
-    app: &AppHandle,
-    root: &Path,
-    project_id: &str,
-    source_watch_config: &SourceWatchConfig,
-    watcher_generation: u64,
-    paths: Vec<PathBuf>,
-) -> Result<(), String> {
-    if !is_active_watcher_generation(watcher_generation) {
-        return Ok(());
-    }
-    let rules = SourceWatchRules::new(source_watch_config);
-    let mut rels = BTreeSet::<String>::new();
-    let mut app_written_rels = BTreeSet::<String>::new();
-    let snapshot = with_queue_lock(root, || read_snapshot(root))?;
-    for path in paths {
-        if is_app_write_ignored(&path) {
-            collect_known_paths(root, &path, &snapshot, &mut app_written_rels, &rules);
-            continue;
-        }
-        if path.is_dir() {
-            for entry in WalkDir::new(&path).into_iter().filter_map(Result::ok) {
-                if entry.file_type().is_file() && !is_app_write_ignored(entry.path()) {
-                    if let Some(rel) = relative_watch_path(root, entry.path(), &rules, entry.metadata().ok().map(|m| m.len())) {
-                        rels.insert(rel);
-                    }
-                }
-            }
-        } else if let Some(rel) = relative_watch_path(root, &path, &rules, None) {
-            rels.insert(rel);
-        } else if !path.exists() {
-            collect_known_paths(root, &path, &snapshot, &mut rels, &rules);
-        }
-    }
-    if !app_written_rels.is_empty() {
-        sync_snapshot_paths(root, app_written_rels)?;
-    }
-    if rels.is_empty() {
-        return Ok(());
-    }
-    if !is_active_watcher_generation(watcher_generation) {
-        return Ok(());
-    }
-    enqueue_paths(root, project_id, rels)?;
-    if !is_active_watcher_generation(watcher_generation) {
-        return Ok(());
-    }
-    process_queue(app, root, project_id)?;
-    let queue = with_queue_lock(root, || read_queue(root))?;
-    if !is_active_watcher_generation(watcher_generation) {
-        return Ok(());
-    }
-    emit_queue(app, project_id, &queue);
-    Ok(())
-}
-
-fn maybe_periodic_rescan(
-    app: &AppHandle,
-    root: &Path,
-    project_id: &str,
-    source_watch_config: &SourceWatchConfig,
-    watcher_generation: u64,
-    last_periodic_rescan: &mut i64,
-) {
-    if !cfg!(target_os = "linux") || now_ms() - *last_periodic_rescan < LINUX_RESCAN_INTERVAL_MS {
-        return;
-    }
-    *last_periodic_rescan = now_ms();
-    let result = std::panic::catch_unwind(AssertUnwindSafe(|| {
-        rescan_watch_roots(app, root, project_id, source_watch_config, watcher_generation)
-    }));
-    match result {
-        Ok(Ok(())) => {}
-        Ok(Err(err)) => eprintln!("[file-sync] periodic rescan failed: {err}"),
-        Err(_) => eprintln!("[file-sync] periodic rescan recovered from panic"),
-    }
-}
-
-fn rescan_watch_roots(
-    app: &AppHandle,
-    root: &Path,
-    project_id: &str,
-    source_watch_config: &SourceWatchConfig,
-    watcher_generation: u64,
-) -> Result<(), String> {
-    if !is_active_watcher_generation(watcher_generation) {
-        return Ok(());
-    }
-    enqueue_rescan_changes_for_prefixes(
-        root,
-        project_id,
-        &["raw/sources", "QM", "wiki", "purpose.md", "schema.md"],
-        source_watch_config,
-    )?;
-    if !is_active_watcher_generation(watcher_generation) {
-        return Ok(());
-    }
-    process_queue(app, root, project_id)?;
-    let queue = with_queue_lock(root, || read_queue(root))?;
-    if !is_active_watcher_generation(watcher_generation) {
-        return Ok(());
-    }
-    emit_queue(app, project_id, &queue);
-    Ok(())
-}
-
 fn is_active_watcher_generation(generation: u64) -> bool {
     WATCHER_GENERATION.load(Ordering::SeqCst) == generation
 }
@@ -763,15 +490,6 @@ fn upsert_task(
     });
 }
 
-fn process_queue(app: &AppHandle, root: &Path, project_id: &str) -> Result<Vec<FileChangeTask>, String> {
-    process_queue_inner(
-        root,
-        project_id,
-        |queue| emit_queue(app, project_id, queue),
-        |tasks| emit_changed_batch(app, project_id, tasks),
-    )
-}
-
 fn process_queue_inner(
     root: &Path,
     project_id: &str,
@@ -1184,25 +902,6 @@ fn merge_kind(existing: &FileChangeKind, incoming: &FileChangeKind) -> FileChang
     }
 }
 
-fn emit_queue(app: &AppHandle, project_id: &str, queue: &FileChangeQueue) {
-    let payload = FileSyncPayload {
-        project_id: project_id.to_string(),
-        tasks: queue.tasks.clone(),
-    };
-    let _ = app.emit(EVENT_QUEUE_UPDATED, payload);
-}
-
-fn emit_changed_batch(app: &AppHandle, project_id: &str, tasks: Vec<FileChangeTask>) {
-    if tasks.is_empty() {
-        return;
-    }
-    let payload = FileSyncPayload {
-        project_id: project_id.to_string(),
-        tasks,
-    };
-    let _ = app.emit(EVENT_CHANGED, payload);
-}
-
 fn ensure_sync_dir(root: &Path) -> Result<(), String> {
     fs::create_dir_all(root.join(".qmai"))
         .map_err(|e| format!("Failed to create .qmai: {e}"))

+ 36 - 8
src-tauri/src/commands/fs.rs

@@ -1188,8 +1188,24 @@ pub async fn write_file_atomic(path: String, contents: String) -> Result<(), Str
         .map_err(|e| format!("write_file_atomic blocking task join error: {e}"))?
 }
 
+/// Whether a directory entry should appear in a listing.
+///
+/// Hidden (dot-prefixed) entries are shown only when `include_hidden`
+/// is set. That flag is reserved for the `raw/sources` content area,
+/// where dotfolders like `.claude` / `.codex` are legitimate sources
+/// the user deliberately added. Every other caller keeps hiding dot
+/// entries so internal state (`.qmai`, `.git`), caches, and secrets
+/// (`.env`) never leak into trees or the ingest candidate set.
+fn entry_is_visible(name: &str, include_hidden: bool) -> bool {
+    include_hidden || !name.starts_with('.')
+}
+
 /// Core logic for `list_directory`, callable from both Tauri commands and Axum handlers.
-pub fn do_list_directory(path: &str) -> Result<Vec<FileNode>, String> {
+pub fn do_list_directory(
+    path: &str,
+    include_hidden: bool,
+    max_depth: usize,
+) -> Result<Vec<FileNode>, String> {
     run_guarded("list_directory", || {
         let path = resolve_project_storage_path(path);
         let p = Path::new(&path);
@@ -1199,20 +1215,31 @@ pub fn do_list_directory(path: &str) -> Result<Vec<FileNode>, String> {
         if !p.is_dir() {
             return Err(format!("Path is not a directory: '{}'", path));
         }
-        let nodes = build_tree(p, 0, 30)?;
+        let nodes = build_tree(p, 0, max_depth, include_hidden)?;
         Ok(nodes)
     })
 }
 
 #[tauri::command]
-pub async fn list_directory(path: String) -> Result<Vec<FileNode>, String> {
+pub async fn list_directory(
+    path: String,
+    include_hidden: Option<bool>,
+    max_depth: Option<usize>,
+) -> Result<Vec<FileNode>, String> {
+    let include_hidden = include_hidden.unwrap_or(false);
+    let max_depth = max_depth.unwrap_or(30).clamp(1, 30);
     let p = path.clone();
-    tauri::async_runtime::spawn_blocking(move || do_list_directory(&p))
+    tauri::async_runtime::spawn_blocking(move || do_list_directory(&p, include_hidden, max_depth))
         .await
         .map_err(|e| format!("list_directory blocking task join error: {e}"))?
 }
 
-fn build_tree(dir: &Path, depth: usize, max_depth: usize) -> Result<Vec<FileNode>, String> {
+fn build_tree(
+    dir: &Path,
+    depth: usize,
+    max_depth: usize,
+    include_hidden: bool,
+) -> Result<Vec<FileNode>, String> {
     if depth >= max_depth {
         return Ok(vec![]);
     }
@@ -1221,8 +1248,9 @@ fn build_tree(dir: &Path, depth: usize, max_depth: usize) -> Result<Vec<FileNode
         .map_err(|e| format!("Failed to read directory '{}': {}", dir.display(), e))?
         .filter_map(|entry| entry.ok())
         .filter(|entry| {
-            // Skip dotfiles; use to_string_lossy so non-UTF-8 filenames are still visible
-            !entry.file_name().to_string_lossy().starts_with('.')
+            let file_name = entry.file_name();
+            let name = file_name.to_string_lossy();
+            entry_is_visible(&name, include_hidden)
         })
         .collect();
 
@@ -1253,7 +1281,7 @@ fn build_tree(dir: &Path, depth: usize, max_depth: usize) -> Result<Vec<FileNode
         let is_dir = entry_path.is_dir();
 
         let children = if is_dir {
-            let kids = build_tree(&entry_path, depth + 1, max_depth)?;
+            let kids = build_tree(&entry_path, depth + 1, max_depth, include_hidden)?;
             if kids.is_empty() {
                 None
             } else {

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

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

+ 132 - 96
src/App.tsx

@@ -10,7 +10,6 @@ import { loadReviewItems, loadChatHistory, saveChatHistory, saveReviewItems } fr
 import { setupAutoSave, teardownAutoSave } from "@/lib/auto-save"
 import { checkForAppUpdate } from "@/lib/app-updater"
 import { initAnalytics } from "@/lib/analytics"
-import { restoreQueue as restoreIngestQueue } from "@/lib/ingest-queue"
 import { AppLayout } from "@/components/layout/app-layout"
 import { WelcomeScreen } from "@/components/project/welcome-screen"
 import { CreateProjectDialog } from "@/components/project/create-project-dialog"
@@ -23,6 +22,7 @@ import type { WikiProject } from "@/types/wiki"
 import { applyTheme, watchSystemTheme } from "@/lib/theme-utils"
 import { applyUiFontFamily } from "@/lib/font-settings"
 import { applyVisualStyle } from "@/lib/visual-style-settings"
+import { normalizePath } from "@/lib/path-utils"
 
 function App() {
   const project = useWikiStore((s) => s.project)
@@ -38,6 +38,114 @@ function App() {
   const [showCreateDialog, setShowCreateDialog] = useState(false)
   const [loading, setLoading] = useState(true)
 
+  function isCurrentProject(proj: WikiProject): boolean {
+    const current = useWikiStore.getState().project
+    if (!current || current.id !== proj.id) return false
+    return normalizePath(current.path) === normalizePath(proj.path)
+  }
+
+  async function hydrateProjectSideStores(proj: WikiProject): Promise<void> {
+    try {
+      const savedReview = await loadReviewItems(proj.path)
+      if (savedReview.length > 0 && isCurrentProject(proj)) {
+        useReviewStore.getState().setItems(savedReview)
+      }
+    } catch (err) {
+      console.warn("[startup] 加载审查项失败:", err)
+    }
+
+    try {
+      const savedChat = await loadChatHistory(proj.path)
+      if (!isCurrentProject(proj)) return
+      if (savedChat.conversations.length > 0) {
+        useChatStore.getState().setConversations(savedChat.conversations)
+        useChatStore.getState().setMessages(savedChat.messages)
+        const sorted = [...savedChat.conversations].sort((a, b) => b.updatedAt - a.updatedAt)
+        if (sorted[0]) {
+          useChatStore.getState().setActiveConversation(sorted[0].id)
+        }
+      }
+    } catch (err) {
+      console.warn("[startup] 加载聊天历史失败:", err)
+    }
+  }
+
+  async function hydrateScheduledImportAfterOpen(proj: WikiProject): Promise<void> {
+    try {
+      const savedScheduledImport = await loadScheduledImportConfig(proj.path)
+      if (!isCurrentProject(proj)) return
+      if (savedScheduledImport) {
+        let path = savedScheduledImport.path
+        if (path && !path.startsWith("/") && !path.match(/^[a-zA-Z]:[/\\]/)) {
+          path = `${proj.path}/${path}`
+        }
+        useWikiStore.getState().setScheduledImportConfig({
+          ...savedScheduledImport,
+          path,
+        })
+      }
+
+      if (!isTauri()) return
+      const scheduledImportConfig = useWikiStore.getState().scheduledImportConfig
+      if (!isCurrentProject(proj)) return
+      if (scheduledImportConfig.enabled && scheduledImportConfig.path && scheduledImportConfig.interval > 0) {
+        const { startScheduledImport } = await import("@/lib/scheduled-import")
+        if (!isCurrentProject(proj)) return
+        startScheduledImport(proj, scheduledImportConfig)
+      }
+    } catch (err) {
+      console.warn("[startup] 加载定时导入配置失败:", err)
+    }
+  }
+
+  async function hydrateProjectBackgroundServices(proj: WikiProject): Promise<void> {
+    if (!isTauri()) return
+    if (!isCurrentProject(proj)) return
+
+    try {
+      const { restoreQueue } = await import("@/lib/ingest-queue")
+      if (!isCurrentProject(proj)) return
+      await restoreQueue(proj.id, proj.path)
+    } catch (err) {
+      console.error("恢复摄取队列失败:", err)
+    }
+
+    if (!isCurrentProject(proj)) return
+
+    try {
+      const { restoreQueue: restoreDedupQueue } = await import("@/lib/dedup-queue")
+      await restoreDedupQueue(proj.id, proj.path)
+    } catch (err) {
+      console.error("恢复去重队列失败:", err)
+    }
+
+    if (!isCurrentProject(proj)) return
+
+    try {
+      const { startProjectFileSync, stopProjectFileSync } = await import("@/lib/project-file-sync")
+      const config = await loadSourceWatchConfig(proj.id, proj.path)
+      if (!isCurrentProject(proj)) return
+      useWikiStore.getState().setSourceWatchConfig(config)
+      if (config.enabled) {
+        startProjectFileSync(proj, config).catch((err) =>
+          console.error("启动项目文件同步失败:", err)
+        )
+      } else {
+        stopProjectFileSync().catch(() => {})
+      }
+    } catch (err) {
+      console.error("配置项目文件同步失败:", err)
+    }
+  }
+
+  async function hydrateDeferredProjectState(proj: WikiProject): Promise<void> {
+    await hydrateProjectBackgroundServices(proj)
+    if (!isCurrentProject(proj)) return
+    await hydrateScheduledImportAfterOpen(proj)
+    if (!isCurrentProject(proj)) return
+    await hydrateProjectSideStores(proj)
+  }
+
   useEffect(() => {
     document.documentElement.style.fontSize = `${Math.round(uiFontSizeScale * 100)}%`
   }, [uiFontSizeScale])
@@ -238,119 +346,47 @@ function App() {
     // 默认开启小说模式
     useWikiStore.getState().setNovelMode(true)
     const projectNovelConfig = await loadNovelConfig(proj.id, proj.path)
-    if (projectNovelConfig) {
+    if (projectNovelConfig && isCurrentProject(proj)) {
       useWikiStore.getState().setNovelConfig(projectNovelConfig)
     }
     const projectRevisionFeedbackWindowConfig = await loadRevisionFeedbackWindowConfig(proj.id, proj.path)
-    useWikiStore.getState().setRevisionFeedbackWindowConfig(projectRevisionFeedbackWindowConfig)
+    if (isCurrentProject(proj)) {
+      useWikiStore.getState().setRevisionFeedbackWindowConfig(projectRevisionFeedbackWindowConfig)
+    }
     setSelectedFile(null)
     setActiveView("wiki")
+    useWikiStore.getState().setScheduledImportConfig({
+      enabled: false,
+      path: `${proj.path}/raw/sources`,
+      interval: 60,
+      lastScan: null,
+    })
     useWikiStore.getState().bumpDataVersion()
     await saveLastProject(proj)
 
     // 自动打开最后阅读的章节和AI会话窗口
     try {
-      const lastChapterPath = await loadLastReadChapter()
-      if (lastChapterPath) {
-        const normalizedPath = lastChapterPath.replace(/\\/g, "/")
-        if (normalizedPath.includes("/wiki/chapters/")) {
-          const exists = await fileExists(lastChapterPath)
-          if (exists) {
-            setSelectedFile(lastChapterPath)
+      if (isCurrentProject(proj)) {
+        const lastChapterPath = await loadLastReadChapter()
+        if (isCurrentProject(proj) && lastChapterPath) {
+          const normalizedPath = lastChapterPath.replace(/\\/g, "/")
+          if (normalizedPath.includes("/wiki/chapters/")) {
+            const exists = await fileExists(lastChapterPath)
+            if (exists && isCurrentProject(proj)) {
+              setSelectedFile(lastChapterPath)
+            }
           }
         }
       }
     } catch (err) {
       console.error("加载最后阅读章节失败:", err)
     }
-    useWikiStore.getState().setChatExpanded(true)
-
-    if (isTauri()) {
-      try {
-        await restoreIngestQueue(proj.id, proj.path)
-      } catch (err) {
-        console.error("恢复摄取队列失败:", err)
-      }
-      import("@/lib/dedup-queue").then(({ restoreQueue }) => {
-        restoreQueue(proj.id, proj.path).catch((err) =>
-          console.error("恢复去重队列失败:", err)
-        )
-      })
-    }
-
-    try {
-      const savedScheduledImport = await loadScheduledImportConfig(proj.path)
-      if (savedScheduledImport) {
-        let path = savedScheduledImport.path
-        if (path && !path.startsWith("/") && !path.match(/^[a-zA-Z]:[/\\]/)) {
-          path = `${proj.path}/${path}`
-        }
-        useWikiStore.getState().setScheduledImportConfig({
-          ...savedScheduledImport,
-          path,
-        })
-      } else {
-        useWikiStore.getState().setScheduledImportConfig({
-          enabled: false,
-          path: `${proj.path}/raw/sources`,
-          interval: 60,
-          lastScan: null,
-        })
-      }
-    } catch (err) {
-      console.error("加载定时导入配置失败:", err)
-    }
-
-    if (isTauri()) {
-      const scheduledImportConfig = useWikiStore.getState().scheduledImportConfig
-      if (scheduledImportConfig.enabled && scheduledImportConfig.path && scheduledImportConfig.interval > 0) {
-        import("@/lib/scheduled-import").then(({ startScheduledImport }) => {
-          startScheduledImport(proj, scheduledImportConfig)
-        }).catch((err) =>
-          console.error("启动定时导入失败:", err)
-        )
-      }
-
-      import("@/lib/project-file-sync").then(async ({ startProjectFileSync, stopProjectFileSync }) => {
-        const config = await loadSourceWatchConfig(proj.id, proj.path)
-        useWikiStore.getState().setSourceWatchConfig(config)
-        if (config.enabled) {
-          startProjectFileSync(proj, config).catch((err) =>
-            console.error("启动项目文件同步失败:", err)
-          )
-        } else {
-          stopProjectFileSync().catch(() => {})
-        }
-      }).catch((err) => console.error("配置项目文件同步失败:", err))
+    if (isCurrentProject(proj)) {
+      useWikiStore.getState().setChatExpanded(true)
     }
 
-    try {
-      const tree = await listDirectory(proj.path)
-      setFileTree(tree)
-    } catch (err) {
-      console.error("加载文件树失败:", err)
-    }
-    try {
-      const savedReview = await loadReviewItems(proj.path)
-      if (savedReview.length > 0) {
-        useReviewStore.getState().setItems(savedReview)
-      }
-    } catch (err) {
-      console.error("加载审查项失败:", err)
-    }
-    try {
-      const savedChat = await loadChatHistory(proj.path)
-      if (savedChat.conversations.length > 0) {
-        useChatStore.getState().setConversations(savedChat.conversations)
-        useChatStore.getState().setMessages(savedChat.messages)
-        const sorted = [...savedChat.conversations].sort((a, b) => b.updatedAt - a.updatedAt)
-        if (sorted[0]) {
-          useChatStore.getState().setActiveConversation(sorted[0].id)
-        }
-      }
-    } catch (err) {
-      console.error("加载聊天历史失败:", err)
-    }
+    // 文件树由 AppLayout 通过 refreshProjectFileTree 加载;重队列/定时导入/审查/聊天后置 hydration。
+    void hydrateDeferredProjectState(proj)
   }
 
   async function handleSelectRecent(proj: WikiProject) {

+ 0 - 0
QM-LOGO-original.png → src/assets/QM-LOGO-original.png


+ 55 - 2
src/commands/fs.ts

@@ -19,8 +19,61 @@ export async function writeFileAtomic(path: string, contents: string): Promise<v
   return invoke<void>("write_file_atomic", { path, contents })
 }
 
-export async function listDirectory(path: string): Promise<FileNode[]> {
-  return invoke<FileNode[]>("list_directory", { path })
+/**
+ * List a directory tree. Dot-prefixed entries (`.claude`, `.env`,
+ * `.qmai`, …) are hidden by default; pass `includeHidden: true`
+ * only for the `raw/sources` content area, where dotfolders are
+ * legitimate user-added sources. See `entry_is_visible` in fs.rs.
+ */
+export interface ListDirectoryOptions {
+  includeHidden?: boolean
+  maxDepth?: number
+}
+
+// In-flight dedupe only: entries are removed when the request settles. Each
+// caller receives its own tree copy when a request is actually shared, so
+// accidental in-place mutations do not leak across concurrent waiters.
+interface PendingListDirectory {
+  request: Promise<FileNode[]>
+  shared: boolean
+}
+
+const pendingListDirectory = new Map<string, PendingListDirectory>()
+
+function cloneFileNodes(nodes: FileNode[]): FileNode[] {
+  return nodes.map((node) => ({
+    ...node,
+    children: node.children ? cloneFileNodes(node.children) : node.children,
+  }))
+}
+
+export async function listDirectory(
+  path: string,
+  includeHiddenOrOptions: boolean | ListDirectoryOptions = false,
+): Promise<FileNode[]> {
+  const options =
+    typeof includeHiddenOrOptions === "boolean"
+      ? { includeHidden: includeHiddenOrOptions }
+      : includeHiddenOrOptions
+  const includeHidden = options.includeHidden ?? false
+  const maxDepth = options.maxDepth
+  const requestKey = JSON.stringify([path, includeHidden, maxDepth ?? null])
+  const pending = pendingListDirectory.get(requestKey)
+  if (pending) {
+    pending.shared = true
+    return pending.request.then(cloneFileNodes)
+  }
+
+  const request = invoke<FileNode[]>("list_directory", {
+    path,
+    includeHidden,
+    maxDepth,
+  }).finally(() => {
+    pendingListDirectory.delete(requestKey)
+  })
+  const entry: PendingListDirectory = { request, shared: false }
+  pendingListDirectory.set(requestKey, entry)
+  return request.then((nodes) => (entry.shared ? cloneFileNodes(nodes) : nodes))
 }
 
 export async function copyFile(

+ 3 - 1
src/components/chat/chat-panel.tsx

@@ -763,9 +763,11 @@ export function ChatPanel() {
       const chapterDir = `${pp}/wiki/chapters`
       await createDirectory(chapterDir)
       const chapterPath = `${chapterDir}/chapter-${String(targetChapterNumber).padStart(3, "0")}.md`
-      await writeFile(chapterPath, buildDraftContent(targetChapterNumber, chapterTitle, cleanedContent))
+      const chapterMarkdown = buildDraftContent(targetChapterNumber, chapterTitle, cleanedContent)
+      await writeFile(chapterPath, chapterMarkdown)
       setChapterSaveStatus(`已保存为${chapterTitle}`)
       useWikiStore.getState().setSelectedFile(chapterPath)
+      useWikiStore.getState().setFileContent(chapterMarkdown)
 
       await refreshProjectState(pp)
       useWikiStore.getState().setActiveView("wiki")

+ 3 - 2
src/components/editor/frontmatter-panel.tsx

@@ -44,6 +44,7 @@ const TOP_LEVEL_KEYS = new Set([
 
 export function FrontmatterPanel({ data }: FrontmatterPanelProps) {
   const project = useWikiStore((s) => s.project)
+  const projectPathIndex = useWikiStore((s) => s.projectPathIndex)
   const fileTree = useWikiStore((s) => s.fileTree)
   const setSelectedFile = useWikiStore((s) => s.setSelectedFile)
 
@@ -153,7 +154,7 @@ export function FrontmatterPanel({ data }: FrontmatterPanelProps) {
             {sources.map((source) => {
               const { slug, label } = unwrapWikilink(source)
               const path = sourcesRoot
-                ? resolveSourceName(fileTree, slug, sourcesRoot)
+                ? resolveSourceName(projectPathIndex, slug, sourcesRoot, fileTree)
                 : null
               return (
                 <SourceCard
@@ -180,7 +181,7 @@ export function FrontmatterPanel({ data }: FrontmatterPanelProps) {
             {related.map((entry) => {
               const { slug, label } = unwrapWikilink(entry)
               const path = wikiRoot
-                ? resolveRelatedSlug(fileTree, slug, wikiRoot)
+                ? resolveRelatedSlug(projectPathIndex, slug, wikiRoot, fileTree)
                 : null
               return (
                 <RelatedChip

+ 32 - 7
src/components/editor/wiki-editor.tsx

@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useMemo, useRef, useState } from "react"
+import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, forwardRef } from "react"
 import { Editor, rootCtx, defaultValueCtx } from "@milkdown/kit/core"
 import { commonmark } from "@milkdown/kit/preset/commonmark"
 import { gfm } from "@milkdown/kit/preset/gfm"
@@ -41,14 +41,18 @@ interface FloatingToolbarPosition {
   left: number
 }
 
-function WritingTextarea({
+interface WritingTextareaHandle {
+  getLiveBodyMarkdown: () => string | null
+}
+
+const WritingTextarea = forwardRef<WritingTextareaHandle, WritingTextareaProps>(function WritingTextarea({
   content,
   onSave,
   autoFocus = false,
   onSelectionAction,
   highlightRequest,
   onHighlightHandled,
-}: WritingTextareaProps) {
+}, ref) {
   const initial = useMemo(() => splitChapterHeading(content), [content])
   const [heading, setHeading] = useState(initial.heading)
   const [value, setValue] = useState(initial.body)
@@ -57,6 +61,13 @@ function WritingTextarea({
   const [selection, setSelection] = useState<ChapterBodySelection | null>(null)
   const [toolbarPosition, setToolbarPosition] = useState<FloatingToolbarPosition | null>(null)
 
+  useImperativeHandle(ref, () => ({
+    getLiveBodyMarkdown: () => {
+      if (!textareaRef.current) return null
+      return heading ? `# ${heading}\n\n${value}` : value
+    },
+  }), [heading, value])
+
   useEffect(() => {
     const { heading: h, body: b } = splitChapterHeading(content)
     if (document.activeElement === textareaRef.current) {
@@ -304,7 +315,7 @@ function WritingTextarea({
       />
     </div>
   )
-}
+})
 
 function WikiEditorInner({ content, onSave }: WikiEditorInnerProps) {
   // Milkdown fires `markdownUpdated` once on initial parse before any
@@ -351,6 +362,10 @@ interface WikiEditorProps {
   onHighlightHandled?: () => void
 }
 
+export interface WikiEditorHandle {
+  getCurrentMarkdown: () => string | null
+}
+
 function wrapBareMathBlocks(text: string): string {
   return text.replace(
     /(?<!\$\$\s*)(\\begin\{[^}]+\}[\s\S]*?\\end\{[^}]+\})(?!\s*\$\$)/g,
@@ -358,7 +373,7 @@ function wrapBareMathBlocks(text: string): string {
   )
 }
 
-export function WikiEditor({
+export const WikiEditor = forwardRef<WikiEditorHandle, WikiEditorProps>(function WikiEditor({
   content,
   onSave,
   defaultMode = "read",
@@ -366,7 +381,8 @@ export function WikiEditor({
   onSelectionAction,
   highlightRequest,
   onHighlightHandled,
-}: WikiEditorProps) {
+}, ref) {
+  const writingTextareaRef = useRef<WritingTextareaHandle>(null)
   // Default to read mode (ReactMarkdown render). Edit mode swaps
   // in Milkdown WYSIWYG. We default to read because:
   //   1. Milkdown's commonmark/gfm preset has no wikilink schema,
@@ -398,6 +414,14 @@ export function WikiEditor({
     [onSave, rawBlock],
   )
 
+  useImperativeHandle(ref, () => ({
+    getCurrentMarkdown: () => {
+      const liveBody = writingTextareaRef.current?.getLiveBodyMarkdown()
+      if (liveBody == null) return null
+      return rawBlock + liveBody
+    },
+  }), [rawBlock])
+
   useEffect(() => {
     setMode(defaultMode)
   }, [defaultMode])
@@ -446,6 +470,7 @@ export function WikiEditor({
             `}</style>
             <div className="mx-auto w-full max-w-4xl flex-col px-8 py-10">
               <WritingTextarea
+                ref={writingTextareaRef}
                 content={body}
                 onSave={handleSave}
                 autoFocus={immersiveWriting}
@@ -466,7 +491,7 @@ export function WikiEditor({
       )}
     </div>
   )
-}
+})
 
 function getTextareaSelectionToolbarPosition(
   textarea: HTMLTextAreaElement,

+ 2 - 1
src/components/editor/wiki-reader.tsx

@@ -31,6 +31,7 @@ interface WikiReaderProps {
  */
 export function WikiReader({ body }: WikiReaderProps) {
   const project = useWikiStore((s) => s.project)
+  const projectPathIndex = useWikiStore((s) => s.projectPathIndex)
   const fileTree = useWikiStore((s) => s.fileTree)
   const setSelectedFile = useWikiStore((s) => s.setSelectedFile)
 
@@ -52,7 +53,7 @@ export function WikiReader({ body }: WikiReaderProps) {
         return href.slice(1)
       }
     })()
-    const path = resolveRelatedSlug(fileTree, slug, wikiRoot)
+    const path = resolveRelatedSlug(projectPathIndex, slug, wikiRoot, fileTree)
     if (path) setSelectedFile(path)
   }
 

+ 6 - 10
src/components/layout/app-layout.tsx

@@ -1,7 +1,6 @@
 import { useCallback, useEffect, useRef, useState } from "react"
 import { useWikiStore } from "@/stores/wiki-store"
-import { listDirectory } from "@/commands/fs"
-import { normalizePath } from "@/lib/path-utils"
+import { refreshProjectFileTree } from "@/lib/project-file-tree-refresh"
 import { IconSidebar } from "./icon-sidebar"
 import { SidebarPanel } from "./sidebar-panel"
 import { ContentArea } from "./content-area"
@@ -25,7 +24,6 @@ export function AppLayout({ onSwitchProject }: AppLayoutProps) {
   const activeView = useWikiStore((s) => s.activeView)
   const setActiveView = useWikiStore((s) => s.setActiveView)
   const setActiveSettingsCategory = useWikiStore((s) => s.setActiveSettingsCategory)
-  const setFileTree = useWikiStore((s) => s.setFileTree)
   const outlineTasks = useOutlineGenerationStore((s: OutlineGenerationState) => s.tasks)
   const removeOutlineTask = useOutlineGenerationStore((s: OutlineGenerationState) => s.removeTask)
   const bookAnalysisTasks = useBookAnalysisStore((s) => s.tasks)
@@ -62,13 +60,11 @@ export function AppLayout({ onSwitchProject }: AppLayoutProps) {
 
   const loadFileTree = useCallback(async () => {
     if (!project) return
-    try {
-      const tree = await listDirectory(normalizePath(project.path))
-      setFileTree(tree)
-    } catch (err) {
-      console.error("Failed to load file tree:", err)
-    }
-  }, [project, setFileTree])
+    await refreshProjectFileTree(project.path, {
+      projectId: project.id,
+      clearDisplayTreeFirst: false,
+    })
+  }, [project])
 
   useEffect(() => {
     loadFileTree()

+ 177 - 52
src/components/layout/preview-panel.tsx

@@ -2,13 +2,13 @@ import { type CSSProperties, Suspense, lazy, useEffect, useCallback, useRef, use
 import { useTranslation } from "react-i18next"
 import { Check, MoreHorizontal, X } from "lucide-react"
 import { useWikiStore } from "@/stores/wiki-store"
-import { resolveDefaultModel } from "@/lib/novel/model-resolver"
+import { resolveDefaultModel, resolveNovelModel } from "@/lib/novel/model-resolver"
 import type { FinalChapterSavePhase } from "@/stores/wiki-store"
 import { useReviewStore } from "@/stores/review-store"
 import { deleteFile, fileExists, readFile, writeFile, writeFileAtomic, listDirectory } from "@/commands/fs"
 import { normalizePath } from "@/lib/path-utils"
 import { getFileCategory, isBinary } from "@/lib/file-types"
-import { WikiEditor } from "@/components/editor/wiki-editor"
+import { WikiEditor, type WikiEditorHandle } from "@/components/editor/wiki-editor"
 import { WikiReader } from "@/components/editor/wiki-reader"
 import { FilePreview } from "@/components/editor/file-preview"
 import { formatChapterWriting } from "@/lib/chapter-formatting"
@@ -32,6 +32,12 @@ import {
 } from "@/lib/novel/de-ai-skill-library"
 import { startOutlineIngestTask } from "@/lib/novel/outline-generation"
 import { streamChat } from "@/lib/llm-client"
+import {
+  extractChapterNumberFromMarkdown,
+  getDraftChapterPath,
+  resolveChapterFlushMarkdown,
+  shouldSyncChapterOnLeave,
+} from "@/lib/novel/chapter-path-sync"
 import { makeChapterFileName, makeDefaultChapterTitle } from "@/lib/wiki-filename"
 import { getPreviewContentContainerClass, shouldUseCompactChapterToolbar } from "@/lib/workspace-layout"
 import { useOutlineGenerationStore, type OutlineGenerationTask } from "@/stores/outline-generation-store"
@@ -45,6 +51,7 @@ import {
   type ChapterBodySelection,
   type ChapterSelectionAction,
 } from "@/lib/chapter-selection"
+import { shouldApplyDiskToEditor } from "@/lib/editor-disk-sync"
 
 const SnapshotViewer = lazy(async () => {
   const mod = await import("@/components/novel/snapshot-viewer")
@@ -95,12 +102,6 @@ async function getCanonicalChapterPath(currentPath: string, markdown: string, ch
   return getUniqueSiblingPath(getDirName(currentPath), makeChapterFileName(title, chapterNumber), currentPath)
 }
 
-function extractChapterNumberFromMarkdown(markdown: string): number | null {
-  const { frontmatter } = parseFrontmatter(markdown)
-  if (!frontmatter || typeof frontmatter !== "object") return null
-  return parseChapterMeta(frontmatter as Record<string, unknown>)?.chapterNumber ?? null
-}
-
 function formatWritingBodyWithIndent(markdown: string): string {
   return formatChapterWriting(markdown)
   /*
@@ -196,6 +197,8 @@ export function PreviewPanel() {
   const setFinalChapterSave = useWikiStore((s) => s.setFinalChapterSave)
   const outlineTasks = useOutlineGenerationStore((s) => s.tasks)
   const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
+  const saveGenerationRef = useRef(0)
+  const wikiEditorRef = useRef<WikiEditorHandle>(null)
   const [isSavingFinal, setIsSavingFinal] = useState(false)
   const [saveStatus, setSaveStatus] = useState<string>("")
   const [showSnapshot, setShowSnapshot] = useState(false)
@@ -231,6 +234,7 @@ export function PreviewPanel() {
   // marker if read_file had returned one for a missing/locked file. We
   // skip save when the incoming markdown equals the last-loaded content.
   const lastLoadedRef = useRef<string>("")
+  const lastLoadedByPathRef = useRef<Map<string, string>>(new Map())
   const fileContentRef = useRef(fileContent)
   const selectedFileRef = useRef<string | null>(selectedFile)
   const deAiSkillMemoryWarningRef = useRef("")
@@ -247,6 +251,50 @@ export function PreviewPanel() {
     fileContentRef.current = fileContent
   }, [fileContent])
 
+  const rememberLoadedChapter = useCallback((path: string, markdown: string) => {
+    const key = normalizePath(path)
+    lastLoadedRef.current = markdown
+    lastLoadedByPathRef.current.set(key, markdown)
+  }, [])
+
+  const applyDiskSyncIfSafe = useCallback(async (path: string): Promise<boolean> => {
+    const normalizedPath = normalizePath(path)
+    if (getFileCategory(normalizedPath) !== "markdown") return false
+
+    let diskContent: string
+    try {
+      diskContent = await readFile(normalizedPath)
+    } catch {
+      return false
+    }
+
+    const editorContent = wikiEditorRef.current?.getCurrentMarkdown() ?? fileContentRef.current
+    const lastLoaded = lastLoadedByPathRef.current.get(normalizedPath) ?? lastLoadedRef.current
+    const hasPendingSave = saveTimerRef.current != null
+
+    if (!shouldApplyDiskToEditor({
+      lastLoaded,
+      editorContent,
+      diskContent,
+      hasPendingSave,
+    })) {
+      return false
+    }
+
+    rememberLoadedChapter(normalizedPath, diskContent)
+    fileContentRef.current = diskContent
+    if (selectedFileRef.current && normalizePath(selectedFileRef.current) === normalizedPath) {
+      setFileContent(diskContent)
+    }
+    return true
+  }, [rememberLoadedChapter, setFileContent])
+
+  const syncDiskBeforeAction = useCallback(async () => {
+    const path = selectedFileRef.current
+    if (!path) return
+    await applyDiskSyncIfSafe(path)
+  }, [applyDiskSyncIfSafe])
+
   useEffect(() => {
     setChapterDeAiSkillId(undefined)
     deAiSkillMemoryWarningRef.current = ""
@@ -272,14 +320,31 @@ export function PreviewPanel() {
     }
   }, [deAiSkillPickerOpen])
 
-  const syncChapterToCanonicalPath = useCallback(async (path: string, markdown: string) => {
+  const syncChapterToCanonicalPath = useCallback(async (
+    path: string,
+    markdown: string,
+    options?: { renameToCanonical?: boolean },
+  ) => {
     const normalized = normalizeChapterWriting(markdown)
     const chapterNumber = extractChapterNumberFromMarkdown(normalized)
-    const targetPath = await getCanonicalChapterPath(path, normalized, chapterNumber)
+    const renameToCanonical = options?.renameToCanonical ?? false
+    const targetPath = renameToCanonical
+      ? await getCanonicalChapterPath(path, normalized, chapterNumber)
+      : path
 
     await writeFileAtomic(targetPath, normalized)
-    if (targetPath !== path) {
+    if (renameToCanonical && targetPath !== path) {
+      if (useWikiStore.getState().selectedFile === path) {
+        selectedFileRef.current = targetPath
+        useWikiStore.getState().setSelectedFile(targetPath)
+      }
       await deleteFile(path)
+      if (chapterNumber !== null) {
+        const draftPath = getDraftChapterPath(getDirName(targetPath), chapterNumber)
+        if (draftPath !== targetPath && draftPath !== path && await fileExists(draftPath)) {
+          await deleteFile(draftPath)
+        }
+      }
       if (project) {
         try {
           const tree = await listDirectory(normalizePath(project.path))
@@ -288,21 +353,17 @@ export function PreviewPanel() {
           // non-critical tree refresh
         }
       }
-      if (useWikiStore.getState().selectedFile === path) {
-        selectedFileRef.current = targetPath
-        useWikiStore.getState().setSelectedFile(targetPath)
-      }
     }
 
+    rememberLoadedChapter(targetPath, normalized)
     if (useWikiStore.getState().selectedFile === targetPath) {
       setFileContent(normalized)
       fileContentRef.current = normalized
-      lastLoadedRef.current = normalized
     }
 
     bumpDataVersion()
     return { targetPath, markdown: normalized }
-  }, [project, setFileContent, setFileTree, bumpDataVersion])
+  }, [project, rememberLoadedChapter, setFileContent, setFileTree, bumpDataVersion])
 
   const flushChapterBeforeLeave = useCallback(async (path: string | null, markdown: string) => {
     if (!path || !isChapterPath(path)) return
@@ -311,9 +372,12 @@ export function PreviewPanel() {
       clearTimeout(saveTimerRef.current)
       saveTimerRef.current = null
     }
-    if (markdown === lastLoadedRef.current) return
+    const normalizedPath = normalizePath(path)
+    const lastLoadedForPath = lastLoadedByPathRef.current.get(normalizedPath) ?? ""
+    const resolvedMarkdown = resolveChapterFlushMarkdown(path, markdown, lastLoadedByPathRef.current)
+    if (!shouldSyncChapterOnLeave(path, markdown, lastLoadedForPath)) return
     try {
-      await syncChapterToCanonicalPath(path, markdown)
+      await syncChapterToCanonicalPath(path, resolvedMarkdown, { renameToCanonical: false })
     } catch (err) {
       console.error("切换章节前同步文件失败:", err)
     }
@@ -321,6 +385,11 @@ export function PreviewPanel() {
 
   useEffect(() => {
     let cancelled = false
+    if (saveTimerRef.current) {
+      clearTimeout(saveTimerRef.current)
+      saveTimerRef.current = null
+    }
+    saveGenerationRef.current += 1
     const previousFile = selectedFileRef.current
     const previousContent = fileContentRef.current
     console.log("[PreviewPanel][debug] useEffect triggered", { selectedFile, previousFile, previousContentLength: previousContent?.length })
@@ -359,14 +428,13 @@ export function PreviewPanel() {
 
     setFileContent("")
     fileContentRef.current = ""
-    lastLoadedRef.current = ""
     setSaveStatus("")
 
     readFile(selectedFile)
       .then((content) => {
         console.log("[PreviewPanel][debug] readFile success", { selectedFile, contentLength: content?.length, cancelled, storeSelectedFile: useWikiStore.getState().selectedFile })
         if (cancelled || useWikiStore.getState().selectedFile !== selectedFile) return
-        lastLoadedRef.current = content
+        rememberLoadedChapter(normalizePath(selectedFile), content)
         setFileContent(content)
         setSaveStatus("")
         setLoadedFilePath(selectedFile)
@@ -375,6 +443,7 @@ export function PreviewPanel() {
         console.log("[PreviewPanel][debug] readFile error", { selectedFile, err, cancelled, storeSelectedFile: useWikiStore.getState().selectedFile })
         if (cancelled || useWikiStore.getState().selectedFile !== selectedFile) return
         lastLoadedRef.current = ""
+        lastLoadedByPathRef.current.delete(normalizePath(selectedFile))
         setFileContent(`Error loading file: ${err}`)
         setSaveStatus("")
         setLoadedFilePath(selectedFile)
@@ -383,7 +452,22 @@ export function PreviewPanel() {
       console.log("[PreviewPanel][debug] useEffect cleanup", { selectedFile })
       cancelled = true
     }
-  }, [selectedFile, setFileContent, flushChapterBeforeLeave])
+  }, [selectedFile, rememberLoadedChapter, setFileContent, flushChapterBeforeLeave])
+
+  useEffect(() => {
+    if (!selectedFile) return
+    const category = getFileCategory(selectedFile)
+    if (category !== "markdown" || isBinary(category)) return
+
+    const normalizedPath = normalizePath(selectedFile)
+    const tick = () => {
+      if (normalizePath(selectedFileRef.current ?? "") !== normalizedPath) return
+      void applyDiskSyncIfSafe(normalizedPath)
+    }
+
+    const intervalId = setInterval(tick, 2000)
+    return () => clearInterval(intervalId)
+  }, [selectedFile, applyDiskSyncIfSafe])
 
   useEffect(() => {
     return () => {
@@ -396,30 +480,50 @@ export function PreviewPanel() {
 
   const handleSave = useCallback(
     (markdown: string) => {
-      if (!selectedFile) return
-      const persistedMarkdown = isChapterPath(selectedFile)
+      const pathAtSave = selectedFileRef.current
+      if (!pathAtSave) return
+      const persistedMarkdown = isChapterPath(pathAtSave)
         ? normalizeChapterWriting(markdown)
         : markdown
       setFileContent(markdown)
       fileContentRef.current = markdown
-      // Ignore no-op saves from the editor's initial re-emit. Only write
-      // when the user has actually changed the content relative to the
-      // last disk read.
-      if (persistedMarkdown === lastLoadedRef.current) return
+      const normalizedPath = normalizePath(pathAtSave)
+      const lastLoadedForPath = lastLoadedByPathRef.current.get(normalizedPath) ?? lastLoadedRef.current
+      if (persistedMarkdown === lastLoadedForPath) return
       if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
+      const generation = saveGenerationRef.current
       saveTimerRef.current = setTimeout(() => {
-        writeFileAtomic(selectedFile, persistedMarkdown)
-          .then(() => {
-            // Our own write becomes the new "last loaded" — subsequent
-            // re-emits from Milkdown that match this content must not
-            // trigger another save.
-            lastLoadedRef.current = persistedMarkdown
+        void (async () => {
+          if (generation !== saveGenerationRef.current) return
+          if (pathAtSave !== selectedFileRef.current) return
+          if (normalizePath(pathAtSave) !== normalizedPath) return
+
+          let diskContent: string
+          try {
+            diskContent = await readFile(normalizedPath)
+          } catch (err) {
+            console.error("保存前读取磁盘失败:", err)
+            return
+          }
+
+          const currentLastLoaded = lastLoadedByPathRef.current.get(normalizedPath) ?? lastLoadedRef.current
+          const normalize = isChapterPath(normalizedPath) ? normalizeChapterWriting : (content: string) => content
+          if (normalize(diskContent) !== normalize(currentLastLoaded)) {
+            void applyDiskSyncIfSafe(normalizedPath)
+            return
+          }
+
+          try {
+            await writeFileAtomic(pathAtSave, persistedMarkdown)
+            rememberLoadedChapter(normalizedPath, persistedMarkdown)
             bumpDataVersion()
-          })
-          .catch((err) => console.error("保存失败:", err))
+          } catch (err) {
+            console.error("保存失败:", err)
+          }
+        })()
       }, 1000)
     },
-    [selectedFile, setFileContent, bumpDataVersion]
+    [rememberLoadedChapter, setFileContent, bumpDataVersion, applyDiskSyncIfSafe]
   )
 
   const chapterFrontmatter = useMemo(() => {
@@ -613,7 +717,7 @@ export function PreviewPanel() {
     setChapterTitleEditing(false)
     if (nextTitle === chapterDisplayTitle) return
     try {
-      await syncChapterToCanonicalPath(selectedFile, updateChapterHeading(fileContent, nextTitle))
+      await syncChapterToCanonicalPath(selectedFile, updateChapterHeading(fileContent, nextTitle), { renameToCanonical: true })
     } catch (error) {
       console.error("章节标题同步失败:", error)
     }
@@ -636,6 +740,9 @@ export function PreviewPanel() {
   const handleSaveAsFinal = useCallback(async () => {
     if (!project || !selectedFile || !chapterFrontmatter) return
 
+    await syncDiskBeforeAction()
+    const currentContent = wikiEditorRef.current?.getCurrentMarkdown() ?? fileContentRef.current
+
     let savePath = selectedFile
     const projectPath = project.path
     const updatePhase = (saving: boolean, phase: FinalChapterSavePhase | null, params?: Record<string, string | number>) => {
@@ -652,7 +759,7 @@ export function PreviewPanel() {
       try {
         const chapterNumber = chapterFrontmatter.chapterNumber as number | undefined
         const { reviewChapter } = await import("@/lib/novel/review-adapter")
-        const results = await reviewChapter(project.path, fileContent, chapterNumber)
+        const results = await reviewChapter(project.path, currentContent, chapterNumber)
         if (results.length > 0) {
           const reviewStore = useReviewStore.getState()
           reviewStore.addNovelReviewEntry({
@@ -686,16 +793,27 @@ export function PreviewPanel() {
         saveTimerRef.current = null
       }
 
-      const updatedMarkdown = updateChapterStatus(fileContent, "final")
-      const syncResult = await syncChapterToCanonicalPath(selectedFile, updatedMarkdown)
+      const markdownToSave = currentContent.trim()
+        ? currentContent
+        : (lastLoadedByPathRef.current.get(normalizePath(selectedFile)) ?? lastLoadedRef.current)
+      if (!markdownToSave.trim()) {
+        updatePhase(false, null)
+        setSaveStatus("章节内容为空,无法保存为正式章节")
+        setIsSavingFinal(false)
+        return
+      }
+
+      const updatedMarkdown = updateChapterStatus(markdownToSave, "final")
+      const syncResult = await syncChapterToCanonicalPath(selectedFile, updatedMarkdown, { renameToCanonical: true })
       const targetPath = syncResult.targetPath
       savePath = targetPath
-      lastLoadedRef.current = syncResult.markdown
+      rememberLoadedChapter(targetPath, syncResult.markdown)
+      fileContentRef.current = syncResult.markdown
       setFileContent(syncResult.markdown)
 
       if (novelConfig.autoIngestOnSave) {
         const state = useWikiStore.getState()
-        const llmConfig = state.llmConfig
+        const llmConfig = resolveNovelModel(state.llmConfig, state.novelConfig, "extract")
         if (!hasUsableLlm(llmConfig, state.providerConfigs)) {
           updatePhase(false, "ingest_no_llm")
         } else {
@@ -746,7 +864,7 @@ export function PreviewPanel() {
     } finally {
       setIsSavingFinal(false)
     }
-  }, [chapterFrontmatter, fileContent, project, selectedFile, setFileContent, setFinalChapterSave, t, syncChapterToCanonicalPath])
+  }, [chapterFrontmatter, project, selectedFile, setFileContent, setFinalChapterSave, syncChapterToCanonicalPath, syncDiskBeforeAction])
 
   const handleReingest = useCallback(async () => {
     if (!project || !selectedFile || !chapterFrontmatter) return
@@ -807,14 +925,14 @@ export function PreviewPanel() {
     if (!selectedFile || !canFormatWriting) return
     const formatted = normalizeChapterWriting(fileContent)
     setFileContent(formatted)
-    lastLoadedRef.current = formatted
+    rememberLoadedChapter(selectedFile, formatted)
     try {
       await writeFileAtomic(selectedFile, formatted)
       bumpDataVersion()
     } catch (err) {
       console.error("格式化写作内容失败:", err)
     }
-  }, [canFormatWriting, fileContent, selectedFile, setFileContent, bumpDataVersion])
+  }, [canFormatWriting, fileContent, rememberLoadedChapter, selectedFile, setFileContent, bumpDataVersion])
 
   const handleIngestOutline = useCallback(() => {
     if (!project || !selectedFile || !canIngestOutline || isOutlineIngesting) return
@@ -823,18 +941,19 @@ export function PreviewPanel() {
   }, [canIngestOutline, isOutlineIngesting, project, selectedFile])
 
   const runWholeChapterDeAi = useCallback(async (skillContent: string, skillName: string) => {
-    if (!fileContent.trim()) return
+    await syncDiskBeforeAction()
+    const source = wikiEditorRef.current?.getCurrentMarkdown() ?? fileContentRef.current
+    if (!source.trim()) return
     setDeAiProcessing(true)
     setDeAiSkillName(skillName)
     const state = useWikiStore.getState()
-    const llmConfig = resolveDefaultModel(state.llmConfig)
+    const llmConfig = resolveNovelModel(state.llmConfig, state.novelConfig, "deAi")
     if (!hasUsableLlm(llmConfig, state.providerConfigs)) {
       setDeAiProcessing(false)
       setSaveStatus("未配置可用的 AI 模型,无法去AI味")
       return
     }
     setSaveStatus(formatDeAiStatus(`去AI味处理中,使用 Skill:${skillName}...`))
-    const source = fileContent
     let result = ""
     try {
       await streamChat(
@@ -861,7 +980,7 @@ export function PreviewPanel() {
       console.error("去AI味处理失败:", err)
       setDeAiProcessing(false)
     }
-  }, [fileContent, formatDeAiStatus])
+  }, [formatDeAiStatus, syncDiskBeforeAction])
 
   const handleDeAiApply = useCallback(() => {
     setDeAiPreviewOpen(false)
@@ -899,8 +1018,13 @@ export function PreviewPanel() {
     skillName?: string,
   ) => {
     if (!selection.text.trim()) return
+    if (action === "de-ai") {
+      await syncDiskBeforeAction()
+    }
     const state = useWikiStore.getState()
-    const llmConfig = resolveDefaultModel(state.llmConfig)
+    const llmConfig = action === "de-ai"
+      ? resolveNovelModel(state.llmConfig, state.novelConfig, "deAi")
+      : resolveDefaultModel(state.llmConfig)
     if (!hasUsableLlm(llmConfig, state.providerConfigs)) {
       setSaveStatus("未配置可用的 AI 模型,无法处理选中文本")
       return
@@ -948,7 +1072,7 @@ export function PreviewPanel() {
       console.error(`${actionLabel}失败:`, err)
       setSaveStatus(`${actionLabel}失败:${message}`)
     }
-  }, [formatDeAiStatus])
+  }, [formatDeAiStatus, syncDiskBeforeAction])
 
   const openDeAiSkillPicker = useCallback((selection: ChapterBodySelection | null, anchor?: HTMLElement | null) => {
     if (deAiProcessing) return
@@ -1430,6 +1554,7 @@ export function PreviewPanel() {
       <div className={getPreviewContentContainerClass(isSelectedChapter)}>
         {category === "markdown" ? (
           <WikiEditor
+            ref={wikiEditorRef}
             key={selectedFile}
             content={fileContent}
             onSave={handleSave}

+ 9 - 5
src/components/lint/lint-view.tsx

@@ -17,6 +17,7 @@ import { useWikiStore } from "@/stores/wiki-store"
 import { useReviewStore } from "@/stores/review-store"
 import { runStructuralLint, runSemanticLint, type LintResult } from "@/lib/lint"
 import { hasUsableLlm } from "@/lib/has-usable-llm"
+import { resolveDefaultModel, resolveNovelModel } from "@/lib/novel/model-resolver"
 import { readFile, writeFile, listDirectory } from "@/commands/fs"
 import { normalizePath } from "@/lib/path-utils"
 import { useTranslation } from "react-i18next"
@@ -34,8 +35,6 @@ export function LintView() {
   const { t } = useTranslation()
   const novelMode = useWikiStore((s) => s.novelMode)
   const project = useWikiStore((s) => s.project)
-  const llmConfig = useWikiStore((s) => s.llmConfig)
-  const providerConfigs = useWikiStore((s) => s.providerConfigs)
   const selectedFile = useWikiStore((s) => s.selectedFile)
   const fileContent = useWikiStore((s) => s.fileContent)
   const setSelectedFile = useWikiStore((s) => s.setSelectedFile)
@@ -111,8 +110,13 @@ export function LintView() {
       const structural = await runStructuralLint(pp)
       let all = structural
 
-      if (runSemantic && hasUsableLlm(llmConfig, providerConfigs)) {
-        const semantic = await runSemanticLint(pp, llmConfig, {
+      const state = useWikiStore.getState()
+      const effectiveLlmConfig = novelMode
+        ? resolveNovelModel(state.llmConfig, state.novelConfig, "lint")
+        : resolveDefaultModel(state.llmConfig)
+
+      if (runSemantic && hasUsableLlm(effectiveLlmConfig, state.providerConfigs)) {
+        const semantic = await runSemanticLint(pp, effectiveLlmConfig, {
           chapterContent: novelMode && selectedFile ? fileContent : undefined,
           chapterNumber: meta?.chapterNumber,
         })
@@ -151,7 +155,7 @@ export function LintView() {
         })
       }
     }
-  }, [project, llmConfig, providerConfigs, running, runSemantic, fileContent, novelMode, selectedFile, t, loadHistory, setLintRun])
+  }, [project, running, runSemantic, fileContent, novelMode, selectedFile, t, loadHistory, setLintRun])
 
   async function handleOpenPage(page: string) {
     if (!project) return

+ 11 - 18
src/components/novel/book-analysis-result-viewer.tsx

@@ -17,7 +17,8 @@ import { upsertWritingStylePreset, setEnabledWritingStyle, getEnabledWritingStyl
 import { joinPath } from "@/lib/path-utils"
 import { toast } from "@/lib/toast"
 import { refreshProjectState } from "@/lib/project-refresh"
-import { resolveModelConfig } from "@/lib/novel/model-resolver"
+import { resolveDefaultModel } from "@/lib/novel/model-resolver"
+import { hasUsableLlm } from "@/lib/has-usable-llm"
 import type { BookAnalysisResult, BookAnalysisMetadata, ExtractedCharacter, PersonalityProfile } from "@/lib/novel/book-analysis/types"
 
 interface BookAnalysisResultViewerProps {
@@ -124,9 +125,11 @@ export function BookAnalysisResultViewer({ projectPath, result, onClose }: BookA
     setReextractRunning(true)
     try {
       const reextractStoreState = useWikiStore.getState()
-      const reextractLlmConfig = reextractStoreState.aiChatModel
-        ? resolveModelConfig(reextractStoreState.aiChatModel, reextractStoreState.llmConfig, reextractStoreState.providerConfigs)
-        : reextractStoreState.llmConfig
+      const reextractLlmConfig = resolveDefaultModel(reextractStoreState.llmConfig)
+      if (!hasUsableLlm(reextractLlmConfig, reextractStoreState.providerConfigs)) {
+        toast.error("未配置 LLM,请先在设置中配置")
+        return
+      }
       // 修复:bookPath 实际写入路径是 book-analysis/{bookId},不是 book-analysis/{title}(feature/book-analysis-reuse)
       const bookId = task?.bookId
       const bookPath = joinPath(currentProject.path, "book-analysis", bookId ?? "unknown")
@@ -179,15 +182,11 @@ export function BookAnalysisResultViewer({ projectPath, result, onClose }: BookA
       return
     }
     const storeState = useWikiStore.getState()
-    const baseLlmConfig = storeState.llmConfig
-    if (!baseLlmConfig) {
+    const llmConfig = resolveDefaultModel(storeState.llmConfig)
+    if (!hasUsableLlm(llmConfig, storeState.providerConfigs)) {
       toast.error("未配置 LLM,请先在设置中配置")
       return
     }
-    const aiChatModel = storeState.aiChatModel
-    const llmConfig = aiChatModel
-      ? resolveModelConfig(aiChatModel, baseLlmConfig, storeState.providerConfigs)
-      : baseLlmConfig
     const bookPath = joinPath(currentProject.path, "book-analysis", bookId)
     setStyleExtracting(true)
     try {
@@ -246,19 +245,13 @@ export function BookAnalysisResultViewer({ projectPath, result, onClose }: BookA
     }
 
     const storeState = useWikiStore.getState()
-    const baseLlmConfig = storeState.llmConfig
-    if (!baseLlmConfig) {
+    const llmConfig = resolveDefaultModel(storeState.llmConfig)
+    if (!hasUsableLlm(llmConfig, storeState.providerConfigs)) {
       console.log('[单角色提取] 未配置LLM')
       toast.error("未配置 LLM,请先在设置中配置")
       return
     }
 
-    // 解析当前 AI 会话模型配置,确保 apiKey/endpoint 与模型匹配
-    const aiChatModel = storeState.aiChatModel
-    const llmConfig = aiChatModel
-      ? resolveModelConfig(aiChatModel, baseLlmConfig, storeState.providerConfigs)
-      : baseLlmConfig
-
     const bookId = task?.bookId
     if (!bookId) {
       console.log('[单角色提取] 未找到bookId')

+ 9 - 7
src/components/novel/book-analysis-view.tsx

@@ -6,7 +6,7 @@ import { BookAnalysisResultViewer } from "./book-analysis-result-viewer"
 import { ChapterSelectionPanel } from "./chapter-selection-panel"
 import { useBookAnalysisStore } from "@/stores/book-analysis-store"
 import { useWikiStore } from "@/stores/wiki-store"
-import { resolveModelConfig } from "@/lib/novel/model-resolver"
+import { resolveDefaultModel } from "@/lib/novel/model-resolver"
 import {
   toBookAnalysisResult,
   type BookAnalysisLibraryState,
@@ -73,11 +73,13 @@ export function BookAnalysisView() {
 
   // 角色识别 LLM 配置(feature/llm-character-recognizer)
   const baseLlmConfig = useWikiStore((s) => s.llmConfig)
+  const novelConfig = useWikiStore((s) => s.novelConfig)
   const aiChatModel = useWikiStore((s) => s.aiChatModel)
   const providerConfigs = useWikiStore((s) => s.providerConfigs)
-  const llmConfig = aiChatModel
-    ? resolveModelConfig(aiChatModel, baseLlmConfig, providerConfigs)
-    : baseLlmConfig
+  const llmConfig = useMemo(
+    () => resolveDefaultModel(baseLlmConfig),
+    [baseLlmConfig, novelConfig.defaultLlmModel, aiChatModel, providerConfigs],
+  )
 
   // 角色识别 store 状态与 actions(feature/character-recognition-and-simple-mode)
   const recognitionStatus = useBookAnalysisStore((s) => s.recognitionStatus)
@@ -113,6 +115,7 @@ export function BookAnalysisView() {
     setSelectedCharacterId,
     setChapterSelectionData,
     llmConfig,
+    providerConfigs,
     startTask,
   })
 
@@ -133,6 +136,7 @@ export function BookAnalysisView() {
     clearRecognition,
     setRecognitionError: useBookAnalysisStore((s) => s.setRecognitionError),
     llmConfig,
+    providerConfigs,
   })
 
   // 角色特征提取钩子
@@ -218,9 +222,7 @@ export function BookAnalysisView() {
       const { splitNovelIntoChapters } = await import("@/lib/novel/book-analysis/analysis-engine")
       const { useWikiStore } = await import("@/stores/wiki-store")
       const storeState = useWikiStore.getState()
-      const analysisLlmConfig = storeState.aiChatModel
-        ? resolveModelConfig(storeState.aiChatModel, storeState.llmConfig, storeState.providerConfigs)
-        : storeState.llmConfig
+      const analysisLlmConfig = resolveDefaultModel(storeState.llmConfig)
       const updateTaskProgress = useBookAnalysisStore.getState().updateTaskProgress
       const updateTaskMetadata = useBookAnalysisStore.getState().updateTaskMetadata
 

+ 6 - 9
src/components/novel/hooks/use-character-extraction.ts

@@ -1,7 +1,8 @@
 import { useState, useCallback } from "react"
 import { useBookAnalysisStore } from "@/stores/book-analysis-store"
 import { useWikiStore } from "@/stores/wiki-store"
-import { resolveModelConfig } from "@/lib/novel/model-resolver"
+import { resolveDefaultModel } from "@/lib/novel/model-resolver"
+import { hasUsableLlm } from "@/lib/has-usable-llm"
 import { readFile } from "@/commands/fs"
 import { joinPath } from "@/lib/path-utils"
 import { toast } from "@/lib/toast"
@@ -54,9 +55,7 @@ export function useCharacterExtraction({
 
   const resolveLlmConfig = useCallback(() => {
     const storeState = useWikiStore.getState()
-    return storeState.aiChatModel
-      ? resolveModelConfig(storeState.aiChatModel, storeState.llmConfig, storeState.providerConfigs)
-      : storeState.llmConfig
+    return resolveDefaultModel(storeState.llmConfig)
   }, [])
 
   /**
@@ -214,7 +213,7 @@ export function useCharacterExtraction({
         simpleExtractionTotal: userPicked.length,
       })
 
-      if (!llmConfig) {
+      if (!hasUsableLlm(llmConfig, useWikiStore.getState().providerConfigs)) {
         throw new Error("未配置 LLM,请先在设置中配置 LLM 后再提取")
       }
       const { streamChat } = await import("@/lib/llm-client")
@@ -416,10 +415,8 @@ export function useCharacterExtraction({
     const updateTaskCharacters = useBookAnalysisStore.getState().updateTaskCharacters
 
     const resumeStoreState = useWikiStore.getState()
-    const llmConfig = resumeStoreState.aiChatModel
-      ? resolveModelConfig(resumeStoreState.aiChatModel, resumeStoreState.llmConfig, resumeStoreState.providerConfigs)
-      : resumeStoreState.llmConfig
-    if (!llmConfig) {
+    const llmConfig = resolveDefaultModel(resumeStoreState.llmConfig)
+    if (!hasUsableLlm(llmConfig, resumeStoreState.providerConfigs)) {
       alert("未配置 LLM,请先在设置中配置")
       return
     }

+ 4 - 1
src/components/novel/hooks/use-character-recognition.ts

@@ -1,6 +1,7 @@
 import { useCallback } from "react"
 import { useBookAnalysisStore } from "@/stores/book-analysis-store"
 import { useWikiStore } from "@/stores/wiki-store"
+import { hasUsableLlm } from "@/lib/has-usable-llm"
 import { readFile } from "@/commands/fs"
 import { joinPath } from "@/lib/path-utils"
 import { toast } from "@/lib/toast"
@@ -19,6 +20,7 @@ export interface UseCharacterRecognitionParams {
   clearRecognition: () => void
   setRecognitionError: (error?: string) => void
   llmConfig: ReturnType<typeof useWikiStore.getState>["llmConfig"]
+  providerConfigs: ReturnType<typeof useWikiStore.getState>["providerConfigs"]
 }
 
 /**
@@ -39,6 +41,7 @@ export function useCharacterRecognition({
   clearRecognition,
   setRecognitionError,
   llmConfig,
+  providerConfigs,
 }: UseCharacterRecognitionParams) {
   /**
    * 用户在章节选择面板中确认章节后,启动角色识别流程
@@ -79,7 +82,7 @@ export function useCharacterRecognition({
         }
       }
 
-      if (!llmConfig) {
+      if (!hasUsableLlm(llmConfig, providerConfigs)) {
         throw new Error("未配置可用的模型,请先在设置中配置 LLM,再识别角色")
       }
       setRecognitionStatus("llm_recognizing")

+ 5 - 2
src/components/novel/hooks/use-library-operations.ts

@@ -15,6 +15,7 @@ import { refreshProjectState } from "@/lib/project-refresh"
 import { readFile, listDirectory, deleteFile } from "@/commands/fs"
 import { joinPath } from "@/lib/path-utils"
 import { toast } from "@/lib/toast"
+import { hasUsableLlm } from "@/lib/has-usable-llm"
 import type { AnalysisDepth } from "@/lib/novel/book-analysis/types"
 import type { ChapterSelectionData } from "./use-character-extraction"
 
@@ -27,6 +28,7 @@ export interface UseLibraryOperationsParams {
   setSelectedCharacterId: React.Dispatch<React.SetStateAction<string | null>>
   setChapterSelectionData: React.Dispatch<React.SetStateAction<ChapterSelectionData | null>>
   llmConfig: ReturnType<typeof useWikiStore.getState>["llmConfig"]
+  providerConfigs: ReturnType<typeof useWikiStore.getState>["providerConfigs"]
   startTask: (projectPath: string, config: any, abortController?: AbortController) => string
 }
 
@@ -46,6 +48,7 @@ export function useLibraryOperations({
   setSelectedCharacterId,
   setChapterSelectionData,
   llmConfig,
+  providerConfigs,
   startTask,
 }: UseLibraryOperationsParams) {
   const [styleExtracting, setStyleExtracting] = useState(false)
@@ -68,7 +71,7 @@ export function useLibraryOperations({
 
   const handleLibraryExtractStyle = useCallback(async () => {
     if (!currentProjectPath || !selectedLibraryBook || styleExtracting) return
-    if (!llmConfig?.apiKey) {
+    if (!hasUsableLlm(llmConfig, providerConfigs)) {
       toast.error("未配置可用模型,请先在设置中配置 LLM。")
       return
     }
@@ -255,7 +258,7 @@ export function useLibraryOperations({
 
   const handleLibraryReextractCharacters = useCallback(async () => {
     if (!currentProjectPath || !selectedLibraryBook) return
-    if (!llmConfig) {
+    if (!hasUsableLlm(llmConfig, providerConfigs)) {
       toast.error("未配置可用模型,请先在设置中配置 LLM。")
       return
     }

+ 10 - 1
src/components/novel/outline-editor.tsx

@@ -13,6 +13,8 @@ import { Button } from "@/components/ui/button"
 import { Label } from "@/components/ui/label"
 import { useWikiStore } from "@/stores/wiki-store"
 import { streamChat } from "@/lib/llm-client"
+import { resolveDefaultModel } from "@/lib/novel/model-resolver"
+import { hasUsableLlm } from "@/lib/has-usable-llm"
 import { writeFile, listDirectory, createDirectory } from "@/commands/fs"
 import { PROMPTS } from "@/lib/novel/prompt-templates"
 import { normalizePath } from "@/lib/path-utils"
@@ -35,7 +37,8 @@ export function OutlineCreatorDialog({
 }: OutlineCreatorDialogProps) {
   const { t } = useTranslation()
   const project = useWikiStore((s) => s.project)
-  const llmConfig = useWikiStore((s) => s.llmConfig)
+  const baseLlmConfig = useWikiStore((s) => s.llmConfig)
+  const providerConfigs = useWikiStore((s) => s.providerConfigs)
   const setFileTree = useWikiStore((s) => s.setFileTree)
 
   const [outlineType, setOutlineType] = useState<OutlineType>("story-outline")
@@ -79,6 +82,12 @@ export function OutlineCreatorDialog({
       let content = ""
 
       if (useAi) {
+        const llmConfig = resolveDefaultModel(baseLlmConfig)
+        if (!hasUsableLlm(llmConfig, providerConfigs)) {
+          setError("请先在设置中配置可用的 AI 模型")
+          setGenerating(false)
+          return
+        }
         const outlineTypeLabel = t(`novel.outline.type.${outlineType}`)
         const prompt = PROMPTS.outlineGeneration(outlineTypeLabel, "", premise)
 

+ 546 - 0
src/components/novel/story-simulation/branch-compare-view.tsx

@@ -0,0 +1,546 @@
+import { useState, useMemo, useEffect } from "react"
+import { ArrowLeft, BarChart3, Clock, Zap } from "lucide-react"
+import type { SimulationBranch, DirectorScore } from "@/lib/novel/story-simulation/types"
+import { Button } from "@/components/ui/button"
+import { useSimulationWorker } from "@/hooks/use-simulation-worker"
+
+interface BranchCompareViewProps {
+  branches: SimulationBranch[]
+  compareBranchIds: string[]
+  onBack: () => void
+}
+
+const DIMENSION_KEYS: (keyof DirectorScore)[] = [
+  "tension",
+  "pace",
+  "characterUtilization",
+  "characterArc",
+  "infoDensity",
+  "emotionalResonance",
+  "logicConsistency",
+]
+
+const DIMENSION_LABELS: Record<keyof DirectorScore, string> = {
+  tension: "剧情张力",
+  pace: "节奏把控",
+  characterUtilization: "角色发挥",
+  characterArc: "人物弧光",
+  infoDensity: "信息密度",
+  emotionalResonance: "情感共鸣",
+  logicConsistency: "逻辑自洽",
+}
+
+const BRANCH_COLORS = [
+  "#3b82f6",
+  "#f97316",
+  "#10b981",
+]
+
+function getAvgDirectorScore(branch: SimulationBranch): DirectorScore {
+  if (branch.directorEvaluations.length === 0) {
+    return {
+      tension: 3.0,
+      pace: 3.0,
+      characterUtilization: 3.0,
+      characterArc: 3.0,
+      infoDensity: 3.0,
+      emotionalResonance: 3.0,
+      logicConsistency: 3.0,
+    }
+  }
+  const sum: DirectorScore = {
+    tension: 0,
+    pace: 0,
+    characterUtilization: 0,
+    characterArc: 0,
+    infoDensity: 0,
+    emotionalResonance: 0,
+    logicConsistency: 0,
+  }
+  for (const ev of branch.directorEvaluations) {
+    for (const key of DIMENSION_KEYS) {
+      sum[key] += ev.scores[key]
+    }
+  }
+  const n = branch.directorEvaluations.length
+  const avg: DirectorScore = { ...sum }
+  for (const key of DIMENSION_KEYS) {
+    avg[key] = Math.round((avg[key] / n) * 10) / 10
+  }
+  return avg
+}
+
+function RadarChart({
+  branches,
+  size = 320,
+}: {
+  branches: SimulationBranch[]
+  size?: number
+}) {
+  const center = size / 2
+  const radius = size * 0.38
+  const levels = 3
+  const angleStep = (Math.PI * 2) / DIMENSION_KEYS.length
+
+  const scoresList = branches.map((b) => getAvgDirectorScore(b))
+
+  const getPoint = (angle: number, r: number) => ({
+    x: center + r * Math.sin(angle),
+    y: center - r * Math.cos(angle),
+  })
+
+  const gridPolygons = []
+  for (let i = 1; i <= levels; i++) {
+    const r = (radius * i) / levels
+    const points = DIMENSION_KEYS.map((_, idx) => {
+      const { x, y } = getPoint(angleStep * idx, r)
+      return `${x},${y}`
+    }).join(" ")
+    gridPolygons.push(
+      <polygon
+        key={`grid-${i}`}
+        points={points}
+        fill="none"
+        stroke="currentColor"
+        strokeOpacity={0.15}
+        strokeWidth={1}
+      />,
+    )
+  }
+
+  const axisLines = DIMENSION_KEYS.map((_, idx) => {
+    const { x, y } = getPoint(angleStep * idx, radius)
+    return (
+      <line
+        key={`axis-${idx}`}
+        x1={center}
+        y1={center}
+        x2={x}
+        y2={y}
+        stroke="currentColor"
+        strokeOpacity={0.15}
+        strokeWidth={1}
+      />
+    )
+  })
+
+  const labelElements = DIMENSION_KEYS.map((key, idx) => {
+    const { x, y } = getPoint(angleStep * idx, radius + 24)
+    return (
+      <text
+        key={`label-${idx}`}
+        x={x}
+        y={y}
+        textAnchor="middle"
+        dominantBaseline="middle"
+        className="fill-current text-[11px] text-foreground"
+      >
+        {DIMENSION_LABELS[key]}
+      </text>
+    )
+  })
+
+  const dataPolygons = scoresList.map((scores, bIdx) => {
+    const points = DIMENSION_KEYS.map((key, idx) => {
+      const value = Math.max(0, Math.min(5, scores[key]))
+      const r = (radius * value) / 5
+      const { x, y } = getPoint(angleStep * idx, r)
+      return `${x},${y}`
+    }).join(" ")
+    return (
+      <polygon
+        key={`data-${bIdx}`}
+        points={points}
+        fill={BRANCH_COLORS[bIdx]}
+        fillOpacity={0.15}
+        stroke={BRANCH_COLORS[bIdx]}
+        strokeWidth={2}
+      />
+    )
+  })
+
+  const dataDots = scoresList.map((scores, bIdx) =>
+    DIMENSION_KEYS.map((key, idx) => {
+      const value = Math.max(0, Math.min(5, scores[key]))
+      const r = (radius * value) / 5
+      const { x, y } = getPoint(angleStep * idx, r)
+      return (
+        <circle
+          key={`dot-${bIdx}-${idx}`}
+          cx={x}
+          cy={y}
+          r={3}
+          fill={BRANCH_COLORS[bIdx]}
+        />
+      )
+    }),
+  )
+
+  return (
+    <svg width={size} height={size} className="text-foreground">
+      {gridPolygons}
+      {axisLines}
+      {labelElements}
+      {dataPolygons}
+      {dataDots}
+    </svg>
+  )
+}
+
+function ScoreCompareTab({ branches }: { branches: SimulationBranch[] }) {
+  const scoresList = branches.map((b) => getAvgDirectorScore(b))
+
+  return (
+    <div className="space-y-6">
+      <div className="flex justify-center">
+        <RadarChart branches={branches} size={360} />
+      </div>
+
+      <div className="flex justify-center gap-4 flex-wrap">
+        {branches.map((b, idx) => (
+          <div key={b.id} className="flex items-center gap-2">
+            <div
+              className="h-3 w-3 rounded-full"
+              style={{ backgroundColor: BRANCH_COLORS[idx] }}
+            />
+            <span className="text-xs">{b.name}</span>
+          </div>
+        ))}
+      </div>
+
+      <div className="overflow-x-auto">
+        <table className="w-full border-collapse text-sm">
+          <thead>
+            <tr className="border-b">
+              <th className="px-3 py-2 text-left font-medium text-muted-foreground">维度</th>
+              {branches.map((b) => (
+                <th key={b.id} className="px-3 py-2 text-center font-medium">
+                  {b.name}
+                </th>
+              ))}
+              <th className="px-3 py-2 text-center font-medium text-muted-foreground">最大差异</th>
+            </tr>
+          </thead>
+          <tbody>
+            {DIMENSION_KEYS.map((key) => {
+              const values = scoresList.map((s) => s[key])
+              const max = Math.max(...values)
+              const min = Math.min(...values)
+              const diff = Math.round((max - min) * 10) / 10
+              return (
+                <tr key={key} className="border-b hover:bg-muted/30">
+                  <td className="px-3 py-2">{DIMENSION_LABELS[key]}</td>
+                  {values.map((v, idx) => (
+                    <td key={idx} className="px-3 py-2 text-center font-medium">
+                      <span
+                        style={{ color: v === max ? BRANCH_COLORS[idx] : undefined }}
+                      >
+                        {v.toFixed(1)}
+                      </span>
+                    </td>
+                  ))}
+                  <td className="px-3 py-2 text-center text-muted-foreground">
+                    {diff > 0 ? `+${diff}` : "-"}
+                  </td>
+                </tr>
+              )
+            })}
+            <tr className="border-t-2 border-primary/30 bg-primary/5 font-medium">
+              <td className="px-3 py-2">综合评分</td>
+              {branches.map((b) => (
+                <td key={b.id} className="px-3 py-2 text-center text-primary">
+                  {b.overallScore.toFixed(1)}
+                </td>
+              ))}
+              <td className="px-3 py-2 text-center text-muted-foreground">
+                +{(Math.max(...branches.map((b) => b.overallScore)) - Math.min(...branches.map((b) => b.overallScore))).toFixed(1)}
+              </td>
+            </tr>
+          </tbody>
+        </table>
+      </div>
+    </div>
+  )
+}
+
+function TimelineCompareTab({ branches }: { branches: SimulationBranch[] }) {
+  const groupedList = branches.map((b) => {
+    const groups = new Map<number, typeof b.timelineEvents>()
+    for (const ev of b.timelineEvents) {
+      if (!groups.has(ev.round)) groups.set(ev.round, [])
+      groups.get(ev.round)!.push(ev)
+    }
+    return {
+      branch: b,
+      rounds: Array.from(groups.entries())
+        .sort(([a], [b]) => a - b)
+        .map(([round, events]) => ({ round, events })),
+    }
+  })
+
+  return (
+    <div className="grid gap-4" style={{ gridTemplateColumns: `repeat(${branches.length}, minmax(0, 1fr))` }}>
+      {groupedList.map(({ branch, rounds }, bIdx) => (
+        <div key={branch.id} className="flex min-h-0 flex-col rounded-lg border bg-muted/20">
+          <div className="border-b px-3 py-2 text-center">
+            <div className="text-sm font-medium">{branch.name}</div>
+            <div className="text-xs text-primary">综合 {branch.overallScore.toFixed(1)} 分</div>
+          </div>
+          <div className="min-h-0 flex-1 overflow-y-auto p-3">
+            {rounds.length === 0 ? (
+              <div className="py-8 text-center text-xs text-muted-foreground">
+                暂无事件
+              </div>
+            ) : (
+              <div className="space-y-3">
+                {rounds.map(({ round, events }) => (
+                  <div key={round} className="space-y-1.5">
+                    <div className="text-[11px] font-medium text-muted-foreground">
+                      第 {round + 1} 轮 · {events.length} 条事件
+                    </div>
+                    {events.map((ev) => (
+                      <div
+                        key={ev.id}
+                        className="rounded-md border bg-background/70 p-2 text-xs leading-relaxed"
+                      >
+                        <div className="mb-0.5">
+                          <span className="font-medium" style={{ color: BRANCH_COLORS[bIdx] }}>
+                            {ev.actorName}
+                          </span>
+                          <span className="text-muted-foreground">
+                            {" "}
+                            {ev.targetName ? `对 ${ev.targetName}` : ""} · {ev.actionType}
+                          </span>
+                        </div>
+                        <div className="line-clamp-3 text-muted-foreground">
+                          {ev.content}
+                        </div>
+                      </div>
+                    ))}
+                  </div>
+                ))}
+              </div>
+            )}
+          </div>
+        </div>
+      ))}
+    </div>
+  )
+}
+
+function KeyDifferencesTab({ branches }: { branches: SimulationBranch[] }) {
+  const { calcBranchDiff } = useSimulationWorker()
+  const [diffResult, setDiffResult] = useState<{
+    dimensionDiffs: { key: string; diff: number; maxBranchName: string; maxValue: number; minValue: number }[]
+    eventCounts: number[]
+    characterCounts: number[]
+    topSentimentDiffs: { charId: string; charName: string; maxDiff: number; maxBranch: string; values: number[] }[]
+    divergenceRound: number
+    bestBranchIdx: number
+  } | null>(null)
+  const [loading, setLoading] = useState(true)
+
+  useEffect(() => {
+    if (branches.length < 2) {
+      setLoading(false)
+      return
+    }
+    setLoading(true)
+    calcBranchDiff(branches[0], branches[1]).then((result) => {
+      setDiffResult(result)
+      setLoading(false)
+    })
+  }, [branches, calcBranchDiff])
+
+  if (loading || !diffResult) {
+    return (
+      <div className="flex h-40 items-center justify-center">
+        <div className="text-sm text-muted-foreground">分析中...</div>
+      </div>
+    )
+  }
+
+  const { dimensionDiffs: rawDimensionDiffs, eventCounts, characterCounts, topSentimentDiffs, divergenceRound, bestBranchIdx } = diffResult
+
+  const dimensionDiffs = rawDimensionDiffs.map((d) => ({
+    ...d,
+    label: DIMENSION_LABELS[d.key as keyof DirectorScore],
+  }))
+
+  const bestBranch = branches[bestBranchIdx]
+  const secondBestIdx = branches.findIndex((_, i) => i !== bestBranchIdx)
+  const secondBest = secondBestIdx >= 0 ? branches[secondBestIdx] : null
+
+  return (
+    <div className="space-y-6">
+      <div className="grid gap-4 md:grid-cols-2">
+        <div className="rounded-lg border bg-muted/20 p-4">
+          <h3 className="mb-3 text-sm font-medium">评分差异最大的 3 个维度</h3>
+          <div className="space-y-2">
+            {dimensionDiffs.map((d, idx) => (
+              <div key={d.key} className="flex items-center justify-between text-sm">
+                <span className="text-muted-foreground">
+                  {idx + 1}. {d.label}
+                </span>
+                <span className="font-medium">
+                  {d.maxBranchName} 领先 {d.maxValue.toFixed(1)} vs {d.minValue.toFixed(1)}
+                </span>
+              </div>
+            ))}
+          </div>
+        </div>
+
+        <div className="rounded-lg border bg-muted/20 p-4">
+          <h3 className="mb-3 text-sm font-medium">基础数据对比</h3>
+          <div className="space-y-2 text-sm">
+            <div className="flex justify-between">
+              <span className="text-muted-foreground">事件数量</span>
+              <span className="font-medium">
+                {eventCounts.join(" / ")} 条
+              </span>
+            </div>
+            <div className="flex justify-between">
+              <span className="text-muted-foreground">角色数量</span>
+              <span className="font-medium">
+                {characterCounts.join(" / ")} 人
+              </span>
+            </div>
+            <div className="flex justify-between">
+              <span className="text-muted-foreground">分岔轮次</span>
+              <span className="font-medium">
+                {divergenceRound > 0 ? `第 ${divergenceRound} 轮开始` : "无明显分岔"}
+              </span>
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <div className="rounded-lg border bg-muted/20 p-4">
+        <h3 className="mb-3 text-sm font-medium">角色好感度差异最大的 3 对</h3>
+        {topSentimentDiffs.length === 0 ? (
+          <div className="text-sm text-muted-foreground">暂无数据</div>
+        ) : (
+          <div className="space-y-2">
+            {topSentimentDiffs.map((d, idx) => (
+              <div key={d.charId} className="flex items-center justify-between text-sm">
+                <span className="text-muted-foreground">
+                  {idx + 1}. {d.charName}
+                </span>
+                <span className="font-medium">
+                  {d.maxBranch} 最高 ({d.values.map((v) => v.toFixed(0)).join(" / ")})
+                </span>
+              </div>
+            ))}
+          </div>
+        )}
+      </div>
+
+      <div className="rounded-lg border border-primary/30 bg-primary/5 p-4">
+        <h3 className="mb-2 text-sm font-medium text-primary">
+          推荐结论:综合推荐 {bestBranch.name}
+        </h3>
+        <div className="text-sm text-muted-foreground">
+          <div className="mb-1 font-medium text-foreground">主要优势:</div>
+          <div className="space-y-1">
+            {dimensionDiffs.slice(0, 3).map((d, idx) => (
+              <div key={d.key}>
+                {idx + 1}. {d.label}更出色({d.maxValue.toFixed(1)} vs {d.minValue.toFixed(1)})
+              </div>
+            ))}
+            {eventCounts[bestBranchIdx] >= (secondBest ? eventCounts[secondBestIdx] : 0) && (
+              <div>
+                {dimensionDiffs.length + 1}. 事件数量更丰富({eventCounts[bestBranchIdx]} 条)
+              </div>
+            )}
+          </div>
+        </div>
+      </div>
+    </div>
+  )
+}
+
+export function BranchCompareView({
+  branches,
+  compareBranchIds,
+  onBack,
+}: BranchCompareViewProps) {
+  const [activeTab, setActiveTab] = useState<"score" | "timeline" | "differences">("score")
+
+  const compareBranches = useMemo(() => {
+    return compareBranchIds
+      .map((id) => branches.find((b) => b.id === id))
+      .filter((b): b is SimulationBranch => !!b)
+  }, [branches, compareBranchIds])
+
+  if (compareBranches.length < 2) {
+    return (
+      <div className="flex h-full items-center justify-center">
+        <div className="text-center text-sm text-muted-foreground">
+          请选择至少 2 个分支进行对比
+        </div>
+      </div>
+    )
+  }
+
+  const tabs = [
+    { key: "score" as const, label: "评分对比", icon: BarChart3 },
+    { key: "timeline" as const, label: "时间线对比", icon: Clock },
+    { key: "differences" as const, label: "关键差异", icon: Zap },
+  ]
+
+  return (
+    <div className="flex h-full min-h-0 flex-col">
+      <div className="flex items-center gap-3 border-b px-4 py-3">
+        <Button
+          type="button"
+          variant="ghost"
+          size="sm"
+          onClick={onBack}
+          className="h-8"
+        >
+          <ArrowLeft className="h-4 w-4 mr-1" />
+          返回分支管理
+        </Button>
+        <div className="flex items-center gap-2">
+          {compareBranches.map((b, idx) => (
+            <span
+              key={b.id}
+              className="rounded-full px-2.5 py-1 text-xs font-medium"
+              style={{
+                backgroundColor: `${BRANCH_COLORS[idx]}20`,
+                color: BRANCH_COLORS[idx],
+              }}
+            >
+              {b.name}
+            </span>
+          ))}
+        </div>
+        <div className="ml-auto">
+          <div className="inline-flex rounded-md border bg-muted/40 p-0.5 text-xs">
+            {tabs.map(({ key, label, icon: Icon }) => (
+              <button
+                key={key}
+                type="button"
+                className={`flex items-center gap-1.5 rounded px-3 py-1.5 ${
+                  activeTab === key
+                    ? "bg-background text-foreground shadow-sm"
+                    : "text-muted-foreground hover:text-foreground"
+                }`}
+                onClick={() => setActiveTab(key)}
+              >
+                <Icon className="h-3.5 w-3.5" />
+                {label}
+              </button>
+            ))}
+          </div>
+        </div>
+      </div>
+
+      <div className="min-h-0 flex-1 overflow-y-auto p-4">
+        {activeTab === "score" && <ScoreCompareTab branches={compareBranches} />}
+        {activeTab === "timeline" && <TimelineCompareTab branches={compareBranches} />}
+        {activeTab === "differences" && <KeyDifferencesTab branches={compareBranches} />}
+      </div>
+    </div>
+  )
+}

+ 330 - 0
src/components/novel/story-simulation/detective-board-panel.tsx

@@ -0,0 +1,330 @@
+import { useState, useMemo } from "react"
+import { Eye, EyeOff, Filter, MessageSquare, Zap, Clock } from "lucide-react"
+import type { RumorEvent, NovelAgent, TimelineEvent } from "@/lib/novel/story-simulation/types"
+
+interface ClueTimelinePanelProps {
+  agents: Map<string, NovelAgent>
+  rumors: RumorEvent[]
+  events: TimelineEvent[]
+}
+
+type ClueType = "event" | "rumor"
+type FilterAgent = "all" | string
+
+interface ClueItem {
+  id: string
+  type: ClueType
+  nodeIndex: number
+  round: number
+  timestamp: number
+  title: string
+  content: string
+  actorName?: string
+  targetName?: string
+  knowAgents: string[]
+  unknowAgents: string[]
+  isSecret: boolean
+}
+
+export function ClueTimelinePanel({ agents, rumors, events }: ClueTimelinePanelProps) {
+  const [filterType, setFilterType] = useState<"all" | ClueType>("all")
+  const [filterAgent, setFilterAgent] = useState<FilterAgent>("all")
+  const [filterNode, setFilterNode] = useState<"all" | number>("all")
+  const [showOnlyUnknown, setShowOnlyUnknown] = useState(false)
+
+  const agentList = useMemo(() => Array.from(agents.values()), [agents])
+
+  const nodeList = useMemo(() => {
+    const nodes = new Set<number>()
+    events.forEach((e) => nodes.add(e.nodeIndex))
+    rumors.forEach((r) => nodes.add(r.nodeIndex))
+    return Array.from(nodes).sort((a, b) => a - b)
+  }, [events, rumors])
+
+  const clues = useMemo<ClueItem[]>(() => {
+    const allAgentIds = agentList.map((a) => a.characterId)
+
+    const eventClues: ClueItem[] = events
+      .filter((e) => e.content && e.content.trim())
+      .map((e) => {
+        const knowAgents = e.observableBy || []
+        const isPublic = knowAgents.length === allAgentIds.length
+        const unknowAgents = allAgentIds.filter((id) => !knowAgents.includes(id))
+
+        return {
+          id: `ev_${e.id}`,
+          type: "event" as ClueType,
+          nodeIndex: e.nodeIndex,
+          round: e.round,
+          timestamp: new Date(e.timestamp).getTime(),
+          title: e.actionType,
+          content: e.content,
+          actorName: e.actorName,
+          targetName: e.targetName,
+          knowAgents,
+          unknowAgents,
+          isSecret: !isPublic && knowAgents.length <= 2,
+        }
+      })
+
+    const rumorClues: ClueItem[] = rumors.map((r) => {
+      const knowAgents = r.observableBy || []
+      const unknowAgents = allAgentIds.filter((id) => !knowAgents.includes(id))
+      const spreaderName = r.spreadBy ? agents.get(r.spreadBy)?.name : null
+      const sourceName = r.sourceId ? agents.get(r.sourceId)?.name : null
+
+      return {
+        id: `rumor_${r.id}`,
+        type: "rumor" as ClueType,
+        nodeIndex: r.nodeIndex,
+        round: r.round,
+        timestamp: new Date(r.timestamp).getTime(),
+        title: sourceName ? `传闻(源自${sourceName})` : "传闻",
+        content: r.content,
+        actorName: spreaderName || sourceName || undefined,
+        knowAgents,
+        unknowAgents,
+        isSecret: knowAgents.length <= 2,
+      }
+    })
+
+    return [...eventClues, ...rumorClues].sort((a, b) => {
+      if (a.nodeIndex !== b.nodeIndex) return a.nodeIndex - b.nodeIndex
+      if (a.round !== b.round) return a.round - b.round
+      return a.timestamp - b.timestamp
+    })
+  }, [events, rumors, agentList])
+
+  const filteredClues = useMemo(() => {
+    return clues.filter((clue) => {
+      if (filterType !== "all" && clue.type !== filterType) return false
+      if (filterNode !== "all" && clue.nodeIndex !== filterNode) return false
+      if (filterAgent !== "all") {
+        const knows = clue.knowAgents.includes(filterAgent)
+        if (showOnlyUnknown) {
+          if (knows) return false
+        } else {
+          if (!knows && !clue.knowAgents.includes(filterAgent)) return false
+        }
+      }
+      return true
+    })
+  }, [clues, filterType, filterAgent, filterNode, showOnlyUnknown])
+
+  const groupedByNode = useMemo(() => {
+    const groups = new Map<number, ClueItem[]>()
+    for (const clue of filteredClues) {
+      if (!groups.has(clue.nodeIndex)) groups.set(clue.nodeIndex, [])
+      groups.get(clue.nodeIndex)!.push(clue)
+    }
+    return Array.from(groups.entries()).sort(([a], [b]) => a - b)
+  }, [filteredClues])
+
+  const getAgentName = (id: string) => {
+    return agents.get(id)?.name || id
+  }
+
+  const typeInfo = (type: ClueType) => {
+    if (type === "event") {
+      return { label: "事件", icon: Zap, color: "text-blue-600 bg-blue-100 dark:bg-blue-900/40 dark:text-blue-400" }
+    }
+    return { label: "传闻", icon: MessageSquare, color: "text-amber-600 bg-amber-100 dark:bg-amber-900/40 dark:text-amber-400" }
+  }
+
+  if (clues.length === 0) {
+    return (
+      <div className="flex h-full items-center justify-center text-sm text-muted-foreground">
+        暂无线索数据,开始推演后会自动生成
+      </div>
+    )
+  }
+
+  return (
+    <div className="flex h-full flex-col">
+      {/* 筛选栏 */}
+      <div className="flex shrink-0 flex-wrap items-center gap-2 border-b p-2 text-xs">
+        <div className="flex items-center gap-1 text-muted-foreground">
+          <Filter className="h-3.5 w-3.5" />
+          <span>筛选</span>
+        </div>
+
+        {/* 类型筛选 */}
+        <div className="inline-flex rounded-md border bg-muted/40 p-0.5">
+          <button
+            type="button"
+            className={`rounded px-2 py-1 ${filterType === "all" ? "bg-background shadow-sm" : "text-muted-foreground hover:text-foreground"}`}
+            onClick={() => setFilterType("all")}
+          >
+            全部
+          </button>
+          <button
+            type="button"
+            className={`flex items-center gap-1 rounded px-2 py-1 ${filterType === "event" ? "bg-background shadow-sm" : "text-muted-foreground hover:text-foreground"}`}
+            onClick={() => setFilterType("event")}
+          >
+            <Zap className="h-3 w-3" />
+            事件
+          </button>
+          <button
+            type="button"
+            className={`flex items-center gap-1 rounded px-2 py-1 ${filterType === "rumor" ? "bg-background shadow-sm" : "text-muted-foreground hover:text-foreground"}`}
+            onClick={() => setFilterType("rumor")}
+          >
+            <MessageSquare className="h-3 w-3" />
+            传闻
+          </button>
+        </div>
+
+        {/* 角色筛选 */}
+        <select
+          className="rounded border bg-background px-2 py-1 text-xs"
+          value={filterAgent}
+          onChange={(e) => setFilterAgent(e.target.value)}
+        >
+          <option value="all">全部角色</option>
+          {agentList.map((a) => (
+            <option key={a.characterId} value={a.characterId}>{a.name}</option>
+          ))}
+        </select>
+
+        {/* 节点筛选 */}
+        <select
+          className="rounded border bg-background px-2 py-1 text-xs"
+          value={filterNode === "all" ? "all" : String(filterNode)}
+          onChange={(e) => setFilterNode(e.target.value === "all" ? "all" : Number(e.target.value))}
+        >
+          <option value="all">全部节点</option>
+          {nodeList.map((n) => (
+            <option key={n} value={n}>节点 {n + 1}</option>
+          ))}
+        </select>
+
+        {filterAgent !== "all" && (
+          <label className="flex items-center gap-1 text-muted-foreground">
+            <input
+              type="checkbox"
+              checked={showOnlyUnknown}
+              onChange={(e) => setShowOnlyUnknown(e.target.checked)}
+              className="h-3 w-3"
+            />
+            只看不知道的
+          </label>
+        )}
+
+        <div className="ml-auto text-[11px] text-muted-foreground">
+          共 {filteredClues.length} 条线索
+        </div>
+      </div>
+
+      {/* 时间线列表 */}
+      <div className="min-h-0 flex-1 overflow-y-auto p-3">
+        {groupedByNode.length === 0 ? (
+          <div className="flex h-full items-center justify-center text-sm text-muted-foreground">
+            没有符合筛选条件的线索
+          </div>
+        ) : (
+          <div className="space-y-4">
+            {groupedByNode.map(([nodeIndex, nodeClues]) => (
+              <div key={nodeIndex}>
+                <div className="mb-2 flex items-center gap-2">
+                  <Clock className="h-3.5 w-3.5 text-primary" />
+                  <span className="text-xs font-medium text-primary">
+                    节点 {nodeIndex + 1}
+                  </span>
+                  <span className="text-[11px] text-muted-foreground">
+                    {nodeClues.length} 条线索
+                  </span>
+                </div>
+                <div className="space-y-2 pl-5 border-l-2 border-muted">
+                  {nodeClues.map((clue) => {
+                    const info = typeInfo(clue.type)
+                    const Icon = info.icon
+                    return (
+                      <div
+                        key={clue.id}
+                        className="relative rounded-md border bg-background/70 p-2.5 text-xs"
+                      >
+                        <div
+                          className="absolute -left-[7px] top-3 h-3 w-3 rounded-full border-2 border-background"
+                          style={{ background: clue.isSecret ? "#f59e0b" : "hsl(var(--primary))" }}
+                        />
+
+                        <div className="mb-1.5 flex flex-wrap items-center gap-2">
+                          <span className={`inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-medium ${info.color}`}>
+                            <Icon className="h-2.5 w-2.5" />
+                            {info.label}
+                          </span>
+                          <span className="text-[11px] text-muted-foreground">
+                            第 {clue.round + 1} 轮
+                          </span>
+                          {clue.isSecret && (
+                            <span className="inline-flex items-center gap-1 rounded bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium text-amber-600 dark:text-amber-400">
+                              <EyeOff className="h-2.5 w-2.5" />
+                              秘密
+                            </span>
+                          )}
+                        </div>
+
+                        {clue.actorName && (
+                          <div className="mb-1 text-[11px] font-medium text-foreground">
+                            {clue.actorName}
+                            {clue.targetName && <> → {clue.targetName}</>}
+                          </div>
+                        )}
+
+                        <div className="mb-2 leading-relaxed text-foreground/90">
+                          {clue.content}
+                        </div>
+
+                        <div className="flex flex-wrap items-start gap-1.5 border-t pt-1.5">
+                          <Eye className="mt-0.5 h-3 w-3 shrink-0 text-emerald-500" />
+                          <div className="flex flex-wrap gap-1">
+                            {clue.knowAgents.length === 0 ? (
+                              <span className="text-[10px] text-muted-foreground">无人知晓</span>
+                            ) : clue.knowAgents.length === agentList.length ? (
+                              <span className="text-[10px] text-emerald-600 dark:text-emerald-400">所有人都知道</span>
+                            ) : (
+                              clue.knowAgents.map((id) => (
+                                <span
+                                  key={id}
+                                  className="rounded bg-emerald-500/10 px-1.5 py-0.5 text-[10px] text-emerald-700 dark:text-emerald-400"
+                                >
+                                  {getAgentName(id)}
+                                </span>
+                              ))
+                            )}
+                          </div>
+                        </div>
+
+                        {clue.unknowAgents.length > 0 && clue.unknowAgents.length < agentList.length && (
+                          <div className="flex flex-wrap items-start gap-1.5 mt-1">
+                            <EyeOff className="mt-0.5 h-3 w-3 shrink-0 text-muted-foreground" />
+                            <div className="flex flex-wrap gap-1">
+                              {clue.unknowAgents.slice(0, 5).map((id) => (
+                                <span
+                                  key={id}
+                                  className="rounded bg-muted/50 px-1.5 py-0.5 text-[10px] text-muted-foreground"
+                                >
+                                  {getAgentName(id)}
+                                </span>
+                              ))}
+                              {clue.unknowAgents.length > 5 && (
+                                <span className="text-[10px] text-muted-foreground">
+                                  +{clue.unknowAgents.length - 5}人
+                                </span>
+                              )}
+                            </div>
+                          </div>
+                        )}
+                      </div>
+                    )
+                  })}
+                </div>
+              </div>
+            ))}
+          </div>
+        )}
+      </div>
+    </div>
+  )
+}

+ 3 - 3
src/components/settings/llm-wiki-model-settings.spec.ts

@@ -22,8 +22,8 @@ function preset(id: string) {
   return found
 }
 
-describe("LLM Wiki model settings copied into QMAI", () => {
-  it("includes the LLM Wiki provider rows below the custom row", () => {
+describe("QMAI model settings", () => {
+  it("includes the built-in provider rows below the custom row", () => {
     expect(LLM_PRESETS[0]?.id).toBe("custom")
 
     expect(LLM_PRESETS.map((item) => item.id)).toEqual([
@@ -107,7 +107,7 @@ describe("LLM Wiki model settings copied into QMAI", () => {
     expect(codex.codexCliTimeoutMinutes).toBe(45)
   })
 
-  it("has Chinese labels for the copied LLM Wiki settings instead of placeholder question marks", () => {
+  it("has Chinese labels for the built-in model settings instead of placeholder question marks", () => {
     const llm = zh.settings.sections.llm
     expect(llm.collapse).toBe("收起配置")
     expect(llm.apiKeyPlaceholder).toBe("输入 API Key")

+ 50 - 24
src/components/settings/sections/changelog-section.tsx

@@ -2,6 +2,11 @@ import { useState, useRef } from "react"
 import { useTranslation } from "react-i18next"
 import { RefreshCw, Download, CheckCircle, AlertCircle } from "lucide-react"
 import { allChangelog } from "@/lib/changelog"
+import {
+  APP_AUTO_UPDATE_RELEASES_URL,
+  APP_AUTO_UPDATE_UNSUPPORTED_MESSAGE,
+  isAppAutoUpdateSupported,
+} from "@/lib/app-update-support"
 import { isTauri } from "@/lib/platform"
 import { formatUpdateErrorMessage } from "@/lib/update-error-message"
 
@@ -19,6 +24,7 @@ export function ChangelogSection() {
   const [errorMessage, setErrorMessage] = useState("")
   const [downloadProgress, setDownloadProgress] = useState(0)
   const updateHandleRef = useRef<unknown>(null)
+  const autoUpdateSupported = isAppAutoUpdateSupported()
 
   async function handleCheckUpdate() {
     if (!isTauri()) {
@@ -26,6 +32,11 @@ export function ChangelogSection() {
       setErrorMessage("仅桌面版支持自动更新检测")
       return
     }
+    if (!autoUpdateSupported) {
+      setUpdateStatus("error")
+      setErrorMessage(APP_AUTO_UPDATE_UNSUPPORTED_MESSAGE)
+      return
+    }
     setUpdateStatus("checking")
     setErrorMessage("")
     setDownloadProgress(0)
@@ -104,31 +115,46 @@ export function ChangelogSection() {
 
       {/* 检查更新区域 */}
       <div className="rounded-lg border border-border p-4">
-        <div className="flex items-center gap-3">
-          <button
-            type="button"
-            onClick={() => void handleCheckUpdate()}
-            disabled={updateStatus === "checking" || updateStatus === "downloading"}
-            className="inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
-          >
-            <RefreshCw className={`h-4 w-4 ${updateStatus === "checking" ? "animate-spin" : ""}`} />
-            {updateStatus === "checking" ? "正在检查..." : "检查更新"}
-          </button>
+        {autoUpdateSupported ? (
+          <div className="flex items-center gap-3">
+            <button
+              type="button"
+              onClick={() => void handleCheckUpdate()}
+              disabled={updateStatus === "checking" || updateStatus === "downloading"}
+              className="inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
+            >
+              <RefreshCw className={`h-4 w-4 ${updateStatus === "checking" ? "animate-spin" : ""}`} />
+              {updateStatus === "checking" ? "正在检查..." : "检查更新"}
+            </button>
 
-          {updateStatus === "up-to-date" ? (
-            <span className="inline-flex items-center gap-1.5 text-sm text-emerald-600 dark:text-emerald-400">
-              <CheckCircle className="h-4 w-4" />
-              当前已是最新版本
-            </span>
-          ) : null}
+            {updateStatus === "up-to-date" ? (
+              <span className="inline-flex items-center gap-1.5 text-sm text-emerald-600 dark:text-emerald-400">
+                <CheckCircle className="h-4 w-4" />
+                当前已是最新版本
+              </span>
+            ) : null}
 
-          {updateStatus === "error" ? (
-            <span className="inline-flex items-center gap-1.5 text-sm text-red-600 dark:text-red-400">
-              <AlertCircle className="h-4 w-4" />
-              {errorMessage || "检查更新失败"}
-            </span>
-          ) : null}
-        </div>
+            {updateStatus === "error" ? (
+              <span className="inline-flex items-center gap-1.5 text-sm text-red-600 dark:text-red-400">
+                <AlertCircle className="h-4 w-4" />
+                {errorMessage || "检查更新失败"}
+              </span>
+            ) : null}
+          </div>
+        ) : (
+          <p className="text-sm leading-relaxed text-muted-foreground">
+            {APP_AUTO_UPDATE_UNSUPPORTED_MESSAGE}
+            {" "}
+            <a
+              href={APP_AUTO_UPDATE_RELEASES_URL}
+              target="_blank"
+              rel="noreferrer"
+              className="font-medium text-primary hover:underline"
+            >
+              GitHub Releases
+            </a>
+          </p>
+        )}
 
         {updateStatus === "available" ? (
           <div className="mt-3 rounded-md border border-amber-200 bg-amber-50 p-3 dark:border-amber-900/60 dark:bg-amber-950/40">
@@ -209,7 +235,7 @@ export function ChangelogSection() {
                 </span>
                 <span className="text-xs text-muted-foreground">{entry.date}</span>
                 {entry.version === __APP_VERSION__ ? (
-                  <span className="text-xs text-emerald-600 dark:text-emerald-400">\u2190 当前版本</span>
+                  <span className="text-xs text-emerald-600 dark:text-emerald-400"> 当前版本</span>
                 ) : null}
               </div>
               <ul className="mt-3 space-y-2 text-sm leading-relaxed text-foreground/90">

+ 492 - 108
src/components/settings/sections/maintenance-section.tsx

@@ -18,21 +18,53 @@ import {
 } from "lucide-react"
 import { Button } from "@/components/ui/button"
 import { Label } from "@/components/ui/label"
+import { ChatModelSelector } from "@/components/chat/chat-model-selector"
 import { useWikiStore } from "@/stores/wiki-store"
+import { getFirstAvailableModelKey } from "@/lib/llm-model-keys"
 import { hasUsableLlm } from "@/lib/has-usable-llm"
-import { resolveDefaultModel } from "@/lib/novel/model-resolver"
-import { runDuplicateDetection } from "@/lib/dedup-runner"
+import { normalizePath } from "@/lib/path-utils"
+import { resolveDefaultModel, resolveModelConfig } from "@/lib/novel/model-resolver"
+import { runDuplicateDetection, type DedupScanStage } from "@/lib/dedup-runner"
 import { addNotDuplicate } from "@/lib/dedup-storage"
+import {
+  loadDedupScanCache,
+  saveDedupScanCache,
+  type DedupScanCacheEntry,
+} from "@/lib/dedup-scan-cache"
+import { toast } from "@/lib/toast"
 import {
   enqueueMerge,
   cancelTask,
   retryTask,
   getQueue,
+  getMergeProgress,
   groupKey,
+  ensureQueueActive,
+  onDedupMergeComplete,
   type DedupTask,
 } from "@/lib/dedup-queue"
+import type { DedupMergeStage } from "@/lib/dedup-runner"
+import type { WikiProject } from "@/types/wiki"
 import type { DuplicateGroup } from "@/lib/dedup"
 
+function confidenceRank(confidence: DuplicateGroup["confidence"]): number {
+  switch (confidence) {
+    case "high":
+      return 0
+    case "medium":
+      return 1
+    case "low":
+      return 2
+  }
+}
+
+function sortGroupEntriesByConfidence(entries: GroupUiEntry[]): GroupUiEntry[] {
+  return [...entries].sort(
+    (a, b) =>
+      confidenceRank(a.group.confidence) - confidenceRank(b.group.confidence),
+  )
+}
+
 interface GroupUiEntry {
   group: DuplicateGroup
   canonicalSlug: string
@@ -42,29 +74,72 @@ interface GroupUiEntry {
 }
 
 interface MaintenanceScanState {
+  projectId: string | null
   projectPath: string | null
   scanning: boolean
   scanError: string | null
   groups: GroupUiEntry[]
   scanCompleted: boolean
+  scannedPageCount: number | null
 }
 
 const emptyScanState: MaintenanceScanState = {
+  projectId: null,
   projectPath: null,
   scanning: false,
   scanError: null,
   groups: [],
   scanCompleted: false,
+  scannedPageCount: null,
 }
 
-let sharedScanState: MaintenanceScanState = emptyScanState
-const scanListeners = new Set<(state: MaintenanceScanState) => void>()
+function normalizeProjectPath(path: string): string {
+  return normalizePath(path).replace(/\/+$/, "")
+}
+
+function scanStateBelongsToProject(
+  state: Pick<MaintenanceScanState, "projectId" | "projectPath">,
+  project: WikiProject,
+): boolean {
+  if (state.projectId && state.projectId === project.id) return true
+  if (!state.projectPath) return false
+  return normalizeProjectPath(state.projectPath) === normalizeProjectPath(project.path)
+}
 
 function setSharedScanState(patch: Partial<MaintenanceScanState>): void {
-  sharedScanState = { ...sharedScanState, ...patch }
+  const normalizedPatch = patch.projectPath
+    ? { ...patch, projectPath: normalizeProjectPath(patch.projectPath) }
+    : patch
+  sharedScanState = { ...sharedScanState, ...normalizedPatch }
+  for (const listener of scanListeners) listener(sharedScanState)
+}
+
+function replaceSharedScanState(state: MaintenanceScanState): void {
+  sharedScanState = {
+    ...state,
+    projectPath: state.projectPath ? normalizeProjectPath(state.projectPath) : state.projectPath,
+  }
   for (const listener of scanListeners) listener(sharedScanState)
 }
 
+function toScanCache(
+  state: MaintenanceScanState,
+  projectId: string,
+  modelId: string,
+): Parameters<typeof saveDedupScanCache>[1] {
+  return {
+    version: 1,
+    projectId,
+    scannedAt: Date.now(),
+    scannedPageCount: state.scannedPageCount,
+    modelId: modelId.trim() || undefined,
+    groups: state.groups as DedupScanCacheEntry[],
+  }
+}
+
+let sharedScanState: MaintenanceScanState = emptyScanState
+const scanListeners = new Set<(state: MaintenanceScanState) => void>()
+
 function subscribeScanState(listener: (state: MaintenanceScanState) => void): () => void {
   scanListeners.add(listener)
   listener(sharedScanState)
@@ -84,81 +159,306 @@ export function MaintenanceSection() {
   const { t } = useTranslation()
   const llmConfig = useWikiStore((s) => s.llmConfig)
   const providerConfigs = useWikiStore((s) => s.providerConfigs)
+  const novelConfig = useWikiStore((s) => s.novelConfig)
+  const defaultLlmModel = novelConfig.defaultLlmModel
+  const aiChatModel = useWikiStore((s) => s.aiChatModel)
   const project = useWikiStore((s) => s.project)
 
+  const [dedupModelId, setDedupModelId] = useState("")
+  const [isScanning, setIsScanning] = useState(false)
+  const [scanStage, setScanStage] = useState<DedupScanStage | null>(null)
   const [scanState, setScanState] = useState<MaintenanceScanState>(sharedScanState)
+  const [localScanState, setLocalScanState] = useState<MaintenanceScanState | null>(null)
 
   useEffect(() => subscribeScanState(setScanState), [])
 
+  useEffect(() => {
+    if (!project) {
+      setLocalScanState(null)
+      return
+    }
+    let cancelled = false
+    void loadDedupScanCache(project.path).then((cached) => {
+      if (cancelled) return
+      if (
+        scanStateBelongsToProject(sharedScanState, project) &&
+        (sharedScanState.scanning || sharedScanState.scanCompleted)
+      ) {
+        setLocalScanState({ ...sharedScanState })
+        return
+      }
+      if (!cached || cached.projectId !== project.id) {
+        setLocalScanState(null)
+        return
+      }
+      if (cached.modelId?.trim()) {
+        setDedupModelId((current) => current || cached.modelId!.trim())
+      }
+      const hydrated: MaintenanceScanState = {
+        projectId: project.id,
+        projectPath: normalizeProjectPath(project.path),
+        scanning: false,
+        scanError: null,
+        groups: cached.groups,
+        scanCompleted: true,
+        scannedPageCount: cached.scannedPageCount,
+      }
+      replaceSharedScanState(hydrated)
+      setLocalScanState(hydrated)
+    })
+    return () => {
+      cancelled = true
+    }
+  }, [project?.id, project?.path])
+
+  useEffect(() => {
+    if (!project) {
+      setIsScanning(false)
+      return
+    }
+    const activeState =
+      localScanState && scanStateBelongsToProject(localScanState, project)
+        ? localScanState
+        : scanStateBelongsToProject(scanState, project)
+          ? scanState
+          : null
+    setIsScanning(!!activeState?.scanning)
+  }, [project, localScanState, scanState])
+
+  useEffect(() => {
+    setDedupModelId((current) => {
+      if (current.trim()) return current
+      const preferred = defaultLlmModel.trim() || aiChatModel.trim()
+      if (preferred) return preferred
+      return getFirstAvailableModelKey(providerConfigs)
+    })
+  }, [defaultLlmModel, aiChatModel, providerConfigs])
+
+  const hasAvailableModels = useMemo(() => {
+    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
+  }, [providerConfigs])
+
   // Poll the queue at 1Hz so the UI reflects pending → processing →
   // failed transitions and cross-window queue activity (e.g. a merge
   // that completed while the user was on a different settings tab).
   // Same pattern activity-panel uses for ingest-queue.
   const [tasks, setTasks] = useState<readonly DedupTask[]>([])
+  const [mergeProgress, setMergeProgress] = useState<{
+    taskId: string
+    stage: DedupMergeStage
+  } | null>(null)
+  const [enqueueingKey, setEnqueueingKey] = useState<string | null>(null)
+  const [mergeErrors, setMergeErrors] = useState<Record<string, string>>({})
+
   useEffect(() => {
-    setTasks([...getQueue()])
-    const id = setInterval(() => setTasks([...getQueue()]), 1000)
-    return () => clearInterval(id)
-  }, [])
+    if (!project) {
+      setTasks([])
+      setMergeProgress(null)
+      return
+    }
+    let cancelled = false
+    void ensureQueueActive(project.id, project.path)
+      .then(() => {
+        if (!cancelled) {
+          setTasks([...getQueue()])
+          setMergeProgress(getMergeProgress())
+        }
+      })
+      .catch((err) => {
+        console.error("[Maintenance] ensureQueueActive failed:", err)
+      })
+    const id = setInterval(() => {
+      setTasks([...getQueue()])
+      setMergeProgress(getMergeProgress())
+    }, 500)
+    return () => {
+      cancelled = true
+      clearInterval(id)
+    }
+  }, [project?.id, project?.path])
 
-  const resolvedLlmConfig = useMemo(() => resolveDefaultModel(llmConfig), [llmConfig])
-  const llmReady = hasUsableLlm(resolvedLlmConfig, providerConfigs)
+  const effectiveDedupConfig = useMemo(
+    () =>
+      dedupModelId.trim()
+        ? resolveModelConfig(dedupModelId, llmConfig, providerConfigs)
+        : resolveDefaultModel(llmConfig),
+    [dedupModelId, llmConfig, providerConfigs],
+  )
+  const llmReady = hasUsableLlm(effectiveDedupConfig, providerConfigs)
   const projectReady = !!project
-  const projectScanState = project && scanState.projectPath === project.path ? scanState : emptyScanState
-  const { scanning, scanError, groups, scanCompleted } = projectScanState
+  const activeScanState = useMemo(() => {
+    if (!project) return emptyScanState
+    if (localScanState && scanStateBelongsToProject(localScanState, project)) {
+      return localScanState
+    }
+    if (scanStateBelongsToProject(scanState, project)) {
+      return scanState
+    }
+    return emptyScanState
+  }, [project, localScanState, scanState])
+  const scanning = isScanning || activeScanState.scanning
+  const { scanError, groups, scanCompleted, scannedPageCount } = activeScanState
+
+  const applyScanState = useCallback(
+    (patch: Partial<MaintenanceScanState>, options?: { persist?: boolean }) => {
+      setSharedScanState(patch)
+      const next = { ...sharedScanState }
+      setLocalScanState({ ...next })
+      if (
+        options?.persist !== false &&
+        project &&
+        next.projectId === project.id &&
+        next.scanCompleted &&
+        !next.scanning
+      ) {
+        void saveDedupScanCache(
+          project.path,
+          toScanCache(next, project.id, dedupModelId),
+        ).catch((err) => {
+          console.error("[Maintenance] save scan cache failed:", err)
+        })
+      }
+    },
+    [project, dedupModelId],
+  )
+
+  const removeMergedGroup = useCallback(
+    (slugs: readonly string[]) => {
+      const active = useWikiStore.getState().project
+      if (!active || !scanStateBelongsToProject(sharedScanState, active)) return
+      const key = groupKey(slugs)
+      const remaining = sharedScanState.groups.filter((g) => groupKey(g.group.slugs) !== key)
+      if (remaining.length === sharedScanState.groups.length) return
+      applyScanState({ groups: remaining })
+    },
+    [applyScanState],
+  )
+
+  useEffect(() => {
+    if (!project) return
+    return onDedupMergeComplete((task) => {
+      if (task.projectId !== project.id) return
+      removeMergedGroup(task.group.slugs)
+      setTasks([...getQueue()])
+    })
+  }, [project?.id, removeMergedGroup])
 
   const handleScan = useCallback(async () => {
     if (!project) return
-    setSharedScanState({
-      projectPath: project.path,
+    const projectPath = normalizeProjectPath(project.path)
+    const projectId = project.id
+
+    if (!hasUsableLlm(effectiveDedupConfig, providerConfigs)) {
+      applyScanState({
+        projectId,
+        projectPath,
+        scanning: false,
+        scanError: t("settings.sections.maintenance.dedup.selectModel", {
+          defaultValue: "请先选择检测模型。",
+        }),
+        groups: [],
+        scanCompleted: false,
+        scannedPageCount: null,
+      })
+      return
+    }
+
+    setIsScanning(true)
+    setScanStage("loading")
+    applyScanState({
+      projectId,
+      projectPath,
       scanning: true,
       scanError: null,
       groups: [],
       scanCompleted: false,
+      scannedPageCount: null,
     })
     try {
-      const detected = await runDuplicateDetection(project.path, llmConfig)
-      setSharedScanState({
-        projectPath: project.path,
+      const { groups: detected, scannedPageCount } = await runDuplicateDetection(
+        projectPath,
+        effectiveDedupConfig,
+        { onProgress: setScanStage },
+      )
+      applyScanState({
+        projectId,
+        projectPath,
+        scanning: false,
         groups: detected.map((g) => ({
           group: g,
           canonicalSlug: g.slugs[0],
           skipped: false,
         })),
         scanCompleted: true,
+        scannedPageCount,
       })
     } catch (err) {
-      setSharedScanState({
-        projectPath: project.path,
+      applyScanState({
+        projectId,
+        projectPath,
+        scanning: false,
         scanError: err instanceof Error ? err.message : String(err),
+        scanCompleted: false,
+        scannedPageCount: null,
       })
     } finally {
-      setSharedScanState({ projectPath: project.path, scanning: false })
+      setIsScanning(false)
+      setScanStage(null)
     }
-  }, [project, llmConfig])
+  }, [project, effectiveDedupConfig, providerConfigs, t, applyScanState])
 
   const handleCanonicalChange = useCallback(
     (idx: number, slug: string) => {
-      setSharedScanState({
-        groups: sharedScanState.groups.map((g, i) => (i === idx ? { ...g, canonicalSlug: slug } : g)),
-      })
+      const source =
+        localScanState && project && scanStateBelongsToProject(localScanState, project)
+          ? localScanState
+          : sharedScanState
+      const groups = source.groups.map((g, i) => (i === idx ? { ...g, canonicalSlug: slug } : g))
+      applyScanState({ groups })
     },
-    [],
+    [applyScanState, localScanState, project],
   )
 
   const handleEnqueue = useCallback(
     async (entry: GroupUiEntry) => {
       if (!project) return
+      const key = groupKey(entry.group.slugs)
+      setEnqueueingKey(key)
+      setMergeErrors((prev) => {
+        if (!prev[key]) return prev
+        const next = { ...prev }
+        delete next[key]
+        return next
+      })
       try {
-        await enqueueMerge(project.id, entry.group, entry.canonicalSlug)
-        // Refresh immediately so the card flips to "queued" without
-        // waiting for the next 1s poll tick.
+        await enqueueMerge(
+          project.id,
+          entry.group,
+          entry.canonicalSlug,
+          dedupModelId.trim() || undefined,
+        )
         setTasks([...getQueue()])
       } catch (err) {
+        const message = err instanceof Error ? err.message : String(err)
         console.error("[Maintenance] enqueue failed:", err)
+        setMergeErrors((prev) => ({ ...prev, [key]: message }))
+        toast.error(message)
+      } finally {
+        setEnqueueingKey(null)
       }
     },
-    [project],
+    [project, dedupModelId],
   )
 
   const handleCancel = useCallback(async (taskId: string) => {
@@ -178,54 +478,25 @@ export function MaintenanceSection() {
       if (!entry) return
       try {
         await addNotDuplicate(project.path, entry.group.slugs)
-        setSharedScanState({
-          groups: sharedScanState.groups.map((g, i) => (i === idx ? { ...g, skipped: true } : g)),
+        const source =
+          localScanState && scanStateBelongsToProject(localScanState, project)
+            ? localScanState
+            : sharedScanState
+        applyScanState({
+          groups: source.groups.map((g, i) => (i === idx ? { ...g, skipped: true } : g)),
         })
       } catch (err) {
         console.error("[Maintenance] addNotDuplicate failed:", err)
       }
     },
-    [project, groups],
+    [project, groups, applyScanState, localScanState],
   )
 
-  // Drive each card's status from the queue.
-  // - Card not in queue + not skipped → idle, can merge / dismiss
-  // - Task pending → "Queued (N ahead)"
-  // - Task processing → "Merging…"
-  // - Task gone (after success) → "Merged" (queue removes done tasks
-  //     immediately, so we only know it succeeded if we observed it
-  //     in-flight before. Track that with a session-local set.)
-  // - Task failed → show error + retry / delete.
-  const [recentlyMergedKeys, setRecentlyMergedKeys] = useState<Set<string>>(
-    () => new Set(),
+  const visibleGroups = useMemo(
+    () => sortGroupEntriesByConfidence(groups.filter((entry) => !entry.skipped)),
+    [groups],
   )
 
-  useEffect(() => {
-    // Detect transitions out of the queue: a slug-set we saw last
-    // tick is now gone → it completed (cancelled paths also remove,
-    // but only with explicit user action that re-renders separately).
-    setRecentlyMergedKeys((prev) => {
-      const currentKeys = new Set(tasks.map((t) => groupKey(t.group.slugs)))
-      let changed = false
-      const next = new Set(prev)
-      for (const g of groups) {
-        const k = groupKey(g.group.slugs)
-        const wasInFlight = lastSeenTaskKeysRef.current.has(k)
-        if (wasInFlight && !currentKeys.has(k) && !next.has(k)) {
-          next.add(k)
-          changed = true
-        }
-      }
-      lastSeenTaskKeysRef.current = currentKeys
-      return changed ? next : prev
-    })
-    // We intentionally only re-run when tasks change — the closure
-    // over `groups` is fine because newly-scanned groups can't be
-    // "recently merged" until they've been observed in-flight first.
-    // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [tasks])
-  const lastSeenTaskKeysRef = useRefInit<Set<string>>(() => new Set())
-
   // Pending position helper: "queued (N ahead)" — count pending tasks
   // before this one in arrival order.
   const pendingPositionByTaskId = useMemo(() => {
@@ -280,17 +551,43 @@ export function MaintenanceSection() {
             })}
           </p>
         )}
-        {projectReady && !llmReady && (
+        {projectReady && !hasAvailableModels && (
           <p className="text-xs text-amber-700 dark:text-amber-400">
-            {t("settings.sections.maintenance.noLlm", {
-              defaultValue: "请先配置大模型提供方。",
+            {t("settings.sections.maintenance.dedup.noModel", {
+              defaultValue: "请先在「设置 → 大语言模型」中添加并启用一个模型。",
             })}
           </p>
         )}
+        {projectReady && hasAvailableModels && !llmReady && (
+          <p className="text-xs text-amber-700 dark:text-amber-400">
+            {dedupModelId.trim()
+              ? t("settings.sections.maintenance.noLlm", {
+                  defaultValue: "请先配置大模型提供方。",
+                })
+              : t("settings.sections.maintenance.dedup.selectModel", {
+                  defaultValue: "请先选择检测模型。",
+                })}
+          </p>
+        )}
+
+        {projectReady && hasAvailableModels && (
+          <div className="space-y-1.5">
+            <Label className="text-xs">
+              {t("settings.sections.maintenance.dedup.modelLabel", {
+                defaultValue: "检测模型",
+              })}
+            </Label>
+            <ChatModelSelector
+              value={dedupModelId}
+              onChange={setDedupModelId}
+              disabled={scanning}
+            />
+          </div>
+        )}
 
         <Button
           onClick={() => void handleScan()}
-          disabled={scanning || !projectReady || !llmReady}
+          disabled={scanning || !projectReady || !hasAvailableModels || !llmReady}
         >
           {scanning ? (
             <>
@@ -306,6 +603,26 @@ export function MaintenanceSection() {
           )}
         </Button>
 
+        {scanning && (
+          <div className="flex items-start gap-1.5 rounded border border-border/60 bg-background/80 px-2 py-1.5 text-xs text-muted-foreground">
+            <Loader2 className="mt-0.5 h-3.5 w-3.5 shrink-0 animate-spin" />
+            <div>
+              {scanStage === "loading"
+                ? t("settings.sections.maintenance.dedup.scanStageLoading", {
+                    defaultValue: "正在读取实体 / 概念页面…",
+                  })
+                : scanStage === "detecting"
+                  ? t("settings.sections.maintenance.dedup.scanStageDetecting", {
+                      defaultValue: "正在调用模型分析…",
+                    })
+                  : t("settings.sections.maintenance.dedup.scanningHint", {
+                      defaultValue:
+                        "正在扫描实体 / 概念页面并调用模型分析,可能需要一会儿…",
+                    })}
+            </div>
+          </div>
+        )}
+
         {scanError && (
           <div className="flex items-start gap-1.5 rounded border border-rose-500/40 bg-rose-500/5 px-2 py-1.5 text-xs text-rose-700 dark:text-rose-400">
             <XCircle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
@@ -313,7 +630,31 @@ export function MaintenanceSection() {
           </div>
         )}
 
-        {scanCompleted && groups.length === 0 && !scanError && (
+        {scanCompleted && visibleGroups.length > 0 && !scanError && (
+          <div className="flex items-start gap-1.5 rounded border border-sky-500/40 bg-sky-500/5 px-2 py-1.5 text-xs text-sky-800 dark:text-sky-300">
+            <CheckCircle2 className="mt-0.5 h-3.5 w-3.5 shrink-0" />
+            <div>
+              {t("settings.sections.maintenance.dedup.groupsFound", {
+                count: visibleGroups.length,
+                defaultValue: "发现 {{count}} 组重复候选,请在下方确认是否合并。",
+              })}
+            </div>
+          </div>
+        )}
+
+        {scanCompleted && scannedPageCount !== null && scannedPageCount < 2 && !scanError && (
+          <div className="flex items-start gap-1.5 rounded border border-amber-500/40 bg-amber-500/5 px-2 py-1.5 text-xs text-amber-700 dark:text-amber-400">
+            <AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
+            <div>
+              {t("settings.sections.maintenance.dedup.insufficientPages", {
+                count: scannedPageCount,
+                defaultValue: "至少需要 2 个实体 / 概念页面才能检测重复,当前只有 {{count}} 个。",
+              })}
+            </div>
+          </div>
+        )}
+
+        {scanCompleted && visibleGroups.length === 0 && groups.length === 0 && !scanError && scannedPageCount !== null && scannedPageCount >= 2 && (
           <div className="flex items-start gap-1.5 rounded border border-emerald-500/40 bg-emerald-500/5 px-2 py-1.5 text-xs text-emerald-700 dark:text-emerald-400">
             <CheckCircle2 className="mt-0.5 h-3.5 w-3.5 shrink-0" />
             <div>
@@ -328,20 +669,24 @@ export function MaintenanceSection() {
       <QueueOrphanList
         tasks={tasks}
         groups={groups}
+        mergeProgress={mergeProgress}
         onCancel={(id) => void handleCancel(id)}
         onRetry={(id) => void handleRetry(id)}
         pendingPositionByTaskId={pendingPositionByTaskId}
       />
 
-      {groups.map((entry, idx) => {
+      {visibleGroups.map((entry) => {
+        const entryKey = groupKey(entry.group.slugs)
+        const idx = groups.findIndex((g) => groupKey(g.group.slugs) === entryKey)
         const task = findTaskForGroup(tasks, entry.group.slugs)
-        const merged = recentlyMergedKeys.has(groupKey(entry.group.slugs))
         return (
           <DuplicateGroupCard
             key={entry.group.slugs.join(",")}
             entry={entry}
             task={task}
-            merged={merged}
+            mergeProgress={mergeProgress}
+            enqueueing={enqueueingKey === entryKey}
+            mergeError={mergeErrors[entryKey] ?? null}
             pendingPosition={
               task && task.status === "pending"
                 ? pendingPositionByTaskId.get(task.id) ?? 0
@@ -361,20 +706,10 @@ export function MaintenanceSection() {
 
 // --- helpers ---------------------------------------------------------------
 
-/** A useRef variant that initializes lazily — avoids constructing a new
- *  Set on every render. Kept inline since it's only used here. */
-function useRefInit<T>(init: () => T): { current: T } {
-  // `useState` returning a ref-shaped object lets us mutate `.current`
-  // without triggering re-renders, which is exactly the ref semantics
-  // we want for the "last seen task keys" tracking above.
-  // eslint-disable-next-line react-hooks/rules-of-hooks
-  const [ref] = useState<{ current: T }>(() => ({ current: init() }))
-  return ref
-}
-
 interface QueueOrphanListProps {
   tasks: readonly DedupTask[]
   groups: GroupUiEntry[]
+  mergeProgress: { taskId: string; stage: DedupMergeStage } | null
   onCancel: (taskId: string) => void
   onRetry: (taskId: string) => void
   pendingPositionByTaskId: Map<string, number>
@@ -390,6 +725,7 @@ interface QueueOrphanListProps {
 function QueueOrphanList({
   tasks,
   groups,
+  mergeProgress,
   onCancel,
   onRetry,
   pendingPositionByTaskId,
@@ -430,6 +766,9 @@ function QueueOrphanList({
             <TaskStatusChip
               task={task}
               pendingPosition={pendingPositionByTaskId.get(task.id) ?? 0}
+              mergeStage={
+                mergeProgress?.taskId === task.id ? mergeProgress.stage : null
+              }
             />
             {task.status === "failed" && (
               <Button
@@ -464,17 +803,42 @@ function QueueOrphanList({
 interface ChipProps {
   task: DedupTask
   pendingPosition: number
+  mergeStage?: DedupMergeStage | null
+}
+
+function mergeStageLabel(
+  stage: DedupMergeStage,
+  t: ReturnType<typeof useTranslation>["t"],
+): string {
+  switch (stage) {
+    case "loading":
+      return t("settings.sections.maintenance.dedup.mergeStageLoading", {
+        defaultValue: "正在读取 wiki…",
+      })
+    case "merging":
+      return t("settings.sections.maintenance.dedup.mergeStageMerging", {
+        defaultValue: "正在合并内容(LLM)…",
+      })
+    case "writing":
+      return t("settings.sections.maintenance.dedup.mergeStageWriting", {
+        defaultValue: "正在写入文件…",
+      })
+  }
 }
 
-function TaskStatusChip({ task, pendingPosition }: ChipProps) {
+function TaskStatusChip({ task, pendingPosition, mergeStage }: ChipProps) {
   const { t } = useTranslation()
   if (task.status === "processing") {
+    const label =
+      mergeStage != null
+        ? mergeStageLabel(mergeStage, t)
+        : t("settings.sections.maintenance.dedup.merging", {
+            defaultValue: "合并中...",
+          })
     return (
       <span className="inline-flex items-center gap-1 rounded bg-amber-500/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase text-amber-700 dark:text-amber-400">
         <Loader2 className="h-3 w-3 animate-spin" />
-        {t("settings.sections.maintenance.dedup.merging", {
-          defaultValue: "合并中...",
-        })}
+        {label}
       </span>
     )
   }
@@ -514,7 +878,9 @@ function TaskStatusChip({ task, pendingPosition }: ChipProps) {
 interface CardProps {
   entry: GroupUiEntry
   task: DedupTask | undefined
-  merged: boolean
+  mergeProgress: { taskId: string; stage: DedupMergeStage } | null
+  enqueueing: boolean
+  mergeError: string | null
   pendingPosition: number
   onCanonicalChange: (slug: string) => void
   onEnqueue: () => void
@@ -526,7 +892,9 @@ interface CardProps {
 function DuplicateGroupCard({
   entry,
   task,
-  merged,
+  mergeProgress,
+  enqueueing,
+  mergeError,
   pendingPosition,
   onCanonicalChange,
   onEnqueue,
@@ -539,7 +907,7 @@ function DuplicateGroupCard({
 
   const inFlight = !!task && (task.status === "pending" || task.status === "processing")
   const failed = !!task && task.status === "failed"
-  const finished = merged || skipped
+  const finished = skipped
 
   const confidenceClass =
     group.confidence === "high"
@@ -564,12 +932,6 @@ function DuplicateGroupCard({
             n: group.slugs.length,
           })}
         </span>
-        {merged && (
-          <span className="ml-auto inline-flex items-center gap-1 text-xs text-emerald-700 dark:text-emerald-400">
-            <CheckCircle2 className="h-3.5 w-3.5" />
-            {t("settings.sections.maintenance.dedup.merged", { defaultValue: "已合并" })}
-          </span>
-        )}
         {skipped && (
           <span className="ml-auto inline-flex items-center gap-1 text-xs text-muted-foreground">
             {t("settings.sections.maintenance.dedup.skipped", { defaultValue: "已标记为不重复" })}
@@ -577,7 +939,13 @@ function DuplicateGroupCard({
         )}
         {task && !finished && (
           <span className="ml-auto">
-            <TaskStatusChip task={task} pendingPosition={pendingPosition} />
+            <TaskStatusChip
+              task={task}
+              pendingPosition={pendingPosition}
+              mergeStage={
+                mergeProgress?.taskId === task.id ? mergeProgress.stage : null
+              }
+            />
           </span>
         )}
       </div>
@@ -614,13 +982,22 @@ function DuplicateGroupCard({
           <div className="flex flex-wrap gap-2">
             {!task && (
               <>
-                <Button size="sm" onClick={onEnqueue}>
-                  {t("settings.sections.maintenance.dedup.mergeButton", {
-                    defaultValue: "合并到 {{slug}}",
-                    slug: canonicalSlug,
-                  })}
+                <Button size="sm" onClick={onEnqueue} disabled={enqueueing}>
+                  {enqueueing ? (
+                    <>
+                      <Loader2 className="h-3.5 w-3.5 animate-spin" />
+                      {t("settings.sections.maintenance.dedup.enqueueing", {
+                        defaultValue: "加入队列...",
+                      })}
+                    </>
+                  ) : (
+                    t("settings.sections.maintenance.dedup.mergeButton", {
+                      defaultValue: "合并到 {{slug}}",
+                      slug: canonicalSlug,
+                    })
+                  )}
                 </Button>
-                <Button size="sm" variant="ghost" onClick={onNotDuplicate}>
+                <Button size="sm" variant="ghost" onClick={onNotDuplicate} disabled={enqueueing}>
                   {t("settings.sections.maintenance.dedup.notDuplicates", {
                     defaultValue: "不是重复",
                   })}
@@ -655,6 +1032,13 @@ function DuplicateGroupCard({
         </>
       )}
 
+      {mergeError && !task && (
+        <div className="flex items-start gap-1.5 rounded border border-rose-500/40 bg-rose-500/5 px-2 py-1.5 text-xs text-rose-700 dark:text-rose-400">
+          <AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
+          <div>{mergeError}</div>
+        </div>
+      )}
+
       {failed && task?.error && (
         <div className="flex items-start gap-1.5 rounded border border-rose-500/40 bg-rose-500/5 px-2 py-1.5 text-xs text-rose-700 dark:text-rose-400">
           <AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />

+ 162 - 68
src/components/settings/sections/novel-section.tsx

@@ -1,4 +1,4 @@
-import { useMemo, useState } from "react"
+import { useMemo, useState, type ReactNode } from "react"
 import { useTranslation } from "react-i18next"
 import { Info } from "lucide-react"
 import { Label } from "@/components/ui/label"
@@ -6,7 +6,8 @@ import { Input } from "@/components/ui/input"
 import { Button } from "@/components/ui/button"
 import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
 import { useWikiStore } from "@/stores/wiki-store"
-import { saveNovelConfig } from "@/lib/project-store"
+import { saveNovelConfig, saveDefaultLlmModel } from "@/lib/project-store"
+import { getFirstAvailableModelKey, hasAvailableModels } from "@/lib/llm-model-keys"
 
 import { testNovelModel, type TestableNovelModelTask } from "@/lib/novel/novel-model-test"
 import { ChatModelSelector } from "@/components/chat/chat-model-selector"
@@ -18,21 +19,111 @@ interface Props {
   setDraft: DraftSetter
 }
 
+const MODEL_PICKER_BLOCK_CLASS = "space-y-3 rounded-lg border border-border/60 p-4"
+
+interface NovelModelPickerBlockProps {
+  title: string
+  hintKey?: string
+  footnote?: string
+  followChecked: boolean
+  onFollowChange: (checked: boolean) => void
+  modelValue: string
+  onModelChange: (model: string) => void
+  renderHint?: (hintKey: string) => ReactNode
+  testState?: { loading: boolean; message: string; success: boolean }
+  onTest?: () => void
+  testLoadingLabel: string
+  testLabel: string
+  followLabel: string
+}
+
+function NovelModelPickerBlock({
+  title,
+  hintKey,
+  footnote,
+  followChecked,
+  onFollowChange,
+  modelValue,
+  onModelChange,
+  renderHint,
+  testState,
+  onTest,
+  testLoadingLabel,
+  testLabel,
+  followLabel,
+}: NovelModelPickerBlockProps) {
+  return (
+    <div className={MODEL_PICKER_BLOCK_CLASS}>
+      <div className="flex items-center gap-1.5">
+        <Label>{title}</Label>
+        {hintKey && renderHint?.(hintKey)}
+      </div>
+      <div className="flex flex-wrap items-center gap-2">
+        <label className="flex shrink-0 items-center gap-2">
+          <input
+            type="checkbox"
+            checked={followChecked}
+            onChange={(e) => onFollowChange(e.target.checked)}
+            className="h-4 w-4"
+          />
+          <span className="text-sm">{followLabel}</span>
+        </label>
+        <ChatModelSelector
+          value={modelValue}
+          onChange={onModelChange}
+          disabled={followChecked}
+        />
+        {onTest ? (
+          <Button
+            type="button"
+            size="sm"
+            variant="outline"
+            disabled={testState?.loading}
+            onClick={onTest}
+          >
+            {testState?.loading ? testLoadingLabel : testLabel}
+          </Button>
+        ) : null}
+      </div>
+      {footnote ? (
+        <p className="text-xs leading-5 text-muted-foreground/80">{footnote}</p>
+      ) : null}
+      {testState?.message ? (
+        <p className={`text-xs ${testState.success ? "text-emerald-600" : "text-destructive"}`}>
+          {testState.message}
+        </p>
+      ) : null}
+    </div>
+  )
+}
+
 export function NovelSection({ draft, setDraft }: Props) {
   const { t } = useTranslation()
   const setNovelConfigStore = useWikiStore((s) => s.setNovelConfig)
   const llmConfig = useWikiStore((s) => s.llmConfig)
   const aiChatModel = useWikiStore((s) => s.aiChatModel)
+  const providerConfigs = useWikiStore((s) => s.providerConfigs)
   const project = useWikiStore((s) => s.project)
+  const defaultLlmModel = draft.novelConfig.defaultLlmModel
+  const isWorkflowModelFollowingChat = !defaultLlmModel.trim()
+  const displayWorkflowDefaultModel = isWorkflowModelFollowingChat ? "" : defaultLlmModel
+
+  const modelsAvailable = useMemo(
+    () => hasAvailableModels(providerConfigs),
+    [providerConfigs],
+  )
+
   const [testStates, setTestStates] = useState<Record<TestableNovelModelTask, {
     loading: boolean
     message: string
     success: boolean
   } | undefined>>({
     writing: undefined,
+    workflow: undefined,
     review: undefined,
     summary: undefined,
     extract: undefined,
+    deAi: undefined,
   })
 
   const updateNovelConfig = async (patch: Partial<NovelConfig>) => {
@@ -42,14 +133,16 @@ export function NovelSection({ draft, setDraft }: Props) {
     await saveNovelConfig(newConfig, project?.id, project?.path)
   }
 
+  const updateWorkflowDefaultModel = async (model: string) => {
+    await updateNovelConfig({ defaultLlmModel: model })
+    await saveDefaultLlmModel(model)
+  }
+
   const modelItems = useMemo(() => ([
-    { task: "review", field: "reviewModel", wrapperClassName: "space-y-2" },
-    { task: "summary", field: "summaryModel", wrapperClassName: "space-y-2" },
-    {
-      task: "extract",
-      field: "extractModel",
-      wrapperClassName: "space-y-2 rounded-md border border-primary/30 bg-primary/5 p-3",
-    },
+    { task: "review", field: "reviewModel" },
+    { task: "summary", field: "summaryModel" },
+    { task: "extract", field: "extractModel" },
+    { task: "deAi", field: "deAiModel" },
   ] as const), [])
 
   const settingTooltip = (key: string) => (
@@ -126,6 +219,32 @@ export function NovelSection({ draft, setDraft }: Props) {
       <div className="space-y-2">
         <Label>{t("novel.settings.title")}</Label>
         <div className="grid gap-4 rounded-lg border p-4">
+          {modelsAvailable && (
+            <NovelModelPickerBlock
+              title={t("novel.settings.defaultLlmModel")}
+              hintKey="defaultLlmModelHint"
+              footnote={t("novel.settings.defaultLlmModelScopeNote")}
+              followChecked={isWorkflowModelFollowingChat}
+              onFollowChange={(checked) => {
+                if (checked) {
+                  void updateWorkflowDefaultModel("")
+                } else {
+                  void updateWorkflowDefaultModel(
+                    aiChatModel.trim() || getFirstAvailableModelKey(providerConfigs),
+                  )
+                }
+              }}
+              modelValue={displayWorkflowDefaultModel}
+              onModelChange={(model) => void updateWorkflowDefaultModel(model)}
+              renderHint={settingTooltip}
+              testState={testStates.workflow}
+              onTest={() => runModelTest("workflow")}
+              followLabel={t("novel.settings.followChatModel")}
+              testLoadingLabel={t("novel.settings.testingModel")}
+              testLabel={t("novel.settings.testModel")}
+            />
+          )}
+
           <div className="space-y-2">
             <div className="flex items-center gap-1.5">
               <Label>{t("novel.settings.recentSummaryWindow")}</Label>
@@ -400,6 +519,13 @@ export function NovelSection({ draft, setDraft }: Props) {
             </>
           )}
 
+          <div className="space-y-1 border-t border-border/60 pt-4">
+            <Label>{t("novel.settings.taskModelsTitle")}</Label>
+            <p className="text-xs leading-5 text-muted-foreground">
+              {t("novel.settings.taskModelsHint")}
+            </p>
+          </div>
+
           {modelItems.map((item) => {
             const state = testStates[item.task]
             const modelValue = draft.novelConfig[item.field] || ""
@@ -407,65 +533,33 @@ export function NovelSection({ draft, setDraft }: Props) {
             const displayValue = isFollowingChat ? "" : modelValue
 
             return (
-              <div key={item.task} className={item.wrapperClassName}>
-                <div className="flex items-center gap-1.5">
-                  <Label>{t(`novel.settings.${item.field}`)}</Label>
-                  {settingTooltip(`${item.field}Hint`)}
-                </div>
-                <div className="flex flex-wrap items-center gap-2">
-                  <div className="flex items-center gap-2">
-                    <label className="flex items-center gap-2">
-                      <input
-                        type="checkbox"
-                        checked={isFollowingChat}
-                        onChange={(e) => {
-                          if (e.target.checked) {
-                            // 勾选:清空模型值,跟随 AI 会话模型
-                            updateNovelConfig({
-                              [item.field]: "",
-                            } as Partial<NovelConfig>)
-                          } else {
-                        // 取消勾选:使用 AI 会话当前模型作为默认值,若为空则填入占位值让用户手动选择
-                        updateNovelConfig({
-                          [item.field]: aiChatModel || " ",
-                        } as Partial<NovelConfig>)
-                      }
-                        }}
-                        className="h-4 w-4"
-                      />
-                      <span className="text-sm">
-                        {t("novel.settings.followChatModel")}
-                      </span>
-                    </label>
-                    <ChatModelSelector
-                      value={displayValue}
-                      onChange={(model) => updateNovelConfig({
-                        [item.field]: model,
-                      } as Partial<NovelConfig>)}
-                      disabled={isFollowingChat}
-                    />
-                  </div>
-                  <Button
-                    type="button"
-                    size="sm"
-                    variant="outline"
-                    disabled={state?.loading}
-                    onClick={() => runModelTest(item.task)}
-                  >
-                    {state?.loading ? t("novel.settings.testingModel") : t("novel.settings.testModel")}
-                  </Button>
-                </div>
-                {item.task === "extract" ? (
-                  <p className="text-xs leading-5 text-muted-foreground">
-                    {t("novel.settings.extractModelHint")}
-                  </p>
-                ) : null}
-                {state?.message ? (
-                  <p className={`text-xs ${state.success ? "text-emerald-600" : "text-destructive"}`}>
-                    {state.message}
-                  </p>
-                ) : null}
-              </div>
+              <NovelModelPickerBlock
+                key={item.task}
+                title={t(`novel.settings.${item.field}`)}
+                hintKey={`${item.field}Hint`}
+                followChecked={isFollowingChat}
+                onFollowChange={(checked) => {
+                  if (checked) {
+                    updateNovelConfig({
+                      [item.field]: "",
+                    } as Partial<NovelConfig>)
+                  } else {
+                    updateNovelConfig({
+                      [item.field]: aiChatModel || " ",
+                    } as Partial<NovelConfig>)
+                  }
+                }}
+                modelValue={displayValue}
+                onModelChange={(model) => updateNovelConfig({
+                  [item.field]: model,
+                } as Partial<NovelConfig>)}
+                renderHint={settingTooltip}
+                testState={state}
+                onTest={() => runModelTest(item.task)}
+                followLabel={t("novel.settings.followDefaultModel")}
+                testLoadingLabel={t("novel.settings.testingModel")}
+                testLabel={t("novel.settings.testModel")}
+              />
             )
           })}
         </div>

+ 35 - 11
src/components/settings/settings-view.tsx

@@ -63,6 +63,8 @@ interface Category {
    *  switching language in Settings → Interface takes effect without
    *  remounting this component (Bug #53). */
   labelKey: string
+  /** Optional muted subtitle under the label (e.g. novel → model setup). */
+  hintKey?: string
   icon: typeof Bot
 }
 
@@ -73,7 +75,7 @@ const CATEGORIES: Category[] = [
   { id: "network", labelKey: "settings.categories.network", icon: Network },
   { id: "mcp", labelKey: "settings.categories.mcp", icon: Network },
   { id: "interface", labelKey: "settings.categories.interface", icon: Palette },
-  { id: "novel", labelKey: "settings.categories.novel", icon: BookOpen },
+  { id: "novel", labelKey: "settings.categories.novel", hintKey: "settings.categories.novelHint", icon: BookOpen },
   { id: "usage-guide", labelKey: "settings.categories.usageGuide", icon: HelpCircle },
   { id: "maintenance", labelKey: "settings.categories.maintenance", icon: Wrench },
   { id: "data-management", labelKey: "settings.categories.dataManagement", icon: Archive },
@@ -83,6 +85,15 @@ const CATEGORIES: Category[] = [
   { id: "changelog", labelKey: "settings.categories.changelog", icon: History },
 ]
 
+/** Settings tabs that edit the shared draft and need the global Save footer. */
+const CATEGORIES_WITH_SAVE_FOOTER: CategoryId[] = [
+  "rerank",
+  "embedding",
+  "network",
+  "interface",
+  "novel",
+]
+
 function initialDraft(
   llm: ReturnType<typeof useWikiStore.getState>["llmConfig"],
   embed: ReturnType<typeof useWikiStore.getState>["embeddingConfig"],
@@ -337,6 +348,7 @@ export function SettingsView() {
       saveSourceWatchConfig,
       saveRevisionFeedbackWindowConfig,
       saveNovelConfig,
+      saveDefaultLlmModel,
       saveOutputLanguage,
       saveMaxHistoryMessages,
       saveUiFontSizeScale,
@@ -459,6 +471,7 @@ export function SettingsView() {
 
     setNovelConfig(draft.novelConfig)
     await saveNovelConfig(draft.novelConfig, project?.id, project?.path)
+    await saveDefaultLlmModel(draft.novelConfig.defaultLlmModel)
 
     setOutputLanguage(draft.outputLanguage)
     await saveOutputLanguage(draft.outputLanguage, project?.id)
@@ -535,6 +548,8 @@ export function SettingsView() {
     }
   }, [active, draft, setDraft])
 
+  const showSaveFooter = CATEGORIES_WITH_SAVE_FOOTER.includes(active)
+
   return (
     <div className="flex h-full overflow-hidden">
       {/* Sidebar — category nav. Matches the IconSidebar's pill-on-accent
@@ -569,7 +584,14 @@ export function SettingsView() {
                     isActive ? "text-primary" : "text-muted-foreground/80 group-hover:text-accent-foreground"
                   }`}
                 />
-                <span className="truncate">{t(c.labelKey)}</span>
+                <span className="flex min-w-0 flex-1 flex-col items-start">
+                  <span className="truncate">{t(c.labelKey)}</span>
+                  {c.hintKey ? (
+                    <span className="truncate text-[10px] leading-tight text-muted-foreground/80">
+                      {t(c.hintKey)}
+                    </span>
+                  ) : null}
+                </span>
               </button>
             )
           })}
@@ -582,16 +604,18 @@ export function SettingsView() {
           <div className="mx-auto max-w-2xl">{body}</div>
         </div>
 
-        <div className="shrink-0 border-t bg-background/80 backdrop-blur px-8 py-3">
-          <div className="mx-auto flex max-w-2xl items-center justify-between gap-4">
-            <p className="text-xs text-muted-foreground">
-              {saved ? t("settings.savedTick") : t("settings.changeHint")}
-            </p>
-            <Button onClick={handleSave}>
-              {saved ? t("settings.saved") : t("settings.save")}
-            </Button>
+        {showSaveFooter && (
+          <div className="shrink-0 border-t bg-background/80 backdrop-blur px-8 py-3">
+            <div className="mx-auto flex max-w-2xl items-center justify-between gap-4">
+              <p className="text-xs text-muted-foreground">
+                {saved ? t("settings.savedTick") : t("settings.changeHint")}
+              </p>
+              <Button onClick={handleSave}>
+                {saved ? t("settings.saved") : t("settings.save")}
+              </Button>
+            </div>
           </div>
-        </div>
+        )}
       </div>
     </div>
   )

+ 1 - 1
src/components/sources/outline-chat-panel.tsx

@@ -40,6 +40,7 @@ import {
   collectWebResearch,
   shouldUseWebResearch,
 } from "@/lib/web-research"
+import { parseAgentResponse } from "@/lib/novel/agent-parser"
 
 const OUTLINE_CHAT_DISABLED_TOOLS = ["write_chapter", "write_memory"]
 
@@ -277,7 +278,6 @@ function OutlineAssistantMessage({ msg, index, isStreaming, streamingContent, ac
   // Parse for file edits
   const parsed = useMemo(() => {
     if (!answer) return { textContent: "", edits: [], hasEdits: false }
-    const { parseAgentResponse } = require("@/lib/novel/agent-parser") as typeof import("@/lib/novel/agent-parser")
     return parseAgentResponse(answer)
   }, [answer])
 

+ 421 - 0
src/hooks/use-simulation-worker.ts

@@ -0,0 +1,421 @@
+import { useRef, useCallback, useEffect } from "react"
+import SimulationWorker from "@/workers/simulation-worker?worker"
+
+type WorkerRequest =
+  | { id: string; type: "calc-clue-relations"; payload: { clues: { id: string; content: string }[] } }
+  | { id: string; type: "calc-branch-diff"; payload: { branchA: any; branchB: any } }
+  | { id: string; type: "calc-board-layout"; payload: { cards: { id: string; x: number; y: number; width: number }[]; connections: { fromCardId: string; toCardId: string }[] } }
+
+type WorkerResponse =
+  | { id: string; type: "clue-relations"; payload: { pairs: [string, string, number][] } }
+  | { id: string; type: "branch-diff"; payload: any }
+  | { id: string; type: "board-layout"; payload: { id: string; x: number; y: number }[] }
+
+type PendingRequest = {
+  resolve: (value: any) => void
+  reject: (reason: any) => void
+  timeoutId: ReturnType<typeof setTimeout>
+}
+
+const TIMEOUT_MS = 3000
+
+function extractKeywords(text: string): string[] {
+  const words = text.split(/[,。?!、的了是在有和与等\s,.!?;:]/).filter((w) => w.length >= 2)
+  return Array.from(new Set(words))
+}
+
+function keywordOverlap(a: string, b: string): number {
+  const keywordsA = extractKeywords(a)
+  const keywordsB = extractKeywords(b)
+  if (keywordsA.length === 0 || keywordsB.length === 0) return 0
+  const setA = new Set(keywordsA)
+  const shared = keywordsB.filter((w) => setA.has(w)).length
+  return shared / Math.min(keywordsA.length, keywordsB.length)
+}
+
+function fallbackCalcClueRelations(clues: { id: string; content: string }[]): { pairs: [string, string, number][] } {
+  const pairs: [string, string, number][] = []
+  for (let i = 0; i < clues.length; i++) {
+    for (let j = i + 1; j < clues.length; j++) {
+      const score = keywordOverlap(clues[i].content, clues[j].content)
+      if (score > 0.3) {
+        pairs.push([clues[i].id, clues[j].id, score])
+      }
+    }
+  }
+  pairs.sort((a, b) => b[2] - a[2])
+  return { pairs: pairs.slice(0, 50) }
+}
+
+function fallbackCalcBoardLayout(
+  cards: { id: string; x: number; y: number; width: number }[],
+  connections: { fromCardId: string; toCardId: string }[],
+): { id: string; x: number; y: number }[] {
+  const k = 80
+  const iterations = 80
+  let temperature = 50
+
+  const nodes = cards.map((c) => ({
+    id: c.id,
+    x: c.x + c.width / 2,
+    y: c.y + 60,
+    vx: 0,
+    vy: 0,
+    width: c.width,
+  }))
+
+  const nodeMap = new Map(nodes.map((n) => [n.id, n]))
+
+  for (let iter = 0; iter < iterations; iter++) {
+    for (let i = 0; i < nodes.length; i++) {
+      nodes[i].vx = 0
+      nodes[i].vy = 0
+    }
+
+    for (let i = 0; i < nodes.length; i++) {
+      for (let j = i + 1; j < nodes.length; j++) {
+        const dx = nodes[j].x - nodes[i].x
+        const dy = nodes[j].y - nodes[i].y
+        let dist = Math.sqrt(dx * dx + dy * dy)
+        if (dist < 1) dist = 1
+        const force = (k * k) / dist
+        const fx = (dx / dist) * force
+        const fy = (dy / dist) * force
+        nodes[i].vx -= fx
+        nodes[i].vy -= fy
+        nodes[j].vx += fx
+        nodes[j].vy += fy
+      }
+    }
+
+    for (const conn of connections) {
+      const from = nodeMap.get(conn.fromCardId)
+      const to = nodeMap.get(conn.toCardId)
+      if (!from || !to) continue
+      const dx = to.x - from.x
+      const dy = to.y - from.y
+      let dist = Math.sqrt(dx * dx + dy * dy)
+      if (dist < 1) dist = 1
+      const force = dist / k
+      const fx = (dx / dist) * force
+      const fy = (dy / dist) * force
+      from.vx += fx
+      from.vy += fy
+      to.vx -= fx
+      to.vy -= fy
+    }
+
+    for (const node of nodes) {
+      const disp = Math.sqrt(node.vx * node.vx + node.vy * node.vy)
+      if (disp > 0) {
+        const ratio = Math.min(disp, temperature) / disp
+        node.x += node.vx * ratio
+        node.y += node.vy * ratio
+      }
+    }
+
+    temperature *= 0.95
+  }
+
+  return nodes.map((n) => ({
+    id: n.id,
+    x: n.x - n.width / 2,
+    y: n.y - 60,
+  }))
+}
+
+const DIMENSION_KEYS = [
+  "tension",
+  "pace",
+  "characterUtilization",
+  "characterArc",
+  "infoDensity",
+  "emotionalResonance",
+  "logicConsistency",
+] as const
+
+type DirectorScore = {
+  tension: number
+  pace: number
+  characterUtilization: number
+  characterArc: number
+  infoDensity: number
+  emotionalResonance: number
+  logicConsistency: number
+}
+
+function fallbackGetAvgDirectorScore(branch: any): DirectorScore {
+  if (branch.directorEvaluations.length === 0) {
+    return {
+      tension: 3.0,
+      pace: 3.0,
+      characterUtilization: 3.0,
+      characterArc: 3.0,
+      infoDensity: 3.0,
+      emotionalResonance: 3.0,
+      logicConsistency: 3.0,
+    }
+  }
+  const sum: DirectorScore = {
+    tension: 0,
+    pace: 0,
+    characterUtilization: 0,
+    characterArc: 0,
+    infoDensity: 0,
+    emotionalResonance: 0,
+    logicConsistency: 0,
+  }
+  for (const ev of branch.directorEvaluations) {
+    for (const key of DIMENSION_KEYS) {
+      sum[key] += ev.scores[key]
+    }
+  }
+  const n = branch.directorEvaluations.length
+  const avg: DirectorScore = { ...sum }
+  for (const key of DIMENSION_KEYS) {
+    avg[key] = Math.round((avg[key] / n) * 10) / 10
+  }
+  return avg
+}
+
+function fallbackCalcBranchDiff(branchA: any, branchB: any): any {
+  const branches = [branchA, branchB]
+  const scoresList = branches.map((b) => fallbackGetAvgDirectorScore(b))
+
+  const dimensionDiffs = DIMENSION_KEYS.map((key) => {
+    const values = scoresList.map((s) => s[key])
+    const max = Math.max(...values)
+    const min = Math.min(...values)
+    const maxIdx = values.indexOf(max)
+    return {
+      key,
+      diff: max - min,
+      maxBranchName: branches[maxIdx].name,
+      maxValue: max,
+      minValue: min,
+    }
+  })
+    .sort((a, b) => b.diff - a.diff)
+    .slice(0, 3)
+
+  const eventCounts = branches.map((b) => b.timelineEvents.length)
+  const characterCounts = branches.map((b) => b.finalAgentSnapshots.length)
+
+  const allCharMap = new Map<string, string>()
+  for (const b of branches) {
+    for (const agent of b.finalAgentSnapshots) {
+      allCharMap.set(agent.agentId, agent.name)
+    }
+  }
+
+  const sentimentDiffs: { charId: string; charName: string; maxDiff: number; maxBranch: string; values: number[] }[] = []
+
+  for (const [charId, charName] of allCharMap) {
+    const values: number[] = []
+    for (const b of branches) {
+      const agent = b.finalAgentSnapshots.find((a: any) => a.agentId === charId)
+      let totalSentiment = 0
+      let count = 0
+      if (agent) {
+        for (const [, val] of agent.sentiments) {
+          totalSentiment += val
+          count++
+        }
+      }
+      values.push(count > 0 ? totalSentiment / count : 0)
+    }
+    const max = Math.max(...values)
+    const min = Math.min(...values)
+    const maxIdx = values.indexOf(max)
+    sentimentDiffs.push({
+      charId,
+      charName,
+      maxDiff: max - min,
+      maxBranch: branches[maxIdx].name,
+      values,
+    })
+  }
+
+  const topSentimentDiffs = sentimentDiffs.sort((a, b) => b.maxDiff - a.maxDiff).slice(0, 3)
+
+  const maxRound = Math.max(
+    ...branches.map((b) =>
+      b.timelineEvents.length > 0 ? Math.max(...b.timelineEvents.map((e: any) => e.round)) : -1,
+    ),
+  )
+
+  let divergenceRound = -1
+  for (let r = 0; r <= maxRound; r++) {
+    const roundContents = branches.map((b) => {
+      const evs = b.timelineEvents.filter((e: any) => e.round === r)
+      return evs.map((e: any) => e.content.slice(0, 10)).join("|")
+    })
+
+    let allSame = true
+    for (let i = 1; i < roundContents.length; i++) {
+      if (roundContents[i] !== roundContents[0]) {
+        allSame = false
+        break
+      }
+    }
+    if (!allSame) {
+      divergenceRound = r + 1
+      break
+    }
+  }
+
+  const bestBranchIdx = branches.reduce(
+    (bestIdx, b, idx) => (b.overallScore > branches[bestIdx].overallScore ? idx : bestIdx),
+    0,
+  )
+
+  return {
+    dimensionDiffs,
+    eventCounts,
+    characterCounts,
+    topSentimentDiffs,
+    divergenceRound,
+    bestBranchIdx,
+    scoresList,
+  }
+}
+
+export function useSimulationWorker() {
+  const workerRef = useRef<Worker | null>(null)
+  const pendingRef = useRef<Map<string, PendingRequest>>(new Map())
+  const counterRef = useRef(0)
+  const workerFailedRef = useRef(false)
+
+  const ensureWorker = useCallback((): Worker | null => {
+    if (workerFailedRef.current) return null
+    if (workerRef.current) return workerRef.current
+
+    try {
+      const worker = new SimulationWorker()
+      worker.onmessage = (e: MessageEvent<WorkerResponse>) => {
+        const response = e.data
+        const pending = pendingRef.current.get(response.id)
+        if (pending) {
+          clearTimeout(pending.timeoutId)
+          pendingRef.current.delete(response.id)
+          pending.resolve(response.payload)
+        }
+      }
+      worker.onerror = () => {
+        workerFailedRef.current = true
+        for (const [, pending] of pendingRef.current) {
+          clearTimeout(pending.timeoutId)
+          pending.reject(new Error("Worker 错误"))
+        }
+        pendingRef.current.clear()
+        workerRef.current = null
+      }
+      workerRef.current = worker
+      return worker
+    } catch {
+      workerFailedRef.current = true
+      return null
+    }
+  }, [])
+
+  const sendRequest = useCallback(
+    <T>(request: Omit<WorkerRequest, "id">, fallback: () => T): Promise<T> => {
+      const worker = ensureWorker()
+      const id = `req-${Date.now()}-${counterRef.current++}`
+
+      if (!worker) {
+        return Promise.resolve(fallback())
+      }
+
+      return new Promise<T>((resolve, reject) => {
+        const timeoutId = setTimeout(() => {
+          pendingRef.current.delete(id)
+          try {
+            worker.terminate()
+          } catch {
+          }
+          workerRef.current = null
+          workerFailedRef.current = true
+          resolve(fallback())
+        }, TIMEOUT_MS)
+
+        pendingRef.current.set(id, {
+          resolve: resolve as (value: any) => void,
+          reject,
+          timeoutId,
+        })
+
+        try {
+          worker.postMessage({ ...request, id })
+        } catch {
+          clearTimeout(timeoutId)
+          pendingRef.current.delete(id)
+          workerFailedRef.current = true
+          workerRef.current = null
+          resolve(fallback())
+        }
+      })
+    },
+    [ensureWorker],
+  )
+
+  const calcClueRelations = useCallback(
+    (clues: { id: string; content: string }[]): Promise<{ pairs: [string, string, number][] }> => {
+      return sendRequest(
+        { type: "calc-clue-relations", payload: { clues } },
+        () => fallbackCalcClueRelations(clues),
+      )
+    },
+    [sendRequest],
+  )
+
+  const calcBoardLayout = useCallback(
+    (
+      cards: { id: string; x: number; y: number; width: number }[],
+      connections: { fromCardId: string; toCardId: string }[],
+    ): Promise<{ id: string; x: number; y: number }[]> => {
+      return sendRequest(
+        { type: "calc-board-layout", payload: { cards, connections } },
+        () => fallbackCalcBoardLayout(cards, connections),
+      )
+    },
+    [sendRequest],
+  )
+
+  const calcBranchDiff = useCallback(
+    (branchA: any, branchB: any): Promise<any> => {
+      return sendRequest(
+        { type: "calc-branch-diff", payload: { branchA, branchB } },
+        () => fallbackCalcBranchDiff(branchA, branchB),
+      )
+    },
+    [sendRequest],
+  )
+
+  const terminate = useCallback(() => {
+    if (workerRef.current) {
+      try {
+        workerRef.current.terminate()
+      } catch {
+      }
+      workerRef.current = null
+    }
+    for (const [, pending] of pendingRef.current) {
+      clearTimeout(pending.timeoutId)
+    }
+    pendingRef.current.clear()
+  }, [])
+
+  useEffect(() => {
+    return () => {
+      terminate()
+    }
+  }, [terminate])
+
+  return {
+    calcClueRelations,
+    calcBoardLayout,
+    calcBranchDiff,
+    terminate,
+  }
+}

+ 32 - 14
src/i18n/en.json

@@ -12,7 +12,7 @@
     "expandChat": "Expand Chat",
     "chatWidth": "Chat width",
     "saveApiKey": "API key saved",
-    "invalidProject": "The selected folder does not look like an LLM Wiki project. It should contain a 'wiki' directory.",
+    "invalidProject": "The selected folder does not look like a QMAI project. It should contain a 'wiki' directory.",
     "status": {
       "connected": "Connected to Daemon",
       "connecting": "Connecting to Daemon...",
@@ -693,6 +693,7 @@
       "output": "Output",
       "interface": "Interface",
       "novel": "Novel",
+      "novelHint": "Model settings",
       "usageGuide": "Usage Guide",
       "maintenance": "Maintenance",
       "feedback": "Feedback",
@@ -760,8 +761,7 @@
         "apiKeyPlaceholderCustom": "Leave blank if no key is required (local model)",
         "apiKeyPlaceholder": "Enter API Key",
         "fetchModels": "Fetch Models",
-        "defaultLlmModel": "Default Model",
-        "defaultLlmModelHint": "The default model used by background tasks such as memory extraction and character extraction. If empty, the current AI chat model will be used.",
+        "pleaseSelectModel": "Please select a model first",
         "loadingModels": "Fetching...",
         "savedBadge": "Saved",
         "configuredBadge": "Configured",
@@ -1071,11 +1071,23 @@
           "description": "Ask the model to scan all entity / concept pages and group entries that likely point to the same topic under different names, such as Chinese vs English names, singular vs plural, or abbreviations vs full names. You confirm each group before merging. Merge tasks enter a queue and run one at a time to keep cross-references consistent.",
           "scanButton": "Scan for Duplicates",
           "scanning": "Scanning...",
+          "modelLabel": "Detection model",
+          "noModel": "Add and enable a model under Settings → LLM first.",
+          "selectModel": "Select a detection model first.",
+          "scanningHint": "Scanning entity / concept pages and analyzing with the model. This may take a while…",
+          "scanStageLoading": "Reading entity / concept pages…",
+          "scanStageDetecting": "Analyzing with the model…",
+          "groupsFound": "Found {{count}} duplicate candidate groups. Review them below before merging.",
+          "insufficientPages": "At least 2 entity / concept pages are required to detect duplicates. Currently only {{count}}.",
           "noneFound": "No duplicate groups found. The knowledge base is clean.",
           "candidates": "{{n}} candidates",
           "canonicalLabel": "Keep this slug as the canonical entry:",
           "mergeButton": "Merge into {{slug}}",
+          "enqueueing": "Queueing...",
           "merging": "Merging...",
+          "mergeStageLoading": "Reading wiki…",
+          "mergeStageMerging": "Merging content (LLM)…",
+          "mergeStageWriting": "Writing files…",
           "queued": "Queued",
           "queuedAhead": "Queued ({{n}} ahead)",
           "failed": "Failed ({{retries}}/3)",
@@ -1238,17 +1250,23 @@
       "deepChapterReviewHint": "When enabled, deep generation reviews the draft and auto-revises blocking issues. When off, the draft goes straight to the light de-AI polish, saving the review and revision calls at the cost of one continuity gate. On by default.",
       "reviewReasoningEffort": "Review thinking effort",
       "reviewReasoningEffortHint": "Controls how deeply the AI review (including six-dimension review) reasons. Lower effort is cheaper and faster but weakens the continuity and canon-conflict gate. High by default.",
-      "taskModelsTitle": "Task Model Routing",
-      "taskModelsHint": "These are LLM models for writing, review, summary, and extraction, not vector embedding models. Empty fields use the current main model.",
-      "writingModel": "Writing Model (empty = use default)",
-      "writingModelHint": "Used for drafting, continuing, and rewriting prose. Defaults to the global LLM model; choose a model only when writing tasks should use a separate model.",
-      "reviewModel": "Review Model (empty = use default)",
-      "reviewModelHint": "Used for chapter review, continuity checks, and auto-review before save. Defaults to the global LLM model.",
-      "summaryModel": "Summary Model (empty = use default)",
-      "summaryModelHint": "Used for chapter summaries, long-context compression, and preparing source material before writing. Defaults to the global LLM model.",
-      "extractModel": "Extract Model (not embedding, empty = use default)",
-      "extractModelHint": "Used for outline ingestion and chapter ingestion to extract characters, locations, events, relationships, and canon facts. Empty uses the current main LLM model; this is not a vector embedding model.",
-      "followChatModel": "Follow AI chat model",
+      "taskModelsTitle": "Per-Step Models (Novel Writing)",
+      "taskModelsHint": "While writing a novel, the review, summary, extract, and de-AI steps can each use their own model. By default they all follow the Default Model above, so you don't have to set anything (only when the default model is also empty do they fall back to the model selected in the chat box). Only when a step should always use a different model, uncheck its Follow default model and pick one on the right.",
+      "defaultLlmModel": "Default Model (General)",
+      "defaultLlmModelHint": "Used by every feature except writing prose: review, summary, extract, plus Dismantling, Story Simulation, character Souls, AI rewrite/polish, duplicate-entry detection, and automatic source import. Setting a model here is recommended. When left empty (Follow chat model checked), it falls back to the model selected in the chat box.",
+      "defaultLlmModelScopeNote": "Review, summary, extract, and de-AI while writing a novel all use this model by default; set them individually in Per-Step Models below only when needed.",
+      "writingModel": "Writing Model",
+      "writingModelHint": "Used while writing a novel for drafting, continuing, and rewriting prose, using the model selected in the chat box.",
+      "reviewModel": "Review Model",
+      "reviewModelHint": "Used while writing a novel for review, continuity checks, and auto-review before save. Follows the Default Model above by default (falls back to the chat model only when the default is also unset); to always use a different model, uncheck Follow default model and pick one on the right.",
+      "summaryModel": "Summary Model",
+      "summaryModelHint": "Used while writing a novel to generate chapter summaries, compress overly long context, and prepare source material before writing. Follows the Default Model above by default (falls back to the chat model only when the default is also unset); to always use a different model, uncheck Follow default model and pick one on the right.",
+      "extractModel": "Extract Model (not an embedding model)",
+      "extractModelHint": "Used while writing a novel to extract characters, locations, events, relationships, and canon facts from outlines and chapters. Follows the Default Model above by default (falls back to the chat model only when the default is also unset); to always use a different model, uncheck Follow default model and pick one on the right. Note: this is not the embedding model used for vector search.",
+      "deAiModel": "De-AI Model",
+      "deAiModelHint": "Used while writing a novel for chapter preview de-AI, selection de-AI, and the final light review and de-AI pass in deep generation. Follows the Default Model above by default (falls back to the chat model only when the default is also unset); to always use a different model, uncheck Follow default model and pick one on the right.",
+      "followChatModel": "Follow chat model",
+      "followDefaultModel": "Follow default model",
       "fetchModels": "Fetch Models",
       "loadingModels": "Fetching...",
       "modelListSuccess": "Fetched {{count}} models.",

+ 29 - 12
src/i18n/zh.json

@@ -405,6 +405,7 @@
       "output": "输出偏好",
       "interface": "界面",
       "novel": "小说",
+      "novelHint": "设置模型",
       "usageGuide": "软件使用说明",
       "maintenance": "维护",
       "feedback": "反馈",
@@ -454,7 +455,7 @@
       },
       "llm": {
         "title": "大语言/LLM模型",
-        "description": "配置不同大语言模型服务商的 API Key、模型和连接方式。自定义模型保留在最上方,LLM Wiki 预设模型在下方可单独启用。",
+        "description": "配置不同大语言模型服务商的 API Key、模型和连接方式。自定义模型保留在最上方,内置预设模型在下方可单独启用。",
         "expand": "展开配置",
         "collapse": "收起配置",
         "toggleOff": "停用此模型",
@@ -472,8 +473,6 @@
         "apiKeyPlaceholderCustom": "输入 Key,留空表示使用本地或免密服务",
         "apiKeyPlaceholder": "输入 API Key",
         "fetchModels": "拉取模型",
-        "defaultLlmModel": "默认模型",
-        "defaultLlmModelHint": "AI 会话提取记忆、提取角色等后台任务默认使用的模型。留空则使用 AI 会话当前选中的模型。",
         "pleaseSelectModel": "请先选择模型",
         "loadingModels": "拉取中...",
         "savedBadge": "已保存",
@@ -792,11 +791,23 @@
           "description": "让大模型扫描全部实体 / 概念页面,并把那些很可能只是名称不同、实则指向同一主题的条目分组出来(例如中英文名称、单复数、简称与全称)。每组都需要你确认后才会合并。合并任务会进入队列并逐个执行,以保持交叉引用一致。",
           "scanButton": "开始扫描重复项",
           "scanning": "扫描中...",
+          "modelLabel": "检测模型",
+          "noModel": "请先在「设置 → 大语言模型」中添加并启用一个模型。",
+          "selectModel": "请先选择检测模型。",
+          "scanningHint": "正在扫描实体 / 概念页面并调用模型分析,可能需要一会儿…",
+          "scanStageLoading": "正在读取实体 / 概念页面…",
+          "scanStageDetecting": "正在调用模型分析…",
+          "groupsFound": "发现 {{count}} 组重复候选,请在下方确认是否合并。",
+          "insufficientPages": "至少需要 2 个实体 / 概念页面才能检测重复,当前只有 {{count}} 个。",
           "noneFound": "未发现重复分组,当前资料库很干净。",
           "candidates": "{{n}} 个候选",
           "canonicalLabel": "保留以下 slug 作为主条目:",
           "mergeButton": "合并到 {{slug}}",
+          "enqueueing": "加入队列...",
           "merging": "合并中...",
+          "mergeStageLoading": "正在读取 wiki…",
+          "mergeStageMerging": "正在合并内容(LLM)…",
+          "mergeStageWriting": "正在写入文件…",
           "queued": "已排队",
           "queuedAhead": "已排队(前方还有 {{n}} 项)",
           "failed": "失败({{retries}}/3)",
@@ -1108,17 +1119,23 @@
       "deepChapterReviewHint": "开启后,深度生成在初稿后会进行 AI 审稿,发现阻断问题时自动返修。关闭则初稿直接进入简单审查与去 AI 味,省下审稿与返修调用,但少了一道连贯性把关。默认开启。",
       "reviewReasoningEffort": "审稿思考档位",
       "reviewReasoningEffortHint": "控制 AI 审稿(含六维审查)的思考深度。档位越低越省 Token、越快,但连贯性与设定冲突的把关会变弱。默认「高」。",
-      "taskModelsTitle": "任务模型路由",
-      "taskModelsHint": "下面都是 LLM 写作、审稿、摘要和提取模型,不是向量嵌入模型。留空时使用当前主模型。",
-      "writingModel": "写作模型(留空使用默认)",
-      "writingModelHint": "用于生成、续写和改写正文。默认使用上方全局 LLM 模型;只有想让写作任务单独使用另一个模型时才需要选择。",
+      "taskModelsTitle": "分环节指定模型(写小说)",
+      "taskModelsHint": "写小说时,审稿、摘要、提取信息、去 AI 味这几个环节可以各用一个模型。默认它们都跟随上面的「默认模型」,你不用管(默认模型也没设时,才用聊天框底部选中的模型兜底)。只有某个环节想固定用别的模型时,才取消它的「跟随默认模型」并在右侧选一个。",
+      "defaultLlmModel": "默认模型(通用)",
+      "defaultLlmModelHint": "写正文以外的功能都用它:审稿、摘要、提取,以及拆书、剧情推演、角色灵魂、AI 改写/润色、重复条目检测、资料自动导入等。建议在这里指定一个模型。留空(勾选「跟随聊天模型」)时,才用聊天框底部选中的模型兜底。",
+      "defaultLlmModelScopeNote": "写小说的审稿、摘要、提取、去 AI 味默认都用这个模型;需要时可在下方「分环节指定模型」里单独指定。",
+      "writingModel": "写作模型",
+      "writingModelHint": "写小说时用于生成、续写和改写正文,直接使用聊天框底部选中的模型。",
       "reviewModel": "审稿模型",
-      "reviewModelHint": "用于章节审稿、连贯性检查和保存前自动审稿。留空则跟随 AI 会话当前选中的模型。",
+      "reviewModelHint": "写小说时用于审稿、检查前后是否连贯、保存前自动审稿。默认跟随上面的「默认模型」(默认模型也没设时才用聊天模型兜底);想固定用别的模型,就取消「跟随默认模型」并在右侧选一个。",
       "summaryModel": "摘要模型",
-      "summaryModelHint": "用于章节摘要、长上下文压缩和写作前的资料整理。留空则跟随 AI 会话当前选中的模型。",
-      "extractModel": "提取模型(非嵌入模型)",
-      "extractModelHint": "用于大纲摄取和章节摄取,抽取人物、地点、事件、关系和设定。留空则跟随 AI 会话当前选中的模型;这不是向量嵌入模型。",
-      "followChatModel": "跟随 AI 会话模型",
+      "summaryModelHint": "写小说时用于生成章节摘要、压缩过长的上下文、写作前整理资料。默认跟随上面的「默认模型」(默认模型也没设时才用聊天模型兜底);想固定用别的模型,就取消「跟随默认模型」并在右侧选一个。",
+      "extractModel": "提取模型(不是向量模型)",
+      "extractModelHint": "写小说时用于从大纲和章节里提取人物、地点、事件、关系和设定。默认跟随上面的「默认模型」(默认模型也没设时才用聊天模型兜底);想固定用别的模型,就取消「跟随默认模型」并在右侧选一个。注意:这不是用来做向量检索的嵌入模型。",
+      "deAiModel": "去 AI 味模型",
+      "deAiModelHint": "写小说时用于章节预览的去 AI 味、选区去 AI 味,以及深度生成最后一遍简单审查与去 AI 味。默认跟随上面的「默认模型」(默认模型也没设时才用聊天模型兜底);想固定用别的模型,就取消「跟随默认模型」并在右侧选一个。",
+      "followChatModel": "跟随聊天模型",
+      "followDefaultModel": "跟随默认模型",
       "fetchModels": "拉取模型",
       "loadingModels": "拉取中...",
       "modelListSuccess": "已拉取 {{count}} 个模型。",

+ 20 - 0
src/lib/app-update-support.ts

@@ -0,0 +1,20 @@
+import { isTauri } from "@/lib/platform"
+
+export const APP_AUTO_UPDATE_RELEASES_URL = "https://github.com/Mochocyang/QMAI/releases"
+
+export const APP_AUTO_UPDATE_UNSUPPORTED_MESSAGE =
+  "当前平台暂不支持应用内自动更新,请前往 GitHub Releases 手动下载安装包。"
+
+export function getDesktopPlatform(): "windows" | "macos" | "linux" | "unknown" {
+  if (!isTauri()) return "unknown"
+
+  const ua = navigator.userAgent
+  if (/Windows/i.test(ua)) return "windows"
+  if (/Mac/i.test(ua)) return "macos"
+  if (/Linux/i.test(ua)) return "linux"
+  return "unknown"
+}
+
+export function isAppAutoUpdateSupported(): boolean {
+  return getDesktopPlatform() === "windows"
+}

+ 2 - 1
src/lib/app-updater.ts

@@ -1,3 +1,4 @@
+import { isAppAutoUpdateSupported } from "@/lib/app-update-support"
 import { isTauri } from "@/lib/platform"
 import type { Update } from "@tauri-apps/plugin-updater"
 
@@ -89,7 +90,7 @@ export async function runAppUpdateFlow(bindings: UpdaterBindings) {
 }
 
 export async function checkForAppUpdate() {
-  if (!isTauri() || updateCheckStarted) return
+  if (!isTauri() || !isAppAutoUpdateSupported() || updateCheckStarted) return
 
   updateCheckStarted = true
   try {

+ 3 - 2
src/lib/changelog.spec.ts

@@ -6,7 +6,8 @@ describe("changelog", () => {
     const entries = allChangelog()
     const versions = entries.map((entry) => entry.version)
 
-    expect(versions.slice(0, 25)).toEqual([
+    expect(versions.slice(0, 26)).toEqual([
+      "2.2.32",
       "2.2.31",
       "2.2.30",
       "2.2.29",
@@ -33,7 +34,7 @@ describe("changelog", () => {
       "2.2.0",
       "2.1.0",
     ])
-    expect(versions[25]).toBe("2.0.0")
+    expect(versions[26]).toBe("2.0.0")
 
     for (let patch = 1; patch <= 6; patch += 1) {
       expect(versions).not.toContain(`2.2.${patch}`)

+ 29 - 0
src/lib/changelog.ts

@@ -78,6 +78,33 @@ const TWO_POINT_TWO_ELEVEN_CHANGELOG: ChangelogEntry = {
   },
 }
 
+const TWO_POINT_TWO_THIRTY_TWO_CHANGELOG: ChangelogEntry = {
+  version: "2.2.32",
+  date: "2026-07-04",
+  highlights: {
+    en: [
+      "Chat model and background-task model are now separate: the model in the chat box is only for conversation and writing prose; review, summary, de-AI, and similar tasks follow the Default Model unless you pick something else for that step.",
+      "De-AI can use its own model, so you no longer have to share the chat model for every de-AI pass.",
+      "Default Model moved to the top of Novel settings with clearer wording about what it actually controls.",
+      "Model-setting labels are easier to read, and Follow default model and Follow chat model are now two separate checkboxes so they cannot mean opposite things in different places.",
+      "Duplicate-entity detection is smoother: pick a model before scanning, last scan results are remembered, merged entries disappear right away, and the merge button no longer gets stuck doing nothing.",
+      "Duplicate-entity scans and merges run faster, with progress that tells you what is happening; the most likely duplicates show up first.",
+      "macOS and Linux no longer check for automatic updates — the settings page points you to manual download instead, and the install guide covers common macOS setup issues.",
+      "Some settings pages no longer need a Save button; changes take effect as you go.",
+    ],
+    zh: [
+      "聊天用的模型和后台任务用的模型分开了:聊天框里选的只管对话和写正文,审稿、摘要、去 AI 味等默认走「默认模型」;需要的话,也可以给单个环节单独指定模型。",
+      "去 AI 味可以单独选一个模型,不必再和聊天模型绑在一起。",
+      "「默认模型」提到小说设置最上面,说明也写清楚了——它负责除写正文以外的各种后台任务。",
+      "各处的模型设置说明改写了,「跟随默认模型」和「跟随聊天模型」分开勾选,不再搞混。",
+      "重复角色/实体检测更好用了:扫描前可以选模型,上次结果会保留,合并后条目马上消失;也修复了点击合并没反应的问题。",
+      "重复实体扫描和合并更快了,进度会告诉你当前在干什么;最可能重复的会排在前面。",
+      "macOS 和 Linux 版暂不支持自动更新,设置里会提示去官网手动下载;安装说明也补充了 macOS 常见问题。",
+      "部分设置页不再需要点保存,改动即时生效。",
+    ],
+  },
+}
+
 const TWO_POINT_TWO_THIRTY_ONE_CHANGELOG: ChangelogEntry = {
   version: "2.2.31",
   date: "2026-06-30",
@@ -717,6 +744,7 @@ export const CHANGELOG: ChangelogEntry[] = [
 ]
 
 export function currentVersionChangelog(version: string): ChangelogEntry[] {
+  if (version === TWO_POINT_TWO_THIRTY_TWO_CHANGELOG.version) return [TWO_POINT_TWO_THIRTY_TWO_CHANGELOG]
   if (version === TWO_POINT_TWO_THIRTY_ONE_CHANGELOG.version) return [TWO_POINT_TWO_THIRTY_ONE_CHANGELOG]
   if (version === TWO_POINT_TWO_THIRTY_CHANGELOG.version) return [TWO_POINT_TWO_THIRTY_CHANGELOG]
   if (version === TWO_POINT_TWO_TWENTY_NINE_CHANGELOG.version) return [TWO_POINT_TWO_TWENTY_NINE_CHANGELOG]
@@ -752,6 +780,7 @@ export function currentVersionChangelog(version: string): ChangelogEntry[] {
 
 export function allChangelog(): ChangelogEntry[] {
   return [
+    TWO_POINT_TWO_THIRTY_TWO_CHANGELOG,
     TWO_POINT_TWO_THIRTY_ONE_CHANGELOG,
     TWO_POINT_TWO_THIRTY_CHANGELOG,
     TWO_POINT_TWO_TWENTY_NINE_CHANGELOG,

+ 88 - 8
src/lib/dedup-queue.ts

@@ -20,8 +20,8 @@ import { useWikiStore } from "@/stores/wiki-store"
 import { normalizePath } from "@/lib/path-utils"
 import { getProjectPathById } from "@/lib/project-identity"
 import { hasUsableLlm } from "@/lib/has-usable-llm"
-import { resolveDefaultModel } from "@/lib/novel/model-resolver"
-import { executeMerge } from "@/lib/dedup-runner"
+import { resolveDefaultModel, resolveModelConfig } from "@/lib/novel/model-resolver"
+import { executeMerge, type DedupMergeStage } from "@/lib/dedup-runner"
 import type { DuplicateGroup } from "@/lib/dedup"
 
 // ── Types ─────────────────────────────────────────────────────────────────
@@ -31,6 +31,7 @@ export interface DedupTask {
   projectId: string
   group: DuplicateGroup
   canonicalSlug: string
+  modelId?: string
   status: "pending" | "processing" | "done" | "failed"
   addedAt: number
   error: string | null
@@ -44,6 +45,26 @@ let processing = false
 let currentProjectId = ""
 let currentProjectPath = ""
 let currentAbortController: AbortController | null = null
+let currentMergeProgress: { taskId: string; stage: DedupMergeStage } | null = null
+
+type MergeCompleteListener = (task: DedupTask) => void
+const mergeCompleteListeners = new Set<MergeCompleteListener>()
+
+/** Fires once when a merge task finishes successfully and leaves the queue. */
+export function onDedupMergeComplete(listener: MergeCompleteListener): () => void {
+  mergeCompleteListeners.add(listener)
+  return () => mergeCompleteListeners.delete(listener)
+}
+
+function notifyMergeComplete(task: DedupTask): void {
+  for (const listener of mergeCompleteListeners) {
+    try {
+      listener(task)
+    } catch (err) {
+      console.error("[Dedup Queue] mergeComplete listener failed:", err)
+    }
+  }
+}
 
 // ── Persistence ───────────────────────────────────────────────────────────
 
@@ -100,10 +121,20 @@ export async function enqueueMerge(
   projectId: string,
   group: DuplicateGroup,
   canonicalSlug: string,
+  modelId?: string,
 ): Promise<string> {
+  const active = useWikiStore.getState().project
+  if (!active || active.id !== projectId) {
+    throw new Error(
+      `enqueueMerge: project ${projectId} is not the active project (current: ${active?.id || "<none>"})`,
+    )
+  }
+
+  await ensureQueueActive(active.id, active.path)
+
   if (!currentProjectId || currentProjectId !== projectId) {
     throw new Error(
-      `enqueueMerge: project ${projectId} is not the active project (current: ${currentProjectId || "<none>"})`,
+      `enqueueMerge: failed to activate dedup queue for project ${projectId}`,
     )
   }
 
@@ -121,6 +152,7 @@ export async function enqueueMerge(
     projectId,
     group,
     canonicalSlug,
+    modelId: modelId?.trim() || undefined,
     status: "pending",
     addedAt: Date.now(),
     error: null,
@@ -139,9 +171,17 @@ export async function enqueueMerge(
  * attempts again.
  */
 export async function retryTask(taskId: string): Promise<void> {
-  const task = queue.find((t) => t.id === taskId)
+  let task = queue.find((t) => t.id === taskId)
   if (!task) return
-  if (task.projectId !== currentProjectId) return
+  const projectId = task.projectId
+
+  const active = useWikiStore.getState().project
+  if (!active || active.id !== projectId) return
+
+  await ensureQueueActive(active.id, active.path)
+
+  task = queue.find((t) => t.id === taskId)
+  if (!task || task.projectId !== currentProjectId) return
 
   task.status = "pending"
   task.error = null
@@ -156,9 +196,17 @@ export async function retryTask(taskId: string): Promise<void> {
  * abort fired. Backup snapshots already on disk are kept either way.
  */
 export async function cancelTask(taskId: string): Promise<void> {
-  const task = queue.find((t) => t.id === taskId)
+  let task = queue.find((t) => t.id === taskId)
   if (!task) return
-  if (task.projectId !== currentProjectId) return
+  const projectId = task.projectId
+
+  const active = useWikiStore.getState().project
+  if (!active || active.id !== projectId) return
+
+  await ensureQueueActive(active.id, active.path)
+
+  task = queue.find((t) => t.id === taskId)
+  if (!task || task.projectId !== currentProjectId) return
 
   if (task.status === "processing") {
     if (currentAbortController) {
@@ -166,6 +214,7 @@ export async function cancelTask(taskId: string): Promise<void> {
       currentAbortController = null
     }
     processing = false
+    currentMergeProgress = null
   }
 
   queue = queue.filter((t) => t.id !== taskId)
@@ -177,6 +226,11 @@ export function getQueue(): readonly DedupTask[] {
   return queue
 }
 
+/** In-memory merge stage for the currently processing task (not persisted). */
+export function getMergeProgress(): { taskId: string; stage: DedupMergeStage } | null {
+  return currentMergeProgress
+}
+
 export function getQueueSummary(): {
   pending: number
   processing: number
@@ -205,6 +259,7 @@ export function clearQueueState(): void {
   currentProjectId = ""
   currentProjectPath = ""
   currentAbortController = null
+  currentMergeProgress = null
 }
 
 /**
@@ -222,6 +277,7 @@ export async function pauseQueue(): Promise<void> {
     currentAbortController = null
   }
   processing = false
+  currentMergeProgress = null
 
   for (const task of queue) {
     if (task.status === "processing") {
@@ -236,6 +292,19 @@ export async function pauseQueue(): Promise<void> {
   currentProjectPath = ""
 }
 
+/**
+ * Ensure the in-memory dedup queue is bound to the given project.
+ * No-op when already active; otherwise loads from disk via restoreQueue.
+ */
+export async function ensureQueueActive(
+  projectId: string,
+  projectPath: string,
+): Promise<void> {
+  const pp = normalizePath(projectPath)
+  if (currentProjectId === projectId && currentProjectPath === pp) return
+  await restoreQueue(projectId, projectPath)
+}
+
 /**
  * Load a project's queue from disk and resume processing. Tasks left
  * in "processing" by an abrupt exit get reverted to "pending" so they
@@ -314,12 +383,15 @@ async function processNext(projectId: string): Promise<void> {
   if (currentProjectId !== projectId) return
 
   const state = useWikiStore.getState()
-  const llmConfig = resolveDefaultModel(state.llmConfig)
+  const llmConfig = next.modelId?.trim()
+    ? resolveModelConfig(next.modelId, state.llmConfig, state.providerConfigs)
+    : resolveDefaultModel(state.llmConfig)
 
   if (!hasUsableLlm(llmConfig, state.providerConfigs)) {
     next.status = "failed"
     next.error = "LLM 未配置,请在设置中配置大模型提供方"
     processing = false
+    currentMergeProgress = null
     await saveQueue(pp)
     return
   }
@@ -329,23 +401,31 @@ async function processNext(projectId: string): Promise<void> {
   )
 
   currentAbortController = new AbortController()
+  currentMergeProgress = { taskId: next.id, stage: "loading" }
 
   try {
     await executeMerge(pp, next.group, next.canonicalSlug, llmConfig, {
       signal: currentAbortController.signal,
+      onProgress: (stage) => {
+        currentMergeProgress = { taskId: next.id, stage }
+      },
     })
     if (currentProjectId !== projectId) return
 
     currentAbortController = null
+    currentMergeProgress = null
+    const completedTask = { ...next }
     queue = queue.filter((t) => t.id !== next.id)
     await saveQueue(pp)
     // Tell the rest of the app the wiki tree changed.
     useWikiStore.getState().bumpDataVersion()
 
     console.log(`[Dedup Queue] Done: ${next.group.slugs.join(",")}`)
+    notifyMergeComplete(completedTask)
   } catch (err) {
     if (currentProjectId !== projectId) return
     currentAbortController = null
+    currentMergeProgress = null
     const message = err instanceof Error ? err.message : String(err)
     next.retryCount++
     next.error = message

+ 254 - 0
src/lib/dedup-runner.spec.ts

@@ -0,0 +1,254 @@
+import { beforeEach, expect, test, vi } from "vitest"
+import type { LlmConfig } from "@/stores/wiki-store"
+import type { FileNode } from "@/types/wiki"
+
+vi.mock("@/commands/fs", () => ({
+  listDirectory: vi.fn(),
+  readFile: vi.fn(),
+  writeFile: vi.fn(),
+  deleteFile: vi.fn(),
+  fileExists: vi.fn(),
+}))
+
+vi.mock("@/lib/llm-client", () => ({
+  streamChat: vi.fn(),
+}))
+
+import { listDirectory, readFile, fileExists } from "@/commands/fs"
+import { streamChat } from "@/lib/llm-client"
+import {
+  loadAllEntitySummaries,
+  loadAllWikiPages,
+  mapWithConcurrency,
+  runDuplicateDetection,
+} from "./dedup-runner"
+import type { EntitySummary } from "./dedup"
+
+const testLlmConfig: LlmConfig = {
+  provider: "openai",
+  model: "gpt-4",
+  apiKey: "test",
+  ollamaUrl: "http://localhost:11434",
+  customEndpoint: "",
+  maxContextSize: 204800,
+  reasoning: { mode: "auto" },
+  localCliIsolation: false,
+}
+
+const mockedListDirectory = vi.mocked(listDirectory)
+const mockedReadFile = vi.mocked(readFile)
+const mockedFileExists = vi.mocked(fileExists)
+const mockedStreamChat = vi.mocked(streamChat)
+
+function entityMarkdown(title: string): string {
+  return `---
+type: entity
+title: ${title}
+tags: []
+---
+# ${title}
+`
+}
+
+function wikiTree(projectPath: string, pageCount: number): FileNode[] {
+  const children: FileNode[] = []
+  for (let i = 0; i < pageCount; i++) {
+    children.push({
+      name: `page-${i}.md`,
+      path: `${projectPath}/wiki/entities/page-${i}.md`,
+      is_dir: false,
+    })
+  }
+  return [
+    {
+      name: "wiki",
+      path: `${projectPath}/wiki`,
+      is_dir: true,
+      children: [
+        {
+          name: "entities",
+          path: `${projectPath}/wiki/entities`,
+          is_dir: true,
+          children,
+        },
+      ],
+    },
+  ]
+}
+
+beforeEach(() => {
+  vi.clearAllMocks()
+  mockedFileExists.mockResolvedValue(false)
+  mockedStreamChat.mockImplementation(async (_config, _messages, callbacks) => {
+    callbacks.onToken('{"groups": []}')
+    callbacks.onDone()
+  })
+})
+
+test("mapWithConcurrency respects concurrency limit", async () => {
+  let inFlight = 0
+  let maxInFlight = 0
+
+  const results = await mapWithConcurrency(
+    [1, 2, 3, 4, 5, 6],
+    2,
+    async (n) => {
+      inFlight++
+      maxInFlight = Math.max(maxInFlight, inFlight)
+      await new Promise((resolve) => setTimeout(resolve, 20))
+      inFlight--
+      return n * 2
+    },
+  )
+
+  expect(maxInFlight).toBeLessThanOrEqual(2)
+  expect(results.sort((a, b) => a - b)).toEqual([2, 4, 6, 8, 10, 12])
+})
+
+test("mapWithConcurrency omits null and undefined results", async () => {
+  const results = await mapWithConcurrency(["a", "b", "c"], 3, async (item) => {
+    if (item === "b") return null
+    if (item === "c") return undefined
+    return item.toUpperCase()
+  })
+
+  expect(results).toEqual(["A"])
+})
+
+test("mapWithConcurrency returns empty array for empty input", async () => {
+  const fn = vi.fn(async () => "x")
+  const results = await mapWithConcurrency([], 4, fn)
+  expect(results).toEqual([])
+  expect(fn).not.toHaveBeenCalled()
+})
+
+test("loadAllWikiPages reads wiki markdown files in parallel", async () => {
+  mockedListDirectory.mockResolvedValue(wikiTree("/Project", 5))
+  mockedReadFile.mockImplementation(async (path) => `content:${path}`)
+
+  const pages = await loadAllWikiPages("/Project")
+
+  expect(pages).toHaveLength(5)
+  expect(mockedReadFile).toHaveBeenCalledTimes(5)
+  for (const page of pages) {
+    expect(page.path).toMatch(/^wiki\/entities\/page-\d+\.md$/)
+    expect(page.content).toContain("content:")
+  }
+})
+
+test("loadAllWikiPages skips unreadable files", async () => {
+  mockedListDirectory.mockResolvedValue(wikiTree("/Project", 3))
+  mockedReadFile.mockImplementation(async (path) => {
+    if (path.endsWith("page-1.md")) {
+      throw new Error("permission denied")
+    }
+    return `ok:${path}`
+  })
+
+  const pages = await loadAllWikiPages("/Project")
+
+  expect(pages).toHaveLength(2)
+  expect(pages.every((p) => !p.path.endsWith("page-1.md"))).toBe(true)
+})
+
+test("loadAllEntitySummaries reads entity and concept pages in parallel", async () => {
+  mockedListDirectory.mockResolvedValue(wikiTree("/Project", 4))
+  mockedReadFile.mockImplementation(async (path) => {
+    const match = path.match(/page-(\d+)\.md$/)
+    const idx = match?.[1] ?? "0"
+    return entityMarkdown(`Page ${idx}`)
+  })
+
+  const summaries = await loadAllEntitySummaries("/Project")
+
+  expect(summaries).toHaveLength(4)
+  expect(mockedReadFile).toHaveBeenCalledTimes(4)
+  expect(summaries.every((s) => s.type === "entity")).toBe(true)
+})
+
+test("loadAllEntitySummaries skips pages without frontmatter", async () => {
+  mockedListDirectory.mockResolvedValue(wikiTree("/Project", 2))
+  mockedReadFile.mockImplementation(async (path) => {
+    if (path.endsWith("page-0.md")) return entityMarkdown("Valid")
+    return "# no frontmatter\n"
+  })
+
+  const summaries = await loadAllEntitySummaries("/Project")
+
+  expect(summaries).toHaveLength(1)
+  expect(summaries[0]?.slug).toBe("page-0")
+})
+
+test("runDuplicateDetection returns scannedPageCount and skips disk when summaries provided", async () => {
+  const summaries: EntitySummary[] = [
+    {
+      slug: "alpha",
+      path: "wiki/entities/alpha.md",
+      type: "entity",
+      title: "Alpha",
+      tags: [],
+    },
+    {
+      slug: "beta",
+      path: "wiki/entities/beta.md",
+      type: "entity",
+      title: "Beta",
+      tags: [],
+    },
+  ]
+
+  const result = await runDuplicateDetection(
+    "/Project",
+    testLlmConfig,
+    { summaries },
+  )
+
+  expect(mockedListDirectory).not.toHaveBeenCalled()
+  expect(mockedReadFile).not.toHaveBeenCalled()
+  expect(result.scannedPageCount).toBe(2)
+  expect(result.groups).toEqual([])
+  expect(mockedStreamChat).toHaveBeenCalledTimes(1)
+})
+
+test("runDuplicateDetection short-circuits when fewer than two summaries", async () => {
+  const summaries: EntitySummary[] = [
+    {
+      slug: "only-one",
+      path: "wiki/entities/only-one.md",
+      type: "entity",
+      title: "Only One",
+      tags: [],
+    },
+  ]
+
+  const result = await runDuplicateDetection(
+    "/Project",
+    testLlmConfig,
+    { summaries },
+  )
+
+  expect(result).toEqual({ groups: [], scannedPageCount: 1 })
+  expect(mockedStreamChat).not.toHaveBeenCalled()
+})
+
+test("runDuplicateDetection invokes onProgress for loading and detecting", async () => {
+  const stages: string[] = []
+  mockedListDirectory.mockResolvedValue(wikiTree("/Project", 2))
+  mockedReadFile.mockImplementation(async (path) => {
+    const match = path.match(/page-(\d+)\.md$/)
+    const idx = match?.[1] ?? "0"
+    return entityMarkdown(`Page ${idx}`)
+  })
+
+  await runDuplicateDetection(
+    "/Project",
+    testLlmConfig,
+    {
+      onProgress: (stage) => {
+        stages.push(stage)
+      },
+    },
+  )
+
+  expect(stages).toEqual(["loading", "detecting"])
+})

+ 122 - 31
src/lib/dedup-runner.ts

@@ -21,6 +21,84 @@ import {
 } from "./dedup"
 import { loadNotDuplicates } from "./dedup-storage"
 
+const WIKI_READ_CONCURRENCY = 12
+const WIKI_WRITE_CONCURRENCY = 12
+
+export type DedupMergeStage = "loading" | "merging" | "writing"
+
+export type DedupScanStage = "loading" | "detecting"
+
+export interface ExecuteMergeOptions {
+  signal?: AbortSignal
+  onProgress?: (stage: DedupMergeStage) => void
+}
+
+export interface RunDuplicateDetectionOptions {
+  signal?: AbortSignal
+  summaries?: EntitySummary[]
+  onProgress?: (stage: DedupScanStage) => void
+}
+
+export interface DuplicateDetectionResult {
+  groups: DuplicateGroup[]
+  scannedPageCount: number
+}
+
+/**
+ * Run `fn` over `items` with a bounded worker pool. Items where `fn`
+ * returns null/undefined are omitted from the result.
+ */
+export async function mapWithConcurrency<T, R>(
+  items: readonly T[],
+  concurrency: number,
+  fn: (item: T) => Promise<R | null | undefined>,
+): Promise<R[]> {
+  if (items.length === 0) return []
+
+  const limit = Math.max(1, concurrency)
+  const results: R[] = []
+  let index = 0
+
+  async function worker(): Promise<void> {
+    while (true) {
+      const i = index++
+      if (i >= items.length) return
+      const result = await fn(items[i])
+      if (result !== null && result !== undefined) {
+        results.push(result)
+      }
+    }
+  }
+
+  await Promise.all(
+    Array.from({ length: Math.min(limit, items.length) }, () => worker()),
+  )
+  return results
+}
+
+async function runWithConcurrency<T>(
+  items: readonly T[],
+  concurrency: number,
+  fn: (item: T) => Promise<void>,
+): Promise<void> {
+  if (items.length === 0) return
+
+  const limit = Math.max(1, concurrency)
+  let index = 0
+
+  async function worker(): Promise<void> {
+    while (true) {
+      const i = index++
+      if (i >= items.length) return
+      await fn(items[i])
+    }
+  }
+
+  await Promise.all(
+    Array.from({ length: Math.min(limit, items.length) }, () => worker()),
+  )
+}
+
 /**
  * Wrap streamChat into the (system, user, signal) → string shape
  * the dedup module expects. Same pattern page-merge uses — keeps
@@ -91,20 +169,20 @@ export async function loadAllEntitySummaries(
 ): Promise<EntitySummary[]> {
   const pp = normalizePath(projectPath)
   const tree = await listDirectory(pp)
-  const out: EntitySummary[] = []
+  const nodes: FileNode[] = []
   for (const prefix of ["wiki/entities", "wiki/concepts"]) {
-    for (const node of walkMd(tree, prefix)) {
-      try {
-        const content = await readFile(node.path)
-        const rel = toWikiRelative(pp, node.path)
-        const summary = extractEntitySummary(rel, content)
-        if (summary) out.push(summary)
-      } catch {
-        // best-effort — skip unreadable pages
-      }
-    }
+    nodes.push(...walkMd(tree, prefix))
   }
-  return out
+
+  return mapWithConcurrency(nodes, WIKI_READ_CONCURRENCY, async (node) => {
+    try {
+      const content = await readFile(node.path)
+      const rel = toWikiRelative(pp, node.path)
+      return extractEntitySummary(rel, content)
+    } catch {
+      return null
+    }
+  })
 }
 
 /** Read every .md under wiki/ as { path, content }. The path is
@@ -114,16 +192,16 @@ export async function loadAllWikiPages(
 ): Promise<{ path: string; content: string }[]> {
   const pp = normalizePath(projectPath)
   const tree = await listDirectory(pp)
-  const out: { path: string; content: string }[] = []
-  for (const node of walkMd(tree, "wiki")) {
+  const nodes = [...walkMd(tree, "wiki")]
+
+  return mapWithConcurrency(nodes, WIKI_READ_CONCURRENCY, async (node) => {
     try {
       const content = await readFile(node.path)
-      out.push({ path: toWikiRelative(pp, node.path), content })
+      return { path: toWikiRelative(pp, node.path), content }
     } catch {
-      // ignore
+      return null
     }
-  }
-  return out
+  })
 }
 
 /**
@@ -134,16 +212,24 @@ export async function loadAllWikiPages(
 export async function runDuplicateDetection(
   projectPath: string,
   llmConfig: LlmConfig,
-  options: { signal?: AbortSignal } = {},
-): Promise<DuplicateGroup[]> {
-  const summaries = await loadAllEntitySummaries(projectPath)
-  if (summaries.length < 2) return []
+  options: RunDuplicateDetectionOptions = {},
+): Promise<DuplicateDetectionResult> {
+  options.onProgress?.("loading")
+  const summaries =
+    options.summaries ?? (await loadAllEntitySummaries(projectPath))
+
+  if (summaries.length < 2) {
+    return { groups: [], scannedPageCount: summaries.length }
+  }
+
+  options.onProgress?.("detecting")
   const notDup = await loadNotDuplicates(projectPath)
   const llm = buildDedupLlmCall(llmConfig)
-  return detectDuplicateGroups(summaries, llm, {
+  const groups = await detectDuplicateGroups(summaries, llm, {
     signal: options.signal,
     notDuplicates: notDup,
   })
+  return { groups, scannedPageCount: summaries.length }
 }
 
 /**
@@ -167,11 +253,13 @@ export async function executeMerge(
   group: DuplicateGroup,
   canonicalSlug: string,
   llmConfig: LlmConfig,
-  options: { signal?: AbortSignal } = {},
+  options: ExecuteMergeOptions = {},
 ): Promise<MergeResult> {
   const pp = normalizePath(projectPath)
+  const { signal, onProgress } = options
 
   // 1. Resolve each group slug to its actual on-disk path + content
+  onProgress?.("loading")
   const allPages = await loadAllWikiPages(pp)
   const pathBySlug = new Map<string, string>()
   for (const p of allPages) {
@@ -199,6 +287,7 @@ export async function executeMerge(
   const otherPages = allPages.filter((p) => !groupPaths.has(p.path))
 
   const llm = buildDedupLlmCall(llmConfig)
+  onProgress?.("merging")
   const result = await mergeDuplicateGroup(
     {
       group: groupPages,
@@ -206,36 +295,38 @@ export async function executeMerge(
       otherWikiPages: otherPages,
     },
     llm,
-    { signal: options.signal },
+    { signal },
   )
 
+  onProgress?.("writing")
+
   // 2. Snapshot backup before any writes. If a write fails partway
   //    through, the user has the pre-merge state intact in
   //    .qmai/page-history/.
   const stamp = new Date().toISOString().replace(/[:.]/g, "-")
   const backupDir = `${pp}/.qmai/page-history/dedup-${stamp}`
-  for (const b of result.backup) {
+  await runWithConcurrency(result.backup, WIKI_WRITE_CONCURRENCY, async (b) => {
     const sanitized = b.path.replace(/[/\\]/g, "_")
     await writeFile(`${backupDir}/${sanitized}`, b.content)
-  }
+  })
 
   // 3. Write canonical
   await writeFile(`${pp}/${result.canonicalPath}`, result.canonicalContent)
 
   // 4. Apply rewrites
-  for (const r of result.rewrites) {
+  await runWithConcurrency(result.rewrites, WIKI_WRITE_CONCURRENCY, async (r) => {
     await writeFile(`${pp}/${r.path}`, r.newContent)
-  }
+  })
 
   // 5. Delete merged-away pages
-  for (const dead of result.pagesToDelete) {
+  await runWithConcurrency(result.pagesToDelete, WIKI_WRITE_CONCURRENCY, async (dead) => {
     try {
       await deleteFile(`${pp}/${dead}`)
     } catch (err) {
       // Surface as a warning — backup is still safe.
       console.warn(`[dedup] failed to delete ${dead}: ${err}`)
     }
-  }
+  })
 
   // 6. Rewrite index.md to drop merged-away entries.
   const indexPath = `${pp}/wiki/index.md`

+ 105 - 0
src/lib/dedup-scan-cache.ts

@@ -0,0 +1,105 @@
+/**
+ * Persist duplicate-detection scan results per project so the
+ * Maintenance UI can restore candidate groups after restart.
+ */
+import { readFile, writeFile, fileExists } from "@/commands/fs"
+import { normalizePath } from "@/lib/path-utils"
+import type { DuplicateGroup } from "@/lib/dedup"
+
+const FILE_NAME = ".qmai/dedup-scan-cache.json"
+
+export interface DedupScanCacheEntry {
+  group: DuplicateGroup
+  canonicalSlug: string
+  skipped: boolean
+}
+
+export interface DedupScanCache {
+  version: 1
+  projectId: string
+  scannedAt: number
+  scannedPageCount: number | null
+  modelId?: string
+  groups: DedupScanCacheEntry[]
+}
+
+function cacheFilePath(projectPath: string): string {
+  return `${normalizePath(projectPath)}/${FILE_NAME}`
+}
+
+function isConfidence(value: unknown): value is DuplicateGroup["confidence"] {
+  return value === "high" || value === "medium" || value === "low"
+}
+
+function parseEntry(raw: unknown): DedupScanCacheEntry | null {
+  if (!raw || typeof raw !== "object") return null
+  const obj = raw as Record<string, unknown>
+  const groupRaw = obj.group
+  if (!groupRaw || typeof groupRaw !== "object") return null
+  const groupObj = groupRaw as Record<string, unknown>
+  const slugs = Array.isArray(groupObj.slugs)
+    ? groupObj.slugs.filter((s): s is string => typeof s === "string")
+    : []
+  if (slugs.length < 2) return null
+  const reason = typeof groupObj.reason === "string" ? groupObj.reason : ""
+  const confidence = isConfidence(groupObj.confidence) ? groupObj.confidence : "low"
+  const canonicalSlug =
+    typeof obj.canonicalSlug === "string" ? obj.canonicalSlug : slugs[0]
+  if (!slugs.includes(canonicalSlug)) return null
+  return {
+    group: { slugs, reason, confidence },
+    canonicalSlug,
+    skipped: obj.skipped === true,
+  }
+}
+
+function parseCache(raw: unknown): DedupScanCache | null {
+  if (!raw || typeof raw !== "object") return null
+  const obj = raw as Record<string, unknown>
+  if (obj.version !== 1) return null
+  if (typeof obj.projectId !== "string" || !obj.projectId.trim()) return null
+  if (typeof obj.scannedAt !== "number" || !Number.isFinite(obj.scannedAt)) return null
+  const scannedPageCount =
+    obj.scannedPageCount === null
+      ? null
+      : typeof obj.scannedPageCount === "number" && Number.isFinite(obj.scannedPageCount)
+        ? obj.scannedPageCount
+        : null
+  const groupsRaw = Array.isArray(obj.groups) ? obj.groups : []
+  const groups = groupsRaw
+    .map(parseEntry)
+    .filter((entry): entry is DedupScanCacheEntry => entry !== null)
+  const modelId = typeof obj.modelId === "string" ? obj.modelId : undefined
+  return {
+    version: 1,
+    projectId: obj.projectId,
+    scannedAt: obj.scannedAt,
+    scannedPageCount,
+    modelId,
+    groups,
+  }
+}
+
+export async function loadDedupScanCache(
+  projectPath: string,
+): Promise<DedupScanCache | null> {
+  const filePath = cacheFilePath(projectPath)
+  try {
+    if (!(await fileExists(filePath))) return null
+  } catch {
+    return null
+  }
+  try {
+    const content = await readFile(filePath)
+    return parseCache(JSON.parse(content))
+  } catch {
+    return null
+  }
+}
+
+export async function saveDedupScanCache(
+  projectPath: string,
+  cache: DedupScanCache,
+): Promise<void> {
+  await writeFile(cacheFilePath(projectPath), JSON.stringify(cache, null, 2))
+}

+ 47 - 0
src/lib/editor-disk-sync.spec.ts

@@ -0,0 +1,47 @@
+import { describe, expect, it } from "vitest"
+import { hasUnsavedLocalEdits, shouldApplyDiskToEditor } from "@/lib/editor-disk-sync"
+
+const DISK_V1 = "disk version one"
+const DISK_V2 = "disk version two"
+const EDITOR_V1 = "editor version one"
+
+describe("editor-disk-sync", () => {
+  it("detects unsaved local edits", () => {
+    expect(hasUnsavedLocalEdits({
+      lastLoaded: DISK_V1,
+      editorContent: EDITOR_V1,
+    })).toBe(true)
+  })
+
+  it("treats pending autosave as unsaved local edits", () => {
+    expect(hasUnsavedLocalEdits({
+      lastLoaded: DISK_V1,
+      editorContent: DISK_V1,
+      hasPendingSave: true,
+    })).toBe(true)
+  })
+
+  it("applies disk when externally changed and editor matches last loaded", () => {
+    expect(shouldApplyDiskToEditor({
+      lastLoaded: DISK_V1,
+      editorContent: DISK_V1,
+      diskContent: DISK_V2,
+    })).toBe(true)
+  })
+
+  it("skips disk apply when editor has local edits", () => {
+    expect(shouldApplyDiskToEditor({
+      lastLoaded: DISK_V1,
+      editorContent: EDITOR_V1,
+      diskContent: DISK_V2,
+    })).toBe(false)
+  })
+
+  it("skips disk apply when disk unchanged", () => {
+    expect(shouldApplyDiskToEditor({
+      lastLoaded: DISK_V1,
+      editorContent: DISK_V1,
+      diskContent: DISK_V1,
+    })).toBe(false)
+  })
+})

+ 22 - 0
src/lib/editor-disk-sync.ts

@@ -0,0 +1,22 @@
+export function hasUnsavedLocalEdits(input: {
+  lastLoaded: string
+  editorContent: string
+  normalize?: (content: string) => string
+  hasPendingSave?: boolean
+}): boolean {
+  const normalize = input.normalize ?? ((content) => content)
+  return normalize(input.editorContent) !== normalize(input.lastLoaded) || Boolean(input.hasPendingSave)
+}
+
+/** True when disk changed on disk and it is safe to overwrite the editor from disk. */
+export function shouldApplyDiskToEditor(input: {
+  lastLoaded: string
+  editorContent: string
+  diskContent: string
+  normalize?: (content: string) => string
+  hasPendingSave?: boolean
+}): boolean {
+  if (hasUnsavedLocalEdits(input)) return false
+  const normalize = input.normalize ?? ((content) => content)
+  return normalize(input.diskContent) !== normalize(input.lastLoaded)
+}

+ 34 - 0
src/lib/llm-model-keys.ts

@@ -0,0 +1,34 @@
+import type { ProviderConfigs } from "@/stores/wiki-store"
+
+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
+}
+
+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
+    const first = config.savedModels?.[0]
+    if (first) return `${key}/${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 ""
+}

+ 85 - 0
src/lib/novel/chapter-path-sync.spec.ts

@@ -0,0 +1,85 @@
+import { describe, expect, it } from "vitest"
+import {
+  chapterContentMatchesPath,
+  getDraftChapterPath,
+  isDraftChapterPath,
+  resolveChapterFlushMarkdown,
+  shouldSkipEmptyChapterFlush,
+  shouldSyncChapterOnLeave,
+} from "@/lib/novel/chapter-path-sync"
+
+const CH45 = `---
+type: chapter
+chapter_number: 45
+chapter_status: draft
+title: "第45章-空情之眼"
+---
+
+# 第45章 空情之眼
+
+第四十五章正文。`
+
+const CH46 = `---
+type: chapter
+chapter_number: 46
+chapter_status: draft
+title: "第46章-逆火第一轮"
+---
+
+# 第46章 逆火第一轮
+
+第四十六章正文。`
+
+describe("isDraftChapterPath", () => {
+  it("detects AI draft chapter filenames", () => {
+    expect(isDraftChapterPath("/proj/wiki/chapters/chapter-046.md")).toBe(true)
+    expect(isDraftChapterPath("/proj/wiki/chapters/第46章-逆火第一轮.md")).toBe(false)
+  })
+})
+
+describe("chapterContentMatchesPath", () => {
+  it("rejects syncing chapter 46 content onto chapter 45 path", () => {
+    expect(chapterContentMatchesPath("/proj/wiki/chapters/第45章-空情之眼.md", CH46)).toBe(false)
+    expect(chapterContentMatchesPath("/proj/wiki/chapters/chapter-046.md", CH46)).toBe(true)
+  })
+})
+
+describe("resolveChapterFlushMarkdown", () => {
+  it("falls back to per-path snapshot instead of unrelated store content", () => {
+    const snapshots = new Map([
+      ["/proj/wiki/chapters/第45章-空情之眼.md", CH45],
+      ["/proj/wiki/chapters/chapter-046.md", CH46],
+    ])
+    expect(resolveChapterFlushMarkdown("/proj/wiki/chapters/第45章-空情之眼.md", "", snapshots)).toBe(CH45)
+  })
+})
+
+describe("shouldSyncChapterOnLeave", () => {
+  it("skips empty flush when the path still has loaded content", () => {
+    expect(shouldSyncChapterOnLeave("/proj/wiki/chapters/第45章-空情之眼.md", "", CH45)).toBe(false)
+  })
+
+  it("skips cross-chapter contamination", () => {
+    expect(shouldSyncChapterOnLeave("/proj/wiki/chapters/第45章-空情之眼.md", CH46, CH45)).toBe(false)
+  })
+
+  it("allows unchanged content skip", () => {
+    expect(shouldSyncChapterOnLeave("/proj/wiki/chapters/chapter-046.md", CH46, CH46)).toBe(false)
+  })
+})
+
+describe("shouldSkipEmptyChapterFlush", () => {
+  it("skips flushing empty markdown when disk snapshot still has content", () => {
+    expect(shouldSkipEmptyChapterFlush("", CH45)).toBe(true)
+  })
+
+  it("allows flushing intentional empty edits when nothing was loaded", () => {
+    expect(shouldSkipEmptyChapterFlush("", "")).toBe(false)
+  })
+})
+
+describe("getDraftChapterPath", () => {
+  it("builds the draft alias path for cleanup", () => {
+    expect(getDraftChapterPath("/proj/wiki/chapters", 46)).toBe("/proj/wiki/chapters/chapter-046.md")
+  })
+})

+ 60 - 0
src/lib/novel/chapter-path-sync.ts

@@ -0,0 +1,60 @@
+import { parseFrontmatter } from "@/lib/frontmatter"
+import { parseChapterMeta } from "@/lib/novel/chapter-meta"
+import { extractChapterNumber } from "@/lib/novel/chapter-utils"
+
+/** AI / chat draft files such as `chapter-046.md`. */
+export function isDraftChapterPath(path: string): boolean {
+  const name = path.replace(/\\/g, "/").split("/").pop() ?? ""
+  return /^chapter-\d+\.md$/i.test(name)
+}
+
+export function getDraftChapterPath(chapterDir: string, chapterNumber: number): string {
+  return `${chapterDir.replace(/\\/g, "/").replace(/\/$/, "")}/chapter-${String(chapterNumber).padStart(3, "0")}.md`
+}
+
+export function extractChapterNumberFromPath(path: string): number | null {
+  const stem = (path.replace(/\\/g, "/").split("/").pop() ?? "").replace(/\.md$/i, "")
+  const draftMatch = stem.match(/^chapter-(\d+)$/i)
+  if (draftMatch?.[1]) return Number.parseInt(draftMatch[1], 10)
+  return extractChapterNumber(stem)
+}
+
+export function extractChapterNumberFromMarkdown(markdown: string): number | null {
+  const { frontmatter } = parseFrontmatter(markdown)
+  if (!frontmatter || typeof frontmatter !== "object") return null
+  return parseChapterMeta(frontmatter as Record<string, unknown>)?.chapterNumber ?? null
+}
+
+/** Avoid flushing an empty in-memory snapshot over a chapter that still has disk content. */
+export function shouldSkipEmptyChapterFlush(markdown: string, lastLoadedForPath: string): boolean {
+  return !markdown.trim() && Boolean(lastLoadedForPath.trim())
+}
+
+export function chapterContentMatchesPath(path: string, markdown: string): boolean {
+  const pathNumber = extractChapterNumberFromPath(path)
+  const markdownNumber = extractChapterNumberFromMarkdown(markdown)
+  if (pathNumber === null || markdownNumber === null) return true
+  return pathNumber === markdownNumber
+}
+
+export function resolveChapterFlushMarkdown(
+  path: string,
+  markdown: string,
+  lastLoadedByPath: ReadonlyMap<string, string>,
+): string {
+  const trimmed = markdown.trim()
+  if (trimmed) return markdown
+  return lastLoadedByPath.get(path) ?? ""
+}
+
+export function shouldSyncChapterOnLeave(
+  path: string,
+  markdown: string,
+  lastLoadedForPath: string,
+): boolean {
+  const resolved = resolveChapterFlushMarkdown(path, markdown, new Map([[path, lastLoadedForPath]]))
+  if (!resolved.trim()) return false
+  if (shouldSkipEmptyChapterFlush(resolved, lastLoadedForPath)) return false
+  if (!chapterContentMatchesPath(path, resolved)) return false
+  return resolved !== lastLoadedForPath
+}

+ 28 - 0
src/lib/novel/classification/classification.story-sim.md

@@ -0,0 +1,28 @@
+# 分支级路由补丁:推演室意图
+
+## 说明
+
+本文档描述推演室分支(juqingtuiyanshierqi)相对于 main 分支新增的 3 个 AI 会话意图。
+这些意图仅在推演室分支中生效,用于从 AI 会话跳转到剧情推演室面板。
+
+## 新增意图列表
+
+| 意图 ID | 名称 | 匹配关键词 | 优先级 | 行为 |
+|---------|------|-----------|--------|------|
+| `story_framework_generate` | 故事框架生成 | 故事框架、剧情框架、生成框架 | 10 | 跳转到推演室,phase = configuring |
+| `multi_agent_simulate` | 多智能体推演 | 推演剧情、多智能体推演、剧情走向、推演一下、推演剧情走向 | 10 | 跳转到推演室,hasFramework 时 phase = simulating,否则 configuring |
+| `character_interview` | 角色采访 | 角色采访、采访角色、问角色 | 10 | 跳转到推演室,hasFramework 且有 savedResults 时 phase = report-viewing,否则 configuring |
+
+## 跳转行为
+
+当 AI 会话识别到上述任一意图时:
+1. 调用 `storySimulationStore.initWithPreset()` 预填配置
+2. 切换侧栏到推演室面板(`setActiveView("storySimulation")`)
+3. AI 会话回复固定提示:"已为你打开剧情推演室并预填配置,请在推演室中继续操作。"
+4. 不执行原意图的工具调用流程
+
+## 注意事项
+
+- 这 3 个意图只做跳转,不在 AI 会话中执行实际逻辑
+- 属于分支级补丁,main 分支不含这些意图
+- 合并回 main 时需要评估是否保留

+ 6 - 2
src/lib/novel/deep-chapter-generation.ts

@@ -4,6 +4,7 @@ import { useWikiStore } from "@/stores/wiki-store"
 import type { AiWorkflowMode } from "@/lib/agent/workflow-mode"
 import type { AgentActivityEvent, AgentActivityKind } from "@/lib/agent/types"
 import { isReasoningDisabled, isReasoningOnlyResponseError, withReasoningDisabled } from "@/lib/reasoning-retry"
+import { resolveNovelModel } from "./model-resolver"
 import { buildContextPack, contextPackToPrompt, type ContextPack } from "./context-engine"
 import { reviewChapter, type NovelReviewResult } from "./review-adapter"
 import type { TaskRouteResult } from "./task-router"
@@ -321,6 +322,9 @@ export async function runDeepChapterGeneration(
   const workflowProfile = resolveChapterWorkflowProfile(input.aiWorkflowMode)
   const lengthSpec = resolveCurrentChapterLengthSpec()
   const novelConfig = useWikiStore.getState().novelConfig
+  const writingConfig = resolveWritingConfig(input.llmConfig)
+  const deAiConfig = resolveNovelModel(input.llmConfig, novelConfig, "deAi")
+  const lengthSpec = resolveCurrentChapterLengthSpec()
   const { loadSmartDeAiSkill } = await import("./de-ai-adapter")
   const workflowBaseParams = {
     mode: workflowProfile.mode,
@@ -909,7 +913,7 @@ export async function runDeepChapterGeneration(
 }
 
 async function finalPolishChapter(
-  writingConfig: LlmConfig,
+  deAiConfig: LlmConfig,
   outlinePrompt: string,
   contextPrompt: string,
   taskBrief: string,
@@ -925,7 +929,7 @@ async function finalPolishChapter(
   assertNotAborted(signal)
   callbacks.onThinking?.(formatStageThinking("阶段6:简单审查与去AI味", "正在进行最后一遍简单审查,去除复读、机械套话和 AI 味。"))
   const polished = await collectModelText(
-    writingConfig,
+    deAiConfig,
     [{
       role: "user",
       content: buildDeepChapterFinalPolishPrompt(

+ 89 - 32
src/lib/novel/model-resolver.ts

@@ -3,12 +3,57 @@ import { LLM_PRESETS } from "@/components/settings/llm-presets"
 import { resolveConfig } from "@/components/settings/preset-resolver"
 import { hasUsableLlm } from "@/lib/has-usable-llm"
 
-export type NovelTaskType = "writing" | "review" | "summary" | "extract" | "lint"
+export type NovelTaskType = "writing" | "review" | "summary" | "extract" | "lint" | "deAi"
+
+const UNUSABLE_LLM_CONFIG: Pick<LlmConfig, "provider" | "apiKey" | "model"> = {
+  provider: "openai",
+  apiKey: "",
+  model: "",
+}
 
 function isConfigUsable(cfg: LlmConfig, providerConfigs: Record<string, ProviderOverride>): boolean {
   return hasUsableLlm(cfg, providerConfigs)
 }
 
+function toUnusableConfig(baseConfig: LlmConfig): LlmConfig {
+  return { ...baseConfig, ...UNUSABLE_LLM_CONFIG }
+}
+
+export function isModelKeyRegistered(
+  targetModel: string,
+  providerConfigs: Record<string, ProviderOverride>,
+): boolean {
+  const trimmed = targetModel.trim()
+  if (!trimmed) return false
+
+  const slashIdx = trimmed.indexOf("/")
+  if (slashIdx > 0) {
+    const providerId = trimmed.slice(0, slashIdx)
+    const modelId = trimmed.slice(slashIdx + 1)
+    return !!providerConfigs[providerId]?.savedModels?.some((m) => m.model === modelId)
+  }
+
+  for (const override of Object.values(providerConfigs)) {
+    if (override.savedModels?.some((m) => m.model === trimmed)) {
+      return true
+    }
+  }
+  return false
+}
+
+function resolveRegisteredModel(
+  targetModel: string,
+  baseConfig: LlmConfig,
+  providerConfigs: Record<string, ProviderOverride>,
+): LlmConfig | null {
+  const trimmed = targetModel.trim()
+  if (!trimmed || !isModelKeyRegistered(trimmed, providerConfigs)) {
+    return null
+  }
+  const cfg = resolveModelConfig(trimmed, baseConfig, providerConfigs)
+  return isConfigUsable(cfg, providerConfigs) ? cfg : null
+}
+
 export function resolveModelConfig(
   targetModel: string,
   baseConfig: LlmConfig,
@@ -40,32 +85,33 @@ export function resolveModelConfig(
   return { ...baseConfig, model: targetModel }
 }
 
+function resolveProjectDefaultLlmModel(): string {
+  const { novelConfig, defaultLlmModel } = useWikiStore.getState()
+  return novelConfig.defaultLlmModel?.trim() || defaultLlmModel?.trim() || ""
+}
+
 /**
  * 解析后台任务的默认模型。
  * 优先级:defaultLlmModel > aiChatModel
  * 不回退到 baseConfig(llmConfig),避免静默使用已禁用的 CLI provider。
- * 用于提取记忆、提取角色等后台 AI 任务。
+ * 用于导入队列、书籍分析、去重等通用后台任务;不含小说章节/大纲摄取(见 resolveNovelModel)
  */
 export function resolveDefaultModel(baseConfig: LlmConfig): LlmConfig {
-  const { providerConfigs, defaultLlmModel, aiChatModel } = useWikiStore.getState()
+  const { providerConfigs, aiChatModel } = useWikiStore.getState()
 
-  const defaultModel = defaultLlmModel?.trim()
+  const defaultModel = resolveProjectDefaultLlmModel()
   if (defaultModel) {
-    const cfg = resolveModelConfig(defaultModel, baseConfig, providerConfigs)
-    if (isConfigUsable(cfg, providerConfigs)) {
-      return cfg
-    }
+    const cfg = resolveRegisteredModel(defaultModel, baseConfig, providerConfigs)
+    if (cfg) return cfg
   }
 
   const chatModel = aiChatModel?.trim()
   if (chatModel && chatModel !== defaultModel) {
-    const cfg = resolveModelConfig(chatModel, baseConfig, providerConfigs)
-    if (isConfigUsable(cfg, providerConfigs)) {
-      return cfg
-    }
+    const cfg = resolveRegisteredModel(chatModel, baseConfig, providerConfigs)
+    if (cfg) return cfg
   }
 
-  return { ...baseConfig, apiKey: "", model: "" }
+  return toUnusableConfig(baseConfig)
 }
 
 export function resolveNovelModel(
@@ -74,38 +120,49 @@ export function resolveNovelModel(
   taskType: NovelTaskType,
 ): LlmConfig {
   const modelMap: Record<NovelTaskType, string> = {
-    writing: "", // 写作模型已移除,始终使用 AI 会话当前模型
+    writing: "", // 写作正文属于“聊天写小说”,直接用聊天模型(下方特判)
     review: novelConfig.reviewModel,
     summary: novelConfig.summaryModel,
     extract: novelConfig.extractModel,
     lint: novelConfig.reviewModel,
+    deAi: novelConfig.deAiModel,
   }
 
-  const { providerConfigs, defaultLlmModel, aiChatModel } = useWikiStore.getState()
+  const { providerConfigs, aiChatModel } = useWikiStore.getState()
+  const chatModel = aiChatModel?.trim()
 
+  // 写作正文:聊天模型优先,默认模型兜底。
+  if (taskType === "writing") {
+    if (chatModel) {
+      const cfg = resolveRegisteredModel(chatModel, llmConfig, providerConfigs)
+      if (cfg) return cfg
+    }
+    const defaultModel = resolveProjectDefaultLlmModel()
+    if (defaultModel && defaultModel !== chatModel) {
+      const cfg = resolveRegisteredModel(defaultModel, llmConfig, providerConfigs)
+      if (cfg) return cfg
+    }
+    return toUnusableConfig(llmConfig)
+  }
+
+  // 其余任务级模型(审稿/摘要/提取/lint):
+  //   任务单独设置 > 默认模型 > 聊天模型(仅默认未设置时兜底)
   const taskModel = modelMap[taskType]
   if (taskModel?.trim()) {
-    const cfg = resolveModelConfig(taskModel, llmConfig, providerConfigs)
-    if (isConfigUsable(cfg, providerConfigs)) {
-      return cfg
-    }
+    const cfg = resolveRegisteredModel(taskModel, llmConfig, providerConfigs)
+    if (cfg) return cfg
   }
 
-  const chatModel = aiChatModel?.trim()
-  if (chatModel) {
-    const cfg = resolveModelConfig(chatModel, llmConfig, providerConfigs)
-    if (isConfigUsable(cfg, providerConfigs)) {
-      return cfg
-    }
+  const defaultModel = resolveProjectDefaultLlmModel()
+  if (defaultModel) {
+    const cfg = resolveRegisteredModel(defaultModel, llmConfig, providerConfigs)
+    if (cfg) return cfg
   }
 
-  const defaultModel = defaultLlmModel?.trim()
-  if (defaultModel && defaultModel !== chatModel) {
-    const cfg = resolveModelConfig(defaultModel, llmConfig, providerConfigs)
-    if (isConfigUsable(cfg, providerConfigs)) {
-      return cfg
-    }
+  if (chatModel && chatModel !== defaultModel) {
+    const cfg = resolveRegisteredModel(chatModel, llmConfig, providerConfigs)
+    if (cfg) return cfg
   }
 
-  return { ...llmConfig, apiKey: "", model: "" }
+  return toUnusableConfig(llmConfig)
 }

+ 12 - 7
src/lib/novel/novel-model-test.ts

@@ -1,8 +1,8 @@
 import { streamChat } from "@/lib/llm-client"
-import { resolveNovelModel } from "@/lib/novel/model-resolver"
+import { resolveDefaultModel, resolveNovelModel } from "@/lib/novel/model-resolver"
 import type { LlmConfig, NovelConfig } from "@/stores/wiki-store"
 
-export type TestableNovelModelTask = "writing" | "review" | "summary" | "extract"
+export type TestableNovelModelTask = "writing" | "review" | "summary" | "extract" | "deAi" | "workflow"
 
 export interface NovelModelTestResult {
   model: string
@@ -12,9 +12,11 @@ export interface NovelModelTestResult {
 
 const TEST_PROMPTS: Record<TestableNovelModelTask, string> = {
   writing: "你正在执行小说写作模型测试。请只回复“写作模型测试成功”。",
+  workflow: "你正在执行默认模型(工作流)测试。请只回复“默认模型测试成功”。",
   review: "你正在执行小说审稿模型测试。请只回复“审稿模型测试成功”。",
   summary: "你正在执行小说摘要模型测试。请只回复“摘要模型测试成功”。",
   extract: "你正在执行小说资料提取模型测试。请只回复“提取模型测试成功”。",
+  deAi: "你正在执行小说去AI味模型测试。请只回复“去AI味模型测试成功”。",
 }
 
 export async function testNovelModel(
@@ -22,7 +24,9 @@ export async function testNovelModel(
   novelConfig: NovelConfig,
   taskType: TestableNovelModelTask,
 ): Promise<NovelModelTestResult> {
-  const effectiveConfig = resolveNovelModel(llmConfig, novelConfig, taskType)
+  const effectiveConfig = taskType === "workflow"
+    ? resolveDefaultModel(llmConfig)
+    : resolveNovelModel(llmConfig, novelConfig, taskType)
   const model = effectiveConfig.model.trim()
   if (!model) {
     throw new Error("请先配置主模型或当前小说专用模型后再测试。")
@@ -56,10 +60,11 @@ export async function testNovelModel(
     throw new Error("模型已连接,但没有返回可用内容。")
   }
 
-  const modelKey = `${taskType}Model` as const
-
-  // writingModel 已移除,writing 任务始终视为回退到 AI 会话模型
-  const usedFallbackModel = taskType === "writing" ? true : !novelConfig[modelKey].trim()
+  const usedFallbackModel = taskType === "workflow"
+    ? !novelConfig.defaultLlmModel.trim()
+    : taskType === "writing"
+      ? true
+      : !novelConfig[`${taskType}Model` as "reviewModel" | "summaryModel" | "extractModel" | "deAiModel"].trim()
 
   return {
     model,

+ 3 - 3
src/lib/novel/outline-generation.ts

@@ -13,7 +13,7 @@ import { useWikiStore } from "@/stores/wiki-store"
 import { hasUsableLlm } from "@/lib/has-usable-llm"
 import { ingestOutline } from "./chapter-ingest"
 import { buildContextPack, type ContextPack } from "./context-engine"
-import { resolveModelConfig } from "@/lib/novel/model-resolver"
+import { resolveDefaultModel, resolveModelConfig } from "@/lib/novel/model-resolver"
 import { readSoulDoc } from "./soul-doc"
 
 export type OutlineSectionGenerationKey =
@@ -510,7 +510,7 @@ export async function runOutlineGenerationTask(taskId: string, llmConfig: LlmCon
   const { providerConfigs } = useWikiStore.getState()
   const effectiveLlmConfig = task.modelId
     ? resolveModelConfig(task.modelId, llmConfig, providerConfigs)
-    : llmConfig
+    : resolveDefaultModel(llmConfig)
 
   if (!hasUsableLlm(effectiveLlmConfig, providerConfigs)) {
     throw new Error("请先在设置中配置并选择一个可用的 AI 模型,或在大纲弹窗中选择模型后再试。")
@@ -564,7 +564,7 @@ export async function runOutlineRefinementTask(taskId: string, llmConfig: LlmCon
   const { providerConfigs } = useWikiStore.getState()
   const effectiveLlmConfig = task.modelId
     ? resolveModelConfig(task.modelId, llmConfig, providerConfigs)
-    : llmConfig
+    : resolveDefaultModel(llmConfig)
 
   if (!hasUsableLlm(effectiveLlmConfig, providerConfigs)) {
     throw new Error("请先在设置中配置并选择一个可用的 AI 模型,或在大纲弹窗中选择模型后再试。")

+ 270 - 0
src/lib/novel/story-simulation/spread-rumor.spec.ts

@@ -0,0 +1,270 @@
+import { describe, expect, it, vi } from "vitest"
+
+import type { NovelAgent, RumorEvent } from "@/lib/novel/story-simulation/types"
+import {
+  createSimulationBlackboard,
+  recordRumorEvent,
+  spreadRumor,
+} from "@/lib/novel/story-simulation/multi-agent-orchestrator"
+
+function makeAgent(id: string, name: string): NovelAgent {
+  return {
+    characterId: id,
+    name,
+    profile: `${name} profile`,
+    aura: null,
+    cognition: null,
+    soul: "",
+    currentGoal: "完成当前节点目标",
+    emotionalState: "neutral",
+    knownFacts: new Set(),
+    relationships: new Map(),
+    powerLevel: "normal",
+    memory: {
+      observedEvents: [],
+      knownSecrets: new Set(),
+      sentiments: new Map(),
+      recentDecisions: [],
+      rumorCredibility: 0.5,
+    },
+    knowledgeScope: [],
+    personality: [],
+    speakingStyle: "",
+  }
+}
+
+function makeRumorEvent(id: string, observableBy: string[], distortion = 0.3): RumorEvent {
+  return {
+    id,
+    round: 0,
+    nodeIndex: 0,
+    sourceId: null,
+    content: `传闻内容:${id}`,
+    distortion,
+    observableBy,
+    believedBy: [],
+    verifiedBy: [],
+    timestamp: "2026-07-04T00:00:00.000Z",
+    generation: 0,
+  }
+}
+
+describe("spreadRumor", () => {
+  it("基本传播:生成新传闻,加入目标可见列表", () => {
+    const agents = [makeAgent("a", "甲"), makeAgent("b", "乙"), makeAgent("c", "丙")]
+    const blackboard = createSimulationBlackboard({ agents })
+    const sourceRumor = makeRumorEvent("rumor-1", ["a"])
+    recordRumorEvent(blackboard, sourceRumor)
+
+    const result = spreadRumor(blackboard, "rumor-1", "a", "b", "我跟你说个秘密")
+
+    expect(result.newRumor).not.toBeNull()
+    expect(result.newRumor!.content).toBe("我跟你说个秘密")
+    expect(blackboard.rumors).toHaveLength(2)
+    expect(blackboard.visibleRumorsByAgent.get("b")!.map((r) => r.id)).toContain(
+      result.newRumor!.id,
+    )
+  })
+
+  it("失真度增加:新传闻 distortion >= 源传闻 distortion", () => {
+    const agents = [makeAgent("a", "甲"), makeAgent("b", "乙")]
+    const blackboard = createSimulationBlackboard({ agents })
+    const sourceRumor = makeRumorEvent("rumor-1", ["a"], 0.2)
+    recordRumorEvent(blackboard, sourceRumor)
+
+    const result = spreadRumor(blackboard, "rumor-1", "a", "b", "")
+
+    expect(result.newRumor!.distortion).toBeGreaterThanOrEqual(sourceRumor.distortion + 0.1)
+    expect(result.newRumor!.distortion).toBeLessThanOrEqual(1)
+  })
+
+  it("generation 递增:子代 generation = 父代 + 1", () => {
+    const agents = [makeAgent("a", "甲"), makeAgent("b", "乙"), makeAgent("c", "丙")]
+    const blackboard = createSimulationBlackboard({ agents })
+    const sourceRumor = makeRumorEvent("rumor-1", ["a"])
+    recordRumorEvent(blackboard, sourceRumor)
+
+    const result1 = spreadRumor(blackboard, "rumor-1", "a", "b", "一传")
+    expect(result1.newRumor!.generation).toBe(1)
+
+    const result2 = spreadRumor(blackboard, result1.newRumor!.id, "b", "c", "二传")
+    expect(result2.newRumor!.generation).toBe(2)
+  })
+
+  it("parentId 和 spreadBy 正确设置", () => {
+    const agents = [makeAgent("a", "甲"), makeAgent("b", "乙")]
+    const blackboard = createSimulationBlackboard({ agents })
+    const sourceRumor = makeRumorEvent("rumor-1", ["a"])
+    recordRumorEvent(blackboard, sourceRumor)
+
+    const result = spreadRumor(blackboard, "rumor-1", "a", "b", "传一下")
+
+    expect(result.newRumor!.parentId).toBe("rumor-1")
+    expect(result.newRumor!.spreadBy).toBe("a")
+    expect(result.newRumor!.spreadRound).toBe(1)
+    expect(result.newRumor!.round).toBe(1)
+  })
+
+  it("传播不存在的传闻:返回 null", () => {
+    const agents = [makeAgent("a", "甲"), makeAgent("b", "乙")]
+    const blackboard = createSimulationBlackboard({ agents })
+
+    const result = spreadRumor(blackboard, "nonexistent", "a", "b", "传一下")
+
+    expect(result.newRumor).toBeNull()
+    expect(result.targetBelieved).toBe(false)
+  })
+
+  it("传播给不存在的目标:返回 null", () => {
+    const agents = [makeAgent("a", "甲"), makeAgent("b", "乙")]
+    const blackboard = createSimulationBlackboard({ agents })
+    const sourceRumor = makeRumorEvent("rumor-1", ["a"])
+    recordRumorEvent(blackboard, sourceRumor)
+
+    const result = spreadRumor(blackboard, "rumor-1", "a", "nonexistent", "传一下")
+
+    expect(result.newRumor).toBeNull()
+    expect(result.targetBelieved).toBe(false)
+  })
+
+  it("传播者看不到传闻时:返回 null", () => {
+    const agents = [makeAgent("a", "甲"), makeAgent("b", "乙")]
+    const blackboard = createSimulationBlackboard({ agents })
+    const sourceRumor = makeRumorEvent("rumor-1", ["b"])
+    recordRumorEvent(blackboard, sourceRumor)
+
+    const result = spreadRumor(blackboard, "rumor-1", "a", "b", "传一下")
+
+    expect(result.newRumor).toBeNull()
+    expect(result.targetBelieved).toBe(false)
+  })
+
+  it("message 为空时使用源传闻 content", () => {
+    const agents = [makeAgent("a", "甲"), makeAgent("b", "乙")]
+    const blackboard = createSimulationBlackboard({ agents })
+    const sourceRumor = makeRumorEvent("rumor-1", ["a"])
+    recordRumorEvent(blackboard, sourceRumor)
+
+    const result = spreadRumor(blackboard, "rumor-1", "a", "b", "")
+
+    expect(result.newRumor!.content).toBe(sourceRumor.content)
+  })
+
+  it("可信度影响:高可信度传播者更容易被相信", () => {
+    const agentA = makeAgent("a", "甲")
+    const agentB = makeAgent("b", "乙")
+    agentA.memory.rumorCredibility = 1.0
+    agentB.memory.sentiments.set("a", 0)
+
+    const blackboard = createSimulationBlackboard({ agents: [agentA, agentB] })
+    const sourceRumor = makeRumorEvent("rumor-1", ["a"], 0.0)
+    recordRumorEvent(blackboard, sourceRumor)
+
+    let callCount = 0
+    const mockRandom = vi.fn(() => {
+      callCount++
+      if (callCount === 1) return 0
+      return 0.99
+    })
+    const originalRandom = Math.random
+    Math.random = mockRandom
+
+    try {
+      const result = spreadRumor(blackboard, "rumor-1", "a", "b", "")
+      const baseBelief = (1 - result.newRumor!.distortion) * 0.6
+      const credibilityBonus = (1.0 - 0.5) * 0.4
+      const finalProb = Math.max(0.1, Math.min(0.9, baseBelief + credibilityBonus))
+      expect(finalProb).toBeGreaterThan(0.6)
+    } finally {
+      Math.random = originalRandom
+    }
+  })
+
+  it("低可信度传播者更难被相信", () => {
+    const agentA = makeAgent("a", "甲")
+    const agentB = makeAgent("b", "乙")
+    agentA.memory.rumorCredibility = 0.0
+    agentB.memory.sentiments.set("a", 0)
+
+    const blackboard = createSimulationBlackboard({ agents: [agentA, agentB] })
+    const sourceRumor = makeRumorEvent("rumor-1", ["a"], 0.5)
+    recordRumorEvent(blackboard, sourceRumor)
+
+    const mockRandom = vi.fn(() => 0.01)
+    const originalRandom = Math.random
+    Math.random = mockRandom
+
+    try {
+      const result = spreadRumor(blackboard, "rumor-1", "a", "b", "")
+      const baseBelief = (1 - result.newRumor!.distortion) * 0.6
+      const credibilityBonus = (0.0 - 0.5) * 0.4
+      const finalProb = Math.max(0.1, Math.min(0.9, baseBelief + credibilityBonus))
+      expect(finalProb).toBeLessThan(0.5)
+    } finally {
+      Math.random = originalRandom
+    }
+  })
+
+  it("情感影响:目标对传播者好感度高更容易相信", () => {
+    const agentA = makeAgent("a", "甲")
+    const agentB = makeAgent("b", "乙")
+    agentB.memory.sentiments.set("a", 100)
+
+    const blackboard = createSimulationBlackboard({ agents: [agentA, agentB] })
+    const sourceRumor = makeRumorEvent("rumor-1", ["a"], 0.5)
+    recordRumorEvent(blackboard, sourceRumor)
+
+    const result = spreadRumor(blackboard, "rumor-1", "a", "b", "")
+    expect(result.newRumor).not.toBeNull()
+  })
+
+  it("sourceId 正确继承", () => {
+    const agents = [makeAgent("a", "甲"), makeAgent("b", "乙")]
+    const blackboard = createSimulationBlackboard({ agents })
+    const sourceRumor = makeRumorEvent("rumor-1", ["a"])
+    sourceRumor.sourceId = "event-123"
+    recordRumorEvent(blackboard, sourceRumor)
+
+    const result = spreadRumor(blackboard, "rumor-1", "a", "b", "")
+
+    expect(result.newRumor!.sourceId).toBe("event-123")
+  })
+
+  it("相信传闻后目标加入 believedBy", () => {
+    const agents = [makeAgent("a", "甲"), makeAgent("b", "乙")]
+    const blackboard = createSimulationBlackboard({ agents })
+    const sourceRumor = makeRumorEvent("rumor-1", ["a"], 0.0)
+    recordRumorEvent(blackboard, sourceRumor)
+
+    const mockRandom = vi.fn(() => 0.0)
+    const originalRandom = Math.random
+    Math.random = mockRandom
+
+    try {
+      const result = spreadRumor(blackboard, "rumor-1", "a", "b", "")
+      expect(result.targetBelieved).toBe(true)
+      expect(result.newRumor!.believedBy).toContain("b")
+    } finally {
+      Math.random = originalRandom
+    }
+  })
+
+  it("不相信传闻时目标不加入 believedBy", () => {
+    const agents = [makeAgent("a", "甲"), makeAgent("b", "乙")]
+    const blackboard = createSimulationBlackboard({ agents })
+    const sourceRumor = makeRumorEvent("rumor-1", ["a"], 0.99)
+    recordRumorEvent(blackboard, sourceRumor)
+
+    const mockRandom = vi.fn(() => 0.99)
+    const originalRandom = Math.random
+    Math.random = mockRandom
+
+    try {
+      const result = spreadRumor(blackboard, "rumor-1", "a", "b", "")
+      expect(result.targetBelieved).toBe(false)
+      expect(result.newRumor!.believedBy).not.toContain("b")
+    } finally {
+      Math.random = originalRandom
+    }
+  })
+})

+ 3 - 23
src/lib/project-file-sync.ts

@@ -1,5 +1,5 @@
 import { listen, type UnlistenFn } from "@tauri-apps/api/event"
-import { readFile, listDirectory } from "@/commands/fs"
+import { listDirectory } from "@/commands/fs"
 import {
   rescanProjectFiles,
   startProjectFileWatcher,
@@ -141,9 +141,8 @@ async function processFileChangeBatch(
   await refreshAfterFileChanges(project, paths)
 }
 
-async function refreshAfterFileChanges(project: WikiProject, relativePaths: string[]): Promise<void> {
+async function refreshAfterFileChanges(project: WikiProject, _relativePaths: string[]): Promise<void> {
   const pp = normalizePath(project.path)
-  const store = useWikiStore.getState()
   try {
     const tree = await listDirectory(pp)
     useWikiStore.getState().setFileTree(tree)
@@ -151,26 +150,7 @@ async function refreshAfterFileChanges(project: WikiProject, relativePaths: stri
     console.warn("[file-sync] failed to refresh file tree:", err)
   }
 
-  store.bumpDataVersion()
-
-  const selected = store.selectedFile ? normalizePath(store.selectedFile) : null
-  if (!selected) return
-
-  const selectedRel = selected.startsWith(`${pp}/`) ? selected.slice(pp.length + 1) : selected
-  if (!relativePaths.includes(selectedRel)) return
-
-  try {
-    const content = await readFile(selected)
-    const currentContent = useWikiStore.getState().fileContent
-    if (currentContent && currentContent !== content) {
-      console.warn("[file-sync] 检测到编辑器有未保存内容,跳过文件刷新:", selected)
-      return
-    }
-    useWikiStore.getState().setFileContent(content)
-  } catch {
-    useWikiStore.getState().setSelectedFile(null)
-    useWikiStore.getState().setFileContent("")
-  }
+  useWikiStore.getState().bumpDataVersion()
 }
 
 async function enqueueRawSourceChanges(project: WikiProject, tasks: FileChangeTask[]): Promise<void> {

+ 81 - 0
src/lib/project-file-tree-refresh.ts

@@ -0,0 +1,81 @@
+import { listDirectory } from "@/commands/fs"
+import { useWikiStore } from "@/stores/wiki-store"
+import { normalizePath } from "@/lib/path-utils"
+
+export interface RefreshProjectFileTreeOptions {
+  projectId?: string
+  clearDisplayTreeFirst?: boolean
+  bumpDataVersion?: boolean
+  refreshDisplayTree?: boolean
+  refreshPathIndex?: boolean
+}
+
+async function listDirectoryWithRetry(
+  path: string,
+  options: Parameters<typeof listDirectory>[1],
+  attempts = 3,
+): Promise<Awaited<ReturnType<typeof listDirectory>>> {
+  let lastError: unknown
+  for (let i = 0; i < attempts; i += 1) {
+    try {
+      return await listDirectory(path, options)
+    } catch (err) {
+      lastError = err
+      if (i < attempts - 1) {
+        await new Promise((resolve) => setTimeout(resolve, 250))
+      }
+    }
+  }
+  throw lastError
+}
+
+function isStillCurrentProject(projectId: string | undefined, projectPath: string): boolean {
+  const current = useWikiStore.getState().project
+  if (!current) return false
+  if (projectId && current.id !== projectId) return false
+  return normalizePath(current.path) === projectPath
+}
+
+/**
+ * Reload the project file tree and optionally rebuild the path index.
+ * QMAI keeps a full tree for KnowledgeTree (flatten/sort/chapter batch),
+ * unlike llm-wiki's shallow display tree + lazy children.
+ */
+export async function refreshProjectFileTree(
+  projectPath: string,
+  options: RefreshProjectFileTreeOptions = {},
+): Promise<void> {
+  const normalizedProjectPath = normalizePath(projectPath)
+  const currentProjectId = options.projectId ?? useWikiStore.getState().project?.id
+  const refreshDisplayTree = options.refreshDisplayTree ?? true
+  const refreshPathIndex = options.refreshPathIndex ?? true
+
+  if (options.clearDisplayTreeFirst && isStillCurrentProject(currentProjectId, normalizedProjectPath)) {
+    useWikiStore.getState().setFileTree([], { syncPathIndex: false })
+  }
+
+  if (refreshDisplayTree) {
+    try {
+      const tree = await listDirectoryWithRetry(normalizedProjectPath, {}, 3)
+      if (isStillCurrentProject(currentProjectId, normalizedProjectPath)) {
+        useWikiStore.getState().setFileTree(tree, { syncPathIndex: refreshPathIndex })
+      }
+    } catch (err) {
+      console.error("Failed to refresh project file tree:", err)
+    }
+  } else if (refreshPathIndex) {
+    if (!isStillCurrentProject(currentProjectId, normalizedProjectPath)) return
+    listDirectoryWithRetry(normalizedProjectPath, {}, 3)
+      .then((fullTree) => {
+        if (!isStillCurrentProject(currentProjectId, normalizedProjectPath)) return
+        useWikiStore.getState().setProjectPathIndexFromTree(fullTree)
+      })
+      .catch((err) => {
+        console.error("Failed to refresh project path index:", err)
+      })
+  }
+
+  if (options.bumpDataVersion && isStillCurrentProject(currentProjectId, normalizedProjectPath)) {
+    useWikiStore.getState().bumpDataVersion()
+  }
+}

+ 2 - 5
src/lib/project-refresh.ts

@@ -1,5 +1,5 @@
-import { listDirectory } from "@/commands/fs"
 import { normalizePath } from "@/lib/path-utils"
+import { refreshProjectFileTree } from "@/lib/project-file-tree-refresh"
 import { useWikiStore } from "@/stores/wiki-store"
 
 /**
@@ -10,10 +10,7 @@ export async function refreshProjectState(projectPath: string | undefined | null
   if (!projectPath) return
   const pp = normalizePath(projectPath)
   try {
-    const tree = await listDirectory(pp)
-    const store = useWikiStore.getState()
-    store.setFileTree(tree)
-    store.bumpDataVersion()
+    await refreshProjectFileTree(pp, { bumpDataVersion: true })
   } catch (err) {
     useWikiStore.getState().bumpDataVersion()
     console.error("[refreshProjectState] 刷新文件树失败:", err)

+ 20 - 3
src/lib/project-store.ts

@@ -499,14 +499,28 @@ export async function saveNovelConfig(config: NovelConfig, projectId?: string, p
   }
 }
 
+async function maybeMigrateLegacyDefaultLlmModel(
+  config: NovelConfig,
+  projectId?: string,
+  projectPath?: string,
+): Promise<NovelConfig> {
+  if (config.defaultLlmModel.trim()) return config
+  const legacyGlobal = await loadDefaultLlmModel()
+  if (!legacyGlobal?.trim()) return config
+  const migrated = { ...config, defaultLlmModel: legacyGlobal.trim() }
+  await saveNovelConfig(migrated, projectId, projectPath)
+  return migrated
+}
+
 export async function loadNovelConfig(projectId?: string, projectPath?: string): Promise<NovelConfig | null> {
   if (projectPath) {
     try {
       const filePath = novelConfigFilePath(projectPath)
       if (await fileExists(filePath)) {
         const raw = await readFile(filePath)
-        const config = JSON.parse(raw)
-        return normalizeNovelConfig(config)
+        const config = normalizeNovelConfig(JSON.parse(raw))
+        if (!config) return null
+        return maybeMigrateLegacyDefaultLlmModel(config, projectId, projectPath)
       }
     } catch {
       // fall through to global store
@@ -530,7 +544,8 @@ export async function loadNovelConfig(projectId?: string, projectPath?: string):
       // non-critical migration
     }
   }
-  return config
+  if (!config) return null
+  return maybeMigrateLegacyDefaultLlmModel(config, projectId, projectPath)
 }
 
 const RERANK_CONFIG_KEY = "rerankConfig"
@@ -668,10 +683,12 @@ function normalizeNovelConfig(
     deepPreviousChaptersAnalysis: config.deepPreviousChaptersAnalysis ?? DEFAULT_NOVEL_CONFIG.deepPreviousChaptersAnalysis,
     deepChapterReview: config.deepChapterReview ?? DEFAULT_NOVEL_CONFIG.deepChapterReview,
     reviewReasoningEffort: config.reviewReasoningEffort ?? DEFAULT_NOVEL_CONFIG.reviewReasoningEffort,
+    defaultLlmModel: config.defaultLlmModel ?? DEFAULT_NOVEL_CONFIG.defaultLlmModel,
     writingModel: config.writingModel ?? DEFAULT_NOVEL_CONFIG.writingModel,
     reviewModel: config.reviewModel ?? DEFAULT_NOVEL_CONFIG.reviewModel,
     summaryModel: config.summaryModel ?? DEFAULT_NOVEL_CONFIG.summaryModel,
     extractModel: config.extractModel ?? DEFAULT_NOVEL_CONFIG.extractModel,
+    deAiModel: config.deAiModel ?? DEFAULT_NOVEL_CONFIG.deAiModel,
     communitySummaryEnabled: config.communitySummaryEnabled ?? DEFAULT_NOVEL_CONFIG.communitySummaryEnabled,
     communitySummaryInterval: Math.max(1, Math.min(50, config.communitySummaryInterval ?? DEFAULT_NOVEL_CONFIG.communitySummaryInterval)),
     communitySummaryAsync: config.communitySummaryAsync ?? DEFAULT_NOVEL_CONFIG.communitySummaryAsync,

+ 3 - 1
src/lib/source-lifecycle.ts

@@ -13,6 +13,7 @@ import type { LlmConfig } from "@/stores/wiki-store"
 import { useWikiStore } from "@/stores/wiki-store"
 import { enqueueBatch } from "@/lib/ingest-queue"
 import { hasUsableLlm } from "@/lib/has-usable-llm"
+import { resolveDefaultModel } from "@/lib/novel/model-resolver"
 import { getFileName, getFileStem, normalizePath } from "@/lib/path-utils"
 import {
   parseFrontmatterArray,
@@ -105,7 +106,8 @@ export async function enqueueSourceIngest(
   llmConfig: LlmConfig,
   options: { sourceRoot?: string; rootContext?: string } = {},
 ): Promise<string[]> {
-  if (!hasUsableLlm(llmConfig, useWikiStore.getState().providerConfigs)) return []
+  const resolvedLlmConfig = resolveDefaultModel(llmConfig)
+  if (!hasUsableLlm(resolvedLlmConfig, useWikiStore.getState().providerConfigs)) return []
   const files = sourcePaths
     .filter(isIngestableSourcePath)
     .map((sourcePath) => ({

+ 6 - 0
src/lib/update-error-message.ts

@@ -1,6 +1,12 @@
+import { APP_AUTO_UPDATE_UNSUPPORTED_MESSAGE } from "@/lib/app-update-support"
+
 export function formatUpdateErrorMessage(error: unknown): string {
   const raw = error instanceof Error ? error.message : String(error)
 
+  if (raw.includes("fallback platforms") && raw.includes("darwin-aarch64")) {
+    return APP_AUTO_UPDATE_UNSUPPORTED_MESSAGE
+  }
+
   if (raw.includes("Could not fetch a valid release JSON from the remote")) {
     return "检查更新失败:没有从更新服务器拿到有效的版本信息。请先确认网络可以访问 GitHub,若未启用代理请关闭系统或软件代理后重试。"
   }

+ 106 - 59
src/lib/wiki-page-resolver.ts

@@ -1,5 +1,43 @@
 import type { FileNode } from "@/types/wiki"
 
+export interface ProjectPathIndexEntry {
+  name: string
+  path: string
+}
+
+export interface ProjectPathIndex {
+  byPath: ReadonlyMap<string, ProjectPathIndexEntry>
+  filesByName: ReadonlyMap<string, readonly ProjectPathIndexEntry[]>
+}
+
+export function createEmptyProjectPathIndex(): ProjectPathIndex {
+  return { byPath: new Map(), filesByName: new Map() }
+}
+
+export function buildProjectPathIndexFromTree(tree: FileNode[]): ProjectPathIndex {
+  const byPath = new Map<string, ProjectPathIndexEntry>()
+  const filesByName = new Map<string, ProjectPathIndexEntry[]>()
+
+  function walk(nodes: FileNode[]) {
+    for (const node of nodes) {
+      const entry: ProjectPathIndexEntry = {
+        name: node.name,
+        path: node.path,
+      }
+      byPath.set(node.path, entry)
+      if (!node.is_dir) {
+        const bucket = filesByName.get(node.name)
+        if (bucket) bucket.push(entry)
+        else filesByName.set(node.name, [entry])
+      }
+      if (node.is_dir && node.children) walk(node.children)
+    }
+  }
+
+  walk(tree)
+  return { byPath, filesByName }
+}
+
 /**
  * Strip Obsidian-style `[[target]]` or `[[target|alias]]` wrapping
  * from a value, returning `{ slug, label }`. Frontmatter authors
@@ -17,21 +55,7 @@ export function unwrapWikilink(s: string): { slug: string; label: string } {
   return { slug: target, label: alias && alias.length > 0 ? alias : target }
 }
 
-/**
- * Walk a FileNode tree and return the absolute path of the first
- * file whose name matches `targetName`, restricted to subtrees that
- * sit underneath any directory whose absolute path contains
- * `pathContains`. Returns null when nothing matches.
- *
- * Used by the frontmatter panel to resolve `related: [slug]` to a
- * concrete `wiki/.../<slug>.md` path so a chip can navigate, and
- * `sources: [name.pdf]` to a `raw/sources/.../name.pdf` path so a
- * card can open the raw file. We intentionally take the first
- * match — duplicate basenames across subfolders are a wiki-author
- * collision the user sees in the file tree anyway, and resolving
- * arbitrarily is no worse than the prior text-only display.
- */
-export function findInTreeByName(
+function findInTreeByNameFromTree(
   tree: FileNode[],
   targetName: string,
   pathContains: string,
@@ -54,80 +78,103 @@ export function findInTreeByName(
   return walk(tree)
 }
 
+function findInTreeByPathFromTree(tree: FileNode[], targetPath: string): string | null {
+  function walk(nodes: FileNode[]): string | null {
+    for (const node of nodes) {
+      if (node.path === targetPath) return node.path
+      if (node.is_dir && node.children) {
+        const r = walk(node.children)
+        if (r) return r
+      }
+    }
+    return null
+  }
+  return walk(tree)
+}
+
+/**
+ * Return the absolute path of the first indexed file whose basename
+ * matches `targetName` and whose path contains `pathContains`.
+ */
+export function findInTreeByName(
+  index: ProjectPathIndex,
+  targetName: string,
+  pathContains: string,
+  fallbackTree?: FileNode[],
+): string | null {
+  for (const entry of index.filesByName.get(targetName) ?? []) {
+    if (entry.path.includes(pathContains)) return entry.path
+  }
+  if (fallbackTree) {
+    return findInTreeByNameFromTree(fallbackTree, targetName, pathContains)
+  }
+  return null
+}
+
+function findInTreeByPath(
+  index: ProjectPathIndex,
+  targetPath: string,
+  fallbackTree?: FileNode[],
+): string | null {
+  const found = index.byPath.get(targetPath)
+  if (found) return found.path
+  if (fallbackTree) return findInTreeByPathFromTree(fallbackTree, targetPath)
+  return null
+}
+
 /**
  * Resolve a `related:` reference to an absolute wiki page path.
- * Accepts three shapes the wiki has historically written:
- *   1. project-relative path:  `wiki/entities/dpao.md`
- *   2. bare filename with .md: `dpao.md`
- *   3. bare slug:              `dpao`
- * Returns the absolute path of an existing file, or null if none
- * matches. Always restricts the lookup to `wiki/` to avoid pulling
- * in a same-named file from `raw/sources/`.
  */
 export function resolveRelatedSlug(
-  tree: FileNode[],
+  index: ProjectPathIndex,
   ref: string,
   wikiRoot: string,
+  fallbackTree?: FileNode[],
 ): string | null {
-  // Path-like → resolve relative to project root (one segment up
-  // from wikiRoot).
   if (ref.includes("/")) {
     const projectRoot = wikiRoot.replace(/\/wiki$/, "")
     const target = `${projectRoot}/${ref}`
-    const found = findInTreeByPath(tree, target)
+    const found = findInTreeByPath(index, target, fallbackTree)
     return found && found.includes(`${wikiRoot}/`) ? found : null
   }
 
   const filename = ref.endsWith(".md") ? ref : `${ref}.md`
-  return findInTreeByName(tree, filename, `${wikiRoot}/`)
+  return findInTreeByName(index, filename, `${wikiRoot}/`, fallbackTree)
 }
 
 /**
- * Resolve a `sources:` reference. Accepts:
- *   1. project-relative path:  `wiki/sources/foo.md` or
- *                              `raw/sources/year-2025/q1.pdf`
- *   2. bare filename with ext: `q1.pdf`
- *   3. wiki source-summary:    `foo.md` (in wiki/sources/)
- * Tries wiki/sources/ first when the ref is a bare .md filename
- * (the ingest pipeline writes summary pages there), then falls
- * back to raw/sources/. Returns null if nothing matches.
+ * Resolve a `sources:` reference.
  */
 export function resolveSourceName(
-  tree: FileNode[],
+  index: ProjectPathIndex,
   ref: string,
   sourcesRoot: string,
+  fallbackTree?: FileNode[],
 ): string | null {
-  // sourcesRoot is `<project>/raw/sources` — derive project root
-  // and wiki/ root from it.
   const projectRoot = sourcesRoot.replace(/\/raw\/sources$/, "")
   const wikiSources = `${projectRoot}/wiki/sources`
 
   if (ref.includes("/")) {
-    const target = `${projectRoot}/${ref}`
-    return findInTreeByPath(tree, target)
+    const normalizedRef = ref.replace(/\\/g, "/").replace(/^\/+/, "")
+    const candidates = normalizedRef.startsWith("raw/sources/") ||
+      normalizedRef.startsWith("wiki/")
+      ? [`${projectRoot}/${normalizedRef}`]
+      : [
+          `${sourcesRoot}/${normalizedRef}`,
+          `${projectRoot}/${normalizedRef}`,
+        ]
+
+    for (const target of candidates) {
+      const found = findInTreeByPath(index, target, fallbackTree)
+      if (found) return found
+    }
+    return null
   }
 
-  // Bare .md filename → look in wiki/sources/ first (ingest's
-  // canonical home for source-summary pages).
   if (ref.endsWith(".md")) {
-    const inWiki = findInTreeByName(tree, ref, `${wikiSources}/`)
+    const inWiki = findInTreeByName(index, ref, `${wikiSources}/`, fallbackTree)
     if (inWiki) return inWiki
   }
 
-  // Otherwise, search raw/sources/.
-  return findInTreeByName(tree, ref, `${sourcesRoot}/`)
-}
-
-function findInTreeByPath(tree: FileNode[], targetPath: string): string | null {
-  function walk(nodes: FileNode[]): string | null {
-    for (const node of nodes) {
-      if (node.path === targetPath) return node.path
-      if (node.is_dir && node.children) {
-        const r = walk(node.children)
-        if (r) return r
-      }
-    }
-    return null
-  }
-  return walk(tree)
+  return findInTreeByName(index, ref, `${sourcesRoot}/`, fallbackTree)
 }

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

@@ -1,5 +1,10 @@
 import { create } from "zustand"
 import type { WikiProject, FileNode } from "@/types/wiki"
+import {
+  buildProjectPathIndexFromTree,
+  createEmptyProjectPathIndex,
+  type ProjectPathIndex,
+} from "@/lib/wiki-page-resolver"
 import { DEFAULT_SOURCE_WATCH_CONFIG } from "@/lib/source-watch-config"
 import type { LintResult } from "@/lib/lint"
 import type { NovelReviewResult } from "@/lib/novel/review-adapter"
@@ -304,10 +309,14 @@ export interface NovelConfig {
   deepChapterReview: boolean
   /** 审稿(含六维审查)使用的 reasoning 档位。下调可省审稿推理 Token,但连贯性把关会变弱(默认 high)。 */
   reviewReasoningEffort: "low" | "medium" | "high"
+  /** 默认模型(工作流):拆文库、剧情推演室、去重等。空字符串表示跟随 AI 会话模型。 */
+  defaultLlmModel: string
   writingModel: string
   reviewModel: string
   summaryModel: string
   extractModel: string
+  /** 去 AI 味:章节预览去 AI 味、深度生成阶段6。空字符串表示跟随默认模型。 */
+  deAiModel: string
   /** 社区摘要自动提取:开启后每 N 章用 LLM 为图谱社区生成叙事摘要,用于回答全局性问题(默认开)。 */
   communitySummaryEnabled: boolean
   /** 社区摘要提取间隔:每摄取多少章后自动重建一次社区摘要(默认 5)。 */
@@ -329,10 +338,12 @@ export const DEFAULT_NOVEL_CONFIG: NovelConfig = {
   deepPreviousChaptersAnalysis: false,
   deepChapterReview: true,
   reviewReasoningEffort: "high",
+  defaultLlmModel: "",
   writingModel: "",
   reviewModel: "",
   summaryModel: "",
   extractModel: "",
+  deAiModel: "",
   communitySummaryEnabled: true,
   communitySummaryInterval: 5,
   communitySummaryAsync: true,
@@ -504,6 +515,13 @@ export function confirmDiscardSkillLibraryDraft(): boolean {
 interface WikiState {
   project: WikiProject | null
   fileTree: FileNode[]
+  /**
+   * Lightweight lookup index derived from `fileTree`. Production code must
+   * update fileTree through `setFileTree` so this stays in sync; direct
+   * `useWikiStore.setState({ fileTree })` is only for tests that also reset or
+   * do not read path resolution.
+   */
+  projectPathIndex: ProjectPathIndex
   selectedFile: string | null
   selectedTrashItem: TrashItem | null
   fileContent: string
@@ -553,7 +571,7 @@ interface WikiState {
   refreshGraph: (() => void) | null
   llmConfig: LlmConfig
   aiChatModel: string
-  /** 默认模型:AI会话提取记忆、提取角色等后台任务默认使用的模型(格式: "providerId/modelId",留空则使用 AI 会话当前模型) */
+  /** 默认模型(工作流):拆文库、导入队列、去重、角色 aura 等。章节/大纲记忆摄取见 novelConfig.extractModel */
   defaultLlmModel: string
   /** Per-provider-preset stored overrides (API key, model, endpoint, …). */
   providerConfigs: ProviderConfigs
@@ -592,7 +610,8 @@ interface WikiState {
   bindingVersion: number
 
   setProject: (project: WikiProject | null) => void
-  setFileTree: (tree: FileNode[]) => void
+  setFileTree: (tree: FileNode[], options?: { syncPathIndex?: boolean }) => void
+  setProjectPathIndexFromTree: (tree: FileNode[]) => void
   setSelectedFile: (path: string | null) => void
   setSelectedTrashItem: (item: TrashItem | null) => void
   setFileContent: (content: string) => void
@@ -668,6 +687,7 @@ interface WikiState {
 export const useWikiStore = create<WikiState>((set) => ({
   project: null,
   fileTree: [],
+  projectPathIndex: createEmptyProjectPathIndex(),
   selectedFile: null,
   selectedTrashItem: null,
   fileContent: "",
@@ -722,7 +742,15 @@ export const useWikiStore = create<WikiState>((set) => ({
   bindingVersion: 0,
 
   setProject: (project) => set({ project }),
-  setFileTree: (fileTree) => set({ fileTree }),
+  setFileTree: (fileTree, options) => {
+    if (options?.syncPathIndex === false) {
+      set({ fileTree })
+      return
+    }
+    set({ fileTree, projectPathIndex: buildProjectPathIndexFromTree(fileTree) })
+  },
+  setProjectPathIndexFromTree: (tree) =>
+    set({ projectPathIndex: buildProjectPathIndexFromTree(tree) }),
   setSelectedFile: (selectedFile) => set({ selectedFile, selectedTrashItem: null }),
   setSelectedTrashItem: (selectedTrashItem) => set({ selectedTrashItem, selectedFile: null }),
   setFileContent: (fileContent) => set({ fileContent }),
@@ -903,7 +931,12 @@ export const useWikiStore = create<WikiState>((set) => ({
     deepChapterEnabled,
     aiWorkflowMode: deepChapterEnabled ? "strict" : "standard",
   }),
-  setNovelConfig: (config) => set((state) => ({ novelConfig: { ...state.novelConfig, ...config } })),
+  setNovelConfig: (config) => set((state) => ({
+    novelConfig: { ...state.novelConfig, ...config },
+    ...(config.defaultLlmModel !== undefined
+      ? { defaultLlmModel: config.defaultLlmModel }
+      : {}),
+  })),
   setCommunitySummaryError: (communitySummaryError) => set({ communitySummaryError }),
   setSearchHistory: (searchHistory) => set({ searchHistory }),
   setSearchTrigger: (searchTrigger) => set({ searchTrigger }),

+ 344 - 0
src/workers/simulation-worker.ts

@@ -0,0 +1,344 @@
+type WorkerRequest =
+  | { id: string; type: "calc-clue-relations"; payload: { clues: { id: string; content: string }[] } }
+  | { id: string; type: "calc-branch-diff"; payload: { branchA: any; branchB: any } }
+  | { id: string; type: "calc-board-layout"; payload: { cards: { id: string; x: number; y: number; width: number }[]; connections: { fromCardId: string; toCardId: string }[] } }
+
+type WorkerResponse =
+  | { id: string; type: "clue-relations"; payload: { pairs: [string, string, number][] } }
+  | { id: string; type: "branch-diff"; payload: any }
+  | { id: string; type: "board-layout"; payload: { id: string; x: number; y: number }[] }
+
+const DIMENSION_KEYS = [
+  "tension",
+  "pace",
+  "characterUtilization",
+  "characterArc",
+  "infoDensity",
+  "emotionalResonance",
+  "logicConsistency",
+] as const
+
+interface DirectorScore {
+  tension: number
+  pace: number
+  characterUtilization: number
+  characterArc: number
+  infoDensity: number
+  emotionalResonance: number
+  logicConsistency: number
+}
+
+interface DirectorEvaluation {
+  scores: DirectorScore
+  totalScore: number
+  highlights: string[]
+  issues: string[]
+  suggestion: string
+  shouldInjectEvent: boolean
+  injectEvent?: string
+}
+
+interface TimelineEvent {
+  id: string
+  round: number
+  nodeIndex: number
+  actorId: string
+  actorName: string
+  actionType: string
+  content: string
+  targetId?: string
+  targetName?: string
+  observableBy: string[]
+  timestamp: string
+}
+
+interface SimulationBranch {
+  id: string
+  name: string
+  frameworkId: string
+  mode: string
+  createdAt: string
+  timelineEvents: TimelineEvent[]
+  rumors: any[]
+  finalAgentSnapshots: { agentId: string; name: string; knownSecrets: string[]; sentiments: [string, number][] }[]
+  directorEvaluations: DirectorEvaluation[]
+  overallScore: number
+  scoreDetails: {
+    avgDirectorScore: number
+    eventCount: number
+    characterDiversity: number
+    plotProgression: number
+  }
+}
+
+function extractKeywords(text: string): string[] {
+  const words = text.split(/[,。?!、的了是在有和与等\s,.!?;:]/).filter((w) => w.length >= 2)
+  return Array.from(new Set(words))
+}
+
+function keywordOverlap(a: string, b: string): number {
+  const keywordsA = extractKeywords(a)
+  const keywordsB = extractKeywords(b)
+  if (keywordsA.length === 0 || keywordsB.length === 0) return 0
+  const setA = new Set(keywordsA)
+  const shared = keywordsB.filter((w) => setA.has(w)).length
+  return shared / Math.min(keywordsA.length, keywordsB.length)
+}
+
+function calcClueRelations(clues: { id: string; content: string }[]): { pairs: [string, string, number][] } {
+  const pairs: [string, string, number][] = []
+  for (let i = 0; i < clues.length; i++) {
+    for (let j = i + 1; j < clues.length; j++) {
+      const score = keywordOverlap(clues[i].content, clues[j].content)
+      if (score > 0.3) {
+        pairs.push([clues[i].id, clues[j].id, score])
+      }
+    }
+  }
+  pairs.sort((a, b) => b[2] - a[2])
+  return { pairs: pairs.slice(0, 50) }
+}
+
+function calcBoardLayout(
+  cards: { id: string; x: number; y: number; width: number }[],
+  connections: { fromCardId: string; toCardId: string }[],
+): { id: string; x: number; y: number }[] {
+  const k = 80
+  const iterations = 80
+  let temperature = 50
+
+  const nodes = cards.map((c) => ({
+    id: c.id,
+    x: c.x + c.width / 2,
+    y: c.y + 60,
+    vx: 0,
+    vy: 0,
+    width: c.width,
+  }))
+
+  const nodeMap = new Map(nodes.map((n) => [n.id, n]))
+
+  for (let iter = 0; iter < iterations; iter++) {
+    for (let i = 0; i < nodes.length; i++) {
+      nodes[i].vx = 0
+      nodes[i].vy = 0
+    }
+
+    for (let i = 0; i < nodes.length; i++) {
+      for (let j = i + 1; j < nodes.length; j++) {
+        const dx = nodes[j].x - nodes[i].x
+        const dy = nodes[j].y - nodes[i].y
+        let dist = Math.sqrt(dx * dx + dy * dy)
+        if (dist < 1) dist = 1
+        const force = (k * k) / dist
+        const fx = (dx / dist) * force
+        const fy = (dy / dist) * force
+        nodes[i].vx -= fx
+        nodes[i].vy -= fy
+        nodes[j].vx += fx
+        nodes[j].vy += fy
+      }
+    }
+
+    for (const conn of connections) {
+      const from = nodeMap.get(conn.fromCardId)
+      const to = nodeMap.get(conn.toCardId)
+      if (!from || !to) continue
+      const dx = to.x - from.x
+      const dy = to.y - from.y
+      let dist = Math.sqrt(dx * dx + dy * dy)
+      if (dist < 1) dist = 1
+      const force = dist / k
+      const fx = (dx / dist) * force
+      const fy = (dy / dist) * force
+      from.vx += fx
+      from.vy += fy
+      to.vx -= fx
+      to.vy -= fy
+    }
+
+    for (const node of nodes) {
+      const disp = Math.sqrt(node.vx * node.vx + node.vy * node.vy)
+      if (disp > 0) {
+        const ratio = Math.min(disp, temperature) / disp
+        node.x += node.vx * ratio
+        node.y += node.vy * ratio
+      }
+    }
+
+    temperature *= 0.95
+  }
+
+  return nodes.map((n) => ({
+    id: n.id,
+    x: n.x - n.width / 2,
+    y: n.y - 60,
+  }))
+}
+
+function getAvgDirectorScore(branch: SimulationBranch): DirectorScore {
+  if (branch.directorEvaluations.length === 0) {
+    return {
+      tension: 3.0,
+      pace: 3.0,
+      characterUtilization: 3.0,
+      characterArc: 3.0,
+      infoDensity: 3.0,
+      emotionalResonance: 3.0,
+      logicConsistency: 3.0,
+    }
+  }
+  const sum: DirectorScore = {
+    tension: 0,
+    pace: 0,
+    characterUtilization: 0,
+    characterArc: 0,
+    infoDensity: 0,
+    emotionalResonance: 0,
+    logicConsistency: 0,
+  }
+  for (const ev of branch.directorEvaluations) {
+    for (const key of DIMENSION_KEYS) {
+      sum[key] += ev.scores[key]
+    }
+  }
+  const n = branch.directorEvaluations.length
+  const avg: DirectorScore = { ...sum }
+  for (const key of DIMENSION_KEYS) {
+    avg[key] = Math.round((avg[key] / n) * 10) / 10
+  }
+  return avg
+}
+
+function calcBranchDiff(branchA: SimulationBranch, branchB: SimulationBranch): any {
+  const branches = [branchA, branchB]
+  const scoresList = branches.map((b) => getAvgDirectorScore(b))
+
+  const dimensionDiffs = DIMENSION_KEYS.map((key) => {
+    const values = scoresList.map((s) => s[key])
+    const max = Math.max(...values)
+    const min = Math.min(...values)
+    const maxIdx = values.indexOf(max)
+    return {
+      key,
+      diff: max - min,
+      maxBranchName: branches[maxIdx].name,
+      maxValue: max,
+      minValue: min,
+    }
+  })
+    .sort((a, b) => b.diff - a.diff)
+    .slice(0, 3)
+
+  const eventCounts = branches.map((b) => b.timelineEvents.length)
+  const characterCounts = branches.map((b) => b.finalAgentSnapshots.length)
+
+  const allCharMap = new Map<string, string>()
+  for (const b of branches) {
+    for (const agent of b.finalAgentSnapshots) {
+      allCharMap.set(agent.agentId, agent.name)
+    }
+  }
+
+  const sentimentDiffs: { charId: string; charName: string; maxDiff: number; maxBranch: string; values: number[] }[] = []
+
+  for (const [charId, charName] of allCharMap) {
+    const values: number[] = []
+    for (const b of branches) {
+      const agent = b.finalAgentSnapshots.find((a) => a.agentId === charId)
+      let totalSentiment = 0
+      let count = 0
+      if (agent) {
+        for (const [, val] of agent.sentiments) {
+          totalSentiment += val
+          count++
+        }
+      }
+      values.push(count > 0 ? totalSentiment / count : 0)
+    }
+    const max = Math.max(...values)
+    const min = Math.min(...values)
+    const maxIdx = values.indexOf(max)
+    sentimentDiffs.push({
+      charId,
+      charName,
+      maxDiff: max - min,
+      maxBranch: branches[maxIdx].name,
+      values,
+    })
+  }
+
+  const topSentimentDiffs = sentimentDiffs.sort((a, b) => b.maxDiff - a.maxDiff).slice(0, 3)
+
+  const maxRound = Math.max(
+    ...branches.map((b) => (b.timelineEvents.length > 0 ? Math.max(...b.timelineEvents.map((e) => e.round)) : -1)),
+  )
+
+  let divergenceRound = -1
+  for (let r = 0; r <= maxRound; r++) {
+    const roundContents = branches.map((b) => {
+      const evs = b.timelineEvents.filter((e) => e.round === r)
+      return evs.map((e) => e.content.slice(0, 10)).join("|")
+    })
+
+    let allSame = true
+    for (let i = 1; i < roundContents.length; i++) {
+      if (roundContents[i] !== roundContents[0]) {
+        allSame = false
+        break
+      }
+    }
+    if (!allSame) {
+      divergenceRound = r + 1
+      break
+    }
+  }
+
+  const bestBranchIdx = branches.reduce(
+    (bestIdx, b, idx) => (b.overallScore > branches[bestIdx].overallScore ? idx : bestIdx),
+    0,
+  )
+
+  return {
+    dimensionDiffs,
+    eventCounts,
+    characterCounts,
+    topSentimentDiffs,
+    divergenceRound,
+    bestBranchIdx,
+    scoresList,
+  }
+}
+
+self.onmessage = (e: MessageEvent<WorkerRequest>) => {
+  const request = e.data
+  let response: WorkerResponse
+
+  switch (request.type) {
+    case "calc-clue-relations":
+      response = {
+        id: request.id,
+        type: "clue-relations",
+        payload: calcClueRelations(request.payload.clues),
+      }
+      break
+    case "calc-board-layout":
+      response = {
+        id: request.id,
+        type: "board-layout",
+        payload: calcBoardLayout(request.payload.cards, request.payload.connections),
+      }
+      break
+    case "calc-branch-diff":
+      response = {
+        id: request.id,
+        type: "branch-diff",
+        payload: calcBranchDiff(request.payload.branchA, request.payload.branchB),
+      }
+      break
+    default:
+      return
+  }
+
+  self.postMessage(response)
+}

BIN
staged-files.txt


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