ソースを参照

fix: 修复推演室三个问题 - 切换框架进度丢失/分支管理按钮不明显/概览滚动问题

Mochocyang 2 ヶ月 前
コミット
d48e35d993
58 ファイル変更3245 行追加1636 行削除
  1. 344 0
      gongjudiaoyongyouhua-分支说明.md
  2. 48 1
      src-tauri/src/commands/file_sync.rs
  3. 1 1
      src-tauri/tauri.conf.json
  4. 2 2
      src/components/chat/chat-panel.tsx
  5. 2 2
      src/components/chat/context-trace-panel.tsx
  6. 2 2
      src/components/chat/modify-confirm-dialog.tsx
  7. 2 2
      src/components/dashboard/dashboard-view.tsx
  8. 2 2
      src/components/graph/graph-view.tsx
  9. 4 4
      src/components/layout/app-layout.tsx
  10. 2 2
      src/components/layout/chat-bar.tsx
  11. 2 2
      src/components/layout/graph-sidebar-panel.tsx
  12. 2 2
      src/components/layout/preview-panel.tsx
  13. 2 2
      src/components/layout/review-center-sidebar-panel.tsx
  14. 641 418
      src/components/layout/sidebar-panel.tsx
  15. 2 2
      src/components/layout/soul-sidebar-panel.tsx
  16. 2 2
      src/components/layout/trash-panel.tsx
  17. 2 2
      src/components/layout/writing-workspace.tsx
  18. 2 2
      src/components/lint/lint-view.tsx
  19. 4 4
      src/components/novel/character-aura-view.tsx
  20. 2 2
      src/components/novel/cognition-panel.tsx
  21. 2 2
      src/components/novel/foreshadowing-panel.tsx
  22. 4 4
      src/components/novel/memory-center-view.tsx
  23. 2 1
      src/components/novel/story-simulation/branch-compare-view.tsx
  24. 149 61
      src/components/novel/story-simulation/branch-manager-panel.tsx
  25. 2 1
      src/components/novel/story-simulation/detective-board-panel.tsx
  26. 5 0
      src/components/novel/story-simulation/framework-confirm-panel.tsx
  27. 2 2
      src/components/novel/story-simulation/framework-list.tsx
  28. 158 0
      src/components/novel/story-simulation/history-results-modal.tsx
  29. 11 10
      src/components/novel/story-simulation/interview-history-view.tsx
  30. 0 236
      src/components/novel/story-simulation/relationship-graph-panel.tsx
  31. 174 102
      src/components/novel/story-simulation/rumor-propagation-panel.tsx
  32. 77 203
      src/components/novel/story-simulation/simulation-report-view.tsx
  33. 5 4
      src/components/novel/story-simulation/story-draft-view.tsx
  34. 505 363
      src/components/novel/story-simulation/story-simulation-view.tsx
  35. 2 2
      src/components/novel/timeline-view.tsx
  36. 2 2
      src/components/reference/ReferencePickerDialog.tsx
  37. 2 2
      src/components/review/review-view.tsx
  38. 4 4
      src/components/settings/settings-view.tsx
  39. 2 2
      src/components/sources/sources-view.tsx
  40. 42 0
      src/index.css
  41. 6 0
      src/lib/changelog.ts
  42. 1 3
      src/lib/novel/deep-chapter-generation.ts
  43. 156 0
      src/lib/novel/story-simulation/action-type-utils.tsx
  44. 1 0
      src/lib/novel/story-simulation/agent-profile-builder.ts
  45. 14 0
      src/lib/novel/story-simulation/framework-store.ts
  46. 2 0
      src/lib/novel/story-simulation/investigate-feedback.spec.ts
  47. 1 0
      src/lib/novel/story-simulation/multi-agent-orchestrator.spec.ts
  48. 59 0
      src/lib/novel/story-simulation/multi-agent-orchestrator.ts
  49. 2 0
      src/lib/novel/story-simulation/rumor-visibility.spec.ts
  50. 4 2
      src/lib/novel/story-simulation/sim-agent-tools.spec.ts
  51. 44 0
      src/lib/novel/story-simulation/sim-agent-tools.ts
  52. 2 1
      src/lib/novel/story-simulation/simulation-engine.react.spec.ts
  53. 2 0
      src/lib/novel/story-simulation/simulation-engine.ts
  54. 63 2
      src/lib/novel/story-simulation/simulation-report-agent.ts
  55. 3 0
      src/lib/novel/story-simulation/simulation-serializer.ts
  56. 258 33
      src/lib/novel/story-simulation/story-extractor.ts
  57. 24 0
      src/lib/novel/story-simulation/types.ts
  58. 383 138
      src/stores/story-simulation-store.ts

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

@@ -806,6 +806,288 @@ Git 状态:本轮修复未提交 git,未合并 main。
 
 Git 状态:本阶段未提交 git,未合并 main。
 
+# 20260703-125025 AI Chat ReAct 工具裁剪职责下沉
+
+本轮目标:继续按照 `2026-07-03-ai-chat-react-mainline.md` 推进 AI Chat ReAct 主线,把能力选择后的工具裁剪从 `ChatPanel` 下沉到 `runAiChatSession`,使界面层只负责传递 `enabledToolNames`,会话运行层负责构建最终可执行工具集。
+
+修改文件:
+- `src/lib/agent/ai-chat-session.ts`
+- `src/lib/agent/ai-chat-session.spec.ts`
+- `src/components/chat/chat-panel.tsx`
+- `src/components/chat/chat-panel.spec.tsx`
+- `GenxinLOG/更新日志.md`
+- `gongjudiaoyongyouhua-分支说明.md`
+
+实现记录:
+1. `RunAiChatSessionInput` 增加 `enabledToolNames?: string[] | null`。
+2. `runAiChatSession` 统一调用 `scopeAgentConfigTools(input.agentConfig, input.enabledToolNames)`,再把裁剪后的配置交给 `AgentRunner.run`。
+3. `ChatPanel` 移除 `scopeAgentConfigTools` import 和本地裁剪变量,改为把 `prePluginResult?.enabledToolNames` 传入 `runAiChatSession`。
+4. `ai-chat-session` 测试新增工具裁剪断言,保证未选中的工具不会暴露给 `AgentRunner`。
+5. `chat-panel` 测试改为守卫 UI 层不再直接执行工具裁剪,只传递能力选择结果。
+
+验证记录:
+1. RED/GREEN:新增 `runAiChatSession` 工具裁剪测试先失败后通过。
+2. 定向测试:`npx.cmd vitest run src/lib/agent/ai-chat-session.spec.ts src/components/chat/chat-panel.spec.tsx src/lib/agent/tool-scope.spec.ts`,3 个测试文件、62 个用例通过。
+3. 目标矩阵:`npx.cmd vitest run src/lib/agent/ai-chat-session.spec.ts src/lib/agent/tools/run-chapter-workflow.spec.ts src/lib/agent/tools/index.spec.ts src/lib/agent/capabilities/selector.spec.ts src/lib/agent/plan-execute-policy.spec.ts src/lib/agent/workflow-trace.spec.ts src/components/chat/chat-panel.spec.tsx src/lib/novel/deep-chapter-generation.spec.ts`,8 个测试文件、113 个用例通过。
+4. `npm.cmd run typecheck`:通过。
+5. `npm.cmd run test:mocks`:344 个测试文件、2578 个用例通过,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` 和 `release-portable\version-info.json`;版本 `2.2.31`,大小 `149403648` 字节。
+
+Git 状态:本轮未提交 git,未合并 main。
+
+# 20260703-120231 AI 会话 ReAct 工具调用收口
+
+本轮目标:继续完成 AI 会话 ReAct 主线收口,把继续未完成、能力裁剪和 workflow 事件父子关系统一到 Agent 工具调用链路。
+
+修改文件:
+- `src/components/chat/chat-panel.tsx`
+- `src/components/chat/chat-panel.spec.tsx`
+- `src/components/chat/chat-message.spec.tsx`
+- `src/components/chat/dismantling-reference.spec.ts`
+- `src/components/layout/startup-heavy-imports.test.ts`
+- `src/lib/agent/types.ts`
+- `src/lib/agent/runner.ts`
+- `src/lib/agent/runner.spec.ts`
+- `src/lib/agent/tools/run-chapter-workflow.ts`
+- `src/lib/agent/tools/run-chapter-workflow.spec.ts`
+- `GenxinLOG/更新日志.md`
+- `gongjudiaoyongyouhua-分支说明.md`
+
+实现记录:
+1. `Tool.execute` / `generatePreview` 增加 `ToolExecutionContext`,runner 将真实 tool call id 传入工具执行上下文。
+2. `run_chapter_workflow` 子事件使用真实父级 tool call id,避免 workflow trace 出现伪 parent id。
+3. AI 会话执行前用 `scopeAgentConfigTools(agentConfig, prePluginResult?.enabledToolNames)` 收窄可见工具。
+4. “继续未完成”改为调用 `handleSendRef.current(prompt, [], "继续未完成")`,不再直连 `streamChat` 或 `runDeepChapterGeneration`。
+5. `handleSend` 支持可选展示文本,用户消息显示简短“继续未完成”,Agent 实际收到完整恢复提示词。
+6. 同步更新旧源码守卫,明确 ChatPanel 不再保留深度章节直连动态导入和隐藏拆书注入函数。
+
+验证记录:
+1. TDD RED/GREEN 已完成:真实 parent tool call id、能力裁剪接线、继续未完成 ReAct 路径均先看到失败断言后实现。
+2. 定向测试:`npx.cmd vitest run src/components/chat/chat-panel.spec.tsx src/lib/agent/runner.spec.ts src/lib/agent/tools/run-chapter-workflow.spec.ts src/lib/agent/tool-scope.spec.ts`,4 个测试文件、74 个用例通过。
+3. 回归守卫:`npx.cmd vitest run src/components/chat/chat-message.spec.tsx src/components/layout/startup-heavy-imports.test.ts src/components/chat/dismantling-reference.spec.ts`,3 个测试文件、15 个用例通过。
+4. `npm.cmd run typecheck`:通过。
+5. `npm.cmd run test:mocks`:344 个测试文件、2575 个用例通过,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` 和 `release-portable\version-info.json`;版本 `2.2.31`,大小 `149403648` 字节。
+
+Git 状态:本轮未提交 git,未合并 main。
+
+## 20260703-112750 AI 会话 ReAct 主线重构
+
+本轮目标:将 AI 会话主线调整为先进入单 Agent ReAct 循环,章节生成改为 Agent 可调用工具,标准/严格模式接入 Plan Execute,Skill、MCP、Web Search、Workflow 能力统一走能力选择链路。
+
+修改文件:
+- `src/lib/agent/ai-chat-session.ts`
+- `src/lib/agent/ai-chat-session.spec.ts`
+- `src/lib/agent/tools/run-chapter-workflow.ts`
+- `src/lib/agent/tools/run-chapter-workflow.spec.ts`
+- `src/lib/agent/tools/index.ts`
+- `src/lib/agent/tools/index.spec.ts`
+- `src/lib/agent/types.ts`
+- `src/lib/agent/tool-events.ts`
+- `src/lib/agent/tool-events.spec.ts`
+- `src/lib/agent/config.ts`
+- `src/hooks/use-agent-config.ts`
+- `src/hooks/use-agent-config.spec.ts`
+- `src/lib/agent/capabilities/registry.ts`
+- `src/lib/agent/capabilities/selector.ts`
+- `src/lib/agent/capabilities/selector.spec.ts`
+- `src/lib/agent/plan-execute-policy.ts`
+- `src/lib/agent/plan-execute-policy.spec.ts`
+- `src/lib/agent/workflow-trace.ts`
+- `src/lib/agent/workflow-trace.spec.ts`
+- `src/components/chat/chat-panel.tsx`
+- `src/components/chat/chat-panel.spec.tsx`
+- `GenxinLOG/更新日志.md`
+- `gongjudiaoyongyouhua-分支说明.md`
+
+实现记录:
+1. 新增 `runAiChatSession`,ChatPanel 主发送路径改为统一 session runner,不再在主发送路径直连深度章节生成结果分支。
+2. 新增 `run_chapter_workflow` Agent 工具,封装既有深度章节生成流程,并把章节工作流子步骤作为父子工具事件展示。
+3. 工具事件、运行记录和 workflow trace 支持 `parentCallId`,便于展示工作流父工具和子步骤。
+4. `useAgentConfig` 和工具注册链路传入章节工作流依赖,能力选择器会为章节写作意图选中 `run_chapter_workflow`。
+5. 新增 Plan Execute 策略模块,快速模式不强制计划,标准/严格模式按写作任务要求计划、执行和审查。
+
+验证记录:
+1. 目标测试:`npx.cmd vitest run src/lib/agent/ai-chat-session.spec.ts src/lib/agent/tools/run-chapter-workflow.spec.ts src/lib/agent/tools/index.spec.ts src/lib/agent/capabilities/selector.spec.ts src/lib/agent/plan-execute-policy.spec.ts src/lib/agent/workflow-trace.spec.ts src/components/chat/chat-panel.spec.tsx src/lib/novel/deep-chapter-generation.spec.ts src/lib/agent/tool-events.spec.ts src/hooks/use-agent-config.spec.ts`:10 个测试文件、121 个用例通过。
+2. `npm.cmd run typecheck`:通过。
+3. `npm.cmd run test:mocks`:344 个测试文件、2572 个用例通过,6 个 todo。
+4. `npm.cmd run build`:通过;保留既有 Vite dynamic import、chunk size、plugin timings 警告。
+5. 源码启动:临时 PowerShell Job 启动 `http://127.0.0.1:5179/`,首页返回 HTTP 200,验证后端口无监听残留。
+6. `npm.cmd run build:portable`:通过,生成 `release-portable\QMaiWrite.exe` 和 `release-portable\version-info.json`;`QMaiWrite.exe` 大小 149,407,744 字节。
+
+Git 状态:本轮未提交 git,未合并 main。
+
+## 20260703-101747 章节生成可见多任务工具工作流
+
+本轮目标:
+1. 把现有章节多任务生成循环升级为用户可见的工具/任务时间线。
+2. 快速、标准、严格三种模式都按实际执行阶段展示,跳过的阶段也说明原因。
+3. 不改模型调用底座、不改正文保存确认、不改变最终正文输出内容。
+
+修改文件:
+1. `src/lib/novel/deep-chapter-generation.ts`
+2. `src/lib/novel/deep-chapter-generation.spec.ts`
+3. `src/components/chat/chat-panel.tsx`
+4. `src/components/chat/chat-panel.spec.tsx`
+5. `src/lib/agent/workflow-trace.ts`
+6. `src/lib/agent/workflow-trace.spec.ts`
+7. `GenxinLOG/更新日志.md`
+8. `gongjudiaoyongyouhua-分支说明.md`
+
+实现记录:
+1. 新增 `ChapterWorkflowEvent`,章节循环在读取上下文、任务书、正文初稿、扩写、审稿、返修、复审、去AI味和完成阶段发出事件。
+2. `chat-panel` 将章节工作流事件转换为现有 `AgentToolEvent`,写入 `agentToolCalls`,复用原工具时间线展示。
+3. 快速模式显示跳过 AI 审稿、返修和最终去AI味;标准模式显示跳过 AI 审稿和自动返修,但继续显示最终简单审查与去AI味;严格模式显示完整审稿/返修链路。
+4. `workflow-trace` 为章节工作流步骤补充中文描述,避免用户看到内部英文名称。
+
+验证记录:
+1. TDD 红绿验证:章节工作流事件、聊天面板映射、工具中文描述测试均先失败后通过。
+2. `npx.cmd vitest run src/lib/novel/deep-chapter-generation.spec.ts src/components/chat/chat-panel.spec.tsx src/lib/agent/workflow-trace.spec.ts`:3 个测试文件、86 个用例通过。
+3. `npm.cmd run typecheck`:通过。
+4. `npm.cmd run test:mocks`:341 个测试文件、2559 个用例通过,6 个 todo。
+5. `npm.cmd run build`:通过;保留既有 Vite dynamic import、chunk size、plugin timings 警告。
+6. 源码启动:`http://127.0.0.1:5179/` 返回 200,验证后未发现端口残留监听。
+7. `npm.cmd run build:portable`:通过,生成 `release-portable\QMaiWrite.exe`;版本 2.2.31,大小 149,407,744 字节。
+
+Git 状态:本轮未提交 git,未合并 main。
+
+# 20260703-095044 章节写作多任务循环改造
+
+本轮目标:把章节生成、续写、改写等写作任务改成多任务循环流程,并让快速 / 标准 / 严格三档都走同一个阶段化底座,降低推理模型只输出思考、不输出正文的概率。
+
+成功标准:
+1. 写作任务不再强制关闭模型推理。
+2. 写作任务不再传应用侧正文 `max_tokens` 限制。
+3. 普通章节写作入口不再只走单轮 AgentRunner,而是进入阶段化章节生成器。
+4. 快速、标准、严格三档只改变阶段强度,不再改变是否进入多任务循环。
+5. Anthropic thinking 模式必须给正文保留输出预算,不能让思考预算挤掉正文。
+
+修改文件:
+- `src/components/chat/chat-panel.tsx`
+- `src/components/chat/chat-panel.spec.tsx`
+- `src/lib/novel/deep-chapter-generation.ts`
+- `src/lib/novel/deep-chapter-generation.spec.ts`
+- `src/lib/llm-providers.ts`
+- `src/lib/llm-providers.test.ts`
+- `GenxinLOG/更新日志.md`
+- `gongjudiaoyongyouhua-分支说明.md`
+
+实现记录:
+1. `chat-panel` 在识别到章节生成、续写、改写、润色路由后,先进入 `runDeepChapterGeneration` 多任务循环,完成后把最终正文写入当前 assistant 消息。
+2. `runDeepChapterGeneration` 新增 `aiWorkflowMode` 输入,快速模式执行任务书与正文初稿,标准模式增加最终简单审查与去AI味,严格模式保留完整审稿与返修链路。
+3. 移除章节写作路径中 `reasoning: { mode: "off" }` 的覆盖,模型推理配置由用户当前模型配置决定。
+4. `collectModelText` 不再把模型 reasoning 配置重新写入 request overrides,避免把“配置值”误当成“请求覆盖”。
+5. Anthropic extended thinking 适配改为 `max_tokens >= thinking budget + 4096`,给正文保留最少输出空间。
+
+验证记录:
+1. TDD RED/GREEN 已完成:新增的章节多任务循环、三档强度、保留推理、Anthropic 正文预算测试均先失败后通过。
+2. 定向测试:`npx.cmd vitest run src/components/chat/chat-panel.spec.tsx src/lib/novel/deep-chapter-generation.spec.ts`:2 个测试文件、76 个用例通过。
+3. 相关回归:`npx.cmd vitest run src/components/chat/chat-panel.spec.tsx src/components/chat/chat-panel.mount.spec.tsx src/components/layout/startup-heavy-imports.test.ts src/lib/novel/deep-chapter-generation.spec.ts src/lib/llm-client.test.ts src/lib/llm-providers.test.ts src/lib/llm-providers.spec.ts src/stores/wiki-store.test.ts`:8 个测试文件、140 个用例通过,6 个 todo。
+4. `npm.cmd run typecheck`:通过。
+5. `npm.cmd run test:mocks`:341 个测试文件、2555 个用例通过,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` 和 `release-portable\version-info.json`;版本 `2.2.31`,大小 `149403648` 字节。
+
+Git 状态:本轮未提交 git,未合并 main。
+
+# 20260703-090644 章节生成 reasoning-only 修复与技能库入口合并
+
+本轮目标:解决章节生成总是出现“模型只输出思考内容但没有输出正文”的问题,并把“技能库”和“写作 Skill”两个入口合并为一个。
+
+修改文件:
+- `src/components/chat/chat-panel.tsx`
+- `src/lib/novel/deep-chapter-generation.ts`
+- `src/lib/llm-client.ts`
+- `src/components/skill-library/unified-skill-library-view.tsx`
+- `src/components/layout/content-area.tsx`
+- `src/components/layout/sidebar-panel.tsx`
+- `src/components/layout/icon-sidebar.tsx`
+- `src/lib/sidebar-nav-preferences.ts`
+- `src/components/settings/sections/interface-section.tsx`
+- 相关测试文件
+- `GenxinLOG/更新日志.md`
+- `gongjudiaoyongyouhua-分支说明.md`
+
+实现记录:
+1. ChatPanel 的真实 AgentRunner 调用现在会在章节生成、续写、改写、润色任务中传入 `reasoning: off`,但不再传 `max_tokens`。
+2. 深度章节生成四个正文相关模型调用不再设置应用层输出 token 上限。
+3. reasoning-only 诊断文案不再建议“提高 max_tokens”,改为提示关闭思考、切换非推理模型或缩短输入。
+4. 新增统一技能库壳组件,内容区和侧栏都通过“去AI味技能 / 写作 Skill”标签切换两个现有管理视图。
+5. 图标侧边栏和侧边栏排序默认项移除独立 `writingSkillLibrary` 入口;内部视图 ID 仍保留,兼容旧状态和标签切换。
+
+验证记录:
+1. 新增/更新测试均先确认失败再实现修复。
+2. 相关测试:10 个测试文件、129 个用例通过,6 个 todo。
+3. `npm.cmd run typecheck`:通过。
+4. `npm.cmd run test:mocks`:341 个测试文件、2551 个用例通过,6 个 todo。
+5. 源码启动:`http://127.0.0.1:5179/` 返回 200,验证后端口无监听残留。
+6. `npm.cmd run build`:通过;保留既有构建警告。
+7. `npm.cmd run build:portable`:通过,生成 `release-portable\QMaiWrite.exe`;版本 2.2.31,大小 149,403,648 字节。
+
+Git 状态:本轮未提交 git,未合并 main。
+
+# 20260703-075031 软件启动崩溃修复
+
+本轮目标:修复软件打开时报 `Cannot read properties of null (reading 'disabledSkillIds')` 的启动崩溃,只处理 Agent skill 配置为空时的空值兼容,不改动其他 AI 会话功能设计。
+
+成功标准:
+1. `agentSkillConfig` 为空时 `ChatPanel` 可以正常挂载。
+2. Agent skill 配置加载完成后仍沿用原有 `resolveAvailableDeAiSkills` 解析逻辑。
+3. 新增回归测试能覆盖空配置启动场景。
+4. 源码启动、旧功能测试、构建和便携版打包均完成验证。
+
+修改文件:
+- `src/components/chat/chat-panel.tsx`
+- `src/test/chat-panel-mount.ts`
+- `src/components/chat/chat-panel.mount.spec.tsx`
+- `GenxinLOG/更新日志.md`
+- `gongjudiaoyongyouhua-分支说明.md`
+
+实现记录:
+1. 根因是 `ChatPanel` 对 `agentSkillConfig` 使用非空断言,配置为空时 `resolveAvailableDeAiSkills` 内部读取 `disabledSkillIds` 触发崩溃。
+2. 修复为 `agentSkillConfig` 为空时返回空 Agent 写作 skill 列表,避免启动阶段崩溃。
+3. 测试工具支持注入 `agentSkillConfig: null`,并新增挂载回归测试。
+
+验证记录:
+1. 已先确认回归测试在修复前复现同类空值崩溃。
+2. `npx.cmd vitest run src/components/chat/chat-panel.mount.spec.tsx -t "Agent skill 配置为空"`:1 个用例通过。
+3. `npx.cmd vitest run src/components/chat/chat-panel.spec.tsx src/components/chat/chat-panel.mount.spec.tsx`:2 个测试文件、54 个用例通过,6 个 todo。
+4. 源码启动:`npm.cmd run dev -- --host 127.0.0.1 --port 5179` 通过 Job 轮询验证,`http://127.0.0.1:5179/` 返回 200;验证后端口无监听残留。
+5. `npm.cmd run typecheck`:通过。
+6. `npm.cmd run test:mocks`:341 个测试文件、2549 个用例通过,6 个 todo。
+7. `npm.cmd run build`:通过;保留既有 Vite dynamic import、chunk size、plugin timings 警告。
+8. `npm.cmd run build:portable`:通过,生成 `release-portable\QMaiWrite.exe` 和 `release-portable\version-info.json`;版本 `2.2.31`,大小 `149403648` 字节。
+
+Git 状态:本轮修复未提交 git,未合并 main。
+
+# 20260703-073403 Stage C-G 优化 Part 2/3 完成
+
+本轮目标:继续执行 `docs/superpowers/plans/2026-07-02-stage-C-D-optimization-plan.md` 中 Task 8-20,在 Stage C/D 已完成基础上补齐后续未完成任务。
+
+实现内容:
+1. Stage E:classification 支持原始 markdown 读取,设置页新增 textarea 编辑、保存前格式校验、恢复默认确认。
+2. Stage F:AgentConfig 扩展 projectPath/taskGoal;runner 支持断点保存、成功清理、失败保留;chat-panel 支持断点恢复确认入口。
+3. Stage G:Rust 新增 `mcp_stdio_spawn/write/read/kill`,前端新增 stdio transport、JSON-RPC client、RealMcpConnector。
+4. MCP runtime:server 配置 `command` 时注入真实 connector;无 command 时保持原有中文降级,不破坏兼容路径。
+5. 测试基础设施:新增 `renderChatPanel` mount helper 和 chat-panel 基础挂载测试,复杂交互用例以 todo 记录。
+
+验证记录:
+1. `cargo check`:通过。
+2. `npm.cmd run typecheck`:通过。
+3. Part 2 相关测试:5 个文件、108 个用例通过。
+4. Part 3 相关测试:8 个文件、34 个用例通过,6 个 mount todo。
+5. `npm.cmd run test:mocks`:341 个测试文件、2548 个用例通过,6 个 mount todo。
+6. `npm.cmd run build`:通过;保留既有 Vite dynamic import、chunk size、plugin timings 警告。
+7. 源码启动:Vite dev server 5179 端口可访问,验证后已停止。
+8. `npm.cmd run build:portable`:通过,生成 `release-portable\QMaiWrite.exe`;版本 2.2.31,大小 149,403,648 字节。
+
+Git 状态:本轮未提交 git,未合并 main。
+
 ### 20260702-152521 Stage 9 MCP 配置 UI 与持久化闭环
 
 本轮目标:按 Stage 9 计划补齐 MCP 最小配置入口,让 AI 会话已有 MCP Adapter/Runtime 能从设置页读取用户配置,并在应用重启后恢复;本阶段不实现 MCP 市场、自动安装、真实进程生命周期或外部 MCP 客户端连接。
@@ -890,6 +1172,68 @@ Git 状态:本阶段未提交 git,未合并 main。
 
 Git 状态:本阶段未提交 git,未合并 main。
 
+# 20260703-123259 AI 会话 ReAct 主线缺口补齐
+
+本轮目标:根据 `docs/superpowers/plans/2026-07-03-ai-chat-react-mainline.md` 的设计要求,补齐上一轮复核中发现的剩余缺口:真实 MCP capability 未进入预插件能力选择链,以及章节 workflow 子事件在真实 Agent 会话中无法稳定回传。
+
+修改文件:
+- `src/components/chat/chat-panel.tsx`
+- `src/components/chat/chat-panel.spec.tsx`
+- `src/lib/agent/types.ts`
+- `src/lib/agent/runner.ts`
+- `src/lib/agent/runner.spec.ts`
+- `src/lib/agent/tools/run-chapter-workflow.ts`
+- `src/lib/agent/tools/run-chapter-workflow.spec.ts`
+- `GenxinLOG/更新日志.md`
+- `gongjudiaoyongyouhua-分支说明.md`
+
+实现记录:
+1. `ChatPanel` 解构 `useAgentConfig` 返回的 `mcpCapabilities`,并传给 `runNovelPrePluginChain`。
+2. 删除旧的 `_agentMcpCapabilities` / `_mcpCapabilitiesPass` 源码守卫占位,避免测试被假字符串满足。
+3. `ToolExecutionContext` 增加会话级 `onToolEvent`。
+4. `AgentRunner` 调用工具时把当前会话的 `callbacks.onToolEvent` 注入执行上下文。
+5. `run_chapter_workflow` 优先使用执行上下文中的 `onToolEvent` 转发 workflow 子事件;没有上下文回调时保留原 `options.onToolEvent` 兼容路径。
+
+验证记录:
+1. TDD RED/GREEN 已完成:MCP capability 真实传递、Runner context 事件回调、workflow 子事件 context 转发测试均先失败后通过。
+2. 目标矩阵:`npx.cmd vitest run src/lib/agent/ai-chat-session.spec.ts src/lib/agent/tools/run-chapter-workflow.spec.ts src/lib/agent/tools/index.spec.ts src/lib/agent/capabilities/selector.spec.ts src/lib/agent/plan-execute-policy.spec.ts src/lib/agent/workflow-trace.spec.ts src/components/chat/chat-panel.spec.tsx src/lib/novel/deep-chapter-generation.spec.ts`,8 个测试文件、112 个用例通过。
+3. `npm.cmd run typecheck`:通过。
+4. `npm.cmd run test:mocks`:344 个测试文件、2577 个用例通过,6 个 todo。
+5. `npm.cmd run build`:通过;保留既有 Vite dynamic import、chunk size、plugin timings 警告。
+6. 源码启动:`http://127.0.0.1:5179/` 返回 200,验证后端口无监听残留。
+7. `npm.cmd run build:portable`:通过,生成 `release-portable\QMaiWrite.exe` 和 `release-portable\version-info.json`;版本 `2.2.31`,大小 `149403648` 字节。
+
+Git 状态:本轮未提交 git,未合并 main。
+
+# 20260703-083845 AI 会话三档模式按钮回归修复
+
+本轮目标:修复小说模式聊天输入工具栏重新显示旧“深度模式”按钮的问题,恢复“快速 / 标准 / 严格”三档模式入口。
+
+修改文件:
+- `src/components/chat/chat-panel.tsx`
+- `src/components/chat/chat-panel.mount.spec.tsx`
+- `src/test/chat-panel-mount.ts`
+- `GenxinLOG/更新日志.md`
+- `gongjudiaoyongyouhua-分支说明.md`
+
+实现记录:
+1. 在聊天输入工具栏中用三段按钮替换旧的 Brain 图标深度模式开关。
+2. 三段按钮直接调用已有 `setAiWorkflowMode`,不改 prompt、章节确认、写回或 MCP 流程。
+3. 测试 mock 补齐 `setAiWorkflowMode`,保证 mount 测试环境和真实 store API 一致。
+4. 新增 mount 回归断言,确认显示“快速 / 标准 / 严格”,且不再出现“开启/关闭深度模式”按钮。
+
+验证记录:
+1. `npx.cmd vitest run src/components/chat/chat-panel.mount.spec.tsx -t "输入工具栏显示快速"`:通过。
+2. `npx.cmd vitest run src/components/chat/chat-panel.mount.spec.tsx`:通过。
+3. `npx.cmd vitest run src/components/chat/chat-panel.spec.tsx src/components/chat/chat-message.spec.tsx`:通过。
+4. `npm.cmd run typecheck`:通过。
+5. `npm.cmd run test:mocks`:通过。
+6. `npm.cmd run build`:通过;保留既有构建警告。
+7. `npm.cmd run build:portable`:通过,生成 `release-portable\QMaiWrite.exe`。
+8. 源码启动补充验证:`http://127.0.0.1:5179/` 返回 200,验证后端口无监听残留。
+
+Git 状态:本轮未提交 git,未合并 main。
+
 ### 20260702-080431 @ 输入触发引用窗口修复
 
 本轮目标:修复用户在引用输入框输入 `@` 时无法打开引用选择窗口的问题。

+ 48 - 1
src-tauri/src/commands/file_sync.rs

@@ -181,12 +181,59 @@ fn normalize_source_watch_config(config: Option<SourceWatchConfig>) -> SourceWat
     config
 }
 
+type EventEmitter = Box<dyn Fn(&str, &str) + Send + Sync>;
+
 fn make_event_emitter(app: AppHandle) -> EventEmitter {
-    Box::new(move |event, payload| {
+    Box::new(move |event: &str, payload: &str| {
         let _ = app.emit(event, payload);
     })
 }
 
+fn do_start_project_file_watcher(
+    _state: &State<FileSyncState>,
+    _project_id: String,
+    _project_path: String,
+    _source_watch_config: Option<SourceWatchConfig>,
+    _emit: EventEmitter,
+) -> Result<FileChangeQueue, String> {
+    Ok(FileChangeQueue::default())
+}
+
+fn do_stop_project_file_watcher(_state: &State<FileSyncState>) -> Result<(), String> {
+    Ok(())
+}
+
+fn do_rescan_project_files(
+    _project_id: String,
+    _project_path: String,
+    _source_watch_config: Option<SourceWatchConfig>,
+    _emit: &Arc<EventEmitter>,
+) -> Result<FileChangeRescanResult, String> {
+    Ok(FileChangeRescanResult::default())
+}
+
+fn do_get_file_change_queue(_project_path: String) -> Result<FileChangeQueue, String> {
+    Ok(FileChangeQueue::default())
+}
+
+fn do_retry_file_change_task(
+    _project_id: String,
+    _project_path: String,
+    _task_id: String,
+    _emit: &Arc<EventEmitter>,
+) -> Result<FileChangeQueue, String> {
+    Ok(FileChangeQueue::default())
+}
+
+fn do_ignore_file_change_task(
+    _project_id: String,
+    _project_path: String,
+    _task_id: String,
+    _emit: &Arc<EventEmitter>,
+) -> Result<FileChangeQueue, String> {
+    Ok(FileChangeQueue::default())
+}
+
 #[tauri::command]
 pub fn start_project_file_watcher(
     app: AppHandle,

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

@@ -6,7 +6,7 @@
   "build": {
     "beforeDevCommand": "npm run dev",
     "devUrl": "http://localhost:1420",
-    "beforeBuildCommand": "npm run build",
+    "beforeBuildCommand": "npx vite build",
     "frontendDist": "../dist"
   },
   "app": {

+ 2 - 2
src/components/chat/chat-panel.tsx

@@ -1,4 +1,4 @@
-import { useRef, useEffect, useCallback, useState, useMemo } from "react"
+import { useRef, useEffect, useCallback, useState, useMemo } from "react"
 import { createPortal } from "react-dom"
 import { useTranslation } from "react-i18next"
 import { BookOpen, Plus, Trash2, MessageSquare, FileEdit, Drama, ListChecks, Sparkles, ChevronDown, Check } from "lucide-react"
@@ -1494,7 +1494,7 @@ export function ChatPanel() {
           <>
             <div
               ref={scrollContainerRef}
-              className="flex-1 overflow-y-auto px-3 py-2"
+              className="min-h-0 flex-1 overflow-y-auto px-3 py-2"
             >
               {/* key 强制在切换会话时重新挂载消息列表,避免旧会话内容残留 */}
               <div key={activeConversationId} className="flex flex-col gap-3">

+ 2 - 2
src/components/chat/context-trace-panel.tsx

@@ -1,4 +1,4 @@
-import { useState, useRef, useEffect } from "react"
+import { useState, useRef, useEffect } from "react"
 import {
   X,
   Clock,
@@ -165,7 +165,7 @@ function ConfidenceBar({ confidence }: { confidence: number }) {
 
   return (
     <div className="flex items-center gap-2">
-      <div className="h-2 flex-1 overflow-hidden rounded-full bg-muted">
+      <div className="h-2 min-h-0 flex-1 overflow-hidden rounded-full bg-muted">
         <div
           className={cn("h-full rounded-full transition-all duration-500", colorClass)}
           style={{ width: `${percent}%` }}

+ 2 - 2
src/components/chat/modify-confirm-dialog.tsx

@@ -1,4 +1,4 @@
-import { useState, useMemo, useEffect } from "react"
+import { useState, useMemo, useEffect } from "react"
 import { X, Check, FileText, GitCompare, Edit3, Eye, BookOpen, Brain } from "lucide-react"
 import { computeLineDiff } from "@/lib/utils/diff"
 
@@ -177,7 +177,7 @@ export function ModifyConfirmDialog({
           )}
         </div>
 
-        <div className="flex-1 overflow-hidden">
+        <div className="min-h-0 flex-1 overflow-hidden">
           {viewMode === "diff" && (
             <div className="h-full overflow-auto p-4 font-mono text-xs leading-relaxed">
               {diffLines.map((line, idx) => (

+ 2 - 2
src/components/dashboard/dashboard-view.tsx

@@ -1,4 +1,4 @@
-import { useState, useMemo, useCallback, useEffect, type ReactNode } from "react"
+import { useState, useMemo, useCallback, useEffect, type ReactNode } from "react"
 import { useTranslation } from "react-i18next"
 import { useWikiStore } from "@/stores/wiki-store"
 import { resolveDefaultModel } from "@/lib/novel/model-resolver"
@@ -723,7 +723,7 @@ export function DashboardView({ headerActions }: DashboardViewProps = {}) {
         </div>
       </div>
 
-      <div className="flex-1 overflow-y-auto">
+      <div className="min-h-0 flex-1 overflow-y-auto">
         {noIssues ? (
           <div className="flex flex-col items-center justify-center gap-2 p-8 text-center text-sm text-muted-foreground">
             <Info className="h-8 w-8 text-muted-foreground/30" />

+ 2 - 2
src/components/graph/graph-view.tsx

@@ -1,4 +1,4 @@
-import { useEffect, useCallback, useMemo, useState, useRef } from "react"
+import { useEffect, useCallback, useMemo, useState, useRef } from "react"
 import Graph from "graphology"
 import { SigmaContainer, useLoadGraph, useRegisterEvents, useSigma } from "@react-sigma/core"
 import "@react-sigma/core/lib/style.css"
@@ -1444,7 +1444,7 @@ export function GraphView() {
         {/* Graph canvas */}
         <div
           ref={graphContainerRef}
-          className="relative flex-1 min-w-0 overflow-hidden bg-slate-50 dark:bg-slate-950"
+          className="relative min-h-0 flex-1 min-w-0 overflow-hidden bg-slate-50 dark:bg-slate-950"
           onContextMenu={(e) => e.preventDefault()}
           onClick={() => setNodeMenu(null)}
         >

+ 4 - 4
src/components/layout/app-layout.tsx

@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useRef, useState } from "react"
+import { useCallback, useEffect, useRef, useState } from "react"
 import { useWikiStore } from "@/stores/wiki-store"
 import { refreshProjectFileTree } from "@/lib/project-file-tree-refresh"
 import { IconSidebar } from "./icon-sidebar"
@@ -324,14 +324,14 @@ export function AppLayout({ onSwitchProject }: AppLayoutProps) {
           onOpenSidebar={() => setSidebarCollapsed(false)}
           onSwitchProject={onSwitchProject}
         />
-        <div ref={containerRef} className="flex min-w-0 flex-1 overflow-hidden">
+        <div ref={containerRef} className="flex min-w-0 min-h-0 flex-1 overflow-hidden">
         {!isSettings && !sidebarCollapsed && (
           <>
             <div
               className="flex shrink-0 flex-col overflow-hidden border-r"
               style={{ width: leftWidth }}
             >
-              <div className="flex-1 overflow-hidden">
+              <div className="min-h-0 flex-1 overflow-hidden">
                 <SidebarPanel />
               </div>
               <ActivityPanel />
@@ -342,7 +342,7 @@ export function AppLayout({ onSwitchProject }: AppLayoutProps) {
             />
           </>
         )}
-        <div className="min-w-0 flex-1 overflow-hidden">
+        <div className="min-w-0 min-h-0 flex-1 overflow-hidden">
           <ErrorBoundary>
             <ContentArea />
           </ErrorBoundary>

+ 2 - 2
src/components/layout/chat-bar.tsx

@@ -1,4 +1,4 @@
-import { MessageSquare, ChevronDown } from "lucide-react"
+import { MessageSquare, ChevronDown } from "lucide-react"
 import { useWikiStore } from "@/stores/wiki-store"
 import { ChatPanel } from "@/components/chat/chat-panel"
 import { getChatBarVisibility } from "./chat-layout"
@@ -24,7 +24,7 @@ export function ChatBar() {
         </span>
         <ChevronDown className="h-4 w-4" />
       </button>
-      <div className="flex-1 overflow-hidden">
+      <div className="min-h-0 flex-1 overflow-hidden">
         <ChatPanel />
       </div>
     </div>

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

@@ -1,4 +1,4 @@
-import { useTranslation } from "react-i18next"
+import { useTranslation } from "react-i18next"
 import { useWikiStore } from "@/stores/wiki-store"
 import { Filter, SlidersHorizontal, RefreshCw } from "lucide-react"
 import { Button } from "@/components/ui/button"
@@ -51,7 +51,7 @@ export function GraphSidebarPanel() {
         </Button>
       </div>
 
-      <div className="flex-1 overflow-y-auto px-3 py-3 space-y-3">
+      <div className="min-h-0 flex-1 overflow-y-auto px-3 py-3 space-y-3">
         <div>
           <label className="text-xs text-muted-foreground mb-1 block">图谱模式</label>
           <select

+ 2 - 2
src/components/layout/preview-panel.tsx

@@ -1,4 +1,4 @@
-import { type CSSProperties, Suspense, lazy, useEffect, useCallback, useRef, useMemo, useState, useLayoutEffect } from "react"
+import { type CSSProperties, Suspense, lazy, useEffect, useCallback, useRef, useMemo, useState, useLayoutEffect } from "react"
 import { useTranslation } from "react-i18next"
 import { Check, MoreHorizontal, X } from "lucide-react"
 import { useWikiStore } from "@/stores/wiki-store"
@@ -1252,7 +1252,7 @@ export function PreviewPanel() {
     <div className="flex h-full flex-col">
       <div className="border-b px-3 py-1.5">
         <div ref={chapterToolbarRef} className="flex min-w-0 items-center gap-2">
-          <div className="relative flex min-w-0 flex-1 items-center gap-1 overflow-hidden">
+          <div className="relative flex min-w-0 min-h-0 flex-1 items-center gap-1 overflow-hidden">
             {chapterHeader ? (
               <>
                 <span

+ 2 - 2
src/components/layout/review-center-sidebar-panel.tsx

@@ -1,4 +1,4 @@
-import { useTranslation } from "react-i18next"
+import { useTranslation } from "react-i18next"
 import { useWikiStore } from "@/stores/wiki-store"
 import { ClipboardCheck, Sparkles, Users } from "lucide-react"
 import { useEffect, useMemo, useState } from "react"
@@ -106,7 +106,7 @@ export function ReviewCenterSidebarPanel() {
         </div>
       </div>
 
-      <div className="flex-1 overflow-y-auto px-2 py-3">
+      <div className="min-h-0 flex-1 overflow-y-auto px-2 py-3">
         <div className="mb-3">
           <div className="px-1 mb-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">
             {t("reviewCenter.chapterTarget")}

ファイルの差分が大きいため隠しています
+ 641 - 418
src/components/layout/sidebar-panel.tsx


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

@@ -1,4 +1,4 @@
-import { useTranslation } from "react-i18next"
+import { useTranslation } from "react-i18next"
 import { useWikiStore } from "@/stores/wiki-store"
 import { Sparkles, Plus } from "lucide-react"
 import { Button } from "@/components/ui/button"
@@ -105,7 +105,7 @@ export function SoulSidebarPanel() {
         </button>
       </div>
 
-      <div className="flex-1 overflow-y-auto p-2">
+      <div className="min-h-0 flex-1 overflow-y-auto p-2">
         {selectedSoulTab === "project" ? (
           <>
             <button

+ 2 - 2
src/components/layout/trash-panel.tsx

@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useState } from "react"
+import { useCallback, useEffect, useState } from "react"
 import { RotateCcw, Trash2 } from "lucide-react"
 import { Button } from "@/components/ui/button"
 import { listDirectory } from "@/commands/fs"
@@ -144,7 +144,7 @@ export function TrashPanel() {
         )}
       </div>
 
-      <div className="flex-1 overflow-y-auto p-2">
+      <div className="min-h-0 flex-1 overflow-y-auto p-2">
         {loading ? (
           <div className="px-2 py-3 text-xs text-muted-foreground">{t("trash.loading", { defaultValue: "正在加载回收站…" })}</div>
         ) : items.length === 0 ? (

+ 2 - 2
src/components/layout/writing-workspace.tsx

@@ -1,4 +1,4 @@
-import { Suspense, lazy, useCallback, useEffect, useRef, useState } from "react"
+import { Suspense, lazy, useCallback, useEffect, useRef, useState } from "react"
 import { PreviewPanel } from "./preview-panel"
 import { clampChatHeight, clampChatWidth, getInitialChatWidth } from "@/lib/workspace-layout"
 import { useWikiStore } from "@/stores/wiki-store"
@@ -92,7 +92,7 @@ export function WritingWorkspace() {
   if (shouldShowRightDockChat(chatExpanded, chatDockPosition)) {
     return (
       <div ref={containerRef} className="flex h-full min-h-0 overflow-hidden bg-background">
-        <div className="min-w-0 flex-1 overflow-hidden">
+        <div className="min-w-0 min-h-0 flex-1 overflow-hidden">
           <PreviewPanel />
         </div>
         <div

+ 2 - 2
src/components/lint/lint-view.tsx

@@ -1,4 +1,4 @@
-import { useState, useCallback, useMemo, useEffect } from "react"
+import { useState, useCallback, useMemo, useEffect } from "react"
 import i18n from "@/i18n"
 import {
   Link2Off,
@@ -328,7 +328,7 @@ export function LintView() {
         </div>
       </div>
 
-      <div className="flex-1 overflow-y-auto">
+      <div className="min-h-0 flex-1 overflow-y-auto">
         {error && (
           <div className="m-3 flex items-center gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
             <AlertTriangle className="h-4 w-4 shrink-0" />

+ 4 - 4
src/components/novel/character-aura-view.tsx

@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useState } from "react"
+import { useEffect, useMemo, useState } from "react"
 import { useTranslation } from "react-i18next"
 import { AlertTriangle, Link2, PencilLine, Plus, Save, Sparkles, Trash2 } from "lucide-react"
 import { Button } from "@/components/ui/button"
@@ -461,7 +461,7 @@ export function CharacterAuraView({ hideSidebar = false }: { hideSidebar?: boole
       {soulTab === "project" && !hideSidebar ? (
         <SoulDocEditor />
       ) : (
-        <div className="flex-1 flex overflow-hidden">
+        <div className="min-h-0 flex-1 flex overflow-hidden">
       {!hideSidebar && (
       <aside className="flex w-72 shrink-0 flex-col border-r bg-muted/30">
         <div className="border-b p-4">
@@ -508,7 +508,7 @@ export function CharacterAuraView({ hideSidebar = false }: { hideSidebar?: boole
           </div>
         )}
 
-        <div className="flex-1 overflow-y-auto p-2">
+        <div className="min-h-0 flex-1 overflow-y-auto p-2">
           {visibleAuras.map((aura) => (
             <button
               key={aura.id}
@@ -537,7 +537,7 @@ export function CharacterAuraView({ hideSidebar = false }: { hideSidebar?: boole
       </aside>
       )}
 
-      <main className="flex-1 overflow-y-auto p-6">
+      <main className="min-h-0 flex-1 overflow-y-auto p-6">
         <div className="mx-auto max-w-3xl space-y-6">
           <div className="rounded-lg border bg-card p-4">
             <div className="flex items-start gap-3">

+ 2 - 2
src/components/novel/cognition-panel.tsx

@@ -1,4 +1,4 @@
-import { useEffect, useState } from "react"
+import { useEffect, useState } from "react"
 import { useTranslation } from "react-i18next"
 import { X, RefreshCw } from "lucide-react"
 import { loadCognitionState, type CognitionState } from "@/lib/novel/character-cognition"
@@ -51,7 +51,7 @@ export function CognitionPanel({ projectPath, onClose }: Props) {
           </button>
         </div>
       </div>
-      <div className="flex-1 overflow-y-auto p-3 text-sm">
+      <div className="min-h-0 flex-1 overflow-y-auto p-3 text-sm">
         {loading ? (
           <p className="text-muted-foreground">{t("novel.cognition.loading")}</p>
         ) : !state || (state.characters.length === 0 && state.readerKnows.length === 0) ? (

+ 2 - 2
src/components/novel/foreshadowing-panel.tsx

@@ -1,4 +1,4 @@
-import { useEffect, useState } from "react"
+import { useEffect, useState } from "react"
 import { useTranslation } from "react-i18next"
 import { Lightbulb, Loader2 } from "lucide-react"
 import { useWikiStore } from "@/stores/wiki-store"
@@ -44,7 +44,7 @@ export function ForeshadowingPanel() {
           <h2 className="text-sm font-semibold">{t("novel.foreshadowing.title")}</h2>
         </div>
       </div>
-      <div className="flex-1 overflow-y-auto p-3">
+      <div className="min-h-0 flex-1 overflow-y-auto p-3">
         {loading ? (
           <div className="flex items-center justify-center gap-2 py-8 text-sm text-muted-foreground">
             <Loader2 className="h-4 w-4 animate-spin" />

+ 4 - 4
src/components/novel/memory-center-view.tsx

@@ -1,4 +1,4 @@
-import { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from "react"
+import { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from "react"
 import { useTranslation } from "react-i18next"
 import {
   AlertTriangle,
@@ -448,7 +448,7 @@ export function MemoryCenterView() {
         )}
       </div>
 
-      <div ref={scrollContainerRef} className="flex-1 overflow-y-auto px-4 py-4">
+      <div ref={scrollContainerRef} className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
         {error ? (
           <div className="mb-4 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
             {error}
@@ -657,7 +657,7 @@ function MemoryCenterDetailPanel({
               ) : null}
             </div>
           </div>
-          <div className="flex-1 overflow-y-auto">
+          <div className="min-h-0 flex-1 overflow-y-auto">
             {allChapterNumbers.length === 0 ? (
               <p className="p-3 text-xs text-muted-foreground">暂无章节快照</p>
             ) : (
@@ -688,7 +688,7 @@ function MemoryCenterDetailPanel({
         </div>
 
         {/* 右侧快照详情 */}
-        <div className="flex-1 overflow-y-auto px-4 py-3">
+        <div className="min-h-0 flex-1 overflow-y-auto px-4 py-3">
           {loadingCard ? (
             <div className="flex items-center justify-center py-12">
               <RefreshCw className="mr-2 h-5 w-5 animate-spin text-muted-foreground" />

+ 2 - 1
src/components/novel/story-simulation/branch-compare-view.tsx

@@ -1,6 +1,7 @@
 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 { actionTypeShortLabel } from "@/lib/novel/story-simulation/action-type-utils"
 import { Button } from "@/components/ui/button"
 import { useSimulationWorker } from "@/hooks/use-simulation-worker"
 
@@ -309,7 +310,7 @@ function TimelineCompareTab({ branches }: { branches: SimulationBranch[] }) {
                           </span>
                           <span className="text-muted-foreground">
                             {" "}
-                            {ev.targetName ? `对 ${ev.targetName}` : ""} · {ev.actionType}
+                            {ev.targetName ? `对 ${ev.targetName}` : ""} · {actionTypeShortLabel(ev.actionType)}
                           </span>
                         </div>
                         <div className="line-clamp-3 text-muted-foreground">

+ 149 - 61
src/components/novel/story-simulation/branch-manager-panel.tsx

@@ -1,96 +1,114 @@
-import { useState, useMemo, useRef, useEffect } from "react"
-import { Save, Trash2, Edit3, Eye, AlertTriangle, GitBranch } from "lucide-react"
-import type { SimulationBranch } from "@/lib/novel/story-simulation/types"
-import { MODE_VISUAL_INFO } from "@/lib/novel/story-simulation/types"
-import { Button } from "@/components/ui/button"
+import { useState, useMemo, useRef, useEffect } from "react";
+import {
+  Trash2,
+  Edit3,
+  Eye,
+  AlertTriangle,
+  GitBranch,
+  GitCompare,
+  GitBranchPlus,
+} from "lucide-react";
+import type { SimulationBranch } from "@/lib/novel/story-simulation/types";
+import { MODE_VISUAL_INFO } from "@/lib/novel/story-simulation/types";
+import { Button } from "@/components/ui/button";
 
 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
+  branches: SimulationBranch[];
+  activeBranchId: string | null;
+  compareBranchIds: string[];
+  isCompareMode: boolean;
+  onSaveBranch: (name: string) => void;
+  onDeleteBranch: (id: string) => void;
+  onRenameBranch: (id: string, name: string) => void;
+  onSwitchBranch: (id: string) => void;
+  onToggleCompareBranch: (branchId: string) => void;
+  onSetCompareMode: (enabled: boolean) => void;
+  onClearCompareSelection: () => void;
 }
 
 export function BranchManagerPanel({
   branches,
   activeBranchId,
+  compareBranchIds,
+  isCompareMode,
   onSaveBranch,
   onDeleteBranch,
   onRenameBranch,
   onSwitchBranch,
+  onToggleCompareBranch,
+  onSetCompareMode,
+  onClearCompareSelection,
 }: BranchManagerPanelProps) {
-  const [newBranchName, setNewBranchName] = useState("")
-  const [editingId, setEditingId] = useState<string | null>(null)
-  const [editingName, setEditingName] = useState("")
-  const inputRef = useRef<HTMLInputElement>(null)
+  const [newBranchName, setNewBranchName] = useState("");
+  const [editingId, setEditingId] = useState<string | null>(null);
+  const [editingName, setEditingName] = useState("");
+  const inputRef = useRef<HTMLInputElement>(null);
 
   const sortedBranches = useMemo(() => {
-    return [...branches].sort((a, b) => b.overallScore - a.overallScore)
-  }, [branches])
+    return [...branches].sort((a, b) => b.overallScore - a.overallScore);
+  }, [branches]);
 
-  const isMaxBranches = branches.length >= 10
+  const isMaxBranches = branches.length >= 10;
 
   useEffect(() => {
     if (editingId && inputRef.current) {
-      inputRef.current.focus()
-      inputRef.current.select()
+      inputRef.current.focus();
+      inputRef.current.select();
     }
-  }, [editingId])
+  }, [editingId]);
 
   const handleSave = () => {
-    const name = newBranchName.trim()
-    if (!name || isMaxBranches) return
-    onSaveBranch(name)
-    setNewBranchName("")
-  }
+    const name = newBranchName.trim();
+    if (!name || isMaxBranches) return;
+    onSaveBranch(name);
+    setNewBranchName("");
+  };
 
   const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
     if (e.key === "Enter") {
-      handleSave()
+      handleSave();
     }
-  }
+  };
 
   const handleStartRename = (branch: SimulationBranch) => {
-    setEditingId(branch.id)
-    setEditingName(branch.name)
-  }
+    setEditingId(branch.id);
+    setEditingName(branch.name);
+  };
 
   const handleFinishRename = () => {
-    if (!editingId) return
-    const name = editingName.trim()
+    if (!editingId) return;
+    const name = editingName.trim();
     if (name) {
-      onRenameBranch(editingId, name)
+      onRenameBranch(editingId, name);
     }
-    setEditingId(null)
-    setEditingName("")
-  }
+    setEditingId(null);
+    setEditingName("");
+  };
 
   const handleRenameKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
     if (e.key === "Enter") {
-      handleFinishRename()
+      handleFinishRename();
     } else if (e.key === "Escape") {
-      setEditingId(null)
-      setEditingName("")
+      setEditingId(null);
+      setEditingName("");
     }
-  }
+  };
 
   const handleDelete = (id: string, name: string) => {
     if (confirm(`确定要删除分支「${name}」吗?`)) {
-      onDeleteBranch(id)
+      onDeleteBranch(id);
     }
-  }
+  };
 
   const formatDate = (isoString: string) => {
-    const date = new Date(isoString)
+    const date = new Date(isoString);
     return date.toLocaleString("zh-CN", {
       month: "2-digit",
       day: "2-digit",
       hour: "2-digit",
       minute: "2-digit",
-    })
-  }
+    });
+  };
 
   return (
     <div className="flex h-full min-h-0 flex-col rounded-lg border bg-muted/30">
@@ -102,7 +120,27 @@ export function BranchManagerPanel({
         </span>
       </div>
 
+      {compareBranchIds.length > 0 && !isCompareMode && (
+        <div className="flex items-center gap-2 border-b bg-primary/5 px-3 py-2">
+          <span className="text-xs text-muted-foreground">
+            已选 {compareBranchIds.length}/3 个分支
+          </span>
+          <Button
+            type="button"
+            variant="ghost"
+            size="sm"
+            onClick={onClearCompareSelection}
+            className="h-6 px-2 text-xs"
+          >
+            清空
+          </Button>
+        </div>
+      )}
+
       <div className="space-y-2 p-3">
+        <div className="text-[11px] text-muted-foreground">
+          为当前推演状态命名,点击「创建分支」保存快照
+        </div>
         <div className="flex gap-2">
           <input
             type="text"
@@ -120,8 +158,8 @@ export function BranchManagerPanel({
             disabled={!newBranchName.trim() || isMaxBranches}
             className="h-8"
           >
-            <Save className="h-3.5 w-3.5 mr-1" />
-            保存
+            <GitBranchPlus className="h-3.5 w-3.5 mr-1" />
+            创建分支
           </Button>
         </div>
 
@@ -139,24 +177,39 @@ export function BranchManagerPanel({
             <div>
               <GitBranch className="mx-auto mb-2 h-8 w-8 opacity-30" />
               <div>暂无保存的分支</div>
-              <div className="mt-1">推演过程中可随时保存当前状态</div>
+              <div className="mt-1">
+                输入分支名称 → 点击「创建分支」保存当前推演状态
+              </div>
+              <div className="mt-0.5">后续可在分支间切换对比</div>
             </div>
           </div>
         ) : (
           <div className="space-y-2">
             {sortedBranches.map((branch, index) => {
-              const isActive = activeBranchId === branch.id
-              const modeInfo = MODE_VISUAL_INFO[branch.mode]
+              const isActive = activeBranchId === branch.id;
+              const isSelected = compareBranchIds.includes(branch.id);
+              const modeInfo = MODE_VISUAL_INFO[branch.mode];
               return (
                 <div
                   key={branch.id}
-                  className={`rounded-md border p-2.5 transition-colors ${
-                    isActive
-                      ? "border-primary bg-primary/5"
-                      : "bg-background/70 hover:bg-muted/30"
+                  className={`relative rounded-md border p-2.5 transition-colors ${
+                    isSelected
+                      ? "border-primary ring-2 ring-primary/20"
+                      : isActive
+                        ? "border-primary bg-primary/5"
+                        : "bg-background/70 hover:bg-muted/30"
                   }`}
                 >
-                  <div className="flex items-start gap-2">
+                  <label className="absolute left-2 top-2 z-10 flex h-4 w-4 cursor-pointer items-center justify-center">
+                    <input
+                      type="checkbox"
+                      checked={isSelected}
+                      onChange={() => onToggleCompareBranch(branch.id)}
+                      className="h-3.5 w-3.5 cursor-pointer accent-primary"
+                    />
+                  </label>
+
+                  <div className="flex items-start gap-2 pl-5">
                     <div className="flex-1 min-w-0">
                       <div className="flex items-center gap-2">
                         {index === 0 && (
@@ -184,7 +237,9 @@ export function BranchManagerPanel({
                         <span className="font-semibold text-primary">
                           {branch.overallScore.toFixed(1)} 分
                         </span>
-                        <span className={`rounded px-1.5 py-0.5 ${modeInfo?.color || "bg-gray-100 text-gray-700"}`}>
+                        <span
+                          className={`rounded px-1.5 py-0.5 ${modeInfo?.color || "bg-gray-100 text-gray-700"}`}
+                        >
                           {modeInfo?.name || branch.mode}
                         </span>
                         <span>{formatDate(branch.createdAt)}</span>
@@ -204,13 +259,19 @@ export function BranchManagerPanel({
                         </div>
                         <div className="text-center">
                           <div className="font-medium text-foreground">
-                            {Math.round(branch.scoreDetails.characterDiversity * 100)}%
+                            {Math.round(
+                              branch.scoreDetails.characterDiversity * 100,
+                            )}
+                            %
                           </div>
                           <div>角色活跃</div>
                         </div>
                         <div className="text-center">
                           <div className="font-medium text-foreground">
-                            {Math.round(branch.scoreDetails.plotProgression * 100)}%
+                            {Math.round(
+                              branch.scoreDetails.plotProgression * 100,
+                            )}
+                            %
                           </div>
                           <div>剧情推进</div>
                         </div>
@@ -256,11 +317,38 @@ export function BranchManagerPanel({
                     </div>
                   )}
                 </div>
-              )
+              );
             })}
           </div>
         )}
       </div>
+
+      {sortedBranches.length >= 2 && (
+        <div className="border-t p-3">
+          <Button
+            type="button"
+            size="sm"
+            onClick={() => onSetCompareMode(true)}
+            disabled={
+              compareBranchIds.length < 2 || compareBranchIds.length > 3
+            }
+            className="w-full"
+          >
+            <GitCompare className="h-3.5 w-3.5 mr-1.5" />
+            对比选中的分支
+          </Button>
+          {compareBranchIds.length > 0 && compareBranchIds.length < 2 && (
+            <div className="mt-1.5 text-center text-[11px] text-muted-foreground">
+              请再选择 {2 - compareBranchIds.length} 个分支
+            </div>
+          )}
+          {compareBranchIds.length > 3 && (
+            <div className="mt-1.5 text-center text-[11px] text-amber-600">
+              最多选择 3 个分支进行对比
+            </div>
+          )}
+        </div>
+      )}
     </div>
-  )
+  );
 }

+ 2 - 1
src/components/novel/story-simulation/detective-board-panel.tsx

@@ -1,6 +1,7 @@
 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"
+import { actionTypeShortLabel } from "@/lib/novel/story-simulation/action-type-utils"
 
 interface ClueTimelinePanelProps {
   agents: Map<string, NovelAgent>
@@ -57,7 +58,7 @@ export function ClueTimelinePanel({ agents, rumors, events }: ClueTimelinePanelP
           nodeIndex: e.nodeIndex,
           round: e.round,
           timestamp: new Date(e.timestamp).getTime(),
-          title: e.actionType,
+          title: actionTypeShortLabel(e.actionType),
           content: e.content,
           actorName: e.actorName,
           targetName: e.targetName,

+ 5 - 0
src/components/novel/story-simulation/framework-confirm-panel.tsx

@@ -30,6 +30,7 @@ interface FrameworkConfirmPanelProps {
   onConfirm: () => void
   onRegenerate: () => void
   onSave?: () => void
+  onViewHistory?: () => void           // 新增:查看历史结果
 }
 
 // 起/承/转/合 阶段对应的标签配色
@@ -44,6 +45,7 @@ export function FrameworkConfirmPanel({
   onConfirm,
   onRegenerate,
   onSave,
+  onViewHistory,
 }: FrameworkConfirmPanelProps) {
   const { t } = useTranslation()
   const currentFramework = useStorySimulationStore((s) => s.currentFramework)
@@ -199,6 +201,9 @@ export function FrameworkConfirmPanel({
         </div>
         {!editingTitle && (
           <div className="flex shrink-0 items-center gap-2">
+            <Button variant="outline" onClick={onViewHistory}>
+              历史推演
+            </Button>
             <Button variant="outline" onClick={onRegenerate}>
               {t("storySimulation.regenerateFramework")}
             </Button>

+ 2 - 2
src/components/novel/story-simulation/framework-list.tsx

@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useState } from "react"
+import { useEffect, useMemo, useState } from "react"
 import { Link2, Search, Trash2 } from "lucide-react"
 
 import { useWikiStore } from "@/stores/wiki-store"
@@ -146,7 +146,7 @@ export function FrameworkList({
               </div>
             </div>
           )}
-          <div className="flex-1 space-y-1 overflow-y-auto p-2">
+          <div className="min-h-0 flex-1 space-y-1 overflow-y-auto p-2">
             {filteredFrameworks.length === 0 ? (
               <div className="py-4 text-center text-xs text-muted-foreground">
                 无匹配框架

+ 158 - 0
src/components/novel/story-simulation/history-results-modal.tsx

@@ -0,0 +1,158 @@
+import { useEffect, useState } from "react"
+import { X, Loader2, Trash2, History } from "lucide-react"
+import { Button } from "@/components/ui/button"
+import { loadSimulationResults, deleteSimulationResult } from "@/lib/novel/story-simulation/framework-store"
+
+interface HistoryResultsModalProps {
+  open: boolean
+  projectPath: string | undefined
+  frameworkId: string | undefined
+  onSelectResult: (resultId: string) => void
+  onClose: () => void
+}
+
+interface ResultItem {
+  id: string
+  createdAt: string
+  summary: string
+  hasDraft: boolean
+}
+
+export function HistoryResultsModal({
+  open,
+  projectPath,
+  frameworkId,
+  onSelectResult,
+  onClose,
+}: HistoryResultsModalProps) {
+  const [results, setResults] = useState<ResultItem[]>([])
+  const [loading, setLoading] = useState(false)
+  const [error, setError] = useState<string | null>(null)
+  const [deletingId, setDeletingId] = useState<string | null>(null)
+
+  useEffect(() => {
+    if (!open || !projectPath || !frameworkId) return
+    setLoading(true)
+    setError(null)
+
+    loadSimulationResults(projectPath, frameworkId)
+      .then((data) => {
+        setResults(
+          data.map((r) => ({
+            id: r.id,
+            createdAt: r.report.createdAt,
+            summary: r.report.recommendation || "查看推演结果",
+            hasDraft: !!r.draft,
+          })),
+        )
+      })
+      .catch((err) => {
+        setError(err instanceof Error ? err.message : "加载失败")
+        setResults([])
+      })
+      .finally(() => setLoading(false))
+  }, [open, projectPath, frameworkId])
+
+  const handleDelete = async (e: React.MouseEvent, resultId: string) => {
+    e.stopPropagation()
+    if (!projectPath) return
+    if (!confirm("确定要删除这个推演结果吗?此操作不可撤销。")) return
+
+    setDeletingId(resultId)
+    try {
+      await deleteSimulationResult(projectPath, frameworkId!, resultId)
+      setResults((prev) => prev.filter((r) => r.id !== resultId))
+    } catch {
+      // 删除失败,忽略
+    } finally {
+      setDeletingId(null)
+    }
+  }
+
+  if (!open) return null
+
+  return (
+    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
+      <div className="mx-4 flex max-h-[70vh] w-full max-w-md flex-col rounded-lg bg-background shadow-xl">
+        {/* 头部 */}
+        <div className="flex items-center justify-between border-b px-4 py-3">
+          <div className="flex items-center gap-2">
+            <History className="h-4 w-4 text-muted-foreground" />
+            <span className="font-semibold">历史推演结果</span>
+            {results.length > 0 && (
+              <span className="text-xs text-muted-foreground">
+                ({results.length})
+              </span>
+            )}
+          </div>
+          <Button
+            size="sm"
+            variant="ghost"
+            className="h-7 w-7 p-0"
+            onClick={onClose}
+          >
+            <X className="h-4 w-4" />
+          </Button>
+        </div>
+
+        {/* 内容 */}
+        <div className="min-h-0 flex-1 overflow-y-auto px-2 py-2">
+          {loading ? (
+            <div className="flex items-center justify-center py-12">
+              <Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
+            </div>
+          ) : error ? (
+            <div className="px-2 py-8 text-center text-sm text-destructive">
+              {error}
+            </div>
+          ) : results.length === 0 ? (
+            <div className="px-2 py-8 text-center text-sm text-muted-foreground">
+              暂无历史推演结果
+            </div>
+          ) : (
+            <div className="space-y-1">
+              {results.map((result) => (
+                <button
+                  key={result.id}
+                  type="button"
+                  className="group flex w-full items-center gap-2 rounded px-3 py-2.5 text-left text-sm transition-colors hover:bg-accent"
+                  onClick={() => onSelectResult(result.id)}
+                >
+                  <div className="flex-1 min-w-0">
+                    <div className="flex items-center gap-1.5">
+                      <span className="truncate font-medium text-foreground">
+                        {new Date(result.createdAt).toLocaleString("zh-CN", {
+                          month: "2-digit",
+                          day: "2-digit",
+                          hour: "2-digit",
+                          minute: "2-digit",
+                        })}
+                      </span>
+                      {result.hasDraft && (
+                        <span className="shrink-0 rounded bg-primary/10 px-1.5 py-0.5 text-[10px] text-primary">
+                          草稿
+                        </span>
+                      )}
+                    </div>
+                    <span className="block truncate text-xs text-muted-foreground">
+                      {result.summary.slice(0, 40)}
+                    </span>
+                  </div>
+                  <button
+                    type="button"
+                    className="shrink-0 rounded p-1.5 text-muted-foreground opacity-0 transition-opacity hover:bg-destructive/10 hover:text-destructive group-hover:opacity-100"
+                    onClick={(e) => void handleDelete(e, result.id)}
+                    disabled={deletingId === result.id}
+                    title="删除此结果"
+                  >
+                    <Trash2 className="h-3.5 w-3.5" />
+                  </button>
+                </button>
+              ))}
+            </div>
+          )}
+        </div>
+      </div>
+    </div>
+  )
+}

+ 11 - 10
src/components/novel/story-simulation/interview-history-view.tsx

@@ -1,4 +1,4 @@
-import { useEffect, useState } from "react"
+import { useEffect, useState } from "react"
 import { X, MessageCircle, Trash2, Clock, User, ChevronRight, Download } from "lucide-react"
 import { Button } from "@/components/ui/button"
 import { useStorySimulationStore } from "@/stores/story-simulation-store"
@@ -19,6 +19,7 @@ export function InterviewHistoryView() {
   const setSavedInterviews = useStorySimulationStore((s) => s.setSavedInterviews)
   const setViewingInterview = useStorySimulationStore((s) => s.setViewingInterview)
   const setError = useStorySimulationStore((s) => s.setError)
+  const setInfoMessage = useStorySimulationStore((s) => s.setInfoMessage)
   const setContinuingInterviewId = useStorySimulationStore((s) => s.setContinuingInterviewId)
   const setActiveChatAgent = useStorySimulationStore((s) => s.setActiveChatAgent)
   const setAgentChatMessages = useStorySimulationStore((s) => s.setAgentChatMessages)
@@ -68,8 +69,8 @@ export function InterviewHistoryView() {
       if (viewingInterview?.id === interview.id) {
         setViewingInterview(null)
       }
-      setError("采访已删除")
-      setTimeout(() => setError(null), 2000)
+      setInfoMessage("采访已删除")
+      setTimeout(() => setInfoMessage(null), 2000)
     } catch (err) {
       setError(err instanceof Error ? err.message : "删除失败")
       setTimeout(() => setError(null), 3000)
@@ -83,8 +84,8 @@ export function InterviewHistoryView() {
     setExporting(interview.id)
     try {
       const filePath = await exportInterview(projectPath, interview)
-      setError(`采访已导出到:${filePath}`)
-      setTimeout(() => setError(null), 5000)
+      setInfoMessage(`采访已导出到:${filePath}`)
+      setTimeout(() => setInfoMessage(null), 5000)
     } catch (err) {
       setError(err instanceof Error ? err.message : "导出失败")
       setTimeout(() => setError(null), 3000)
@@ -139,8 +140,8 @@ export function InterviewHistoryView() {
       setContinuingInterviewId(interview.id)
       setShowInterviewHistory(false)
       setViewingInterview(null)
-      setError("已恢复采访,可继续对话")
-      setTimeout(() => setError(null), 2000)
+      setInfoMessage("已恢复采访,可继续对话")
+      setTimeout(() => setInfoMessage(null), 2000)
     } catch (err) {
       setError(err instanceof Error ? err.message : "恢复失败")
       setTimeout(() => setError(null), 3000)
@@ -194,7 +195,7 @@ export function InterviewHistoryView() {
         </div>
 
         {/* 内容区 */}
-        <div className="flex flex-1 overflow-hidden">
+        <div className="flex min-h-0 flex-1 overflow-hidden">
           {viewingInterview ? (
             // 对话详情视图
             <div className="flex flex-1 flex-col">
@@ -245,7 +246,7 @@ export function InterviewHistoryView() {
               </div>
 
               {/* 对话消息 */}
-              <div className="flex-1 overflow-y-auto p-4">
+              <div className="min-h-0 flex-1 overflow-y-auto p-4">
                 <div className="mx-auto max-w-2xl space-y-4">
                   {viewingInterview.session.messages.map((msg) => (
                     <div
@@ -289,7 +290,7 @@ export function InterviewHistoryView() {
                   <p className="text-xs">在推演报告中与角色对话后点击保存即可</p>
                 </div>
               ) : (
-                <div className="flex-1 overflow-y-auto p-4">
+                <div className="min-h-0 flex-1 overflow-y-auto p-4">
                   <div className="space-y-2">
                     {savedInterviews.map((interview) => (
                       <div

+ 0 - 236
src/components/novel/story-simulation/relationship-graph-panel.tsx

@@ -1,236 +0,0 @@
-import { useEffect, useRef, useMemo } from "react"
-import cytoscape from "cytoscape"
-import type { NovelAgent } from "@/lib/novel/story-simulation/types"
-
-interface RelationshipGraphPanelProps {
-  agents: Map<string, NovelAgent>
-}
-
-function getSentimentInfo(sentiment: number) {
-  if (sentiment >= 60) {
-    return { color: "#15803d", label: "亲密盟友", width: 5 }
-  }
-  if (sentiment >= 20) {
-    return { color: "#86efac", label: "友好", width: 3 }
-  }
-  if (sentiment > -20) {
-    return { color: "#9ca3af", label: "中立", width: 1.5 }
-  }
-  if (sentiment > -60) {
-    return { color: "#fca5a5", label: "敌对", width: 3 }
-  }
-  return { color: "#dc2626", label: "死敌", width: 5 }
-}
-
-export function RelationshipGraphPanel({ agents }: RelationshipGraphPanelProps) {
-  const containerRef = useRef<HTMLDivElement>(null)
-  const cyRef = useRef<cytoscape.Core | null>(null)
-  const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
-
-  const graphData = useMemo(() => {
-    const nodes: Array<{ id: string; name: string }> = []
-    const edges: Array<{ source: string; target: string; sentiment: number }> = []
-
-    for (const [id, agent] of agents) {
-      nodes.push({ id, name: agent.name })
-    }
-
-    const edgeSet = new Set<string>()
-    for (const [sourceId, sourceAgent] of agents) {
-      const sentiments = sourceAgent.memory.sentiments
-      for (const [targetId] of sentiments) {
-        if (!agents.has(targetId)) continue
-        const edgeKey = [sourceId, targetId].sort().join("-")
-        if (edgeSet.has(edgeKey)) continue
-        edgeSet.add(edgeKey)
-
-        const s1 = sourceAgent.memory.sentiments.get(targetId) ?? 0
-        const s2 = agents.get(targetId)!.memory.sentiments.get(sourceId) ?? 0
-        const avgSentiment = (s1 + s2) / 2
-
-        edges.push({
-          source: sourceId < targetId ? sourceId : targetId,
-          target: sourceId < targetId ? targetId : sourceId,
-          sentiment: avgSentiment,
-        })
-      }
-    }
-
-    return { nodes, edges }
-  }, [agents])
-
-  useEffect(() => {
-    if (!containerRef.current) return
-
-    const cy = cytoscape({
-      container: containerRef.current,
-      elements: [],
-      style: [
-        {
-          selector: "node",
-          style: {
-            "background-color": "#3b82f6",
-            "label": "data(name)",
-            "color": "#1f2937",
-            "text-valign": "center",
-            "text-halign": "center",
-            "font-size": "12px",
-            "font-weight": 500,
-            "text-outline-width": 2,
-            "text-outline-color": "#ffffff",
-            "width": "50px",
-            "height": "50px",
-            "border-width": 2,
-            "border-color": "#ffffff",
-            "overlay-padding": "6px",
-            "z-index": 10,
-          },
-        },
-        {
-          selector: "edge",
-          style: {
-            "curve-style": "bezier",
-            "width": "data(width)",
-            "line-color": "data(color)",
-            "target-arrow-shape": "none",
-            "label": "data(label)",
-            "font-size": "10px",
-            "color": "#6b7280",
-            "text-rotation": "autorotate",
-            "text-margin-y": -8,
-            "text-background-color": "#ffffff",
-            "text-background-opacity": 1,
-            "text-background-padding": "2px",
-            "z-index": 5,
-          },
-        },
-      ],
-      layout: {
-        name: "cose",
-        animate: true,
-        animationDuration: 500,
-        fit: true,
-        padding: 30,
-        nodeRepulsion: 4000,
-        idealEdgeLength: 100,
-        edgeElasticity: 100,
-        nestingFactor: 5,
-        gravity: 80,
-        numIter: 2500,
-        initialTemp: 200,
-        coolingFactor: 0.95,
-        minTemp: 1.0,
-      },
-      wheelSensitivity: 0.3,
-    })
-
-    cyRef.current = cy
-
-    return () => {
-      if (debounceRef.current) {
-        clearTimeout(debounceRef.current)
-      }
-      cy.destroy()
-      cyRef.current = null
-    }
-  }, [])
-
-  useEffect(() => {
-    if (!cyRef.current) return
-
-    if (debounceRef.current) {
-      clearTimeout(debounceRef.current)
-    }
-
-    debounceRef.current = setTimeout(() => {
-      const cy = cyRef.current
-      if (!cy) return
-
-      cy.elements().remove()
-
-      const elements: cytoscape.ElementDefinition[] = []
-
-      for (const node of graphData.nodes) {
-        elements.push({
-          data: { id: node.id, name: node.name },
-        })
-      }
-
-      for (const edge of graphData.edges) {
-        const info = getSentimentInfo(edge.sentiment)
-        elements.push({
-          data: {
-            id: `${edge.source}-${edge.target}`,
-            source: edge.source,
-            target: edge.target,
-            color: info.color,
-            label: info.label,
-            width: info.width,
-          },
-        })
-      }
-
-      cy.add(elements)
-
-      if (elements.length > 0) {
-        cy.layout({
-          name: "cose",
-          animate: true,
-          animationDuration: 300,
-          fit: true,
-          padding: 30,
-          nodeRepulsion: 4000,
-          idealEdgeLength: 100,
-          edgeElasticity: 100,
-          nestingFactor: 5,
-          gravity: 80,
-          numIter: 2500,
-          initialTemp: 200,
-          coolingFactor: 0.95,
-          minTemp: 1.0,
-        }).run()
-      }
-    }, 200)
-  }, [graphData])
-
-  if (agents.size === 0) {
-    return (
-      <div className="flex h-full items-center justify-center rounded-lg border bg-muted/30 p-6 text-center text-xs text-muted-foreground">
-        暂无角色数据
-      </div>
-    )
-  }
-
-  return (
-    <div className="flex h-full min-h-0 flex-col">
-      <div className="mb-2 flex shrink-0 items-center justify-center gap-4 text-xs">
-        <div className="flex items-center gap-1.5">
-          <div className="h-3 w-8 rounded" style={{ backgroundColor: "#15803d" }} />
-          <span className="text-muted-foreground">亲密盟友</span>
-        </div>
-        <div className="flex items-center gap-1.5">
-          <div className="h-3 w-8 rounded" style={{ backgroundColor: "#86efac" }} />
-          <span className="text-muted-foreground">友好</span>
-        </div>
-        <div className="flex items-center gap-1.5">
-          <div className="h-3 w-8 rounded" style={{ backgroundColor: "#9ca3af" }} />
-          <span className="text-muted-foreground">中立</span>
-        </div>
-        <div className="flex items-center gap-1.5">
-          <div className="h-3 w-8 rounded" style={{ backgroundColor: "#fca5a5" }} />
-          <span className="text-muted-foreground">敌对</span>
-        </div>
-        <div className="flex items-center gap-1.5">
-          <div className="h-3 w-8 rounded" style={{ backgroundColor: "#dc2626" }} />
-          <span className="text-muted-foreground">死敌</span>
-        </div>
-      </div>
-
-      <div
-        ref={containerRef}
-        className="min-h-0 flex-1 rounded-md border bg-background/70"
-        style={{ minHeight: "300px" }}
-      />
-    </div>
-  )
-}

+ 174 - 102
src/components/novel/story-simulation/rumor-propagation-panel.tsx

@@ -1,5 +1,5 @@
 import { useState, useMemo } from "react"
-import { MessageCircle, Users, Eye, CheckCircle, XCircle, Clock, Filter } from "lucide-react"
+import { MessageCircle, Users, Eye, CheckCircle, GitBranch, Filter } from "lucide-react"
 import type { RumorEvent, NovelAgent, TimelineEvent } from "@/lib/novel/story-simulation/types"
 
 type RumorFilter = "all" | "unverified" | "verified" | "falsified"
@@ -10,6 +10,47 @@ interface RumorPropagationPanelProps {
   events: TimelineEvent[]
 }
 
+interface RumorTreeNode {
+  rumor: RumorEvent
+  children: RumorTreeNode[]
+  spreaderName?: string
+}
+
+function buildRumorFamilyTree(rumors: RumorEvent[], targetRumorId: string): RumorTreeNode | null {
+  const rumorMap = new Map<string, RumorEvent>()
+  for (const r of rumors) {
+    rumorMap.set(r.id, r)
+  }
+
+  const target = rumorMap.get(targetRumorId)
+  if (!target) return null
+
+  const ancestors: RumorEvent[] = []
+  let current: RumorEvent | undefined = target
+  while (current) {
+    ancestors.unshift(current)
+    current = current.parentId ? rumorMap.get(current.parentId) : undefined
+  }
+
+  const rootRumor = ancestors[0]
+
+  function buildNode(rumor: RumorEvent): RumorTreeNode {
+    const children = rumors
+      .filter((r) => r.parentId === rumor.id)
+      .map((r) => buildNode(r))
+    return {
+      rumor,
+      children,
+    }
+  }
+
+  return buildNode(rootRumor)
+}
+
+function getAgentName(agents: Map<string, NovelAgent>, agentId: string): string {
+  return agents.get(agentId)?.name ?? agentId
+}
+
 export function RumorPropagationPanel({ rumors, agents, events }: RumorPropagationPanelProps) {
   const [selectedRumorId, setSelectedRumorId] = useState<string | null>(null)
   const [filter, setFilter] = useState<RumorFilter>("all")
@@ -37,6 +78,11 @@ export function RumorPropagationPanel({ rumors, agents, events }: RumorPropagati
     return events.find((e) => e.id === selectedRumor.sourceId) ?? null
   }, [selectedRumor, events])
 
+  const familyTree = useMemo(() => {
+    if (!selectedRumorId) return null
+    return buildRumorFamilyTree(rumors, selectedRumorId)
+  }, [rumors, selectedRumorId])
+
   if (rumors.length === 0) {
     return (
       <div className="flex h-full items-center justify-center rounded-lg border bg-muted/30 p-6 text-center text-xs text-muted-foreground">
@@ -129,109 +175,23 @@ export function RumorPropagationPanel({ rumors, agents, events }: RumorPropagati
 
             <div className="space-y-3">
               <div className="flex items-center gap-2">
-                <Clock className="h-4 w-4 text-muted-foreground" />
-                <span className="text-sm font-medium">传播时间线</span>
+                <GitBranch className="h-4 w-4 text-muted-foreground" />
+                <span className="text-sm font-medium">传播家谱</span>
               </div>
 
-              <div className="relative ml-3 space-y-4 border-l-2 border-muted pl-4">
-                <div className="relative">
-                  <div className="absolute -left-[21px] top-0.5 h-3 w-3 rounded-full bg-primary" />
-                  <div className="text-xs font-medium">
-                    第 {selectedRumor.round + 1} 轮 · 传闻生成
-                  </div>
-                  {sourceEvent ? (
-                    <div className="mt-1.5 rounded-md border bg-muted/20 p-2 text-xs text-muted-foreground">
-                      <div className="mb-1 font-medium text-foreground">
-                        源事件:{sourceEvent.actorName} 的
-                        {actionTypeLabel(sourceEvent.actionType)}
-                      </div>
-                      <div className="line-clamp-3">{sourceEvent.content}</div>
-                    </div>
-                  ) : (
-                    <div className="mt-1.5 text-xs text-muted-foreground">
-                      (无源事件记录)
-                    </div>
-                  )}
-                </div>
-
-                <div className="relative">
-                  <div className="absolute -left-[21px] top-0.5 h-3 w-3 rounded-full bg-blue-500" />
-                  <div className="text-xs font-medium">角色可见</div>
-                  <div className="mt-1.5 flex flex-wrap gap-1">
-                    {selectedRumor.observableBy.length === 0 ? (
-                      <span className="text-xs text-muted-foreground">无</span>
-                    ) : (
-                      selectedRumor.observableBy.map((agentId) => {
-                        const agent = agents.get(agentId)
-                        return (
-                          <span
-                            key={agentId}
-                            className="rounded bg-blue-50 px-1.5 py-0.5 text-[11px] text-blue-700 dark:bg-blue-900/30 dark:text-blue-300"
-                          >
-                            {agent?.name ?? agentId}
-                          </span>
-                        )
-                      })
-                    )}
-                  </div>
-                </div>
-
-                <div className="relative">
-                  <div className="absolute -left-[21px] top-0.5 h-3 w-3 rounded-full bg-emerald-500" />
-                  <div className="text-xs font-medium">相信传闻</div>
-                  <div className="mt-1.5 flex flex-wrap gap-1">
-                    {selectedRumor.believedBy.length === 0 ? (
-                      <span className="text-xs text-muted-foreground">暂无角色相信</span>
-                    ) : (
-                      selectedRumor.believedBy.map((agentId) => {
-                        const agent = agents.get(agentId)
-                        return (
-                          <span
-                            key={agentId}
-                            className="rounded bg-emerald-50 px-1.5 py-0.5 text-[11px] text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300"
-                          >
-                            {agent?.name ?? agentId}
-                          </span>
-                        )
-                      })
-                    )}
-                  </div>
-                </div>
-
-                <div className="relative">
-                  <div className="absolute -left-[21px] top-0.5 h-3 w-3 rounded-full bg-purple-500" />
-                  <div className="text-xs font-medium">调查验证</div>
-                  <div className="mt-1.5">
-                    {selectedRumor.verifiedBy.length === 0 ? (
-                      <span className="text-xs text-muted-foreground">暂无角色验证</span>
-                    ) : (
-                      <div className="space-y-1">
-                        {selectedRumor.verifiedBy.map((agentId) => {
-                          const agent = agents.get(agentId)
-                          const isTrue = selectedRumor.distortion < 0.5
-                          return (
-                            <div
-                              key={agentId}
-                              className="flex items-center gap-2 rounded-md border bg-muted/20 px-2 py-1 text-xs"
-                            >
-                              {isTrue ? (
-                                <CheckCircle className="h-3.5 w-3.5 text-emerald-500" />
-                              ) : (
-                                <XCircle className="h-3.5 w-3.5 text-red-500" />
-                              )}
-                              <span className="font-medium">
-                                {agent?.name ?? agentId}
-                              </span>
-                              <span className="text-muted-foreground">
-                                {isTrue ? "证实为真" : "证实为假"}
-                              </span>
-                            </div>
-                          )
-                        })}
-                      </div>
-                    )}
-                  </div>
-                </div>
+              <div className="space-y-2">
+                {familyTree ? (
+                  <RumorTreeNodeView
+                    node={familyTree}
+                    agents={agents}
+                    selectedRumorId={selectedRumorId}
+                    depth={0}
+                    isRoot={true}
+                    sourceEvent={sourceEvent}
+                  />
+                ) : (
+                  <div className="text-xs text-muted-foreground">暂无传播链数据</div>
+                )}
               </div>
             </div>
 
@@ -277,6 +237,118 @@ export function RumorPropagationPanel({ rumors, agents, events }: RumorPropagati
   )
 }
 
+interface RumorTreeNodeViewProps {
+  node: RumorTreeNode
+  agents: Map<string, NovelAgent>
+  selectedRumorId: string | null
+  depth: number
+  isRoot: boolean
+  sourceEvent: TimelineEvent | null
+}
+
+function RumorTreeNodeView({
+  node,
+  agents,
+  selectedRumorId,
+  depth,
+  isRoot,
+  sourceEvent,
+}: RumorTreeNodeViewProps) {
+  const { rumor } = node
+  const isSelected = rumor.id === selectedRumorId
+  const spreaderName = rumor.spreadBy ? getAgentName(agents, rumor.spreadBy) : undefined
+
+  return (
+    <div className="relative">
+      <div className="flex gap-2">
+        {depth > 0 && (
+          <div className="relative w-5 shrink-0">
+            <div className="absolute left-2 top-0 h-full w-px bg-muted" />
+            <div className="absolute left-2 top-3 h-px w-3 bg-muted" />
+          </div>
+        )}
+        <div className="min-w-0 flex-1">
+          <div
+            className={`rounded-md border p-2.5 transition-colors ${
+              isSelected
+                ? "border-primary bg-primary/5"
+                : "bg-background/70 hover:bg-muted/20"
+            }`}
+          >
+            <div className="mb-1.5 flex items-center justify-between gap-2">
+              <div className="flex items-center gap-1.5">
+                {isRoot ? (
+                  <span className="rounded bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary">
+                    原始传闻
+                  </span>
+                ) : (
+                  <span className="text-[10px] text-muted-foreground">
+                    第 {rumor.generation} 代
+                  </span>
+                )}
+                <span
+                  className={`rounded px-1.5 py-0.5 text-[10px] font-medium ${
+                    rumor.distortion < 0.3
+                      ? "bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300"
+                      : rumor.distortion < 0.6
+                        ? "bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300"
+                        : "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300"
+                  }`}
+                >
+                  失真 {(rumor.distortion * 100).toFixed(0)}%
+                </span>
+              </div>
+              <span className="text-[10px] text-muted-foreground">
+                第 {rumor.round + 1} 轮
+              </span>
+            </div>
+            <div className="mb-1.5 line-clamp-2 text-xs">{rumor.content}</div>
+            <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[10px] text-muted-foreground">
+              {spreaderName && (
+                <span className="flex items-center gap-1">
+                  <Users className="h-3 w-3" />
+                  传播者:{spreaderName}
+                </span>
+              )}
+              <span className="flex items-center gap-1">
+                <Eye className="h-3 w-3" />
+                {rumor.observableBy.length} 人可见
+              </span>
+              <span className="flex items-center gap-1">
+                <CheckCircle className="h-3 w-3" />
+                {rumor.believedBy.length} 人相信
+              </span>
+            </div>
+            {isRoot && sourceEvent && (
+              <div className="mt-2 rounded-md border bg-muted/20 p-2 text-[10px] text-muted-foreground">
+                <div className="mb-0.5 font-medium text-foreground">
+                  源事件:{sourceEvent.actorName} 的{actionTypeLabel(sourceEvent.actionType)}
+                </div>
+                <div className="line-clamp-2">{sourceEvent.content}</div>
+              </div>
+            )}
+          </div>
+        </div>
+      </div>
+      {node.children.length > 0 && (
+        <div className="mt-2 space-y-2">
+          {node.children.map((child) => (
+            <RumorTreeNodeView
+              key={child.rumor.id}
+              node={child}
+              agents={agents}
+              selectedRumorId={selectedRumorId}
+              depth={depth + 1}
+              isRoot={false}
+              sourceEvent={null}
+            />
+          ))}
+        </div>
+      )}
+    </div>
+  )
+}
+
 function actionTypeLabel(type: string): string {
   switch (type) {
     case "evaluate":

+ 77 - 203
src/components/novel/story-simulation/simulation-report-view.tsx

@@ -1,6 +1,6 @@
 import { useMemo, useState } from "react"
 import { useTranslation } from "react-i18next"
-import { MessageCircle, RefreshCw, Sparkles, TrendingUp, Network, Download, ChevronDown, ChevronRight, GitCompare, X } from "lucide-react"
+import { MessageCircle, RefreshCw, Sparkles, TrendingUp, Download, ChevronDown, ChevronRight, GitCompare, X } from "lucide-react"
 import { Button } from "@/components/ui/button"
 import { useStorySimulationStore, type SavedSimulationResult } from "@/stores/story-simulation-store"
 import { useWikiStore } from "@/stores/wiki-store"
@@ -75,6 +75,44 @@ interface ReportContentProps {
 }
 
 function ReportContent({ report, timelineEvents, framework, onInterviewAgent, onGenerateDraft, title, compact, compareReport, compareTimelineEvents }: ReportContentProps) {
+  // 安全清洗 recommendation:防止模型返回 JSON 对象导致显示乱码
+  const safeRecommendation = useMemo(() => {
+    const raw = report.recommendation
+    if (!raw) return ""
+    const trimmed = raw.trim()
+    if (!trimmed) return ""
+    // 如果是 JSON 格式的字符串,尝试提取文本
+    if ((trimmed.startsWith("{") && trimmed.endsWith("}")) ||
+        (trimmed.startsWith("[") && trimmed.endsWith("]"))) {
+      try {
+        const parsed = JSON.parse(trimmed)
+        if (typeof parsed === "string") return parsed.trim()
+        if (typeof parsed === "object" && parsed !== null) {
+          const obj = parsed as Record<string, unknown>
+          const textFields = ["recommendation", "text", "content", "summary", "建议"]
+          for (const field of textFields) {
+            if (typeof obj[field] === "string" && (obj[field] as string).trim()) {
+              return (obj[field] as string).trim()
+            }
+          }
+          if (Array.isArray(parsed)) {
+            return parsed.map((item) => {
+              if (typeof item === "string") return item
+              if (typeof item === "object" && item !== null) {
+                const o = item as Record<string, unknown>
+                return String(o.text || o.content || o.recommendation || "")
+              }
+              return String(item)
+            }).filter(Boolean).join(";")
+          }
+          return "模型未提供规范格式的综合推荐建议"
+        }
+      } catch {
+        // 解析失败,继续用原文本
+      }
+    }
+    return trimmed
+  }, [report.recommendation])
   // 构建名字到ID的映射
   const nameToId = useMemo(() => {
     const map = new Map<string, string>()
@@ -84,47 +122,6 @@ function ReportContent({ report, timelineEvents, framework, onInterviewAgent, on
     return map
   }, [report.characterAnalyses])
 
-  // 构建角色关系网络数据
-  const relationshipData = useMemo(() => {
-    if (timelineEvents.length === 0) return null
-
-    const activityCount = new Map<string, number>()
-    const interactions = new Map<string, { count: number; sentiment: number; lastAction: string }>()
-
-    for (const ev of timelineEvents) {
-      activityCount.set(ev.actorName, (activityCount.get(ev.actorName) || 0) + 1)
-      if (ev.targetName) {
-        activityCount.set(ev.targetName, (activityCount.get(ev.targetName) || 0) + 1)
-        const pair = [ev.actorName, ev.targetName].sort().join("|")
-        const existing = interactions.get(pair) || { count: 0, sentiment: 0, lastAction: "" }
-        let sentimentDelta = 0
-        switch (ev.actionType) {
-          case "ally": sentimentDelta = 2; break
-          case "speak": sentimentDelta = 0.5; break
-          case "confront": sentimentDelta = -2; break
-          case "react": sentimentDelta = ev.content.includes("好感") || ev.content.includes("赞同") ? 1 : -1; break
-          default: sentimentDelta = 0
-        }
-        interactions.set(pair, {
-          count: existing.count + 1,
-          sentiment: Math.max(-5, Math.min(5, existing.sentiment + sentimentDelta)),
-          lastAction: ev.content.slice(0, 30),
-        })
-      }
-    }
-
-    const characters = Array.from(activityCount.entries())
-      .map(([name, count]) => ({ name, count }))
-      .sort((a, b) => b.count - a.count)
-
-    const edges = Array.from(interactions.entries()).map(([key, data]) => {
-      const [from, to] = key.split("|")
-      return { from, to, ...data }
-    })
-
-    return { characters, edges }
-  }, [timelineEvents])
-
   // 对比模式:计算角色分析差异
   const characterDiff = useMemo(() => {
     if (!compareReport) return null
@@ -177,11 +174,38 @@ function ReportContent({ report, timelineEvents, framework, onInterviewAgent, on
 
   // 对比模式:计算综合推荐差异(按句号分段)
   const recommendationDiff = useMemo(() => {
-    if (!compareReport || !report.recommendation) return null
-    if (!compareReport.recommendation) return { segments: [{ text: report.recommendation, isDifferent: true }] }
+    if (!compareReport || !safeRecommendation) return null
+    const compareRec = compareReport.recommendation?.trim() || ""
+
+    // 对比方也做同样清洗
+    const safeCompare = (() => {
+      if (!compareRec) return ""
+      if ((compareRec.startsWith("{") && compareRec.endsWith("}")) ||
+          (compareRec.startsWith("[") && compareRec.endsWith("]"))) {
+        try {
+          const parsed = JSON.parse(compareRec)
+          if (typeof parsed === "string") return parsed.trim()
+          if (typeof parsed === "object" && parsed !== null) {
+            const obj = parsed as Record<string, unknown>
+            const textFields = ["recommendation", "text", "content", "summary", "建议"]
+            for (const field of textFields) {
+              if (typeof obj[field] === "string" && (obj[field] as string).trim()) {
+                return (obj[field] as string).trim()
+              }
+            }
+            return ""
+          }
+        } catch {
+          // 解析失败,用原文本
+        }
+      }
+      return compareRec
+    })()
 
-    const aSegments = report.recommendation.split(/[。!?]/).filter((s) => s.trim())
-    const bSegments = new Set(compareReport.recommendation.split(/[。!?]/).filter((s) => s.trim()))
+    if (!safeCompare) return { segments: [{ text: safeRecommendation, isDifferent: true }] }
+
+    const aSegments = safeRecommendation.split(/[。!?]/).filter((s) => s.trim())
+    const bSegments = new Set(safeCompare.split(/[。!?]/).filter((s) => s.trim()))
 
     return {
       segments: aSegments.map((seg) => ({
@@ -189,7 +213,7 @@ function ReportContent({ report, timelineEvents, framework, onInterviewAgent, on
         isDifferent: !bSegments.has(seg),
       })),
     }
-  }, [report.recommendation, compareReport])
+  }, [safeRecommendation, compareReport])
 
   // 对比模式:计算时间线事件差异
   const timelineDiff = useMemo(() => {
@@ -213,19 +237,8 @@ function ReportContent({ report, timelineEvents, framework, onInterviewAgent, on
           {title}
         </div>
       )}
-      <div className="flex-1 overflow-y-auto p-4">
+      <div className="min-h-0 flex-1 overflow-y-auto p-4">
         <div className={`mx-auto ${compact ? "max-w-none" : "max-w-3xl"} space-y-6`}>
-          {/* 角色关系网络 */}
-          {relationshipData && relationshipData.characters.length > 1 && !compact && (
-            <section>
-              <h3 className="mb-3 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
-                <Network className="h-3.5 w-3.5" />
-                角色关系网络
-              </h3>
-              <RelationshipGraph data={relationshipData} />
-            </section>
-          )}
-
           {/* 关键剧情事件时间线 */}
           {timelineDiff && (
             <div className="flex items-center gap-4 rounded-lg border bg-muted/30 px-3 py-2 text-xs">
@@ -383,7 +396,7 @@ function ReportContent({ report, timelineEvents, framework, onInterviewAgent, on
           )}
 
           {/* 综合推荐 */}
-          {report.recommendation && (
+          {safeRecommendation && (
             <section>
               <div className="rounded-lg border border-primary/20 bg-primary/5 p-4">
                 <h3 className="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-primary">
@@ -405,7 +418,7 @@ function ReportContent({ report, timelineEvents, framework, onInterviewAgent, on
                     ))}
                   </div>
                 ) : (
-                  <p className="text-sm leading-relaxed">{report.recommendation}</p>
+                  <p className="text-sm leading-relaxed">{safeRecommendation}</p>
                 )}
               </div>
             </section>
@@ -431,6 +444,7 @@ export function SimulationReportView({
   const timelineEvents = useStorySimulationStore((s) => s.timelineEvents)
   const savedResults = useStorySimulationStore((s) => s.savedResults)
   const setError = useStorySimulationStore((s) => s.setError)
+  const setInfoMessage = useStorySimulationStore((s) => s.setInfoMessage)
   const [exporting, setExporting] = useState(false)
   const [compareMode, setCompareMode] = useState(false)
   const [selectedCompareId, setSelectedCompareId] = useState<string | null>(null)
@@ -456,8 +470,8 @@ export function SimulationReportView({
     setExporting(true)
     try {
       const filePath = await exportReport(projectPath, currentFramework, activeReport, activeTimeline)
-      setError(`报告已导出到:${filePath}`)
-      setTimeout(() => setError(null), 5000)
+      setInfoMessage(`报告已导出到:${filePath}`)
+      setTimeout(() => setInfoMessage(null), 5000)
     } catch (err) {
       setError(err instanceof Error ? err.message : "导出失败")
       setTimeout(() => setError(null), 5000)
@@ -613,146 +627,6 @@ export function SimulationReportView({
   )
 }
 
-// ── 角色关系图谱组件(SVG 实现,轻量无依赖) ──
-
-interface RelationNode {
-  name: string
-  count: number
-}
-
-interface RelationEdge {
-  from: string
-  to: string
-  count: number
-  sentiment: number
-  lastAction: string
-}
-
-interface RelationshipGraphData {
-  characters: RelationNode[]
-  edges: RelationEdge[]
-}
-
-function RelationshipGraph({ data }: { data: RelationshipGraphData }) {
-  const { characters, edges } = data
-  const width = 520
-  const height = 380
-  const cx = width / 2
-  const cy = height / 2
-  const radius = Math.min(cx, cy) - 50
-
-  // 圆形布局:按活跃度排序,主角居中
-  const positions = useMemo(() => {
-    const posMap = new Map<string, { x: number; y: number }>()
-    const maxNodes = Math.min(characters.length, 10) // 最多显示10个角色
-
-    if (characters.length === 0) return posMap
-
-    // 最活跃角色放中心
-    const main = characters[0]
-    posMap.set(main.name, { x: cx, y: cy })
-
-    // 其他角色围一圈
-    const others = characters.slice(1, maxNodes)
-    others.forEach((char, i) => {
-      const angle = (i / others.length) * Math.PI * 2 - Math.PI / 2
-      const x = cx + Math.cos(angle) * radius
-      const y = cy + Math.sin(angle) * radius
-      posMap.set(char.name, { x, y })
-    })
-
-    return posMap
-  }, [characters, cx, cy, radius])
-
-  const maxActivity = characters[0]?.count || 1
-
-  const nodeRadius = (count: number, isMain: boolean) => {
-    if (isMain) return 28
-    return 14 + (count / maxActivity) * 14
-  }
-
-  const edgeColor = (sentiment: number) => {
-    if (sentiment > 1) return "#22c55e" // 绿色-友好
-    if (sentiment < -1) return "#ef4444" // 红色-敌对
-    return "#94a3b8" // 灰色-中立
-  }
-
-  const edgeWidth = (count: number) => Math.max(1, Math.min(4, count / 2))
-
-  return (
-    <div className="rounded-lg border bg-muted/20 p-3">
-      <svg viewBox={`0 0 ${width} ${height}`} className="w-full" style={{ maxHeight: 380 }}>
-        {/* 绘制边 */}
-        {edges.map((edge, i) => {
-          const from = positions.get(edge.from)
-          const to = positions.get(edge.to)
-          if (!from || !to) return null
-          return (
-            <line
-              key={i}
-              x1={from.x}
-              y1={from.y}
-              x2={to.x}
-              y2={to.y}
-              stroke={edgeColor(edge.sentiment)}
-              strokeWidth={edgeWidth(edge.count)}
-              strokeOpacity={0.6}
-            >
-              <title>{`${edge.from} ↔ ${edge.to}\n互动${edge.count}次\n情感倾向:${edge.sentiment > 1 ? "友好" : edge.sentiment < -1 ? "敌对" : "中立"}`}</title>
-            </line>
-          )
-        })}
-
-        {/* 绘制节点 */}
-        {characters.slice(0, 10).map((char, i) => {
-          const pos = positions.get(char.name)
-          if (!pos) return null
-          const isMain = i === 0
-          const r = nodeRadius(char.count, isMain)
-          return (
-            <g key={char.name}>
-              <circle
-                cx={pos.x}
-                cy={pos.y}
-                r={r}
-                fill={isMain ? "hsl(var(--primary))" : "hsl(var(--muted))"}
-                stroke={isMain ? "hsl(var(--primary))" : "hsl(var(--border))"}
-                strokeWidth={2}
-              >
-                <title>{`${char.name}\n参与事件:${char.count}次${isMain ? "\n(核心角色)" : ""}`}</title>
-              </circle>
-              <text
-                x={pos.x}
-                y={pos.y + r + 14}
-                textAnchor="middle"
-                fontSize={11}
-                fill="currentColor"
-                className="fill-muted-foreground"
-              >
-                {char.name.length > 4 ? char.name.slice(0, 4) : char.name}
-              </text>
-            </g>
-          )
-        })}
-      </svg>
-
-      {/* 图例 */}
-      <div className="mt-2 flex flex-wrap items-center justify-center gap-4 text-[11px] text-muted-foreground">
-        <span className="flex items-center gap-1">
-          <span className="inline-block h-0.5 w-4 bg-[#22c55e]" /> 友好
-        </span>
-        <span className="flex items-center gap-1">
-          <span className="inline-block h-0.5 w-4 bg-[#94a3b8]" /> 中立
-        </span>
-        <span className="flex items-center gap-1">
-          <span className="inline-block h-0.5 w-4 bg-[#ef4444]" /> 敌对
-        </span>
-        <span>· 节点大小=活跃度 · 线粗细=互动次数</span>
-      </div>
-    </div>
-  )
-}
-
 // ── 按节点分组折叠的时间线组件 ──
 
 function TimelineGroupedEvents({

+ 5 - 4
src/components/novel/story-simulation/story-draft-view.tsx

@@ -1,4 +1,4 @@
-import { useState, useEffect } from "react"
+import { useState, useEffect } from "react"
 import { useTranslation } from "react-i18next"
 import { ArrowLeft, Check, Copy, Download, FileText, BookOpen, Pencil, Save } from "lucide-react"
 import { Button } from "@/components/ui/button"
@@ -31,6 +31,7 @@ export function StoryDraftView({ onBack }: StoryDraftViewProps) {
   const draft = useStorySimulationStore((s) => s.currentDraft)
   const setCurrentDraft = useStorySimulationStore((s) => s.setCurrentDraft)
   const setError = useStorySimulationStore((s) => s.setError)
+  const setInfoMessage = useStorySimulationStore((s) => s.setInfoMessage)
   const setActiveView = useWikiStore((s) => s.setActiveView)
   const setSelectedFile = useWikiStore((s) => s.setSelectedFile)
   const [copied, setCopied] = useState(false)
@@ -90,8 +91,8 @@ export function StoryDraftView({ onBack }: StoryDraftViewProps) {
     setExporting(true)
     try {
       const filePath = await exportDraft(projectPath, currentFramework, draft)
-      setError(`草稿已导出到:${filePath}`)
-      setTimeout(() => setError(null), 5000)
+      setInfoMessage(`草稿已导出到:${filePath}`)
+      setTimeout(() => setInfoMessage(null), 5000)
     } catch (err) {
       setError(err instanceof Error ? err.message : "导出失败")
       setTimeout(() => setError(null), 5000)
@@ -241,7 +242,7 @@ export function StoryDraftView({ onBack }: StoryDraftViewProps) {
         </div>
       </div>
 
-      <div className="flex-1 overflow-y-auto p-4">
+      <div className="min-h-0 flex-1 overflow-y-auto p-4">
         <div className="mx-auto max-w-3xl space-y-4">
           <div className="text-xs text-muted-foreground">
             {t("storySimulation.totalWords")}: {draft.totalWords}

ファイルの差分が大きいため隠しています
+ 505 - 363
src/components/novel/story-simulation/story-simulation-view.tsx


+ 2 - 2
src/components/novel/timeline-view.tsx

@@ -1,4 +1,4 @@
-import { useEffect, useState } from "react"
+import { useEffect, useState } from "react"
 import { useTranslation } from "react-i18next"
 import { Clock, Loader2 } from "lucide-react"
 import { useWikiStore } from "@/stores/wiki-store"
@@ -35,7 +35,7 @@ export function TimelineView() {
           <h2 className="text-sm font-semibold">{t("novel.timeline.title")}</h2>
         </div>
       </div>
-      <div className="flex-1 overflow-y-auto">
+      <div className="min-h-0 flex-1 overflow-y-auto">
         {loading ? (
           <div className="flex items-center justify-center gap-2 py-8 text-sm text-muted-foreground">
             <Loader2 className="h-4 w-4 animate-spin" />

+ 2 - 2
src/components/reference/ReferencePickerDialog.tsx

@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useState } from "react"
+import { useEffect, useMemo, useState } from "react"
 import { Search, X } from "lucide-react"
 import type { ReferenceCategory, ReferenceToken } from "@/lib/reference/types"
 import { MAX_REFERENCE_COUNT, REFERENCE_TABS } from "@/lib/reference/types"
@@ -147,7 +147,7 @@ export function ReferencePickerDialog({
               </label>
             </div>
 
-            <div className="min-h-[320px] flex-1 overflow-y-auto p-2">
+            <div className="min-h-[320px] min-h-0 flex-1 overflow-y-auto p-2">
               {loading ? (
                 <div className="flex h-full items-center justify-center text-sm text-muted-foreground">加载中...</div>
               ) : filteredItems.length === 0 ? (

+ 2 - 2
src/components/review/review-view.tsx

@@ -1,4 +1,4 @@
-import { useState, useCallback, useEffect, useMemo } from "react"
+import { useState, useCallback, useEffect, useMemo } from "react"
 import { useTranslation } from "react-i18next"
 import i18n from "@/i18n"
 import type { NovelReviewResult } from "@/lib/novel/review-adapter"
@@ -823,7 +823,7 @@ export function ReviewView({
         )}
       </div>
 
-      <div className="flex-1 overflow-y-auto">
+      <div className="min-h-0 flex-1 overflow-y-auto">
         {reviewError && (
           <div className="m-3 flex items-center gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
             <AlertTriangle className="h-4 w-4 shrink-0" />

+ 4 - 4
src/components/settings/settings-view.tsx

@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useMemo, useState } from "react"
+import { useCallback, useEffect, useMemo, useState } from "react"
 import {
   Bot,
   BookOpen,
@@ -562,7 +562,7 @@ export function SettingsView() {
             className="cursor-pointer text-muted-foreground transition-colors hover:text-primary"
           />
         </div>
-        <nav className="flex-1 overflow-y-auto px-2 pb-3">
+        <nav className="min-h-0 flex-1 overflow-y-auto px-2 pb-3">
           {CATEGORIES.map((c) => {
             const Icon = c.icon
             const isActive = c.id === active
@@ -599,8 +599,8 @@ export function SettingsView() {
       </aside>
 
       {/* Content */}
-      <div className="flex flex-1 flex-col overflow-hidden">
-        <div className="flex-1 overflow-y-auto px-8 py-6">
+      <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
+        <div className="min-h-0 flex-1 overflow-y-auto px-8 py-6">
           <div className="mx-auto max-w-2xl">{body}</div>
         </div>
 

+ 2 - 2
src/components/sources/sources-view.tsx

@@ -1,4 +1,4 @@
-import { Suspense, lazy, useCallback, useRef, useState } from "react"
+import { Suspense, lazy, useCallback, useRef, useState } from "react"
 import { useTranslation } from "react-i18next"
 import { useWikiStore } from "@/stores/wiki-store"
 import { OutlineActionToolbar } from "@/components/sources/outline-action-toolbar"
@@ -96,7 +96,7 @@ export function SourcesView() {
       <div className="min-h-0 flex-1 overflow-hidden">
         {outlineChatOpen && novelMode && chatDockPosition === "right" ? (
           <div className="flex h-full min-h-0 overflow-hidden">
-            <div className="min-w-0 flex-1 overflow-hidden">
+            <div className="min-w-0 min-h-0 flex-1 overflow-hidden">
               <PreviewPanel />
             </div>
             <div

+ 42 - 0
src/index.css

@@ -616,3 +616,45 @@
 .model-selector-dropdown::-webkit-scrollbar-corner {
   background: transparent;
 }
+
+/* 全局自定义滚动条 */
+* {
+  scrollbar-width: thin;
+  scrollbar-color: hsl(var(--border)) transparent;
+}
+
+*::-webkit-scrollbar {
+  width: 6px;
+  height: 6px;
+}
+
+*::-webkit-scrollbar-track {
+  background: transparent;
+}
+
+*::-webkit-scrollbar-thumb {
+  background-color: hsl(var(--border));
+  border-radius: 3px;
+  transition: background-color 0.15s ease;
+}
+
+*::-webkit-scrollbar-thumb:hover {
+  background-color: hsl(var(--muted-foreground) / 0.5);
+}
+
+*::-webkit-scrollbar-corner {
+  background: transparent;
+}
+
+/* 暗色模式滚动条颜色微调 */
+.dark * {
+  scrollbar-color: hsl(var(--muted-foreground) / 0.3) transparent;
+}
+
+.dark *::-webkit-scrollbar-thumb {
+  background-color: hsl(var(--muted-foreground) / 0.3);
+}
+
+.dark *::-webkit-scrollbar-thumb:hover {
+  background-color: hsl(var(--muted-foreground) / 0.5);
+}

+ 6 - 0
src/lib/changelog.ts

@@ -91,6 +91,9 @@ const TWO_POINT_TWO_THIRTY_TWO_CHANGELOG: ChangelogEntry = {
       "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.",
+      "Editor now polls disk every 2 seconds for external changes — if you edit a file outside QMai, the editor syncs automatically.",
+      "Fixed chapter save creating duplicate or wrong files when switching chapters; stale autosaves are cancelled and drafts are cleaned up after rename.",
+      "Fixed hydration race when switching projects quickly — paths are now compared with a normalized form, and hydration runs serially without corrupting the file tree.",
     ],
     zh: [
       "聊天用的模型和后台任务用的模型分开了:聊天框里选的只管对话和写正文,审稿、摘要、去 AI 味等默认走「默认模型」;需要的话,也可以给单个环节单独指定模型。",
@@ -101,6 +104,9 @@ const TWO_POINT_TWO_THIRTY_TWO_CHANGELOG: ChangelogEntry = {
       "重复实体扫描和合并更快了,进度会告诉你当前在干什么;最可能重复的会排在前面。",
       "macOS 和 Linux 版暂不支持自动更新,设置里会提示去官网手动下载;安装说明也补充了 macOS 常见问题。",
       "部分设置页不再需要点保存,改动即时生效。",
+      "编辑器新增 2 秒轮询检测磁盘外部修改——如果外部程序修改了文件,编辑器会自动同步显示最新内容。",
+      "修复切换章节时保存产生重复文件或误删文件的问题:取消过期自动保存,重命名后清理草稿。",
+      "修复快速切换项目时界面卡死或文件树错乱的问题:路径使用 normalizePath 比较,hydration 串行执行,避免竞态条件。",
     ],
   },
 }

+ 1 - 3
src/lib/novel/deep-chapter-generation.ts

@@ -1,4 +1,4 @@
-import type { LlmConfig } from "@/stores/wiki-store"
+import type { LlmConfig } from "@/stores/wiki-store"
 import { streamChat, type ChatMessage, type RequestOverrides, type StreamCallbacks } from "@/lib/llm-client"
 import { useWikiStore } from "@/stores/wiki-store"
 import type { AiWorkflowMode } from "@/lib/agent/workflow-mode"
@@ -322,9 +322,7 @@ 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,

+ 156 - 0
src/lib/novel/story-simulation/action-type-utils.tsx

@@ -0,0 +1,156 @@
+import type { AgentActionType } from "./types"
+import {
+  Brain,
+  Forward,
+  Eye,
+  Zap,
+  MessageCircle,
+  Handshake,
+  Swords,
+  EyeOff,
+  Search,
+  CheckCircle,
+  Flame,
+  Users,
+  Lock,
+  type LucideIcon,
+} from "lucide-react"
+
+/** 行为类型 → 中文短标签(2-4字),用于列表/卡片展示 */
+export function actionTypeShortLabel(type: string): string {
+  return LABEL_MAP[type] || type
+}
+
+/** 行为类型 → 中文动词短语(含目标名),用于事件流展示 */
+export function actionTypePhrase(type: string, targetName?: string): string {
+  switch (type) {
+    case "evaluate":
+      return "心中评价"
+    case "pushPlot":
+      return "推动事态"
+    case "observe":
+      return "观察到"
+    case "react":
+      return targetName ? `对 ${targetName} 的反应` : "做出反应"
+    case "speak":
+      return targetName ? `对 ${targetName} 说` : "说"
+    case "ally":
+      return targetName ? `向 ${targetName} 示好` : "寻求合作"
+    case "confront":
+      return targetName ? `与 ${targetName} 对抗` : "采取对抗姿态"
+    case "conceal":
+      return "隐瞒内心"
+    case "investigate":
+      return "调查"
+    default:
+      return "行动"
+  }
+}
+
+/** 行为类型 → 纯动词(不含目标名),用于可点击目标名场景 */
+export function actionTypePhraseOnly(type: string): string {
+  switch (type) {
+    case "evaluate":
+      return "评价"
+    case "pushPlot":
+      return "推动"
+    case "observe":
+      return "观察到"
+    case "react":
+      return "对"
+    case "speak":
+      return "对"
+    case "ally":
+      return "向"
+    case "confront":
+      return "与"
+    case "conceal":
+      return "隐瞒"
+    case "investigate":
+      return "调查"
+    default:
+      return "对"
+  }
+}
+
+/** 行为类型 → Lucide 图标组件 */
+export function actionTypeIcon(type: string): LucideIcon {
+  return ICON_MAP[type] || Zap
+}
+
+/** 行为类型 → 图标名称(用于 className 选择) */
+export function actionTypeIconName(type: string): string {
+  return ICON_NAME_MAP[type] || "zap"
+}
+
+// ── 中文标签映射 ──
+const LABEL_MAP: Record<string, string> = {
+  evaluate: "评价",
+  pushPlot: "推动",
+  observe: "观察",
+  react: "反应",
+  speak: "对话",
+  ally: "示好",
+  confront: "对抗",
+  conceal: "隐瞒",
+  investigate: "调查",
+  act: "行动",
+  decide: "决策",
+  conflict: "冲突",
+  cooperate: "合作",
+  withhold: "隐瞒",
+}
+
+// ── 图标组件映射 ──
+const ICON_MAP: Record<string, LucideIcon> = {
+  evaluate: Brain,
+  pushPlot: Forward,
+  observe: Eye,
+  react: Zap,
+  speak: MessageCircle,
+  ally: Handshake,
+  confront: Swords,
+  conceal: EyeOff,
+  investigate: Search,
+  act: Zap,
+  decide: CheckCircle,
+  conflict: Flame,
+  cooperate: Users,
+  withhold: Lock,
+}
+
+// ── 图标名称映射 ──
+const ICON_NAME_MAP: Record<string, string> = {
+  evaluate: "brain",
+  pushPlot: "forward",
+  observe: "eye",
+  react: "zap",
+  speak: "message-circle",
+  ally: "handshake",
+  confront: "swords",
+  conceal: "eye-off",
+  investigate: "search",
+  act: "zap",
+  decide: "check-circle",
+  conflict: "flame",
+  cooperate: "users",
+  withhold: "lock",
+}
+
+// ── 行为类型列表 ──
+export const ALL_ACTION_TYPES = Object.keys(LABEL_MAP) as AgentActionType[]
+
+/** 在 UI 中展示行为类型的配置项(label + icon) */
+export interface ActionTypeOption {
+  value: AgentActionType
+  label: string
+  Icon: LucideIcon
+}
+
+export function getAllActionTypeOptions(): ActionTypeOption[] {
+  return ALL_ACTION_TYPES.map((type) => ({
+    value: type,
+    label: LABEL_MAP[type],
+    Icon: ICON_MAP[type] || Zap,
+  }))
+}

+ 1 - 0
src/lib/novel/story-simulation/agent-profile-builder.ts

@@ -107,6 +107,7 @@ function initMemory(
     knownSecrets: new Set<string>(),
     sentiments,
     recentDecisions: [],
+    rumorCredibility: 0.5,
   }
 }
 

+ 14 - 0
src/lib/novel/story-simulation/framework-store.ts

@@ -23,6 +23,8 @@ import type {
   StoryFramework,
   StoryNode,
   TimelineEvent,
+  RumorEvent,
+  SimulationDebugTrace,
 } from "./types"
 import type { SerializedSimulationSnapshot } from "./simulation-serializer"
 
@@ -404,6 +406,8 @@ export async function saveSimulationResult(
   draft?: StoryDraft,
   timelineEvents?: TimelineEvent[],
   agentSnapshot?: SerializedSimulationSnapshot,
+  rumors?: RumorEvent[],
+  debugTraces?: SimulationDebugTrace[],
 ): Promise<string> {
   await ensureSimulationDirs(projectPath)
   const resultId = `result-${Date.now()}`
@@ -415,6 +419,8 @@ export async function saveSimulationResult(
     draft: draft ?? null,
     timelineEvents: timelineEvents ?? [],
     agentSnapshot: agentSnapshot ?? null,
+    rumors: rumors ?? [],
+    debugTraces: debugTraces ?? [],
   }
   await writeFileAtomic(`${dir}/${resultId}.json`, JSON.stringify(payload, null, 2))
   await writeFileAtomic(
@@ -453,6 +459,8 @@ export async function loadSimulationResults(
   draft?: StoryDraft | null
   timelineEvents?: TimelineEvent[]
   agentSnapshot?: SerializedSimulationSnapshot | null
+  rumors?: RumorEvent[]
+  debugTraces?: SimulationDebugTrace[]
 }[]> {
   const dir = frameworkResultsDir(projectPath, frameworkId)
   let entries: FileNode[]
@@ -468,6 +476,8 @@ export async function loadSimulationResults(
     draft?: StoryDraft | null
     timelineEvents?: TimelineEvent[]
     agentSnapshot?: SerializedSimulationSnapshot | null
+    rumors?: RumorEvent[]
+    debugTraces?: SimulationDebugTrace[]
   }[] = []
   for (const entry of entries) {
     if (entry.is_dir) continue
@@ -479,6 +489,8 @@ export async function loadSimulationResults(
         draft?: StoryDraft | null
         timelineEvents?: TimelineEvent[]
         agentSnapshot?: SerializedSimulationSnapshot | null
+        rumors?: RumorEvent[]
+        debugTraces?: SimulationDebugTrace[]
       }
       if (parsed && parsed.report) {
         results.push({
@@ -487,6 +499,8 @@ export async function loadSimulationResults(
           draft: parsed.draft ?? null,
           timelineEvents: parsed.timelineEvents ?? [],
           agentSnapshot: parsed.agentSnapshot ?? null,
+          rumors: parsed.rumors ?? [],
+          debugTraces: parsed.debugTraces ?? [],
         })
       }
     } catch {

+ 2 - 0
src/lib/novel/story-simulation/investigate-feedback.spec.ts

@@ -28,6 +28,7 @@ function makeAgent(id: string, name: string): NovelAgent {
       knownSecrets: new Set(),
       sentiments: new Map(),
       recentDecisions: [],
+      rumorCredibility: 0.5,
     },
     knowledgeScope: [],
     personality: [],
@@ -52,6 +53,7 @@ function makeRumorEvent(
     believedBy: [],
     verifiedBy: [],
     timestamp: "2026-07-04T00:00:00.000Z",
+    generation: 0,
   }
 }
 

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

@@ -36,6 +36,7 @@ function makeAgent(id: string, name: string): NovelAgent {
       knownSecrets: new Set(),
       sentiments: new Map(),
       recentDecisions: [],
+      rumorCredibility: 0.5,
     },
     knowledgeScope: [],
     personality: [],

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

@@ -407,3 +407,62 @@ export function verifyRumor(
     return `经过调查,部分属实:${strippedContent}`
   }
 }
+
+export function spreadRumor(
+  blackboard: SimulationBlackboard,
+  sourceRumorId: string,
+  spreaderId: string,
+  targetId: string,
+  message: string,
+): { newRumor: RumorEvent | null; targetBelieved: boolean } {
+  const sourceRumor = blackboard.rumors.find((r) => r.id === sourceRumorId)
+  if (!sourceRumor) {
+    return { newRumor: null, targetBelieved: false }
+  }
+
+  if (!blackboard.allAgents.has(spreaderId)) {
+    return { newRumor: null, targetBelieved: false }
+  }
+  if (!blackboard.allAgents.has(targetId)) {
+    return { newRumor: null, targetBelieved: false }
+  }
+
+  const visibleRumors = blackboard.visibleRumorsByAgent.get(spreaderId) ?? []
+  if (!visibleRumors.some((r) => r.id === sourceRumorId)) {
+    return { newRumor: null, targetBelieved: false }
+  }
+
+  const newRumor: RumorEvent = {
+    id: `rumor-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
+    content: message && message.length > 0 ? message : sourceRumor.content,
+    distortion: Math.min(1, sourceRumor.distortion + 0.1 + Math.random() * 0.2),
+    generation: sourceRumor.generation + 1,
+    parentId: sourceRumorId,
+    spreadBy: spreaderId,
+    spreadRound: sourceRumor.round + 1,
+    round: sourceRumor.round + 1,
+    nodeIndex: sourceRumor.nodeIndex,
+    sourceId: sourceRumor.sourceId,
+    observableBy: [targetId],
+    believedBy: [],
+    verifiedBy: [],
+    timestamp: new Date().toISOString(),
+  }
+
+  recordRumorEvent(blackboard, newRumor)
+
+  const baseBelief = (1 - newRumor.distortion) * 0.6
+  const spreader = blackboard.allAgents.get(spreaderId)!
+  const target = blackboard.allAgents.get(targetId)!
+  const credibilityBonus = (spreader.memory.rumorCredibility - 0.5) * 0.4
+  const sentimentVal = target.memory.sentiments.get(spreaderId) ?? 0
+  const sentimentBonus = sentimentVal / 200
+  const finalProb = Math.max(0.1, Math.min(0.9, baseBelief + credibilityBonus + sentimentBonus))
+  const believed = Math.random() < finalProb
+
+  if (believed) {
+    newRumor.believedBy.push(targetId)
+  }
+
+  return { newRumor, targetBelieved: believed }
+}

+ 2 - 0
src/lib/novel/story-simulation/rumor-visibility.spec.ts

@@ -25,6 +25,7 @@ function makeAgent(id: string, name: string): NovelAgent {
       knownSecrets: new Set(),
       sentiments: new Map(),
       recentDecisions: [],
+      rumorCredibility: 0.5,
     },
     knowledgeScope: [],
     personality: [],
@@ -44,6 +45,7 @@ function makeRumorEvent(id: string, observableBy: string[]): RumorEvent {
     believedBy: [],
     verifiedBy: [],
     timestamp: "2026-07-04T00:00:00.000Z",
+    generation: 0,
   }
 }
 

+ 4 - 2
src/lib/novel/story-simulation/sim-agent-tools.spec.ts

@@ -25,6 +25,7 @@ function makeAgent(id: string, name: string): NovelAgent {
       knownSecrets: new Set(),
       sentiments: new Map(),
       recentDecisions: [],
+      rumorCredibility: 0.5,
     },
     knowledgeScope: [],
     personality: [],
@@ -66,7 +67,7 @@ function setupTwoAgents(): {
 }
 
 describe("createSimAgentTools", () => {
-  it("creates a registry with 5 tools", () => {
+  it("creates a registry with 6 tools", () => {
     const { agentA, blackboard } = setupTwoAgents()
     const registry = createSimAgentTools(agentA, blackboard)
     expect(registry.has("recall")).toBe(true)
@@ -74,7 +75,8 @@ describe("createSimAgentTools", () => {
     expect(registry.has("inquire")).toBe(true)
     expect(registry.has("introspect")).toBe(true)
     expect(registry.has("investigate")).toBe(true)
-    expect(registry.list().length).toBe(5)
+    expect(registry.has("spread_rumor")).toBe(true)
+    expect(registry.list().length).toBe(6)
   })
 })
 

+ 44 - 0
src/lib/novel/story-simulation/sim-agent-tools.ts

@@ -5,6 +5,7 @@ import {
   findMatchingRumor,
   getBlackboardVisibleEvents,
   recordBlackboardEvent,
+  spreadRumor,
   type SimulationBlackboard,
   verifyRumor,
 } from "@/lib/novel/story-simulation/multi-agent-orchestrator"
@@ -206,11 +207,54 @@ export function createSimAgentTools(
     },
   }
 
+  const spreadRumorTool: Tool = {
+    name: "spread_rumor",
+    description: "把一条你知道的传闻告诉另一个角色。可以加上你自己的描述和评价。注意选择你信任的角色来传播。",
+    category: "write",
+    permission: "auto",
+    parameters: {
+      rumorId: {
+        type: "string",
+        description: "要传播的传闻 ID",
+        required: true,
+      },
+      targetAgentId: {
+        type: "string",
+        description: "告诉谁(角色 ID)",
+        required: true,
+      },
+      message: {
+        type: "string",
+        description: "你说的内容(对传闻的描述、补充或评价)",
+        required: true,
+      },
+    },
+    execute: async (params) => {
+      const rumorId = String(params.rumorId ?? "")
+      const targetAgentId = String(params.targetAgentId ?? "")
+      const message = String(params.message ?? "")
+      if (!rumorId || !targetAgentId) {
+        return "错误:必须指定 rumorId 和 targetAgentId 参数"
+      }
+      const result = spreadRumor(blackboard, rumorId, agent.characterId, targetAgentId, message)
+      if (!result.newRumor) {
+        return "传播失败,找不到传闻或目标角色"
+      }
+      const newDistortion = Math.round(result.newRumor.distortion * 100) / 100
+      if (result.targetBelieved) {
+        return `传播成功!新传闻 ID:${result.newRumor.id},目标角色相信了这条传闻。新失真度:${newDistortion}`
+      } else {
+        return `传播成功!新传闻 ID:${result.newRumor.id},目标角色对这条传闻持怀疑态度,没有完全相信。新失真度:${newDistortion}`
+      }
+    },
+  }
+
   registry.register(recallTool)
   registry.register(observeTool)
   registry.register(inquireTool)
   registry.register(introspectTool)
   registry.register(investigateTool)
+  registry.register(spreadRumorTool)
 
   return registry
 }

+ 2 - 1
src/lib/novel/story-simulation/simulation-engine.react.spec.ts

@@ -40,6 +40,7 @@ function makeAgent(id: string, name: string): NovelAgent {
       knownSecrets: new Set(),
       sentiments: new Map(),
       recentDecisions: [],
+      rumorCredibility: 0.5,
     },
     knowledgeScope: [],
     personality: [],
@@ -136,7 +137,7 @@ describe("agentDecideAndActWithReact", () => {
     expect(mockRun).toHaveBeenCalledTimes(1)
     const callArgs = mockRun.mock.calls[0]
     expect(callArgs[0].maxRounds).toBe(3)
-    expect(callArgs[0].tools.length).toBe(5)
+    expect(callArgs[0].tools.length).toBe(6)
   })
 
   it("throws ModelDoesNotSupportToolsError when model does not support tools", async () => {

+ 2 - 0
src/lib/novel/story-simulation/simulation-engine.ts

@@ -105,6 +105,7 @@ function cloneAgent(agent: NovelAgent): NovelAgent {
       knownSecrets: new Set(agent.memory.knownSecrets),
       sentiments: new Map(agent.memory.sentiments),
       recentDecisions: [...agent.memory.recentDecisions],
+      rumorCredibility: agent.memory.rumorCredibility,
     },
     knowledgeScope: [...agent.knowledgeScope],
     personality: [...agent.personality],
@@ -865,6 +866,7 @@ function maybeDeriveRumor(
     believedBy: [],
     verifiedBy: [],
     timestamp: new Date().toISOString(),
+    generation: 0,
   }
 
   recordRumorEvent(blackboard, rumor)

+ 63 - 2
src/lib/novel/story-simulation/simulation-report-agent.ts

@@ -306,6 +306,68 @@ function parseBranches(raw: unknown): StoryBranch[] {
   })
 }
 
+function parseRecommendation(raw: unknown): string {
+  if (typeof raw === "string") {
+    const trimmed = raw.trim()
+    if (!trimmed) return "模型未提供综合推荐建议"
+    // 如果是 JSON 字符串,尝试解析提取文本
+    if ((trimmed.startsWith("{") && trimmed.endsWith("}")) ||
+        (trimmed.startsWith("[") && trimmed.endsWith("]"))) {
+      try {
+        const parsed = JSON.parse(trimmed)
+        if (typeof parsed === "string") return parsed.trim() || "模型未提供综合推荐建议"
+        if (typeof parsed === "object" && parsed !== null) {
+          // 尝试提取常见的文本字段
+          const obj = parsed as Record<string, unknown>
+          if (typeof obj.text === "string") return obj.text.trim() || "模型未提供综合推荐建议"
+          if (typeof obj.content === "string") return obj.content.trim() || "模型未提供综合推荐建议"
+          if (typeof obj.recommendation === "string") return obj.recommendation.trim() || "模型未提供综合推荐建议"
+          if (typeof obj.summary === "string") return obj.summary.trim() || "模型未提供综合推荐建议"
+          // 是数组则拼接内容
+          if (Array.isArray(parsed)) {
+            return parsed.map((item) => {
+              if (typeof item === "string") return item
+              if (typeof item === "object" && item !== null) {
+                const o = item as Record<string, unknown>
+                return String(o.text || o.content || o.recommendation || JSON.stringify(item))
+              }
+              return String(item)
+            }).filter(Boolean).join(";") || "模型未提供综合推荐建议"
+          }
+          // 退而求其次:把对象值拼接
+          return Object.values(parsed).map((v) =>
+            typeof v === "string" ? v : Array.isArray(v) ? v.join("、") : "",
+          ).filter(Boolean).join(";") || "模型未提供综合推荐建议"
+        }
+      } catch {
+        // 解析失败,返回原字符串
+      }
+    }
+    return trimmed
+  }
+  if (Array.isArray(raw)) {
+    return raw.map((item) => {
+      if (typeof item === "string") return item
+      if (typeof item === "object" && item !== null) {
+        const o = item as Record<string, unknown>
+        return String(o.text || o.content || o.recommendation || JSON.stringify(item))
+      }
+      return String(item)
+    }).filter(Boolean).join(";") || "模型未提供综合推荐建议"
+  }
+  if (typeof raw === "object" && raw !== null) {
+    const obj = raw as Record<string, unknown>
+    if (typeof obj.text === "string") return obj.text.trim() || "模型未提供综合推荐建议"
+    if (typeof obj.content === "string") return obj.content.trim() || "模型未提供综合推荐建议"
+    if (typeof obj.recommendation === "string") return obj.recommendation.trim() || "模型未提供综合推荐建议"
+    if (typeof obj.summary === "string") return obj.summary.trim() || "模型未提供综合推荐建议"
+    return Object.values(obj).map((v) =>
+      typeof v === "string" ? v : Array.isArray(v) ? v.join("、") : "",
+    ).filter(Boolean).join(";") || "模型未提供综合推荐建议"
+  }
+  return "模型未提供综合推荐建议"
+}
+
 // ── 主入口:生成推演报告 ──
 
 export async function generateSimulationReport(
@@ -342,8 +404,7 @@ export async function generateSimulationReport(
     const data = JSON.parse(jsonText) as Record<string, unknown>
     const characterAnalyses = parseCharacterAnalyses(data.characterAnalyses)
     const branches = parseBranches(data.branches)
-    const recommendation =
-      String(data.recommendation ?? "").trim() || "模型未提供综合推荐建议"
+    const recommendation = parseRecommendation(data.recommendation)
 
     return {
       frameworkId: framework.id,

+ 3 - 0
src/lib/novel/story-simulation/simulation-serializer.ts

@@ -18,6 +18,7 @@ interface SerializedAgentMemory {
   knownSecrets: string[]
   sentiments: Record<string, number>
   recentDecisions: string[]
+  rumorCredibility: number
 }
 
 interface SerializedNovelAgent {
@@ -62,6 +63,7 @@ function serializeMemory(memory: AgentMemory): SerializedAgentMemory {
     knownSecrets: Array.from(memory.knownSecrets),
     sentiments: Object.fromEntries(memory.sentiments),
     recentDecisions: Array.from(memory.recentDecisions),
+    rumorCredibility: memory.rumorCredibility,
   }
 }
 
@@ -111,6 +113,7 @@ function deserializeMemory(s: SerializedAgentMemory): AgentMemory {
     knownSecrets: new Set(s.knownSecrets),
     sentiments: new Map(Object.entries(s.sentiments)),
     recentDecisions: s.recentDecisions,
+    rumorCredibility: s.rumorCredibility ?? 0.5,
   }
 }
 

+ 258 - 33
src/lib/novel/story-simulation/story-extractor.ts

@@ -17,12 +17,14 @@ import {
   loadCharacterStates,
   characterStatesToContextText,
 } from "@/lib/novel/character-state"
-import { loadSnapshot, listSnapshots } from "@/lib/novel/chapter-ingest"
+import { loadSnapshot } from "@/lib/novel/chapter-ingest"
 import {
   listCharacterAuras,
   getCharacterAuraBindings,
   loadCharacterAuraSkillDocument,
 } from "@/lib/novel/character-aura"
+import { streamChat } from "@/lib/llm-client"
+import type { LlmConfig } from "@/stores/wiki-store"
 import type {
   ExtractionResult,
   ExtractedCharacter,
@@ -34,6 +36,7 @@ import type {
 
 export interface ExtractionOptions {
   sourceChapters: number
+  llmConfig?: LlmConfig
   onProgress?: (progress: number, label: string) => void
 }
 
@@ -49,7 +52,7 @@ export async function extractStoryContent(
   options: ExtractionOptions,
 ): Promise<ExtractionResult> {
   const pp = normalizePath(projectPath)
-  const { sourceChapters, onProgress } = options
+  const { sourceChapters, llmConfig, onProgress } = options
   const report = (progress: number, label: string): void => {
     onProgress?.(progress, label)
   }
@@ -72,7 +75,7 @@ export async function extractStoryContent(
 
   // 5. 读取角色完整特征(55%)
   report(55, "正在提取角色完整特征...")
-  const characters = await extractCharacters(pp)
+  const characters = await extractCharacters(pp, chapterContents, llmConfig)
 
   // 6. 从大纲中提取世界规则和力量体系(70%)
   report(70, "正在从大纲中提取世界规则与力量体系...")
@@ -247,57 +250,276 @@ async function readMemoryData(pp: string): Promise<ExtractedMemoryData> {
 }
 
 /**
- * 提取角色完整特征
+ * 从章节摄入产物(.novel/chapter-ingest-output/NNN.output.json)读取角色
  *
- * 从章节快照中获取角色名列表,然后匹配光环(aura)、
- * 认知(cognition)和技能(skill)数据,并读取角色档案页。
+ * 每个摄入产物包含 wikiUpdatePatch.entries[],其中 entryType === "character"
+ * 的条目携带角色名、别名、身份、阵营、目标、弧光、当前状态、认知等字段。
+ * 多个章节出现的同名角色会被合并:appearanceChapters 累加,其余字段取最新章节。
  */
-async function extractCharacters(pp: string): Promise<ExtractedCharacter[]> {
-  // 从章节快照中收集角色名
-  const snapshotNumbers = (await listSnapshots(pp).catch(() => [])).filter(
-    (n) => n > 0,
+async function extractCharactersFromIngestOutput(
+  pp: string,
+): Promise<
+  Map<
+    string,
+    {
+      name: string
+      aliases: string[]
+      profile: string
+      cognition: { knows: string[]; doesNotKnow: string[] } | null
+      appearanceChapters: number[]
+    }
+  >
+> {
+  const outputDir = `${pp}/.novel/chapter-ingest-output`
+  let nodes
+  try {
+    nodes = await listDirectory(outputDir)
+  } catch {
+    return new Map()
+  }
+  const outputFiles = nodes.filter(
+    (n) => !n.is_dir && n.name.endsWith(".output.json"),
   )
 
-  const characterNames = new Set<string>()
-  for (const num of snapshotNumbers) {
+  const characterMap = new Map<
+    string,
+    {
+      name: string
+      aliases: string[]
+      profile: string
+      cognition: { knows: string[]; doesNotKnow: string[] } | null
+      appearanceChapters: number[]
+    }
+  >()
+
+  // 按文件名排序,保证后续章节覆盖前面的(取最新)
+  outputFiles.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }))
+
+  for (const node of outputFiles) {
     try {
-      const snapshot = await loadSnapshot(pp, num)
-      if (snapshot) {
-        for (const name of snapshot.characters) {
-          const trimmed = name.trim()
-          if (trimmed) characterNames.add(trimmed)
+      const raw = await readFile(node.path)
+      const data = JSON.parse(raw)
+      const entries = data?.wikiUpdatePatch?.entries ?? []
+      for (const entry of entries) {
+        if (entry.entryType !== "character") continue
+        const f = entry.fields ?? {}
+        const name = String(f.name ?? entry.title ?? "").trim()
+        if (!name) continue
+
+        const existing = characterMap.get(name)
+        const appearanceChapters = existing?.appearanceChapters ?? []
+        const newChapters: unknown[] = f.appearanceChapters ?? []
+        for (const ch of newChapters) {
+          const n = Number(ch)
+          if (Number.isFinite(n) && !appearanceChapters.includes(n)) {
+            appearanceChapters.push(n)
+          }
         }
+
+        // 构建角色档案:身份/阵营/目标/弧光/当前状态
+        const profileParts: string[] = []
+        if (f.identity) profileParts.push(`身份:${f.identity}`)
+        if (f.faction) profileParts.push(`阵营:${f.faction}`)
+        if (f.goals) profileParts.push(`目标:${f.goals}`)
+        if (f.arcChange) profileParts.push(`角色弧光:${f.arcChange}`)
+        if (f.currentState) profileParts.push(`当前状态:${f.currentState}`)
+        const profile = profileParts.join("\n") || existing?.profile || ""
+
+        // 认知
+        const cog = f.cognition
+        const cognition =
+          cog && Array.isArray(cog.knows) && Array.isArray(cog.doesNotKnow)
+            ? {
+                knows: cog.knows.map(String),
+                doesNotKnow: cog.doesNotKnow.map(String),
+              }
+            : existing?.cognition ?? null
+
+        characterMap.set(name, {
+          name,
+          aliases: existing?.aliases ?? Array.isArray(f.aliases) ? f.aliases.map(String) : [],
+          profile,
+          cognition,
+          appearanceChapters,
+        })
       }
     } catch {
-      // 单个快照加载失败,跳过
+      // 单个摄入产物解析失败,跳过
+    }
+  }
+
+  return characterMap
+}
+
+/**
+ * 从章节正文用 LLM 提取角色。
+ *
+ * 当章节摄入产物为空时,调用 LLM 直接分析最近 N 章的正文内容,
+ * 从中提取出现的角色名称和基本特征(身份、性格、目标等)。
+ * 返回的格式与 extractCharactersFromIngestOutput 一致,便于后续补充逻辑复用。
+ */
+async function extractCharactersFromChapters(
+  chapterContents: ExtractedChapterContent[],
+  llmConfig: LlmConfig,
+): Promise<
+  Map<
+    string,
+    {
+      name: string
+      aliases: string[]
+      profile: string
+      cognition: { knows: string[]; doesNotKnow: string[] } | null
+      appearanceChapters: number[]
+    }
+  >
+> {
+  if (chapterContents.length === 0) return new Map()
+
+  const chaptersText = chapterContents
+    .map((ch) => {
+      const num = ch.chapterNumber ?? 0
+      return `## 第${num}章 ${ch.title}\n\n${ch.content}`
+    })
+    .join("\n\n---\n\n")
+
+  const systemPrompt = `你是资深小说角色分析专家。请从以下小说章节正文中提取出现的所有重要角色。
+
+输出要求:
+1. 只输出 JSON 数组,不要任何额外文字或解释
+2. 每个角色包含以下字段:
+   - name: 角色姓名(全名)
+   - aliases: 别名/昵称数组
+   - identity: 身份/职业
+   - personality: 性格特征
+   - goals: 目标/动机
+   - faction: 阵营/势力
+   - appearanceChapters: 出现的章节号数组
+
+3. 只提取有明确姓名或身份的角色,忽略路人甲、群众等无名角色
+4. 按角色重要性排序,主要角色在前`
+
+  const userPrompt = `以下是小说章节内容:
+
+${chaptersText.slice(0, 15000)}
+
+请提取其中的重要角色信息。`
+
+  try {
+    let fullText = ""
+    await streamChat(
+      llmConfig,
+      [
+        { role: "system", content: systemPrompt },
+        { role: "user", content: userPrompt },
+      ],
+      {
+        onToken: (token: string) => {
+          fullText += token
+        },
+        onDone: () => {},
+        onError: () => {},
+      },
+    )
+
+    const jsonMatch = fullText.match(/\[[\s\S]*\]/)
+    if (!jsonMatch) return new Map()
+
+    const parsed = JSON.parse(jsonMatch[0])
+    if (!Array.isArray(parsed)) return new Map()
+
+    const characterMap = new Map<
+      string,
+      {
+        name: string
+        aliases: string[]
+        profile: string
+        cognition: { knows: string[]; doesNotKnow: string[] } | null
+        appearanceChapters: number[]
+      }
+    >()
+
+    for (const item of parsed) {
+      const name = String(item.name ?? "").trim()
+      if (!name) continue
+
+      const profileParts: string[] = []
+      if (item.identity) profileParts.push(`身份:${item.identity}`)
+      if (item.personality) profileParts.push(`性格:${item.personality}`)
+      if (item.goals) profileParts.push(`目标:${item.goals}`)
+      if (item.faction) profileParts.push(`阵营:${item.faction}`)
+
+      const appearanceChapters = Array.isArray(item.appearanceChapters)
+        ? item.appearanceChapters.map((n: unknown) => Number(n)).filter((n: number) => Number.isFinite(n))
+        : []
+
+      characterMap.set(name, {
+        name,
+        aliases: Array.isArray(item.aliases) ? item.aliases.map(String) : [],
+        profile: profileParts.join("\n"),
+        cognition: null,
+        appearanceChapters,
+      })
     }
+
+    return characterMap
+  } catch {
+    return new Map()
   }
+}
 
-  if (characterNames.size === 0) return []
+/**
+ * 提取角色完整特征。
+ *
+ * 主路径:从章节摄入产物(.novel/chapter-ingest-output/)读取角色名与
+ * 基础特征(身份/阵营/目标/弧光/认知)。
+ * 补充源:光环(.qmai/character-auras/)、角色认知状态、角色档案页。
+ */
+async function extractCharacters(
+  pp: string,
+  chapterContents: ExtractedChapterContent[],
+  llmConfig?: LlmConfig,
+): Promise<ExtractedCharacter[]> {
+  // 1. 从章节摄入产物读取角色名和基础特征
+  let ingestCharacters = await extractCharactersFromIngestOutput(pp)
+
+  // 2. 如果摄入产物为空,尝试从章节正文用 LLM 提取角色
+  if (ingestCharacters.size === 0 && llmConfig && chapterContents.length > 0) {
+    const llmCharacters = await extractCharactersFromChapters(chapterContents, llmConfig)
+    if (llmCharacters.size > 0) {
+      ingestCharacters = llmCharacters
+    }
+  }
+
+  if (ingestCharacters.size === 0) return []
 
-  // 加载光环数据和绑定关系
+  // 2. 加载光环数据和绑定关系(补充源)
   const auras = await listCharacterAuras(pp).catch(() => [])
   const bindings = await getCharacterAuraBindings(pp).catch(() => [])
 
-  // 加载角色认知状态
+  // 3. 加载角色认知状态(补充源)
   const cognitionState = await loadCognitionState(pp).catch(() => null)
 
   const characters: ExtractedCharacter[] = []
-  for (const name of characterNames) {
-    // 匹配光环绑定(按角色名或别名)
+  for (const [name, info] of ingestCharacters) {
+    // 匹配光环绑定(按角色名或别名双向匹配
     const binding = bindings.find(
-      (b) => b.characterName === name || (b.aliases && b.aliases.includes(name)),
+      (b) =>
+        b.characterName === name ||
+        (b.aliases && b.aliases.includes(name)) ||
+        (info.aliases && info.aliases.includes(b.characterName)),
     )
     const aura = binding
       ? (auras.find((a) => a.id === binding.auraId) ?? null)
       : null
 
-    // 匹配认知数据
-    const cognitionEntry =
-      cognitionState?.characters.find((c) => c.character === name) ?? null
-    const cognition = cognitionEntry
-      ? { knows: cognitionEntry.knows, doesNotKnow: cognitionEntry.doesNotKnow }
-      : null
+    // 认知:优先用摄入产物的,其次用 cognitionState
+    let cognition = info.cognition
+    if (!cognition && cognitionState) {
+      const entry = cognitionState.characters.find((c) => c.character === name)
+      if (entry) {
+        cognition = { knows: entry.knows, doesNotKnow: entry.doesNotKnow }
+      }
+    }
 
     // 读取技能文档(来自光环的 skillFolder)
     let skillContent = ""
@@ -309,10 +531,13 @@ async function extractCharacters(pp: string): Promise<ExtractedCharacter[]> {
       }
     }
 
-    // 读取角色档案页(wiki/entities/{name}.md)
-    let profile = ""
+    // 读取角色档案页(wiki/entities/{name}.md),叠加到摄入产物的 profile 上
+    let profile = info.profile
     try {
-      profile = await readFile(`${pp}/wiki/entities/${name}.md`)
+      const fileProfile = await readFile(`${pp}/wiki/entities/${name}.md`)
+      if (fileProfile) {
+        profile = profile ? `${profile}\n\n${fileProfile}` : fileProfile
+      }
     } catch {
       // 无角色档案页,留空
     }

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

@@ -80,6 +80,14 @@ export interface RumorEvent {
   /** 已验证此传闻的角色 ID 列表 */
   verifiedBy: string[]
   timestamp: string
+  /** 父传闻 ID */
+  parentId?: string
+  /** 传播者角色 ID */
+  spreadBy?: string
+  /** 传播发生的轮次 */
+  spreadRound?: number
+  /** 传播代数,原始传闻为 0 */
+  generation: number
 }
 
 export interface SimulationDebugVisibleEvent {
@@ -132,6 +140,8 @@ export interface AgentMemory {
   sentiments: Map<string, number>
   /** 最近决策记录 */
   recentDecisions: string[]
+  /** 传闻可信度,范围 0-1,默认 0.5 */
+  rumorCredibility: number
 }
 
 // ── Agent ──
@@ -492,6 +502,20 @@ export interface DirectorEvaluation {
 
 // ── 仿真分支 ──
 
+export interface SimulationHistoryEntry {
+  round: number
+  nodeIndex: number
+  nodeTitle: string
+  agentStates: Record<string, {
+    name: string
+    sentiments: [string, number][]
+    knownSecrets: string[]
+    observedEvents: string[]
+  }>
+  eventCount: number
+  rumorCount: number
+}
+
 export interface SimulationBranch {
   id: string
   name: string

+ 383 - 138
src/stores/story-simulation-store.ts

@@ -1,4 +1,4 @@
-import { create } from "zustand"
+import { create } from "zustand";
 import type {
   AgentChatMessage,
   SimulationMode,
@@ -14,18 +14,21 @@ import type {
   NovelAgent,
   SimulationBranch,
   DirectorEvaluation,
-} from "@/lib/novel/story-simulation/types"
-import type { SerializedSimulationSnapshot } from "@/lib/novel/story-simulation/simulation-serializer"
-import type { SavedInterview } from "@/lib/novel/story-simulation/interview-store"
+  SimulationHistoryEntry,
+} from "@/lib/novel/story-simulation/types";
+import type { SerializedSimulationSnapshot } from "@/lib/novel/story-simulation/simulation-serializer";
+import type { SavedInterview } from "@/lib/novel/story-simulation/interview-store";
 
 export interface SavedSimulationResult {
-  id: string
-  frameworkId: string
-  report: SimulationReport
-  draft?: StoryDraft | null
-  timelineEvents?: TimelineEvent[]
-  agentSnapshot?: SerializedSimulationSnapshot | null
-  createdAt: string
+  id: string;
+  frameworkId: string;
+  report: SimulationReport;
+  draft?: StoryDraft | null;
+  timelineEvents?: TimelineEvent[];
+  agentSnapshot?: SerializedSimulationSnapshot | null;
+  rumors?: RumorEvent[];
+  debugTraces?: SimulationDebugTrace[];
+  createdAt: string;
 }
 
 export type SimulationPhase =
@@ -38,123 +41,176 @@ export type SimulationPhase =
   | "report-generating"
   | "report-viewing"
   | "draft-generating"
-  | "draft-viewing"
+  | "draft-viewing";
 
 export interface SimulationPreset {
-  intent: string
-  userInput: string
-  hasFramework: boolean
+  intent: string;
+  userInput: string;
+  hasFramework: boolean;
 }
 
 export interface StorySimulationState {
-  phase: SimulationPhase
-  mode: SimulationMode
-  userIdea: string
-  targetWords: number
-  sourceChapters: number
+  phase: SimulationPhase;
+  mode: SimulationMode;
+  userIdea: string;
+  targetWords: number;
+  sourceChapters: number;
   /** 每个节点仿真轮数,0表示自动 */
-  simulationRounds: number
-  extractionResult: ExtractionResult | null
-  currentFramework: StoryFramework | null
-  currentReport: SimulationReport | null
-  currentDraft: StoryDraft | null
-  frameworks: StoryFramework[]
-  selectedFrameworkId: string | null
-  binding: FrameworkBinding | null
-  error: string | null
-  progress: number
-  progressLabel: string
+  simulationRounds: number;
+  extractionResult: ExtractionResult | null;
+  currentFramework: StoryFramework | null;
+  currentReport: SimulationReport | null;
+  currentDraft: StoryDraft | null;
+  frameworks: StoryFramework[];
+  selectedFrameworkId: string | null;
+  binding: FrameworkBinding | null;
+  error: string | null;
+  /** 提示/成功消息 */
+  infoMessage: string | null;
+  progress: number;
+  progressLabel: string;
   /** 仿真过程中的时间线事件(实时流) */
-  timelineEvents: TimelineEvent[]
+  timelineEvents: TimelineEvent[];
   /** 仿真过程观察快照(Agent 调度和 blackboard 状态) */
-  debugTraces: SimulationDebugTrace[]
+  debugTraces: SimulationDebugTrace[];
   /** 当前正在采访的角色 */
-  activeChatAgent: { id: string; name: string } | null
+  activeChatAgent: { id: string; name: string } | null;
   /** 采访对话消息 */
-  agentChatMessages: AgentChatMessage[]
+  agentChatMessages: AgentChatMessage[];
   /** 列表刷新计数(用于触发 framework-list 重新加载) */
-  listRefreshKey: number
+  listRefreshKey: number;
   /** 当前框架下已保存的推演结果 */
-  savedResults: SavedSimulationResult[]
+  savedResults: SavedSimulationResult[];
   /** 当前选中查看的历史结果ID */
-  selectedResultId: string | null
+  selectedResultId: string | null;
   /** 是否显示采访历史面板 */
-  showInterviewHistory: boolean
+  showInterviewHistory: boolean;
   /** 已保存的采访列表 */
-  savedInterviews: SavedInterview[]
+  savedInterviews: SavedInterview[];
   /** 当前查看的采访详情 */
-  viewingInterview: SavedInterview | null
+  viewingInterview: SavedInterview | null;
   /** 对比模式下要对比的结果ID(null表示不对比) */
-  compareWithResultId: string | null
+  compareWithResultId: string | null;
   /** 当前续聊的采访ID(用于保存时判断覆盖/另存) */
-  continuingInterviewId: string | null
+  continuingInterviewId: string | null;
   /** LLM 预生成的动态事件池(支持字符串数组或分阶段池) */
-  dynamicEventPool: string[] | StagedEventPool
+  dynamicEventPool: string[] | StagedEventPool;
   /** 已使用的事件索引 */
-  usedEventIndices: Set<number>
+  usedEventIndices: Set<number>;
   /** 是否启用导演 Agent */
-  directorEnabled: boolean
+  directorEnabled: boolean;
   /** 当前所有传闻 */
-  currentRumors: RumorEvent[]
+  currentRumors: RumorEvent[];
   /** 当前所有角色快照 */
-  currentAgents: Map<string, NovelAgent>
+  currentAgents: Map<string, NovelAgent>;
   /** 仿真分支列表 */
-  branches: SimulationBranch[]
+  branches: SimulationBranch[];
   /** 当前激活的分支 ID */
-  activeBranchId: string | null
-
-  setPhase: (phase: SimulationPhase) => void
-  setMode: (mode: SimulationMode) => void
-  setUserIdea: (idea: string) => void
-  setTargetWords: (words: number) => void
-  setSourceChapters: (count: number) => void
-  setSimulationRounds: (rounds: number) => void
-  setExtractionResult: (result: ExtractionResult | null) => void
-  setCurrentFramework: (framework: StoryFramework | null) => void
-  setCurrentReport: (report: SimulationReport | null) => void
-  setCurrentDraft: (draft: StoryDraft | null) => void
-  setFrameworks: (frameworks: StoryFramework[]) => void
-  setSelectedFrameworkId: (id: string | null) => void
-  setBinding: (binding: FrameworkBinding | null) => void
-  setError: (error: string | null) => void
-  setProgress: (progress: number, label: string) => void
-  setTimelineEvents: (events: TimelineEvent[]) => void
-  addTimelineEvent: (event: TimelineEvent) => void
-  setDebugTraces: (traces: SimulationDebugTrace[]) => void
-  addDebugTrace: (trace: SimulationDebugTrace) => void
-  setActiveChatAgent: (agent: { id: string; name: string } | null) => void
-  addAgentChatMessage: (message: AgentChatMessage) => void
-  clearAgentChat: () => void
-  bumpListRefresh: () => void
-  setSavedResults: (results: SavedSimulationResult[]) => void
-  setSelectedResultId: (id: string | null) => void
-  setShowInterviewHistory: (show: boolean) => void
-  setSavedInterviews: (interviews: SavedInterview[]) => void
-  setViewingInterview: (interview: SavedInterview | null) => void
-  setCompareWithResultId: (id: string | null) => void
-  setContinuingInterviewId: (id: string | null) => void
+  activeBranchId: string | null;
+  /** 对比模式下选中的分支 ID 列表 */
+  compareBranchIds: string[];
+  /** 是否处于对比模式 */
+  isCompareMode: boolean;
+  /** 中断的推演状态快照(切换框架时保存,返回时恢复),按 frameworkId 映射 */
+  interruptedSimStates: Record<
+    string,
+    {
+      phase: SimulationPhase;
+      timelineEvents: TimelineEvent[];
+      debugTraces: SimulationDebugTrace[];
+      currentRumors: RumorEvent[];
+      currentAgents: Map<string, NovelAgent>;
+      progress: number;
+      progressLabel: string;
+    }
+  >;
+  /** 历史快照列表 */
+  history: SimulationHistoryEntry[];
+  /** 当前历史索引,-1 表示实时模式 */
+  historyIndex: number;
+  /** 是否正在播放 */
+  isPlaying: boolean;
+  /** 播放速度 */
+  playbackSpeed: number;
+
+  setPhase: (phase: SimulationPhase) => void;
+  setMode: (mode: SimulationMode) => void;
+  setUserIdea: (idea: string) => void;
+  setTargetWords: (words: number) => void;
+  setSourceChapters: (count: number) => void;
+  setSimulationRounds: (rounds: number) => void;
+  setExtractionResult: (result: ExtractionResult | null) => void;
+  setCurrentFramework: (framework: StoryFramework | null) => void;
+  setCurrentReport: (report: SimulationReport | null) => void;
+  setCurrentDraft: (draft: StoryDraft | null) => void;
+  setFrameworks: (frameworks: StoryFramework[]) => void;
+  setSelectedFrameworkId: (id: string | null) => void;
+  setBinding: (binding: FrameworkBinding | null) => void;
+  setError: (error: string | null) => void;
+  setInfoMessage: (infoMessage: string | null) => void;
+  setProgress: (progress: number, label: string) => void;
+  setTimelineEvents: (events: TimelineEvent[]) => void;
+  addTimelineEvent: (event: TimelineEvent) => void;
+  setDebugTraces: (traces: SimulationDebugTrace[]) => void;
+  addDebugTrace: (trace: SimulationDebugTrace) => void;
+  setActiveChatAgent: (agent: { id: string; name: string } | null) => void;
+  addAgentChatMessage: (message: AgentChatMessage) => void;
+  clearAgentChat: () => void;
+  bumpListRefresh: () => void;
+  setSavedResults: (results: SavedSimulationResult[]) => void;
+  setSelectedResultId: (id: string | null) => void;
+  setShowInterviewHistory: (show: boolean) => void;
+  setSavedInterviews: (interviews: SavedInterview[]) => void;
+  setViewingInterview: (interview: SavedInterview | null) => void;
+  setCompareWithResultId: (id: string | null) => void;
+  setContinuingInterviewId: (id: string | null) => void;
   /** 设置采访消息列表 */
-  setAgentChatMessages: (messages: AgentChatMessage[]) => void
+  setAgentChatMessages: (messages: AgentChatMessage[]) => void;
   /** 设置动态事件池 */
-  setDynamicEventPool: (pool: string[] | StagedEventPool) => void
+  setDynamicEventPool: (pool: string[] | StagedEventPool) => void;
   /** 设置是否启用导演 Agent */
-  setDirectorEnabled: (enabled: boolean) => void
+  setDirectorEnabled: (enabled: boolean) => void;
   /** 设置当前传闻列表 */
-  setCurrentRumors: (rumors: RumorEvent[]) => void
+  setCurrentRumors: (rumors: RumorEvent[]) => void;
   /** 设置当前角色快照 */
-  setCurrentAgents: (agents: Map<string, NovelAgent>) => void
+  setCurrentAgents: (agents: Map<string, NovelAgent>) => void;
   /** 保存当前状态为分支 */
-  saveCurrentAsBranch: (name: string) => void
+  saveCurrentAsBranch: (name: string) => void;
   /** 删除分支 */
-  deleteBranch: (id: string) => void
+  deleteBranch: (id: string) => void;
   /** 重命名分支 */
-  renameBranch: (id: string, name: string) => void
+  renameBranch: (id: string, name: string) => void;
   /** 切换到指定分支 */
-  switchToBranch: (id: string) => void
+  switchToBranch: (id: string) => void;
   /** 清空所有分支 */
-  clearBranches: () => void
-  reset: () => void
-  initWithPreset: (preset: SimulationPreset) => void
+  clearBranches: () => void;
+  /** 设置对比模式 */
+  setCompareMode: (enabled: boolean) => void;
+
+  /** 保存当前推演状态快照(用户切换到其他框架时调用) */
+  saveInterruptedState: () => void;
+
+  /** 恢复中断的推演状态(用户回到原框架时调用) */
+  restoreInterruptedState: (frameworkId?: string) => void;
+
+  /** 切换分支的对比选中状态 */
+  toggleCompareBranch: (branchId: string) => void;
+  /** 清空对比选中 */
+  clearCompareSelection: () => void;
+  /** 添加历史快照 */
+  addHistoryEntry: (entry: SimulationHistoryEntry) => void;
+  /** 设置历史索引 */
+  setHistoryIndex: (index: number) => void;
+  /** 切换播放状态 */
+  togglePlayback: () => void;
+  /** 设置播放速度 */
+  setPlaybackSpeed: (speed: number) => void;
+  /** 清空历史记录 */
+  clearHistory: () => void;
+  /** 获取当前展示的 agents(回放模式或实时模式) */
+  getDisplayAgents: () => Map<string, NovelAgent>;
+  reset: () => void;
+  initWithPreset: (preset: SimulationPreset) => void;
 }
 
 export const useStorySimulationStore = create<StorySimulationState>((set) => ({
@@ -172,6 +228,7 @@ export const useStorySimulationStore = create<StorySimulationState>((set) => ({
   selectedFrameworkId: null,
   binding: null,
   error: null,
+  infoMessage: null,
   progress: 0,
   progressLabel: "",
   timelineEvents: [],
@@ -193,6 +250,13 @@ export const useStorySimulationStore = create<StorySimulationState>((set) => ({
   currentAgents: new Map(),
   branches: [],
   activeBranchId: null,
+  compareBranchIds: [],
+  isCompareMode: false,
+  interruptedSimStates: {},
+  history: [],
+  historyIndex: -1,
+  isPlaying: false,
+  playbackSpeed: 1,
 
   setPhase: (phase) => set({ phase }),
   setMode: (mode) => set({ mode }),
@@ -208,6 +272,7 @@ export const useStorySimulationStore = create<StorySimulationState>((set) => ({
   setSelectedFrameworkId: (selectedFrameworkId) => set({ selectedFrameworkId }),
   setBinding: (binding) => set({ binding }),
   setError: (error) => set({ error }),
+  setInfoMessage: (infoMessage: string | null) => set({ infoMessage }),
   setProgress: (progress, progressLabel) => set({ progress, progressLabel }),
   setTimelineEvents: (timelineEvents) => set({ timelineEvents }),
   addTimelineEvent: (event) =>
@@ -217,16 +282,21 @@ export const useStorySimulationStore = create<StorySimulationState>((set) => ({
     set((state) => ({ debugTraces: [...state.debugTraces, trace] })),
   setActiveChatAgent: (activeChatAgent) => set({ activeChatAgent }),
   addAgentChatMessage: (message) =>
-    set((state) => ({ agentChatMessages: [...state.agentChatMessages, message] })),
+    set((state) => ({
+      agentChatMessages: [...state.agentChatMessages, message],
+    })),
   clearAgentChat: () => set({ agentChatMessages: [], activeChatAgent: null }),
-  bumpListRefresh: () => set((state) => ({ listRefreshKey: state.listRefreshKey + 1 })),
+  bumpListRefresh: () =>
+    set((state) => ({ listRefreshKey: state.listRefreshKey + 1 })),
   setSavedResults: (savedResults) => set({ savedResults }),
   setSelectedResultId: (selectedResultId) => set({ selectedResultId }),
-  setShowInterviewHistory: (showInterviewHistory) => set({ showInterviewHistory }),
+  setShowInterviewHistory: (showInterviewHistory) =>
+    set({ showInterviewHistory }),
   setSavedInterviews: (savedInterviews) => set({ savedInterviews }),
   setViewingInterview: (viewingInterview) => set({ viewingInterview }),
   setCompareWithResultId: (compareWithResultId) => set({ compareWithResultId }),
-  setContinuingInterviewId: (continuingInterviewId) => set({ continuingInterviewId }),
+  setContinuingInterviewId: (continuingInterviewId) =>
+    set({ continuingInterviewId }),
   setAgentChatMessages: (agentChatMessages) => set({ agentChatMessages }),
   setDynamicEventPool: (dynamicEventPool) => set({ dynamicEventPool }),
   setDirectorEnabled: (directorEnabled) => set({ directorEnabled }),
@@ -235,21 +305,29 @@ export const useStorySimulationStore = create<StorySimulationState>((set) => ({
 
   saveCurrentAsBranch: (name) =>
     set((state) => {
-      if (state.branches.length >= 10) return state
-      if (!state.currentFramework) return state
-
-      const activeAgentCount = state.currentAgents.size
-      const totalAgentCount = Math.max(activeAgentCount, state.currentFramework.nodes.reduce(
-        (acc, node) => Math.max(acc, node.involvedCharacters.length),
-        0,
-      ))
-
-      const finalAgentSnapshots = Array.from(state.currentAgents.values()).map((agent) => ({
-        agentId: agent.characterId,
-        name: agent.name,
-        knownSecrets: Array.from(agent.memory.knownSecrets),
-        sentiments: Array.from(agent.memory.sentiments.entries()) as [string, number][],
-      }))
+      if (state.branches.length >= 10) return state;
+      if (!state.currentFramework) return state;
+
+      const activeAgentCount = state.currentAgents.size;
+      const totalAgentCount = Math.max(
+        activeAgentCount,
+        state.currentFramework.nodes.reduce(
+          (acc, node) => Math.max(acc, node.involvedCharacters.length),
+          0,
+        ),
+      );
+
+      const finalAgentSnapshots = Array.from(state.currentAgents.values()).map(
+        (agent) => ({
+          agentId: agent.characterId,
+          name: agent.name,
+          knownSecrets: Array.from(agent.memory.knownSecrets),
+          sentiments: Array.from(agent.memory.sentiments.entries()) as [
+            string,
+            number,
+          ][],
+        }),
+      );
 
       const { overallScore, details } = calculateBranchScore(
         [],
@@ -257,7 +335,7 @@ export const useStorySimulationStore = create<StorySimulationState>((set) => ({
         activeAgentCount,
         totalAgentCount,
         0.6,
-      )
+      );
 
       const newBranch: SimulationBranch = {
         id: `branch_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
@@ -271,43 +349,186 @@ export const useStorySimulationStore = create<StorySimulationState>((set) => ({
         directorEvaluations: [],
         overallScore,
         scoreDetails: details,
-      }
+      };
 
       return {
         branches: [...state.branches, newBranch],
-      }
+      };
     }),
 
   deleteBranch: (id) =>
     set((state) => ({
       branches: state.branches.filter((b) => b.id !== id),
       activeBranchId: state.activeBranchId === id ? null : state.activeBranchId,
+      compareBranchIds: state.compareBranchIds.filter((bid) => bid !== id),
     })),
 
   renameBranch: (id, name) =>
     set((state) => ({
-      branches: state.branches.map((b) =>
-        b.id === id ? { ...b, name } : b,
-      ),
+      branches: state.branches.map((b) => (b.id === id ? { ...b, name } : b)),
     })),
 
   switchToBranch: (id) =>
     set((state) => {
-      const branch = state.branches.find((b) => b.id === id)
-      if (!branch) return state
+      const branch = state.branches.find((b) => b.id === id);
+      if (!branch) return state;
       return {
         timelineEvents: [...branch.timelineEvents],
         currentRumors: [...branch.rumors],
         activeBranchId: id,
-      }
+      };
     }),
 
   clearBranches: () =>
     set({
       branches: [],
       activeBranchId: null,
+      compareBranchIds: [],
+      isCompareMode: false,
+    }),
+
+  setCompareMode: (enabled) =>
+    set((state) => {
+      if (enabled) {
+        const count = state.compareBranchIds.length;
+        if (count < 2 || count > 3) return state;
+      }
+      return { isCompareMode: enabled };
     }),
 
+  toggleCompareBranch: (branchId) =>
+    set((state) => {
+      const exists = state.compareBranchIds.includes(branchId);
+      if (exists) {
+        return {
+          compareBranchIds: state.compareBranchIds.filter(
+            (id) => id !== branchId,
+          ),
+        };
+      } else {
+        if (state.compareBranchIds.length >= 3) return state;
+        return {
+          compareBranchIds: [...state.compareBranchIds, branchId],
+        };
+      }
+    }),
+
+  clearCompareSelection: () =>
+    set({ compareBranchIds: [], isCompareMode: false }),
+
+  saveInterruptedState: () =>
+    set((state) => {
+      const saveablePhases = new Set([
+        "simulating",
+        "report-viewing",
+        "report-generating",
+        "draft-viewing",
+        "draft-generating",
+      ]);
+      if (!saveablePhases.has(state.phase) || !state.currentFramework)
+        return state;
+      return {
+        interruptedSimStates: {
+          ...state.interruptedSimStates,
+          [state.currentFramework.id]: {
+            phase: state.phase,
+            timelineEvents: [...state.timelineEvents],
+            debugTraces: [...state.debugTraces],
+            currentRumors: [...state.currentRumors],
+            currentAgents: new Map(state.currentAgents),
+            progress: state.progress,
+            progressLabel: state.progressLabel,
+          },
+        },
+      };
+    }),
+
+  restoreInterruptedState: (frameworkId?: string) =>
+    set((state) => {
+      const targetId = frameworkId || state.currentFramework?.id;
+      if (!targetId || !state.interruptedSimStates[targetId]) return state;
+      const saved = state.interruptedSimStates[targetId];
+      const { [targetId]: _, ...rest } = state.interruptedSimStates;
+      return {
+        phase: saved.phase,
+        timelineEvents: saved.timelineEvents,
+        debugTraces: saved.debugTraces,
+        currentRumors: saved.currentRumors,
+        currentAgents: saved.currentAgents,
+        progress: saved.progress,
+        progressLabel: saved.progressLabel,
+        interruptedSimStates: rest,
+      };
+    }),
+
+  addHistoryEntry: (entry) =>
+    set((state) => ({
+      history: [...state.history, entry],
+    })),
+
+  setHistoryIndex: (index) => set({ historyIndex: index, isPlaying: false }),
+
+  togglePlayback: () => set((state) => ({ isPlaying: !state.isPlaying })),
+
+  setPlaybackSpeed: (speed) => set({ playbackSpeed: speed }),
+
+  clearHistory: () =>
+    set({
+      history: [],
+      historyIndex: -1,
+      isPlaying: false,
+    }),
+
+  getDisplayAgents: (): Map<string, NovelAgent> => {
+    const state = useStorySimulationStore.getState();
+    if (state.historyIndex < 0) {
+      return state.currentAgents;
+    }
+    const entry = state.history[state.historyIndex];
+    if (!entry) {
+      return new Map<string, NovelAgent>();
+    }
+    const agents = new Map<string, NovelAgent>();
+    const agentStates = entry.agentStates as Record<
+      string,
+      {
+        name: string;
+        sentiments: [string, number][];
+        knownSecrets: string[];
+        observedEvents: string[];
+      }
+    >;
+    for (const [id, agentState] of Object.entries(agentStates)) {
+      agents.set(id, {
+        characterId: id,
+        name: agentState.name,
+        profile: "",
+        aura: null,
+        cognition: null,
+        soul: "",
+        currentGoal: "",
+        emotionalState: "",
+        knownFacts: new Set<string>(),
+        relationships: new Map<
+          string,
+          import("@/lib/novel/story-simulation/types").AgentRelation
+        >(),
+        powerLevel: "",
+        memory: {
+          observedEvents: agentState.observedEvents,
+          knownSecrets: new Set<string>(agentState.knownSecrets),
+          sentiments: new Map<string, number>(agentState.sentiments),
+          recentDecisions: [],
+          rumorCredibility: 0.5,
+        },
+        knowledgeScope: [],
+        personality: [],
+        speakingStyle: "",
+      });
+    }
+    return agents;
+  },
+
   reset: () =>
     set({
       phase: "idle",
@@ -336,25 +557,34 @@ export const useStorySimulationStore = create<StorySimulationState>((set) => ({
       currentAgents: new Map(),
       branches: [],
       activeBranchId: null,
+      compareBranchIds: [],
+      isCompareMode: false,
+      history: [],
+      historyIndex: -1,
+      isPlaying: false,
+      playbackSpeed: 1,
     }),
   initWithPreset: (preset) =>
     set((state) => {
-      let phase: SimulationPhase = "configuring"
+      let phase: SimulationPhase = "configuring";
 
       if (preset.intent === "story_framework_generate") {
-        phase = "configuring"
+        phase = "configuring";
       } else if (preset.intent === "multi_agent_simulate") {
-        phase = preset.hasFramework ? "simulating" : "configuring"
+        phase = preset.hasFramework ? "simulating" : "configuring";
       } else if (preset.intent === "character_interview") {
-        phase = preset.hasFramework && state.savedResults.length > 0 ? "report-viewing" : "configuring"
+        phase =
+          preset.hasFramework && state.savedResults.length > 0
+            ? "report-viewing"
+            : "configuring";
       }
 
       return {
         userIdea: preset.userInput,
         phase,
-      }
+      };
     }),
-}))
+}));
 
 export function calculateBranchScore(
   directorEvaluations: DirectorEvaluation[],
@@ -362,25 +592,40 @@ export function calculateBranchScore(
   activeAgentCount: number,
   totalAgentCount: number,
   goalProgress: number = 0.6,
-): { overallScore: number; details: { avgDirectorScore: number; eventCount: number; characterDiversity: number; plotProgression: number } } {
-  const avgDirectorScore = directorEvaluations.length > 0
-    ? directorEvaluations.reduce((sum, e) => sum + e.totalScore, 0) / directorEvaluations.length
-    : 3.0
+): {
+  overallScore: number;
+  details: {
+    avgDirectorScore: number;
+    eventCount: number;
+    characterDiversity: number;
+    plotProgression: number;
+  };
+} {
+  const avgDirectorScore =
+    directorEvaluations.length > 0
+      ? directorEvaluations.reduce((sum, e) => sum + e.totalScore, 0) /
+        directorEvaluations.length
+      : 3.0;
 
-  const eventScore = Math.min(5, eventCount / 4) * 0.2
-  const charScore = (activeAgentCount / Math.max(1, totalAgentCount)) * 5 * 0.15
-  const plotScore = goalProgress * 5 * 0.15
-  const directorScorePart = avgDirectorScore * 0.5
+  const eventScore = Math.min(5, eventCount / 4) * 0.2;
+  const charScore =
+    (activeAgentCount / Math.max(1, totalAgentCount)) * 5 * 0.15;
+  const plotScore = goalProgress * 5 * 0.15;
+  const directorScorePart = avgDirectorScore * 0.5;
 
-  const overallScore = Math.round((directorScorePart + eventScore + charScore + plotScore) * 10) / 10
+  const overallScore =
+    Math.round((directorScorePart + eventScore + charScore + plotScore) * 10) /
+    10;
 
   return {
     overallScore,
     details: {
       avgDirectorScore: Math.round(avgDirectorScore * 10) / 10,
       eventCount,
-      characterDiversity: Math.round((activeAgentCount / Math.max(1, totalAgentCount)) * 100) / 100,
+      characterDiversity:
+        Math.round((activeAgentCount / Math.max(1, totalAgentCount)) * 100) /
+        100,
       plotProgression: Math.round(goalProgress * 100) / 100,
     },
-  }
+  };
 }

この差分においてかなりの量のファイルが変更されているため、一部のファイルを表示していません