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

merge: feature-more-optimizations into main

剧情推演室/草稿编辑/拖拽排序/采访续聊/报告对比等33个提交
Mochocyang 2 месяцев назад
Родитель
Сommit
3e58dc2b1e
44 измененных файлов с 15231 добавлено и 7 удалено
  1. 3745 0
      docs/superpowers/plans/2026-06-26-story-simulation-room.md
  2. 1080 0
      docs/superpowers/plans/2026-06-27-story-sim-optimization-plan.md
  3. 165 0
      docs/superpowers/specs/2026-06-27-story-sim-optimization-design.md
  4. 56 0
      package-lock.json
  5. 4 1
      package.json
  6. 13 2
      scripts/build-portable.mjs
  7. 1 1
      src-tauri/tauri.conf.json
  8. 80 1
      src/components/chat/chat-panel.tsx
  9. 12 0
      src/components/layout/content-area.tsx
  10. 2 1
      src/components/layout/icon-sidebar.tsx
  11. 203 0
      src/components/layout/sidebar-panel.tsx
  12. 148 0
      src/components/novel/story-simulation/framework-binding-dialog.tsx
  13. 530 0
      src/components/novel/story-simulation/framework-confirm-panel.tsx
  14. 234 0
      src/components/novel/story-simulation/framework-list.tsx
  15. 364 0
      src/components/novel/story-simulation/interview-history-view.tsx
  16. 218 0
      src/components/novel/story-simulation/simulation-config-panel.tsx
  17. 939 0
      src/components/novel/story-simulation/simulation-report-view.tsx
  18. 509 0
      src/components/novel/story-simulation/story-draft-view.tsx
  19. 1287 0
      src/components/novel/story-simulation/story-simulation-view.tsx
  20. 84 0
      src/i18n/en.json
  21. 84 0
      src/i18n/zh.json
  22. 24 0
      src/lib/novel/context-data-sources.ts
  23. 256 0
      src/lib/novel/story-simulation/agent-interview.ts
  24. 351 0
      src/lib/novel/story-simulation/agent-profile-builder.ts
  25. 69 0
      src/lib/novel/story-simulation/draft-export.ts
  26. 134 0
      src/lib/novel/story-simulation/draft-importer.ts
  27. 132 0
      src/lib/novel/story-simulation/framework-binding.ts
  28. 503 0
      src/lib/novel/story-simulation/framework-store.ts
  29. 90 0
      src/lib/novel/story-simulation/interview-export.ts
  30. 126 0
      src/lib/novel/story-simulation/interview-store.ts
  31. 192 0
      src/lib/novel/story-simulation/report-export.ts
  32. 1117 0
      src/lib/novel/story-simulation/simulation-engine.ts
  33. 236 0
      src/lib/novel/story-simulation/simulation-modes/decision-tree.ts
  34. 11 0
      src/lib/novel/story-simulation/simulation-modes/event-driven.ts
  35. 11 0
      src/lib/novel/story-simulation/simulation-modes/free-emergence.ts
  36. 11 0
      src/lib/novel/story-simulation/simulation-modes/hybrid.ts
  37. 367 0
      src/lib/novel/story-simulation/simulation-report-agent.ts
  38. 156 0
      src/lib/novel/story-simulation/simulation-serializer.ts
  39. 181 0
      src/lib/novel/story-simulation/story-draft-generator.ts
  40. 400 0
      src/lib/novel/story-simulation/story-extractor.ts
  41. 450 0
      src/lib/novel/story-simulation/story-framework-generator.ts
  42. 459 0
      src/lib/novel/story-simulation/types.ts
  43. 192 0
      src/stores/story-simulation-store.ts
  44. 5 1
      src/stores/wiki-store.ts

+ 3745 - 0
docs/superpowers/plans/2026-06-26-story-simulation-room.md

@@ -0,0 +1,3745 @@
+# 剧情推演室(Story Simulation Room)实现计划
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 在 QMAI 小说写作软件中新增"剧情推演室"功能,将 MiroFish 的多 Agent 仿真能力以 TypeScript 重写集成,把社媒舆论预测改造为小说剧情推演。
+
+**Architecture:** 纯 TypeScript 实现,复用 QMAI 现有 LanceDB + Graphology + 章节快照基础设施。核心流程:单页配置 → 后台全维度提取 → 故事框架生成 → 仿真推演 → 推演报告 → 故事草稿。故事框架保存为 MD 文档,可绑定 AI 会话。独立分支 `feature-story-simulation` 开发,不合并 main。
+
+**Tech Stack:** React 19 + TypeScript + Zustand + Tauri 2 + lucide-react + i18next
+
+**关键工程约束:**
+- 独立分支 `feature-story-simulation`,绝对不合并 main
+- 测试版打包命名显示"剧情推演版"
+- 中途 main 分支修复 Bug 后直接上传 GitHub,不能带入此功能代码
+
+---
+
+## 文件结构总览
+
+### 新建文件(核心逻辑层)
+
+| 文件 | 职责 |
+|------|------|
+| `src/lib/novel/story-simulation/types.ts` | 所有类型定义 |
+| `src/lib/novel/story-simulation/story-extractor.ts` | 全维度内容提取器 |
+| `src/lib/novel/story-simulation/agent-profile-builder.ts` | Agent 人格构建器 |
+| `src/lib/novel/story-simulation/story-framework-generator.ts` | 故事框架生成器 |
+| `src/lib/novel/story-simulation/simulation-engine.ts` | 仿真引擎核心循环 |
+| `src/lib/novel/story-simulation/simulation-modes/event-driven.ts` | 事件驱动模式 |
+| `src/lib/novel/story-simulation/simulation-modes/free-emergence.ts` | 自由涌现模式 |
+| `src/lib/novel/story-simulation/simulation-modes/decision-tree.ts` | 决策树模式 |
+| `src/lib/novel/story-simulation/simulation-modes/hybrid.ts` | 混合模式 |
+| `src/lib/novel/story-simulation/simulation-report-agent.ts` | 推演报告生成器(ReACT) |
+| `src/lib/novel/story-simulation/story-draft-generator.ts` | 故事草稿生成器 |
+| `src/lib/novel/story-simulation/framework-store.ts` | 故事框架持久化 |
+| `src/lib/novel/story-simulation/framework-binding.ts` | AI 会话绑定逻辑 |
+| `src/stores/story-simulation-store.ts` | Zustand 状态管理 |
+
+### 新建文件(UI 组件层)
+
+| 文件 | 职责 |
+|------|------|
+| `src/components/novel/story-simulation/story-simulation-view.tsx` | 主视图 |
+| `src/components/novel/story-simulation/simulation-config-panel.tsx` | 单页配置面板 |
+| `src/components/novel/story-simulation/extraction-progress.tsx` | 提取进度展示 |
+| `src/components/novel/story-simulation/framework-confirm-panel.tsx` | 框架确认面板 |
+| `src/components/novel/story-simulation/simulation-progress.tsx` | 仿真进度展示 |
+| `src/components/novel/story-simulation/simulation-report-view.tsx` | 推演报告展示 |
+| `src/components/novel/story-simulation/story-draft-view.tsx` | 故事草稿展示 |
+| `src/components/novel/story-simulation/framework-list.tsx` | 二栏:故事框架列表 |
+| `src/components/novel/story-simulation/framework-binding-dialog.tsx` | AI 会话绑定对话框 |
+| `src/components/novel/story-simulation/simulation-result-list.tsx` | 三栏:推演结果列表 |
+
+### 修改文件
+
+| 文件 | 修改内容 |
+|------|---------|
+| `src/stores/wiki-store.ts` | 新增 `storySimulation` 到 `activeView` 类型 |
+| `src/components/layout/icon-sidebar.tsx` | 新增剧情推演室导航项 |
+| `src/components/layout/content-area.tsx` | 新增 storySimulation case |
+| `src/i18n/zh.json` | 新增剧情推演室相关翻译 |
+| `src/i18n/en.json` | 新增英文翻译 |
+| `scripts/build-portable.mjs` | 测试版打包命名显示"剧情推演版" |
+
+---
+
+## Task 1: 类型定义
+
+**Files:**
+- Create: `src/lib/novel/story-simulation/types.ts`
+
+- [ ] **Step 1: 创建类型定义文件**
+
+```typescript
+// src/lib/novel/story-simulation/types.ts
+
+import type { CharacterAura } from "@/lib/novel/character-aura"
+import type { CognitionState } from "@/lib/novel/character-cognition"
+import type { ChapterSnapshot } from "@/lib/novel/chapter-ingest"
+import type { ForeshadowingStore } from "@/lib/novel/foreshadowing-tracker"
+import type { LlmConfig } from "@/stores/wiki-store"
+
+// ── 仿真模式 ──
+
+export type SimulationMode = "event-driven" | "free-emergence" | "decision-tree" | "hybrid"
+
+// ── 提取结果 ──
+
+export interface ExtractionResult {
+  characters: ExtractedCharacter[]
+  chapterContents: ExtractedChapterContent[]
+  memoryData: ExtractedMemoryData
+  worldRules: string
+  powerSystem: string
+  foreshadowing: ForeshadowingStore | null
+  timeline: string[]
+  outlineContent: string
+  soulDoc: string
+}
+
+export interface ExtractedCharacter {
+  id: string
+  name: string
+  profile: string
+  aura: CharacterAura | null
+  cognition: { knows: string[]; doesNotKnow: string[] } | null
+  soul: string
+  skillContent: string
+}
+
+export interface ExtractedChapterContent {
+  chapterNumber: number
+  title: string
+  summary: string
+  content: string
+}
+
+export interface ExtractedMemoryData {
+  characterStates: string
+  characterCognition: CognitionState | null
+  foreshadowingTracker: ForeshadowingStore | null
+  timeline: string[]
+  canonFacts: string
+  conflicts: string
+}
+
+// ── 故事框架 ──
+
+export interface StoryFramework {
+  id: string
+  title: string
+  premise: string
+  targetWords: number
+  simulationMode: SimulationMode
+  userIdea?: string
+  sourceChapters: number
+  nodes: StoryNode[]
+  createdAt: string
+}
+
+export interface StoryNode {
+  index: number
+  phase: "起" | "承" | "转" | "合"
+  title: string
+  coreConflict: string
+  involvedCharacters: string[]
+  goal: string
+  causeFromPrev: string
+  expectedOutcome: string
+}
+
+// ── Agent ──
+
+export interface NovelAgent {
+  characterId: string
+  name: string
+  profile: string
+  aura: CharacterAura | null
+  cognition: { knows: string[]; doesNotKnow: string[] } | null
+  soul: string
+  currentGoal: string
+  emotionalState: string
+  knownFacts: Set<string>
+  relationships: Map<string, AgentRelation>
+  powerLevel: string
+}
+
+export interface AgentRelation {
+  targetId: string
+  relationType: string
+  sentiment: number // -100 ~ 100
+}
+
+// ── Agent 行为 ──
+
+export type AgentAction =
+  | { type: "speak"; target?: string; content: string }
+  | { type: "act"; content: string }
+  | { type: "react"; target: string; content: string }
+  | { type: "decide"; content: string }
+  | { type: "investigate"; content: string }
+  | { type: "conflict"; target: string; content: string }
+  | { type: "cooperate"; target: string; content: string }
+  | { type: "withhold"; content: string }
+
+// ── 仿真事件 ──
+
+export interface SimulationEvent {
+  type: "agent-action" | "node-complete" | "node-start"
+  agent?: NovelAgent
+  action?: AgentAction
+  round?: number
+  node?: StoryNode
+  stateChanges?: string[]
+  timestamp: string
+}
+
+// ── 推演报告 ──
+
+export interface SimulationReport {
+  frameworkId: string
+  mode: SimulationMode
+  characterAnalyses: CharacterAnalysis[]
+  branches: StoryBranch[]
+  recommendation: string
+  createdAt: string
+}
+
+export interface CharacterAnalysis {
+  characterId: string
+  name: string
+  behaviors: { node: string; action: string; motivation: string }[]
+  stateChanges: string[]
+  consistencyScore: number
+}
+
+export interface StoryBranch {
+  title: string
+  summary: string
+  keyEvents: string[]
+  probability: "high" | "medium" | "low"
+  pros: string
+  cons: string
+  recommendation: boolean
+}
+
+// ── 故事草稿 ──
+
+export interface StoryDraft {
+  branchId: string
+  frameworkId: string
+  chapters: DraftChapter[]
+  totalWords: number
+  createdAt: string
+}
+
+export interface DraftChapter {
+  title: string
+  content: string
+  correspondingNode: number
+}
+
+// ── 框架绑定 ──
+
+export interface FrameworkBinding {
+  frameworkId: string
+  frameworkTitle: string
+  targetChapterCount: number
+  chapterAllocation: ChapterAllocation[]
+  boundAt: string
+}
+
+export interface ChapterAllocation {
+  nodeIndex: number
+  nodeTitle: string
+  startChapter: number
+  endChapter: number
+}
+
+// ── 仿真输入 ──
+
+export interface SimulationInput {
+  agents: NovelAgent[]
+  framework: StoryFramework
+  mode: SimulationMode
+  wordBudget: number
+  llmConfig: LlmConfig
+  userIdea?: string
+  injectionEvent?: string
+}
+
+// ── 仿真配置 ──
+
+export interface SimulationConfig {
+  mode: SimulationMode
+  userIdea?: string
+  targetWords: number
+  sourceChapters: number
+}
+
+// ── 字数预算 ──
+
+export const WORD_BUDGET_PRESETS = [10000, 30000, 50000] as const
+
+export function calcNodeCount(targetWords: number): number {
+  if (targetWords <= 10000) return 4
+  if (targetWords <= 30000) return 6
+  return 8
+}
+
+export function calcMaxRoundsPerNode(wordBudget: number): number {
+  return Math.max(2, Math.floor(wordBudget / 10000))
+}
+
+export function calcMaxAgentsPerRound(activeAgentCount: number): number {
+  return Math.min(8, activeAgentCount)
+}
+```
+
+- [ ] **Step 2: 验证类型可被导入**
+
+Run: `cd C:\QMAI_C\QMAI-main && npx tsc --noEmit src/lib/novel/story-simulation/types.ts 2>&1 | head -20`
+Expected: 无错误或仅有缺少依赖的警告(因为还没有实现)
+
+- [ ] **Step 3: 提交**
+
+```bash
+git add src/lib/novel/story-simulation/types.ts
+git commit -m "feat(story-simulation): 添加剧情推演室类型定义"
+```
+
+---
+
+## Task 2: Zustand 状态管理
+
+**Files:**
+- Modify: `src/stores/wiki-store.ts`(扩展 activeView)
+- Create: `src/stores/story-simulation-store.ts`
+
+- [ ] **Step 1: 扩展 wiki-store 的 activeView 类型**
+
+在 `src/stores/wiki-store.ts` 第 483 行,将 `"storySimulation"` 加入 activeView 联合类型:
+
+```typescript
+// 修改前
+activeView: "wiki" | "sources" | "search" | "graph" | "lint" | "soul" | "dismantling" | "bookAnalysis" | "settings" | "trash" | "reviewCenter"
+
+// 修改后
+activeView: "wiki" | "sources" | "search" | "graph" | "lint" | "soul" | "dismantling" | "bookAnalysis" | "settings" | "trash" | "reviewCenter" | "storySimulation"
+```
+
+- [ ] **Step 2: 创建 story-simulation-store.ts**
+
+```typescript
+// src/stores/story-simulation-store.ts
+
+import { create } from "zustand"
+import type {
+  SimulationMode,
+  StoryFramework,
+  SimulationReport,
+  StoryDraft,
+  ExtractionResult,
+  FrameworkBinding,
+} from "@/lib/novel/story-simulation/types"
+
+export type SimulationPhase =
+  | "idle"
+  | "configuring"
+  | "extracting"
+  | "framework-generating"
+  | "framework-confirming"
+  | "simulating"
+  | "report-generating"
+  | "report-viewing"
+  | "draft-generating"
+  | "draft-viewing"
+
+export interface StorySimulationState {
+  phase: SimulationPhase
+  mode: SimulationMode
+  userIdea: string
+  targetWords: number
+  sourceChapters: 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
+
+  setPhase: (phase: SimulationPhase) => void
+  setMode: (mode: SimulationMode) => void
+  setUserIdea: (idea: string) => void
+  setTargetWords: (words: number) => void
+  setSourceChapters: (count: 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
+  reset: () => void
+}
+
+export const useStorySimulationStore = create<StorySimulationState>((set) => ({
+  phase: "idle",
+  mode: "event-driven",
+  userIdea: "",
+  targetWords: 10000,
+  sourceChapters: 10,
+  extractionResult: null,
+  currentFramework: null,
+  currentReport: null,
+  currentDraft: null,
+  frameworks: [],
+  selectedFrameworkId: null,
+  binding: null,
+  error: null,
+  progress: 0,
+  progressLabel: "",
+
+  setPhase: (phase) => set({ phase }),
+  setMode: (mode) => set({ mode }),
+  setUserIdea: (userIdea) => set({ userIdea }),
+  setTargetWords: (targetWords) => set({ targetWords }),
+  setSourceChapters: (sourceChapters) => set({ sourceChapters }),
+  setExtractionResult: (extractionResult) => set({ extractionResult }),
+  setCurrentFramework: (currentFramework) => set({ currentFramework }),
+  setCurrentReport: (currentReport) => set({ currentReport }),
+  setCurrentDraft: (currentDraft) => set({ currentDraft }),
+  setFrameworks: (frameworks) => set({ frameworks }),
+  setSelectedFrameworkId: (selectedFrameworkId) => set({ selectedFrameworkId }),
+  setBinding: (binding) => set({ binding }),
+  setError: (error) => set({ error }),
+  setProgress: (progress, progressLabel) => set({ progress, progressLabel }),
+  reset: () =>
+    set({
+      phase: "idle",
+      extractionResult: null,
+      currentFramework: null,
+      currentReport: null,
+      currentDraft: null,
+      error: null,
+      progress: 0,
+      progressLabel: "",
+    }),
+}))
+```
+
+- [ ] **Step 3: 验证类型检查**
+
+Run: `cd C:\QMAI_C\QMAI-main && npx tsc --noEmit --pretty 2>&1 | grep -i "story-simulation" | head -5`
+Expected: 无新增错误
+
+- [ ] **Step 4: 提交**
+
+```bash
+git add src/stores/wiki-store.ts src/stores/story-simulation-store.ts
+git commit -m "feat(story-simulation): 添加状态管理和 activeView 扩展"
+```
+
+---
+
+## Task 3: 图标栏导航入口
+
+**Files:**
+- Modify: `src/components/layout/icon-sidebar.tsx`
+- Modify: `src/i18n/zh.json`
+- Modify: `src/i18n/en.json`
+
+- [ ] **Step 1: 在 icon-sidebar.tsx 添加导航项**
+
+在 `src/components/layout/icon-sidebar.tsx` 第 1 行的 import 中添加 `Drama` 图标:
+
+```typescript
+// 修改前
+import {
+  FileText, FolderOpen, Search, Network, Brain, Settings, ArrowLeftRight, Sun, Moon, Monitor, Trash2, Sparkles, LayoutDashboard, BookOpen,
+} from "lucide-react"
+
+// 修改后
+import {
+  FileText, FolderOpen, Search, Network, Brain, Settings, ArrowLeftRight, Sun, Moon, Monitor, Trash2, Sparkles, LayoutDashboard, BookOpen, Drama,
+} from "lucide-react"
+```
+
+在第 28 行的 NAV_ITEMS 数组末尾(reviewCenter 后面)添加:
+
+```typescript
+// 在 reviewCenter 行后面添加
+  { view: "storySimulation", icon: Drama, labelKey: "novel.nav.storySimulation" },
+```
+
+- [ ] **Step 2: 在 zh.json 添加翻译**
+
+在 `src/i18n/zh.json` 的 `novel.nav` 对象中添加:
+
+```json
+"storySimulation": "剧情推演室"
+```
+
+- [ ] **Step 3: 在 en.json 添加翻译**
+
+在 `src/i18n/en.json` 的 `novel.nav` 对象中添加:
+
+```json
+"storySimulation": "Story Simulation"
+```
+
+- [ ] **Step 4: 验证类型检查**
+
+Run: `cd C:\QMAI_C\QMAI-main && npx tsc --noEmit --pretty 2>&1 | grep -i "icon-sidebar\|storySimulation" | head -5`
+Expected: 无错误
+
+- [ ] **Step 5: 提交**
+
+```bash
+git add src/components/layout/icon-sidebar.tsx src/i18n/zh.json src/i18n/en.json
+git commit -m "feat(story-simulation): 添加图标栏导航入口"
+```
+
+---
+
+## Task 4: 内容区域路由
+
+**Files:**
+- Modify: `src/components/layout/content-area.tsx`
+
+- [ ] **Step 1: 在 content-area.tsx 添加 lazy import 和 case**
+
+在 `src/components/layout/content-area.tsx` 第 48 行(BookAnalysisView lazy import 后面)添加:
+
+```typescript
+const StorySimulationView = lazy(async () => {
+  const mod = await import("@/components/novel/story-simulation/story-simulation-view")
+  return { default: mod.StorySimulationView }
+})
+```
+
+在 switch 语句中(`case "bookAnalysis":` 后面、`default:` 前面)添加:
+
+```typescript
+      case "storySimulation":
+        content = (
+          <Suspense fallback={<LoadingView />}>
+            <StorySimulationView />
+          </Suspense>
+        )
+        break
+```
+
+- [ ] **Step 2: 创建占位主视图组件**
+
+```typescript
+// src/components/novel/story-simulation/story-simulation-view.tsx
+
+import { useWikiStore } from "@/stores/wiki-store"
+import { useStorySimulationStore } from "@/stores/story-simulation-store"
+import { useTranslation } from "react-i18next"
+
+export function StorySimulationView() {
+  const { t } = useTranslation()
+  const projectPath = useWikiStore((s) => s.projectPath)
+
+  return (
+    <div className="flex h-full flex-col items-center justify-center gap-4 p-8">
+      <h2 className="text-2xl font-bold">{t("storySimulation.title")}</h2>
+      <p className="text-muted-foreground">{t("storySimulation.description")}</p>
+      <p className="text-sm text-muted-foreground">项目路径: {projectPath}</p>
+    </div>
+  )
+}
+```
+
+- [ ] **Step 3: 在 zh.json 添加 storySimulation 翻译根节点**
+
+在 `src/i18n/zh.json` 根级别添加(与 `novel` 同级):
+
+```json
+"storySimulation": {
+  "title": "剧情推演室",
+  "description": "通过多 Agent 仿真推演小说角色在给定情境下的行为选择和剧情走向",
+  "selectMode": "选择仿真模式",
+  "modeEventDriven": "事件驱动",
+  "modeFreeEmergence": "自由涌现",
+  "modeDecisionTree": "决策树",
+  "modeHybrid": "混合模式",
+  "modeEventDrivenDesc": "注入一个触发事件,推演各角色反应和连锁效应",
+  "modeFreeEmergenceDesc": "让角色根据目标自由互动,涌现剧情走向",
+  "modeDecisionTreeDesc": "为关键角色生成多个决策分支,对比连锁反应",
+  "modeHybridDesc": "自由涌现与事件驱动结合,生成多条可能分支",
+  "yourIdea": "你的思路(可选)",
+  "yourIdeaPlaceholder": "输入你对剧情走向的想法或约束...",
+  "targetWords": "目标字数",
+  "words10k": "10000字",
+  "words30k": "30000字",
+  "words50k": "50000字",
+  "wordsCustom": "自定义",
+  "sourceChapters": "提取章节数量",
+  "recentChapters": "最近",
+  "chapters": "章",
+  "startExtract": "开始提取并生成框架",
+  "extracting": "正在提取内容...",
+  "extractProgress": "提取进度",
+  "frameworkTitle": "故事框架",
+  "frameworkPremise": "前提",
+  "frameworkNodes": "故事节点",
+  "regenerateFramework": "重新生成框架",
+  "saveFramework": "保存框架",
+  "confirmFramework": "确认框架,开始推演",
+  "simulating": "正在推演...",
+  "simulationProgress": "推演进度",
+  "reportTitle": "推演报告",
+  "characterAnalysis": "角色行为分析",
+  "storyBranches": "走向分支",
+  "recommendation": "综合推荐",
+  "resimulate": "重新推演",
+  "generateDraft": "选择分支,生成草稿",
+  "draftTitle": "故事草稿",
+  "exportDraft": "导出",
+  "copyAll": "复制全部",
+  "importToChapters": "导入到章节",
+  "discard": "丢弃",
+  "frameworkList": "故事框架",
+  "newFramework": "新建故事框架",
+  "bindToChat": "绑定到 AI 会话",
+  "unbindFromChat": "取消绑定",
+  "bindingTitle": "绑定故事框架到 AI 会话",
+  "selectFramework": "选择框架",
+  "targetChapterCount": "生成章节数",
+  "confirmBinding": "确认绑定",
+  "bindingHint": "绑定后,AI 会话将按此框架分析指定章节数如何推动故事发展",
+  "noFrameworks": "暂无故事框架,点击上方按钮开始创建",
+  "noResults": "暂无推演结果",
+  "phase": "阶段",
+  "conflict": "冲突",
+  "characters": "角色",
+  "goal": "目标",
+  "cause": "起因",
+  "expectedOutcome": "预期走向",
+  "consistencyScore": "人设一致性",
+  "probability": "概率",
+  "probabilityHigh": "高",
+  "probabilityMedium": "中",
+  "probabilityLow": "低",
+  "pros": "优势",
+  "cons": "不足",
+  "actualWords": "实际字数",
+  "totalWords": "总字数",
+  "error": "错误",
+  "retry": "重试"
+}
+```
+
+在 `src/i18n/en.json` 根级别添加对应英文翻译:
+
+```json
+"storySimulation": {
+  "title": "Story Simulation Room",
+  "description": "Simulate character behavior and plot development through multi-agent simulation",
+  "selectMode": "Select Simulation Mode",
+  "modeEventDriven": "Event-Driven",
+  "modeFreeEmergence": "Free Emergence",
+  "modeDecisionTree": "Decision Tree",
+  "modeHybrid": "Hybrid",
+  "modeEventDrivenDesc": "Inject a trigger event, simulate character reactions",
+  "modeFreeEmergenceDesc": "Let characters interact freely, observe emergent plot",
+  "modeDecisionTreeDesc": "Generate decision branches for key characters",
+  "modeHybridDesc": "Combine free emergence with event injection",
+  "yourIdea": "Your Idea (Optional)",
+  "yourIdeaPlaceholder": "Enter your thoughts on plot direction...",
+  "targetWords": "Target Word Count",
+  "words10k": "10,000 words",
+  "words30k": "30,000 words",
+  "words50k": "50,000 words",
+  "wordsCustom": "Custom",
+  "sourceChapters": "Source Chapters",
+  "recentChapters": "Recent",
+  "chapters": "chapters",
+  "startExtract": "Start Extraction & Generate Framework",
+  "extracting": "Extracting content...",
+  "extractProgress": "Extraction Progress",
+  "frameworkTitle": "Story Framework",
+  "frameworkPremise": "Premise",
+  "frameworkNodes": "Story Nodes",
+  "regenerateFramework": "Regenerate Framework",
+  "saveFramework": "Save Framework",
+  "confirmFramework": "Confirm & Start Simulation",
+  "simulating": "Simulating...",
+  "simulationProgress": "Simulation Progress",
+  "reportTitle": "Simulation Report",
+  "characterAnalysis": "Character Analysis",
+  "storyBranches": "Story Branches",
+  "recommendation": "Recommendation",
+  "resimulate": "Re-simulate",
+  "generateDraft": "Select Branch & Generate Draft",
+  "draftTitle": "Story Draft",
+  "exportDraft": "Export",
+  "copyAll": "Copy All",
+  "importToChapters": "Import to Chapters",
+  "discard": "Discard",
+  "frameworkList": "Story Frameworks",
+  "newFramework": "New Framework",
+  "bindToChat": "Bind to AI Chat",
+  "unbindFromChat": "Unbind",
+  "bindingTitle": "Bind Story Framework to AI Chat",
+  "selectFramework": "Select Framework",
+  "targetChapterCount": "Target Chapter Count",
+  "confirmBinding": "Confirm Binding",
+  "bindingHint": "After binding, AI chat will follow this framework to analyze how chapters advance the story",
+  "noFrameworks": "No frameworks yet. Click the button above to create one.",
+  "noResults": "No simulation results yet",
+  "phase": "Phase",
+  "conflict": "Conflict",
+  "characters": "Characters",
+  "goal": "Goal",
+  "cause": "Cause",
+  "expectedOutcome": "Expected Outcome",
+  "consistencyScore": "Consistency Score",
+  "probability": "Probability",
+  "probabilityHigh": "High",
+  "probabilityMedium": "Medium",
+  "probabilityLow": "Low",
+  "pros": "Pros",
+  "cons": "Cons",
+  "actualWords": "Actual Words",
+  "totalWords": "Total Words",
+  "error": "Error",
+  "retry": "Retry"
+}
+```
+
+- [ ] **Step 4: 验证 dev server 可启动**
+
+Run: `cd C:\QMAI_C\QMAI-main && npm run dev`
+Expected: dev server 正常启动,无编译错误
+
+- [ ] **Step 5: 提交**
+
+```bash
+git add src/components/layout/content-area.tsx src/components/novel/story-simulation/story-simulation-view.tsx src/i18n/zh.json src/i18n/en.json
+git commit -m "feat(story-simulation): 添加内容区域路由和占位主视图"
+```
+
+---
+
+## Task 5: 全维度内容提取器
+
+**Files:**
+- Create: `src/lib/novel/story-simulation/story-extractor.ts`
+
+- [ ] **Step 1: 创建提取器**
+
+```typescript
+// src/lib/novel/story-simulation/story-extractor.ts
+
+import { readFile, listDirectory } from "@/commands/fs"
+import { normalizePath, joinPath } from "@/lib/path-utils"
+import { useWikiStore } from "@/stores/wiki-store"
+import { readSoulDoc } from "@/lib/novel/soul-doc"
+import { loadCognitionState } from "@/lib/novel/character-cognition"
+import { loadForeshadowingTracker } from "@/lib/novel/foreshadowing-tracker"
+import { loadTimeline } from "@/lib/novel/timeline"
+import { loadCharacterStates } from "@/lib/novel/character-state"
+import { loadSnapshot, listSnapshots, type ChapterSnapshot } from "@/lib/novel/chapter-ingest"
+import { parseFrontmatter } from "@/lib/frontmatter"
+import { searchWiki } from "@/lib/search"
+import { listAuras } from "@/lib/novel/character-aura"
+import type { ExtractionResult, ExtractedCharacter, ExtractedChapterContent, ExtractedMemoryData } from "./types"
+
+export interface ExtractionOptions {
+  sourceChapters: number
+  onProgress?: (progress: number, label: string) => void
+}
+
+export async function extractStoryContent(
+  projectPath: string,
+  options: ExtractionOptions,
+): Promise<ExtractionResult> {
+  const pp = normalizePath(projectPath)
+  const { sourceChapters, onProgress } = options
+
+  onProgress?.(5, "正在读取大纲...")
+
+  // 1. 读取大纲
+  const outlineContent = await readOutline(pp)
+
+  onProgress?.(15, "正在读取项目灵魂...")
+
+  // 2. 读取灵魂文档
+  const soulDoc = await readSoulDoc(pp)
+
+  onProgress?.(25, "正在读取章节内容...")
+
+  // 3. 读取最近N章内容
+  const chapterContents = await readRecentChapters(pp, sourceChapters)
+
+  onProgress?.(40, "正在读取记忆库...")
+
+  // 4. 读取记忆库
+  const memoryData = await readMemoryData(pp)
+
+  onProgress?.(55, "正在读取角色数据...")
+
+  // 5. 读取角色完整特征(profile + aura + cognition + soul + skill)
+  const characters = await readCharacterData(pp, chapterContents)
+
+  onProgress?.(70, "正在提取世界规则...")
+
+  // 6. 提取世界规则和力量体系(从大纲中)
+  const { worldRules, powerSystem } = extractWorldRules(outlineContent)
+
+  onProgress?.(85, "正在读取伏笔和时间线...")
+
+  // 7. 伏笔状态
+  const foreshadowing = memoryData.foreshadowingTracker
+
+  // 8. 时间线
+  const timeline = memoryData.timeline
+
+  onProgress?.(100, "提取完成")
+
+  return {
+    characters,
+    chapterContents,
+    memoryData,
+    worldRules,
+    powerSystem,
+    foreshadowing,
+    timeline,
+    outlineContent,
+    soulDoc,
+  }
+}
+
+async function readOutline(projectPath: string): Promise<string> {
+  try {
+    const items = await listDirectory(`${projectPath}/wiki/outlines`)
+    const outlines: string[] = []
+    for (const item of items) {
+      if (item.type === "file" && item.name.endsWith(".md")) {
+        const content = await readFile(`${projectPath}/wiki/outlines/${item.name}`)
+        outlines.push(`## ${item.name}\n\n${content}`)
+      }
+    }
+    return outlines.join("\n\n")
+  } catch {
+    return ""
+  }
+}
+
+async function readRecentChapters(projectPath: string, count: number): Promise<ExtractedChapterContent[]> {
+  try {
+    const items = await listDirectory(`${projectPath}/wiki/chapters`)
+    const chapterFiles = items
+      .filter((item) => item.type === "file" && item.name.endsWith(".md"))
+      .sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }))
+
+    const recentFiles = chapterFiles.slice(-count)
+    const results: ExtractedChapterContent[] = []
+
+    for (const file of recentFiles) {
+      const raw = await readFile(`${projectPath}/wiki/chapters/${file.name}`)
+      const { body, frontmatter } = parseFrontmatter(raw)
+      const chapterNumber = parseInt(String(frontmatter?.chapter_number || "0"), 10)
+      const title = String(frontmatter?.title || file.name.replace(/\.md$/, ""))
+
+      // 获取章节快照摘要
+      let summary = ""
+      try {
+        const snapshot = await loadSnapshot(projectPath, chapterNumber)
+        if (snapshot) {
+          summary = snapshot.summary
+        }
+      } catch {}
+
+      results.push({
+        chapterNumber,
+        title,
+        summary,
+        content: body,
+      })
+    }
+
+    return results
+  } catch {
+    return []
+  }
+}
+
+async function readMemoryData(projectPath: string): Promise<ExtractedMemoryData> {
+  const pp = normalizePath(projectPath)
+  const memoryDir = `${pp}/.qmai`
+
+  let characterStates = ""
+  let characterCognition = null
+  let foreshadowingTracker = null
+  let timeline: string[] = []
+  let canonFacts = ""
+  let conflicts = ""
+
+  try {
+    characterStates = await readFile(`${memoryDir}/character-states.md`)
+  } catch {}
+
+  try {
+    characterCognition = await loadCognitionState(pp)
+  } catch {}
+
+  try {
+    foreshadowingTracker = await loadForeshadowingTracker(pp)
+  } catch {}
+
+  try {
+    const tl = await loadTimeline(pp)
+    timeline = tl.entries.map((e) => `第${e.chapterNumber}章: ${e.event}`)
+  } catch {}
+
+  try {
+    canonFacts = await readFile(`${memoryDir}/canon-facts.md`)
+  } catch {}
+
+  try {
+    conflicts = await readFile(`${memoryDir}/conflicts.md`)
+  } catch {}
+
+  return {
+    characterStates,
+    characterCognition,
+    foreshadowingTracker,
+    timeline,
+    canonFacts,
+    conflicts,
+  }
+}
+
+async function readCharacterData(
+  projectPath: string,
+  chapterContents: ExtractedChapterContent[],
+): Promise<ExtractedCharacter[]> {
+  const characters: ExtractedCharacter[] = []
+
+  // 从章节内容中提取角色名
+  const characterNames = new Set<string>()
+  for (const chapter of chapterContents) {
+    try {
+      const snapshot = await loadSnapshot(projectPath, chapter.chapterNumber)
+      if (snapshot) {
+        for (const name of snapshot.characters) {
+          characterNames.add(name)
+        }
+      }
+    } catch {}
+  }
+
+  // 读取角色光环
+  let auras: Awaited<ReturnType<typeof listAuras>> = []
+  try {
+    auras = await listAuras(projectPath)
+  } catch {}
+
+  // 读取角色认知
+  let cognitionState = null
+  try {
+    cognitionState = await loadCognitionState(projectPath)
+  } catch {}
+
+  for (const name of characterNames) {
+    const aura = auras.find((a) => a.name === name) || null
+    const cognition = cognitionState?.characters.find((c) => c.character === name) || null
+
+    characters.push({
+      id: name,
+      name,
+      profile: aura?.sourceNote || "",
+      aura,
+      cognition: cognition ? { knows: cognition.knows, doesNotKnow: cognition.doesNotKnow } : null,
+      soul: "",
+      skillContent: aura?.corpus || "",
+    })
+  }
+
+  return characters
+}
+
+function extractWorldRules(outlineContent: string): { worldRules: string; powerSystem: string } {
+  // 简单提取:从大纲内容中寻找世界规则和力量体系相关段落
+  const lines = outlineContent.split("\n")
+  const worldRules: string[] = []
+  const powerSystem: string[] = []
+
+  let currentSection = ""
+  for (const line of lines) {
+    const trimmed = line.trim()
+    if (/^#{1,3}\s.*(世界观|世界规则|设定|规则)/.test(trimmed)) {
+      currentSection = "world"
+    } else if (/^#{1,3}\s.*(力量|修炼|等级|能力|体系)/.test(trimmed)) {
+      currentSection = "power"
+    } else if (/^#{1,3}\s/.test(trimmed)) {
+      currentSection = ""
+    }
+
+    if (currentSection === "world" && trimmed) {
+      worldRules.push(line)
+    } else if (currentSection === "power" && trimmed) {
+      powerSystem.push(line)
+    }
+  }
+
+  return {
+    worldRules: worldRules.join("\n"),
+    powerSystem: powerSystem.join("\n"),
+  }
+}
+```
+
+- [ ] **Step 2: 验证类型检查**
+
+Run: `cd C:\QMAI_C\QMAI-main && npx tsc --noEmit --pretty 2>&1 | grep -i "story-extractor" | head -5`
+Expected: 无错误
+
+- [ ] **Step 3: 提交**
+
+```bash
+git add src/lib/novel/story-simulation/story-extractor.ts
+git commit -m "feat(story-simulation): 实现全维度内容提取器"
+```
+
+---
+
+## Task 6: Agent 人格构建器
+
+**Files:**
+- Create: `src/lib/novel/story-simulation/agent-profile-builder.ts`
+
+- [ ] **Step 1: 创建 Agent 构建器**
+
+```typescript
+// src/lib/novel/story-simulation/agent-profile-builder.ts
+
+import type { NovelAgent, ExtractionResult, ExtractedCharacter, StoryFramework } from "./types"
+
+export function buildAgents(
+  extraction: ExtractionResult,
+  framework: StoryFramework,
+): NovelAgent[] {
+  // 收集框架中涉及的所有角色
+  const involvedNames = new Set<string>()
+  for (const node of framework.nodes) {
+    for (const char of node.involvedCharacters) {
+      involvedNames.add(char)
+    }
+  }
+
+  // 如果框架没有指定角色,使用所有提取到的角色
+  const targetNames = involvedNames.size > 0
+    ? Array.from(involvedNames)
+    : extraction.characters.map((c) => c.name)
+
+  const agents: NovelAgent[] = []
+
+  for (const name of targetNames) {
+    const char = extraction.characters.find((c) => c.name === name || c.id === name)
+    if (!char) continue
+
+    const agent: NovelAgent = {
+      characterId: char.id,
+      name: char.name,
+      profile: char.profile,
+      aura: char.aura,
+      cognition: char.cognition,
+      soul: char.soul,
+      currentGoal: inferGoalFromFramework(char.name, framework),
+      emotionalState: "neutral",
+      knownFacts: new Set(char.cognition?.knows || []),
+      relationships: new Map(),
+      powerLevel: "",
+    }
+
+    // 初始化角色间关系
+    for (const other of targetNames) {
+      if (other !== name) {
+        agent.relationships.set(other, {
+          targetId: other,
+          relationType: "neutral",
+          sentiment: 0,
+        })
+      }
+    }
+
+    agents.push(agent)
+  }
+
+  return agents
+}
+
+function inferGoalFromFramework(characterName: string, framework: StoryFramework): string {
+  // 从框架的第一个节点中推断角色目标
+  for (const node of framework.nodes) {
+    if (node.involvedCharacters.includes(characterName)) {
+      return `${node.goal}(${node.title})`
+    }
+  }
+  return "推动故事发展"
+}
+
+export function buildAgentContext(
+  agent: NovelAgent,
+  node: StoryFramework["nodes"][number],
+  recentEvents: string[],
+  worldRules: string,
+): string {
+  const parts: string[] = []
+
+  parts.push(`## 当前场景`)
+  parts.push(`节点:${node.phase} · ${node.title}`)
+  parts.push(`核心冲突:${node.coreConflict}`)
+  parts.push(`目标:${node.goal}`)
+
+  parts.push(`\n## 你的身份`)
+  parts.push(`姓名:${agent.name}`)
+  if (agent.profile) {
+    parts.push(`档案:${agent.profile}`)
+  }
+  if (agent.aura?.expressionDna) {
+    parts.push(`表达特征:${agent.aura.expressionDna}`)
+  }
+  if (agent.aura?.mentalModel) {
+    parts.push(`心智模型:${agent.aura.mentalModel}`)
+  }
+  if (agent.aura?.decisionHeuristics) {
+    parts.push(`决策启发式:${agent.aura.decisionHeuristics}`)
+  }
+  if (agent.aura?.valueAntiPatterns) {
+    parts.push(`价值观反模式:${agent.aura.valueAntiPatterns}`)
+  }
+
+  parts.push(`\n## 你的认知边界`)
+  if (agent.cognition) {
+    parts.push(`你知道的:${agent.knownFacts.size > 0 ? Array.from(agent.knownFacts).join(";") : "无"}`)
+    parts.push(`你不知道的:${agent.cognition.doesNotKnow.join(";") || "无"}`)
+  }
+
+  parts.push(`\n## 你的当前状态`)
+  parts.push(`目标:${agent.currentGoal}`)
+  parts.push(`情绪:${agent.emotionalState}`)
+
+  parts.push(`\n## 人际关系`)
+  for (const [name, rel] of agent.relationships) {
+    parts.push(`与${name}:${rel.relationType}(好感度${rel.sentiment})`)
+  }
+
+  parts.push(`\n## 近期事件`)
+  parts.push(recentEvents.join("\n") || "无")
+
+  if (worldRules) {
+    parts.push(`\n## 世界规则`)
+    parts.push(worldRules)
+  }
+
+  return parts.join("\n")
+}
+```
+
+- [ ] **Step 2: 验证类型检查**
+
+Run: `cd C:\QMAI_C\QMAI-main && npx tsc --noEmit --pretty 2>&1 | grep -i "agent-profile" | head -5`
+Expected: 无错误
+
+- [ ] **Step 3: 提交**
+
+```bash
+git add src/lib/novel/story-simulation/agent-profile-builder.ts
+git commit -m "feat(story-simulation): 实现 Agent 人格构建器"
+```
+
+---
+
+## Task 7: 故事框架生成器
+
+**Files:**
+- Create: `src/lib/novel/story-simulation/story-framework-generator.ts`
+
+- [ ] **Step 1: 创建框架生成器**
+
+```typescript
+// src/lib/novel/story-simulation/story-framework-generator.ts
+
+import { streamChat, type ChatMessage } from "@/lib/llm-client"
+import type { LlmConfig } from "@/stores/wiki-store"
+import type { ExtractionResult, StoryFramework, StoryNode, SimulationMode } from "./types"
+import { calcNodeCount } from "./types"
+
+export interface FrameworkGenerationOptions {
+  extraction: ExtractionResult
+  mode: SimulationMode
+  targetWords: number
+  userIdea?: string
+  llmConfig: LlmConfig
+  onProgress?: (label: string) => void
+}
+
+export async function generateStoryFramework(
+  options: FrameworkGenerationOptions,
+): Promise<StoryFramework> {
+  const { extraction, mode, targetWords, userIdea, llmConfig, onProgress } = options
+
+  onProgress?.("正在分析已写内容...")
+
+  const nodeCount = calcNodeCount(targetWords)
+  const prompt = buildFrameworkPrompt(extraction, mode, targetWords, userIdea, nodeCount)
+
+  onProgress?.("正在生成故事框架...")
+
+  const messages: ChatMessage[] = [
+    { role: "system", content: FRAMEWORK_SYSTEM_PROMPT },
+    { role: "user", content: prompt },
+  ]
+
+  let result = ""
+  await streamChat(
+    llmConfig,
+    messages,
+    {
+      onToken: (token) => {
+        result += token
+      },
+      onDone: () => {},
+      onError: (error) => {
+        throw error
+      },
+    },
+  )
+
+  onProgress?.("正在解析框架...")
+
+  const framework = parseFramework(result, mode, targetWords, userIdea, extraction.chapterContents.length)
+
+  return framework
+}
+
+const FRAMEWORK_SYSTEM_PROMPT = `你是一位专业的小说策划编辑,精通故事结构学。你的任务是分析小说已写内容,生成一个遵循"起承转合"结构的故事框架。
+
+要求:
+1. 框架必须包含前提(当前故事进展到什么程度)
+2. 框架包含若干关键节点,每个节点标注"起/承/转/合"阶段
+3. 每个节点必须包含:标题、核心冲突、涉及角色、推进目标、与上一节点的因果关系、预期走向
+4. 节点间必须有明确的因果链
+5. 所有内容必须符合小说写作结构
+
+输出格式为 JSON:
+\`\`\`json
+{
+  "premise": "当前故事进展的总结",
+  "nodes": [
+    {
+      "phase": "起",
+      "title": "节点标题",
+      "coreConflict": "核心冲突描述",
+      "involvedCharacters": ["角色1", "角色2"],
+      "goal": "该节点要推进的目标",
+      "causeFromPrev": "与上一节点的因果关系(第一个节点填'故事起点')",
+      "expectedOutcome": "预期走向"
+    }
+  ]
+}
+\`\`\`
+
+只输出 JSON,不要输出其他内容。`
+
+function buildFrameworkPrompt(
+  extraction: ExtractionResult,
+  mode: SimulationMode,
+  targetWords: number,
+  userIdea: string | undefined,
+  nodeCount: number,
+): string {
+  const parts: string[] = []
+
+  parts.push(`## 任务\n根据以下小说内容,生成一个包含 ${nodeCount} 个关键节点的故事框架。目标字数:${targetWords} 字。`)
+
+  if (userIdea) {
+    parts.push(`\n## 作者思路\n${userIdea}`)
+  }
+
+  parts.push(`\n## 当前已写内容概要`)
+  for (const chapter of extraction.chapterContents.slice(-5)) {
+    parts.push(`### 第${chapter.chapterNumber}章 ${chapter.title}`)
+    parts.push(chapter.summary || chapter.content.slice(0, 500))
+  }
+
+  parts.push(`\n## 角色信息`)
+  for (const char of extraction.characters) {
+    parts.push(`### ${char.name}`)
+    if (char.profile) parts.push(`档案:${char.profile}`)
+    if (char.aura?.expressionDna) parts.push(`表达特征:${char.aura.expressionDna}`)
+    if (char.aura?.mentalModel) parts.push(`心智模型:${char.aura.mentalModel}`)
+    if (char.aura?.decisionHeuristics) parts.push(`决策方式:${char.aura.decisionHeuristics}`)
+    if (char.cognition) {
+      parts.push(`已知信息:${char.cognition.knows.join(";") || "无"}`)
+      parts.push(`未知信息:${char.cognition.doesNotKnow.join(";") || "无"}`)
+    }
+  }
+
+  if (extraction.worldRules) {
+    parts.push(`\n## 世界规则\n${extraction.worldRules}`)
+  }
+
+  if (extraction.powerSystem) {
+    parts.push(`\n## 力量体系\n${extraction.powerSystem}`)
+  }
+
+  if (extraction.foreshadowing && extraction.foreshadowing.items.length > 0) {
+    parts.push(`\n## 伏笔状态`)
+    for (const f of extraction.foreshadowing.items) {
+      parts.push(`- ${f.name}(${f.status}):${f.description}`)
+    }
+  }
+
+  if (extraction.timeline.length > 0) {
+    parts.push(`\n## 时间线\n${extraction.timeline.join("\n")}`)
+  }
+
+  if (extraction.soulDoc) {
+    parts.push(`\n## 项目灵魂\n${extraction.soulDoc}`)
+  }
+
+  return parts.join("\n")
+}
+
+function parseFramework(
+  jsonText: string,
+  mode: SimulationMode,
+  targetWords: number,
+  userIdea: string | undefined,
+  sourceChapters: number,
+): StoryFramework {
+  // 提取 JSON 块
+  const jsonMatch = jsonText.match(/```json\s*([\s\S]*?)```/) || jsonText.match(/\{[\s\S]*\}/)
+  const jsonStr = jsonMatch ? (jsonMatch[1] || jsonMatch[0]).trim() : jsonText.trim()
+
+  const parsed = JSON.parse(jsonStr)
+
+  const nodes: StoryNode[] = (parsed.nodes || []).map((node: any, index: number) => ({
+    index,
+    phase: node.phase || "起",
+    title: node.title || `节点${index + 1}`,
+    coreConflict: node.coreConflict || "",
+    involvedCharacters: Array.isArray(node.involvedCharacters) ? node.involvedCharacters : [],
+    goal: node.goal || "",
+    causeFromPrev: node.causeFromPrev || "",
+    expectedOutcome: node.expectedOutcome || "",
+  }))
+
+  return {
+    id: `framework-${Date.now()}`,
+    title: `故事框架-${new Date().toLocaleDateString("zh-CN")}`,
+    premise: parsed.premise || "",
+    targetWords,
+    simulationMode: mode,
+    userIdea,
+    sourceChapters,
+    nodes,
+    createdAt: new Date().toISOString(),
+  }
+}
+```
+
+- [ ] **Step 2: 验证类型检查**
+
+Run: `cd C:\QMAI_C\QMAI-main && npx tsc --noEmit --pretty 2>&1 | grep -i "story-framework" | head -5`
+Expected: 无错误
+
+- [ ] **Step 3: 提交**
+
+```bash
+git add src/lib/novel/story-simulation/story-framework-generator.ts
+git commit -m "feat(story-simulation): 实现故事框架生成器"
+```
+
+---
+
+## Task 8: 仿真引擎核心
+
+**Files:**
+- Create: `src/lib/novel/story-simulation/simulation-engine.ts`
+
+- [ ] **Step 1: 创建仿真引擎**
+
+```typescript
+// src/lib/novel/story-simulation/simulation-engine.ts
+
+import { streamChat, type ChatMessage } from "@/lib/llm-client"
+import type { LlmConfig } from "@/stores/wiki-store"
+import { buildAgentContext } from "./agent-profile-builder"
+import type {
+  NovelAgent,
+  AgentAction,
+  SimulationEvent,
+  SimulationInput,
+  StoryNode,
+  ExtractionResult,
+} from "./types"
+import { calcMaxRoundsPerNode, calcMaxAgentsPerRound } from "./types"
+
+export interface SimulationCallbacks {
+  onEvent: (event: SimulationEvent) => void
+  onProgress: (progress: number, label: string) => void
+  onComplete: (events: SimulationEvent[]) => void
+  onError: (error: Error) => void
+}
+
+export async function runSimulation(
+  input: SimulationInput,
+  extraction: ExtractionResult,
+  callbacks: SimulationCallbacks,
+  signal?: AbortSignal,
+): Promise<SimulationEvent[]> {
+  const { agents, framework, mode, wordBudget, llmConfig, userIdea, injectionEvent } = input
+  const events: SimulationEvent[] = []
+  const maxRounds = calcMaxRoundsPerNode(wordBudget)
+  const totalNodes = framework.nodes.length
+
+  try {
+    for (let nodeIndex = 0; nodeIndex < totalNodes; nodeIndex++) {
+      const node = framework.nodes[nodeIndex]
+      const nodeProgress = ((nodeIndex / totalNodes) * 100)
+
+      callbacks.onProgress(nodeProgress, `正在推演节点 ${nodeIndex + 1}/${totalNodes}:${node.title}`)
+
+      // 发出节点开始事件
+      const startEvent: SimulationEvent = {
+        type: "node-start",
+        node,
+        timestamp: new Date().toISOString(),
+      }
+      events.push(startEvent)
+      callbacks.onEvent(startEvent)
+
+      // 确定本轮参与角色
+      const activeAgents = agents.filter((a) =>
+        node.involvedCharacters.includes(a.name),
+      )
+      const maxAgents = calcMaxAgentsPerRound(activeAgents.length)
+      const participatingAgents = activeAgents.slice(0, maxAgents)
+
+      const recentEvents: string[] = []
+
+      for (let round = 0; round < maxRounds; round++) {
+        for (const agent of participatingAgents) {
+          if (signal?.aborted) {
+            callbacks.onComplete(events)
+            return events
+          }
+
+          const context = buildAgentContext(agent, node, recentEvents, extraction.worldRules)
+          const action = await decideAgentAction(agent, context, llmConfig, mode, injectionEvent, signal)
+
+          if (!action) continue
+
+          // 应用行为效果
+          applyAction(agent, action, agents)
+
+          const event: SimulationEvent = {
+            type: "agent-action",
+            agent,
+            action,
+            round,
+            node,
+            timestamp: new Date().toISOString(),
+          }
+          events.push(event)
+          callbacks.onEvent(event)
+
+          const eventDesc = formatActionForContext(agent.name, action)
+          recentEvents.push(eventDesc)
+        }
+
+        // 检查节点目标是否达成
+        if (checkNodeCompletion(node, recentEvents)) break
+      }
+
+      // 发出节点完成事件
+      const completeEvent: SimulationEvent = {
+        type: "node-complete",
+        node,
+        stateChanges: collectStateChanges(participatingAgents),
+        timestamp: new Date().toISOString(),
+      }
+      events.push(completeEvent)
+      callbacks.onEvent(completeEvent)
+    }
+
+    callbacks.onProgress(100, "推演完成")
+    callbacks.onComplete(events)
+    return events
+  } catch (error) {
+    callbacks.onError(error as Error)
+    throw error
+  }
+}
+
+async function decideAgentAction(
+  agent: NovelAgent,
+  context: string,
+  llmConfig: LlmConfig,
+  mode: string,
+  injectionEvent: string | undefined,
+  signal?: AbortSignal,
+): Promise<AgentAction | null> {
+  const systemPrompt = buildAgentSystemPrompt(agent, mode, injectionEvent)
+
+  const messages: ChatMessage[] = [
+    { role: "system", content: systemPrompt },
+    { role: "user", content: `${context}\n\n请决定你在这个场景中的下一步行动。输出 JSON 格式。` },
+  ]
+
+  let result = ""
+  await streamChat(
+    llmConfig,
+    messages,
+    {
+      onToken: (token) => {
+        result += token
+      },
+      onDone: () => {},
+      onError: (error) => {
+        throw error
+      },
+    },
+    signal,
+  )
+
+  return parseAgentAction(result)
+}
+
+function buildAgentSystemPrompt(
+  agent: NovelAgent,
+  mode: string,
+  injectionEvent: string | undefined,
+): string {
+  let prompt = `你是小说角色"${agent.name}",请完全以该角色的视角思考和行动。
+
+你必须:
+1. 严格遵循角色的性格特征、心智模型和决策方式
+2. 遵守角色的认知边界——你不知道的信息不能使用
+3. 做出的行为必须符合角色当前的目标和情绪
+4. 保持角色人设一致性
+
+行为格式(输出 JSON):
+\`\`\`json
+{
+  "type": "speak | act | react | decide | investigate | conflict | cooperate | withhold",
+  "target": "目标角色名(如适用)",
+  "content": "行为描述(用第三人称叙述)",
+  "motivation": "你做出这个选择的内心动机"
+}
+\`\`\`
+
+行为类型说明:
+- speak: 对某人说话或公开发言
+- act: 执行一个行动(移动/使用物品/施法等)
+- react: 对他人的行为做出反应
+- decide: 在关键决策点做出选择
+- investigate: 调查或获取信息
+- conflict: 与某人发生冲突或对抗
+- cooperate: 与某人合作
+- withhold: 隐瞒或保留信息
+
+只输出 JSON,不要输出其他内容。`
+
+  if (injectionEvent && mode === "event-driven") {
+    prompt += `\n\n触发事件:${injectionEvent}\n请针对这个事件做出反应。`
+  }
+
+  return prompt
+}
+
+function parseAgentAction(text: string): AgentAction | null {
+  try {
+    const jsonMatch = text.match(/```json\s*([\s\S]*?)```/) || text.match(/\{[\s\S]*\}/)
+    const jsonStr = jsonMatch ? (jsonMatch[1] || jsonMatch[0]).trim() : text.trim()
+    const parsed = JSON.parse(jsonStr)
+
+    const type = parsed.type as AgentAction["type"]
+    if (!type) return null
+
+    return {
+      type,
+      target: parsed.target,
+      content: parsed.content || "",
+    } as AgentAction
+  } catch {
+    // 如果无法解析 JSON,将文本作为行动描述
+    if (text.trim()) {
+      return { type: "act", content: text.trim().slice(0, 500) }
+    }
+    return null
+  }
+}
+
+function applyAction(agent: NovelAgent, action: AgentAction, allAgents: NovelAgent[]): void {
+  // 更新角色已知信息
+  if (action.type === "investigate" || action.type === "speak") {
+    agent.knownFacts.add(action.content)
+  }
+
+  // 更新关系
+  if ("target" in action && action.target) {
+    const target = action.target
+    const relation = agent.relationships.get(target)
+    if (relation) {
+      if (action.type === "conflict") {
+        relation.sentiment = Math.max(-100, relation.sentiment - 20)
+        relation.relationType = "hostile"
+      } else if (action.type === "cooperate") {
+        relation.sentiment = Math.min(100, relation.sentiment + 15)
+        relation.relationType = "ally"
+      }
+    }
+  }
+
+  // 更新情绪
+  if (action.type === "conflict") {
+    agent.emotionalState = "tense"
+  } else if (action.type === "cooperate") {
+    agent.emotionalState = "hopeful"
+  } else if (action.type === "decide") {
+    agent.emotionalState = "determined"
+  }
+}
+
+function formatActionForContext(name: string, action: AgentAction): string {
+  const targetStr = "target" in action && action.target ? ` → ${action.target}` : ""
+  return `${name}${targetStr}:${action.content}`
+}
+
+function checkNodeCompletion(node: StoryNode, recentEvents: string[]): boolean {
+  // 简单启发式:如果已产生足够事件,认为节点完成
+  return recentEvents.length >= 4
+}
+
+function collectStateChanges(agents: NovelAgent[]): string[] {
+  const changes: string[] = []
+  for (const agent of agents) {
+    if (agent.emotionalState !== "neutral") {
+      changes.push(`${agent.name} 情绪变为 ${agent.emotionalState}`)
+    }
+    for (const [name, rel] of agent.relationships) {
+      if (rel.relationType !== "neutral") {
+        changes.push(`${agent.name} 与 ${name} 关系变为 ${rel.relationType}(好感度 ${rel.sentiment})`)
+      }
+    }
+  }
+  return changes
+}
+```
+
+- [ ] **Step 2: 验证类型检查**
+
+Run: `cd C:\QMAI_C\QMAI-main && npx tsc --noEmit --pretty 2>&1 | grep -i "simulation-engine" | head -5`
+Expected: 无错误
+
+- [ ] **Step 3: 提交**
+
+```bash
+git add src/lib/novel/story-simulation/simulation-engine.ts
+git commit -m "feat(story-simulation): 实现仿真引擎核心循环"
+```
+
+---
+
+## Task 9: 四种仿真模式
+
+**Files:**
+- Create: `src/lib/novel/story-simulation/simulation-modes/event-driven.ts`
+- Create: `src/lib/novel/story-simulation/simulation-modes/free-emergence.ts`
+- Create: `src/lib/novel/story-simulation/simulation-modes/decision-tree.ts`
+- Create: `src/lib/novel/story-simulation/simulation-modes/hybrid.ts`
+
+- [ ] **Step 1: 创建事件驱动模式**
+
+```typescript
+// src/lib/novel/story-simulation/simulation-modes/event-driven.ts
+
+import { runSimulation, type SimulationCallbacks } from "../simulation-engine"
+import type { SimulationInput, ExtractionResult, SimulationEvent } from "../types"
+
+export async function runEventDrivenSimulation(
+  input: SimulationInput,
+  extraction: ExtractionResult,
+  callbacks: SimulationCallbacks,
+  signal?: AbortSignal,
+): Promise<SimulationEvent[]> {
+  // 事件驱动模式:用户注入的触发事件已在 input.injectionEvent 中
+  return runSimulation(
+    { ...input, mode: "event-driven" },
+    extraction,
+    callbacks,
+    signal,
+  )
+}
+```
+
+- [ ] **Step 2: 创建自由涌现模式**
+
+```typescript
+// src/lib/novel/story-simulation/simulation-modes/free-emergence.ts
+
+import { runSimulation, type SimulationCallbacks } from "../simulation-engine"
+import type { SimulationInput, ExtractionResult, SimulationEvent } from "../types"
+
+export async function runFreeEmergenceSimulation(
+  input: SimulationInput,
+  extraction: ExtractionResult,
+  callbacks: SimulationCallbacks,
+  signal?: AbortSignal,
+): Promise<SimulationEvent[]> {
+  // 自由涌现模式:不注入特定事件,让角色自由互动
+  return runSimulation(
+    { ...input, mode: "free-emergence", injectionEvent: undefined },
+    extraction,
+    callbacks,
+    signal,
+  )
+}
+```
+
+- [ ] **Step 3: 创建决策树模式**
+
+```typescript
+// src/lib/novel/story-simulation/simulation-modes/decision-tree.ts
+
+import { streamChat, type ChatMessage } from "@/lib/llm-client"
+import { runSimulation, type SimulationCallbacks } from "../simulation-engine"
+import type { SimulationInput, ExtractionResult, SimulationEvent, NovelAgent, StoryBranch } from "../types"
+
+export async function runDecisionTreeSimulation(
+  input: SimulationInput,
+  extraction: ExtractionResult,
+  callbacks: SimulationCallbacks,
+  signal?: AbortSignal,
+): Promise<SimulationEvent[]> {
+  // 决策树模式:先为关键角色生成多个决策选择,每个选择推演一次
+  const { agents, framework, llmConfig } = input
+
+  // 选定第一个节点的第一个角色作为决策角色
+  const firstNode = framework.nodes[0]
+  const decisionAgent = agents.find((a) =>
+    firstNode.involvedCharacters.includes(a.name),
+  )
+
+  if (!decisionAgent) {
+    return runSimulation(input, extraction, callbacks, signal)
+  }
+
+  // 生成决策选项
+  const choices = await generateDecisionChoices(decisionAgent, firstNode, llmConfig, signal)
+  callbacks.onProgress(10, `已生成 ${choices.length} 个决策分支`)
+
+  // 对每个选择推演一次(限制深度以控制 token 消耗)
+  const allEvents: SimulationEvent[] = []
+  for (let i = 0; i < choices.length; i++) {
+    if (signal?.aborted) break
+    callbacks.onProgress(
+      10 + (i / choices.length) * 80,
+      `正在推演决策分支 ${i + 1}/${choices.length}:${choices[i].slice(0, 20)}...`,
+    )
+
+    const branchEvents = await runSimulation(
+      {
+        ...input,
+        mode: "decision-tree",
+        injectionEvent: `决策选择:${choices[i]}`,
+      },
+      extraction,
+      {
+        ...callbacks,
+        onEvent: () => {}, // 不转发子事件,避免过多输出
+      },
+      signal,
+    )
+    allEvents.push(...branchEvents)
+  }
+
+  callbacks.onProgress(100, "决策树推演完成")
+  callbacks.onComplete(allEvents)
+  return allEvents
+}
+
+async function generateDecisionChoices(
+  agent: NovelAgent,
+  node: { title: string; coreConflict: string; goal: string },
+  llmConfig: SimulationInput["llmConfig"],
+  signal?: AbortSignal,
+): Promise<string[]> {
+  const prompt = `角色"${agent.name}"面临以下场景:
+节点:${node.title}
+冲突:${node.coreConflict}
+目标:${node.goal}
+
+请为该角色生成 3 个不同的决策选择,每个选择代表不同的剧情走向方向。
+
+输出 JSON 数组格式:
+\`\`\`json
+["选择1描述", "选择2描述", "选择3描述"]
+\`\`\`
+
+只输出 JSON,不要输出其他内容。`
+
+  const messages: ChatMessage[] = [
+    { role: "system", content: "你是小说剧情策划专家,擅长设计角色的关键决策点。" },
+    { role: "user", content: prompt },
+  ]
+
+  let result = ""
+  await streamChat(
+    llmConfig,
+    messages,
+    {
+      onToken: (token) => { result += token },
+      onDone: () => {},
+      onError: (error) => { throw error },
+    },
+    signal,
+  )
+
+  try {
+    const jsonMatch = result.match(/```json\s*([\s\S]*?)```/) || result.match(/\[[\s\S]*\]/)
+    const jsonStr = jsonMatch ? (jsonMatch[1] || jsonMatch[0]).trim() : result.trim()
+    const parsed = JSON.parse(jsonStr)
+    return Array.isArray(parsed) ? parsed : []
+  } catch {
+    return ["按照原计划行动", "改变策略", "寻求帮助"]
+  }
+}
+```
+
+- [ ] **Step 4: 创建混合模式**
+
+```typescript
+// src/lib/novel/story-simulation/simulation-modes/hybrid.ts
+
+import { runSimulation, type SimulationCallbacks } from "../simulation-engine"
+import type { SimulationInput, ExtractionResult, SimulationEvent } from "../types"
+
+export async function runHybridSimulation(
+  input: SimulationInput,
+  extraction: ExtractionResult,
+  callbacks: SimulationCallbacks,
+  signal?: AbortSignal,
+): Promise<SimulationEvent[]> {
+  // 混合模式:先自由涌现,然后在中间节点注入事件
+  return runSimulation(
+    { ...input, mode: "hybrid" },
+    extraction,
+    callbacks,
+    signal,
+  )
+}
+```
+
+- [ ] **Step 5: 验证类型检查**
+
+Run: `cd C:\QMAI_C\QMAI-main && npx tsc --noEmit --pretty 2>&1 | grep -i "simulation-modes" | head -5`
+Expected: 无错误
+
+- [ ] **Step 6: 提交**
+
+```bash
+git add src/lib/novel/story-simulation/simulation-modes/
+git commit -m "feat(story-simulation): 实现四种仿真模式"
+```
+
+---
+
+## Task 10: 推演报告生成器
+
+**Files:**
+- Create: `src/lib/novel/story-simulation/simulation-report-agent.ts`
+
+- [ ] **Step 1: 创建报告生成器**
+
+```typescript
+// src/lib/novel/story-simulation/simulation-report-agent.ts
+
+import { streamChat, type ChatMessage } from "@/lib/llm-client"
+import type { LlmConfig } from "@/stores/wiki-store"
+import type {
+  SimulationEvent,
+  SimulationReport,
+  CharacterAnalysis,
+  StoryBranch,
+  StoryFramework,
+  SimulationMode,
+} from "./types"
+
+export interface ReportGenerationOptions {
+  events: SimulationEvent[]
+  framework: StoryFramework
+  mode: SimulationMode
+  llmConfig: LlmConfig
+  onProgress?: (label: string) => void
+  signal?: AbortSignal
+}
+
+export async function generateSimulationReport(
+  options: ReportGenerationOptions,
+): Promise<SimulationReport> {
+  const { events, framework, mode, llmConfig, onProgress, signal } = options
+
+  onProgress?.("正在分析仿真事件...")
+
+  // 将事件序列化为文本
+  const eventsText = serializeEvents(events)
+
+  onProgress?.("正在生成推演报告...")
+
+  const prompt = buildReportPrompt(eventsText, framework, mode)
+
+  const messages: ChatMessage[] = [
+    { role: "system", content: REPORT_SYSTEM_PROMPT },
+    { role: "user", content: prompt },
+  ]
+
+  let result = ""
+  await streamChat(
+    llmConfig,
+    messages,
+    {
+      onToken: (token) => { result += token },
+      onDone: () => {},
+      onError: (error) => { throw error },
+    },
+    signal,
+  )
+
+  onProgress?.("正在解析报告...")
+
+  return parseReport(result, framework, mode)
+}
+
+const REPORT_SYSTEM_PROMPT = `你是一位专业的小说剧情分析师。你的任务是基于仿真推演事件,生成一份结构化的推演报告。
+
+报告必须包含:
+1. 角色行为分析:每个主要角色在各节点的行为、动机和人设一致性评分(0-100)
+2. 走向分支:2-3 条可能的剧情走向,每条包含标题、摘要、关键事件、概率(高/中/低)、优势、不足、是否推荐
+3. 综合推荐:对整体推演结果的建议
+
+输出 JSON 格式:
+\`\`\`json
+{
+  "characterAnalyses": [
+    {
+      "name": "角色名",
+      "behaviors": [
+        { "node": "节点标题", "action": "行为描述", "motivation": "动机" }
+      ],
+      "stateChanges": ["状态变化1", "状态变化2"],
+      "consistencyScore": 90
+    }
+  ],
+  "branches": [
+    {
+      "title": "走向标题",
+      "summary": "走向摘要",
+      "keyEvents": ["事件1", "事件2"],
+      "probability": "high",
+      "pros": "优势",
+      "cons": "不足",
+      "recommendation": true
+    }
+  ],
+  "recommendation": "综合推荐建议"
+}
+\`\`\`
+
+只输出 JSON,不要输出其他内容。`
+
+function buildReportPrompt(
+  eventsText: string,
+  framework: StoryFramework,
+  mode: SimulationMode,
+): string {
+  return `## 故事框架
+前提:${framework.premise}
+节点:${framework.nodes.map((n) => `${n.phase}·${n.title}`).join(" → ")}
+
+## 仿真模式
+${mode}
+
+## 仿真事件记录
+${eventsText}
+
+请基于以上仿真结果,生成推演报告。`
+}
+
+function serializeEvents(events: SimulationEvent[]): string {
+  const lines: string[] = []
+  for (const event of events) {
+    if (event.type === "node-start" && event.node) {
+      lines.push(`\n=== 节点开始:${event.node.phase}·${event.node.title} ===`)
+    } else if (event.type === "agent-action" && event.agent && event.action) {
+      const target = "target" in event.action && event.action.target
+        ? ` → ${event.action.target}`
+        : ""
+      lines.push(`[轮次${(event.round || 0) + 1}] ${event.agent.name}${target}(${event.action.type}):${event.action.content}`)
+    } else if (event.type === "node-complete" && event.node) {
+      lines.push(`=== 节点完成:${event.node.title} ===`)
+      if (event.stateChanges && event.stateChanges.length > 0) {
+        lines.push(`状态变化:${event.stateChanges.join(";")}`)
+      }
+    }
+  }
+  return lines.join("\n")
+}
+
+function parseReport(
+  text: string,
+  framework: StoryFramework,
+  mode: SimulationMode,
+): SimulationReport {
+  try {
+    const jsonMatch = text.match(/```json\s*([\s\S]*?)```/) || text.match(/\{[\s\S]*\}/)
+    const jsonStr = jsonMatch ? (jsonMatch[1] || jsonMatch[0]).trim() : text.trim()
+    const parsed = JSON.parse(jsonStr)
+
+    const characterAnalyses: CharacterAnalysis[] = (parsed.characterAnalyses || []).map((ca: any) => ({
+      characterId: ca.name,
+      name: ca.name,
+      behaviors: Array.isArray(ca.behaviors) ? ca.behaviors : [],
+      stateChanges: Array.isArray(ca.stateChanges) ? ca.stateChanges : [],
+      consistencyScore: typeof ca.consistencyScore === "number" ? ca.consistencyScore : 80,
+    }))
+
+    const branches: StoryBranch[] = (parsed.branches || []).map((b: any) => ({
+      title: b.title || "未命名走向",
+      summary: b.summary || "",
+      keyEvents: Array.isArray(b.keyEvents) ? b.keyEvents : [],
+      probability: b.probability === "high" || b.probability === "medium" || b.probability === "low"
+        ? b.probability
+        : "medium",
+      pros: b.pros || "",
+      cons: b.cons || "",
+      recommendation: Boolean(b.recommendation),
+    }))
+
+    return {
+      frameworkId: framework.id,
+      mode,
+      characterAnalyses,
+      branches,
+      recommendation: parsed.recommendation || "",
+      createdAt: new Date().toISOString(),
+    }
+  } catch {
+    return {
+      frameworkId: framework.id,
+      mode,
+      characterAnalyses: [],
+      branches: [],
+      recommendation: text.slice(0, 1000),
+      createdAt: new Date().toISOString(),
+    }
+  }
+}
+```
+
+- [ ] **Step 2: 验证类型检查**
+
+Run: `cd C:\QMAI_C\QMAI-main && npx tsc --noEmit --pretty 2>&1 | grep -i "simulation-report" | head -5`
+Expected: 无错误
+
+- [ ] **Step 3: 提交**
+
+```bash
+git add src/lib/novel/story-simulation/simulation-report-agent.ts
+git commit -m "feat(story-simulation): 实现推演报告生成器(ReACT 模式)"
+```
+
+---
+
+## Task 11: 故事草稿生成器
+
+**Files:**
+- Create: `src/lib/novel/story-simulation/story-draft-generator.ts`
+
+- [ ] **Step 1: 创建草稿生成器**
+
+```typescript
+// src/lib/novel/story-simulation/story-draft-generator.ts
+
+import { streamChat, type ChatMessage } from "@/lib/llm-client"
+import type { LlmConfig } from "@/stores/wiki-store"
+import type {
+  SimulationReport,
+  StoryFramework,
+  StoryDraft,
+  DraftChapter,
+  StoryBranch,
+} from "./types"
+
+export interface DraftGenerationOptions {
+  framework: StoryFramework
+  report: SimulationReport
+  selectedBranch: StoryBranch
+  llmConfig: LlmConfig
+  onProgress?: (label: string) => void
+  onChapterGenerated?: (chapter: DraftChapter) => void
+  signal?: AbortSignal
+}
+
+export async function generateStoryDraft(
+  options: DraftGenerationOptions,
+): Promise<StoryDraft> {
+  const { framework, report, selectedBranch, llmConfig, onProgress, onChapterGenerated, signal } = options
+
+  const targetWords = framework.targetWords
+  const nodeCount = framework.nodes.length
+  const wordsPerChapter = Math.floor(targetWords / nodeCount)
+
+  const chapters: DraftChapter[] = []
+
+  for (let i = 0; i < nodeCount; i++) {
+    if (signal?.aborted) break
+
+    const node = framework.nodes[i]
+    onProgress?.(`正在生成第 ${i + 1}/${nodeCount} 章:${node.title}...`)
+
+    const chapter = await generateChapter(
+      node,
+      selectedBranch,
+      framework,
+      report,
+      wordsPerChapter,
+      llmConfig,
+      signal,
+    )
+
+    chapters.push(chapter)
+    onChapterGenerated?.(chapter)
+  }
+
+  const totalWords = chapters.reduce((sum, ch) => sum + ch.content.length, 0)
+
+  return {
+    branchId: selectedBranch.title,
+    frameworkId: framework.id,
+    chapters,
+    totalWords,
+    createdAt: new Date().toISOString(),
+  }
+}
+
+async function generateChapter(
+  node: StoryFramework["nodes"][number],
+  branch: StoryBranch,
+  framework: StoryFramework,
+  report: SimulationReport,
+  targetWords: number,
+  llmConfig: LlmConfig,
+  signal?: AbortSignal,
+): Promise<DraftChapter> {
+  const relevantAnalysis = report.characterAnalyses.filter((ca) =>
+    node.involvedCharacters.includes(ca.name),
+  )
+
+  const prompt = `## 章节生成任务
+
+### 故事框架
+前提:${framework.premise}
+当前节点:${node.phase} · ${node.title}
+核心冲突:${node.coreConflict}
+涉及角色:${node.involvedCharacters.join("、")}
+推进目标:${node.goal}
+预期走向:${node.expectedOutcome}
+
+### 选择的剧情走向
+${branch.title}:${branch.summary}
+关键事件:${branch.keyEvents.join(";")}
+
+### 角色行为参考
+${relevantAnalysis.map((ca) =>
+  `${ca.name}(人设一致性 ${ca.consistencyScore}分):${ca.behaviors.map((b) => b.action).join(";")}`,
+).join("\n")}
+
+### 要求
+- 目标字数:约 ${targetWords} 字
+- 遵循小说写作结构,保持起承转合的节奏
+- 角色行为必须符合其人设特征
+- 自然融入核心冲突,推进剧情发展
+- 只输出正文内容,不要输出标题
+
+请开始写作:`
+
+  const messages: ChatMessage[] = [
+    { role: "system", content: "你是一位专业的小说作者,擅长根据剧情框架和角色分析写出引人入胜的章节正文。" },
+    { role: "user", content: prompt },
+  ]
+
+  let content = ""
+  await streamChat(
+    llmConfig,
+    messages,
+    {
+      onToken: (token) => { content += token },
+      onDone: () => {},
+      onError: (error) => { throw error },
+    },
+    signal,
+  )
+
+  return {
+    title: `第${node.index + 1}章 ${node.title}`,
+    content: content.trim(),
+    correspondingNode: node.index,
+  }
+}
+```
+
+- [ ] **Step 2: 验证类型检查**
+
+Run: `cd C:\QMAI_C\QMAI-main && npx tsc --noEmit --pretty 2>&1 | grep -i "story-draft" | head -5`
+Expected: 无错误
+
+- [ ] **Step 3: 提交**
+
+```bash
+git add src/lib/novel/story-simulation/story-draft-generator.ts
+git commit -m "feat(story-simulation): 实现故事草稿生成器"
+```
+
+---
+
+## Task 12: 故事框架持久化
+
+**Files:**
+- Create: `src/lib/novel/story-simulation/framework-store.ts`
+
+- [ ] **Step 1: 创建框架存储**
+
+```typescript
+// src/lib/novel/story-simulation/framework-store.ts
+
+import { readFile, writeFileAtomic, createDirectory, listDirectory, deleteFile } from "@/commands/fs"
+import { normalizePath } from "@/lib/path-utils"
+import type { StoryFramework, SimulationReport, StoryDraft } from "./types"
+
+const FRAMEWORK_DIR = ".qmai/simulations/frameworks"
+const RESULT_DIR = ".qmai/simulations/results"
+const BINDING_DIR = ".qmai/simulations/bindings"
+const ACTIVE_BINDING_FILE = ".qmai/simulations/bindings/active-binding.json"
+
+function baseDir(projectPath: string): string {
+  return normalizePath(projectPath)
+}
+
+function frameworkPath(projectPath: string, frameworkId: string): string {
+  return `${baseDir(projectPath)}/${FRAMEWORK_DIR}/${frameworkId}.md`
+}
+
+function resultDir(projectPath: string, frameworkId: string): string {
+  return `${baseDir(projectPath)}/${RESULT_DIR}/${frameworkId}`
+}
+
+export async function ensureSimulationDirs(projectPath: string): Promise<void> {
+  const pp = baseDir(projectPath)
+  await createDirectory(`${pp}/${FRAMEWORK_DIR}`)
+  await createDirectory(`${pp}/${RESULT_DIR}`)
+  await createDirectory(`${pp}/${BINDING_DIR}`)
+}
+
+export async function saveFramework(projectPath: string, framework: StoryFramework): Promise<void> {
+  await ensureSimulationDirs(projectPath)
+  const md = frameworkToMarkdown(framework)
+  await writeFileAtomic(frameworkPath(projectPath, framework.id), md)
+}
+
+export async function loadFrameworks(projectPath: string): Promise<StoryFramework[]> {
+  await ensureSimulationDirs(projectPath)
+  const dir = `${baseDir(projectPath)}/${FRAMEWORK_DIR}`
+  const items = await listDirectory(dir)
+  const frameworks: StoryFramework[] = []
+
+  for (const item of items) {
+    if (item.type === "file" && item.name.endsWith(".md")) {
+      try {
+        const raw = await readFile(`${dir}/${item.name}`)
+        const framework = markdownToFramework(raw)
+        if (framework) frameworks.push(framework)
+      } catch {}
+    }
+  }
+
+  return frameworks.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
+}
+
+export async function deleteFramework(projectPath: string, frameworkId: string): Promise<void> {
+  await deleteFile(frameworkPath(projectPath, frameworkId))
+  // 同时删除关联的推演结果
+  try {
+    const rDir = resultDir(projectPath, frameworkId)
+    const items = await listDirectory(rDir)
+    for (const item of items) {
+      await deleteFile(`${rDir}/${item.name}`)
+    }
+  } catch {}
+}
+
+export async function saveSimulationResult(
+  projectPath: string,
+  frameworkId: string,
+  report: SimulationReport,
+  draft?: StoryDraft,
+): Promise<string> {
+  await ensureSimulationDirs(projectPath)
+  const dir = resultDir(projectPath, frameworkId)
+  await createDirectory(dir)
+
+  const resultId = `result-${Date.now()}`
+  await writeFileAtomic(`${dir}/${resultId}.json`, JSON.stringify(report, null, 2))
+  await writeFileAtomic(`${dir}/${resultId}-report.md`, reportToMarkdown(report))
+
+  if (draft) {
+    await writeFileAtomic(`${dir}/${resultId}-draft.md`, draftToMarkdown(draft))
+  }
+
+  return resultId
+}
+
+export async function loadSimulationResults(
+  projectPath: string,
+  frameworkId: string,
+): Promise<{ id: string; report: SimulationReport }[]> {
+  const dir = resultDir(projectPath, frameworkId)
+  try {
+    const items = await listDirectory(dir)
+    const results: { id: string; report: SimulationReport }[] = []
+
+    for (const item of items) {
+      if (item.type === "file" && item.name.endsWith(".json")) {
+        try {
+          const raw = await readFile(`${dir}/${item.name}`)
+          const report = JSON.parse(raw) as SimulationReport
+          results.push({ id: item.name.replace(/\.json$/, ""), report })
+        } catch {}
+      }
+    }
+
+    return results.sort((a, b) => b.report.createdAt.localeCompare(a.report.createdAt))
+  } catch {
+    return []
+  }
+}
+
+// ── Markdown 序列化 ──
+
+function frameworkToMarkdown(framework: StoryFramework): string {
+  const lines: string[] = []
+  lines.push("---")
+  lines.push(`type: story-framework`)
+  lines.push(`title: ${framework.title}`)
+  lines.push(`createdAt: ${framework.createdAt}`)
+  lines.push(`sourceChapters: ${framework.sourceChapters}`)
+  lines.push(`targetWords: ${framework.targetWords}`)
+  lines.push(`simulationMode: ${framework.simulationMode}`)
+  if (framework.userIdea) {
+    lines.push(`userIdea: ${framework.userIdea}`)
+  }
+  lines.push("---")
+  lines.push("")
+  lines.push("## 前提")
+  lines.push(framework.premise)
+  lines.push("")
+  lines.push("## 故事节点")
+
+  for (const node of framework.nodes) {
+    lines.push("")
+    lines.push(`### ${node.phase} · 节点${node.index + 1}:${node.title}`)
+    lines.push(`- **冲突**:${node.coreConflict}`)
+    lines.push(`- **角色**:${node.involvedCharacters.join("、")}`)
+    lines.push(`- **目标**:${node.goal}`)
+    lines.push(`- **起因**:${node.causeFromPrev}`)
+    lines.push(`- **预期走向**:${node.expectedOutcome}`)
+  }
+
+  return lines.join("\n")
+}
+
+function markdownToFramework(raw: string): StoryFramework | null {
+  try {
+    const fmMatch = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/)
+    if (!fmMatch) return null
+
+    const frontmatter = fmMatch[1]
+    const body = fmMatch[2]
+
+    const getFm = (key: string): string => {
+      const m = frontmatter.match(new RegExp(`^${key}:\\s*(.+)$`, "m"))
+      return m ? m[1].trim() : ""
+    }
+
+    const nodes: StoryFramework["nodes"] = []
+    const nodeRegex = /### (.) · 节点(\d+):(.+)/g
+    let nodeMatch
+    while ((nodeMatch = nodeRegex.exec(body)) !== null) {
+      const phase = nodeMatch[1] as "起" | "承" | "转" | "合"
+      const index = parseInt(nodeMatch[2], 10) - 1
+      const title = nodeMatch[3]
+
+      const sectionStart = nodeMatch.index + nodeMatch[0].length
+      const nextNode = body.indexOf("### ", sectionStart)
+      const sectionEnd = nextNode > 0 ? nextNode : body.length
+      const section = body.slice(sectionStart, sectionEnd)
+
+      const conflict = section.match(/- \*\*冲突\*\*:(.+)/)?.[1] || ""
+      const characters = (section.match(/- \*\*角色\*\*:(.+)/)?.[1] || "").split("、").filter(Boolean)
+      const goal = section.match(/- \*\*目标\*\*:(.+)/)?.[1] || ""
+      const cause = section.match(/- \*\*起因\*\*:(.+)/)?.[1] || ""
+      const outcome = section.match(/- \*\*预期走向\*\*:(.+)/)?.[1] || ""
+
+      nodes.push({ index, phase, title, coreConflict: conflict, involvedCharacters: characters, goal, causeFromPrev: cause, expectedOutcome: outcome })
+    }
+
+    const premiseMatch = body.match(/## 前提\n([\s\S]*?)(?=\n## |$)/)
+    const premise = premiseMatch ? premiseMatch[1].trim() : ""
+
+    return {
+      id: getFm("title").replace(/\s/g, "-").toLowerCase() + `-${getFm("createdAt")}`,
+      title: getFm("title"),
+      premise,
+      targetWords: parseInt(getFm("targetWords"), 10) || 10000,
+      simulationMode: (getFm("simulationMode") as StoryFramework["simulationMode"]) || "event-driven",
+      userIdea: getFm("userIdea") || undefined,
+      sourceChapters: parseInt(getFm("sourceChapters"), 10) || 10,
+      nodes,
+      createdAt: getFm("createdAt") || new Date().toISOString(),
+    }
+  } catch {
+    return null
+  }
+}
+
+function reportToMarkdown(report: SimulationReport): string {
+  const lines: string[] = []
+  lines.push("# 推演报告")
+  lines.push(`> 生成时间:${report.createdAt}`)
+  lines.push("")
+  lines.push("## 角色行为分析")
+
+  for (const ca of report.characterAnalyses) {
+    lines.push(`### ${ca.name}(人设一致性:${ca.consistencyScore}分)`)
+    for (const b of ca.behaviors) {
+      lines.push(`- **${b.node}**:${b.action}(动机:${b.motivation})`)
+    }
+    if (ca.stateChanges.length > 0) {
+      lines.push(`状态变化:${ca.stateChanges.join(";")}`)
+    }
+    lines.push("")
+  }
+
+  lines.push("## 走向分支")
+  for (const b of report.branches) {
+    lines.push(`### ${b.title}${b.recommendation ? "(推荐)" : ""}`)
+    lines.push(`概率:${b.probability}`)
+    lines.push(`摘要:${b.summary}`)
+    lines.push(`关键事件:${b.keyEvents.join(";")}`)
+    lines.push(`优势:${b.pros}`)
+    lines.push(`不足:${b.cons}`)
+    lines.push("")
+  }
+
+  lines.push("## 综合推荐")
+  lines.push(report.recommendation)
+
+  return lines.join("\n")
+}
+
+function draftToMarkdown(draft: StoryDraft): string {
+  const lines: string[] = []
+  lines.push("# 故事草稿")
+  lines.push(`> 总字数:${draft.totalWords}`)
+  lines.push("")
+
+  for (const ch of draft.chapters) {
+    lines.push(`## ${ch.title}`)
+    lines.push(ch.content)
+    lines.push("")
+  }
+
+  return lines.join("\n")
+}
+```
+
+- [ ] **Step 2: 验证类型检查**
+
+Run: `cd C:\QMAI_C\QMAI-main && npx tsc --noEmit --pretty 2>&1 | grep -i "framework-store" | head -5`
+Expected: 无错误
+
+- [ ] **Step 3: 提交**
+
+```bash
+git add src/lib/novel/story-simulation/framework-store.ts
+git commit -m "feat(story-simulation): 实现故事框架持久化存储"
+```
+
+---
+
+## Task 13: AI 会话绑定
+
+**Files:**
+- Create: `src/lib/novel/story-simulation/framework-binding.ts`
+- Modify: `src/lib/novel/context-data-sources.ts`
+
+- [ ] **Step 1: 创建绑定逻辑**
+
+```typescript
+// src/lib/novel/story-simulation/framework-binding.ts
+
+import { readFile, writeFileAtomic, createDirectory, deleteFile } from "@/commands/fs"
+import { normalizePath } from "@/lib/path-utils"
+import type { StoryFramework, FrameworkBinding, ChapterAllocation } from "./types"
+
+const BINDING_FILE = ".qmai/simulations/bindings/active-binding.json"
+
+function bindingPath(projectPath: string): string {
+  return `${normalizePath(projectPath)}/${BINDING_FILE}`
+}
+
+export async function loadBinding(projectPath: string): Promise<FrameworkBinding | null> {
+  try {
+    const raw = await readFile(bindingPath(projectPath))
+    return JSON.parse(raw) as FrameworkBinding
+  } catch {
+    return null
+  }
+}
+
+export async function saveBinding(
+  projectPath: string,
+  framework: StoryFramework,
+  targetChapterCount: number,
+): Promise<FrameworkBinding> {
+  const pp = normalizePath(projectPath)
+  await createDirectory(`${pp}/.qmai/simulations/bindings`)
+
+  const allocation = allocateChapters(framework, targetChapterCount)
+
+  const binding: FrameworkBinding = {
+    frameworkId: framework.id,
+    frameworkTitle: framework.title,
+    targetChapterCount,
+    chapterAllocation: allocation,
+    boundAt: new Date().toISOString(),
+  }
+
+  await writeFileAtomic(bindingPath(projectPath), JSON.stringify(binding, null, 2))
+  return binding
+}
+
+export async function clearBinding(projectPath: string): Promise<void> {
+  try {
+    await deleteFile(bindingPath(projectPath))
+  } catch {}
+}
+
+function allocateChapters(
+  framework: StoryFramework,
+  targetChapterCount: number,
+): ChapterAllocation[] {
+  const nodeCount = framework.nodes.length
+  const baseChaptersPerNode = Math.floor(targetChapterCount / nodeCount)
+  let remaining = targetChapterCount - baseChaptersPerNode * nodeCount
+
+  const allocations: ChapterAllocation[] = []
+  let currentChapter = 1
+
+  for (let i = 0; i < nodeCount; i++) {
+    const chapters = baseChaptersPerNode + (remaining > 0 ? 1 : 0)
+    if (remaining > 0) remaining--
+
+    allocations.push({
+      nodeIndex: framework.nodes[i].index,
+      nodeTitle: framework.nodes[i].title,
+      startChapter: currentChapter,
+      endChapter: currentChapter + chapters - 1,
+    })
+
+    currentChapter += chapters
+  }
+
+  return allocations
+}
+
+export function buildBindingContext(binding: FrameworkBinding, framework: StoryFramework): string {
+  if (!binding || !framework) return ""
+
+  const lines: string[] = []
+  lines.push("## 故事框架绑定")
+  lines.push(`框架:${binding.frameworkTitle}`)
+  lines.push(`目标章节数:${binding.targetChapterCount} 章`)
+  lines.push("")
+  lines.push("### 章节分配")
+
+  for (const alloc of binding.chapterAllocation) {
+    const node = framework.nodes[alloc.nodeIndex]
+    lines.push(`第 ${alloc.startChapter}-${alloc.endChapter} 章 → ${node.phase}·${node.title}`)
+    lines.push(`  冲突:${node.coreConflict}`)
+    lines.push(`  角色:${node.involvedCharacters.join("、")}`)
+    lines.push(`  目标:${node.goal}`)
+  }
+
+  lines.push("")
+  lines.push("### 要求")
+  lines.push("- 每章节必须遵循所分配的框架节点推进剧情")
+  lines.push("- 角色行为必须符合框架中设定的核心冲突")
+  lines.push("- 确保章节间因果链连贯,遵循起承转合结构")
+
+  return lines.join("\n")
+}
+```
+
+- [ ] **Step 2: 在 context-data-sources.ts 中添加框架绑定数据源**
+
+在 `src/lib/novel/context-data-sources.ts` 文件末尾添加新的数据源:
+
+```typescript
+// 在文件末尾添加
+
+import { loadBinding } from "@/lib/novel/story-simulation/framework-binding"
+import { loadFrameworks } from "@/lib/novel/story-simulation/framework-store"
+import { buildBindingContext } from "@/lib/novel/story-simulation/framework-binding"
+
+export const storyFrameworkBindingDataSource: DataSource<string> = {
+  name: "storyFrameworkBinding",
+  priority: 18,
+  async load(context: ContextLoadContext): Promise<string> {
+    if (!context.projectPath) return ""
+    try {
+      const binding = await loadBinding(context.projectPath)
+      if (!binding) return ""
+      const frameworks = await loadFrameworks(context.projectPath)
+      const framework = frameworks.find((f) => f.id === binding.frameworkId)
+      if (!framework) return ""
+      return buildBindingContext(binding, framework)
+    } catch {
+      return ""
+    }
+  },
+}
+```
+
+然后在 `getAllDataSources()` 函数中注册(如果该函数存在),或在 context-engine.ts 的数据源注册处添加。
+
+- [ ] **Step 3: 验证类型检查**
+
+Run: `cd C:\QMAI_C\QMAI-main && npx tsc --noEmit --pretty 2>&1 | grep -i "framework-binding\|context-data-sources" | head -5`
+Expected: 无错误
+
+- [ ] **Step 4: 提交**
+
+```bash
+git add src/lib/novel/story-simulation/framework-binding.ts src/lib/novel/context-data-sources.ts
+git commit -m "feat(story-simulation): 实现 AI 会话绑定和上下文注入"
+```
+
+---
+
+## Task 14: 单页配置面板 UI
+
+**Files:**
+- Create: `src/components/novel/story-simulation/simulation-config-panel.tsx`
+
+- [ ] **Step 1: 创建配置面板**
+
+```tsx
+// src/components/novel/story-simulation/simulation-config-panel.tsx
+
+import { useStorySimulationStore } from "@/stores/story-simulation-store"
+import { useTranslation } from "react-i18next"
+import { WORD_BUDGET_PRESETS, type SimulationMode } from "@/lib/novel/story-simulation/types"
+import { Button } from "@/components/ui/button"
+import { Textarea } from "@/components/ui/textarea"
+import { useState } from "react"
+
+const MODES: { mode: SimulationMode; labelKey: string; descKey: string }[] = [
+  { mode: "event-driven", labelKey: "storySimulation.modeEventDriven", descKey: "storySimulation.modeEventDrivenDesc" },
+  { mode: "free-emergence", labelKey: "storySimulation.modeFreeEmergence", descKey: "storySimulation.modeFreeEmergenceDesc" },
+  { mode: "decision-tree", labelKey: "storySimulation.modeDecisionTree", descKey: "storySimulation.modeDecisionTreeDesc" },
+  { mode: "hybrid", labelKey: "storySimulation.modeHybrid", descKey: "storySimulation.modeHybridDesc" },
+]
+
+const CHAPTER_OPTIONS = [5, 10, 20, 30, 50]
+
+export function SimulationConfigPanel({ onStart }: { onStart: () => void }) {
+  const { t } = useTranslation()
+  const { mode, userIdea, targetWords, sourceChapters, setMode, setUserIdea, setTargetWords, setSourceChapters } = useStorySimulationStore()
+  const [customWords, setCustomWords] = useState("")
+
+  return (
+    <div className="mx-auto max-w-2xl space-y-6 p-6">
+      {/* 模式选择 */}
+      <div>
+        <h3 className="mb-3 text-lg font-semibold">{t("storySimulation.selectMode")}</h3>
+        <div className="grid grid-cols-2 gap-3">
+          {MODES.map(({ mode: m, labelKey, descKey }) => (
+            <button
+              key={m}
+              onClick={() => setMode(m)}
+              className={`rounded-lg border p-3 text-left transition-colors ${
+                mode === m
+                  ? "border-primary bg-primary/5"
+                  : "border-border hover:border-primary/50"
+              }`}
+            >
+              <div className="font-medium">{t(labelKey)}</div>
+              <div className="mt-1 text-xs text-muted-foreground">{t(descKey)}</div>
+            </button>
+          ))}
+        </div>
+      </div>
+
+      {/* 用户思路 */}
+      <div>
+        <h3 className="mb-2 text-lg font-semibold">{t("storySimulation.yourIdea")}</h3>
+        <Textarea
+          value={userIdea}
+          onChange={(e) => setUserIdea(e.target.value)}
+          placeholder={t("storySimulation.yourIdeaPlaceholder")}
+          rows={3}
+        />
+      </div>
+
+      {/* 目标字数 */}
+      <div>
+        <h3 className="mb-2 text-lg font-semibold">{t("storySimulation.targetWords")}</h3>
+        <div className="flex gap-2">
+          {WORD_BUDGET_PRESETS.map((w) => (
+            <button
+              key={w}
+              onClick={() => setTargetWords(w)}
+              className={`rounded-md border px-3 py-1.5 text-sm transition-colors ${
+                targetWords === w && !customWords
+                  ? "border-primary bg-primary/5"
+                  : "border-border hover:border-primary/50"
+              }`}
+            >
+              {t(w === 10000 ? "storySimulation.words10k" : w === 30000 ? "storySimulation.words30k" : "storySimulation.words50k")}
+            </button>
+          ))}
+          <div className="flex items-center gap-1">
+            <input
+              type="number"
+              value={customWords}
+              onChange={(e) => {
+                setCustomWords(e.target.value)
+                const n = parseInt(e.target.value, 10)
+                if (n > 0) setTargetWords(n)
+              }}
+              placeholder={t("storySimulation.wordsCustom")}
+              className="w-24 rounded-md border border-border px-2 py-1.5 text-sm"
+            />
+            <span className="text-sm text-muted-foreground">{t("storySimulation.chapters")}</span>
+          </div>
+        </div>
+      </div>
+
+      {/* 提取章节数量 */}
+      <div>
+        <h3 className="mb-2 text-lg font-semibold">{t("storySimulation.sourceChapters")}</h3>
+        <div className="flex items-center gap-2">
+          <span className="text-sm text-muted-foreground">{t("storySimulation.recentChapters")}</span>
+          <select
+            value={sourceChapters}
+            onChange={(e) => setSourceChapters(parseInt(e.target.value, 10))}
+            className="rounded-md border border-border px-3 py-1.5 text-sm"
+          >
+            {CHAPTER_OPTIONS.map((n) => (
+              <option key={n} value={n}>{n}</option>
+            ))}
+          </select>
+          <span className="text-sm text-muted-foreground">{t("storySimulation.chapters")}</span>
+        </div>
+      </div>
+
+      {/* 开始按钮 */}
+      <Button onClick={onStart} className="w-full" size="lg">
+        {t("storySimulation.startExtract")}
+      </Button>
+    </div>
+  )
+}
+```
+
+- [ ] **Step 2: 验证类型检查**
+
+Run: `cd C:\QMAI_C\QMAI-main && npx tsc --noEmit --pretty 2>&1 | grep -i "simulation-config" | head -5`
+Expected: 无错误
+
+- [ ] **Step 3: 提交**
+
+```bash
+git add src/components/novel/story-simulation/simulation-config-panel.tsx
+git commit -m "feat(story-simulation): 实现单页配置面板 UI"
+```
+
+---
+
+## Task 15: 框架确认面板 UI
+
+**Files:**
+- Create: `src/components/novel/story-simulation/framework-confirm-panel.tsx`
+
+- [ ] **Step 1: 创建框架确认面板**
+
+```tsx
+// src/components/novel/story-simulation/framework-confirm-panel.tsx
+
+import { useStorySimulationStore } from "@/stores/story-simulation-store"
+import { useTranslation } from "react-i18next"
+import { Button } from "@/components/ui/button"
+
+export function FrameworkConfirmPanel({
+  onConfirm,
+  onRegenerate,
+}: {
+  onConfirm: () => void
+  onRegenerate: () => void
+}) {
+  const { t } = useTranslation()
+  const { currentFramework } = useStorySimulationStore()
+
+  if (!currentFramework) return null
+
+  return (
+    <div className="mx-auto max-w-3xl space-y-4 p-6">
+      <div className="flex items-center justify-between">
+        <h2 className="text-xl font-bold">{t("storySimulation.frameworkTitle")}</h2>
+        <div className="flex gap-2">
+          <Button variant="outline" onClick={onRegenerate}>
+            {t("storySimulation.regenerateFramework")}
+          </Button>
+          <Button onClick={onConfirm}>
+            {t("storySimulation.confirmFramework")}
+          </Button>
+        </div>
+      </div>
+
+      {/* 前提 */}
+      <div className="rounded-lg border bg-muted/30 p-4">
+        <h3 className="mb-2 font-semibold text-muted-foreground">{t("storySimulation.frameworkPremise")}</h3>
+        <p className="text-sm">{currentFramework.premise}</p>
+      </div>
+
+      {/* 节点列表 */}
+      <div className="space-y-3">
+        <h3 className="font-semibold">{t("storySimulation.frameworkNodes")}</h3>
+        {currentFramework.nodes.map((node) => (
+          <div key={node.index} className="rounded-lg border p-4">
+            <div className="mb-2 flex items-center gap-2">
+              <span className="rounded bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
+                {node.phase}
+              </span>
+              <h4 className="font-medium">{node.title}</h4>
+            </div>
+            <dl className="grid grid-cols-2 gap-2 text-sm">
+              <div>
+                <dt className="text-muted-foreground">{t("storySimulation.conflict")}</dt>
+                <dd>{node.coreConflict}</dd>
+              </div>
+              <div>
+                <dt className="text-muted-foreground">{t("storySimulation.characters")}</dt>
+                <dd>{node.involvedCharacters.join("、")}</dd>
+              </div>
+              <div>
+                <dt className="text-muted-foreground">{t("storySimulation.goal")}</dt>
+                <dd>{node.goal}</dd>
+              </div>
+              <div>
+                <dt className="text-muted-foreground">{t("storySimulation.cause")}</dt>
+                <dd>{node.causeFromPrev}</dd>
+              </div>
+              <div className="col-span-2">
+                <dt className="text-muted-foreground">{t("storySimulation.expectedOutcome")}</dt>
+                <dd>{node.expectedOutcome}</dd>
+              </div>
+            </dl>
+          </div>
+        ))}
+      </div>
+    </div>
+  )
+}
+```
+
+- [ ] **Step 2: 验证类型检查**
+
+Run: `cd C:\QMAI_C\QMAI-main && npx tsc --noEmit --pretty 2>&1 | grep -i "framework-confirm" | head -5`
+Expected: 无错误
+
+- [ ] **Step 3: 提交**
+
+```bash
+git add src/components/novel/story-simulation/framework-confirm-panel.tsx
+git commit -m "feat(story-simulation): 实现框架确认面板 UI"
+```
+
+---
+
+## Task 16: 推演报告展示 UI
+
+**Files:**
+- Create: `src/components/novel/story-simulation/simulation-report-view.tsx`
+
+- [ ] **Step 1: 创建报告展示**
+
+```tsx
+// src/components/novel/story-simulation/simulation-report-view.tsx
+
+import { useStorySimulationStore } from "@/stores/story-simulation-store"
+import { useTranslation } from "react-i18next"
+import { Button } from "@/components/ui/button"
+import type { StoryBranch } from "@/lib/novel/story-simulation/types"
+
+export function SimulationReportView({
+  onResimulate,
+  onGenerateDraft,
+}: {
+  onResimulate: () => void
+  onGenerateDraft: (branch: StoryBranch) => void
+}) {
+  const { t } = useTranslation()
+  const { currentReport } = useStorySimulationStore()
+
+  if (!currentReport) return null
+
+  return (
+    <div className="mx-auto max-w-3xl space-y-6 p-6">
+      <div className="flex items-center justify-between">
+        <h2 className="text-xl font-bold">{t("storySimulation.reportTitle")}</h2>
+        <Button variant="outline" onClick={onResimulate}>
+          {t("storySimulation.resimulate")}
+        </Button>
+      </div>
+
+      {/* 角色行为分析 */}
+      <div>
+        <h3 className="mb-3 text-lg font-semibold">{t("storySimulation.characterAnalysis")}</h3>
+        <div className="space-y-3">
+          {currentReport.characterAnalyses.map((ca) => (
+            <div key={ca.name} className="rounded-lg border p-4">
+              <div className="mb-2 flex items-center justify-between">
+                <h4 className="font-medium">{ca.name}</h4>
+                <span className="text-sm text-muted-foreground">
+                  {t("storySimulation.consistencyScore")}:{ca.consistencyScore}
+                </span>
+              </div>
+              <ul className="space-y-1 text-sm text-muted-foreground">
+                {ca.behaviors.map((b, i) => (
+                  <li key={i}>
+                    <span className="font-medium text-foreground">{b.node}:</span>
+                    {b.action}
+                    <span className="ml-1 text-xs">({b.motivation})</span>
+                  </li>
+                ))}
+              </ul>
+              {ca.stateChanges.length > 0 && (
+                <p className="mt-2 text-xs text-muted-foreground">
+                  {ca.stateChanges.join(";")}
+                </p>
+              )}
+            </div>
+          ))}
+        </div>
+      </div>
+
+      {/* 走向分支 */}
+      <div>
+        <h3 className="mb-3 text-lg font-semibold">{t("storySimulation.storyBranches")}</h3>
+        <div className="space-y-3">
+          {currentReport.branches.map((branch, i) => (
+            <div
+              key={i}
+              className={`rounded-lg border p-4 ${branch.recommendation ? "border-primary" : ""}`}
+            >
+              <div className="mb-2 flex items-center justify-between">
+                <h4 className="font-medium">
+                  {branch.title}
+                  {branch.recommendation && (
+                    <span className="ml-2 rounded bg-primary/10 px-1.5 py-0.5 text-xs text-primary">
+                      {t("storySimulation.recommendation")}
+                    </span>
+                  )}
+                </h4>
+                <span className="text-sm text-muted-foreground">
+                  {t("storySimulation.probability")}:
+                  {t(branch.probability === "high" ? "storySimulation.probabilityHigh" : branch.probability === "medium" ? "storySimulation.probabilityMedium" : "storySimulation.probabilityLow")}
+                </span>
+              </div>
+              <p className="mb-2 text-sm">{branch.summary}</p>
+              <ul className="mb-2 list-disc pl-4 text-xs text-muted-foreground">
+                {branch.keyEvents.map((e, j) => (
+                  <li key={j}>{e}</li>
+                ))}
+              </ul>
+              <div className="grid grid-cols-2 gap-2 text-sm">
+                <div>
+                  <span className="text-muted-foreground">{t("storySimulation.pros")}:</span>
+                  {branch.pros}
+                </div>
+                <div>
+                  <span className="text-muted-foreground">{t("storySimulation.cons")}:</span>
+                  {branch.cons}
+                </div>
+              </div>
+              <Button
+                size="sm"
+                className="mt-3"
+                onClick={() => onGenerateDraft(branch)}
+              >
+                {t("storySimulation.generateDraft")}
+              </Button>
+            </div>
+          ))}
+        </div>
+      </div>
+
+      {/* 综合推荐 */}
+      <div className="rounded-lg border bg-muted/30 p-4">
+        <h3 className="mb-2 font-semibold">{t("storySimulation.recommendation")}</h3>
+        <p className="text-sm">{currentReport.recommendation}</p>
+      </div>
+    </div>
+  )
+}
+```
+
+- [ ] **Step 2: 验证类型检查**
+
+Run: `cd C:\QMAI_C\QMAI-main && npx tsc --noEmit --pretty 2>&1 | grep -i "simulation-report-view" | head -5`
+Expected: 无错误
+
+- [ ] **Step 3: 提交**
+
+```bash
+git add src/components/novel/story-simulation/simulation-report-view.tsx
+git commit -m "feat(story-simulation): 实现推演报告展示 UI"
+```
+
+---
+
+## Task 17: 故事草稿展示 UI
+
+**Files:**
+- Create: `src/components/novel/story-simulation/story-draft-view.tsx`
+
+- [ ] **Step 1: 创建草稿展示**
+
+```tsx
+// src/components/novel/story-simulation/story-draft-view.tsx
+
+import { useStorySimulationStore } from "@/stores/story-simulation-store"
+import { useTranslation } from "react-i18next"
+import { Button } from "@/components/ui/button"
+import { useState } from "react"
+
+export function StoryDraftView({
+  onBack,
+}: {
+  onBack: () => void
+}) {
+  const { t } = useTranslation()
+  const { currentDraft } = useStorySimulationStore()
+  const [copied, setCopied] = useState(false)
+
+  if (!currentDraft) return null
+
+  const handleCopyAll = () => {
+    const text = currentDraft.chapters
+      .map((ch) => `# ${ch.title}\n\n${ch.content}`)
+      .join("\n\n---\n\n")
+    navigator.clipboard.writeText(text)
+    setCopied(true)
+    setTimeout(() => setCopied(false), 2000)
+  }
+
+  return (
+    <div className="mx-auto max-w-3xl space-y-4 p-6">
+      <div className="flex items-center justify-between">
+        <h2 className="text-xl font-bold">{t("storySimulation.draftTitle")}</h2>
+        <div className="flex gap-2">
+          <Button variant="outline" size="sm" onClick={handleCopyAll}>
+            {copied ? "✓" : t("storySimulation.copyAll")}
+          </Button>
+          <Button variant="outline" size="sm" onClick={onBack}>
+            {t("common.back")}
+          </Button>
+        </div>
+      </div>
+
+      <div className="text-sm text-muted-foreground">
+        {t("storySimulation.totalWords")}:{currentDraft.totalWords}
+      </div>
+
+      <div className="space-y-4">
+        {currentDraft.chapters.map((ch, i) => (
+          <div key={i} className="rounded-lg border p-4">
+            <h3 className="mb-2 font-medium">{ch.title}</h3>
+            <div className="prose prose-sm max-w-none whitespace-pre-wrap text-sm">
+              {ch.content}
+            </div>
+          </div>
+        ))}
+      </div>
+    </div>
+  )
+}
+```
+
+- [ ] **Step 2: 验证类型检查**
+
+Run: `cd C:\QMAI_C\QMAI-main && npx tsc --noEmit --pretty 2>&1 | grep -i "story-draft-view" | head -5`
+Expected: 无错误
+
+- [ ] **Step 3: 提交**
+
+```bash
+git add src/components/novel/story-simulation/story-draft-view.tsx
+git commit -m "feat(story-simulation): 实现故事草稿展示 UI"
+```
+
+---
+
+## Task 18: 框架列表和绑定对话框 UI
+
+**Files:**
+- Create: `src/components/novel/story-simulation/framework-list.tsx`
+- Create: `src/components/novel/story-simulation/framework-binding-dialog.tsx`
+
+- [ ] **Step 1: 创建框架列表**
+
+```tsx
+// src/components/novel/story-simulation/framework-list.tsx
+
+import { useStorySimulationStore } from "@/stores/story-simulation-store"
+import { useTranslation } from "react-i18next"
+import { Button } from "@/components/ui/button"
+import { useState, useEffect } from "react"
+import { loadFrameworks } from "@/lib/novel/story-simulation/framework-store"
+import { loadBinding } from "@/lib/novel/story-simulation/framework-binding"
+import { useWikiStore } from "@/stores/wiki-store"
+import type { StoryFramework, FrameworkBinding } from "@/lib/novel/story-simulation/types"
+import { FrameworkBindingDialog } from "./framework-binding-dialog"
+
+export function FrameworkList({
+  onSelectFramework,
+  onNewFramework,
+}: {
+  onSelectFramework: (framework: StoryFramework) => void
+  onNewFramework: () => void
+}) {
+  const { t } = useTranslation()
+  const projectPath = useWikiStore((s) => s.projectPath)
+  const { setFrameworks, setSelectedFrameworkId, setBinding } = useStorySimulationStore()
+  const [localFrameworks, setLocalFrameworks] = useState<StoryFramework[]>([])
+  const [localBinding, setLocalBinding] = useState<FrameworkBinding | null>(null)
+  const [bindingDialogOpen, setBindingDialogOpen] = useState(false)
+  const [selectedFramework, setSelectedFramework] = useState<StoryFramework | null>(null)
+
+  useEffect(() => {
+    loadList()
+  }, [projectPath])
+
+  const loadList = async () => {
+    if (!projectPath) return
+    const frameworks = await loadFrameworks(projectPath)
+    const binding = await loadBinding(projectPath)
+    setLocalFrameworks(frameworks)
+    setLocalBinding(binding)
+    setFrameworks(frameworks)
+    setBinding(binding)
+  }
+
+  const handleBindClick = (framework: StoryFramework) => {
+    setSelectedFramework(framework)
+    setBindingDialogOpen(true)
+  }
+
+  return (
+    <div className="flex h-full flex-col">
+      <div className="border-b p-2">
+        <Button size="sm" className="w-full" onClick={onNewFramework}>
+          {t("storySimulation.newFramework")}
+        </Button>
+      </div>
+      <div className="flex-1 overflow-auto p-2">
+        {localFrameworks.length === 0 ? (
+          <p className="p-4 text-center text-sm text-muted-foreground">
+            {t("storySimulation.noFrameworks")}
+          </p>
+        ) : (
+          <div className="space-y-1">
+            {localFrameworks.map((fw) => (
+              <div
+                key={fw.id}
+                className="rounded-md border p-2 transition-colors hover:bg-accent/30"
+              >
+                <button
+                  onClick={() => {
+                    setSelectedFrameworkId(fw.id)
+                    onSelectFramework(fw)
+                  }}
+                  className="block w-full text-left"
+                >
+                  <div className="text-sm font-medium">{fw.title}</div>
+                  <div className="text-xs text-muted-foreground">
+                    {fw.nodes.length} {t("storySimulation.phase")} · {fw.targetWords} {t("storySimulation.chapters")}
+                  </div>
+                </button>
+                <div className="mt-1 flex gap-1">
+                  <Button
+                    size="sm"
+                    variant="ghost"
+                    className="h-6 px-2 text-xs"
+                    onClick={() => handleBindClick(fw)}
+                  >
+                    {localBinding?.frameworkId === fw.id
+                      ? t("storySimulation.unbindFromChat")
+                      : t("storySimulation.bindToChat")}
+                  </Button>
+                </div>
+              </div>
+            ))}
+          </div>
+        )}
+      </div>
+      {selectedFramework && (
+        <FrameworkBindingDialog
+          open={bindingDialogOpen}
+          onOpenChange={setBindingDialogOpen}
+          framework={selectedFramework}
+          onBound={() => loadList()}
+        />
+      )}
+    </div>
+  )
+}
+```
+
+- [ ] **Step 2: 创建绑定对话框**
+
+```tsx
+// src/components/novel/story-simulation/framework-binding-dialog.tsx
+
+import { useTranslation } from "react-i18next"
+import { Button } from "@/components/ui/button"
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
+import { useState, useEffect } from "react"
+import { saveBinding, clearBinding } from "@/lib/novel/story-simulation/framework-binding"
+import { useWikiStore } from "@/stores/wiki-store"
+import { useStorySimulationStore } from "@/stores/story-simulation-store"
+import type { StoryFramework } from "@/lib/novel/story-simulation/types"
+
+const CHAPTER_COUNT_OPTIONS = [5, 10, 20, 30, 50]
+
+export function FrameworkBindingDialog({
+  open,
+  onOpenChange,
+  framework,
+  onBound,
+}: {
+  open: boolean
+  onOpenChange: (open: boolean) => void
+  framework: StoryFramework
+  onBound: () => void
+}) {
+  const { t } = useTranslation()
+  const projectPath = useWikiStore((s) => s.projectPath)
+  const { binding, setBinding } = useStorySimulationStore()
+  const [chapterCount, setChapterCount] = useState(10)
+  const isBound = binding?.frameworkId === framework.id
+
+  useEffect(() => {
+    if (open && isBound && binding) {
+      setChapterCount(binding.targetChapterCount)
+    }
+  }, [open, isBound, binding])
+
+  const handleConfirm = async () => {
+    if (!projectPath) return
+    const newBinding = await saveBinding(projectPath, framework, chapterCount)
+    setBinding(newBinding)
+    onBound()
+    onOpenChange(false)
+  }
+
+  const handleUnbind = async () => {
+    if (!projectPath) return
+    await clearBinding(projectPath)
+    setBinding(null)
+    onBound()
+    onOpenChange(false)
+  }
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange}>
+      <DialogContent>
+        <DialogHeader>
+          <DialogTitle>{t("storySimulation.bindingTitle")}</DialogTitle>
+        </DialogHeader>
+        <div className="space-y-4 py-4">
+          <div>
+            <label className="text-sm font-medium">{t("storySimulation.selectFramework")}</label>
+            <div className="mt-1 rounded-md border p-2 text-sm">{framework.title}</div>
+          </div>
+          <div>
+            <label className="text-sm font-medium">{t("storySimulation.targetChapterCount")}</label>
+            <div className="mt-1 flex gap-2">
+              {CHAPTER_COUNT_OPTIONS.map((n) => (
+                <button
+                  key={n}
+                  onClick={() => setChapterCount(n)}
+                  className={`rounded-md border px-3 py-1 text-sm transition-colors ${
+                    chapterCount === n
+                      ? "border-primary bg-primary/5"
+                      : "border-border hover:border-primary/50"
+                  }`}
+                >
+                  {n}
+                </button>
+              ))}
+            </div>
+          </div>
+          <p className="text-sm text-muted-foreground">{t("storySimulation.bindingHint")}</p>
+        </div>
+        <DialogFooter>
+          {isBound && (
+            <Button variant="outline" onClick={handleUnbind}>
+              {t("storySimulation.unbindFromChat")}
+            </Button>
+          )}
+          <Button variant="outline" onClick={() => onOpenChange(false)}>
+            {t("common.cancel")}
+          </Button>
+          <Button onClick={handleConfirm}>
+            {t("storySimulation.confirmBinding")}
+          </Button>
+        </DialogFooter>
+      </DialogContent>
+    </Dialog>
+  )
+}
+```
+
+- [ ] **Step 3: 验证类型检查**
+
+Run: `cd C:\QMAI_C\QMAI-main && npx tsc --noEmit --pretty 2>&1 | grep -i "framework-list\|framework-binding-dialog" | head -5`
+Expected: 无错误
+
+- [ ] **Step 4: 提交**
+
+```bash
+git add src/components/novel/story-simulation/framework-list.tsx src/components/novel/story-simulation/framework-binding-dialog.tsx
+git commit -m "feat(story-simulation): 实现框架列表和绑定对话框 UI"
+```
+
+---
+
+## Task 19: 主视图集成
+
+**Files:**
+- Modify: `src/components/novel/story-simulation/story-simulation-view.tsx`
+
+- [ ] **Step 1: 重写主视图,集成所有面板**
+
+将 `src/components/novel/story-simulation/story-simulation-view.tsx` 替换为完整的主视图:
+
+```tsx
+// src/components/novel/story-simulation/story-simulation-view.tsx
+
+import { useStorySimulationStore } from "@/stores/story-simulation-store"
+import { useWikiStore } from "@/stores/wiki-store"
+import { useTranslation } from "react-i18next"
+import { SimulationConfigPanel } from "./simulation-config-panel"
+import { FrameworkConfirmPanel } from "./framework-confirm-panel"
+import { SimulationReportView } from "./simulation-report-view"
+import { StoryDraftView } from "./story-draft-view"
+import { FrameworkList } from "./framework-list"
+import { extractStoryContent } from "@/lib/novel/story-simulation/story-extractor"
+import { generateStoryFramework } from "@/lib/novel/story-simulation/story-framework-generator"
+import { buildAgents } from "@/lib/novel/story-simulation/agent-profile-builder"
+import { runSimulation } from "@/lib/novel/story-simulation/simulation-engine"
+import { generateSimulationReport } from "@/lib/novel/story-simulation/simulation-report-agent"
+import { generateStoryDraft } from "@/lib/novel/story-simulation/story-draft-generator"
+import { saveFramework, loadSimulationResults } from "@/lib/novel/story-simulation/framework-store"
+import { resolveDefaultModel } from "@/lib/novel/model-resolver"
+import type { StoryFramework, StoryBranch, StoryDraft } from "@/lib/novel/story-simulation/types"
+
+export function StorySimulationView() {
+  const { t } = useTranslation()
+  const projectPath = useWikiStore((s) => s.projectPath)
+  const llmConfig = useWikiStore((s) => s.llmConfig)
+  const store = useStorySimulationStore()
+  const {
+    phase, mode, userIdea, targetWords, sourceChapters,
+    setPhase, setProgress, setError, reset,
+    setExtractionResult, setCurrentFramework, setCurrentReport, setCurrentDraft,
+    setSelectedFrameworkId,
+  } = store
+
+  const handleStart = async () => {
+    if (!projectPath) return
+    setPhase("extracting")
+    setError(null)
+    setProgress(0, t("storySimulation.extracting"))
+
+    try {
+      // 1. 提取内容
+      const extraction = await extractStoryContent(projectPath, {
+        sourceChapters,
+        onProgress: (p, label) => setProgress(p, label),
+      })
+      setExtractionResult(extraction)
+
+      // 2. 生成框架
+      setPhase("framework-generating")
+      setProgress(0, t("storySimulation.frameworkTitle"))
+
+      const llm = resolveDefaultModel(llmConfig)
+      const framework = await generateStoryFramework({
+        extraction,
+        mode,
+        targetWords,
+        userIdea: userIdea || undefined,
+        llmConfig: llm,
+        onProgress: (label) => setProgress(0, label),
+      })
+
+      setCurrentFramework(framework)
+      setPhase("framework-confirming")
+    } catch (e) {
+      setError((e as Error).message)
+      setPhase("configuring")
+    }
+  }
+
+  const handleConfirmFramework = async () => {
+    if (!projectPath || !store.currentFramework) return
+
+    // 保存框架
+    await saveFramework(projectPath, store.currentFramework)
+
+    setPhase("simulating")
+    setProgress(0, t("storySimulation.simulating"))
+
+    try {
+      const extraction = store.extractionResult!
+      const framework = store.currentFramework
+      const agents = buildAgents(extraction, framework)
+      const llm = resolveDefaultModel(llmConfig)
+
+      const events = await runSimulation(
+        {
+          agents,
+          framework,
+          mode,
+          wordBudget: targetWords,
+          llmConfig: llm,
+          userIdea: userIdea || undefined,
+        },
+        extraction,
+        {
+          onEvent: () => {},
+          onProgress: (p, label) => setProgress(p, label),
+          onComplete: () => {},
+          onError: (e) => { throw e },
+        },
+      )
+
+      // 生成报告
+      setPhase("report-generating")
+      setProgress(0, t("storySimulation.reportTitle"))
+
+      const report = await generateSimulationReport({
+        events,
+        framework,
+        mode,
+        llmConfig: llm,
+        onProgress: (label) => setProgress(0, label),
+      })
+
+      setCurrentReport(report)
+      setPhase("report-viewing")
+    } catch (e) {
+      setError((e as Error).message)
+      setPhase("framework-confirming")
+    }
+  }
+
+  const handleRegenerateFramework = async () => {
+    // 重新生成框架
+    setPhase("framework-generating")
+    await handleStart()
+  }
+
+  const handleResimulate = () => {
+    setPhase("framework-confirming")
+  }
+
+  const handleGenerateDraft = async (branch: StoryBranch) => {
+    if (!store.currentFramework || !store.currentReport) return
+
+    setPhase("draft-generating")
+    setProgress(0, t("storySimulation.draftTitle"))
+
+    try {
+      const llm = resolveDefaultModel(llmConfig)
+      const draft = await generateStoryDraft({
+        framework: store.currentFramework,
+        report: store.currentReport,
+        selectedBranch: branch,
+        llmConfig: llm,
+        onProgress: (label) => setProgress(0, label),
+      })
+
+      setCurrentDraft(draft)
+      setPhase("draft-viewing")
+    } catch (e) {
+      setError((e as Error).message)
+      setPhase("report-viewing")
+    }
+  }
+
+  const handleNewFramework = () => {
+    reset()
+    setPhase("configuring")
+  }
+
+  const handleSelectFramework = (framework: StoryFramework) => {
+    setCurrentFramework(framework)
+    setPhase("framework-confirming")
+  }
+
+  return (
+    <div className="flex h-full">
+      {/* 二栏:框架列表 */}
+      <div className="w-56 border-r">
+        <FrameworkList
+          onSelectFramework={handleSelectFramework}
+          onNewFramework={handleNewFramework}
+        />
+      </div>
+
+      {/* 三栏:内容区 */}
+      <div className="flex-1 overflow-auto">
+        {phase === "idle" || phase === "configuring" ? (
+          <SimulationConfigPanel onStart={handleStart} />
+        ) : phase === "extracting" || phase === "framework-generating" || phase === "simulating" || phase === "report-generating" || phase === "draft-generating" ? (
+          <div className="flex h-full flex-col items-center justify-center gap-4">
+            <div className="text-lg font-medium">{store.progressLabel}</div>
+            <div className="h-2 w-64 overflow-hidden rounded-full bg-muted">
+              <div
+                className="h-full bg-primary transition-all"
+                style={{ width: `${store.progress}%` }}
+              />
+            </div>
+          </div>
+        ) : phase === "framework-confirming" ? (
+          <FrameworkConfirmPanel
+            onConfirm={handleConfirmFramework}
+            onRegenerate={handleRegenerateFramework}
+          />
+        ) : phase === "report-viewing" ? (
+          <SimulationReportView
+            onResimulate={handleResimulate}
+            onGenerateDraft={handleGenerateDraft}
+          />
+        ) : phase === "draft-viewing" ? (
+          <StoryDraftView onBack={() => setPhase("report-viewing")} />
+        ) : null}
+
+        {store.error && (
+          <div className="absolute bottom-4 left-1/2 -translate-x-1/2 rounded-lg border border-destructive bg-destructive/10 p-3 text-sm text-destructive">
+            {store.error}
+          </div>
+        )}
+      </div>
+    </div>
+  )
+}
+```
+
+- [ ] **Step 2: 验证类型检查**
+
+Run: `cd C:\QMAI_C\QMAI-main && npx tsc --noEmit --pretty 2>&1 | grep -i "story-simulation-view" | head -5`
+Expected: 无错误
+
+- [ ] **Step 3: 验证 dev server 可启动**
+
+Run: `cd C:\QMAI_C\QMAI-main && npm run dev`
+Expected: dev server 正常启动
+
+- [ ] **Step 4: 提交**
+
+```bash
+git add src/components/novel/story-simulation/story-simulation-view.tsx
+git commit -m "feat(story-simulation): 实现主视图集成(完整流程串联)"
+```
+
+---
+
+## Task 20: 测试版打包命名
+
+**Files:**
+- Modify: `scripts/build-portable.mjs`
+
+- [ ] **Step 1: 修改打包脚本,支持测试版命名**
+
+在 `scripts/build-portable.mjs` 中,修改输出文件名和 manifest 信息。在文件头部添加分支检测:
+
+在第 6 行 `const pkg = ...` 后添加:
+
+```javascript
+// 检测当前分支,如果是 feature-story-simulation 则使用"剧情推演版"命名
+import { execSync } from "node:child_process"
+let currentBranch = ""
+try {
+  currentBranch = execSync("git rev-parse --abbrev-ref HEAD").toString().trim()
+} catch {}
+
+const isStorySimulationBranch = currentBranch === "feature-story-simulation"
+const variantName = isStorySimulationBranch ? "剧情推演版" : ""
+```
+
+修改第 16 行的 `outExe`:
+
+```javascript
+// 修改前
+const outExe = resolve(outDir, "QMaiWrite.exe")
+
+// 修改后
+const outExe = resolve(outDir, isStorySimulationBranch ? "QMaiWrite-剧情推演版.exe" : "QMaiWrite.exe")
+```
+
+修改第 79-88 行的 manifest:
+
+```javascript
+// 修改前
+writeFileSync(manifest, JSON.stringify({
+  productName: "青幕AI写作",
+  version: pkg.version,
+  builtAt: new Date().toISOString(),
+  sourceExe,
+  portableExe: outExe,
+  exeBytes: exeStat.size,
+  includesPdfium: existsSync(outPdfium),
+  includesSkills: existsSync(outSkillDir),
+}, null, 2), "utf8")
+
+// 修改后
+writeFileSync(manifest, JSON.stringify({
+  productName: isStorySimulationBranch ? "青幕AI写作(剧情推演版)" : "青幕AI写作",
+  version: pkg.version,
+  variant: isStorySimulationBranch ? "story-simulation-test" : "stable",
+  branch: currentBranch,
+  builtAt: new Date().toISOString(),
+  sourceExe,
+  portableExe: outExe,
+  exeBytes: exeStat.size,
+  includesPdfium: existsSync(outPdfium),
+  includesSkills: existsSync(outSkillDir),
+}, null, 2), "utf8")
+```
+
+修改最后的输出日志:
+
+```javascript
+// 修改前
+console.log(`便携版已生成:${outExe}`)
+console.log(`版本信息:${manifest}`)
+
+// 修改后
+if (isStorySimulationBranch) {
+  console.log(`剧情推演版便携版已生成:${outExe}`)
+  console.log(`注意:这是测试版,不可上传到 GitHub main 分支`)
+} else {
+  console.log(`便携版已生成:${outExe}`)
+}
+console.log(`版本信息:${manifest}`)
+```
+
+- [ ] **Step 2: 验证打包脚本语法**
+
+Run: `cd C:\QMAI_C\QMAI-main && node -c scripts/build-portable.mjs`
+Expected: 无语法错误
+
+- [ ] **Step 3: 提交**
+
+```bash
+git add scripts/build-portable.mjs
+git commit -m "feat(story-simulation): 测试版打包命名显示'剧情推演版'"
+```
+
+---
+
+## Task 21: 验证旧功能完整性
+
+- [ ] **Step 1: 切换到 main 分支验证旧功能不受影响**
+
+Run: `cd C:\QMAI_C\QMAI-main && git stash && git checkout main && npm run dev`
+Expected: main 分支正常启动,所有旧功能可用
+
+- [ ] **Step 2: 切回 feature 分支**
+
+Run: `cd C:\QMAI_C\QMAI-main && git checkout feature-story-simulation && git stash pop`
+Expected: feature 分支正常
+
+- [ ] **Step 3: 验证 feature 分支旧功能仍然可用**
+
+在 feature 分支上测试:
+- 章节写作功能正常
+- 拆书库功能正常
+- 记忆中心功能正常
+- 图谱功能正常
+- 审查中心功能正常
+- 设置功能正常
+
+- [ ] **Step 4: 提交验证记录**
+
+```bash
+git commit --allow-empty -m "chore(story-simulation): 验证旧功能完整性,所有功能正常"
+```
+
+---
+
+## 自检清单
+
+- [ ] **Spec coverage:**
+  - 全维度提取(Task 5)✓
+  - 故事框架(Task 7)✓
+  - 仿真引擎四种模式(Task 8-9)✓
+  - 推演报告(Task 10)✓
+  - 故事草稿(Task 11)✓
+  - 框架保存(Task 12)✓
+  - AI 会话绑定(Task 13)✓
+  - 单页配置(Task 14)✓
+  - 框架确认(Task 15)✓
+  - 报告展示(Task 16)✓
+  - 草稿展示(Task 17)✓
+  - 框架列表(Task 18)✓
+  - 主视图集成(Task 19)✓
+  - 测试版打包命名(Task 20)✓
+  - 旧功能验证(Task 21)✓
+
+- [ ] **Placeholder scan:** 无 TBD/TODO
+- [ ] **Type consistency:** types.ts 中的类型在各模块中一致使用
+- [ ] **工程隔离:** 所有代码在 feature-story-simulation 分支,不影响 main

+ 1080 - 0
docs/superpowers/plans/2026-06-27-story-sim-optimization-plan.md

@@ -0,0 +1,1080 @@
+# 剧情推演室第四轮优化 实现计划
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 实现框架节点拖拽排序、草稿章节预览编辑、采访继续对话、对比差异高亮四个功能
+
+**Architecture:** 在现有 feature-more-optimizations 分支上开发。新增 @dnd-kit 依赖用于拖拽,复用已有 milkdown 编辑器用于草稿编辑,复用已有 simulation-serializer 用于采访续聊的 agent 恢复,在 ReportContent 中扩展对比高亮逻辑。四个功能相互独立,按功能分 Task 实现。
+
+**Tech Stack:** React + TypeScript + Zustand + @dnd-kit/sortable + milkdown + Tailwind CSS
+
+---
+
+## 文件结构
+
+| 文件 | 操作 | 职责 |
+|------|------|------|
+| `package.json` | 修改 | 添加 @dnd-kit/core 和 @dnd-kit/sortable 依赖 |
+| `src/lib/novel/story-simulation/types.ts` | 修改 | DraftChapter 添加 rawContent 字段 |
+| `src/lib/novel/story-simulation/interview-store.ts` | 修改 | SavedInterview 添加 agentSnapshot 字段,saveInterview 添加参数 |
+| `src/stores/story-simulation-store.ts` | 修改 | 添加续聊模式状态、草稿编辑状态 |
+| `src/components/novel/story-simulation/framework-confirm-panel.tsx` | 修改 | 节点列表改为可拖拽 |
+| `src/components/novel/story-simulation/story-draft-view.tsx` | 修改 | 添加章节编辑弹窗 |
+| `src/components/novel/story-simulation/interview-history-view.tsx` | 修改 | 添加"继续对话"按钮和恢复逻辑 |
+| `src/components/novel/story-simulation/story-simulation-view.tsx` | 修改 | 采访续聊状态管理、保存采访时传入 agentSnapshot |
+| `src/components/novel/story-simulation/simulation-report-view.tsx` | 修改 | ReportContent 添加对比差异高亮 |
+
+---
+
+## Task 1: 安装 @dnd-kit 依赖
+
+**Files:**
+- Modify: `package.json`
+
+- [ ] **Step 1: 安装依赖**
+
+```bash
+npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities
+```
+
+- [ ] **Step 2: 验证安装成功**
+
+```bash
+node -e "require('@dnd-kit/core'); require('@dnd-kit/sortable'); require('@dnd-kit/utilities'); console.log('OK')"
+```
+Expected: 输出 `OK`
+
+- [ ] **Step 3: 提交**
+
+```bash
+git add package.json package-lock.json
+git commit -m "chore: 添加 @dnd-kit 拖拽排序依赖"
+```
+
+---
+
+## Task 2: 扩展 DraftChapter 类型
+
+**Files:**
+- Modify: `src/lib/novel/story-simulation/types.ts:255-259`
+
+- [ ] **Step 1: 添加 rawContent 可选字段**
+
+在 `DraftChapter` 接口中添加 `rawContent` 字段:
+
+```typescript
+export interface DraftChapter {
+  title: string
+  content: string
+  correspondingNode: number
+  /** 原始 AI 生成内容(编辑前的备份),未编辑时为 undefined */
+  rawContent?: string
+}
+```
+
+- [ ] **Step 2: 类型检查**
+
+```bash
+npx tsc --noEmit
+```
+Expected: 无错误
+
+- [ ] **Step 3: 提交**
+
+```bash
+git add src/lib/novel/story-simulation/types.ts
+git commit -m "feat: DraftChapter 添加 rawContent 字段用于草稿编辑备份"
+```
+
+---
+
+## Task 3: 扩展 SavedInterview 和 saveInterview 支持 agentSnapshot
+
+**Files:**
+- Modify: `src/lib/novel/story-simulation/interview-store.ts`
+- Modify: `src/components/novel/story-simulation/story-simulation-view.tsx`(保存采访时传入 agentSnapshot)
+
+- [ ] **Step 1: SavedInterview 添加 agentSnapshot 字段**
+
+在 `interview-store.ts` 的 `SavedInterview` 接口中添加:
+
+```typescript
+import type { SerializedSimulationSnapshot } from "./simulation-serializer"
+
+export interface SavedInterview {
+  id: string
+  agentName: string
+  frameworkId?: string
+  frameworkTitle?: string
+  createdAt: string
+  updatedAt: string
+  session: AgentChatSession
+  /** 推演时的 agent 快照,用于继续对话时恢复角色状态 */
+  agentSnapshot?: SerializedSimulationSnapshot
+}
+```
+
+- [ ] **Step 2: saveInterview 函数添加 agentSnapshot 参数**
+
+修改 `saveInterview` 函数签名,在 options 中添加 `agentSnapshot`:
+
+```typescript
+export async function saveInterview(
+  projectPath: string,
+  session: AgentChatSession,
+  options?: {
+    frameworkId?: string
+    frameworkTitle?: string
+    existingId?: string
+    agentSnapshot?: SerializedSimulationSnapshot
+  },
+): Promise<string> {
+  // ... 现有代码不变 ...
+  const payload: SavedInterview = {
+    id,
+    agentName: session.agentName,
+    frameworkId: options?.frameworkId,
+    frameworkTitle: options?.frameworkTitle,
+    createdAt: now,
+    updatedAt: now,
+    session,
+    agentSnapshot: options?.agentSnapshot,
+  }
+  // ... 后续不变 ...
+}
+```
+
+- [ ] **Step 3: 在 story-simulation-view.tsx 中保存采访时传入 agentSnapshot**
+
+找到保存采访的代码(`handleSaveChat` 或类似函数),在调用 `saveInterview` 时传入 `agentSnapshot`:
+
+```typescript
+// 在 story-simulation-view.tsx 的 handleSaveChat 函数中
+import { serializeSimulationState } from "@/lib/novel/story-simulation/simulation-serializer"
+
+// 保存时传入当前 agents 快照
+const agentSnapshot = lastSimulationStateRef.current && lastAgentsRef.current.length > 0
+  ? serializeSimulationState(lastSimulationStateRef.current, lastAgentsRef.current)
+  : undefined
+
+await saveInterview(projectPath, session, {
+  frameworkId: currentFramework?.id,
+  frameworkTitle: currentFramework?.title,
+  existingId: options?.existingId,
+  agentSnapshot,
+})
+```
+
+- [ ] **Step 4: 类型检查**
+
+```bash
+npx tsc --noEmit
+```
+Expected: 无错误
+
+- [ ] **Step 5: 提交**
+
+```bash
+git add src/lib/novel/story-simulation/interview-store.ts src/components/novel/story-simulation/story-simulation-view.tsx
+git commit -m "feat: 采访保存支持 agentSnapshot 持久化"
+```
+
+---
+
+## Task 4: 框架节点拖拽排序
+
+**Files:**
+- Modify: `src/components/novel/story-simulation/framework-confirm-panel.tsx`
+
+- [ ] **Step 1: 添加 dnd-kit 导入和拖拽相关 hook**
+
+在文件顶部添加导入:
+
+```typescript
+import { useState } from "react"
+import { useTranslation } from "react-i18next"
+import { Check, Pencil, X, GripVertical } from "lucide-react"
+import {
+  DndContext,
+  closestCenter,
+  KeyboardSensor,
+  PointerSensor,
+  useSensor,
+  useSensors,
+  type DragEndEvent,
+} from "@dnd-kit/core"
+import {
+  arrayMove,
+  SortableContext,
+  sortableKeyboardCoordinates,
+  verticalListSortingStrategy,
+  useSortable,
+} from "@dnd-kit/sortable"
+import { CSS } from "@dnd-kit/utilities"
+
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Textarea } from "@/components/ui/textarea"
+import { useStorySimulationStore } from "@/stores/story-simulation-store"
+import type { StoryNode } from "@/lib/novel/story-simulation/types"
+import { cn } from "@/lib/utils"
+```
+
+- [ ] **Step 2: 添加 SortableNodeCard 组件**
+
+在 `FrameworkNodeCard` 组件定义之前,添加一个可排序包装组件:
+
+```typescript
+/** 可排序的节点卡片包装器 */
+function SortableNodeCard({
+  node,
+  onUpdate,
+}: {
+  node: StoryNode
+  onUpdate: (updates: Partial<StoryNode>) => void
+}) {
+  const {
+    attributes,
+    listeners,
+    setNodeRef,
+    transform,
+    transition,
+    isDragging,
+  } = useSortable({ id: node.index })
+
+  const style = {
+    transform: CSS.Transform.toString(transform),
+    transition,
+    opacity: isDragging ? 0.5 : 1,
+  }
+
+  return (
+    <div ref={setNodeRef} style={style} className="relative">
+      {/* 拖拽手柄 */}
+      <button
+        type="button"
+        className="absolute left-0 top-0 z-10 flex h-full w-6 cursor-grab items-center justify-center text-muted-foreground/30 hover:text-primary active:cursor-grabbing"
+        {...attributes}
+        {...listeners}
+        title="拖拽排序"
+      >
+        <GripVertical className="h-4 w-4" />
+      </button>
+      <div className="pl-6">
+        <FrameworkNodeCard node={node} onUpdate={onUpdate} />
+      </div>
+    </div>
+  )
+}
+```
+
+- [ ] **Step 3: 在 FrameworkConfirmPanel 中使用 DndContext 替换原节点列表**
+
+将原来第222-236行的节点列表部分替换为:
+
+```typescript
+  // 拖拽传感器
+  const sensors = useSensors(
+    useSensor(PointerSensor, {
+      activationConstraint: { distance: 5 },
+    }),
+    useSensor(KeyboardSensor, {
+      coordinateGetter: sortableKeyboardCoordinates,
+    }),
+  )
+
+  // 拖拽结束时重排节点
+  const handleDragEnd = (event: DragEndEvent) => {
+    const { active, over } = event
+    if (!over || active.id === over.id || !currentFramework) return
+
+    const sortedNodes = currentFramework.nodes.slice().sort((a, b) => a.index - b.index)
+    const oldIndex = sortedNodes.findIndex((n) => n.index === active.id)
+    const newIndex = sortedNodes.findIndex((n) => n.index === over.id)
+    if (oldIndex === -1 || newIndex === -1) return
+
+    const reordered = arrayMove(sortedNodes, oldIndex, newIndex)
+    // 重新分配 index,保持 phase 不变
+    const updatedNodes = reordered.map((n, i) => ({ ...n, index: i }))
+    setCurrentFramework({
+      ...currentFramework,
+      nodes: updatedNodes,
+    })
+    // 显示未保存提示
+    setSavedTip(false)
+  }
+
+  const sortedNodes = currentFramework.nodes.slice().sort((a, b) => a.index - b.index)
+```
+
+然后在 JSX 中替换节点列表渲染:
+
+```tsx
+      {/* 节点列表 - 可拖拽排序 */}
+      <div className="flex flex-col gap-3">
+        <div className="text-sm font-medium text-muted-foreground">
+          {t("storySimulation.frameworkNodes")}
+          <span className="ml-2 text-xs text-primary/60">(拖拽手柄可排序)</span>
+        </div>
+        <DndContext
+          sensors={sensors}
+          collisionDetection={closestCenter}
+          onDragEnd={handleDragEnd}
+        >
+          <SortableContext
+            items={sortedNodes.map((n) => n.index)}
+            strategy={verticalListSortingStrategy}
+          >
+            {sortedNodes.map((node) => (
+              <SortableNodeCard
+                key={node.index}
+                node={node}
+                onUpdate={(updates) => updateNode(node.index, updates)}
+              />
+            ))}
+          </SortableContext>
+        </DndContext>
+      </div>
+```
+
+- [ ] **Step 4: 类型检查**
+
+```bash
+npx tsc --noEmit
+```
+Expected: 无错误
+
+- [ ] **Step 5: 构建验证**
+
+```bash
+npm run build
+```
+Expected: 构建成功
+
+- [ ] **Step 6: 提交**
+
+```bash
+git add src/components/novel/story-simulation/framework-confirm-panel.tsx
+git commit -m "feat: 框架节点支持拖拽排序,phase保持不变"
+```
+
+---
+
+## Task 5: 草稿章节预览编辑
+
+**Files:**
+- Modify: `src/components/novel/story-simulation/story-draft-view.tsx`
+- Modify: `src/components/novel/story-simulation/story-draft-generator.ts`(生成时设置 rawContent)
+
+- [ ] **Step 1: 在 story-draft-generator.ts 中生成时设置 rawContent**
+
+找到 `generateStoryDraft` 函数中创建 `DraftChapter` 的地方,在生成时将 content 同时存入 rawContent:
+
+```typescript
+// 在创建 DraftChapter 时
+const chapter: DraftChapter = {
+  title: chapterTitle,
+  content: chapterContent,
+  correspondingNode: nodeIndex,
+  rawContent: chapterContent, // 保存原始内容
+}
+```
+
+- [ ] **Step 2: 在 story-draft-view.tsx 中添加编辑状态和 Dialog**
+
+在文件顶部添加导入和状态:
+
+```typescript
+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"
+import { Input } from "@/components/ui/input"
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from "@/components/ui/dialog"
+import { Textarea } from "@/components/ui/textarea"
+import { useStorySimulationStore } from "@/stores/story-simulation-store"
+import { useWikiStore } from "@/stores/wiki-store"
+import { exportDraft } from "@/lib/novel/story-simulation/draft-export"
+import { importDraftToChapters } from "@/lib/novel/story-simulation/draft-importer"
+import { getNextChapterNumber } from "@/lib/novel/chapter-utils"
+import { refreshProjectState } from "@/lib/project-refresh"
+import type { StoryDraft } from "@/lib/novel/story-simulation/types"
+```
+
+在组件函数中添加编辑状态:
+
+```typescript
+  const [editingChapterIdx, setEditingChapterIdx] = useState<number | null>(null)
+  const [editContent, setEditContent] = useState("")
+  const [editTitle, setEditTitle] = useState("")
+
+  // 打开编辑弹窗
+  const openEditDialog = (idx: number) => {
+    if (!draft) return
+    const chapter = draft.chapters[idx]
+    setEditTitle(chapter.title)
+    setEditContent(chapter.content)
+    setEditingChapterIdx(idx)
+  }
+
+  // 保存编辑
+  const saveEdit = () => {
+    if (editingChapterIdx === null || !draft) return
+    const updatedDraft: StoryDraft = {
+      ...draft,
+      chapters: draft.chapters.map((ch, i) =>
+        i === editingChapterIdx
+          ? { ...ch, title: editTitle.trim() || ch.title, content: editContent }
+          : ch,
+      ),
+    }
+    setCurrentDraft(updatedDraft)
+    setEditingChapterIdx(null)
+  }
+
+  // 放弃编辑
+  const cancelEdit = () => {
+    setEditingChapterIdx(null)
+  }
+```
+
+注意:需要在 store 解构中添加 `setCurrentDraft`:
+
+```typescript
+  const setCurrentDraft = useStorySimulationStore((s) => s.setCurrentDraft)
+```
+
+- [ ] **Step 3: 在章节卡片中添加编辑按钮**
+
+将章节渲染部分(第218-228行)替换为:
+
+```tsx
+          {draft.chapters.map((chapter, idx) => (
+            <div key={idx} className="rounded-lg border p-4">
+              <h3 className="mb-2 flex items-center gap-2 font-medium">
+                <FileText className="h-4 w-4 text-muted-foreground" />
+                {chapter.title}
+                <Button
+                  variant="ghost"
+                  size="sm"
+                  className="ml-auto h-7 w-7 p-0 opacity-50 hover:opacity-100"
+                  onClick={() => openEditDialog(idx)}
+                  title="编辑章节"
+                >
+                  <Pencil className="h-3.5 w-3.5" />
+                </Button>
+              </h3>
+              <p className="whitespace-pre-wrap text-sm leading-relaxed">
+                {chapter.content}
+              </p>
+              {chapter.rawContent && chapter.rawContent !== chapter.content && (
+                <div className="mt-2 rounded bg-amber-50 px-2 py-1 text-xs text-amber-600 dark:bg-amber-950/30 dark:text-amber-400">
+                  ✓ 已编辑(原始内容已备份)
+                </div>
+              )}
+            </div>
+          ))}
+```
+
+- [ ] **Step 4: 添加编辑 Dialog**
+
+在导入 Dialog 之后添加编辑 Dialog:
+
+```tsx
+      {/* 章节编辑对话框 */}
+      <Dialog open={editingChapterIdx !== null} onOpenChange={(open) => {
+        if (!open) cancelEdit()
+      }}>
+        <DialogContent className="max-h-[90vh] max-w-3xl">
+          <DialogHeader>
+            <DialogTitle>编辑章节</DialogTitle>
+            <DialogDescription>
+              编辑后的内容将用于导入到章节库。
+            </DialogDescription>
+          </DialogHeader>
+          <div className="space-y-3">
+            <div>
+              <label className="mb-1 block text-xs font-medium text-muted-foreground">章节标题</label>
+              <Input
+                value={editTitle}
+                onChange={(e) => setEditTitle(e.target.value)}
+                className="text-sm"
+              />
+            </div>
+            <div>
+              <div className="mb-1 flex items-center justify-between">
+                <label className="text-xs font-medium text-muted-foreground">章节内容</label>
+                <span className="text-xs text-muted-foreground">
+                  {editContent.length} 字
+                </span>
+              </div>
+              <Textarea
+                value={editContent}
+                onChange={(e) => setEditContent(e.target.value)}
+                className="min-h-[50vh] text-sm leading-relaxed"
+                autoFocus
+              />
+            </div>
+          </div>
+          <DialogFooter>
+            <Button variant="outline" onClick={cancelEdit}>
+              放弃
+            </Button>
+            <Button onClick={saveEdit}>
+              <Save className="mr-1 h-3.5 w-3.5" />
+              保存修改
+            </Button>
+          </DialogFooter>
+        </DialogContent>
+      </Dialog>
+```
+
+- [ ] **Step 5: 类型检查**
+
+```bash
+npx tsc --noEmit
+```
+Expected: 无错误
+
+- [ ] **Step 6: 提交**
+
+```bash
+git add src/components/novel/story-simulation/story-draft-view.tsx src/lib/novel/story-simulation/story-draft-generator.ts
+git commit -m "feat: 草稿章节支持编辑,原始内容备份到rawContent"
+```
+
+---
+
+## Task 6: 采访继续对话
+
+**Files:**
+- Modify: `src/components/novel/story-simulation/interview-history-view.tsx`
+- Modify: `src/stores/story-simulation-store.ts`
+- Modify: `src/components/novel/story-simulation/story-simulation-view.tsx`
+
+- [ ] **Step 1: 在 store 中添加续聊模式状态**
+
+在 `story-simulation-store.ts` 的接口和实现中添加:
+
+```typescript
+  /** 当前续聊的采访ID(用于保存时判断覆盖/另存) */
+  continuingInterviewId: string | null
+  
+  setContinuingInterviewId: (id: string | null) => void
+```
+
+初始值:
+```typescript
+  continuingInterviewId: null,
+```
+
+setter:
+```typescript
+  setContinuingInterviewId: (continuingInterviewId) => set({ continuingInterviewId }),
+```
+
+reset 中添加:
+```typescript
+  continuingInterviewId: null,
+```
+
+- [ ] **Step 2: 在 interview-history-view.tsx 中添加"继续对话"按钮**
+
+在对话详情视图的工具栏中(第148-173行之间),在导出按钮之前添加"继续对话"按钮:
+
+```typescript
+import { deserializeSimulationSnapshot } from "@/lib/novel/story-simulation/simulation-serializer"
+import { loadSimulationResults } from "@/lib/novel/story-simulation/framework-store"
+import type { NovelAgent } from "@/lib/novel/story-simulation/types"
+```
+
+添加恢复 agent 的函数和"继续对话"按钮:
+
+```typescript
+  const setContinuingInterviewId = useStorySimulationStore((s) => s.setContinuingInterviewId)
+  const setActiveChatAgent = useStorySimulationStore((s) => s.setActiveChatAgent)
+  const setAgentChatMessages = useStorySimulationStore((s) => s.setAgentChatMessages)
+  const [resuming, setResuming] = useState(false)
+
+  /** 从采访记录或推演结果中恢复 agent 状态 */
+  const handleContinueInterview = async (interview: SavedInterview) => {
+    if (!projectPath) return
+    setResuming(true)
+    try {
+      let agents: NovelAgent[] = []
+
+      // 优先从采访记录的 agentSnapshot 恢复
+      if (interview.agentSnapshot) {
+        const { agents: deserializedAgents } = deserializeSimulationSnapshot(interview.agentSnapshot)
+        agents = deserializedAgents
+      }
+
+      // 若采访记录无快照,尝试从对应 frameworkId 的推演结果恢复
+      if (agents.length === 0 && interview.frameworkId) {
+        const results = await loadSimulationResults(projectPath, interview.frameworkId)
+        for (const r of results) {
+          if (r.agentSnapshot) {
+            const { agents: deserializedAgents } = deserializeSimulationSnapshot(r.agentSnapshot)
+            // 找到匹配 agentName 的结果
+            if (deserializedAgents.some((a) => a.name === interview.agentName)) {
+              agents = deserializedAgents
+              break
+            }
+          }
+        }
+      }
+
+      if (agents.length === 0) {
+        setError("无法恢复角色状态,仅支持只读查看")
+        setTimeout(() => setError(null), 3000)
+        return
+      }
+
+      // 找到对应角色的 agent
+      const targetAgent = agents.find((a) => a.name === interview.agentName)
+      if (!targetAgent) {
+        setError(`未找到角色「${interview.agentName}」的 agent 数据`)
+        setTimeout(() => setError(null), 3000)
+        return
+      }
+
+      // 加载旧对话消息到 store
+      setAgentChatMessages(interview.session.messages)
+      setActiveChatAgent(targetAgent.characterId)
+      setContinuingInterviewId(interview.id)
+      setShowInterviewHistory(false)
+      setViewingInterview(null)
+      setError("已恢复采访,可继续对话")
+      setTimeout(() => setError(null), 2000)
+    } catch (err) {
+      setError(err instanceof Error ? err.message : "恢复失败")
+      setTimeout(() => setError(null), 3000)
+    } finally {
+      setResuming(false)
+    }
+  }
+```
+
+在对话详情视图的工具栏中添加按钮(导出按钮之前):
+
+```tsx
+                  <Button
+                    variant="default"
+                    size="sm"
+                    onClick={() => handleContinueInterview(viewingInterview)}
+                    disabled={resuming}
+                  >
+                    {resuming ? "恢复中..." : "继续对话"}
+                  </Button>
+```
+
+- [ ] **Step 3: 修改 store 的 setAgentChatMessages**
+
+确保 store 中有 `setAgentChatMessages` 函数,如果没有则添加:
+
+```typescript
+  setAgentChatMessages: (messages: AgentChatMessage[]) => void
+```
+
+实现:
+```typescript
+  setAgentChatMessages: (messages) => set({ agentChatMessages: messages }),
+```
+
+- [ ] **Step 4: 在 story-simulation-view.tsx 中处理续聊保存**
+
+修改保存采访的逻辑,支持续聊模式下的覆盖/另存选择。在 `handleSaveChat` 或类似函数中:
+
+```typescript
+  const continuingInterviewId = useStorySimulationStore((s) => s.continuingInterviewId)
+  const setContinuingInterviewId = useStorySimulationStore((s) => s.setContinuingInterviewId)
+
+  // 在保存采访时
+  const handleSaveChat = async () => {
+    // ... 现有逻辑 ...
+    
+    // 如果是续聊模式,询问覆盖还是另存
+    let existingId: string | undefined
+    if (continuingInterviewId) {
+      const choice = confirm("覆盖原采访对话?\n\n确定 = 覆盖原采访\n取消 = 另存为新采访")
+      if (choice) {
+        existingId = continuingInterviewId
+      }
+    }
+    
+    const agentSnapshot = lastSimulationStateRef.current && lastAgentsRef.current.length > 0
+      ? serializeSimulationState(lastSimulationStateRef.current, lastAgentsRef.current)
+      : undefined
+
+    const interviewId = await saveInterview(projectPath, session, {
+      frameworkId: currentFramework?.id,
+      frameworkTitle: currentFramework?.title,
+      existingId,
+      agentSnapshot,
+    })
+    
+    setContinuingInterviewId(null)
+    // ... 后续逻辑 ...
+  }
+```
+
+- [ ] **Step 5: 类型检查**
+
+```bash
+npx tsc --noEmit
+```
+Expected: 无错误
+
+- [ ] **Step 6: 提交**
+
+```bash
+git add src/stores/story-simulation-store.ts src/components/novel/story-simulation/interview-history-view.tsx src/components/novel/story-simulation/story-simulation-view.tsx
+git commit -m "feat: 采访支持继续对话,恢复agent状态追加到旧对话"
+```
+
+---
+
+## Task 7: 对比差异高亮 - 角色分析和走向分支
+
+**Files:**
+- Modify: `src/components/novel/story-simulation/simulation-report-view.tsx`
+
+- [ ] **Step 1: 在 ReportContent 中添加 compareReport 参数**
+
+修改 `ReportContentProps` 接口:
+
+```typescript
+interface ReportContentProps {
+  report: SimulationReport
+  timelineEvents: TimelineEvent[]
+  framework?: StoryFramework | null
+  onInterviewAgent?: (agentId: string, agentName: string) => void
+  onGenerateDraft?: (branch: StoryBranch) => void
+  title?: string
+  compact?: boolean
+  /** 对比模式下的另一个报告,用于高亮差异 */
+  compareReport?: SimulationReport | null
+  /** 对比模式下的另一组时间线事件,用于差异统计 */
+  compareTimelineEvents?: TimelineEvent[]
+}
+```
+
+在 `ReportContent` 函数签名中解构 `compareReport` 和 `compareTimelineEvents`。
+
+- [ ] **Step 2: 添加角色分析差异高亮**
+
+在 `ReportContent` 中,角色分析渲染部分添加差异对比逻辑:
+
+```typescript
+  // 对比模式:计算角色分析差异
+  const characterDiff = useMemo(() => {
+    if (!compareReport) return null
+    const aNames = new Set(report.characterAnalyses.map((c) => c.name))
+    const bNames = new Set(compareReport.characterAnalyses.map((c) => c.name))
+    const onlyInA = new Set([...aNames].filter((n) => !bNames.has(n)))
+    const onlyInB = new Set([...bNames].filter((n) => !aNames.has(n)))
+    const scoreDiff = new Map<string, { a: number; b: number }>()
+    for (const ca of report.characterAnalyses) {
+      const cb = compareReport.characterAnalyses.find((c) => c.name === ca.name)
+      if (cb && ca.consistencyScore !== cb.consistencyScore) {
+        scoreDiff.set(ca.name, { a: ca.consistencyScore, b: cb.consistencyScore })
+      }
+    }
+    return { onlyInA, onlyInB, scoreDiff }
+  }, [report.characterAnalyses, compareReport])
+
+  // 获取角色卡片高亮类名
+  const getCharHighlightClass = (name: string): string => {
+    if (!characterDiff) return ""
+    if (characterDiff.onlyInA.has(name)) return "bg-green-100 dark:bg-green-950/40"
+    if (characterDiff.onlyInB.has(name)) return "bg-red-100 dark:bg-red-950/40"
+    if (characterDiff.scoreDiff.has(name)) return "bg-amber-100 dark:bg-amber-950/40"
+    return ""
+  }
+```
+
+在角色分析卡片渲染中添加高亮:
+
+```tsx
+                {report.characterAnalyses.map((char) => (
+                  <div key={char.characterId} className={cn("rounded-lg border p-3", getCharHighlightClass(char.name))}>
+```
+
+需要在导入中添加 `cn`:
+
+```typescript
+import { cn } from "@/lib/utils"
+```
+
+对于一致性分数差异,在显示分数时添加标记:
+
+```tsx
+                      <span className="rounded px-1.5 py-0.5 text-xs bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300">
+                        一致性: {char.consistencyScore}
+                        {characterDiff?.scoreDiff.has(char.name) && (
+                          <span className="ml-1 text-amber-600">
+                            (B: {characterDiff.scoreDiff.get(char.name)!.b})
+                          </span>
+                        )}
+                      </span>
+```
+
+- [ ] **Step 3: 添加走向分支差异高亮**
+
+在 `ReportContent` 中添加分支差异计算:
+
+```typescript
+  // 对比模式:计算走向分支差异
+  const branchDiff = useMemo(() => {
+    if (!compareReport) return null
+    const aTitles = new Set(report.branches.map((b) => b.title))
+    const bTitles = new Set(compareReport.branches.map((b) => b.title))
+    const onlyInA = new Set([...aTitles].filter((t) => !bTitles.has(t)))
+    const onlyInB = new Set([...bTitles].filter((t) => !aTitles.has(t)))
+    const probDiff = new Map<string, { a: string; b: string }>()
+    for (const ba of report.branches) {
+      const bb = compareReport.branches.find((b) => b.title === ba.title)
+      if (bb && ba.probability !== bb.probability) {
+        probDiff.set(ba.title, { a: ba.probability, b: bb.probability })
+      }
+    }
+    return { onlyInA, onlyInB, probDiff }
+  }, [report.branches, compareReport])
+
+  const getBranchHighlightClass = (title: string): string => {
+    if (!branchDiff) return ""
+    if (branchDiff.onlyInA.has(title)) return "bg-green-100 dark:bg-green-950/40"
+    if (branchDiff.onlyInB.has(title)) return "bg-red-100 dark:bg-red-950/40"
+    if (branchDiff.probDiff.has(title)) return "bg-amber-100 dark:bg-amber-950/40"
+    return ""
+  }
+```
+
+在分支卡片渲染中添加高亮:
+
+```tsx
+                  <div key={idx} className={cn("rounded-lg border p-3", getBranchHighlightClass(branch.title))}>
+```
+
+- [ ] **Step 4: 类型检查**
+
+```bash
+npx tsc --noEmit
+```
+Expected: 无错误
+
+- [ ] **Step 5: 提交**
+
+```bash
+git add src/components/novel/story-simulation/simulation-report-view.tsx
+git commit -m "feat: 对比模式高亮角色分析和走向分支差异"
+```
+
+---
+
+## Task 8: 对比差异高亮 - 综合推荐和时间线
+
+**Files:**
+- Modify: `src/components/novel/story-simulation/simulation-report-view.tsx`
+
+- [ ] **Step 1: 添加综合推荐差异高亮**
+
+在 `ReportContent` 中添加推荐差异计算:
+
+```typescript
+  // 对比模式:计算综合推荐差异(按句号分段)
+  const recommendationDiff = useMemo(() => {
+    if (!compareReport || !report.recommendation) return null
+    if (!compareReport.recommendation) return { segments: [report.recommendation] }
+    
+    const aSegments = report.recommendation.split(/[。!?]/).filter((s) => s.trim())
+    const bSegments = new Set(compareReport.recommendation.split(/[。!?]/).filter((s) => s.trim()))
+    
+    // 标记 A 中有但 B 中没有的段落
+    return {
+      segments: aSegments.map((seg) => ({
+        text: seg,
+        isDifferent: !bSegments.has(seg),
+      })),
+    }
+  }, [report.recommendation, compareReport])
+```
+
+在综合推荐渲染中添加高亮:
+
+```tsx
+          {report.recommendation && (
+            <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">
+                  <Sparkles className="h-3.5 w-3.5" />
+                  综合推荐
+                  {recommendationDiff && (
+                    <span className="ml-auto text-xs font-normal text-amber-600">有差异</span>
+                  )}
+                </h3>
+                {recommendationDiff ? (
+                  <div className="space-y-1 text-sm leading-relaxed">
+                    {recommendationDiff.segments.map((seg, i) => (
+                      <span
+                        key={i}
+                        className={seg.isDifferent ? "rounded bg-amber-100 px-1 dark:bg-amber-950/40" : ""}
+                      >
+                        {seg.text}。
+                      </span>
+                    ))}
+                  </div>
+                ) : (
+                  <p className="text-sm leading-relaxed">{report.recommendation}</p>
+                )}
+              </div>
+            </section>
+          )}
+```
+
+- [ ] **Step 2: 添加时间线事件差异统计**
+
+在 `ReportContent` 的关系网络区域之前添加时间线差异统计栏:
+
+```typescript
+  // 对比模式:计算时间线事件差异
+  const timelineDiff = useMemo(() => {
+    if (!compareReport || !compareTimelineEvents) return null
+    const aCount = timelineEvents.length
+    const bCount = compareTimelineEvents.length
+    
+    // 角色活跃度排名对比
+    const aActivity = new Map<string, number>()
+    for (const ev of timelineEvents) {
+      aActivity.set(ev.actorName, (aActivity.get(ev.actorName) || 0) + 1)
+    }
+    const aRanking = Array.from(aActivity.entries()).sort((a, b) => b[1] - a[1]).slice(0, 5)
+    
+    return { aCount, bCount, aRanking }
+  }, [timelineEvents, compareReport, compareTimelineEvents])
+```
+
+在时间线事件区域顶部添加对比统计栏:
+
+```tsx
+          {timelineDiff && (
+            <div className="flex items-center gap-4 rounded-lg border bg-muted/30 px-3 py-2 text-xs">
+              <span className="font-medium">事件数量对比:</span>
+              <span className="text-primary">A: {timelineDiff.aCount}</span>
+              <span className="text-muted-foreground">vs</span>
+              <span className="text-red-500">B: {timelineDiff.bCount}</span>
+              <span className="ml-auto text-muted-foreground">
+                差异: {Math.abs(timelineDiff.aCount - timelineDiff.bCount)} 条
+              </span>
+            </div>
+          )}
+```
+
+- [ ] **Step 3: 在对比双栏中传入 compareReport**
+
+在 `SimulationReportView` 的双栏对比渲染中,给左侧 ReportContent 传入 `compareReport`:
+
+```tsx
+          <div className="min-w-0 flex-1 border-r">
+            <ReportContent
+              report={activeReport}
+              timelineEvents={activeTimeline}
+              framework={currentFramework}
+              onInterviewAgent={!currentResult ? onInterviewAgent : undefined}
+              onGenerateDraft={!currentResult ? onGenerateDraft : undefined}
+              title={currentResult ? `结果 A (${formatDate(currentResult.createdAt)})` : "结果 A (最新)"}
+              compact={true}
+              compareReport={compareResult?.report}
+              compareTimelineEvents={compareResult?.timelineEvents || []}
+            />
+          </div>
+```
+
+- [ ] **Step 4: 类型检查**
+
+```bash
+npx tsc --noEmit
+```
+Expected: 无错误
+
+- [ ] **Step 5: 构建验证**
+
+```bash
+npm run build
+```
+Expected: 构建成功
+
+- [ ] **Step 6: 提交**
+
+```bash
+git add src/components/novel/story-simulation/simulation-report-view.tsx
+git commit -m "feat: 对比模式高亮综合推荐和时间线事件差异"
+```
+
+---
+
+## Task 9: 最终验证和打包
+
+**Files:**
+- 无文件修改
+
+- [ ] **Step 1: 完整类型检查**
+
+```bash
+npx tsc --noEmit
+```
+Expected: 无错误
+
+- [ ] **Step 2: 完整构建**
+
+```bash
+npm run build
+```
+Expected: 构建成功
+
+- [ ] **Step 3: 打包便携版**
+
+```bash
+npm run build:portable
+```
+Expected: 输出 `C:\QMAI_C\QMAI-main\release-portable\QMaiWrite.exe`
+
+- [ ] **Step 4: 最终提交**
+
+```bash
+git add -A
+git commit -m "feat: 完成第四轮优化 - 拖拽排序/草稿编辑/采访续聊/对比高亮"
+```
+
+---
+
+## 测试清单
+
+完成实现后,逐项手动验证:
+
+- [ ] 框架节点拖拽:拖拽后 index 正确、phase 不变
+- [ ] 框架节点拖拽:保存后重新加载顺序正确
+- [ ] 草稿编辑:编辑后内容正确保存
+- [ ] 草稿编辑:编辑后导入验证内容正确
+- [ ] 草稿编辑:原始内容 rawContent 未丢失
+- [ ] 采访续聊:恢复后 agent 状态正确
+- [ ] 采访续聊:新消息正常生成
+- [ ] 采访续聊:保存覆盖/另存正确
+- [ ] 采访续聊:无 agentSnapshot 时禁用按钮
+- [ ] 对比高亮:角色分析差异标记准确
+- [ ] 对比高亮:走向分支差异标记准确
+- [ ] 对比高亮:综合推荐差异段落高亮
+- [ ] 对比高亮:时间线事件数量对比显示
+- [ ] 旧功能验证:推演流程正常
+- [ ] 旧功能验证:报告导出正常
+- [ ] 旧功能验证:草稿导入正常
+- [ ] 旧功能验证:采访保存正常
+- [ ] 旧功能验证:历史采访查看正常
+- [ ] 旧功能验证:模式选择正常

+ 165 - 0
docs/superpowers/specs/2026-06-27-story-sim-optimization-design.md

@@ -0,0 +1,165 @@
+# 剧情推演室第四轮优化设计
+
+> 日期:2026-06-27
+> 分支:feature-more-optimizations
+> 方案:B(体验优先)
+
+## 背景
+
+剧情推演室已完成三轮优化(模式差异化、采访持久化、导入进度条、模式可视化、历史采访查看、推演结果对比)。本轮继续优化以下4个功能。
+
+## 功能1:框架节点拖拽排序
+
+### 目标
+在框架确认面板(`framework-confirm-panel.tsx`)中支持拖拽调整节点顺序。
+
+### 设计
+- 使用 `@dnd-kit/core` + `@dnd-kit/sortable`(新依赖)
+- 节点列表用 `SortableContext` 包裹,每个节点卡片用 `useSortable` hook
+- 卡片左侧添加拖拽手柄(`GripVertical` 图标),悬停时手柄变主色调
+- 拖拽时:被拖卡片半透明(opacity-50),下方显示半透明占位线
+- 松手后:重新分配 `index`(0,1,2,...),`phase` 保持不变
+- 更新 `currentFramework.nodes` 数组顺序
+- 拖拽后"保存框架"按钮高亮提示有未保存改动
+
+### 数据流
+```
+用户拖拽节点 → onDragEnd 回调 → arrayMove(nodes, from, to) → 
+重新分配 index → setCurrentFramework(updatedFramework) → 
+UI 自动刷新 → 保存框架按钮高亮
+```
+
+### 涉及文件
+- `framework-confirm-panel.tsx`:重写节点列表为可拖拽
+- `package.json`:添加 @dnd-kit/core 和 @dnd-kit/sortable
+
+## 功能2:草稿章节预览编辑
+
+### 目标
+草稿生成后可在弹窗中编辑章节内容,确认后再导入到正式章节。
+
+### 设计
+- 在 `story-draft-view.tsx` 中,每个章节卡片增加"编辑"按钮(`Pencil` 图标)
+- 点击后弹出全屏 Dialog
+- Dialog 内嵌入 milkdown 编辑器(复用项目已有的编辑器组件)
+- 编辑器底部显示字数统计
+- 底部按钮:"保存修改"和"放弃"
+- 编辑内容保存在内存中的 draft 数据结构(`DraftChapter.content`)
+- 原始 AI 生成内容保留在新字段 `DraftChapter.rawContent`(需扩展类型)
+- 导入到正式章节时使用编辑后的 content
+- 导入完成后清除编辑缓存
+
+### 数据流
+```
+点击编辑按钮 → 打开 Dialog → 加载章节 content 到 milkdown → 
+用户编辑 → 点击保存 → 更新 draft.chapters[i].content → 
+关闭 Dialog → 导入时使用编辑后的 content
+```
+
+### 涉及文件
+- `story-draft-view.tsx`:添加编辑按钮和 Dialog
+- 可能复用已有的 milkdown 编辑器组件(需查找项目中现有使用)
+
+## 功能3:采访继续对话
+
+### 目标
+从历史采访进入后,恢复角色 agent 状态,可继续与角色对话。
+
+### 设计
+- 在 `interview-history-view.tsx` 的对话详情视图中,添加"继续对话"按钮
+- 修改 `SavedInterview` 接口,添加可选的 `agentSnapshot` 字段(SerializedSimulationSnapshot 类型)
+- 保存采访时,如果有 agentSnapshot(从 `lastAgentsRef` 获取),一并保存
+- 点击"继续对话"后:
+  1. 优先从采访记录的 `agentSnapshot` 字段恢复 `NovelAgent[]`
+  2. 若采访记录无 agentSnapshot(旧版数据),尝试从对应 frameworkId 的最新推演结果中恢复
+  3. 若都找不到,禁用"继续对话"按钮,提示"无法恢复角色状态,仅支持只读查看"
+  4. 恢复成功后,将旧对话消息加载到 `story-simulation-store` 的 `agentChatMessages`
+  5. 设置 `activeChatAgent` 为对应角色,激活采访面板
+  6. 复用现有 `interviewAgent` 函数继续对话
+- 新消息追加到旧 session 末尾
+- 保存时弹出选择:覆盖原采访 or 另存为新采访
+
+### 数据流
+```
+点击"继续对话" → 反序列化 agentSnapshot → 恢复 NovelAgent[] → 
+加载旧消息到 agentChatMessages → 设置 activeChatAgent → 
+关闭历史面板 → 采访面板激活 → 用户继续发消息 → 
+interviewAgent() 生成回复 → 追加到 agentChatMessages → 
+保存时选择覆盖/另存
+```
+
+### 涉及文件
+- `interview-history-view.tsx`:添加"继续对话"按钮和恢复逻辑
+- `interview-store.ts`:SavedInterview 接口添加 agentSnapshot 字段,saveInterview 函数添加 agentSnapshot 参数
+- `story-simulation-view.tsx`:可能需要调整采访面板状态管理
+- `story-simulation-store.ts`:可能需要添加"继续模式"状态
+- `types.ts`:DraftChapter 添加 rawContent 字段
+
+## 功能4:对比差异高亮
+
+### 目标
+推演结果对比模式下,高亮显示两个结果的差异部分。
+
+### 设计
+- 在 `ReportContent` 组件中,对比模式下传入 `compareReport` 参数
+- 计算两个 report 的差异并标记:
+
+#### 角色分析差异
+- 按角色名匹配
+- 仅A有的角色:绿色背景标记
+- 仅B有的角色:红色背景标记
+- 共有角色但一致性分数不同:黄色背景标记分数
+- 行为列表不同:黄色背景标记差异行为
+
+#### 走向分支差异
+- 按标题匹配
+- 仅A有的分支:绿色背景
+- 仅B有的分支:红色背景
+- 共有分支但概率不同:黄色背景标记概率
+- 利弊内容变化:黄色背景标记
+
+#### 综合推荐差异
+- 按句号分段
+- 逐段对比,不同段落标黄色背景
+
+#### 时间线事件差异
+- 顶部统计栏:对比事件总数、角色活跃度排名变化
+- 事件数量差异用数字标注(如 A:23 vs B:18)
+
+### 高亮颜色规范
+- 绿色(仅A有):`bg-green-100 dark:bg-green-950/40`
+- 红色(仅B有):`bg-red-100 dark:bg-red-950/40`
+- 黄色(内容不同):`bg-amber-100 dark:bg-amber-950/40`
+
+### 数据流
+```
+对比模式开启 → 选择对比结果 → ReportContent 接收 compareReport → 
+计算 diff(角色分析/分支/推荐/时间线)→ 渲染时添加高亮背景色 → 
+用户直观看到差异
+```
+
+### 涉及文件
+- `simulation-report-view.tsx`:在 ReportContent 中添加对比逻辑和高亮
+
+## 依赖变更
+- 新增:`@dnd-kit/core`、`@dnd-kit/sortable`
+- 已有:milkdown(用于草稿编辑)
+
+## 风险评估
+- **低风险**:4个功能相互独立,不会相互影响
+- **中风险**:dnd-kit 新依赖可能与现有样式冲突,需测试拖拽动画
+- **低风险**:milkdown 编辑器已在项目中使用,弹窗集成是已知模式
+- **低风险**:采访续聊复用现有函数,agent 恢复已有反序列化逻辑
+
+## 测试计划
+1. 框架节点拖拽:拖拽后验证 index 正确、phase 不变、保存后重新加载顺序正确
+2. 草稿编辑:编辑后导入验证内容正确、原始内容未丢失
+3. 采访续聊:恢复后验证 agent 状态正确、新消息正常生成、保存覆盖/另存正确
+4. 对比高亮:验证高亮标记准确、颜色区分清晰
+
+## 成功标准
+- 4个功能均可正常使用
+- 旧功能(推演、报告、草稿导入、采访、历史查看、模式选择)不受影响
+- TypeScript 类型检查通过
+- 前端构建成功
+- 便携版打包成功

+ 56 - 0
package-lock.json

@@ -9,6 +9,9 @@
       "version": "2.2.24",
       "dependencies": {
         "@base-ui/react": "^1.3.0",
+        "@dnd-kit/core": "^6.3.1",
+        "@dnd-kit/sortable": "^10.0.0",
+        "@dnd-kit/utilities": "^3.2.2",
         "@fontsource-variable/geist": "^5.2.8",
         "@milkdown/kit": "^7.20.0",
         "@milkdown/plugin-math": "^7.5.9",
@@ -1194,6 +1197,59 @@
         "node": ">=20.19.0"
       }
     },
+    "node_modules/@dnd-kit/accessibility": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
+      "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
+      "license": "MIT",
+      "dependencies": {
+        "tslib": "^2.0.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.8.0"
+      }
+    },
+    "node_modules/@dnd-kit/core": {
+      "version": "6.3.1",
+      "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
+      "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@dnd-kit/accessibility": "^3.1.1",
+        "@dnd-kit/utilities": "^3.2.2",
+        "tslib": "^2.0.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.8.0",
+        "react-dom": ">=16.8.0"
+      }
+    },
+    "node_modules/@dnd-kit/sortable": {
+      "version": "10.0.0",
+      "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
+      "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
+      "license": "MIT",
+      "dependencies": {
+        "@dnd-kit/utilities": "^3.2.2",
+        "tslib": "^2.0.0"
+      },
+      "peerDependencies": {
+        "@dnd-kit/core": "^6.3.0",
+        "react": ">=16.8.0"
+      }
+    },
+    "node_modules/@dnd-kit/utilities": {
+      "version": "3.2.2",
+      "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
+      "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
+      "license": "MIT",
+      "dependencies": {
+        "tslib": "^2.0.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.8.0"
+      }
+    },
     "node_modules/@dotenvx/dotenvx": {
       "version": "1.59.1",
       "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.59.1.tgz",

+ 4 - 1
package.json

@@ -18,6 +18,9 @@
   },
   "dependencies": {
     "@base-ui/react": "^1.3.0",
+    "@dnd-kit/core": "^6.3.1",
+    "@dnd-kit/sortable": "^10.0.0",
+    "@dnd-kit/utilities": "^3.2.2",
     "@fontsource-variable/geist": "^5.2.8",
     "@milkdown/kit": "^7.20.0",
     "@milkdown/plugin-math": "^7.5.9",
@@ -72,4 +75,4 @@
     "vite": "^8.0.0",
     "vitest": "^4.1.4"
   }
-}
+}

+ 13 - 2
scripts/build-portable.mjs

@@ -1,11 +1,18 @@
 import { cpSync, existsSync, mkdirSync, rmSync, statSync, writeFileSync, renameSync } from "node:fs"
 import { readFile } from "node:fs/promises"
+import { execSync } from "node:child_process"
 import { dirname, resolve } from "node:path"
 import { fileURLToPath } from "node:url"
 
 const root = resolve(dirname(fileURLToPath(import.meta.url)), "..")
 const pkg = JSON.parse(await readFile(resolve(root, "package.json"), "utf8"))
 
+let currentBranch = ""
+try {
+  currentBranch = execSync("git rev-parse --abbrev-ref HEAD").toString().trim()
+} catch {}
+const isStorySimulationBranch = currentBranch === "feature-story-simulation"
+
 const releaseExe = resolve(root, "src-tauri/target/release/qmai.exe")
 const portableDevExe = resolve(root, "src-tauri/target/portable-dev/qmai.exe")
 const sourceExe = existsSync(portableDevExe) ? portableDevExe : releaseExe
@@ -13,7 +20,7 @@ const releasePdfium = resolve(root, "src-tauri/target/release/pdfium/pdfium.dll"
 const portableDevPdfium = resolve(root, "src-tauri/target/portable-dev/pdfium/pdfium.dll")
 const sourcePdfium = existsSync(portableDevPdfium) ? portableDevPdfium : releasePdfium
 const outDir = resolve(root, "release-portable")
-const outExe = resolve(outDir, "QMaiWrite.exe")
+const outExe = resolve(outDir, isStorySimulationBranch ? "QMaiWrite-剧情推演版.exe" : "QMaiWrite.exe")
 const outPdfium = resolve(outDir, "pdfium/pdfium.dll")
 const outSkillDir = resolve(outDir, "skills")
 const manifest = resolve(outDir, "version-info.json")
@@ -77,7 +84,7 @@ if (existsSync(sourceSkillDir)) {
 
 const exeStat = statSync(outExe)
 writeFileSync(manifest, JSON.stringify({
-  productName: "青幕AI写作",
+  productName: isStorySimulationBranch ? "青幕AI写作(剧情推演版)" : "青幕AI写作",
   version: pkg.version,
   builtAt: new Date().toISOString(),
   sourceExe,
@@ -85,7 +92,11 @@ writeFileSync(manifest, JSON.stringify({
   exeBytes: exeStat.size,
   includesPdfium: existsSync(outPdfium),
   includesSkills: existsSync(outSkillDir),
+  ...(isStorySimulationBranch ? { variant: "story-simulation", branch: currentBranch } : {}),
 }, null, 2), "utf8")
 
 console.log(`便携版已生成:${outExe}`)
 console.log(`版本信息:${manifest}`)
+if (isStorySimulationBranch) {
+  console.log("注意:这是测试版,不可上传到 GitHub main 分支")
+}

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

@@ -12,7 +12,7 @@
   "app": {
     "windows": [
       {
-        "title": "青幕AI写作",
+        "title": "青幕AI写作 - 剧情推演版",
         "width": 1200,
         "height": 800,
         "resizable": true,

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

@@ -1,6 +1,6 @@
 import { useRef, useEffect, useCallback, useState } from "react"
 import { useTranslation } from "react-i18next"
-import { BookOpen, Brain, Plus, Trash2, MessageSquare, FileEdit } from "lucide-react"
+import { BookOpen, Brain, Plus, Trash2, MessageSquare, FileEdit, Drama } from "lucide-react"
 import { Button } from "@/components/ui/button"
 import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
 import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
@@ -48,6 +48,9 @@ import { isChatEditRequest, resolveChatEditTarget, validateStructuredChapterEdit
 import { backupChapterFile } from "@/lib/novel/chapter-backup"
 import { decideChapterSaveStrategy, detectGeneratedTargetChapterNumber } from "@/lib/novel/chapter-save-strategy"
 import { normalizeChapterEditFile } from "@/lib/novel/chapter-edit-file"
+import { loadBinding } from "@/lib/novel/story-simulation/framework-binding"
+import { loadFrameworks } from "@/lib/novel/story-simulation/framework-store"
+import type { FrameworkBinding, StoryFramework } from "@/lib/novel/story-simulation/types"
 
 function formatDate(timestamp: number): string {
   const d = new Date(timestamp)
@@ -192,7 +195,9 @@ export function ChatPanel() {
 
   const project = useWikiStore((s) => s.project)
   const novelMode = useWikiStore((s) => s.novelMode)
+  const setActiveView = useWikiStore((s) => s.setActiveView)
   const llmConfig = useWikiStore((s) => s.llmConfig)
+  const bindingVersion = useWikiStore((s) => s.bindingVersion)
   const providerConfigs = useWikiStore((s) => s.providerConfigs)
   const aiChatModel = useWikiStore((s) => s.aiChatModel)
   const setAiChatModel = useWikiStore((s) => s.setAiChatModel)
@@ -213,6 +218,8 @@ export function ChatPanel() {
   const [isSavingChapter, setIsSavingChapter] = useState(false)
   const [pendingSoulDialog, setPendingSoulDialog] = useState({ open: false, summary: "" })
   const [deepChapterEnabled, setDeepChapterEnabled] = useState(false)
+  // 故事框架绑定状态
+  const [activeBinding, setActiveBinding] = useState<{ binding: FrameworkBinding; framework: StoryFramework } | null>(null)
   const closeSoulDialog = useCallback((confirmed: boolean) => {
     const resolver = soulDialogResolverRef.current
     soulDialogResolverRef.current = null
@@ -334,6 +341,37 @@ export function ChatPanel() {
     userScrolledUpRef.current = false
   }, [activeConversationId])
 
+  // 加载故事框架绑定状态
+  useEffect(() => {
+    let cancelled = false
+    void (async () => {
+      if (!novelMode || !project) {
+        setActiveBinding(null)
+        return
+      }
+      try {
+        const binding = await loadBinding(normalizePath(project.path))
+        if (cancelled || !binding) {
+          setActiveBinding(null)
+          return
+        }
+        const frameworks = await loadFrameworks(normalizePath(project.path))
+        if (cancelled) return
+        const framework = frameworks.find((f) => f.id === binding.frameworkId)
+        if (framework) {
+          setActiveBinding({ binding, framework })
+        } else {
+          setActiveBinding(null)
+        }
+      } catch {
+        if (!cancelled) setActiveBinding(null)
+      }
+    })()
+    return () => {
+      cancelled = true
+    }
+  }, [novelMode, project, bindingVersion])
+
   // 切换会话时不再中断后台生成——每个会话独立运行
 
   const handleSend = useCallback(
@@ -1508,6 +1546,47 @@ export function ChatPanel() {
                             开启后,AI会话会读取当前章节或识别到的章节范围进行修改,并在写回前自动备份原内容。
                           </TooltipContent>
                         </Tooltip>
+                        {/* 故事框架绑定状态 */}
+                        <Tooltip>
+                          <TooltipTrigger
+                            render={(
+                              <button
+                                type="button"
+                                onClick={() => setActiveView("storySimulation")}
+                                className={`flex h-8 shrink-0 items-center gap-1 rounded-full border px-2.5 text-xs transition-colors ${
+                                  activeBinding
+                                    ? "border-purple-300 bg-purple-50 text-purple-700 hover:bg-purple-100"
+                                    : "border-border text-muted-foreground hover:bg-accent hover:text-foreground"
+                                }`}
+                              >
+                                <Drama className="h-3.5 w-3.5" />
+                                <span className="max-w-[100px] truncate">
+                                  {activeBinding
+                                    ? activeBinding.framework.shortTitle || activeBinding.framework.title
+                                    : "故事框架"}
+                                </span>
+                              </button>
+                            )}
+                          />
+                          <TooltipContent side="top" className="max-w-xs leading-5">
+                            {activeBinding ? (
+                              <>
+                                <div className="font-medium">已绑定故事框架</div>
+                                <div className="mt-1 text-xs opacity-80">
+                                  {activeBinding.framework.title}
+                                </div>
+                                <div className="mt-1 text-xs opacity-70">
+                                  目标章节数:{activeBinding.binding.targetChapterCount}章
+                                </div>
+                                <div className="mt-1 text-xs opacity-70">
+                                  点击可进入剧情推演室管理
+                                </div>
+                              </>
+                            ) : (
+                              <>未绑定故事框架,点击进入剧情推演室创建</>
+                            )}
+                          </TooltipContent>
+                        </Tooltip>
                       </>
                     ) : null}
                   </div>

+ 12 - 0
src/components/layout/content-area.tsx

@@ -48,6 +48,11 @@ const BookAnalysisView = lazy(async () => {
   return { default: mod.BookAnalysisView }
 })
 
+const StorySimulationView = lazy(async () => {
+  const mod = await import("@/components/novel/story-simulation/story-simulation-view")
+  return { default: mod.StorySimulationView }
+})
+
 function LoadingView() {
   return (
     <div className="flex h-full items-center justify-center text-sm text-muted-foreground">
@@ -118,6 +123,13 @@ export function ContentArea() {
           </Suspense>
         )
         break
+      case "storySimulation":
+        content = (
+          <Suspense fallback={<LoadingView />}>
+            <StorySimulationView />
+          </Suspense>
+        )
+        break
       default:
         content = (
           <Suspense fallback={<LoadingView />}>

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

@@ -1,5 +1,5 @@
 import {
-  FileText, FolderOpen, Search, Network, Brain, Settings, ArrowLeftRight, Sun, Moon, Monitor, Trash2, Sparkles, LayoutDashboard, BookOpen,
+  FileText, FolderOpen, Search, Network, Brain, Settings, ArrowLeftRight, Sun, Moon, Monitor, Trash2, Sparkles, LayoutDashboard, BookOpen, Drama,
 } from "lucide-react"
 import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
 import { useWikiStore } from "@/stores/wiki-store"
@@ -26,6 +26,7 @@ const NAV_ITEMS: { view: NavView; icon: typeof FileText; labelKey: string }[] =
   { view: "soul", icon: Sparkles, labelKey: "novel.nav.soul" },
   { view: "bookAnalysis", icon: BookOpen, labelKey: "novel.nav.dismantling" },
   { view: "reviewCenter", icon: LayoutDashboard, labelKey: "novel.nav.reviewCenter" },
+  { view: "storySimulation", icon: Drama, labelKey: "novel.nav.storySimulation" },
 ]
 
 interface IconSidebarProps {

+ 203 - 0
src/components/layout/sidebar-panel.tsx

@@ -23,8 +23,13 @@ import { GraphSidebarPanel } from "./graph-sidebar-panel"
 import { SoulSidebarPanel } from "./soul-sidebar-panel"
 import { ReviewCenterSidebarPanel } from "./review-center-sidebar-panel"
 import { BookAnalysisSidebarPanel } from "./book-analysis-sidebar-panel"
+import { FrameworkList } from "@/components/novel/story-simulation/framework-list"
 
 import { useWikiStore } from "@/stores/wiki-store"
+import { useStorySimulationStore } from "@/stores/story-simulation-store"
+import { loadFrameworks, loadSimulationResults, deleteSimulationResult } from "@/lib/novel/story-simulation/framework-store"
+import { loadBinding } from "@/lib/novel/story-simulation/framework-binding"
+import type { StoryFramework } from "@/lib/novel/story-simulation/types"
 import { createDirectory, fileExists, listDirectory, preprocessFile, readFile, writeFile } from "@/commands/fs"
 import { countChapterBodyWords } from "@/lib/chapter-word-count"
 import { buildChapterTotalWordCountLabel } from "@/lib/chapter-display"
@@ -337,6 +342,200 @@ export function DismantlingSidebarPanel() {
 
 void DismantlingSidebarPanel
 
+/** 剧情推演室侧边栏:头部标题+新建按钮 + 框架列表 */
+function StorySimulationSidebarPanel() {
+  const projectPath = useWikiStore((s) => s.project?.path)
+  const setFrameworks = useStorySimulationStore((s) => s.setFrameworks)
+  const setBinding = useStorySimulationStore((s) => s.setBinding)
+  const setCurrentFramework = useStorySimulationStore((s) => s.setCurrentFramework)
+  const setCurrentReport = useStorySimulationStore((s) => s.setCurrentReport)
+  const setCurrentDraft = useStorySimulationStore((s) => s.setCurrentDraft)
+  const setTimelineEvents = useStorySimulationStore((s) => s.setTimelineEvents)
+  const setPhase = useStorySimulationStore((s) => s.setPhase)
+  const setSavedResults = useStorySimulationStore((s) => s.setSavedResults)
+  const setSelectedResultId = useStorySimulationStore((s) => s.setSelectedResultId)
+  const currentFramework = useStorySimulationStore((s) => s.currentFramework)
+  const savedResults = useStorySimulationStore((s) => s.savedResults)
+  const selectedResultId = useStorySimulationStore((s) => s.selectedResultId)
+  const reset = useStorySimulationStore((s) => s.reset)
+  const [deletingId, setDeletingId] = useState<string | null>(null)
+
+  // 加载指定框架的历史推演结果
+  const loadResultsForFramework = useCallback(async (frameworkId: string) => {
+    if (!projectPath) return
+    try {
+      const results = await loadSimulationResults(projectPath, frameworkId)
+      setSavedResults(results.map(r => ({
+        id: r.id,
+        frameworkId,
+        report: r.report,
+        draft: r.draft,
+        timelineEvents: r.timelineEvents,
+        agentSnapshot: r.agentSnapshot,
+        createdAt: r.report.createdAt,
+      })))
+    } catch {
+      setSavedResults([])
+    }
+  }, [projectPath, setSavedResults])
+
+  // 进入视图时加载框架列表和绑定
+  useEffect(() => {
+    if (!projectPath) return
+    let cancelled = false
+    void (async () => {
+      try {
+        const [list, currentBinding] = await Promise.all([
+          loadFrameworks(projectPath),
+          loadBinding(projectPath),
+        ])
+        if (cancelled) return
+        setFrameworks(list)
+        setBinding(currentBinding)
+      } catch {
+        // 忽略加载错误
+      }
+    })()
+    return () => {
+      cancelled = true
+    }
+  }, [projectPath, setFrameworks, setBinding])
+
+  // 当currentFramework变化时,加载其历史结果
+  useEffect(() => {
+    if (currentFramework) {
+      void loadResultsForFramework(currentFramework.id)
+    } else {
+      setSavedResults([])
+    }
+  }, [currentFramework, loadResultsForFramework, setSavedResults])
+
+  const handleSelectFramework = (framework: StoryFramework) => {
+    setCurrentFramework(framework)
+    setCurrentReport(null)
+    setCurrentDraft(null)
+    setTimelineEvents([])
+    setSelectedResultId(null)
+    setPhase("framework-confirming")
+  }
+
+  const handleSelectResult = (resultId: string) => {
+    const result = savedResults.find(r => r.id === resultId)
+    if (result) {
+      setCurrentReport(result.report)
+      setCurrentDraft(result.draft || null)
+      setTimelineEvents(result.timelineEvents || [])
+      setSelectedResultId(resultId)
+      setPhase("report-viewing")
+    }
+  }
+
+  const handleDeleteResult = async (e: { stopPropagation: () => void }, resultId: string) => {
+    e.stopPropagation() // 防止触发选择
+    if (!projectPath || !currentFramework) return
+    if (!confirm("确定要删除这个推演结果吗?此操作不可撤销。")) return
+
+    setDeletingId(resultId)
+    try {
+      await deleteSimulationResult(projectPath, currentFramework.id, resultId)
+      // 刷新列表
+      await loadResultsForFramework(currentFramework.id)
+      // 如果删除的是当前选中的结果,清空
+      if (selectedResultId === resultId) {
+        setCurrentReport(null)
+        setCurrentDraft(null)
+        setTimelineEvents([])
+        setSelectedResultId(null)
+        setPhase("framework-confirming")
+      }
+    } catch {
+      // 删除失败忽略
+    } finally {
+      setDeletingId(null)
+    }
+  }
+
+  const handleNewFramework = () => {
+    reset()
+    setPhase("configuring")
+  }
+
+  return (
+    <div className="flex h-full flex-col">
+      <div className="flex shrink-0 items-center justify-between border-b px-3 py-2">
+        <div className="text-sm font-semibold text-foreground">故事框架</div>
+        <Button
+          type="button"
+          size="sm"
+          variant="ghost"
+          className="h-7 px-2 text-xs"
+          onClick={handleNewFramework}
+        >
+          <Plus className="mr-1 h-3.5 w-3.5" />
+          新建框架
+        </Button>
+      </div>
+      <div className="min-h-0 flex-1 overflow-y-auto">
+        <FrameworkList
+          onSelectFramework={handleSelectFramework}
+          onNewFramework={handleNewFramework}
+        />
+      </div>
+      {/* 历史推演结果 */}
+      {currentFramework && savedResults.length > 0 && (
+        <div className="shrink-0 border-t">
+          <div className="flex items-center justify-between px-3 py-2">
+            <div className="text-xs font-semibold text-muted-foreground">
+              历史推演 ({savedResults.length})
+            </div>
+          </div>
+          <div className="max-h-48 overflow-y-auto px-2 pb-2">
+            {savedResults.map((result) => (
+              <div
+                key={result.id}
+                className={`group flex items-center gap-1 rounded px-2 py-1.5 text-xs transition-colors cursor-pointer hover:bg-accent ${
+                  selectedResultId === result.id ? "bg-accent" : ""
+                }`}
+                onClick={() => handleSelectResult(result.id)}
+              >
+                <div className="flex-1 min-w-0">
+                  <div className="flex items-center gap-1">
+                    <span className="truncate text-foreground">
+                      {new Date(result.createdAt).toLocaleString("zh-CN", {
+                        month: "2-digit",
+                        day: "2-digit",
+                        hour: "2-digit",
+                        minute: "2-digit",
+                      })}
+                    </span>
+                    {result.draft && (
+                      <span className="shrink-0 rounded bg-primary/10 px-1 text-[10px] text-primary">
+                        草稿
+                      </span>
+                    )}
+                  </div>
+                  <span className="block truncate text-[11px] text-muted-foreground">
+                    {result.report.recommendation?.slice(0, 25) || "查看推演结果"}...
+                  </span>
+                </div>
+                <button
+                  type="button"
+                  className="shrink-0 rounded p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-destructive/10 hover:text-destructive group-hover:opacity-100"
+                  onClick={(e) => void handleDeleteResult(e, result.id)}
+                  disabled={deletingId === result.id}
+                  title="删除此结果"
+                >
+                  <Trash2 className="h-3 w-3" />
+                </button>
+              </div>
+            ))}
+          </div>
+        </div>
+      )}
+    </div>
+  )
+}
+
 function inferModeFromPath(path: string): "knowledge" | "files" {
   const normalized = normalizePath(path)
   if (normalized.includes("/wiki/outlines/")) return "files"
@@ -1057,6 +1256,10 @@ export function SidebarPanel() {
     }
   }, [activeView, loadMemoryCenter, novelMode, project?.path])
 
+  if (activeView === "storySimulation") {
+    return <StorySimulationSidebarPanel />
+  }
+
   if (activeView === "graph") {
     return <GraphSidebarPanel />
   }

+ 148 - 0
src/components/novel/story-simulation/framework-binding-dialog.tsx

@@ -0,0 +1,148 @@
+import { useEffect, useState } from "react"
+
+import { useWikiStore } from "@/stores/wiki-store"
+import { useStorySimulationStore } from "@/stores/story-simulation-store"
+import {
+  saveBinding,
+  clearBinding,
+} from "@/lib/novel/story-simulation/framework-binding"
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from "@/components/ui/dialog"
+import { Button } from "@/components/ui/button"
+import type { StoryFramework } from "@/lib/novel/story-simulation/types"
+
+const CHAPTER_OPTIONS = [5, 10, 20, 30, 50]
+
+interface FrameworkBindingDialogProps {
+  open: boolean
+  onOpenChange: (open: boolean) => void
+  framework: StoryFramework
+  onBound: () => void
+}
+
+export function FrameworkBindingDialog({
+  open,
+  onOpenChange,
+  framework,
+  onBound,
+}: FrameworkBindingDialogProps) {
+  const projectPath = useWikiStore((s) => s.project?.path)
+  const bumpBindingVersion = useWikiStore((s) => s.bumpBindingVersion)
+  const binding = useStorySimulationStore((s) => s.binding)
+  const setBinding = useStorySimulationStore((s) => s.setBinding)
+
+  const [chapterCount, setChapterCount] = useState(10)
+  const [submitting, setSubmitting] = useState(false)
+  const [error, setError] = useState<string | null>(null)
+
+  const isBound = binding?.frameworkId === framework.id
+
+  useEffect(() => {
+    if (!open) return
+    const bound =
+      binding && binding.frameworkId === framework.id ? binding : null
+    setChapterCount(bound ? bound.targetChapterCount : 10)
+    setError(null)
+  }, [open, binding, framework])
+
+  const handleConfirm = async () => {
+    if (!projectPath) return
+    setSubmitting(true)
+    setError(null)
+    try {
+      const updated = await saveBinding(projectPath, framework, chapterCount)
+      setBinding(updated)
+      bumpBindingVersion()
+      onBound()
+      onOpenChange(false)
+    } catch (err) {
+      setError("绑定失败,请重试")
+      console.error(err)
+    } finally {
+      setSubmitting(false)
+    }
+  }
+
+  const handleClear = async () => {
+    if (!projectPath) return
+    setSubmitting(true)
+    setError(null)
+    try {
+      await clearBinding(projectPath)
+      setBinding(null)
+      bumpBindingVersion()
+      onBound()
+      onOpenChange(false)
+    } catch (err) {
+      setError("取消绑定失败,请重试")
+      console.error(err)
+    } finally {
+      setSubmitting(false)
+    }
+  }
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange}>
+      <DialogContent className="sm:max-w-[440px]">
+        <DialogHeader>
+          <DialogTitle>绑定框架到 AI 会话</DialogTitle>
+          <DialogDescription>
+            将「{framework.title}」按章节数分配到各故事节点,并注入 AI 写作会话。
+          </DialogDescription>
+        </DialogHeader>
+
+        <div className="space-y-4 py-2">
+          <div className="space-y-2">
+            <span className="text-sm font-medium">目标章节数</span>
+            <div className="flex flex-wrap gap-2">
+              {CHAPTER_OPTIONS.map((count) => (
+                <Button
+                  key={count}
+                  size="sm"
+                  variant={chapterCount === count ? "default" : "outline"}
+                  onClick={() => setChapterCount(count)}
+                  disabled={submitting}
+                >
+                  {count} 章
+                </Button>
+              ))}
+            </div>
+            <p className="text-xs text-muted-foreground">
+              共 {framework.nodes.length} 个故事节点,章节将按起承转合分配。
+            </p>
+          </div>
+
+          {error && <p className="text-sm text-destructive">{error}</p>}
+        </div>
+
+        <DialogFooter>
+          {isBound && (
+            <Button
+              variant="destructive"
+              onClick={handleClear}
+              disabled={submitting}
+            >
+              取消绑定
+            </Button>
+          )}
+          <Button
+            variant="outline"
+            onClick={() => onOpenChange(false)}
+            disabled={submitting}
+          >
+            关闭
+          </Button>
+          <Button onClick={handleConfirm} disabled={submitting}>
+            {submitting ? "处理中..." : "确认绑定"}
+          </Button>
+        </DialogFooter>
+      </DialogContent>
+    </Dialog>
+  )
+}

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

@@ -0,0 +1,530 @@
+import { useState } from "react"
+import { useTranslation } from "react-i18next"
+import { Check, GripVertical, Pencil, X } from "lucide-react"
+import {
+  DndContext,
+  closestCenter,
+  KeyboardSensor,
+  PointerSensor,
+  useSensor,
+  useSensors,
+  type DragEndEvent,
+} from "@dnd-kit/core"
+import {
+  arrayMove,
+  SortableContext,
+  sortableKeyboardCoordinates,
+  verticalListSortingStrategy,
+  useSortable,
+} from "@dnd-kit/sortable"
+import { CSS } from "@dnd-kit/utilities"
+
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Textarea } from "@/components/ui/textarea"
+import { useStorySimulationStore } from "@/stores/story-simulation-store"
+import type { StoryNode } from "@/lib/novel/story-simulation/types"
+import { cn } from "@/lib/utils"
+
+interface FrameworkConfirmPanelProps {
+  onConfirm: () => void
+  onRegenerate: () => void
+  onSave?: () => void
+}
+
+// 起/承/转/合 阶段对应的标签配色
+const PHASE_STYLES: Record<StoryNode["phase"], string> = {
+  起: "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400",
+  承: "bg-blue-500/15 text-blue-600 dark:text-blue-400",
+  转: "bg-amber-500/15 text-amber-600 dark:text-amber-400",
+  合: "bg-purple-500/15 text-purple-600 dark:text-purple-400",
+}
+
+export function FrameworkConfirmPanel({
+  onConfirm,
+  onRegenerate,
+  onSave,
+}: FrameworkConfirmPanelProps) {
+  const { t } = useTranslation()
+  const currentFramework = useStorySimulationStore((s) => s.currentFramework)
+  const setCurrentFramework = useStorySimulationStore((s) => s.setCurrentFramework)
+  const [savedTip, setSavedTip] = useState(false)
+  const [editingTitle, setEditingTitle] = useState(false)
+  const [editingPremise, setEditingPremise] = useState(false)
+  const [titleDraft, setTitleDraft] = useState("")
+  const [shortTitleDraft, setShortTitleDraft] = useState("")
+  const [premiseDraft, setPremiseDraft] = useState("")
+
+  // 拖拽传感器(需在 early return 之前调用,避免违反 Rules of Hooks)
+  const sensors = useSensors(
+    useSensor(PointerSensor, {
+      activationConstraint: { distance: 5 },
+    }),
+    useSensor(KeyboardSensor, {
+      coordinateGetter: sortableKeyboardCoordinates,
+    }),
+  )
+
+  if (!currentFramework) return null
+
+  // 节点按 index 排序后的引用,供渲染与拖拽复用
+  const sortedNodes = currentFramework.nodes.slice().sort((a, b) => a.index - b.index)
+
+  const handleSave = () => {
+    if (!onSave) return
+    onSave()
+    setSavedTip(true)
+    setTimeout(() => setSavedTip(false), 2000)
+  }
+
+  const startEditTitle = () => {
+    setTitleDraft(currentFramework.title)
+    setShortTitleDraft(currentFramework.shortTitle || "")
+    setEditingTitle(true)
+  }
+
+  const saveTitle = () => {
+    if (!titleDraft.trim()) return
+    setCurrentFramework({
+      ...currentFramework,
+      title: titleDraft.trim(),
+      shortTitle: shortTitleDraft.trim() || undefined,
+    })
+    setEditingTitle(false)
+  }
+
+  const cancelEditTitle = () => {
+    setEditingTitle(false)
+  }
+
+  const startEditPremise = () => {
+    setPremiseDraft(currentFramework.premise)
+    setEditingPremise(true)
+  }
+
+  const savePremise = () => {
+    setCurrentFramework({
+      ...currentFramework,
+      premise: premiseDraft,
+    })
+    setEditingPremise(false)
+  }
+
+  const cancelEditPremise = () => {
+    setEditingPremise(false)
+  }
+
+  const updateNode = (nodeIndex: number, updates: Partial<StoryNode>) => {
+    setCurrentFramework({
+      ...currentFramework,
+      nodes: currentFramework.nodes.map((n) =>
+        n.index === nodeIndex ? { ...n, ...updates } : n,
+      ),
+    })
+  }
+
+  const handleDragEnd = (event: DragEndEvent) => {
+    const { active, over } = event
+    if (!over || active.id === over.id || !currentFramework) return
+
+    const oldIndex = sortedNodes.findIndex((n) => n.index === active.id)
+    const newIndex = sortedNodes.findIndex((n) => n.index === over.id)
+    if (oldIndex === -1 || newIndex === -1) return
+
+    const reordered = arrayMove(sortedNodes, oldIndex, newIndex)
+    const updatedNodes = reordered.map((n, i) => ({ ...n, index: i }))
+    setCurrentFramework({
+      ...currentFramework,
+      nodes: updatedNodes,
+    })
+    setSavedTip(false)
+  }
+
+  return (
+    <div className="flex flex-col gap-4">
+      {/* 顶部:标题 + 操作按钮 */}
+      <div className="flex items-center justify-between gap-2">
+        <div className="flex min-w-0 flex-1 items-center gap-2">
+          {editingTitle ? (
+            <div className="flex flex-1 flex-wrap items-center gap-2">
+              <Input
+                value={titleDraft}
+                onChange={(e) => setTitleDraft(e.target.value)}
+                placeholder="框架标题"
+                className="h-8 flex-1 min-w-[200px] text-base font-semibold"
+                autoFocus
+                onKeyDown={(e) => {
+                  if (e.key === "Enter") saveTitle()
+                  if (e.key === "Escape") cancelEditTitle()
+                }}
+              />
+              <Input
+                value={shortTitleDraft}
+                onChange={(e) => setShortTitleDraft(e.target.value)}
+                placeholder="短标题(可选)"
+                className="h-8 w-32 text-sm"
+                onKeyDown={(e) => {
+                  if (e.key === "Enter") saveTitle()
+                  if (e.key === "Escape") cancelEditTitle()
+                }}
+              />
+              <Button size="sm" variant="ghost" className="h-8 w-8 p-0" onClick={saveTitle}>
+                <Check className="h-4 w-4 text-emerald-500" />
+              </Button>
+              <Button size="sm" variant="ghost" className="h-8 w-8 p-0" onClick={cancelEditTitle}>
+                <X className="h-4 w-4 text-muted-foreground" />
+              </Button>
+            </div>
+          ) : (
+            <>
+              <h3 className="truncate text-lg font-semibold">
+                {currentFramework.title}
+              </h3>
+              {currentFramework.shortTitle && (
+                <span className="shrink-0 rounded bg-primary/10 px-1.5 py-0.5 text-xs text-primary">
+                  {currentFramework.shortTitle}
+                </span>
+              )}
+              <Button
+                size="sm"
+                variant="ghost"
+                className="h-7 w-7 p-0 opacity-50 hover:opacity-100"
+                onClick={startEditTitle}
+                title="编辑标题"
+              >
+                <Pencil className="h-3.5 w-3.5" />
+              </Button>
+            </>
+          )}
+        </div>
+        {!editingTitle && (
+          <div className="flex shrink-0 items-center gap-2">
+            <Button variant="outline" onClick={onRegenerate}>
+              {t("storySimulation.regenerateFramework")}
+            </Button>
+            {onSave && (
+              <Button variant="outline" onClick={handleSave}>
+                {savedTip ? (
+                  <>
+                    <Check className="mr-1 h-4 w-4 text-emerald-500" />
+                    已保存
+                  </>
+                ) : (
+                  "保存框架"
+                )}
+              </Button>
+            )}
+            <Button onClick={onConfirm}>
+              {t("storySimulation.confirmFramework")}
+            </Button>
+          </div>
+        )}
+      </div>
+
+      {/* 前提区 - 支持编辑 */}
+      <div className="rounded-lg bg-muted p-4">
+        <div className="mb-2 flex items-center justify-between">
+          <div className="text-sm font-medium text-muted-foreground">
+            {t("storySimulation.frameworkPremise")}
+          </div>
+          {!editingPremise && (
+            <Button
+              size="sm"
+              variant="ghost"
+              className="h-6 w-6 p-0 opacity-50 hover:opacity-100"
+              onClick={startEditPremise}
+              title="编辑前提"
+            >
+              <Pencil className="h-3 w-3" />
+            </Button>
+          )}
+        </div>
+        {editingPremise ? (
+          <div className="space-y-2">
+            <Textarea
+              value={premiseDraft}
+              onChange={(e) => setPremiseDraft(e.target.value)}
+              rows={3}
+              className="text-sm"
+              autoFocus
+            />
+            <div className="flex justify-end gap-2">
+              <Button size="sm" variant="ghost" onClick={cancelEditPremise}>
+                取消
+              </Button>
+              <Button size="sm" onClick={savePremise}>
+                <Check className="mr-1 h-3.5 w-3.5" />
+                保存
+              </Button>
+            </div>
+          </div>
+        ) : (
+          <p className="text-sm leading-relaxed whitespace-pre-wrap">
+            {currentFramework.premise || "(无前提)"}
+          </p>
+        )}
+      </div>
+
+      {/* 节点列表 */}
+      <div className="flex flex-col gap-3">
+        <div className="text-sm font-medium text-muted-foreground">
+          {t("storySimulation.frameworkNodes")}
+          <span className="ml-2 text-xs font-normal text-muted-foreground/70">
+            (拖拽手柄可排序)
+          </span>
+        </div>
+        <DndContext
+          sensors={sensors}
+          collisionDetection={closestCenter}
+          onDragEnd={handleDragEnd}
+        >
+          <SortableContext
+            items={sortedNodes.map((n) => n.index)}
+            strategy={verticalListSortingStrategy}
+          >
+            {sortedNodes.map((node) => (
+              <SortableNodeCard
+                key={node.index}
+                node={node}
+                onUpdate={(updates) => updateNode(node.index, updates)}
+              />
+            ))}
+          </SortableContext>
+        </DndContext>
+      </div>
+    </div>
+  )
+}
+
+function SortableNodeCard({
+  node,
+  onUpdate,
+}: {
+  node: StoryNode
+  onUpdate: (updates: Partial<StoryNode>) => void
+}) {
+  const {
+    attributes,
+    listeners,
+    setNodeRef,
+    transform,
+    transition,
+    isDragging,
+  } = useSortable({ id: node.index })
+
+  const style = {
+    transform: CSS.Transform.toString(transform),
+    transition,
+    opacity: isDragging ? 0.5 : 1,
+  }
+
+  return (
+    <div ref={setNodeRef} style={style} className="relative">
+      <button
+        type="button"
+        className="absolute left-0 top-0 z-10 flex h-full w-6 cursor-grab items-center justify-center text-muted-foreground/30 hover:text-primary active:cursor-grabbing"
+        {...attributes}
+        {...listeners}
+        title="拖拽排序"
+      >
+        <GripVertical className="h-4 w-4" />
+      </button>
+      <div className="pl-6">
+        <FrameworkNodeCard node={node} onUpdate={onUpdate} />
+      </div>
+    </div>
+  )
+}
+
+function FrameworkNodeCard({
+  node,
+  onUpdate,
+}: {
+  node: StoryNode
+  onUpdate: (updates: Partial<StoryNode>) => void
+}) {
+  const { t } = useTranslation()
+  const [editing, setEditing] = useState(false)
+  const [draft, setDraft] = useState<Omit<StoryNode, "involvedCharacters"> & { involvedCharacters: string }>({
+    ...node,
+    involvedCharacters: node.involvedCharacters.join("、"),
+  })
+
+  const startEdit = () => {
+    setDraft({
+      ...node,
+      involvedCharacters: node.involvedCharacters.join("、"),
+    })
+    setEditing(true)
+  }
+
+  const save = () => {
+    onUpdate({
+      title: draft.title.trim() || node.title,
+      coreConflict: draft.coreConflict,
+      involvedCharacters: String(draft.involvedCharacters)
+        .split(/[,,、]/)
+        .map((s) => s.trim())
+        .filter(Boolean),
+      goal: draft.goal,
+      causeFromPrev: draft.causeFromPrev,
+      expectedOutcome: draft.expectedOutcome,
+    })
+    setEditing(false)
+  }
+
+  const cancel = () => {
+    setEditing(false)
+  }
+
+  const involvedCharsStr = String(draft.involvedCharacters)
+
+  return (
+    <div className="rounded-lg border p-4">
+      {/* 阶段标签 + 节点标题 */}
+      <div className="mb-3 flex items-center justify-between gap-2">
+        <div className="flex min-w-0 items-center gap-2">
+          <span
+            className={cn(
+              "shrink-0 rounded px-1.5 py-0.5 text-xs font-medium",
+              PHASE_STYLES[node.phase]
+            )}
+          >
+            {node.phase}
+          </span>
+          {editing ? (
+            <Input
+              value={draft.title}
+              onChange={(e) => setDraft({ ...draft, title: e.target.value })}
+              className="h-7 flex-1 text-sm font-medium"
+              autoFocus
+              onKeyDown={(e) => {
+                if (e.key === "Enter") save()
+                if (e.key === "Escape") cancel()
+              }}
+            />
+          ) : (
+            <span className="truncate font-medium">{node.title}</span>
+          )}
+        </div>
+        {!editing ? (
+          <Button
+            size="sm"
+            variant="ghost"
+            className="h-7 w-7 p-0 opacity-50 hover:opacity-100"
+            onClick={startEdit}
+            title="编辑节点"
+          >
+            <Pencil className="h-3.5 w-3.5" />
+          </Button>
+        ) : (
+          <div className="flex shrink-0 items-center gap-1">
+            <Button size="sm" variant="ghost" className="h-7 w-7 p-0" onClick={save}>
+              <Check className="h-4 w-4 text-emerald-500" />
+            </Button>
+            <Button size="sm" variant="ghost" className="h-7 w-7 p-0" onClick={cancel}>
+              <X className="h-4 w-4 text-muted-foreground" />
+            </Button>
+          </div>
+        )}
+      </div>
+
+      {/* 节点字段 */}
+      {editing ? (
+        <div className="space-y-3 text-sm">
+          <FieldEdit label={t("storySimulation.coreConflict")}>
+            <Textarea
+              value={draft.coreConflict}
+              onChange={(e) => setDraft({ ...draft, coreConflict: e.target.value })}
+              rows={2}
+              className="text-sm"
+            />
+          </FieldEdit>
+          <FieldEdit label={t("storySimulation.involvedCharacters")}>
+            <Input
+              value={involvedCharsStr}
+              onChange={(e) =>
+                setDraft({
+                  ...draft,
+                  involvedCharacters: e.target.value,
+                })
+              }
+              placeholder="用逗号或顿号分隔"
+              className="h-8 text-sm"
+            />
+          </FieldEdit>
+          <FieldEdit label={t("storySimulation.goal")}>
+            <Textarea
+              value={draft.goal}
+              onChange={(e) => setDraft({ ...draft, goal: e.target.value })}
+              rows={2}
+              className="text-sm"
+            />
+          </FieldEdit>
+          <FieldEdit label={t("storySimulation.cause")}>
+            <Textarea
+              value={draft.causeFromPrev}
+              onChange={(e) => setDraft({ ...draft, causeFromPrev: e.target.value })}
+              rows={2}
+              className="text-sm"
+            />
+          </FieldEdit>
+          <FieldEdit label={t("storySimulation.expectedOutcome")}>
+            <Textarea
+              value={draft.expectedOutcome}
+              onChange={(e) => setDraft({ ...draft, expectedOutcome: e.target.value })}
+              rows={2}
+              className="text-sm"
+            />
+          </FieldEdit>
+        </div>
+      ) : (
+        <dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-sm">
+          <dt className="whitespace-nowrap text-muted-foreground">
+            {t("storySimulation.coreConflict")}
+          </dt>
+          <dd className="leading-relaxed">{node.coreConflict}</dd>
+
+          <dt className="whitespace-nowrap text-muted-foreground">
+            {t("storySimulation.involvedCharacters")}
+          </dt>
+          <dd className="leading-relaxed">
+            {Array.isArray(node.involvedCharacters)
+              ? node.involvedCharacters.join("、")
+              : node.involvedCharacters}
+          </dd>
+
+          <dt className="whitespace-nowrap text-muted-foreground">
+            {t("storySimulation.goal")}
+          </dt>
+          <dd className="leading-relaxed">{node.goal}</dd>
+
+          <dt className="whitespace-nowrap text-muted-foreground">
+            {t("storySimulation.cause")}
+          </dt>
+          <dd className="leading-relaxed">{node.causeFromPrev}</dd>
+
+          <dt className="whitespace-nowrap text-muted-foreground">
+            {t("storySimulation.expectedOutcome")}
+          </dt>
+          <dd className="leading-relaxed">{node.expectedOutcome}</dd>
+        </dl>
+      )}
+    </div>
+  )
+}
+
+function FieldEdit({
+  label,
+  children,
+}: {
+  label: string
+  children: React.ReactNode
+}) {
+  return (
+    <div>
+      <div className="mb-1 text-xs text-muted-foreground">{label}</div>
+      {children}
+    </div>
+  )
+}

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

@@ -0,0 +1,234 @@
+import { useEffect, useMemo, useState } from "react"
+import { Link2, Search, Trash2 } from "lucide-react"
+
+import { useWikiStore } from "@/stores/wiki-store"
+import {
+  useStorySimulationStore,
+} from "@/stores/story-simulation-store"
+import { deleteFramework, loadFrameworks } from "@/lib/novel/story-simulation/framework-store"
+import { loadBinding } from "@/lib/novel/story-simulation/framework-binding"
+import { Button } from "@/components/ui/button"
+import type { StoryFramework } from "@/lib/novel/story-simulation/types"
+
+import { FrameworkBindingDialog } from "./framework-binding-dialog"
+
+interface FrameworkListProps {
+  onSelectFramework: (framework: StoryFramework) => void
+  onNewFramework: () => void
+  /** 刷新计数:外部 bump 时触发重新加载 */
+  refreshKey?: number
+}
+
+/** 计算卡片显示标题:优先 shortTitle,否则截取 title 前 8 字。 */
+function displayTitle(fw: StoryFramework): string {
+  if (fw.shortTitle && fw.shortTitle.trim().length > 0) {
+    return fw.shortTitle
+  }
+  if (fw.title.length <= 8) return fw.title
+  return fw.title.slice(0, 8) + "..."
+}
+
+export function FrameworkList({
+  onSelectFramework,
+  onNewFramework,
+  refreshKey,
+}: FrameworkListProps) {
+  const projectPath = useWikiStore((s) => s.project?.path)
+  const frameworks = useStorySimulationStore((s) => s.frameworks)
+  const setFrameworks = useStorySimulationStore((s) => s.setFrameworks)
+  const binding = useStorySimulationStore((s) => s.binding)
+  const setBinding = useStorySimulationStore((s) => s.setBinding)
+  const currentFrameworkId = useStorySimulationStore(
+    (s) => s.currentFramework?.id ?? null,
+  )
+  const listRefreshKey = useStorySimulationStore((s) => s.listRefreshKey)
+
+  const [loading, setLoading] = useState(true)
+  const [dialogFramework, setDialogFramework] =
+    useState<StoryFramework | null>(null)
+  const [deletingId, setDeletingId] = useState<string | null>(null)
+  const [searchQuery, setSearchQuery] = useState("")
+
+  // 过滤框架
+  const filteredFrameworks = useMemo(() => {
+    if (!searchQuery.trim()) return frameworks
+    const q = searchQuery.toLowerCase().trim()
+    return frameworks.filter(
+      (fw) =>
+        fw.title.toLowerCase().includes(q) ||
+        (fw.shortTitle && fw.shortTitle.toLowerCase().includes(q)) ||
+        fw.premise.toLowerCase().includes(q),
+    )
+  }, [frameworks, searchQuery])
+
+  useEffect(() => {
+    if (!projectPath) {
+      setLoading(false)
+      return
+    }
+    let cancelled = false
+    const run = async () => {
+      setLoading(true)
+      try {
+        const [list, currentBinding] = await Promise.all([
+          loadFrameworks(projectPath),
+          loadBinding(projectPath),
+        ])
+        if (cancelled) return
+        setFrameworks(list)
+        setBinding(currentBinding)
+      } catch {
+        // 加载失败时保持空列表,不阻塞 UI
+      } finally {
+        if (!cancelled) setLoading(false)
+      }
+    }
+    void run()
+    return () => {
+      cancelled = true
+    }
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [projectPath, setFrameworks, setBinding, refreshKey, listRefreshKey])
+
+  const handleDelete = async (framework: StoryFramework) => {
+    if (!projectPath) return
+    const confirmed = window.confirm(
+      `确定删除框架「${framework.title}」吗?删除后不可恢复。`,
+    )
+    if (!confirmed) return
+    setDeletingId(framework.id)
+    try {
+      await deleteFramework(projectPath, framework.id)
+      const list = await loadFrameworks(projectPath)
+      setFrameworks(list)
+    } catch {
+      // 删除失败不做额外提示
+    } finally {
+      setDeletingId(null)
+    }
+  }
+
+  if (!projectPath) {
+    return (
+      <div className="flex h-full items-center justify-center p-4 text-xs text-muted-foreground">
+        请先打开一个项目
+      </div>
+    )
+  }
+
+  return (
+    <div className="flex h-full flex-col">
+      {loading ? (
+        <div className="flex flex-1 items-center justify-center text-xs text-muted-foreground">
+          加载中...
+        </div>
+      ) : frameworks.length === 0 ? (
+        <div className="flex flex-1 flex-col items-center justify-center gap-3 p-4">
+          <div className="text-xs text-muted-foreground">暂无故事框架</div>
+          <Button size="sm" variant="outline" onClick={onNewFramework}>
+            新建框架
+          </Button>
+        </div>
+      ) : (
+        <>
+          {/* 搜索框 */}
+          {frameworks.length > 3 && (
+            <div className="shrink-0 px-2 pt-2">
+              <div className="relative">
+                <Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
+                <input
+                  type="text"
+                  value={searchQuery}
+                  onChange={(e) => setSearchQuery(e.target.value)}
+                  placeholder="搜索框架..."
+                  className="h-7 w-full rounded-md border border-input bg-background pl-7 pr-2 text-xs outline-none focus:border-ring focus:ring-1 focus:ring-ring/50"
+                />
+              </div>
+            </div>
+          )}
+          <div className="flex-1 space-y-1 overflow-y-auto p-2">
+            {filteredFrameworks.length === 0 ? (
+              <div className="py-4 text-center text-xs text-muted-foreground">
+                无匹配框架
+              </div>
+            ) : (
+              filteredFrameworks.map((framework) => {
+                const isBound = binding?.frameworkId === framework.id
+                const isSelected = currentFrameworkId === framework.id
+                return (
+                  <div
+                    key={framework.id}
+                    className={`group flex w-full items-center gap-1 rounded-md border px-2 py-1.5 text-left transition ${
+                      isSelected
+                        ? "border-primary bg-primary/10"
+                        : "bg-background hover:bg-muted"
+                    }`}
+                  >
+                    <button
+                      type="button"
+                      className="flex min-w-0 flex-1 flex-col items-start"
+                      onClick={() => onSelectFramework(framework)}
+                    >
+                      <span
+                        className="w-full truncate text-sm font-medium"
+                        title={framework.title}
+                      >
+                        {displayTitle(framework)}
+                      </span>
+                      <span className="mt-0.5 text-[11px] text-muted-foreground">
+                        {framework.nodes.length} 节点 · {framework.targetWords} 字
+                        {isBound ? " · 已绑定" : ""}
+                      </span>
+                    </button>
+                    <div className="flex shrink-0 items-center opacity-60 group-hover:opacity-100">
+                      <Button
+                        type="button"
+                        size="icon"
+                        variant="ghost"
+                        className="h-7 w-7"
+                        title={isBound ? "管理绑定" : "绑定到 AI 会话"}
+                        onClick={(e) => {
+                          e.stopPropagation()
+                          setDialogFramework(framework)
+                        }}
+                      >
+                        <Link2
+                          className={`h-3.5 w-3.5 ${isBound ? "text-primary" : ""}`}
+                        />
+                      </Button>
+                      <Button
+                        type="button"
+                        size="icon"
+                        variant="ghost"
+                        className="h-7 w-7 text-muted-foreground hover:text-destructive"
+                        title="删除框架"
+                        disabled={deletingId === framework.id}
+                        onClick={(e) => {
+                          e.stopPropagation()
+                          void handleDelete(framework)
+                        }}
+                      >
+                        <Trash2 className="h-3.5 w-3.5" />
+                      </Button>
+                    </div>
+                  </div>
+                )
+              })
+            )}
+          </div>
+        </>
+      )}
+
+      {dialogFramework && (
+        <FrameworkBindingDialog
+          open={true}
+          onOpenChange={(open) => {
+            if (!open) setDialogFramework(null)
+          }}
+          framework={dialogFramework}
+          onBound={() => setDialogFramework(null)}
+        />
+      )}
+    </div>
+  )
+}

+ 364 - 0
src/components/novel/story-simulation/interview-history-view.tsx

@@ -0,0 +1,364 @@
+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"
+import { useWikiStore } from "@/stores/wiki-store"
+import { loadInterviews, deleteInterview } from "@/lib/novel/story-simulation/interview-store"
+import { exportInterview } from "@/lib/novel/story-simulation/interview-export"
+import { deserializeSimulationSnapshot } from "@/lib/novel/story-simulation/simulation-serializer"
+import { loadSimulationResults } from "@/lib/novel/story-simulation/framework-store"
+import type { SavedInterview } from "@/lib/novel/story-simulation/interview-store"
+import type { NovelAgent } from "@/lib/novel/story-simulation/types"
+
+export function InterviewHistoryView() {
+  const projectPath = useWikiStore((s) => s.project?.path)
+  const showInterviewHistory = useStorySimulationStore((s) => s.showInterviewHistory)
+  const savedInterviews = useStorySimulationStore((s) => s.savedInterviews)
+  const viewingInterview = useStorySimulationStore((s) => s.viewingInterview)
+  const setShowInterviewHistory = useStorySimulationStore((s) => s.setShowInterviewHistory)
+  const setSavedInterviews = useStorySimulationStore((s) => s.setSavedInterviews)
+  const setViewingInterview = useStorySimulationStore((s) => s.setViewingInterview)
+  const setError = useStorySimulationStore((s) => s.setError)
+  const setContinuingInterviewId = useStorySimulationStore((s) => s.setContinuingInterviewId)
+  const setActiveChatAgent = useStorySimulationStore((s) => s.setActiveChatAgent)
+  const setAgentChatMessages = useStorySimulationStore((s) => s.setAgentChatMessages)
+
+  const [loading, setLoading] = useState(false)
+  const [deleting, setDeleting] = useState<string | null>(null)
+  const [exporting, setExporting] = useState<string | null>(null)
+  const [resuming, setResuming] = useState(false)
+
+  // 加载采访列表
+  useEffect(() => {
+    if (!showInterviewHistory || !projectPath) return
+    let cancelled = false
+    setLoading(true)
+    loadInterviews(projectPath)
+      .then((interviews) => {
+        if (!cancelled) {
+          setSavedInterviews(interviews)
+        }
+      })
+      .catch((err) => {
+        console.error("加载采访历史失败:", err)
+      })
+      .finally(() => {
+        if (!cancelled) setLoading(false)
+      })
+    return () => {
+      cancelled = true
+    }
+  }, [showInterviewHistory, projectPath, setSavedInterviews])
+
+  const handleClose = () => {
+    setShowInterviewHistory(false)
+    setViewingInterview(null)
+  }
+
+  const handleDelete = async (interview: SavedInterview) => {
+    if (!projectPath) return
+    if (!confirm(`确定要删除与「${interview.agentName}」的采访对话吗?此操作不可恢复。`)) {
+      return
+    }
+    setDeleting(interview.id)
+    try {
+      await deleteInterview(projectPath, interview.id)
+      const updated = savedInterviews.filter((i) => i.id !== interview.id)
+      setSavedInterviews(updated)
+      if (viewingInterview?.id === interview.id) {
+        setViewingInterview(null)
+      }
+      setError("采访已删除")
+      setTimeout(() => setError(null), 2000)
+    } catch (err) {
+      setError(err instanceof Error ? err.message : "删除失败")
+      setTimeout(() => setError(null), 3000)
+    } finally {
+      setDeleting(null)
+    }
+  }
+
+  const handleExport = async (interview: SavedInterview) => {
+    if (!projectPath) return
+    setExporting(interview.id)
+    try {
+      const filePath = await exportInterview(projectPath, interview)
+      setError(`采访已导出到:${filePath}`)
+      setTimeout(() => setError(null), 5000)
+    } catch (err) {
+      setError(err instanceof Error ? err.message : "导出失败")
+      setTimeout(() => setError(null), 3000)
+    } finally {
+      setExporting(null)
+    }
+  }
+
+  const handleContinueInterview = async (interview: SavedInterview) => {
+    if (!projectPath) return
+    setResuming(true)
+    try {
+      let agents: NovelAgent[] = []
+
+      // 优先从采访记录的 agentSnapshot 恢复
+      if (interview.agentSnapshot) {
+        const { agents: deserializedAgents } = deserializeSimulationSnapshot(interview.agentSnapshot)
+        agents = deserializedAgents
+      }
+
+      // 若采访记录无快照,尝试从对应 frameworkId 的推演结果恢复
+      if (agents.length === 0 && interview.frameworkId) {
+        const results = await loadSimulationResults(projectPath, interview.frameworkId)
+        for (const r of results) {
+          if (r.agentSnapshot) {
+            const { agents: deserializedAgents } = deserializeSimulationSnapshot(r.agentSnapshot)
+            if (deserializedAgents.some((a) => a.name === interview.agentName)) {
+              agents = deserializedAgents
+              break
+            }
+          }
+        }
+      }
+
+      if (agents.length === 0) {
+        setError("无法恢复角色状态,仅支持只读查看")
+        setTimeout(() => setError(null), 3000)
+        return
+      }
+
+      // 找到对应角色的 agent
+      const targetAgent = agents.find((a) => a.name === interview.agentName)
+      if (!targetAgent) {
+        setError(`未找到角色「${interview.agentName}」的 agent 数据`)
+        setTimeout(() => setError(null), 3000)
+        return
+      }
+
+      // 加载旧对话消息到 store
+      setAgentChatMessages(interview.session.messages)
+      setActiveChatAgent({ id: targetAgent.characterId, name: targetAgent.name })
+      setContinuingInterviewId(interview.id)
+      setShowInterviewHistory(false)
+      setViewingInterview(null)
+      setError("已恢复采访,可继续对话")
+      setTimeout(() => setError(null), 2000)
+    } catch (err) {
+      setError(err instanceof Error ? err.message : "恢复失败")
+      setTimeout(() => setError(null), 3000)
+    } finally {
+      setResuming(false)
+    }
+  }
+
+  const formatDate = (dateStr: string) => {
+    try {
+      const date = new Date(dateStr)
+      return date.toLocaleString("zh-CN", {
+        year: "numeric",
+        month: "2-digit",
+        day: "2-digit",
+        hour: "2-digit",
+        minute: "2-digit",
+      })
+    } catch {
+      return dateStr
+    }
+  }
+
+  const getMessageCount = (interview: SavedInterview) => {
+    return interview.session.messages.length
+  }
+
+  const getPreview = (interview: SavedInterview) => {
+    const messages = interview.session.messages
+    if (messages.length === 0) return "(空对话)"
+    const lastMsg = messages[messages.length - 1]
+    return lastMsg.content.slice(0, 50) + (lastMsg.content.length > 50 ? "..." : "")
+  }
+
+  if (!showInterviewHistory) return null
+
+  return (
+    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
+      <div className="flex h-[80vh] w-full max-w-4xl flex-col overflow-hidden rounded-xl bg-background shadow-2xl">
+        {/* 头部 */}
+        <div className="flex items-center justify-between border-b px-4 py-3">
+          <div className="flex items-center gap-2">
+            <MessageCircle className="h-5 w-5 text-primary" />
+            <h2 className="text-base font-semibold">
+              {viewingInterview ? `与 ${viewingInterview.agentName} 的对话` : "采访历史"}
+            </h2>
+          </div>
+          <Button variant="ghost" size="sm" onClick={handleClose}>
+            <X className="h-4 w-4" />
+          </Button>
+        </div>
+
+        {/* 内容区 */}
+        <div className="flex flex-1 overflow-hidden">
+          {viewingInterview ? (
+            // 对话详情视图
+            <div className="flex flex-1 flex-col">
+              {/* 返回按钮和信息栏 */}
+              <div className="flex items-center justify-between border-b px-4 py-2 text-sm">
+                <button
+                  type="button"
+                  className="flex items-center gap-1 text-muted-foreground hover:text-foreground"
+                  onClick={() => setViewingInterview(null)}
+                >
+                  <ChevronRight className="h-4 w-4 rotate-180" />
+                  返回列表
+                </button>
+                <div className="flex items-center gap-2">
+                  {viewingInterview.frameworkTitle && (
+                    <span className="text-xs text-muted-foreground">
+                      框架:{viewingInterview.frameworkTitle}
+                    </span>
+                  )}
+                  <Button
+                    variant="default"
+                    size="sm"
+                    onClick={() => handleContinueInterview(viewingInterview)}
+                    disabled={resuming}
+                  >
+                    {resuming ? "恢复中..." : "继续对话"}
+                  </Button>
+                  <Button
+                    variant="outline"
+                    size="sm"
+                    onClick={() => handleExport(viewingInterview)}
+                    disabled={exporting === viewingInterview.id}
+                  >
+                    <Download className="mr-1 h-3.5 w-3.5" />
+                    {exporting === viewingInterview.id ? "导出中..." : "导出"}
+                  </Button>
+                  <Button
+                    variant="outline"
+                    size="sm"
+                    onClick={() => handleDelete(viewingInterview)}
+                    disabled={deleting === viewingInterview.id}
+                    className="text-red-600 hover:bg-red-50 hover:text-red-700 dark:hover:bg-red-950"
+                  >
+                    <Trash2 className="mr-1 h-3.5 w-3.5" />
+                    {deleting === viewingInterview.id ? "删除中..." : "删除"}
+                  </Button>
+                </div>
+              </div>
+
+              {/* 对话消息 */}
+              <div className="flex-1 overflow-y-auto p-4">
+                <div className="mx-auto max-w-2xl space-y-4">
+                  {viewingInterview.session.messages.map((msg) => (
+                    <div
+                      key={msg.id}
+                      className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}
+                    >
+                      <div
+                        className={`max-w-[80%] rounded-2xl px-4 py-2 ${
+                          msg.role === "user"
+                            ? "bg-primary text-primary-foreground"
+                            : "bg-muted"
+                        }`}
+                      >
+                        <div className="mb-1 flex items-center gap-1.5 text-xs opacity-70">
+                          <User className="h-3 w-3" />
+                          {msg.role === "user" ? "你" : viewingInterview.agentName}
+                        </div>
+                        <p className="whitespace-pre-wrap text-sm leading-relaxed">{msg.content}</p>
+                      </div>
+                    </div>
+                  ))}
+                  {viewingInterview.session.messages.length === 0 && (
+                    <div className="py-12 text-center text-sm text-muted-foreground">
+                      暂无对话内容
+                    </div>
+                  )}
+                </div>
+              </div>
+            </div>
+          ) : (
+            // 列表视图
+            <div className="flex w-full flex-col">
+              {loading ? (
+                <div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
+                  加载中...
+                </div>
+              ) : savedInterviews.length === 0 ? (
+                <div className="flex flex-1 flex-col items-center justify-center gap-2 text-muted-foreground">
+                  <MessageCircle className="h-12 w-12 opacity-20" />
+                  <p className="text-sm">暂无保存的采访对话</p>
+                  <p className="text-xs">在推演报告中与角色对话后点击保存即可</p>
+                </div>
+              ) : (
+                <div className="flex-1 overflow-y-auto p-4">
+                  <div className="space-y-2">
+                    {savedInterviews.map((interview) => (
+                      <div
+                        key={interview.id}
+                        className="group rounded-lg border p-3 transition-colors hover:border-primary/50 hover:bg-accent/30"
+                      >
+                        <div className="flex items-start justify-between gap-3">
+                          <button
+                            type="button"
+                            className="flex-1 text-left"
+                            onClick={() => setViewingInterview(interview)}
+                          >
+                            <div className="flex items-center gap-2">
+                              <span className="font-medium">{interview.agentName}</span>
+                              {interview.frameworkTitle && (
+                                <span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
+                                  {interview.frameworkTitle}
+                                </span>
+                              )}
+                            </div>
+                            <p className="mt-1 text-xs text-muted-foreground line-clamp-1">
+                              {getPreview(interview)}
+                            </p>
+                            <div className="mt-2 flex items-center gap-3 text-[11px] text-muted-foreground">
+                              <span className="flex items-center gap-1">
+                                <Clock className="h-3 w-3" />
+                                {formatDate(interview.updatedAt)}
+                              </span>
+                              <span>{getMessageCount(interview)} 条消息</span>
+                            </div>
+                          </button>
+                          <div className="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
+                            <Button
+                              variant="ghost"
+                              size="sm"
+                              className="h-7 w-7 p-0"
+                              onClick={(e) => {
+                                e.stopPropagation()
+                                handleExport(interview)
+                              }}
+                              disabled={exporting === interview.id}
+                              title="导出"
+                            >
+                              <Download className="h-3.5 w-3.5" />
+                            </Button>
+                            <Button
+                              variant="ghost"
+                              size="sm"
+                              className="h-7 w-7 p-0 text-red-600 hover:bg-red-50 hover:text-red-700 dark:hover:bg-red-950"
+                              onClick={(e) => {
+                                e.stopPropagation()
+                                handleDelete(interview)
+                              }}
+                              disabled={deleting === interview.id}
+                              title="删除"
+                            >
+                              <Trash2 className="h-3.5 w-3.5" />
+                            </Button>
+                          </div>
+                        </div>
+                      </div>
+                    ))}
+                  </div>
+                </div>
+              )}
+            </div>
+          )}
+        </div>
+      </div>
+    </div>
+  )
+}

+ 218 - 0
src/components/novel/story-simulation/simulation-config-panel.tsx

@@ -0,0 +1,218 @@
+import { useTranslation } from "react-i18next"
+
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Textarea } from "@/components/ui/textarea"
+import { WORD_BUDGET_PRESETS, type SimulationMode, MODE_VISUAL_INFO } from "@/lib/novel/story-simulation/types"
+import { useStorySimulationStore } from "@/stores/story-simulation-store"
+import { cn } from "@/lib/utils"
+import { Info } from "lucide-react"
+
+const MODES: { mode: SimulationMode }[] = [
+  { mode: "event-driven" },
+  { mode: "free-emergence" },
+  { mode: "decision-tree" },
+  { mode: "hybrid" },
+]
+
+const CHAPTER_OPTIONS = [5, 10, 20, 30, 50]
+const ROUND_OPTIONS = [
+  { value: 0, label: "自动(按字数)" },
+  { value: 2, label: "快速(2轮)" },
+  { value: 3, label: "标准(3轮)" },
+  { value: 5, label: "深度(5轮)" },
+  { value: 8, label: "充分(8轮)" },
+]
+
+const WORD_LABEL_KEYS: Record<number, string> = {
+  10000: "storySimulation.words10k",
+  30000: "storySimulation.words30k",
+  50000: "storySimulation.words50k",
+}
+
+interface SimulationConfigPanelProps {
+  onStart: () => void
+}
+
+export function SimulationConfigPanel({ onStart }: SimulationConfigPanelProps) {
+  const { t } = useTranslation()
+  const mode = useStorySimulationStore((s) => s.mode)
+  const userIdea = useStorySimulationStore((s) => s.userIdea)
+  const targetWords = useStorySimulationStore((s) => s.targetWords)
+  const sourceChapters = useStorySimulationStore((s) => s.sourceChapters)
+  const simulationRounds = useStorySimulationStore((s) => s.simulationRounds)
+  const setMode = useStorySimulationStore((s) => s.setMode)
+  const setUserIdea = useStorySimulationStore((s) => s.setUserIdea)
+  const setTargetWords = useStorySimulationStore((s) => s.setTargetWords)
+  const setSourceChapters = useStorySimulationStore((s) => s.setSourceChapters)
+  const setSimulationRounds = useStorySimulationStore((s) => s.setSimulationRounds)
+
+  const selectedModeInfo = MODE_VISUAL_INFO[mode]
+
+  return (
+    <div className="mx-auto flex w-full max-w-2xl flex-col gap-6 p-6">
+      {/* 1. 仿真模式选择 */}
+      <section className="flex flex-col gap-3">
+        <h3 className="flex items-center gap-1.5 text-sm font-medium">
+          {t("storySimulation.selectMode")}
+          <Info className="h-3.5 w-3.5 text-muted-foreground" />
+        </h3>
+        <div className="grid grid-cols-2 gap-3">
+          {MODES.map((m) => {
+            const info = MODE_VISUAL_INFO[m.mode]
+            const isSelected = mode === m.mode
+            return (
+              <button
+                key={m.mode}
+                type="button"
+                onClick={() => setMode(m.mode)}
+                className={cn(
+                  "flex flex-col items-start gap-2 rounded-lg border p-3 text-left transition-all",
+                  isSelected
+                    ? "border-primary bg-primary/5 ring-2 ring-primary/20"
+                    : "border-border hover:border-primary/50 hover:bg-accent/30"
+                )}
+              >
+                <div className="flex w-full items-center justify-between">
+                  <span className="text-sm font-medium">{info.name}</span>
+                  <span className={cn("rounded-full px-2 py-0.5 text-[10px] font-medium", info.color)}>
+                    {info.freedomLabel}
+                  </span>
+                </div>
+                <span className="text-xs leading-relaxed text-muted-foreground">{info.shortDesc}</span>
+              </button>
+            )
+          })}
+        </div>
+
+        {/* 选中模式的详细说明 */}
+        {selectedModeInfo && (
+          <div className="rounded-lg border bg-muted/30 p-4">
+            <div className="mb-3 flex items-center gap-2">
+              <span className="font-medium">{selectedModeInfo.name}模式</span>
+              <span className={cn("ml-auto rounded-full px-2 py-0.5 text-xs", selectedModeInfo.color)}>
+                推荐
+              </span>
+            </div>
+            
+            <div className="mb-3 grid grid-cols-3 gap-2 text-center text-xs">
+              <div className="rounded-md bg-background p-2">
+                <div className="text-muted-foreground">轮数</div>
+                <div className="mt-0.5 font-medium">{selectedModeInfo.roundsLabel}</div>
+              </div>
+              <div className="rounded-md bg-background p-2">
+                <div className="text-muted-foreground">随机性</div>
+                <div className="mt-0.5 font-medium">{selectedModeInfo.randomnessLabel}</div>
+              </div>
+              <div className="rounded-md bg-background p-2">
+                <div className="text-muted-foreground">自由度</div>
+                <div className="mt-0.5 font-medium">{selectedModeInfo.freedomLabel}</div>
+              </div>
+            </div>
+
+            <div className="mb-2">
+              <p className="mb-1.5 text-xs font-medium text-muted-foreground">模式特点:</p>
+              <ul className="space-y-1">
+                {selectedModeInfo.features.map((feature, i) => (
+                  <li key={i} className="flex items-start gap-1.5 text-xs">
+                    <span className="text-primary">•</span>
+                    <span>{feature}</span>
+                  </li>
+                ))}
+              </ul>
+            </div>
+
+            <div className="rounded-md bg-primary/5 p-2 text-xs">
+              <span className="font-medium text-primary">适用场景:</span>
+              <span className="text-foreground/80"> {selectedModeInfo.bestFor}</span>
+            </div>
+          </div>
+        )}
+      </section>
+
+      {/* 2. 用户思路 */}
+      <section className="flex flex-col gap-3">
+        <h3 className="text-sm font-medium">{t("storySimulation.yourIdea")}</h3>
+        <Textarea
+          value={userIdea}
+          onChange={(e) => setUserIdea(e.target.value)}
+          placeholder={t("storySimulation.yourIdeaPlaceholder")}
+          rows={4}
+        />
+      </section>
+
+      {/* 3. 目标字数 */}
+      <section className="flex flex-col gap-3">
+        <h3 className="text-sm font-medium">{t("storySimulation.targetWords")}</h3>
+        <div className="flex flex-wrap items-center gap-2">
+          {WORD_BUDGET_PRESETS.map((preset) => (
+            <Button
+              key={preset}
+              variant={targetWords === preset ? "default" : "outline"}
+              size="sm"
+              onClick={() => setTargetWords(preset)}
+            >
+              {t(WORD_LABEL_KEYS[preset])}
+            </Button>
+          ))}
+          <span className="text-sm text-muted-foreground">{t("storySimulation.wordsCustom")}</span>
+          <Input
+            type="number"
+            className="w-28"
+            value={targetWords}
+            min={1}
+            onChange={(e) => {
+              const val = parseInt(e.target.value, 10)
+              if (!isNaN(val) && val > 0) {
+                setTargetWords(val)
+              }
+            }}
+          />
+        </div>
+      </section>
+
+      {/* 4. 提取章节数量 */}
+      <section className="flex flex-col gap-3">
+        <h3 className="text-sm font-medium">{t("storySimulation.sourceChapters")}</h3>
+        <select
+          value={sourceChapters}
+          onChange={(e) => setSourceChapters(Number(e.target.value))}
+          className="h-8 w-40 rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
+        >
+          {CHAPTER_OPTIONS.map((n) => (
+            <option key={n} value={n}>
+              {t("storySimulation.recentChapters")} {n} {t("storySimulation.chapters")}
+            </option>
+          ))}
+        </select>
+      </section>
+
+      {/* 5. 仿真轮次 */}
+      <section className="flex flex-col gap-3">
+        <h3 className="text-sm font-medium">仿真深度(每节点轮次)</h3>
+        <div className="flex flex-wrap items-center gap-2">
+          {ROUND_OPTIONS.map((opt) => (
+            <Button
+              key={opt.value}
+              variant={simulationRounds === opt.value ? "default" : "outline"}
+              size="sm"
+              onClick={() => setSimulationRounds(opt.value)}
+            >
+              {opt.label}
+            </Button>
+          ))}
+        </div>
+        <p className="text-xs text-muted-foreground">
+          轮次越多,角色互动越丰富,但消耗token越多。自动模式:1万字约1轮,至少2轮。
+        </p>
+      </section>
+
+      {/* 6. 开始按钮 */}
+      <div className="flex justify-end pt-2">
+        <Button onClick={onStart} size="lg">
+          {t("storySimulation.startExtract")}
+        </Button>
+      </div>
+    </div>
+  )
+}

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

@@ -0,0 +1,939 @@
+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 { Button } from "@/components/ui/button"
+import { useStorySimulationStore, type SavedSimulationResult } from "@/stores/story-simulation-store"
+import { useWikiStore } from "@/stores/wiki-store"
+import { exportReport } from "@/lib/novel/story-simulation/report-export"
+import { cn } from "@/lib/utils"
+import type { StoryBranch, TimelineEvent, StoryFramework, SimulationReport } from "@/lib/novel/story-simulation/types"
+
+const PROBABILITY_COLORS: Record<string, string> = {
+  high: "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300",
+  medium: "bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300",
+  low: "bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300",
+}
+
+interface SimulationReportViewProps {
+  onResimulate: () => void
+  onGenerateDraft: (branch: StoryBranch) => void
+  onInterviewAgent: (agentId: string, agentName: string) => void
+  onViewDraft?: () => void
+  hasDraft?: boolean
+  onViewInterviewHistory?: () => void
+}
+
+/** 将 actionType 映射为中文动词短语 */
+function actionLabel(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 "行动"
+  }
+}
+
+function formatDate(dateStr: string): string {
+  try {
+    const d = new Date(dateStr)
+    return d.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" })
+  } catch {
+    return dateStr
+  }
+}
+
+// ── 单个报告内容面板(可复用,用于对比模式) ──
+
+interface ReportContentProps {
+  report: SimulationReport
+  timelineEvents: TimelineEvent[]
+  framework?: StoryFramework | null
+  onInterviewAgent?: (agentId: string, agentName: string) => void
+  onGenerateDraft?: (branch: StoryBranch) => void
+  title?: string
+  compact?: boolean
+  /** 对比模式下的另一个报告,用于高亮差异 */
+  compareReport?: SimulationReport | null
+  /** 对比模式下的另一组时间线事件,用于差异统计 */
+  compareTimelineEvents?: TimelineEvent[]
+}
+
+function ReportContent({ report, timelineEvents, framework, onInterviewAgent, onGenerateDraft, title, compact, compareReport, compareTimelineEvents }: ReportContentProps) {
+  // 构建名字到ID的映射
+  const nameToId = useMemo(() => {
+    const map = new Map<string, string>()
+    for (const ca of report.characterAnalyses) {
+      map.set(ca.name, ca.characterId)
+    }
+    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
+    const aNames = new Set(report.characterAnalyses.map((c) => c.name))
+    const bNames = new Set(compareReport.characterAnalyses.map((c) => c.name))
+    const onlyInA = new Set([...aNames].filter((n) => !bNames.has(n)))
+    const onlyInB = new Set([...bNames].filter((n) => !aNames.has(n)))
+    const scoreDiff = new Map<string, { a: number; b: number }>()
+    for (const ca of report.characterAnalyses) {
+      const cb = compareReport.characterAnalyses.find((c) => c.name === ca.name)
+      if (cb && ca.consistencyScore !== cb.consistencyScore) {
+        scoreDiff.set(ca.name, { a: ca.consistencyScore, b: cb.consistencyScore })
+      }
+    }
+    return { onlyInA, onlyInB, scoreDiff }
+  }, [report.characterAnalyses, compareReport])
+
+  const getCharHighlightClass = (name: string): string => {
+    if (!characterDiff) return ""
+    if (characterDiff.onlyInA.has(name)) return "bg-green-100 dark:bg-green-950/40"
+    if (characterDiff.onlyInB.has(name)) return "bg-red-100 dark:bg-red-950/40"
+    if (characterDiff.scoreDiff.has(name)) return "bg-amber-100 dark:bg-amber-950/40"
+    return ""
+  }
+
+  // 对比模式:计算走向分支差异
+  const branchDiff = useMemo(() => {
+    if (!compareReport) return null
+    const aTitles = new Set(report.branches.map((b) => b.title))
+    const bTitles = new Set(compareReport.branches.map((b) => b.title))
+    const onlyInA = new Set([...aTitles].filter((t) => !bTitles.has(t)))
+    const onlyInB = new Set([...bTitles].filter((t) => !aTitles.has(t)))
+    const probDiff = new Map<string, { a: string; b: string }>()
+    for (const ba of report.branches) {
+      const bb = compareReport.branches.find((b) => b.title === ba.title)
+      if (bb && ba.probability !== bb.probability) {
+        probDiff.set(ba.title, { a: ba.probability, b: bb.probability })
+      }
+    }
+    return { onlyInA, onlyInB, probDiff }
+  }, [report.branches, compareReport])
+
+  const getBranchHighlightClass = (title: string): string => {
+    if (!branchDiff) return ""
+    if (branchDiff.onlyInA.has(title)) return "bg-green-100 dark:bg-green-950/40"
+    if (branchDiff.onlyInB.has(title)) return "bg-red-100 dark:bg-red-950/40"
+    if (branchDiff.probDiff.has(title)) return "bg-amber-100 dark:bg-amber-950/40"
+    return ""
+  }
+
+  // 对比模式:计算综合推荐差异(按句号分段)
+  const recommendationDiff = useMemo(() => {
+    if (!compareReport || !report.recommendation) return null
+    if (!compareReport.recommendation) return { segments: [{ text: report.recommendation, isDifferent: true }] }
+
+    const aSegments = report.recommendation.split(/[。!?]/).filter((s) => s.trim())
+    const bSegments = new Set(compareReport.recommendation.split(/[。!?]/).filter((s) => s.trim()))
+
+    return {
+      segments: aSegments.map((seg) => ({
+        text: seg,
+        isDifferent: !bSegments.has(seg),
+      })),
+    }
+  }, [report.recommendation, compareReport])
+
+  // 对比模式:计算时间线事件差异
+  const timelineDiff = useMemo(() => {
+    if (!compareReport || !compareTimelineEvents) return null
+    const aCount = timelineEvents.length
+    const bCount = compareTimelineEvents.length
+
+    const aActivity = new Map<string, number>()
+    for (const ev of timelineEvents) {
+      aActivity.set(ev.actorName, (aActivity.get(ev.actorName) || 0) + 1)
+    }
+    const aRanking = Array.from(aActivity.entries()).sort((a, b) => b[1] - a[1]).slice(0, 5)
+
+    return { aCount, bCount, aRanking }
+  }, [timelineEvents, compareReport, compareTimelineEvents])
+
+  return (
+    <div className="flex h-full flex-col">
+      {title && (
+        <div className="border-b bg-muted/30 px-4 py-2 text-sm font-medium text-center">
+          {title}
+        </div>
+      )}
+      <div className="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">
+              <span className="font-medium">事件数量对比:</span>
+              <span className="text-primary">A: {timelineDiff.aCount}</span>
+              <span className="text-muted-foreground">vs</span>
+              <span className="text-red-500">B: {timelineDiff.bCount}</span>
+              <span className="ml-auto text-muted-foreground">
+                差异: {Math.abs(timelineDiff.aCount - timelineDiff.bCount)} 条
+              </span>
+            </div>
+          )}
+          {timelineEvents.length > 0 && (
+            <TimelineGroupedEvents
+              events={timelineEvents}
+              framework={framework}
+              nameToId={nameToId}
+              onInterviewAgent={onInterviewAgent}
+              compact={compact}
+            />
+          )}
+
+          {/* 角色采访区 */}
+          {!compact && report.characterAnalyses.length > 0 && onInterviewAgent && (
+            <section>
+              <h3 className="mb-3 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
+                <MessageCircle className="h-3.5 w-3.5" />
+                采访角色
+              </h3>
+              <div className="flex flex-wrap gap-2">
+                {report.characterAnalyses.map((char) => (
+                  <Button
+                    key={char.characterId}
+                    variant="outline"
+                    size="sm"
+                    onClick={() => onInterviewAgent(char.characterId, char.name)}
+                  >
+                    <MessageCircle className="mr-1 h-3.5 w-3.5" />
+                    与 {char.name} 对话
+                  </Button>
+                ))}
+              </div>
+            </section>
+          )}
+
+          {/* 角色行为分析 */}
+          {report.characterAnalyses.length > 0 && (
+            <section>
+              <h3 className="mb-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
+                角色行为分析
+              </h3>
+              <div className="space-y-3">
+                {report.characterAnalyses.map((char) => (
+                  <div key={char.characterId} className={cn("rounded-lg border p-3", getCharHighlightClass(char.name))}>
+                    <div className="flex items-center justify-between">
+                      <span className="font-medium">{char.name}</span>
+                      <span className="rounded px-1.5 py-0.5 text-xs bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300">
+                        一致性: {char.consistencyScore}
+                        {characterDiff?.scoreDiff.has(char.name) && (
+                          <span className="ml-1 text-amber-600">
+                            (B: {characterDiff.scoreDiff.get(char.name)!.b})
+                          </span>
+                        )}
+                      </span>
+                    </div>
+
+                    {char.behaviors.length > 0 && (
+                      <div className="mt-2">
+                        <p className="mb-1 text-xs font-medium text-muted-foreground">行为:</p>
+                        <ul className="space-y-1">
+                          {char.behaviors.map((b, i) => (
+                            <li key={i} className="text-sm">
+                              <span className="text-muted-foreground">[{b.node}]</span> {b.action}
+                              <span className="text-muted-foreground"> — 动机: {b.motivation}</span>
+                            </li>
+                          ))}
+                        </ul>
+                      </div>
+                    )}
+
+                    {char.stateChanges.length > 0 && (
+                      <div className="mt-2">
+                        <p className="mb-1 text-xs font-medium text-muted-foreground">状态变化:</p>
+                        <ul className="list-disc space-y-0.5 pl-4 text-sm">
+                          {char.stateChanges.map((s, i) => <li key={i}>{s}</li>)}
+                        </ul>
+                      </div>
+                    )}
+                  </div>
+                ))}
+              </div>
+            </section>
+          )}
+
+          {/* 走向分支 */}
+          {report.branches.length > 0 && (
+            <section>
+              <h3 className="mb-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
+                走向分支
+              </h3>
+              <div className="space-y-3">
+                {report.branches.map((branch, idx) => (
+                  <div key={idx} className={cn("rounded-lg border p-3", getBranchHighlightClass(branch.title))}>
+                    <div className="flex flex-wrap items-center gap-2">
+                      <span className="font-medium">{branch.title}</span>
+                      {branch.recommendation && (
+                        <span className="rounded px-1.5 py-0.5 text-xs bg-primary/10 text-primary">推荐</span>
+                      )}
+                      <span className={`rounded px-1.5 py-0.5 text-xs ${PROBABILITY_COLORS[branch.probability]}`}>
+                        概率: {branch.probability === "high" ? "高" : branch.probability === "medium" ? "中" : "低"}
+                      </span>
+                    </div>
+
+                    <p className="mt-2 text-sm text-muted-foreground">{branch.summary}</p>
+
+                    {branch.keyEvents.length > 0 && (
+                      <div className="mt-2">
+                        <p className="mb-1 text-xs font-medium text-muted-foreground">关键事件:</p>
+                        <ul className="list-disc space-y-0.5 pl-4 text-sm">
+                          {branch.keyEvents.map((e, i) => <li key={i}>{e}</li>)}
+                        </ul>
+                      </div>
+                    )}
+
+                    <div className="mt-2 grid gap-2 sm:grid-cols-2">
+                      {branch.pros && (
+                        <div className="rounded-md bg-green-50 p-2 text-sm dark:bg-green-950/30">
+                          <span className="font-medium text-green-700 dark:text-green-400">利:</span>
+                          {branch.pros}
+                        </div>
+                      )}
+                      {branch.cons && (
+                        <div className="rounded-md bg-red-50 p-2 text-sm dark:bg-red-950/30">
+                          <span className="font-medium text-red-700 dark:text-red-400">弊:</span>
+                          {branch.cons}
+                        </div>
+                      )}
+                    </div>
+
+                    {!compact && onGenerateDraft && (
+                      <Button
+                        variant="default"
+                        size="sm"
+                        className="mt-3"
+                        onClick={() => onGenerateDraft(branch)}
+                      >
+                        <Sparkles className="h-3.5 w-3.5" />
+                        生成草稿
+                      </Button>
+                    )}
+                  </div>
+                ))}
+              </div>
+            </section>
+          )}
+
+          {/* 综合推荐 */}
+          {report.recommendation && (
+            <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">
+                  <Sparkles className="h-3.5 w-3.5" />
+                  综合推荐
+                  {recommendationDiff && (
+                    <span className="ml-auto text-xs font-normal text-amber-600">有差异</span>
+                  )}
+                </h3>
+                {recommendationDiff ? (
+                  <div className="space-y-1 text-sm leading-relaxed">
+                    {recommendationDiff.segments.map((seg, i) => (
+                      <span
+                        key={i}
+                        className={seg.isDifferent ? "rounded bg-amber-100 px-1 dark:bg-amber-950/40" : ""}
+                      >
+                        {seg.text}。
+                      </span>
+                    ))}
+                  </div>
+                ) : (
+                  <p className="text-sm leading-relaxed">{report.recommendation}</p>
+                )}
+              </div>
+            </section>
+          )}
+        </div>
+      </div>
+    </div>
+  )
+}
+
+export function SimulationReportView({
+  onResimulate,
+  onGenerateDraft,
+  onInterviewAgent,
+  onViewDraft,
+  hasDraft,
+  onViewInterviewHistory,
+}: SimulationReportViewProps) {
+  const { t } = useTranslation()
+  const projectPath = useWikiStore((s) => s.project?.path)
+  const report = useStorySimulationStore((s) => s.currentReport)
+  const currentFramework = useStorySimulationStore((s) => s.currentFramework)
+  const timelineEvents = useStorySimulationStore((s) => s.timelineEvents)
+  const savedResults = useStorySimulationStore((s) => s.savedResults)
+  const setError = useStorySimulationStore((s) => s.setError)
+  const [exporting, setExporting] = useState(false)
+  const [compareMode, setCompareMode] = useState(false)
+  const [selectedCompareId, setSelectedCompareId] = useState<string | null>(null)
+  const [viewingResultId, setViewingResultId] = useState<string | null>(null)
+
+  // 当前查看的结果(可能是历史结果)
+  const currentResult: SavedSimulationResult | null = useMemo(() => {
+    if (!viewingResultId) return null
+    return savedResults.find(r => r.id === viewingResultId) || null
+  }, [viewingResultId, savedResults])
+
+  const activeReport = currentResult?.report || report
+  const activeTimeline = currentResult?.timelineEvents || timelineEvents
+  const compareResult = selectedCompareId ? savedResults.find(r => r.id === selectedCompareId) : null
+
+  // 可选择对比的结果(排除当前查看的)
+  const comparableResults = useMemo(() => {
+    return savedResults.filter(r => r.id !== viewingResultId && r.report)
+  }, [savedResults, viewingResultId])
+
+  const handleExport = async () => {
+    if (!projectPath || !activeReport || !currentFramework) return
+    setExporting(true)
+    try {
+      const filePath = await exportReport(projectPath, currentFramework, activeReport, activeTimeline)
+      setError(`报告已导出到:${filePath}`)
+      setTimeout(() => setError(null), 5000)
+    } catch (err) {
+      setError(err instanceof Error ? err.message : "导出失败")
+      setTimeout(() => setError(null), 5000)
+    } finally {
+      setExporting(false)
+    }
+  }
+
+  const handleExitCompare = () => {
+    setCompareMode(false)
+    setSelectedCompareId(null)
+  }
+
+  if (!activeReport) return null
+
+  return (
+    <div className="flex h-full flex-col">
+      {/* 顶部工具栏 */}
+      <div className="flex items-center justify-between border-b px-4 py-3">
+        <div className="flex items-center gap-2">
+          <TrendingUp className="h-4 w-4 text-primary" />
+          <h2 className="text-sm font-semibold">{t("storySimulation.reportTitle")}</h2>
+          {currentResult && (
+            <span className="rounded bg-muted px-2 py-0.5 text-[10px] text-muted-foreground">
+              {formatDate(currentResult.createdAt)}
+            </span>
+          )}
+        </div>
+        <div className="flex items-center gap-2">
+          {/* 历史结果选择 */}
+          {savedResults.length > 0 && (
+            <select
+              value={viewingResultId || ""}
+              onChange={(e) => {
+                setViewingResultId(e.target.value || null)
+                setCompareMode(false)
+                setSelectedCompareId(null)
+              }}
+              className="h-7 rounded-md border border-input bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
+            >
+              <option value="">最新推演</option>
+              {savedResults.map((r) => (
+                <option key={r.id} value={r.id}>
+                  {formatDate(r.createdAt)} ({r.report.mode})
+                </option>
+              ))}
+            </select>
+          )}
+          {/* 对比按钮 */}
+          {comparableResults.length > 0 && !compareMode && (
+            <Button
+              variant="outline"
+              size="sm"
+              onClick={() => {
+                setCompareMode(true)
+                if (comparableResults.length > 0) {
+                  setSelectedCompareId(comparableResults[0].id)
+                }
+              }}
+            >
+              <GitCompare className="mr-1 h-3.5 w-3.5" />
+              对比结果
+            </Button>
+          )}
+          {compareMode && (
+            <>
+              <select
+                value={selectedCompareId || ""}
+                onChange={(e) => setSelectedCompareId(e.target.value || null)}
+                className="h-7 rounded-md border border-input bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
+              >
+                {comparableResults.map((r) => (
+                  <option key={r.id} value={r.id}>
+                    对比: {formatDate(r.createdAt)}
+                  </option>
+                ))}
+              </select>
+              <Button variant="ghost" size="sm" onClick={handleExitCompare}>
+                <X className="h-3.5 w-3.5" />
+              </Button>
+            </>
+          )}
+          {onViewInterviewHistory && (
+            <Button
+              variant="outline"
+              size="sm"
+              onClick={onViewInterviewHistory}
+            >
+              <MessageCircle className="mr-1 h-3.5 w-3.5" />
+              采访历史
+            </Button>
+          )}
+          <Button
+            variant="outline"
+            size="sm"
+            onClick={handleExport}
+            disabled={exporting}
+          >
+            <Download className="mr-1 h-3.5 w-3.5" />
+            {exporting ? "导出中..." : "导出报告"}
+          </Button>
+          {!currentResult && hasDraft && onViewDraft && (
+            <Button variant="default" size="sm" onClick={onViewDraft}>
+              <Sparkles className="mr-1 h-3.5 w-3.5" />
+              查看草稿
+            </Button>
+          )}
+          {!currentResult && (
+            <Button variant="outline" size="sm" onClick={onResimulate}>
+              <RefreshCw className="mr-1 h-3.5 w-3.5" />
+              {t("storySimulation.resimulate")}
+            </Button>
+          )}
+        </div>
+      </div>
+
+      {/* 内容区域:单栏或双栏对比 */}
+      {compareMode && compareResult ? (
+        <div className="flex min-h-0 flex-1">
+          <div className="min-w-0 flex-1 border-r">
+            <ReportContent
+              report={activeReport}
+              timelineEvents={activeTimeline}
+              framework={currentFramework}
+              onInterviewAgent={!currentResult ? onInterviewAgent : undefined}
+              onGenerateDraft={!currentResult ? onGenerateDraft : undefined}
+              title={currentResult ? `结果 A (${formatDate(currentResult.createdAt)})` : "结果 A (最新)"}
+              compact={true}
+              compareReport={compareResult?.report}
+              compareTimelineEvents={compareResult?.timelineEvents || []}
+            />
+          </div>
+          <div className="min-w-0 flex-1">
+            <ReportContent
+              report={compareResult.report}
+              timelineEvents={compareResult.timelineEvents || []}
+              framework={currentFramework}
+              title={`结果 B (${formatDate(compareResult.createdAt)})`}
+              compact={true}
+            />
+          </div>
+        </div>
+      ) : (
+        <ReportContent
+          report={activeReport}
+          timelineEvents={activeTimeline}
+          framework={currentFramework}
+          onInterviewAgent={!currentResult ? onInterviewAgent : undefined}
+          onGenerateDraft={!currentResult ? onGenerateDraft : undefined}
+        />
+      )}
+    </div>
+  )
+}
+
+// ── 角色关系图谱组件(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({
+  events,
+  framework,
+  nameToId,
+  onInterviewAgent,
+  compact,
+}: {
+  events: TimelineEvent[]
+  framework?: StoryFramework | null
+  nameToId: Map<string, string>
+  onInterviewAgent?: (agentId: string, agentName: string) => void
+  compact?: boolean
+}) {
+  // 折叠状态:key = nodeIndex,value = 是否折叠
+  const [collapsedNodes, setCollapsedNodes] = useState<Set<number>>(new Set())
+
+  // 构建节点索引映射
+  const nodeMap = useMemo(() => {
+    const map = new Map<number, { title: string; phase: string }>()
+    if (framework) {
+      for (const node of framework.nodes) {
+        map.set(node.index, { title: node.title, phase: node.phase })
+      }
+    }
+    return map
+  }, [framework])
+
+  // 阶段中文标签
+  const phaseLabel = (phase: string): string => {
+    const map: Record<string, string> = { 起: "起", 承: "承", 转: "转", 合: "合" }
+    return map[phase] || phase
+  }
+
+  // 按节点分组事件
+  const groupedEvents = useMemo(() => {
+    const groups = new Map<number, TimelineEvent[]>()
+    for (const ev of events) {
+      const idx = ev.nodeIndex
+      if (!groups.has(idx)) groups.set(idx, [])
+      groups.get(idx)!.push(ev)
+    }
+    return Array.from(groups.entries())
+      .sort(([a], [b]) => a - b)
+      .map(([nodeIndex, evs]) => ({
+        nodeIndex,
+        nodeInfo: nodeMap.get(nodeIndex),
+        events: evs,
+      }))
+  }, [events, nodeMap])
+
+  const toggleNode = (idx: number) => {
+    setCollapsedNodes((prev) => {
+      const next = new Set(prev)
+      if (next.has(idx)) {
+        next.delete(idx)
+      } else {
+        next.add(idx)
+      }
+      return next
+    })
+  }
+
+  const expandAll = () => setCollapsedNodes(new Set())
+  const collapseAll = () => {
+    const allNodes = new Set(groupedEvents.map((g) => g.nodeIndex))
+    setCollapsedNodes(allNodes)
+  }
+
+  return (
+    <section>
+      <div className="mb-3 flex items-center justify-between">
+        <h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
+          关键剧情事件
+        </h3>
+        <div className="flex items-center gap-1 text-xs">
+          <button
+            type="button"
+            className="text-muted-foreground hover:text-foreground"
+            onClick={expandAll}
+          >
+            全部展开
+          </button>
+          <span className="text-muted-foreground">|</span>
+          <button
+            type="button"
+            className="text-muted-foreground hover:text-foreground"
+            onClick={collapseAll}
+          >
+            全部折叠
+          </button>
+        </div>
+      </div>
+      <div className={compact ? "space-y-2" : "space-y-3"}>
+        {groupedEvents.map(({ nodeIndex, nodeInfo, events: nodeEvents }) => {
+          const isCollapsed = collapsedNodes.has(nodeIndex)
+          const phase = nodeInfo?.phase || "起"
+          const nodeTitle = nodeInfo?.title || `节点 ${nodeIndex + 1}`
+          return (
+            <div key={nodeIndex} className="rounded-md border bg-background/50">
+              {/* 节点标题栏 - 可点击折叠 */}
+              <button
+                type="button"
+                className={`flex w-full items-center gap-2 text-left hover:bg-accent/50 ${compact ? "px-2 py-1.5" : "px-3 py-2"}`}
+                onClick={() => toggleNode(nodeIndex)}
+              >
+                {isCollapsed ? (
+                  <ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
+                ) : (
+                  <ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
+                )}
+                <span className="rounded bg-primary/10 px-1.5 py-0.5 text-[11px] font-medium text-primary">
+                  {phaseLabel(phase)}
+                </span>
+                <span className={`font-medium ${compact ? "text-xs" : "text-sm"}`}>
+                  节点 {nodeIndex + 1}:{nodeTitle}
+                </span>
+                <span className="ml-auto text-[11px] text-muted-foreground">
+                  {nodeEvents.length} 条
+                </span>
+              </button>
+              {/* 节点事件列表 */}
+              {!isCollapsed && (
+                <div className={`border-t ${compact ? "space-y-1 p-2" : "space-y-2 p-3"}`}>
+                  {nodeEvents.map((ev) => (
+                    <div
+                      key={ev.id}
+                      className={`rounded-md border bg-muted/20 ${compact ? "px-2 py-1.5 text-xs" : "px-3 py-2 text-sm"}`}
+                    >
+                      <div className="flex items-center gap-2 flex-wrap">
+                        <span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-[11px] text-muted-foreground">
+                          R{ev.round + 1}
+                        </span>
+                        {onInterviewAgent ? (
+                          <button
+                            type="button"
+                            className="font-medium text-primary hover:underline"
+                            onClick={() => onInterviewAgent(ev.actorId, ev.actorName)}
+                          >
+                            {ev.actorName}
+                          </button>
+                        ) : (
+                          <span className="font-medium">{ev.actorName}</span>
+                        )}
+                        <span className="text-xs text-muted-foreground">
+                          {actionLabel(ev.actionType)}
+                        </span>
+                        {ev.targetName && (
+                          <>
+                            <span className="text-xs text-muted-foreground">→</span>
+                            {onInterviewAgent ? (
+                              <button
+                                type="button"
+                                className="text-xs text-primary hover:underline"
+                                onClick={() => {
+                                  const targetId = ev.targetId || nameToId.get(ev.targetName || "")
+                                  if (targetId && ev.targetName) {
+                                    onInterviewAgent(targetId, ev.targetName)
+                                  }
+                                }}
+                              >
+                                {ev.targetName}
+                              </button>
+                            ) : (
+                              <span className="text-xs">{ev.targetName}</span>
+                            )}
+                          </>
+                        )}
+                      </div>
+                      <p className="mt-1 text-sm leading-relaxed text-foreground/90">
+                        {ev.content}
+                      </p>
+                    </div>
+                  ))}
+                </div>
+              )}
+            </div>
+          )
+        })}
+      </div>
+    </section>
+  )
+}

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

@@ -0,0 +1,509 @@
+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"
+import { Input } from "@/components/ui/input"
+import { Textarea } from "@/components/ui/textarea"
+import type { StoryDraft } from "@/lib/novel/story-simulation/types"
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from "@/components/ui/dialog"
+import { useStorySimulationStore } from "@/stores/story-simulation-store"
+import { useWikiStore } from "@/stores/wiki-store"
+import { exportDraft } from "@/lib/novel/story-simulation/draft-export"
+import { importDraftToChapters } from "@/lib/novel/story-simulation/draft-importer"
+import { getNextChapterNumber } from "@/lib/novel/chapter-utils"
+import { refreshProjectState } from "@/lib/project-refresh"
+
+interface StoryDraftViewProps {
+  onBack: () => void
+}
+
+export function StoryDraftView({ onBack }: StoryDraftViewProps) {
+  const { t } = useTranslation()
+  const projectPath = useWikiStore((s) => s.project?.path)
+  const currentFramework = useStorySimulationStore((s) => s.currentFramework)
+  const draft = useStorySimulationStore((s) => s.currentDraft)
+  const setCurrentDraft = useStorySimulationStore((s) => s.setCurrentDraft)
+  const setError = useStorySimulationStore((s) => s.setError)
+  const setActiveView = useWikiStore((s) => s.setActiveView)
+  const setSelectedFile = useWikiStore((s) => s.setSelectedFile)
+  const [copied, setCopied] = useState(false)
+  const [exporting, setExporting] = useState(false)
+  const [importing, setImporting] = useState(false)
+  const [showImportDialog, setShowImportDialog] = useState(false)
+  const [importResult, setImportResult] = useState<{
+    count: number
+    startChapter: number
+    paths: string[]
+    backedUpCount: number
+  } | null>(null)
+  const [startChapter, setStartChapter] = useState(1)
+  const [overwrite, setOverwrite] = useState(false)
+  const [autoStartChapter, setAutoStartChapter] = useState(true)
+  const [selectedIndices, setSelectedIndices] = useState<number[]>([])
+  const [importProgress, setImportProgress] = useState<{ current: number; total: number; title: string } | null>(null)
+  const [editingChapterIdx, setEditingChapterIdx] = useState<number | null>(null)
+  const [editContent, setEditContent] = useState("")
+  const [editTitle, setEditTitle] = useState("")
+
+  // 打开对话框时自动计算下一个章节号,并默认全选
+  useEffect(() => {
+    if (showImportDialog && projectPath && autoStartChapter && !importResult && draft) {
+      let cancelled = false
+      void (async () => {
+        try {
+          const next = await getNextChapterNumber(projectPath)
+          if (!cancelled) setStartChapter(next)
+        } catch {
+          // 计算失败时使用默认值
+        }
+      })()
+      // 默认全选
+      if (selectedIndices.length === 0 && draft) {
+        setSelectedIndices(draft.chapters.map((_, i) => i))
+      }
+      return () => {
+        cancelled = true
+      }
+    }
+  }, [showImportDialog, projectPath, autoStartChapter, importResult, draft, selectedIndices.length])
+
+  if (!draft) return null
+
+  const handleCopyAll = async () => {
+    const text = draft.chapters
+      .map((ch) => `${ch.title}\n\n${ch.content}`)
+      .join("\n\n---\n\n")
+    await navigator.clipboard.writeText(text)
+    setCopied(true)
+    setTimeout(() => setCopied(false), 2000)
+  }
+
+  const handleExport = async () => {
+    if (!projectPath || !currentFramework || !draft) return
+    setExporting(true)
+    try {
+      const filePath = await exportDraft(projectPath, currentFramework, draft)
+      setError(`草稿已导出到:${filePath}`)
+      setTimeout(() => setError(null), 5000)
+    } catch (err) {
+      setError(err instanceof Error ? err.message : "导出失败")
+      setTimeout(() => setError(null), 5000)
+    } finally {
+      setExporting(false)
+    }
+  }
+
+  const handleImport = async () => {
+    if (!projectPath || !currentFramework || !draft) return
+    if (selectedIndices.length === 0) {
+      setError("请至少选择一章导入")
+      setTimeout(() => setError(null), 3000)
+      return
+    }
+    setImporting(true)
+    try {
+      const result = await importDraftToChapters(
+        projectPath,
+        currentFramework,
+        draft,
+        {
+          startChapter,
+          overwrite,
+          selectedIndices,
+          onProgress: (current, total, title) => {
+            setImportProgress({ current, total, title })
+          },
+        },
+      )
+      setImportResult({
+        count: result.importedCount,
+        startChapter: result.startChapter,
+        paths: result.chapterPaths,
+        backedUpCount: result.backedUpPaths.length,
+      })
+      // 刷新项目状态
+      await refreshProjectState(projectPath)
+      // 选中第一个导入的章节
+      if (result.chapterPaths.length > 0) {
+        setSelectedFile(result.chapterPaths[0])
+      }
+    } catch (err) {
+      setError(err instanceof Error ? err.message : "导入失败")
+      setTimeout(() => setError(null), 5000)
+    } finally {
+      setImporting(false)
+      setImportProgress(null)
+    }
+  }
+
+  const handleGoToChapters = () => {
+    setShowImportDialog(false)
+    setImportResult(null)
+    setAutoStartChapter(true)
+    setOverwrite(false)
+    setSelectedIndices([])
+    setActiveView("wiki")
+  }
+
+  const handleCloseDialog = () => {
+    setShowImportDialog(false)
+    setImportResult(null)
+    setAutoStartChapter(true)
+    setOverwrite(false)
+    setSelectedIndices([])
+  }
+
+  const toggleChapter = (idx: number) => {
+    setSelectedIndices((prev) =>
+      prev.includes(idx) ? prev.filter((i) => i !== idx) : [...prev, idx],
+    )
+  }
+
+  const toggleAll = () => {
+    if (draft && selectedIndices.length === draft.chapters.length) {
+      setSelectedIndices([])
+    } else if (draft) {
+      setSelectedIndices(draft.chapters.map((_, i) => i))
+    }
+  }
+
+  const openEditDialog = (idx: number) => {
+    if (!draft) return
+    const chapter = draft.chapters[idx]
+    setEditTitle(chapter.title)
+    setEditContent(chapter.content)
+    setEditingChapterIdx(idx)
+  }
+
+  const saveEdit = () => {
+    if (editingChapterIdx === null || !draft) return
+    const updatedDraft: StoryDraft = {
+      ...draft,
+      chapters: draft.chapters.map((ch, i) =>
+        i === editingChapterIdx
+          ? { ...ch, title: editTitle.trim() || ch.title, content: editContent }
+          : ch,
+      ),
+    }
+    setCurrentDraft(updatedDraft)
+    setEditingChapterIdx(null)
+  }
+
+  const cancelEdit = () => {
+    setEditingChapterIdx(null)
+  }
+
+  const allSelected = draft && selectedIndices.length === draft.chapters.length
+
+  return (
+    <div className="flex h-full flex-col">
+      <div className="flex items-center justify-between border-b px-4 py-3">
+        <div className="flex items-center gap-2">
+          <Button variant="ghost" size="icon-sm" onClick={onBack}>
+            <ArrowLeft className="h-4 w-4" />
+          </Button>
+          <h2 className="text-sm font-semibold">{t("storySimulation.draftTitle")}</h2>
+        </div>
+        <div className="flex items-center gap-2">
+          <Button
+            variant="default"
+            size="sm"
+            onClick={() => setShowImportDialog(true)}
+            disabled={importing}
+          >
+            <BookOpen className="mr-1 h-3.5 w-3.5" />
+            导入到章节库
+          </Button>
+          <Button
+            variant="outline"
+            size="sm"
+            onClick={handleExport}
+            disabled={exporting}
+          >
+            <Download className="mr-1 h-3.5 w-3.5" />
+            {exporting ? "导出中..." : "导出MD"}
+          </Button>
+          <Button variant="outline" size="sm" onClick={handleCopyAll}>
+            {copied ? (
+              <Check className="h-3.5 w-3.5" />
+            ) : (
+              <Copy className="h-3.5 w-3.5" />
+            )}
+            {copied ? t("storySimulation.copied") : t("storySimulation.copyAll")}
+          </Button>
+        </div>
+      </div>
+
+      <div className="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}
+          </div>
+
+          {draft.chapters.map((chapter, idx) => (
+            <div key={idx} className="rounded-lg border p-4">
+              <h3 className="mb-2 flex items-center gap-2 font-medium">
+                <FileText className="h-4 w-4 text-muted-foreground" />
+                {chapter.title}
+                <Button
+                  variant="ghost"
+                  size="sm"
+                  className="ml-auto h-7 w-7 p-0 opacity-50 hover:opacity-100"
+                  onClick={() => openEditDialog(idx)}
+                  title="编辑章节"
+                >
+                  <Pencil className="h-3.5 w-3.5" />
+                </Button>
+              </h3>
+              <p className="whitespace-pre-wrap text-sm leading-relaxed">
+                {chapter.content}
+              </p>
+              {chapter.rawContent && chapter.rawContent !== chapter.content && (
+                <div className="mt-2 rounded bg-amber-50 px-2 py-1 text-xs text-amber-600 dark:bg-amber-950/30 dark:text-amber-400">
+                  ✓ 已编辑(原始内容已备份)
+                </div>
+              )}
+            </div>
+          ))}
+        </div>
+      </div>
+
+      {/* 导入确认对话框 */}
+      <Dialog open={showImportDialog} onOpenChange={(open) => {
+        if (!open) handleCloseDialog()
+        else setShowImportDialog(true)
+      }}>
+        <DialogContent className="max-h-[85vh] overflow-y-auto">
+          <DialogHeader>
+            <DialogTitle>
+              {importResult ? "导入成功" : "导入草稿到章节库"}
+            </DialogTitle>
+            {!importResult && (
+              <DialogDescription>
+                选择要导入的章节,配置起始章节号和覆盖选项。
+              </DialogDescription>
+            )}
+          </DialogHeader>
+
+          {importResult ? (
+            <div className="space-y-3 py-2">
+              <div className="rounded-lg bg-emerald-50 p-3 text-sm text-emerald-700 dark:bg-emerald-950 dark:text-emerald-400">
+                <div className="font-medium">✓ 成功导入 {importResult.count} 章</div>
+                <div className="mt-1 text-xs opacity-80">
+                  章节范围:第{importResult.startChapter}章 ~ 第{importResult.startChapter + importResult.count - 1}章
+                </div>
+                {importResult.backedUpCount > 0 && (
+                  <div className="mt-1 text-xs opacity-80">
+                    已自动备份 {importResult.backedUpCount} 个原章节文件
+                  </div>
+                )}
+              </div>
+            </div>
+          ) : (
+            <div className="space-y-4 py-2">
+              {/* 章节选择 */}
+              <div className="space-y-2">
+                <div className="flex items-center justify-between">
+                  <span className="text-sm font-medium">选择导入章节</span>
+                  <button
+                    type="button"
+                    onClick={toggleAll}
+                    className="text-xs text-primary hover:underline"
+                  >
+                    {allSelected ? "取消全选" : "全选"}
+                  </button>
+                </div>
+                <div className="max-h-48 space-y-1 overflow-y-auto rounded border p-2">
+                  {draft.chapters.map((chapter, idx) => (
+                    <label
+                      key={idx}
+                      className="flex cursor-pointer items-center gap-2 rounded px-2 py-1 hover:bg-accent"
+                    >
+                      <input
+                        type="checkbox"
+                        checked={selectedIndices.includes(idx)}
+                        onChange={() => toggleChapter(idx)}
+                        className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary"
+                      />
+                      <span className="flex-1 truncate text-sm">
+                        {idx + 1}. {chapter.title}
+                      </span>
+                      <span className="text-xs text-muted-foreground">
+                        ~{chapter.content.length}字
+                      </span>
+                    </label>
+                  ))}
+                </div>
+                <div className="text-xs text-muted-foreground">
+                  已选 {selectedIndices.length} / {draft.chapters.length} 章
+                </div>
+              </div>
+
+              {/* 起始章节号 */}
+              <div className="space-y-2">
+                <label className="flex items-center gap-2 cursor-pointer">
+                  <input
+                    type="checkbox"
+                    checked={autoStartChapter}
+                    onChange={(e) => {
+                      setAutoStartChapter(e.target.checked)
+                      if (e.target.checked && projectPath) {
+                        void getNextChapterNumber(projectPath).then(setStartChapter)
+                      }
+                    }}
+                    className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary"
+                  />
+                  <span className="text-sm font-medium">
+                    自动选择起始章节号
+                  </span>
+                </label>
+                {!autoStartChapter && (
+                  <div className="flex items-center gap-2 pl-6">
+                    <span className="text-sm text-muted-foreground">起始章节:</span>
+                    <Input
+                      type="number"
+                      min={1}
+                      value={startChapter}
+                      onChange={(e) => setStartChapter(Math.max(1, parseInt(e.target.value, 10) || 1))}
+                      className="w-24"
+                    />
+                    <span className="text-xs text-muted-foreground">
+                      将从第{startChapter}章开始
+                    </span>
+                  </div>
+                )}
+                {autoStartChapter && (
+                  <div className="pl-6 text-xs text-muted-foreground">
+                    从下一个可用章节号(第{startChapter}章)开始
+                  </div>
+                )}
+              </div>
+
+              {/* 覆盖选项 */}
+              <div className="space-y-2">
+                <label className="flex items-center gap-2 cursor-pointer">
+                  <input
+                    type="checkbox"
+                    checked={overwrite}
+                    onChange={(e) => setOverwrite(e.target.checked)}
+                    className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary"
+                  />
+                  <span className="text-sm font-medium">
+                    覆盖已存在的章节文件
+                  </span>
+                </label>
+                {overwrite && (
+                  <p className="pl-6 text-xs text-emerald-600 dark:text-emerald-400">
+                    ✓ 覆盖前会自动备份原章节文件到 .qmai/chapter-backups/
+                  </p>
+                )}
+                {!overwrite && (
+                  <p className="pl-6 text-xs text-amber-600 dark:text-amber-400">
+                    ⚠️ 如果目标章节号已存在,导入将失败并中止。
+                  </p>
+                )}
+              </div>
+            </div>
+          )}
+
+          <DialogFooter>
+            {importResult ? (
+              <>
+                <Button variant="outline" onClick={handleCloseDialog}>
+                  留在当前页
+                </Button>
+                <Button onClick={handleGoToChapters}>
+                  前往章节查看
+                </Button>
+              </>
+            ) : importing && importProgress ? (
+              <div className="w-full space-y-2">
+                <div className="flex items-center justify-between text-xs text-muted-foreground">
+                  <span className="truncate">正在导入:{importProgress.title}</span>
+                  <span className="shrink-0">{importProgress.current}/{importProgress.total}</span>
+                </div>
+                <div className="h-2 w-full overflow-hidden rounded-full bg-muted">
+                  <div
+                    className="h-full rounded-full bg-primary transition-all duration-300"
+                    style={{ width: `${(importProgress.current / importProgress.total) * 100}%` }}
+                  />
+                </div>
+              </div>
+            ) : (
+              <>
+                <Button
+                  variant="outline"
+                  onClick={handleCloseDialog}
+                  disabled={importing}
+                >
+                  取消
+                </Button>
+                <Button onClick={handleImport} disabled={importing || selectedIndices.length === 0}>
+                  {importing && importProgress
+                    ? `导入中 ${importProgress.current}/${importProgress.total}...`
+                    : importing
+                      ? "导入中..."
+                      : `确认导入(${selectedIndices.length}章)`}
+                </Button>
+              </>
+            )}
+          </DialogFooter>
+        </DialogContent>
+      </Dialog>
+
+      {/* 编辑章节对话框 */}
+      <Dialog open={editingChapterIdx !== null} onOpenChange={(open) => {
+        if (!open) cancelEdit()
+      }}>
+        <DialogContent className="max-h-[90vh] max-w-3xl">
+          <DialogHeader>
+            <DialogTitle>编辑章节</DialogTitle>
+            <DialogDescription>
+              编辑后的内容将用于导入到章节库。
+            </DialogDescription>
+          </DialogHeader>
+          <div className="space-y-3">
+            <div>
+              <label className="mb-1 block text-xs font-medium text-muted-foreground">章节标题</label>
+              <Input
+                value={editTitle}
+                onChange={(e) => setEditTitle(e.target.value)}
+                className="text-sm"
+              />
+            </div>
+            <div>
+              <div className="mb-1 flex items-center justify-between">
+                <label className="text-xs font-medium text-muted-foreground">章节内容</label>
+                <span className="text-xs text-muted-foreground">
+                  {editContent.length} 字
+                </span>
+              </div>
+              <Textarea
+                value={editContent}
+                onChange={(e) => setEditContent(e.target.value)}
+                className="min-h-[50vh] text-sm leading-relaxed"
+                autoFocus
+              />
+            </div>
+          </div>
+          <DialogFooter>
+            <Button variant="outline" onClick={cancelEdit}>
+              放弃
+            </Button>
+            <Button onClick={saveEdit}>
+              <Save className="mr-1 h-3.5 w-3.5" />
+              保存修改
+            </Button>
+          </DialogFooter>
+        </DialogContent>
+      </Dialog>
+    </div>
+  )
+}

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

@@ -0,0 +1,1287 @@
+import { useEffect, useRef, useState, useMemo } from "react"
+import { useTranslation } from "react-i18next"
+import { Send, X, Download, ChevronDown, ChevronRight, Save, Loader2 } from "lucide-react"
+
+import { useWikiStore } from "@/stores/wiki-store"
+import { useStorySimulationStore } from "@/stores/story-simulation-store"
+import { extractStoryContent } from "@/lib/novel/story-simulation/story-extractor"
+import { generateStoryFramework } from "@/lib/novel/story-simulation/story-framework-generator"
+import { buildAgents } from "@/lib/novel/story-simulation/agent-profile-builder"
+import {
+  runSimulation,
+  type SimulationCallbacks,
+} from "@/lib/novel/story-simulation/simulation-engine"
+import { generateSimulationReport } from "@/lib/novel/story-simulation/simulation-report-agent"
+import { generateStoryDraft } from "@/lib/novel/story-simulation/story-draft-generator"
+import { saveFramework, saveSimulationResult, loadSimulationResults } from "@/lib/novel/story-simulation/framework-store"
+import { resolveDefaultModel } from "@/lib/novel/model-resolver"
+import { interviewAgent } from "@/lib/novel/story-simulation/agent-interview"
+import { saveInterview, loadInterviews } from "@/lib/novel/story-simulation/interview-store"
+import { exportInterview } from "@/lib/novel/story-simulation/interview-export"
+import { serializeSimulationState, deserializeSimulationSnapshot } from "@/lib/novel/story-simulation/simulation-serializer"
+import type {
+  AgentChatMessage,
+  ExtractionResult,
+  NovelAgent,
+  SimulationState,
+  StoryBranch,
+  StoryFramework,
+  TimelineEvent,
+} from "@/lib/novel/story-simulation/types"
+
+import { SimulationConfigPanel } from "./simulation-config-panel"
+import { FrameworkConfirmPanel } from "./framework-confirm-panel"
+import { SimulationReportView } from "./simulation-report-view"
+import { StoryDraftView } from "./story-draft-view"
+import { InterviewHistoryView } from "./interview-history-view"
+import { Button } from "@/components/ui/button"
+
+const PROGRESS_PHASES = [
+  "extracting",
+  "framework-generating",
+  "simulating",
+  "report-generating",
+  "draft-generating",
+] as const
+
+/** 将 actionType 映射为中文动词短语(不含角色名,用于实时事件流展示) */
+function actionPhrase(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 "行动"
+  }
+}
+
+/** 返回只有动作的短语(不含目标名),用于可点击目标名场景。 */
+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 "对"
+  }
+}
+
+/**
+ * 故事推演室主视图(重构后)。
+ *
+ * 单栏全宽布局:框架列表已迁移到左侧 SidebarPanel。
+ * 主区域根据 phase 切换:配置 → 框架确认 → 推演(实时事件流)→ 报告 → 草稿。
+ * 报告阶段可弹出 Agent 采访面板与角色对话。
+ */
+export function StorySimulationView() {
+  const { t } = useTranslation()
+  const projectPath = useWikiStore((s) => s.project?.path)
+  const baseLlmConfig = useWikiStore((s) => s.llmConfig)
+
+  const phase = useStorySimulationStore((s) => s.phase)
+  const mode = useStorySimulationStore((s) => s.mode)
+  const userIdea = useStorySimulationStore((s) => s.userIdea)
+  const targetWords = useStorySimulationStore((s) => s.targetWords)
+  const sourceChapters = useStorySimulationStore((s) => s.sourceChapters)
+  const simulationRounds = useStorySimulationStore((s) => s.simulationRounds)
+  const extractionResult = useStorySimulationStore((s) => s.extractionResult)
+  const currentFramework = useStorySimulationStore((s) => s.currentFramework)
+  const currentReport = useStorySimulationStore((s) => s.currentReport)
+  const currentDraft = useStorySimulationStore((s) => s.currentDraft)
+  const error = useStorySimulationStore((s) => s.error)
+  const progress = useStorySimulationStore((s) => s.progress)
+  const progressLabel = useStorySimulationStore((s) => s.progressLabel)
+  const timelineEvents = useStorySimulationStore((s) => s.timelineEvents)
+  const activeChatAgent = useStorySimulationStore((s) => s.activeChatAgent)
+  const savedResults = useStorySimulationStore((s) => s.savedResults)
+  const selectedResultId = useStorySimulationStore((s) => s.selectedResultId)
+
+  const setPhase = useStorySimulationStore((s) => s.setPhase)
+  const setExtractionResult = useStorySimulationStore(
+    (s) => s.setExtractionResult,
+  )
+  const setCurrentFramework = useStorySimulationStore(
+    (s) => s.setCurrentFramework,
+  )
+  const setCurrentReport = useStorySimulationStore((s) => s.setCurrentReport)
+  const setCurrentDraft = useStorySimulationStore((s) => s.setCurrentDraft)
+  const setError = useStorySimulationStore((s) => s.setError)
+  const setProgress = useStorySimulationStore((s) => s.setProgress)
+  const setTimelineEvents = useStorySimulationStore((s) => s.setTimelineEvents)
+  const addTimelineEvent = useStorySimulationStore((s) => s.addTimelineEvent)
+  const setActiveChatAgent = useStorySimulationStore((s) => s.setActiveChatAgent)
+  const addAgentChatMessage = useStorySimulationStore((s) => s.addAgentChatMessage)
+  const agentChatMessages = useStorySimulationStore((s) => s.agentChatMessages)
+  const clearAgentChat = useStorySimulationStore((s) => s.clearAgentChat)
+  const bumpListRefresh = useStorySimulationStore((s) => s.bumpListRefresh)
+  const setSavedResults = useStorySimulationStore((s) => s.setSavedResults)
+  const setShowInterviewHistory = useStorySimulationStore((s) => s.setShowInterviewHistory)
+  const continuingInterviewId = useStorySimulationStore((s) => s.continuingInterviewId)
+  const setContinuingInterviewId = useStorySimulationStore((s) => s.setContinuingInterviewId)
+
+  // 保存仿真后的 agents 和 state 供采访使用
+  const lastAgentsRef = useRef<NovelAgent[]>([])
+  const lastSimulationStateRef = useRef<SimulationState | null>(null)
+
+  // 取消控制器
+  const abortControllerRef = useRef<AbortController | null>(null)
+  const [isCancelling, setIsCancelling] = useState(false)
+
+  // 采访输入框
+  const [chatInput, setChatInput] = useState("")
+  const [chatSending, setChatSending] = useState(false)
+  const [chatExporting, setChatExporting] = useState(false)
+  const [chatSaving, setChatSaving] = useState(false)
+  const chatStreamRef = useRef("")
+  const chatLogRef = useRef<HTMLDivElement | null>(null)
+
+  // 当前阶段的进度基线
+  const phaseBaseProgressRef = useRef(0)
+
+  // 采访面板打开时自动滚动到底部
+  useEffect(() => {
+    if (activeChatAgent && chatLogRef.current) {
+      chatLogRef.current.scrollTop = chatLogRef.current.scrollHeight
+    }
+  }, [agentChatMessages, activeChatAgent])
+
+  // 选择历史结果时,反序列化恢复agent状态,支持采访
+  useEffect(() => {
+    if (!selectedResultId) return
+    const result = savedResults.find(r => r.id === selectedResultId)
+    if (result?.agentSnapshot) {
+      try {
+        const { agents, state } = deserializeSimulationSnapshot(result.agentSnapshot)
+        lastAgentsRef.current = agents
+        lastSimulationStateRef.current = state
+      } catch (err) {
+        console.error("反序列化历史结果失败:", err)
+      }
+    }
+  }, [selectedResultId, savedResults])
+
+  // 续聊模式:恢复 agents 和 simulationState 到 ref
+  useEffect(() => {
+    if (!continuingInterviewId || !projectPath) return
+
+    // 异步恢复 agents
+    const restoreAgents = async () => {
+      try {
+        // 先尝试从采访记录恢复
+        const interviews = await loadInterviews(projectPath)
+        const interview = interviews.find((i) => i.id === continuingInterviewId)
+
+        if (interview?.agentSnapshot) {
+          const { agents, state } = deserializeSimulationSnapshot(interview.agentSnapshot)
+          lastAgentsRef.current = agents
+          lastSimulationStateRef.current = state
+          return
+        }
+
+        // 若采访记录无快照,尝试从推演结果恢复
+        if (interview?.frameworkId) {
+          const results = await loadSimulationResults(projectPath, interview.frameworkId)
+          for (const r of results) {
+            if (r.agentSnapshot) {
+              const { agents, state } = deserializeSimulationSnapshot(r.agentSnapshot)
+              lastAgentsRef.current = agents
+              lastSimulationStateRef.current = state
+              return
+            }
+          }
+        }
+      } catch (err) {
+        console.error("恢复 agent 状态失败:", err)
+      }
+    }
+
+    restoreAgents()
+  }, [continuingInterviewId, projectPath])
+
+  // ── 核心流程 ──
+
+  /** 取消当前正在进行的操作 */
+  const handleCancel = () => {
+    if (abortControllerRef.current) {
+      setIsCancelling(true)
+      setError("正在取消...")
+      abortControllerRef.current.abort()
+    }
+  }
+
+  /** 提取内容并生成故事框架,进入框架确认阶段。 */
+  const handleStart = async () => {
+    if (!projectPath) {
+      setError("请先打开一个项目")
+      return
+    }
+    setError(null)
+    setCurrentFramework(null)
+    setTimelineEvents([])
+    try {
+      // 1. 提取内容
+      setPhase("extracting")
+      phaseBaseProgressRef.current = 0
+      setProgress(0, t("storySimulation.extracting"))
+      const extraction: ExtractionResult = await extractStoryContent(
+        projectPath,
+        {
+          sourceChapters,
+          onProgress: (p, label) => setProgress(p, label),
+        },
+      )
+      setExtractionResult(extraction)
+
+      // 2. 生成框架
+      setPhase("framework-generating")
+      phaseBaseProgressRef.current = 30
+      setProgress(30, "正在生成故事框架...")
+      const llmConfig = resolveDefaultModel(baseLlmConfig)
+      const framework: StoryFramework = await generateStoryFramework({
+        extraction,
+        mode,
+        targetWords,
+        userIdea: userIdea || undefined,
+        llmConfig,
+        onProgress: (label) =>
+          setProgress(phaseBaseProgressRef.current, label),
+      })
+      setCurrentFramework(framework)
+      setPhase("framework-confirming")
+    } catch (err) {
+      setError(err instanceof Error ? err.message : String(err))
+      setPhase("configuring")
+    }
+  }
+
+  /** 保存当前框架到磁盘,并刷新侧边栏列表。 */
+  const handleSaveFramework = async () => {
+    if (!projectPath || !currentFramework) return
+    try {
+      await saveFramework(projectPath, currentFramework)
+      bumpListRefresh()
+    } catch (err) {
+      setError(err instanceof Error ? err.message : String(err))
+    }
+  }
+
+  /** 确认框架:必要时先保存 → 构建角色 → 仿真 → 生成报告。 */
+  const handleConfirmFramework = async () => {
+    if (!projectPath || !currentFramework) {
+      setError("缺少项目路径或故事框架")
+      return
+    }
+    setError(null)
+    setTimelineEvents([])
+    setIsCancelling(false)
+    const ac = new AbortController()
+    abortControllerRef.current = ac
+
+    try {
+      // 若尚无提取结果(如从历史框架进入),先提取
+      let extraction = extractionResult
+      if (!extraction) {
+        setPhase("extracting")
+        phaseBaseProgressRef.current = 0
+        setProgress(0, t("storySimulation.extracting"))
+        extraction = await extractStoryContent(projectPath, {
+          sourceChapters,
+          onProgress: (p, label) => setProgress(p, label),
+        })
+        setExtractionResult(extraction)
+      }
+
+      // 开始推演前,若框架尚未保存到磁盘则先保存
+      // 简单判断:检查 frameworks 列表里是否有当前 id
+      const existing = useStorySimulationStore
+        .getState()
+        .frameworks.find((f) => f.id === currentFramework.id)
+      if (!existing) {
+        await saveFramework(projectPath, currentFramework)
+        bumpListRefresh()
+      }
+
+      // 构建角色并运行仿真
+      setPhase("simulating")
+      phaseBaseProgressRef.current = 50
+      setProgress(50, t("storySimulation.simulating"))
+      const agents = buildAgents(extraction, currentFramework)
+      lastAgentsRef.current = agents
+      const llmConfig = resolveDefaultModel(baseLlmConfig)
+
+      const collectedTimeline: TimelineEvent[] = []
+
+      const callbacks: SimulationCallbacks = {
+        onEvent: () => {},
+        onProgress: (p, label) =>
+          setProgress(50 + Math.floor(p / 2), label),
+        onComplete: () => {},
+        onError: () => {},
+        onTimelineEvent: (event) => {
+          collectedTimeline.push(event)
+          addTimelineEvent(event)
+        },
+      }
+      const events = await runSimulation(
+        {
+          agents,
+          framework: currentFramework,
+          mode,
+          wordBudget: targetWords,
+          llmConfig,
+          userIdea: userIdea || undefined,
+          maxRoundsPerNode: simulationRounds > 0 ? simulationRounds : undefined,
+        },
+        extraction,
+        callbacks,
+        ac.signal,
+      )
+
+      if (ac.signal.aborted) {
+        setPhase("framework-confirming")
+        setError("推演已取消")
+        setTimeout(() => setError(null), 3000)
+        return
+      }
+
+      // 保存仿真状态供采访使用
+      lastSimulationStateRef.current = {
+        currentRound: 0,
+        timelineEvents: collectedTimeline,
+        activeAgents: new Map(agents.map((a) => [a.characterId, a])),
+        worldState: {},
+      }
+
+      // 生成推演报告
+      setPhase("report-generating")
+      phaseBaseProgressRef.current = 80
+      setProgress(80, "正在生成推演报告...")
+      const report = await generateSimulationReport({
+        events,
+        framework: currentFramework,
+        mode,
+        llmConfig,
+        onProgress: (label) =>
+          setProgress(phaseBaseProgressRef.current, label),
+        signal: ac.signal,
+      })
+
+      if (ac.signal.aborted) {
+        setPhase("framework-confirming")
+        setError("已取消")
+        setTimeout(() => setError(null), 3000)
+        return
+      }
+
+      setCurrentReport(report)
+      setPhase("report-viewing")
+
+      // 自动保存推演结果(包含时间线事件和agent快照)
+      try {
+        const agentSnapshot = serializeSimulationState(
+          lastSimulationStateRef.current!,
+          lastAgentsRef.current,
+        )
+        await saveSimulationResult(
+          projectPath,
+          currentFramework.id,
+          report,
+          undefined,
+          collectedTimeline,
+          agentSnapshot,
+        )
+        // 刷新历史结果列表
+        const results = await loadSimulationResults(projectPath, currentFramework.id)
+        setSavedResults(results.map(r => ({
+          id: r.id,
+          frameworkId: currentFramework.id,
+          report: r.report,
+          draft: r.draft,
+          timelineEvents: r.timelineEvents,
+          agentSnapshot: r.agentSnapshot,
+          createdAt: r.report.createdAt,
+        })))
+      } catch (saveErr) {
+        console.error("保存推演结果失败:", saveErr)
+      }
+    } catch (err) {
+      if (ac.signal.aborted) {
+        setPhase("framework-confirming")
+        setError("推演已取消")
+        setTimeout(() => setError(null), 3000)
+      } else {
+        setError(err instanceof Error ? err.message : String(err))
+        setPhase("framework-confirming")
+      }
+    } finally {
+      setIsCancelling(false)
+      abortControllerRef.current = null
+    }
+  }
+
+  /** 重新生成框架(重新提取 + 生成)。 */
+  const handleRegenerateFramework = () => {
+    void handleStart()
+  }
+
+  /** 重新推演:回退到框架确认阶段。 */
+  const handleResimulate = () => {
+    setTimelineEvents([])
+    setCurrentReport(null)
+    setPhase("framework-confirming")
+  }
+
+  /** 选择走向分支并生成故事草稿。 */
+  const handleGenerateDraft = async (branch: StoryBranch) => {
+    if (!projectPath || !currentFramework || !currentReport) {
+      setError("缺少项目路径、故事框架或推演报告")
+      return
+    }
+    setError(null)
+    setIsCancelling(false)
+    const ac = new AbortController()
+    abortControllerRef.current = ac
+
+    try {
+      setPhase("draft-generating")
+      phaseBaseProgressRef.current = 90
+      setProgress(90, "正在生成故事草稿...")
+      const llmConfig = resolveDefaultModel(baseLlmConfig)
+      const draft = await generateStoryDraft({
+        framework: currentFramework,
+        report: currentReport,
+        selectedBranch: branch,
+        llmConfig,
+        onProgress: (label) =>
+          setProgress(phaseBaseProgressRef.current, label),
+        signal: ac.signal,
+      })
+
+      if (ac.signal.aborted) {
+        setPhase("report-viewing")
+        setError("草稿生成已取消")
+        setTimeout(() => setError(null), 3000)
+        return
+      }
+
+      setCurrentDraft(draft)
+      setPhase("draft-viewing")
+
+      // 更新保存的推演结果,添加草稿
+      try {
+        const agentSnapshot = lastSimulationStateRef.current
+          ? serializeSimulationState(lastSimulationStateRef.current, lastAgentsRef.current)
+          : undefined
+        await saveSimulationResult(
+          projectPath,
+          currentFramework.id,
+          currentReport,
+          draft,
+          timelineEvents,
+          agentSnapshot,
+        )
+        // 刷新历史结果列表
+        const results = await loadSimulationResults(projectPath, currentFramework.id)
+        setSavedResults(results.map(r => ({
+          id: r.id,
+          frameworkId: currentFramework.id,
+          report: r.report,
+          draft: r.draft,
+          timelineEvents: r.timelineEvents,
+          agentSnapshot: r.agentSnapshot,
+          createdAt: r.report.createdAt,
+        })))
+      } catch (saveErr) {
+        console.error("更新推演结果草稿失败:", saveErr)
+      }
+    } catch (err) {
+      if (ac.signal.aborted) {
+        setPhase("report-viewing")
+        setError("草稿生成已取消")
+        setTimeout(() => setError(null), 3000)
+      } else {
+        setError(err instanceof Error ? err.message : String(err))
+        setPhase("report-viewing")
+      }
+    } finally {
+      setIsCancelling(false)
+      abortControllerRef.current = null
+    }
+  }
+
+  /** 草稿视图返回报告视图。 */
+  const handleBackToReport = () => {
+    setPhase("report-viewing")
+  }
+
+  /** 从报告视图进入草稿视图。 */
+  const handleViewDraft = () => {
+    if (currentDraft) {
+      setPhase("draft-viewing")
+    }
+  }
+
+  /** 打开 Agent 采访面板。 */
+  const handleInterviewAgent = (agentId: string, agentName: string) => {
+    clearAgentChat()
+    setActiveChatAgent({ id: agentId, name: agentName })
+  }
+
+  /** 关闭采访面板。 */
+  const handleCloseChat = () => {
+    clearAgentChat()
+  }
+
+  /** 导出对话记录为MD。 */
+  const handleExportChat = async () => {
+    if (!activeChatAgent || !projectPath || agentChatMessages.length === 0) return
+    setChatExporting(true)
+    try {
+      const filePath = await exportInterview(
+        projectPath,
+        activeChatAgent.name,
+        agentChatMessages,
+      )
+      setError(`对话已导出到:${filePath}`)
+      setTimeout(() => setError(null), 5000)
+    } catch (err) {
+      setError(err instanceof Error ? err.message : "导出失败")
+    } finally {
+      setChatExporting(false)
+    }
+  }
+
+  /** 保存采访对话到项目。 */
+  const handleSaveChat = async () => {
+    if (!activeChatAgent || !projectPath || agentChatMessages.length === 0) return
+    setChatSaving(true)
+    try {
+      const session = {
+        agentId: activeChatAgent.id,
+        agentName: activeChatAgent.name,
+        messages: agentChatMessages,
+      }
+      const agentSnapshot = lastSimulationStateRef.current && lastAgentsRef.current.length > 0
+        ? serializeSimulationState(lastSimulationStateRef.current, lastAgentsRef.current)
+        : undefined
+      // 续聊模式下询问覆盖原采访或另存为新采访
+      let existingId: string | undefined
+      if (continuingInterviewId) {
+        const choice = confirm("覆盖原采访对话?\n\n确定 = 覆盖原采访\n取消 = 另存为新采访")
+        if (choice) {
+          existingId = continuingInterviewId
+        }
+      }
+      await saveInterview(projectPath, session, {
+        frameworkId: currentFramework?.id,
+        frameworkTitle: currentFramework?.title,
+        agentSnapshot,
+        existingId,
+      })
+      setContinuingInterviewId(null)
+      setError(`采访对话已保存(${agentChatMessages.length}条消息)`)
+      setTimeout(() => setError(null), 3000)
+    } catch (err) {
+      setError(err instanceof Error ? err.message : "保存失败")
+      setTimeout(() => setError(null), 5000)
+    } finally {
+      setChatSaving(false)
+    }
+  }
+
+  /** 发送采访消息。 */
+  const handleSendChat = async () => {
+    const text = chatInput.trim()
+    if (!text || chatSending || !activeChatAgent) return
+    if (!lastSimulationStateRef.current) {
+      setError("没有可用的仿真状态,无法采访")
+      return
+    }
+    const llmConfig = resolveDefaultModel(baseLlmConfig)
+    const agent = lastAgentsRef.current.find(
+      (a) => a.characterId === activeChatAgent.id,
+    )
+    if (!agent) {
+      setError(`找不到角色:${activeChatAgent.name}`)
+      return
+    }
+
+    setChatInput("")
+    setChatSending(true)
+    chatStreamRef.current = ""
+
+    // 先添加一条占位的 agent 消息,流式更新
+    const placeholderId = `msg_${Date.now()}_stream`
+    const placeholder: AgentChatMessage = {
+      id: placeholderId,
+      role: "agent",
+      agentId: agent.characterId,
+      agentName: agent.name,
+      content: "",
+      timestamp: new Date().toISOString(),
+    }
+
+    try {
+      // 把已有消息(除 placeholder)组织成 session
+      const existingMessages = agentChatMessages
+      const session = {
+        agentId: agent.characterId,
+        agentName: agent.name,
+        messages: [...existingMessages],
+      }
+
+      addAgentChatMessage({
+        id: `msg_${Date.now()}_user`,
+        role: "user",
+        content: text,
+        timestamp: new Date().toISOString(),
+      })
+      addAgentChatMessage(placeholder)
+
+      await interviewAgent({
+        llmConfig,
+        agent,
+        simulationState: lastSimulationStateRef.current,
+        userPrompt: text,
+        session,
+        onToken: (token) => {
+          chatStreamRef.current += token
+          // 直接更新最后一条消息:通过替换 store 中的消息
+          // 简化:用 setAgentChatMessages 替换整条消息;这里用 addAgentChatMessage 不好做增量
+          // 我们用一个小技巧:拿到当前消息列表,替换最后一条 content
+          const state = useStorySimulationStore.getState()
+          const msgs = [...state.agentChatMessages]
+          const lastIdx = msgs.length - 1
+          if (lastIdx >= 0 && msgs[lastIdx].id === placeholderId) {
+            msgs[lastIdx] = { ...msgs[lastIdx], content: chatStreamRef.current }
+            useStorySimulationStore.setState({ agentChatMessages: msgs })
+          }
+        },
+        onDone: () => {},
+        onError: (err) => {
+          setError(err.message)
+        },
+      })
+    } catch (err) {
+      setError(err instanceof Error ? err.message : String(err))
+    } finally {
+      setChatSending(false)
+    }
+  }
+
+  // ── 渲染 ──
+
+  const isProgressPhase = (
+    PROGRESS_PHASES as readonly string[]
+  ).includes(phase)
+
+  const progressTitle = (() => {
+    switch (phase) {
+      case "extracting":
+        return t("storySimulation.extracting")
+      case "framework-generating":
+        return "正在生成故事框架..."
+      case "simulating":
+        return t("storySimulation.simulating")
+      case "report-generating":
+        return "正在生成推演报告..."
+      case "draft-generating":
+        return "正在生成故事草稿..."
+      default:
+        return ""
+    }
+  })()
+
+  return (
+    <div className="flex h-full">
+      {/* 主区域:单栏全宽 */}
+      <div className="flex min-w-0 flex-1 flex-col overflow-hidden">
+        {error && (
+          <div className="flex items-center justify-between gap-3 border-b border-red-500/30 bg-red-500/10 px-4 py-2 text-sm text-red-600 dark:text-red-400">
+            <span>
+              {t("storySimulation.error")}: {error}
+            </span>
+            <button
+              type="button"
+              className="shrink-0 text-xs underline"
+              onClick={() => setError(null)}
+            >
+              {t("storySimulation.back")}
+            </button>
+          </div>
+        )}
+
+        {isProgressPhase && phase !== "simulating" ? (
+          <ProgressPanel
+            progress={progress}
+            label={progressLabel || progressTitle}
+            onCancel={handleCancel}
+            cancelling={isCancelling}
+          />
+        ) : phase === "simulating" ? (
+          <div className="min-h-0 flex-1 overflow-hidden">
+            <SimulatingTimelinePanel
+              progress={progress}
+              label={progressLabel || progressTitle}
+              events={timelineEvents}
+              framework={currentFramework}
+              onInterviewAgent={(id, name) => handleInterviewAgent(id, name)}
+              onCancel={handleCancel}
+              cancelling={isCancelling}
+            />
+          </div>
+        ) : phase === "framework-confirming" ? (
+          <div className="flex-1 overflow-y-auto p-4">
+            <FrameworkConfirmPanel
+              onConfirm={() => void handleConfirmFramework()}
+              onRegenerate={handleRegenerateFramework}
+              onSave={() => void handleSaveFramework()}
+            />
+          </div>
+        ) : phase === "report-viewing" ? (
+          <div className="flex min-h-0 flex-1">
+            <div className="min-w-0 flex-1 overflow-hidden">
+              <SimulationReportView
+                onResimulate={handleResimulate}
+                onGenerateDraft={(branch) => void handleGenerateDraft(branch)}
+                onInterviewAgent={(id, name) => handleInterviewAgent(id, name)}
+                onViewDraft={handleViewDraft}
+                hasDraft={!!currentDraft}
+                onViewInterviewHistory={() => setShowInterviewHistory(true)}
+              />
+            </div>
+            {activeChatAgent && (
+              <AgentChatPanel
+                agentName={activeChatAgent.name}
+                messages={agentChatMessages}
+                input={chatInput}
+                onInputChange={setChatInput}
+                onSend={() => void handleSendChat()}
+                onClose={handleCloseChat}
+                onExport={() => void handleExportChat()}
+                onSave={() => void handleSaveChat()}
+                sending={chatSending}
+                exporting={chatExporting}
+                saving={chatSaving}
+                chatLogRef={chatLogRef}
+              />
+            )}
+          </div>
+        ) : phase === "draft-viewing" ? (
+          <StoryDraftView onBack={handleBackToReport} />
+        ) : (
+          <div className="flex-1 overflow-y-auto">
+            <SimulationConfigPanel onStart={() => void handleStart()} />
+          </div>
+        )}
+      </div>
+      <InterviewHistoryView />
+    </div>
+  )
+}
+
+/** 进度展示面板:文字 + 进度条 + 取消按钮。 */
+function ProgressPanel({
+  progress,
+  label,
+  onCancel,
+  cancelling,
+}: {
+  progress: number
+  label: string
+  onCancel?: () => void
+  cancelling?: boolean
+}) {
+  const clamped = Math.min(100, Math.max(0, progress))
+  return (
+    <div className="flex flex-1 flex-col items-center justify-center gap-4 p-8">
+      <div className="text-base font-medium">{label}</div>
+      <div className="h-2 w-64 max-w-full overflow-hidden rounded-full bg-muted">
+        <div
+          className="h-full rounded-full bg-primary transition-all"
+          style={{ width: `${clamped}%` }}
+        />
+      </div>
+      <div className="text-xs text-muted-foreground">{clamped}%</div>
+      {onCancel && (
+        <Button
+          type="button"
+          variant="outline"
+          size="sm"
+          onClick={onCancel}
+          disabled={cancelling}
+          className="mt-2"
+        >
+          {cancelling ? "正在取消..." : "取消"}
+        </Button>
+      )}
+    </div>
+  )
+}
+
+/** 仿真中面板:进度条 + 实时时间线事件流(按节点分组折叠,带筛选)。 */
+function SimulatingTimelinePanel({
+  progress,
+  label,
+  events,
+  framework,
+  onInterviewAgent,
+  onCancel,
+  cancelling,
+}: {
+  progress: number
+  label: string
+  events: TimelineEvent[]
+  framework?: StoryFramework | null
+  onInterviewAgent?: (agentId: string, agentName: string) => void
+  onCancel?: () => void
+  cancelling?: boolean
+}) {
+  const clamped = Math.min(100, Math.max(0, progress))
+  const logRef = useRef<HTMLDivElement | null>(null)
+
+  // 筛选状态
+  const [filterActor, setFilterActor] = useState<string>("all")
+  const [filterType, setFilterType] = useState<string>("all")
+  // 折叠状态:key = nodeIndex,value = 是否折叠
+  const [collapsedNodes, setCollapsedNodes] = useState<Set<number>>(new Set())
+
+  // 从事件中提取所有角色和行动类型
+  const actors = useMemo(
+    () => Array.from(new Set(events.map((e) => e.actorName))).sort(),
+    [events],
+  )
+  const actionTypes = useMemo(
+    () => Array.from(new Set(events.map((e) => e.actionType))).sort(),
+    [events],
+  )
+
+  // 构建节点索引映射
+  const nodeMap = useMemo(() => {
+    const map = new Map<number, { title: string; phase: string }>()
+    if (framework) {
+      for (const node of framework.nodes) {
+        map.set(node.index, { title: node.title, phase: node.phase })
+      }
+    }
+    return map
+  }, [framework])
+
+  // 阶段中文标签
+  const phaseLabel = (phase: string): string => {
+    const map: Record<string, string> = { 起: "起", 承: "承", 转: "转", 合: "合" }
+    return map[phase] || phase
+  }
+
+  // 按节点分组事件
+  const groupedEvents = useMemo(() => {
+    // 先过滤事件
+    const filtered = events.filter((e) => {
+      if (filterActor !== "all" && e.actorName !== filterActor) return false
+      if (filterType !== "all" && e.actionType !== filterType) return false
+      return true
+    })
+    // 按 nodeIndex 分组
+    const groups = new Map<number, TimelineEvent[]>()
+    for (const ev of filtered) {
+      const idx = ev.nodeIndex
+      if (!groups.has(idx)) groups.set(idx, [])
+      groups.get(idx)!.push(ev)
+    }
+    // 按节点索引排序
+    return Array.from(groups.entries())
+      .sort(([a], [b]) => a - b)
+      .map(([nodeIndex, evs]) => ({
+        nodeIndex,
+        nodeInfo: nodeMap.get(nodeIndex),
+        events: evs,
+      }))
+  }, [events, filterActor, filterType, nodeMap])
+
+  // 计算过滤后的事件总数
+  const totalFiltered = groupedEvents.reduce((sum, g) => sum + g.events.length, 0)
+
+  useEffect(() => {
+    if (logRef.current) {
+      logRef.current.scrollTop = logRef.current.scrollHeight
+    }
+  }, [totalFiltered])
+
+  // 行动类型中文标签
+  const actionTypeLabel = (type: string): string => {
+    const map: Record<string, string> = {
+      evaluate: "评价",
+      pushPlot: "推动",
+      observe: "观察",
+      react: "反应",
+      speak: "对话",
+      ally: "示好",
+      confront: "对抗",
+      conceal: "隐瞒",
+      investigate: "调查",
+      act: "行动",
+      decide: "决策",
+      conflict: "冲突",
+      cooperate: "合作",
+      withhold: "隐瞒",
+    }
+    return map[type] || type
+  }
+
+  const toggleNode = (idx: number) => {
+    setCollapsedNodes((prev) => {
+      const next = new Set(prev)
+      if (next.has(idx)) {
+        next.delete(idx)
+      } else {
+        next.add(idx)
+      }
+      return next
+    })
+  }
+
+  const expandAll = () => setCollapsedNodes(new Set())
+  const collapseAll = () => {
+    const allNodes = new Set(groupedEvents.map((g) => g.nodeIndex))
+    setCollapsedNodes(allNodes)
+  }
+
+  return (
+    <div className="flex flex-1 flex-col p-6">
+      <div className="mb-4 flex flex-col items-center gap-2">
+        <div className="text-base font-medium">{label}</div>
+        <div className="h-2 w-64 max-w-full overflow-hidden rounded-full bg-muted">
+          <div
+            className="h-full rounded-full bg-primary transition-all"
+            style={{ width: `${clamped}%` }}
+          />
+        </div>
+        <div className="flex items-center gap-3">
+          <div className="text-xs text-muted-foreground">{clamped}%</div>
+          {onCancel && (
+            <Button
+              type="button"
+              variant="outline"
+              size="sm"
+              onClick={onCancel}
+              disabled={cancelling}
+              className="h-7 text-xs"
+            >
+              {cancelling ? "正在取消..." : "取消推演"}
+            </Button>
+          )}
+        </div>
+      </div>
+
+      {/* 筛选栏 */}
+      {events.length > 0 && (
+        <div className="mb-3 flex flex-wrap items-center gap-2 text-xs">
+          <span className="text-muted-foreground">筛选:</span>
+          <select
+            value={filterActor}
+            onChange={(e) => setFilterActor(e.target.value)}
+            className="h-7 rounded border border-input bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
+          >
+            <option value="all">全部角色</option>
+            {actors.map((name) => (
+              <option key={name} value={name}>{name}</option>
+            ))}
+          </select>
+          <select
+            value={filterType}
+            onChange={(e) => setFilterType(e.target.value)}
+            className="h-7 rounded border border-input bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
+          >
+            <option value="all">全部行为</option>
+            {actionTypes.map((type) => (
+              <option key={type} value={type}>{actionTypeLabel(type)}</option>
+            ))}
+          </select>
+          {(filterActor !== "all" || filterType !== "all") && (
+            <button
+              type="button"
+              className="text-xs text-primary hover:underline"
+              onClick={() => {
+                setFilterActor("all")
+                setFilterType("all")
+              }}
+            >
+              清除筛选
+            </button>
+          )}
+          <div className="ml-auto flex items-center gap-1">
+            <button
+              type="button"
+              className="text-xs text-muted-foreground hover:text-foreground"
+              onClick={expandAll}
+            >
+              全部展开
+            </button>
+            <span className="text-muted-foreground">|</span>
+            <button
+              type="button"
+              className="text-xs text-muted-foreground hover:text-foreground"
+              onClick={collapseAll}
+            >
+              全部折叠
+            </button>
+            <span className="ml-2 text-muted-foreground">
+              显示 {totalFiltered}/{events.length} 条
+            </span>
+          </div>
+        </div>
+      )}
+
+      <div
+        ref={logRef}
+        className="flex-1 overflow-y-auto rounded-lg border bg-muted/30 p-3 text-sm"
+      >
+        {groupedEvents.length === 0 ? (
+          <div className="py-8 text-center text-xs text-muted-foreground">
+            {events.length === 0 ? "等待角色行动..." : "没有符合筛选条件的事件"}
+          </div>
+        ) : (
+          <div className="space-y-3">
+            {groupedEvents.map(({ nodeIndex, nodeInfo, events: nodeEvents }) => {
+              const isCollapsed = collapsedNodes.has(nodeIndex)
+              const phase = nodeInfo?.phase || "起"
+              const nodeTitle = nodeInfo?.title || `节点 ${nodeIndex + 1}`
+              return (
+                <div key={nodeIndex} className="rounded-md border bg-background/50">
+                  {/* 节点标题栏 - 可点击折叠 */}
+                  <button
+                    type="button"
+                    className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-accent/50"
+                    onClick={() => toggleNode(nodeIndex)}
+                  >
+                    {isCollapsed ? (
+                      <ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
+                    ) : (
+                      <ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
+                    )}
+                    <span className="rounded bg-primary/10 px-1.5 py-0.5 text-[11px] font-medium text-primary">
+                      {phaseLabel(phase)}
+                    </span>
+                    <span className="text-sm font-medium">
+                      节点 {nodeIndex + 1}:{nodeTitle}
+                    </span>
+                    <span className="ml-auto text-[11px] text-muted-foreground">
+                      {nodeEvents.length} 条事件
+                    </span>
+                  </button>
+                  {/* 节点事件列表 */}
+                  {!isCollapsed && (
+                    <div className="space-y-1.5 border-t px-3 py-2">
+                      {nodeEvents.map((ev) => (
+                        <div key={ev.id} className="leading-relaxed">
+                          <span className="mr-1 rounded bg-muted px-1 py-0.5 text-[10px] text-muted-foreground">
+                            R{ev.round + 1}
+                          </span>
+                          {onInterviewAgent ? (
+                            <button
+                              type="button"
+                              className="font-medium text-primary hover:underline"
+                              onClick={() => onInterviewAgent(ev.actorId, ev.actorName)}
+                            >
+                              {ev.actorName}
+                            </button>
+                          ) : (
+                            <span className="font-medium">{ev.actorName}</span>
+                          )}
+                          <span className="text-muted-foreground">
+                            {" "}
+                            {ev.targetName && ev.targetId && onInterviewAgent ? (
+                              <>
+                                {actionTypePhraseOnly(ev.actionType)}{" "}
+                                <button
+                                  type="button"
+                                  className="text-primary hover:underline"
+                                  onClick={() => onInterviewAgent(ev.targetId!, ev.targetName!)}
+                                >
+                                  {ev.targetName}
+                                </button>
+                                :
+                              </>
+                            ) : (
+                              <>
+                                {actionPhrase(ev.actionType, ev.targetName)}:
+                              </>
+                            )}
+                          </span>
+                          <span>{ev.content}</span>
+                        </div>
+                      ))}
+                    </div>
+                  )}
+                </div>
+              )
+            })}
+          </div>
+        )}
+      </div>
+    </div>
+  )
+}
+
+/** Agent 采访对话面板(右侧内联面板)。 */
+function AgentChatPanel({
+  agentName,
+  messages,
+  input,
+  onInputChange,
+  onSend,
+  onClose,
+  onExport,
+  onSave,
+  sending,
+  exporting,
+  saving,
+  chatLogRef,
+}: {
+  agentName: string
+  messages: AgentChatMessage[]
+  input: string
+  onInputChange: (v: string) => void
+  onSend: () => void
+  onClose: () => void
+  onExport: () => void
+  onSave: () => void
+  sending: boolean
+  exporting: boolean
+  saving: boolean
+  chatLogRef: React.RefObject<HTMLDivElement | null>
+}) {
+  return (
+    <div className="flex w-80 shrink-0 flex-col border-l">
+      <div className="flex shrink-0 items-center justify-between border-b px-3 py-2">
+        <div className="text-sm font-semibold">与 {agentName} 对话</div>
+        <div className="flex items-center gap-1">
+          <Button
+            type="button"
+            size="icon"
+            variant="ghost"
+            className="h-7 w-7"
+            onClick={onSave}
+            title="保存采访对话"
+            disabled={saving || messages.length === 0}
+          >
+            {saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
+          </Button>
+          <Button
+            type="button"
+            size="icon"
+            variant="ghost"
+            className="h-7 w-7"
+            onClick={onExport}
+            title="导出对话为MD"
+            disabled={exporting || messages.length === 0}
+          >
+            <Download className="h-4 w-4" />
+          </Button>
+          <Button
+            type="button"
+            size="icon"
+            variant="ghost"
+            className="h-7 w-7"
+            onClick={onClose}
+            title="关闭对话"
+          >
+            <X className="h-4 w-4" />
+          </Button>
+        </div>
+      </div>
+      <div
+        ref={chatLogRef}
+        className="flex-1 space-y-2 overflow-y-auto p-3 text-sm"
+      >
+        {messages.length === 0 ? (
+          <div className="py-8 text-center text-xs text-muted-foreground">
+            你可以向 {agentName} 提问,了解他/她的想法。
+          </div>
+        ) : (
+          messages.map((msg) => (
+            <div
+              key={msg.id}
+              className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}
+            >
+              <div
+                className={`max-w-[85%] rounded-lg px-3 py-2 text-sm ${
+                  msg.role === "user"
+                    ? "bg-primary text-primary-foreground"
+                    : "bg-muted"
+                }`}
+              >
+                {msg.content || (sending && msg.role === "agent" ? "..." : "")}
+              </div>
+            </div>
+          ))
+        )}
+      </div>
+      <div className="shrink-0 border-t p-2">
+        <div className="flex gap-2">
+          <input
+            type="text"
+            value={input}
+            onChange={(e) => onInputChange(e.target.value)}
+            onKeyDown={(e) => {
+              if (e.key === "Enter" && !e.shiftKey) {
+                e.preventDefault()
+                onSend()
+              }
+            }}
+            placeholder="输入你的问题..."
+            className="flex-1 rounded-md border bg-background px-2 py-1.5 text-sm outline-none focus:ring-1 focus:ring-ring"
+            disabled={sending}
+          />
+          <Button
+            type="button"
+            size="icon"
+            className="h-8 w-8"
+            onClick={onSend}
+            disabled={sending || !input.trim()}
+          >
+            <Send className="h-4 w-4" />
+          </Button>
+        </div>
+      </div>
+    </div>
+  )
+}

+ 84 - 0
src/i18n/en.json

@@ -1110,6 +1110,7 @@
       "lint": "Memory Center",
       "soul": "Soul",
       "reviewCenter": "Review Center",
+      "storySimulation": "Story Simulation",
       "dismantling": "Dismantling",
       "settings": "模型与写作设置",
       "switchProject": "Switch Project"
@@ -1713,5 +1714,88 @@
         "conflicts": "Conflicts"
       }
     }
+  },
+  "storySimulation": {
+    "title": "Story Simulation Room",
+    "description": "Simulate character behavior and plot development through multi-agent simulation",
+    "selectMode": "Select Simulation Mode",
+    "modeEventDriven": "Event-Driven",
+    "modeFreeEmergence": "Free Emergence",
+    "modeDecisionTree": "Decision Tree",
+    "modeHybrid": "Hybrid",
+    "modeEventDrivenDesc": "Inject a trigger event, simulate character reactions",
+    "modeFreeEmergenceDesc": "Let characters interact freely, observe emergent plot",
+    "modeDecisionTreeDesc": "Generate decision branches for key characters",
+    "modeHybridDesc": "Combine free emergence with event injection",
+    "yourIdea": "Your Idea (Optional)",
+    "yourIdeaPlaceholder": "Enter your thoughts on plot direction...",
+    "targetWords": "Target Word Count",
+    "words10k": "10,000 words",
+    "words30k": "30,000 words",
+    "words50k": "50,000 words",
+    "wordsCustom": "Custom",
+    "sourceChapters": "Source Chapters",
+    "recentChapters": "Recent",
+    "chapters": "chapters",
+    "startExtract": "Start Extraction & Generate Framework",
+    "extracting": "Extracting content...",
+    "extractProgress": "Extraction Progress",
+    "frameworkTitle": "Story Framework",
+    "frameworkPremise": "Premise",
+    "frameworkNodes": "Story Nodes",
+    "regenerateFramework": "Regenerate Framework",
+    "saveFramework": "Save Framework",
+    "confirmFramework": "Confirm & Start Simulation",
+    "simulating": "Simulating...",
+    "simulationProgress": "Simulation Progress",
+    "reportTitle": "Simulation Report",
+    "characterAnalysis": "Character Analysis",
+    "storyBranches": "Story Branches",
+    "recommendation": "Recommendation",
+    "resimulate": "Re-simulate",
+    "generateDraft": "Select Branch & Generate Draft",
+    "draftTitle": "Story Draft",
+    "exportDraft": "Export",
+    "copyAll": "Copy All",
+    "importToChapters": "Import to Chapters",
+    "discard": "Discard",
+    "frameworkList": "Story Frameworks",
+    "newFramework": "New Framework",
+    "bindToChat": "Bind to AI Chat",
+    "unbindFromChat": "Unbind",
+    "bindingTitle": "Bind Story Framework to AI Chat",
+    "selectFramework": "Select Framework",
+    "targetChapterCount": "Target Chapter Count",
+    "confirmBinding": "Confirm Binding",
+    "bindingHint": "After binding, AI chat will follow this framework to analyze how chapters advance the story",
+    "noFrameworks": "No frameworks yet. Click the button above to create one.",
+    "noResults": "No simulation results yet",
+    "phase": "Phase",
+    "conflict": "Conflict",
+    "characters": "Characters",
+    "goal": "Goal",
+    "cause": "Cause",
+    "expectedOutcome": "Expected Outcome",
+    "coreConflict": "Core Conflict",
+    "involvedCharacters": "Involved Characters",
+    "consistencyScore": "Consistency Score",
+    "probability": "Probability",
+    "probabilityHigh": "High",
+    "probabilityMedium": "Medium",
+    "probabilityLow": "Low",
+    "pros": "Pros",
+    "cons": "Cons",
+    "actualWords": "Actual Words",
+    "totalWords": "Total Words",
+    "error": "Error",
+    "retry": "Retry",
+    "back": "Back",
+    "copied": "Copied",
+    "keyEvents": "Key Events",
+    "behaviors": "Behaviors",
+    "motivation": "Motivation",
+    "stateChanges": "State Changes",
+    "summary": "Summary",
+    "recommended": "Recommended"
   }
 }

+ 84 - 0
src/i18n/zh.json

@@ -991,6 +991,7 @@
       "lint": "记忆中心",
       "soul": "灵魂",
       "reviewCenter": "审查中心",
+      "storySimulation": "剧情推演室",
       "dismantling": "拆文库",
       "settings": "模型与写作设置",
       "switchProject": "切换小说"
@@ -1590,5 +1591,88 @@
         "conflicts": "冲突与矛盾"
       }
     }
+  },
+  "storySimulation": {
+    "title": "剧情推演室",
+    "description": "通过多 Agent 仿真推演小说角色在给定情境下的行为选择和剧情走向",
+    "selectMode": "选择仿真模式",
+    "modeEventDriven": "事件驱动",
+    "modeFreeEmergence": "自由涌现",
+    "modeDecisionTree": "决策树",
+    "modeHybrid": "混合模式",
+    "modeEventDrivenDesc": "注入一个触发事件,推演各角色反应和连锁效应",
+    "modeFreeEmergenceDesc": "让角色根据目标自由互动,涌现剧情走向",
+    "modeDecisionTreeDesc": "为关键角色生成多个决策分支,对比连锁反应",
+    "modeHybridDesc": "自由涌现与事件驱动结合,生成多条可能分支",
+    "yourIdea": "你的思路(可选)",
+    "yourIdeaPlaceholder": "输入你对剧情走向的想法或约束...",
+    "targetWords": "目标字数",
+    "words10k": "10000字",
+    "words30k": "30000字",
+    "words50k": "50000字",
+    "wordsCustom": "自定义",
+    "sourceChapters": "提取章节数量",
+    "recentChapters": "最近",
+    "chapters": "章",
+    "startExtract": "开始提取并生成框架",
+    "extracting": "正在提取内容...",
+    "extractProgress": "提取进度",
+    "frameworkTitle": "故事框架",
+    "frameworkPremise": "前提",
+    "frameworkNodes": "故事节点",
+    "regenerateFramework": "重新生成框架",
+    "saveFramework": "保存框架",
+    "confirmFramework": "确认框架,开始推演",
+    "simulating": "正在推演...",
+    "simulationProgress": "推演进度",
+    "reportTitle": "推演报告",
+    "characterAnalysis": "角色行为分析",
+    "storyBranches": "走向分支",
+    "recommendation": "综合推荐",
+    "resimulate": "重新推演",
+    "generateDraft": "选择分支,生成草稿",
+    "draftTitle": "故事草稿",
+    "exportDraft": "导出",
+    "copyAll": "复制全部",
+    "importToChapters": "导入到章节",
+    "discard": "丢弃",
+    "frameworkList": "故事框架",
+    "newFramework": "新建故事框架",
+    "bindToChat": "绑定到 AI 会话",
+    "unbindFromChat": "取消绑定",
+    "bindingTitle": "绑定故事框架到 AI 会话",
+    "selectFramework": "选择框架",
+    "targetChapterCount": "生成章节数",
+    "confirmBinding": "确认绑定",
+    "bindingHint": "绑定后,AI 会话将按此框架分析指定章节数如何推动故事发展",
+    "noFrameworks": "暂无故事框架,点击上方按钮开始创建",
+    "noResults": "暂无推演结果",
+    "phase": "阶段",
+    "conflict": "冲突",
+    "characters": "角色",
+    "goal": "目标",
+    "cause": "起因",
+    "expectedOutcome": "预期走向",
+    "coreConflict": "核心冲突",
+    "involvedCharacters": "涉及角色",
+    "consistencyScore": "人设一致性",
+    "probability": "概率",
+    "probabilityHigh": "高",
+    "probabilityMedium": "中",
+    "probabilityLow": "低",
+    "pros": "优势",
+    "cons": "不足",
+    "actualWords": "实际字数",
+    "totalWords": "总字数",
+    "error": "错误",
+    "retry": "重试",
+    "back": "返回",
+    "copied": "已复制",
+    "keyEvents": "关键事件",
+    "behaviors": "行为",
+    "motivation": "动机",
+    "stateChanges": "状态变化",
+    "summary": "摘要",
+    "recommended": "推荐"
   }
 }

+ 24 - 0
src/lib/novel/context-data-sources.ts

@@ -14,6 +14,8 @@ import { getChapterVolumes } from "./volume"
 import { readSoulDoc } from "./soul-doc"
 import { buildWritingStyleContext } from "./writing-style-store"
 import type { DataSource, ContextLoadContext } from "./context-data-source"
+import { loadFrameworks } from "./story-simulation/framework-store"
+import { loadBinding, buildBindingContext } from "./story-simulation/framework-binding"
 
 // 导入现有的辅助函数
 import {
@@ -441,6 +443,27 @@ export const characterAurasDataSource: DataSource<string> = {
   },
 }
 
+/**
+ * 故事框架绑定数据源
+ * 加载当前激活的框架绑定,构建注入 AI 会话的上下文文本。
+ */
+export const storyFrameworkBindingDataSource: DataSource<string> = {
+  name: "storyFrameworkBinding",
+  priority: 19,
+  async load(context: ContextLoadContext): Promise<string> {
+    try {
+      const binding = await loadBinding(context.projectPath)
+      if (!binding) return ""
+      const frameworks = await loadFrameworks(context.projectPath)
+      const framework = frameworks.find((f) => f.id === binding.frameworkId)
+      if (!framework) return ""
+      return buildBindingContext(binding, framework)
+    } catch {
+      return ""
+    }
+  },
+}
+
 /**
  * 获取所有数据源
  */
@@ -464,5 +487,6 @@ export function getAllDataSources(): DataSource<any>[] {
     revisionFeedbackDataSource,
     cognitionTextDataSource,
     soulDocDataSource,
+    storyFrameworkBindingDataSource,
   ]
 }

+ 256 - 0
src/lib/novel/story-simulation/agent-interview.ts

@@ -0,0 +1,256 @@
+import type { ChatMessage } from "@/lib/llm-client"
+import { streamChat } from "@/lib/llm-client"
+import type { LlmConfig } from "@/stores/wiki-store"
+import type {
+  AgentChatMessage,
+  AgentChatSession,
+  NovelAgent,
+  SimulationState,
+} from "@/lib/novel/story-simulation/types"
+import { formatTimelineEvent, getVisibleEvents } from "@/lib/novel/story-simulation/agent-profile-builder"
+
+// ── 对外接口 ──
+
+export interface InterviewOptions {
+  llmConfig: LlmConfig
+  agent: NovelAgent
+  simulationState: SimulationState
+  userPrompt: string
+  signal?: AbortSignal
+  /** 流式 token 回调 */
+  onToken?: (token: string) => void
+  /** 对话历史(可选,用于多轮采访) */
+  session?: AgentChatSession
+  /** 完成回调,返回完整回复文本和更新后的会话 */
+  onDone?: (fullText: string, session: AgentChatSession) => void
+  /** 错误回调 */
+  onError?: (error: Error) => void
+}
+
+// ── 内部辅助:构建采访系统提示词 ──
+
+function buildInterviewSystemPrompt(agent: NovelAgent): string {
+  const personalityLine =
+    agent.personality.length > 0
+      ? `你的性格:${agent.personality.join("、")}`
+      : ""
+  const styleLine = agent.speakingStyle
+    ? `你的说话风格:${agent.speakingStyle}`
+    : ""
+
+  return [
+    `你正在以小说角色「${agent.name}」的身份接受采访/对话。`,
+    "",
+    "【核心规则 - 必须严格遵守】",
+    "1. 你就是「" + agent.name + "」本人,不是AI助手,不要跳出角色。",
+    "2. 严格基于你的认知范围回答:你只知道你亲身经历、亲眼看到、亲耳听到的事情。",
+    "3. 你不知道的事情必须明确表示「我不知道」或「我不清楚」,绝不能编造你不知道的信息。",
+    "4. 绝对禁止全知视角:你不知道其他角色的内心想法,不知道剧情走向,不知道作者的安排。",
+    "5. 保持你的性格和说话风格,回答要自然、像真实的人物对话。",
+    "6. 用纯文本回复,不要输出JSON,不要使用markdown格式,不要加入舞台指示或动作描写括号。",
+    "",
+    personalityLine,
+    styleLine,
+  ]
+    .filter((line) => line !== null && line !== undefined && line !== "")
+    .join("\n")
+}
+
+// ── 内部辅助:构建采访上下文 ──
+
+function buildInterviewContext(
+  agent: NovelAgent,
+  simulationState: SimulationState,
+): string {
+  const sections: string[] = []
+
+  // 角色基本信息
+  sections.push("【你的身份】")
+  sections.push(`姓名:${agent.name}`)
+  if (agent.profile) {
+    sections.push(`档案:${agent.profile}`)
+  }
+  if (agent.soul) {
+    sections.push(`角色灵魂:${agent.soul}`)
+  }
+
+  // 你知道的信息
+  sections.push("")
+  sections.push("【你知道的信息】")
+  if (agent.knowledgeScope.length > 0) {
+    sections.push(agent.knowledgeScope.slice(-20).join("\n"))
+  } else {
+    sections.push("(目前你了解的信息有限)")
+  }
+
+  // 你不知道的信息(提醒不要越界)
+  if (agent.cognition?.doesNotKnow && agent.cognition.doesNotKnow.length > 0) {
+    sections.push("")
+    sections.push("【你绝对不知道的事情(禁止提及)】")
+    sections.push(agent.cognition.doesNotKnow.join("\n"))
+  }
+
+  // 人际关系/情感
+  sections.push("")
+  sections.push("【你对其他角色的情感】")
+  let hasRelation = false
+  for (const [otherId, value] of agent.memory.sentiments.entries()) {
+    if (otherId === agent.characterId) continue
+    const otherAgent = simulationState.activeAgents.get(otherId)
+    const otherName = otherAgent?.name ?? otherId
+    const desc =
+      value > 30
+        ? "有好感"
+        : value < -30
+          ? "有敌意"
+          : value > 0
+            ? "印象不错"
+            : value < 0
+              ? "有些不满"
+              : "态度中立"
+    sections.push(`- 对 ${otherName}:${desc}(好感度 ${value})`)
+    hasRelation = true
+  }
+  if (!hasRelation) {
+    sections.push("(你目前对其他角色没有特别的情感倾向)")
+  }
+
+  // 你最近观察到的事件(最近10条可见事件)
+  const visibleEvents = getVisibleEvents(
+    agent.characterId,
+    simulationState.timelineEvents,
+    10,
+  )
+  if (visibleEvents.length > 0) {
+    sections.push("")
+    sections.push("【你最近经历/观察到的事情】")
+    for (const ev of visibleEvents) {
+      sections.push(`- ${formatTimelineEvent(ev)}`)
+    }
+  }
+
+  // 当前情绪和目标
+  sections.push("")
+  sections.push("【你当前的状态】")
+  sections.push(`当前目标:${agent.currentGoal}`)
+  sections.push(`情绪状态:${agent.emotionalState}`)
+
+  return sections.join("\n")
+}
+
+// ── 内部辅助:生成消息 ID ──
+
+function nextMsgId(): string {
+  return `msg_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
+}
+
+// ── 主入口:采访/对话 Agent ──
+
+export async function interviewAgent(
+  options: InterviewOptions,
+): Promise<{ fullText: string; session: AgentChatSession }> {
+  const {
+    llmConfig,
+    agent,
+    simulationState,
+    userPrompt,
+    signal,
+    onToken,
+    session: existingSession,
+    onDone,
+    onError,
+  } = options
+
+  // 初始化或复用会话
+  const session: AgentChatSession = existingSession
+    ? { ...existingSession, messages: [...existingSession.messages] }
+    : {
+        agentId: agent.characterId,
+        agentName: agent.name,
+        messages: [],
+      }
+
+  try {
+    // 构建消息列表
+    const messages: ChatMessage[] = [
+      { role: "system", content: buildInterviewSystemPrompt(agent) },
+    ]
+
+    // 添加上下文作为第一条 user 消息(只在会话开始时添加)
+    if (session.messages.length === 0) {
+      const context = buildInterviewContext(agent, simulationState)
+      messages.push({
+        role: "user",
+        content:
+          context +
+          "\n\n以上是你的背景信息。采访即将开始,请根据后续提问自然地以角色身份回答。",
+      })
+      messages.push({
+        role: "assistant",
+        content: "我准备好了,请问吧。",
+      })
+    }
+
+    // 添加历史消息
+    for (const msg of session.messages) {
+      if (msg.role === "user") {
+        messages.push({ role: "user", content: msg.content })
+      } else if (msg.role === "agent") {
+        messages.push({ role: "assistant", content: msg.content })
+      }
+    }
+
+    // 添加当前用户提问
+    const userMsg: AgentChatMessage = {
+      id: nextMsgId(),
+      role: "user",
+      content: userPrompt,
+      timestamp: new Date().toISOString(),
+    }
+    session.messages.push(userMsg)
+    messages.push({ role: "user", content: userPrompt })
+
+    // 调用 LLM 流式回复
+    let fullText = ""
+    let streamError: Error | null = null
+
+    await streamChat(
+      llmConfig,
+      messages,
+      {
+        onToken: (token) => {
+          fullText += token
+          onToken?.(token)
+        },
+        onDone: () => {},
+        onError: (err) => {
+          streamError = err
+        },
+      },
+      signal,
+    )
+
+    if (streamError) {
+      throw streamError
+    }
+
+    // 记录 Agent 回复到会话
+    const agentMsg: AgentChatMessage = {
+      id: nextMsgId(),
+      role: "agent",
+      agentId: agent.characterId,
+      agentName: agent.name,
+      content: fullText,
+      timestamp: new Date().toISOString(),
+    }
+    session.messages.push(agentMsg)
+
+    onDone?.(fullText, session)
+
+    return { fullText, session }
+  } catch (err) {
+    const error = err instanceof Error ? err : new Error(String(err))
+    onError?.(error)
+    throw error
+  }
+}

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

@@ -0,0 +1,351 @@
+import type { CharacterAura } from "@/lib/novel/character-aura"
+import type {
+  AgentMemory,
+  AgentRelation,
+  ExtractionResult,
+  ExtractedCharacter,
+  NovelAgent,
+  StoryFramework,
+  StoryNode,
+  TimelineEvent,
+} from "@/lib/novel/story-simulation/types"
+
+/**
+ * 从故事框架中推断某个角色当前的目标。
+ *
+ * 策略:找到第一个涉及该角色的节点,返回该节点的 goal;
+ * 如果没有涉及该角色的节点,使用框架前提作为兜底目标。
+ */
+function inferGoalFromFramework(
+  framework: StoryFramework,
+  characterName: string,
+): string {
+  const node = framework.nodes.find((n) =>
+    n.involvedCharacters.includes(characterName),
+  )
+  if (node) {
+    return node.goal
+  }
+  return framework.premise || "待定"
+}
+
+/**
+ * 从角色 soul 和 aura 中提取性格关键词。
+ * 简单策略:取 soul 文本的前若干关键词 + aura 中相关字段的关键短语。
+ */
+function extractPersonalityKeywords(
+  soul: string,
+  aura: CharacterAura | null,
+): string[] {
+  const keywords: string[] = []
+
+  // 从 soul 中提取简短关键词(按标点分割,取短句)
+  if (soul) {
+    const parts = soul
+      .split(/[。;,、\n,;.]/)
+      .map((s) => s.trim())
+      .filter((s) => s.length > 0 && s.length <= 12)
+    for (const part of parts.slice(0, 5)) {
+      if (!keywords.includes(part)) {
+        keywords.push(part)
+      }
+    }
+  }
+
+  // 从 aura 关键字段提取
+  if (aura) {
+    const fields = [aura.styleDescription, aura.behaviorRules, aura.mentalModel]
+    for (const field of fields) {
+      if (!field) continue
+      const parts = field
+        .split(/[。;,、\n,;.]/)
+        .map((s) => s.trim())
+        .filter((s) => s.length > 0 && s.length <= 10)
+      for (const part of parts.slice(0, 2)) {
+        if (!keywords.includes(part)) {
+          keywords.push(part)
+        }
+      }
+    }
+  }
+
+  return keywords.slice(0, 8)
+}
+
+/**
+ * 从 aura 的表达 DNA 和风格描述中提取说话风格。
+ */
+function extractSpeakingStyle(aura: CharacterAura | null, soul: string): string {
+  const parts: string[] = []
+  if (aura?.expressionDna) {
+    parts.push(aura.expressionDna.trim())
+  }
+  if (aura?.styleDescription) {
+    parts.push(aura.styleDescription.trim())
+  }
+  if (parts.length === 0 && soul) {
+    // 兜底:取 soul 的前 100 字作为风格参考
+    parts.push(soul.slice(0, 100))
+  }
+  return parts.join(";").slice(0, 300)
+}
+
+/**
+ * 初始化 Agent 记忆。
+ */
+function initMemory(
+  allCharacterIds: string[],
+  initialSentiments?: Map<string, number>,
+): AgentMemory {
+  const sentiments = new Map<string, number>()
+  for (const id of allCharacterIds) {
+    sentiments.set(id, initialSentiments?.get(id) ?? 0)
+  }
+  return {
+    observedEvents: [],
+    knownSecrets: new Set<string>(),
+    sentiments,
+    recentDecisions: [],
+  }
+}
+
+/**
+ * 根据提取结果与故事框架构建仿真用 Agent 列表。
+ *
+ * - 从框架节点中收集所有涉及的角色名
+ * - 如果框架未指定角色,则使用全部提取到的角色
+ * - 为每个角色构建 NovelAgent,初始化目标、情绪、已知事实、关系、记忆、性格、说话风格、认知范围
+ */
+export function buildAgents(
+  extraction: ExtractionResult,
+  framework: StoryFramework,
+): NovelAgent[] {
+  // 收集框架中涉及的角色名(去重,保持顺序)
+  const frameworkCharacters: string[] = []
+  for (const node of framework.nodes) {
+    for (const name of node.involvedCharacters) {
+      if (!frameworkCharacters.includes(name)) {
+        frameworkCharacters.push(name)
+      }
+    }
+  }
+
+  // 根据框架指定角色筛选,若框架未指定则使用全部提取角色
+  const selectedCharacters: ExtractedCharacter[] =
+    frameworkCharacters.length > 0
+      ? extraction.characters.filter((c) =>
+          frameworkCharacters.includes(c.name),
+        )
+      : extraction.characters
+
+  // 构建所有选中角色的 id 列表,用于初始化关系
+  const allCharacterIds = selectedCharacters.map((c) => c.id)
+
+  const agents: NovelAgent[] = selectedCharacters.map((character) => {
+    // 已知事实从认知的 knows 初始化(保留兼容)
+    const knownFacts = new Set<string>(
+      character.cognition?.knows ?? [],
+    )
+
+    // 初始化与其他角色的关系(保留兼容)
+    const relationships = new Map<string, AgentRelation>()
+    for (const otherId of allCharacterIds) {
+      if (otherId === character.id) continue
+      relationships.set(otherId, {
+        targetId: otherId,
+        relationType: "neutral",
+        sentiment: 0,
+      })
+    }
+
+    // 知识范围从 cognition.knows 构建
+    const knowledgeScope: string[] = [...(character.cognition?.knows ?? [])]
+
+    // 性格关键词
+    const personality = extractPersonalityKeywords(character.soul, character.aura)
+
+    // 说话风格
+    const speakingStyle = extractSpeakingStyle(character.aura, character.soul)
+
+    // 记忆初始化
+    const memory = initMemory(allCharacterIds)
+
+    return {
+      characterId: character.id,
+      name: character.name,
+      profile: character.profile,
+      aura: character.aura,
+      cognition: character.cognition,
+      soul: character.soul,
+      currentGoal: inferGoalFromFramework(framework, character.name),
+      emotionalState: "neutral",
+      knownFacts,
+      relationships,
+      powerLevel: "",
+      memory,
+      knowledgeScope,
+      personality,
+      speakingStyle,
+    }
+  })
+
+  return agents
+}
+
+/**
+ * 从 SimulationState 中筛选某个 Agent 可见的时间线事件(最近 N 条)。
+ */
+export function getVisibleEvents(
+  agentId: string,
+  timelineEvents: TimelineEvent[],
+  limit = 10,
+): TimelineEvent[] {
+  return timelineEvents
+    .filter((e) => e.observableBy.includes(agentId))
+    .slice(-limit)
+}
+
+/**
+ * 格式化时间线事件为简短描述(供上下文拼接使用)。
+ */
+export function formatTimelineEvent(event: TimelineEvent): string {
+  const targetDesc = event.targetName ? ` → ${event.targetName}` : ""
+  const visibilityTag =
+    event.observableBy.length > 2
+      ? "[公开]"
+      : event.observableBy.length === 2
+        ? "[私聊]"
+        : "[内心]"
+  return `第${event.round + 1}轮 ${visibilityTag} ${event.actorName}${targetDesc} [${event.actionType}]:${event.content}`
+}
+
+/**
+ * 构建 Agent 决策时的上下文文本。
+ *
+ * 包含当前场景、Agent 身份(含性格与说话风格)、认知边界、记忆/情感、人际关系、可见时间线事件与世界规则。
+ */
+export function buildAgentContext(
+  agent: NovelAgent,
+  node: StoryNode,
+  recentEvents: string[],
+  worldRules: string,
+  visibleTimelineEvents?: TimelineEvent[],
+): string {
+  const sections: string[] = []
+
+  // ── 当前场景 ──
+  sections.push("【当前场景】")
+  sections.push(`节点 ${node.index}(${node.phase}):${node.title}`)
+  sections.push(`核心冲突:${node.coreConflict}`)
+  sections.push(`本节点目标:${node.goal}`)
+  if (node.causeFromPrev) {
+    sections.push(`承前原因:${node.causeFromPrev}`)
+  }
+  if (node.expectedOutcome) {
+    sections.push(`预期结果:${node.expectedOutcome}`)
+  }
+  if (node.involvedCharacters.length > 0) {
+    sections.push(`涉及角色:${node.involvedCharacters.join("、")}`)
+  }
+
+  // ── Agent 身份 ──
+  sections.push("")
+  sections.push("【Agent 身份】")
+  sections.push(`姓名:${agent.name}`)
+  if (agent.profile) {
+    sections.push(`档案:${agent.profile}`)
+  }
+  if (agent.soul) {
+    sections.push(`灵魂:${agent.soul}`)
+  }
+  if (agent.personality.length > 0) {
+    sections.push(`性格关键词:${agent.personality.join("、")}`)
+  }
+  if (agent.speakingStyle) {
+    sections.push(`说话风格:${agent.speakingStyle}`)
+  }
+
+  // 光环各字段(以实际源码为准,不存在的字段跳过)
+  if (agent.aura) {
+    const aura = agent.aura as CharacterAura
+    sections.push("")
+    sections.push("【角色光环】")
+    appendAuraField(sections, "风格描述", aura.styleDescription)
+    appendAuraField(sections, "行为规则", aura.behaviorRules)
+    appendAuraField(sections, "边界", aura.boundaries)
+    appendAuraField(sections, "表达 DNA", aura.expressionDna)
+    appendAuraField(sections, "心智模型", aura.mentalModel)
+    appendAuraField(sections, "决策启发式", aura.decisionHeuristics)
+    appendAuraField(sections, "价值反模式", aura.valueAntiPatterns)
+    appendAuraField(sections, "诚实边界", aura.honestyBoundaries)
+    appendAuraField(sections, "备注", aura.notes)
+  }
+
+  // ── 认知边界 ──
+  sections.push("")
+  sections.push("【认知边界】")
+  if (agent.knowledgeScope.length > 0) {
+    sections.push(`你知道的信息:${agent.knowledgeScope.join(";")}`)
+  }
+  if (agent.cognition?.doesNotKnow && agent.cognition.doesNotKnow.length > 0) {
+    sections.push(`你不知道的信息:${agent.cognition.doesNotKnow.join(";")}`)
+  }
+  sections.push("【重要提醒】你只能基于你知道的信息行动,你不知道的事情绝对不能使用,绝不能表现出全知视角。")
+
+  // ── 当前状态 ──
+  sections.push("")
+  sections.push("【当前状态】")
+  sections.push(`当前目标:${agent.currentGoal}`)
+  sections.push(`情绪状态:${agent.emotionalState}`)
+
+  // ── 人际关系与情感(从 memory.sentiments 读取) ──
+  if (agent.memory.sentiments.size > 0) {
+    sections.push("")
+    sections.push("【你对其他角色的情感】")
+    for (const [otherId, value] of agent.memory.sentiments.entries()) {
+      if (otherId === agent.characterId) continue
+      // 尝试找到角色名
+      sections.push(`对角色[${otherId}]:好感度 ${value}`)
+    }
+  }
+
+  // ── 近期事件(旧接口字符串列表,保留兼容) ──
+  if (recentEvents.length > 0) {
+    sections.push("")
+    sections.push("【近期事件】")
+    for (const event of recentEvents) {
+      sections.push(`- ${event}`)
+    }
+  }
+
+  // ── 你能观察到的时间线事件(新引擎) ──
+  if (visibleTimelineEvents && visibleTimelineEvents.length > 0) {
+    sections.push("")
+    sections.push("【你观察到的最近事件】")
+    for (const ev of visibleTimelineEvents) {
+      sections.push(`- ${formatTimelineEvent(ev)}`)
+    }
+  }
+
+  // ── 世界规则 ──
+  if (worldRules) {
+    sections.push("")
+    sections.push("【世界规则】")
+    sections.push(worldRules)
+  }
+
+  return sections.join("\n")
+}
+
+/**
+ * 将光环字段追加到 sections,仅当字段存在且非空时输出。
+ */
+function appendAuraField(
+  sections: string[],
+  label: string,
+  value: string | undefined,
+): void {
+  if (value !== undefined && value !== "") {
+    sections.push(`${label}:${value}`)
+  }
+}

+ 69 - 0
src/lib/novel/story-simulation/draft-export.ts

@@ -0,0 +1,69 @@
+/**
+ * 故事草稿导出
+ * 将 StoryDraft 导出为 Markdown 文件。
+ */
+
+import { createDirectory, writeFileAtomic } from "@/commands/fs"
+import { normalizePath } from "@/lib/path-utils"
+import type { StoryDraft, StoryFramework } from "./types"
+
+const SIM_ROOT = ".qmai/simulations"
+const EXPORTS_DIR = `${SIM_ROOT}/exports`
+
+function exportsDir(projectPath: string): string {
+  return `${normalizePath(projectPath)}/${EXPORTS_DIR}`
+}
+
+function draftFilePath(
+  projectPath: string,
+  frameworkTitle: string,
+  timestamp: string,
+): string {
+  const safeTitle = frameworkTitle
+    .replace(/[\\/:*?"<>|]/g, "_")
+    .slice(0, 30)
+  const safeTs = timestamp.replace(/[:.]/g, "-")
+  return `${exportsDir(projectPath)}/故事草稿_${safeTitle}_${safeTs}.md`
+}
+
+/**
+ * 导出故事草稿为 Markdown。
+ * @returns 导出的文件路径
+ */
+export async function exportDraft(
+  projectPath: string,
+  framework: StoryFramework,
+  draft: StoryDraft,
+): Promise<string> {
+  const dir = exportsDir(projectPath)
+  await createDirectory(dir)
+
+  const now = new Date()
+  const timestamp = now.toISOString()
+  const filePath = draftFilePath(projectPath, framework.shortTitle || framework.title, timestamp)
+
+  const lines: string[] = []
+  lines.push(`# ${framework.title}`)
+  lines.push("")
+  lines.push(`> 生成时间:${now.toLocaleString("zh-CN")}`)
+  lines.push(`> 目标字数:${framework.targetWords}`)
+  lines.push(`> 实际字数:${draft.totalWords}`)
+  if (framework.premise) {
+    lines.push(`>`)
+    lines.push(`> ${framework.premise}`)
+  }
+  lines.push("")
+  lines.push("---")
+  lines.push("")
+
+  for (const chapter of draft.chapters) {
+    lines.push(`## ${chapter.title}`)
+    lines.push("")
+    lines.push(chapter.content)
+    lines.push("")
+  }
+
+  const content = lines.join("\n")
+  await writeFileAtomic(filePath, content)
+  return filePath
+}

+ 134 - 0
src/lib/novel/story-simulation/draft-importer.ts

@@ -0,0 +1,134 @@
+/**
+ * 故事草稿导入到章节库
+ *
+ * 将 StoryDraft 中的章节写入到项目的 wiki/chapters/ 目录下,
+ * 使用标准的 chapter-XXX.md 文件格式(带 YAML frontmatter)。
+ * 支持选择性导入和覆盖前自动备份。
+ */
+
+import { createDirectory, writeFileAtomic, fileExists, readFile } from "@/commands/fs"
+import { normalizePath } from "@/lib/path-utils"
+import { getNextChapterNumber } from "@/lib/novel/chapter-utils"
+import { backupChapterFile } from "@/lib/novel/chapter-backup"
+import type { StoryDraft, StoryFramework } from "./types"
+
+export interface ImportResult {
+  importedCount: number
+  chapterPaths: string[]
+  startChapter: number
+  backedUpPaths: string[]
+}
+
+/**
+ * 将草稿导入到项目章节库。
+ * @returns 导入的章节数量、起始章节号、文件路径列表和备份路径列表
+ */
+export async function importDraftToChapters(
+  projectPath: string,
+  framework: StoryFramework,
+  draft: StoryDraft,
+  options?: {
+    /** 指定起始章节号,不指定则自动使用下一个可用章节号 */
+    startChapter?: number
+    /** 是否覆盖已存在的章节文件(覆盖前自动备份) */
+    overwrite?: boolean
+    /** 只导入指定索引的章节(0-based),不指定则全部导入 */
+    selectedIndices?: number[]
+    /** 导入进度回调 */
+    onProgress?: (current: number, total: number, chapterTitle: string) => void
+  },
+): Promise<ImportResult> {
+  const pp = normalizePath(projectPath)
+  const chapterDir = `${pp}/wiki/chapters`
+  await createDirectory(chapterDir)
+
+  const startChapter = options?.startChapter ?? await getNextChapterNumber(pp)
+  const chapterPaths: string[] = []
+  const backedUpPaths: string[] = []
+  const now = new Date()
+  const dateStr = now.toISOString().split("T")[0]
+
+  // 确定要导入的章节索引
+  const indices = options?.selectedIndices
+    ? options.selectedIndices.filter((i) => i >= 0 && i < draft.chapters.length)
+    : draft.chapters.map((_, i) => i)
+
+  if (indices.length === 0) {
+    throw new Error("未选择任何章节,请至少选择一章导入。")
+  }
+
+  // 计算每个导入章节对应的章节号
+  // 如果是选择性导入,章节号仍按原顺序连续分配
+  let chapterNumOffset = 0
+  const totalToImport = indices.length
+  for (let i = 0; i < indices.length; i++) {
+    const draftIdx = indices[i]
+    const chapter = draft.chapters[draftIdx]
+    const chapterNum = startChapter + chapterNumOffset
+    const fileName = `chapter-${String(chapterNum).padStart(3, "0")}.md`
+    const filePath = `${chapterDir}/${fileName}`
+
+    // 进度回调
+    options?.onProgress?.(i + 1, totalToImport, chapter.title)
+
+    // 检查文件是否已存在
+    const exists = await fileExists(filePath)
+    if (exists) {
+      if (!options?.overwrite) {
+        throw new Error(
+          `章节文件已存在:${fileName}。请先删除已有章节或选择覆盖模式。`,
+        )
+      }
+      // 覆盖前自动备份原文件
+      try {
+        const originalContent = await readFile(filePath)
+        const backupPath = await backupChapterFile({
+          projectPath: pp,
+          chapterPath: filePath,
+          chapterNumber: chapterNum,
+          content: originalContent,
+          now,
+        })
+        backedUpPaths.push(backupPath)
+      } catch {
+        // 备份失败不阻塞导入,但记录警告
+        console.warn(`[draft-import] 备份 ${fileName} 失败,继续覆盖`)
+      }
+    }
+
+    // 构建章节标题
+    const fullTitle = chapter.title.startsWith("第")
+      ? chapter.title
+      : `第${chapterNum}章 ${chapter.title}`
+
+    // 构建 frontmatter + 正文
+    const content = [
+      "---",
+      `type: chapter`,
+      `chapter_number: ${chapterNum}`,
+      `chapter_status: draft`,
+      `title: "${fullTitle.replace(/"/g, '\\"')}"`,
+      `source: story-simulation`,
+      `framework_id: "${framework.id}"`,
+      `framework_title: "${framework.title.replace(/"/g, '\\"')}"`,
+      `created: ${dateStr}`,
+      `---`,
+      "",
+      `# ${fullTitle}`,
+      "",
+      chapter.content,
+      "",
+    ].join("\n")
+
+    await writeFileAtomic(filePath, content)
+    chapterPaths.push(filePath)
+    chapterNumOffset++
+  }
+
+  return {
+    importedCount: indices.length,
+    chapterPaths,
+    startChapter,
+    backedUpPaths,
+  }
+}

+ 132 - 0
src/lib/novel/story-simulation/framework-binding.ts

@@ -0,0 +1,132 @@
+/**
+ * AI 会话绑定
+ *
+ * 将一个 StoryFramework 绑定到目标章节数,把章节按"起承转合"节点
+ * 分配,并把绑定信息 + 框架上下文注入到 AI 写作会话中。
+ */
+
+import { createDirectory, deleteFile, readFile, writeFileAtomic } from "@/commands/fs"
+import { normalizePath } from "@/lib/path-utils"
+import type {
+  ChapterAllocation,
+  FrameworkBinding,
+  StoryFramework,
+} from "./types"
+
+const BINDING_FILE = ".qmai/simulations/bindings/active-binding.json"
+
+function bindingFilePath(projectPath: string): string {
+  return `${normalizePath(projectPath)}/${BINDING_FILE}`
+}
+
+/**
+ * 将 targetChapterCount 分配到各节点:
+ * baseChaptersPerNode = floor(target / nodeCount),余数依次分配给前面的节点。
+ */
+function allocateChapters(
+  nodes: StoryFramework["nodes"],
+  targetChapterCount: number,
+): ChapterAllocation[] {
+  const sorted = [...nodes].sort((a, b) => a.index - b.index)
+  if (sorted.length === 0) return []
+
+  const base = Math.floor(targetChapterCount / sorted.length)
+  const remainder = targetChapterCount % sorted.length
+
+  const allocations: ChapterAllocation[] = []
+  let cursor = 1
+  for (let i = 0; i < sorted.length; i++) {
+    const count = base + (i < remainder ? 1 : 0)
+    const startChapter = cursor
+    const endChapter = cursor + count - 1
+    allocations.push({
+      nodeIndex: sorted[i].index,
+      nodeTitle: sorted[i].title,
+      startChapter,
+      endChapter,
+    })
+    cursor += Math.max(count, 0)
+  }
+  return allocations
+}
+
+/** 读取当前激活的框架绑定,不存在时返回 null。 */
+export async function loadBinding(
+  projectPath: string,
+): Promise<FrameworkBinding | null> {
+  try {
+    const content = await readFile(bindingFilePath(projectPath))
+    const parsed = JSON.parse(content) as FrameworkBinding
+    if (!parsed || !parsed.frameworkId) return null
+    return parsed
+  } catch {
+    return null
+  }
+}
+
+/**
+ * 保存绑定:根据框架节点与目标章节数生成章节分配,写入绑定文件。
+ */
+export async function saveBinding(
+  projectPath: string,
+  framework: StoryFramework,
+  targetChapterCount: number,
+): Promise<FrameworkBinding> {
+  const chapterAllocation = allocateChapters(framework.nodes, targetChapterCount)
+  const binding: FrameworkBinding = {
+    frameworkId: framework.id,
+    frameworkTitle: framework.title,
+    targetChapterCount,
+    chapterAllocation,
+    boundAt: new Date().toISOString(),
+  }
+
+  // createDirectory 使用 create_dir_all,会递归创建 .qmai/simulations/bindings
+  await createDirectory(
+    `${normalizePath(projectPath)}/.qmai/simulations/bindings`,
+  )
+  await writeFileAtomic(bindingFilePath(projectPath), JSON.stringify(binding, null, 2))
+  return binding
+}
+
+/** 清除当前激活的框架绑定。 */
+export async function clearBinding(projectPath: string): Promise<void> {
+  try {
+    await deleteFile(bindingFilePath(projectPath))
+  } catch {
+    // 绑定文件可能不存在
+  }
+}
+
+/**
+ * 构建注入 AI 会话的上下文文本:
+ * 框架标题 + 目标章节数 + 章节分配表(第X-Y章 → 起承转合节点)+ 要求。
+ */
+export function buildBindingContext(
+  binding: FrameworkBinding,
+  framework: StoryFramework,
+): string {
+  const lines: string[] = []
+  lines.push("# 故事框架绑定")
+  lines.push("")
+  lines.push(`- 框架标题:${framework.title}`)
+  lines.push(`- 目标章节数:${binding.targetChapterCount}`)
+  lines.push("")
+  lines.push("## 章节分配")
+  for (const allocation of binding.chapterAllocation) {
+    const node = framework.nodes.find((n) => n.index === allocation.nodeIndex)
+    const phaseLabel = node ? `【${node.phase}】` : ""
+    const range =
+      allocation.endChapter < allocation.startChapter
+        ? "无章节分配"
+        : allocation.startChapter === allocation.endChapter
+          ? `第${allocation.startChapter}章`
+          : `第${allocation.startChapter}-${allocation.endChapter}章`
+    lines.push(`- ${range} → ${phaseLabel}${allocation.nodeTitle}`)
+  }
+  lines.push("")
+  lines.push("## 要求")
+  lines.push("- 请严格遵循上述故事框架推进剧情,按章节分配在对应节点完成相应情节。")
+  lines.push("- 保持各节点核心冲突与预期结果的连贯性。")
+  return lines.join("\n")
+}

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

@@ -0,0 +1,503 @@
+/**
+ * 故事框架持久化
+ *
+ * 将 StoryFramework 以 Markdown 文档(YAML frontmatter + 正文)的形式
+ * 持久化到项目的 .qmai/simulations 目录下,并提供加载 / 删除 / 推演结果
+ * 存取能力。
+ */
+
+import {
+  createDirectory,
+  deleteFile,
+  listDirectory,
+  readFile,
+  writeFileAtomic,
+} from "@/commands/fs"
+import { normalizePath } from "@/lib/path-utils"
+import { parseFrontmatter } from "@/lib/frontmatter"
+import type { FileNode } from "@/types/wiki"
+import type {
+  SimulationMode,
+  SimulationReport,
+  StoryDraft,
+  StoryFramework,
+  StoryNode,
+  TimelineEvent,
+} from "./types"
+import type { SerializedSimulationSnapshot } from "./simulation-serializer"
+
+const SIM_ROOT = ".qmai/simulations"
+const FRAMEWORKS_DIR = `${SIM_ROOT}/frameworks`
+const RESULTS_DIR = `${SIM_ROOT}/results`
+
+const VALID_MODES: SimulationMode[] = [
+  "event-driven",
+  "free-emergence",
+  "decision-tree",
+  "hybrid",
+]
+
+// ── 路径辅助 ──
+
+function frameworksDir(projectPath: string): string {
+  return `${normalizePath(projectPath)}/${FRAMEWORKS_DIR}`
+}
+
+function frameworkFilePath(projectPath: string, frameworkId: string): string {
+  return `${frameworksDir(projectPath)}/${frameworkId}.md`
+}
+
+function frameworkResultsDir(projectPath: string, frameworkId: string): string {
+  return `${normalizePath(projectPath)}/${RESULTS_DIR}/${frameworkId}`
+}
+
+// ── 目录初始化 ──
+
+/**
+ * 创建 .qmai/simulations/{frameworks,results,bindings} 目录。
+ * createDirectory 内部使用 create_dir_all,已存在时不会报错。
+ */
+export async function ensureSimulationDirs(projectPath: string): Promise<void> {
+  const root = `${normalizePath(projectPath)}/${SIM_ROOT}`
+  await createDirectory(`${root}/frameworks`)
+  await createDirectory(`${root}/results`)
+  await createDirectory(`${root}/bindings`)
+}
+
+// ── Markdown 互转 ──
+
+function yamlString(value: string): string {
+  const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
+  return `"${escaped}"`
+}
+
+/** 将 StoryFramework 序列化为 Markdown 文档。 */
+export function frameworkToMarkdown(framework: StoryFramework): string {
+  const lines: string[] = []
+  lines.push("---")
+  lines.push(`id: ${yamlString(framework.id)}`)
+  lines.push(`type: "story-framework"`)
+  lines.push(`title: ${yamlString(framework.title)}`)
+  if (framework.shortTitle) {
+    lines.push(`shortTitle: ${yamlString(framework.shortTitle)}`)
+  }
+  lines.push(`createdAt: ${yamlString(framework.createdAt)}`)
+  lines.push(`sourceChapters: ${framework.sourceChapters}`)
+  lines.push(`targetWords: ${framework.targetWords}`)
+  lines.push(`simulationMode: ${yamlString(framework.simulationMode)}`)
+  lines.push(`userIdea: ${yamlString(framework.userIdea ?? "")}`)
+  lines.push("---")
+  lines.push("")
+  lines.push("## 前提")
+  lines.push("")
+  lines.push(framework.premise || "(无前提)")
+  lines.push("")
+  lines.push("## 故事节点")
+  lines.push("")
+
+  const sorted = [...framework.nodes].sort((a, b) => a.index - b.index)
+  for (const node of sorted) {
+    lines.push(`### 节点 ${node.index} 【${node.phase}】 ${node.title}`)
+    lines.push(`- 核心冲突:${node.coreConflict}`)
+    lines.push(`- 涉及角色:${node.involvedCharacters.join("、")}`)
+    lines.push(`- 目标:${node.goal}`)
+    lines.push(`- 承接前因:${node.causeFromPrev}`)
+    lines.push(`- 预期结果:${node.expectedOutcome}`)
+    lines.push("")
+  }
+  return lines.join("\n")
+}
+
+function fmStr(v: string | string[] | undefined): string {
+  if (v === undefined) return ""
+  if (Array.isArray(v)) return v.join(", ")
+  return v
+}
+
+/** 提取 body 中某个二级标题(## heading)下的内容,直到下一个二级标题。 */
+function extractSection(body: string, heading: string): string {
+  const lines = body.split("\n")
+  let capturing = false
+  const captured: string[] = []
+  for (const line of lines) {
+    const isH2 = /^##\s+/.test(line)
+    if (isH2) {
+      if (capturing) break // 到达下一个 ## 章节
+      const headingText = line.replace(/^##\s+/, "").trim()
+      if (headingText === heading || headingText.startsWith(heading)) {
+        capturing = true
+      }
+      continue
+    }
+    if (capturing) captured.push(line)
+  }
+  return captured.join("\n").trim()
+}
+
+function parseNodeHeader(
+  line: string,
+  fallbackIndex: number,
+): { index: number; phase: StoryNode["phase"]; title: string } {
+  const indexMatch = line.match(/节点\s*(\d+)/)
+  const index = indexMatch ? parseInt(indexMatch[1], 10) : fallbackIndex
+  const phaseMatch = line.match(/【(起|承|转|合)】/)
+  const phase = phaseMatch ? (phaseMatch[1] as StoryNode["phase"]) : "起"
+
+  let title = ""
+  const bracketEnd = line.lastIndexOf("】")
+  if (bracketEnd >= 0) {
+    title = line.slice(bracketEnd + 1).trim()
+  }
+  if (!title) {
+    title = line.replace(/节点\s*\d+\s*【[起承转合]】/, "").trim()
+  }
+  if (!title) title = "未命名节点"
+  return { index, phase, title }
+}
+
+function parseNodeBody(block: string): {
+  coreConflict: string
+  involvedCharacters: string[]
+  goal: string
+  causeFromPrev: string
+  expectedOutcome: string
+} {
+  const result = {
+    coreConflict: "",
+    involvedCharacters: [] as string[],
+    goal: "",
+    causeFromPrev: "",
+    expectedOutcome: "",
+  }
+  for (const rawLine of block.split("\n")) {
+    const line = rawLine.trim()
+    if (!line.startsWith("-")) continue
+    const m = line.match(/^-\s*(.+?)[::]\s*(.*)$/)
+    if (!m) continue
+    const key = m[1].trim()
+    const value = m[2].trim()
+    switch (key) {
+      case "核心冲突":
+        result.coreConflict = value
+        break
+      case "涉及角色":
+        result.involvedCharacters = value
+          ? value
+              .split(/[,,、]/)
+              .map((s) => s.trim())
+              .filter(Boolean)
+          : []
+        break
+      case "目标":
+        result.goal = value
+        break
+      case "承接前因":
+        result.causeFromPrev = value
+        break
+      case "预期结果":
+        result.expectedOutcome = value
+        break
+      default:
+        break
+    }
+  }
+  return result
+}
+
+function parseNodes(nodesSection: string): StoryNode[] {
+  const nodes: StoryNode[] = []
+  const blocks = nodesSection.split(/^###\s+/m)
+  let order = 0
+  for (const block of blocks) {
+    const trimmed = block.trim()
+    if (!trimmed || !trimmed.startsWith("节点")) continue
+    order += 1
+    const lines = trimmed.split("\n")
+    const { index, phase, title } = parseNodeHeader(lines[0], order)
+    const body = lines.slice(1).join("\n")
+    const fields = parseNodeBody(body)
+    nodes.push({
+      index,
+      phase,
+      title,
+      coreConflict: fields.coreConflict,
+      involvedCharacters: fields.involvedCharacters,
+      goal: fields.goal,
+      causeFromPrev: fields.causeFromPrev,
+      expectedOutcome: fields.expectedOutcome,
+    })
+  }
+  return nodes
+}
+
+/**
+ * 将 Markdown 文档解析回 StoryFramework。
+ * 解析尽量健壮:缺失字段使用默认值,frontmatter 异常时尽量恢复。
+ */
+export function markdownToFramework(
+  content: string,
+  fallbackId?: string,
+): StoryFramework | null {
+  try {
+    const { frontmatter, body } = parseFrontmatter(content)
+    const fm = (frontmatter ?? {}) as Record<string, string | string[]>
+
+    const id = fmStr(fm.id) || fallbackId || `fw-${Date.now()}`
+    const title = fmStr(fm.title) || "未命名框架"
+    const shortTitleValue = fmStr(fm.shortTitle)
+    const shortTitle = shortTitleValue || undefined
+    const createdAt = fmStr(fm.createdAt) || new Date().toISOString()
+    const sourceChapters = parseInt(fmStr(fm.sourceChapters), 10) || 0
+    const targetWords = parseInt(fmStr(fm.targetWords), 10) || 0
+    const modeValue = fmStr(fm.simulationMode)
+    const simulationMode: SimulationMode = VALID_MODES.includes(
+      modeValue as SimulationMode,
+    )
+      ? (modeValue as SimulationMode)
+      : "hybrid"
+    const userIdeaValue = fmStr(fm.userIdea)
+    const userIdea = userIdeaValue || undefined
+
+    const premise = extractSection(body, "前提")
+    const nodesSection = extractSection(body, "故事节点")
+    const nodes = parseNodes(nodesSection)
+
+    return {
+      id,
+      title,
+      shortTitle,
+      premise,
+      targetWords,
+      simulationMode,
+      userIdea,
+      sourceChapters,
+      nodes,
+      createdAt,
+    }
+  } catch {
+    return null
+  }
+}
+
+// ── 框架 CRUD ──
+
+/** 将框架保存为 MD 文档。 */
+export async function saveFramework(
+  projectPath: string,
+  framework: StoryFramework,
+): Promise<void> {
+  await ensureSimulationDirs(projectPath)
+  const md = frameworkToMarkdown(framework)
+  await writeFileAtomic(frameworkFilePath(projectPath, framework.id), md)
+}
+
+/** 加载所有框架,按 createdAt 降序排列。 */
+export async function loadFrameworks(
+  projectPath: string,
+): Promise<StoryFramework[]> {
+  let entries: FileNode[]
+  try {
+    entries = await listDirectory(frameworksDir(projectPath))
+  } catch {
+    return []
+  }
+
+  const frameworks: StoryFramework[] = []
+  for (const entry of entries) {
+    if (entry.is_dir) continue
+    if (!entry.name.toLowerCase().endsWith(".md")) continue
+    try {
+      const content = await readFile(entry.path)
+      const id = entry.name.replace(/\.md$/i, "")
+      const fw = markdownToFramework(content, id)
+      if (fw) frameworks.push(fw)
+    } catch {
+      // 跳过无法读取的文件
+    }
+  }
+
+  frameworks.sort((a, b) => {
+    if (a.createdAt < b.createdAt) return 1
+    if (a.createdAt > b.createdAt) return -1
+    return 0
+  })
+  return frameworks
+}
+
+/** 删除框架及其关联的推演结果。 */
+export async function deleteFramework(
+  projectPath: string,
+  frameworkId: string,
+): Promise<void> {
+  try {
+    await deleteFile(frameworkFilePath(projectPath, frameworkId))
+  } catch {
+    // 框架文件可能不存在
+  }
+
+  // 删除该框架下的所有推演结果文件
+  const resultsPath = frameworkResultsDir(projectPath, frameworkId)
+  try {
+    const entries = await listDirectory(resultsPath)
+    for (const entry of entries) {
+      if (!entry.is_dir) {
+        try {
+          await deleteFile(entry.path)
+        } catch {
+          // 跳过无法删除的文件
+        }
+      }
+    }
+  } catch {
+    // 结果目录可能不存在
+  }
+}
+
+// ── 推演结果存取 ──
+
+function simulationResultToMarkdown(
+  report: SimulationReport,
+  draft?: StoryDraft,
+): string {
+  const lines: string[] = []
+  lines.push("# 推演结果")
+  lines.push("")
+  lines.push(`- 框架ID:${report.frameworkId}`)
+  lines.push(`- 仿真模式:${report.mode}`)
+  lines.push(`- 生成时间:${report.createdAt}`)
+  lines.push("")
+  lines.push("## 推荐")
+  lines.push(report.recommendation || "(无推荐)")
+  lines.push("")
+  lines.push("## 分支")
+  for (const branch of report.branches) {
+    lines.push(`### ${branch.title}`)
+    lines.push(`- 概率:${branch.probability}`)
+    lines.push(`- 摘要:${branch.summary}`)
+    if (branch.recommendation) lines.push("- 推荐:是")
+  }
+  lines.push("")
+  lines.push("## 角色分析")
+  for (const ca of report.characterAnalyses) {
+    lines.push(`### ${ca.name}`)
+    lines.push(`- 一致性评分:${ca.consistencyScore}`)
+  }
+  if (draft) {
+    lines.push("")
+    lines.push("## 草稿")
+    lines.push(`- 总字数:${draft.totalWords}`)
+    for (const ch of draft.chapters) {
+      lines.push(`### ${ch.title}(对应节点 ${ch.correspondingNode})`)
+    }
+  }
+  return lines.join("\n")
+}
+
+/**
+ * 保存推演结果,同时写入 JSON(结构化)与 MD(人类可读)。
+ * @returns resultId
+ */
+export async function saveSimulationResult(
+  projectPath: string,
+  frameworkId: string,
+  report: SimulationReport,
+  draft?: StoryDraft,
+  timelineEvents?: TimelineEvent[],
+  agentSnapshot?: SerializedSimulationSnapshot,
+): Promise<string> {
+  await ensureSimulationDirs(projectPath)
+  const resultId = `result-${Date.now()}`
+  const dir = frameworkResultsDir(projectPath, frameworkId)
+  await createDirectory(dir)
+
+  const payload = {
+    report,
+    draft: draft ?? null,
+    timelineEvents: timelineEvents ?? [],
+    agentSnapshot: agentSnapshot ?? null,
+  }
+  await writeFileAtomic(`${dir}/${resultId}.json`, JSON.stringify(payload, null, 2))
+  await writeFileAtomic(
+    `${dir}/${resultId}.md`,
+    simulationResultToMarkdown(report, draft),
+  )
+  return resultId
+}
+
+/** 删除指定的推演结果。 */
+export async function deleteSimulationResult(
+  projectPath: string,
+  frameworkId: string,
+  resultId: string,
+): Promise<void> {
+  const dir = frameworkResultsDir(projectPath, frameworkId)
+  try {
+    await deleteFile(`${dir}/${resultId}.json`)
+  } catch {
+    // 文件可能不存在
+  }
+  try {
+    await deleteFile(`${dir}/${resultId}.md`)
+  } catch {
+    // 文件可能不存在
+  }
+}
+
+/** 加载框架的所有推演结果,按 report.createdAt 降序排列。 */
+export async function loadSimulationResults(
+  projectPath: string,
+  frameworkId: string,
+): Promise<{
+  id: string
+  report: SimulationReport
+  draft?: StoryDraft | null
+  timelineEvents?: TimelineEvent[]
+  agentSnapshot?: SerializedSimulationSnapshot | null
+}[]> {
+  const dir = frameworkResultsDir(projectPath, frameworkId)
+  let entries: FileNode[]
+  try {
+    entries = await listDirectory(dir)
+  } catch {
+    return []
+  }
+
+  const results: {
+    id: string
+    report: SimulationReport
+    draft?: StoryDraft | null
+    timelineEvents?: TimelineEvent[]
+    agentSnapshot?: SerializedSimulationSnapshot | null
+  }[] = []
+  for (const entry of entries) {
+    if (entry.is_dir) continue
+    if (!entry.name.toLowerCase().endsWith(".json")) continue
+    try {
+      const content = await readFile(entry.path)
+      const parsed = JSON.parse(content) as {
+        report: SimulationReport
+        draft?: StoryDraft | null
+        timelineEvents?: TimelineEvent[]
+        agentSnapshot?: SerializedSimulationSnapshot | null
+      }
+      if (parsed && parsed.report) {
+        results.push({
+          id: entry.name.replace(/\.json$/i, ""),
+          report: parsed.report,
+          draft: parsed.draft ?? null,
+          timelineEvents: parsed.timelineEvents ?? [],
+          agentSnapshot: parsed.agentSnapshot ?? null,
+        })
+      }
+    } catch {
+      // 跳过无法解析的文件
+    }
+  }
+
+  results.sort((a, b) => {
+    if (a.report.createdAt < b.report.createdAt) return 1
+    if (a.report.createdAt > b.report.createdAt) return -1
+    return 0
+  })
+  return results
+}

+ 90 - 0
src/lib/novel/story-simulation/interview-export.ts

@@ -0,0 +1,90 @@
+/**
+ * 角色采访对话导出
+ * 将 AgentChatMessage[] 导出为 Markdown 文件,保存到项目目录。
+ */
+
+import { createDirectory, writeFileAtomic } from "@/commands/fs"
+import { normalizePath } from "@/lib/path-utils"
+import type { AgentChatMessage } from "./types"
+import type { SavedInterview } from "./interview-store"
+
+const SIM_ROOT = ".qmai/simulations"
+const INTERVIEWS_DIR = `${SIM_ROOT}/interviews`
+
+function interviewsDir(projectPath: string): string {
+  return `${normalizePath(projectPath)}/${INTERVIEWS_DIR}`
+}
+
+function interviewFilePath(projectPath: string, agentName: string, timestamp: string): string {
+  const safeName = agentName.replace(/[\\/:*?"<>|]/g, "_")
+  const safeTs = timestamp.replace(/[:.]/g, "-")
+  return `${interviewsDir(projectPath)}/${safeName}_${safeTs}.md`
+}
+
+/**
+ * 将对话记录导出为 Markdown 文件。
+ * @returns 导出的文件路径
+ */
+export async function exportInterview(
+  projectPath: string,
+  agentNameOrInterview: string | SavedInterview,
+  messages?: AgentChatMessage[],
+): Promise<string> {
+  let agentName: string
+  let msgs: AgentChatMessage[]
+  let frameworkTitle: string | undefined
+  let createdAt: string | undefined
+
+  if (typeof agentNameOrInterview === "string") {
+    agentName = agentNameOrInterview
+    msgs = messages || []
+  } else {
+    agentName = agentNameOrInterview.agentName
+    msgs = agentNameOrInterview.session.messages
+    frameworkTitle = agentNameOrInterview.frameworkTitle
+    createdAt = agentNameOrInterview.createdAt
+  }
+
+  const dir = interviewsDir(projectPath)
+  await createDirectory(dir)
+
+  const now = new Date()
+  const timestamp = now.toISOString()
+  const filePath = interviewFilePath(projectPath, agentName, timestamp)
+
+  const lines: string[] = []
+  lines.push(`# 与「${agentName}」的对话`)
+  lines.push("")
+  if (frameworkTitle) {
+    lines.push(`> 故事框架:${frameworkTitle}`)
+  }
+  if (createdAt) {
+    lines.push(`> 创建时间:${new Date(createdAt).toLocaleString("zh-CN")}`)
+  }
+  lines.push(`> 导出时间:${now.toLocaleString("zh-CN")}`)
+  lines.push(`> 消息数量:${msgs.length}`)
+  lines.push("")
+  lines.push("---")
+  lines.push("")
+
+  for (const msg of msgs) {
+    const time = new Date(msg.timestamp).toLocaleTimeString("zh-CN", {
+      hour: "2-digit",
+      minute: "2-digit",
+    })
+    if (msg.role === "user") {
+      lines.push(`**[${time}] 采访者:**`)
+    } else {
+      lines.push(`**[${time}] ${msg.agentName || agentName}:**`)
+    }
+    lines.push("")
+    lines.push(msg.content.trim())
+    lines.push("")
+    lines.push("---")
+    lines.push("")
+  }
+
+  const content = lines.join("\n")
+  await writeFileAtomic(filePath, content)
+  return filePath
+}

+ 126 - 0
src/lib/novel/story-simulation/interview-store.ts

@@ -0,0 +1,126 @@
+/**
+ * 采访对话持久化
+ *
+ * 将 Agent 采访对话保存到项目的 .qmai/simulations/interviews/ 目录下,
+ * 方便后续回顾和查看。
+ */
+
+import { createDirectory, writeFileAtomic, listDirectory, deleteFile, readFile } from "@/commands/fs"
+import { normalizePath } from "@/lib/path-utils"
+import type { FileNode } from "@/types/wiki"
+import type { AgentChatSession } from "./types"
+import type { SerializedSimulationSnapshot } from "./simulation-serializer"
+
+const INTERVIEWS_DIR = ".qmai/simulations/interviews"
+
+function interviewsDir(projectPath: string): string {
+  return `${normalizePath(projectPath)}/${INTERVIEWS_DIR}`
+}
+
+function interviewFilePath(projectPath: string, interviewId: string): string {
+  return `${interviewsDir(projectPath)}/${interviewId}.json`
+}
+
+export interface SavedInterview {
+  id: string
+  agentName: string
+  frameworkId?: string
+  frameworkTitle?: string
+  createdAt: string
+  updatedAt: string
+  session: AgentChatSession
+  /** 推演时的 agent 快照,用于继续对话时恢复角色状态 */
+  agentSnapshot?: SerializedSimulationSnapshot
+}
+
+/**
+ * 保存采访对话到项目。
+ * @returns interviewId
+ */
+export async function saveInterview(
+  projectPath: string,
+  session: AgentChatSession,
+  options?: {
+    frameworkId?: string
+    frameworkTitle?: string
+    existingId?: string
+    agentSnapshot?: SerializedSimulationSnapshot
+  },
+): Promise<string> {
+  const dir = interviewsDir(projectPath)
+  await createDirectory(dir)
+
+  const now = new Date().toISOString()
+  const id = options?.existingId ?? `interview-${Date.now()}`
+  const payload: SavedInterview = {
+    id,
+    agentName: session.agentName,
+    frameworkId: options?.frameworkId,
+    frameworkTitle: options?.frameworkTitle,
+    createdAt: now,
+    updatedAt: now,
+    session,
+    agentSnapshot: options?.agentSnapshot,
+  }
+
+  // 如果已存在,保留原始 createdAt
+  try {
+    const existing = await readFile(interviewFilePath(projectPath, id))
+    const parsed = JSON.parse(existing) as SavedInterview
+    payload.createdAt = parsed.createdAt
+  } catch {
+    // 新文件,使用当前时间
+  }
+
+  await writeFileAtomic(interviewFilePath(projectPath, id), JSON.stringify(payload, null, 2))
+  return id
+}
+
+/**
+ * 加载所有采访对话,按 updatedAt 降序排列。
+ */
+export async function loadInterviews(projectPath: string): Promise<SavedInterview[]> {
+  const dir = interviewsDir(projectPath)
+  let entries: FileNode[]
+  try {
+    entries = await listDirectory(dir)
+  } catch {
+    return []
+  }
+
+  const interviews: SavedInterview[] = []
+  for (const entry of entries) {
+    if (entry.is_dir) continue
+    if (!entry.name.toLowerCase().endsWith(".json")) continue
+    try {
+      const content = await readFile(entry.path)
+      const parsed = JSON.parse(content) as SavedInterview
+      if (parsed && parsed.session) {
+        interviews.push(parsed)
+      }
+    } catch {
+      // 跳过无法读取的文件
+    }
+  }
+
+  interviews.sort((a, b) => {
+    if (a.updatedAt < b.updatedAt) return 1
+    if (a.updatedAt > b.updatedAt) return -1
+    return 0
+  })
+  return interviews
+}
+
+/**
+ * 删除指定采访对话。
+ */
+export async function deleteInterview(
+  projectPath: string,
+  interviewId: string,
+): Promise<void> {
+  try {
+    await deleteFile(interviewFilePath(projectPath, interviewId))
+  } catch {
+    // 文件可能不存在
+  }
+}

+ 192 - 0
src/lib/novel/story-simulation/report-export.ts

@@ -0,0 +1,192 @@
+/**
+ * 推演报告导出
+ * 将 SimulationReport 导出为完整的 Markdown 文件。
+ */
+
+import { createDirectory, writeFileAtomic } from "@/commands/fs"
+import { normalizePath } from "@/lib/path-utils"
+import type {
+  SimulationReport,
+  StoryFramework,
+  TimelineEvent,
+} from "./types"
+
+const SIM_ROOT = ".qmai/simulations"
+const EXPORTS_DIR = `${SIM_ROOT}/exports`
+
+function exportsDir(projectPath: string): string {
+  return `${normalizePath(projectPath)}/${EXPORTS_DIR}`
+}
+
+function reportFilePath(
+  projectPath: string,
+  frameworkTitle: string,
+  timestamp: string,
+): string {
+  const safeTitle = frameworkTitle
+    .replace(/[\\/:*?"<>|]/g, "_")
+    .slice(0, 30)
+  const safeTs = timestamp.replace(/[:.]/g, "-")
+  return `${exportsDir(projectPath)}/推演报告_${safeTitle}_${safeTs}.md`
+}
+
+function probabilityLabel(p: string): string {
+  switch (p) {
+    case "high":
+      return "高"
+    case "medium":
+      return "中"
+    case "low":
+      return "低"
+    default:
+      return p
+  }
+}
+
+function actionTypeLabel(type: string): string {
+  const map: Record<string, string> = {
+    evaluate: "评价",
+    pushPlot: "推动事态",
+    observe: "观察",
+    react: "反应",
+    speak: "对话",
+    ally: "示好",
+    confront: "对抗",
+    conceal: "隐瞒",
+    investigate: "调查",
+    act: "行动",
+    decide: "决策",
+    conflict: "冲突",
+    cooperate: "合作",
+    withhold: "隐瞒",
+  }
+  return map[type] || type
+}
+
+/**
+ * 导出推演报告为 Markdown。
+ * @returns 导出的文件路径
+ */
+export async function exportReport(
+  projectPath: string,
+  framework: StoryFramework,
+  report: SimulationReport,
+  timelineEvents?: TimelineEvent[],
+): Promise<string> {
+  const dir = exportsDir(projectPath)
+  await createDirectory(dir)
+
+  const now = new Date()
+  const timestamp = now.toISOString()
+  const filePath = reportFilePath(projectPath, framework.shortTitle || framework.title, timestamp)
+
+  const lines: string[] = []
+  lines.push(`# 故事推演报告:${framework.title}`)
+  lines.push("")
+  lines.push(`> 推演时间:${now.toLocaleString("zh-CN")}`)
+  lines.push(`> 仿真模式:${framework.simulationMode}`)
+  lines.push(`> 目标字数:${framework.targetWords}`)
+  if (framework.shortTitle) {
+    lines.push(`> 简短标题:${framework.shortTitle}`)
+  }
+  lines.push("")
+  lines.push("---")
+  lines.push("")
+
+  // 故事前提
+  lines.push("## 故事前提")
+  lines.push("")
+  lines.push(framework.premise || "(无)")
+  lines.push("")
+
+  // 综合推荐
+  if (report.recommendation) {
+    lines.push("## 综合推荐")
+    lines.push("")
+    lines.push(report.recommendation)
+    lines.push("")
+  }
+
+  // 剧情事件时间线
+  if (timelineEvents && timelineEvents.length > 0) {
+    lines.push("## 剧情事件时间线")
+    lines.push("")
+    const byNode = new Map<number, TimelineEvent[]>()
+    for (const ev of timelineEvents) {
+      const arr = byNode.get(ev.nodeIndex) || []
+      arr.push(ev)
+      byNode.set(ev.nodeIndex, arr)
+    }
+    const nodeIndices = Array.from(byNode.keys()).sort((a, b) => a - b)
+    for (const ni of nodeIndices) {
+      const node = framework.nodes.find(n => n.index === ni)
+      const nodeEvents = byNode.get(ni) || []
+      lines.push(`### 节点${ni + 1}【${node?.phase || ""}】${node?.title || ""}`)
+      lines.push("")
+      for (const ev of nodeEvents) {
+        const targetStr = ev.targetName ? ` → ${ev.targetName}` : ""
+        lines.push(`- **R${ev.round + 1}** ${ev.actorName} ${actionTypeLabel(ev.actionType)}${targetStr}:${ev.content}`)
+      }
+      lines.push("")
+    }
+  }
+
+  // 角色分析
+  if (report.characterAnalyses.length > 0) {
+    lines.push("## 角色行为分析")
+    lines.push("")
+    for (const char of report.characterAnalyses) {
+      lines.push(`### ${char.name}`)
+      lines.push("")
+      lines.push(`- 一致性评分:${char.consistencyScore}/100`)
+      if (char.behaviors.length > 0) {
+        lines.push("")
+        lines.push("**关键行为:**")
+        for (const b of char.behaviors) {
+          lines.push(`- [${b.node}] ${b.action} — 动机:${b.motivation}`)
+        }
+      }
+      if (char.stateChanges.length > 0) {
+        lines.push("")
+        lines.push("**状态变化:**")
+        for (const s of char.stateChanges) {
+          lines.push(`- ${s}`)
+        }
+      }
+      lines.push("")
+    }
+  }
+
+  // 故事走向分支
+  if (report.branches.length > 0) {
+    lines.push("## 故事走向分支")
+    lines.push("")
+    for (let i = 0; i < report.branches.length; i++) {
+      const branch = report.branches[i]
+      lines.push(`### 分支${i + 1}:${branch.title}`)
+      lines.push("")
+      lines.push(`- 概率:${probabilityLabel(branch.probability)}`)
+      if (branch.recommendation) {
+        lines.push(`- **推荐分支**`)
+      }
+      lines.push(`- 摘要:${branch.summary}`)
+      if (branch.keyEvents.length > 0) {
+        lines.push("- 关键事件:")
+        for (const e of branch.keyEvents) {
+          lines.push(`  - ${e}`)
+        }
+      }
+      if (branch.pros) {
+        lines.push(`- 优势:${branch.pros}`)
+      }
+      if (branch.cons) {
+        lines.push(`- 风险:${branch.cons}`)
+      }
+      lines.push("")
+    }
+  }
+
+  const content = lines.join("\n")
+  await writeFileAtomic(filePath, content)
+  return filePath
+}

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

@@ -0,0 +1,1117 @@
+import type { ChatMessage } from "@/lib/llm-client"
+import { streamChat } from "@/lib/llm-client"
+import type { LlmConfig } from "@/stores/wiki-store"
+import {
+  buildAgentContext,
+  getVisibleEvents,
+} from "@/lib/novel/story-simulation/agent-profile-builder"
+import type {
+  ActionVisibility,
+  AgentAction,
+  AgentActionType,
+  EventImpact,
+  ExtractionResult,
+  NovelAgent,
+  SimulationEvent,
+  SimulationInput,
+  SimulationState,
+  StoryNode,
+  TimelineEvent,
+} from "@/lib/novel/story-simulation/types"
+import { calcMaxRoundsPerNode, getModeConfig } from "@/lib/novel/story-simulation/types"
+import type { ModeConfig } from "@/lib/novel/story-simulation/types"
+
+// ── 对外接口 ──
+
+export interface SimulationCallbacks {
+  onEvent: (event: SimulationEvent) => void
+  onProgress: (progress: number, label: string) => void
+  onComplete: (events: SimulationEvent[]) => void
+  onError: (error: Error) => void
+  /** 新引擎时间线事件回调 */
+  onTimelineEvent?: (event: TimelineEvent) => void
+}
+
+// ── 常量 ──
+
+const MAX_ROUNDS_PER_NODE_FALLBACK = 3
+const REACT_CHAIN_LIMIT = 2
+
+// ── 内部辅助:将 streamChat 的流式回调收拢为一个完整字符串 ──
+
+async function collectStream(
+  config: LlmConfig,
+  messages: ChatMessage[],
+  signal?: AbortSignal,
+): Promise<string> {
+  let result = ""
+  let streamError: Error | null = null
+
+  await streamChat(
+    config,
+    messages,
+    {
+      onToken: (token) => {
+        result += token
+      },
+      onDone: () => {},
+      onError: (err) => {
+        streamError = err
+      },
+    },
+    signal,
+  )
+
+  if (streamError) throw streamError
+  return result
+}
+
+// ── 内部辅助:Agent 深拷贝(避免事件间共享可变状态) ──
+
+function cloneAgent(agent: NovelAgent): NovelAgent {
+  return {
+    ...agent,
+    knownFacts: new Set(agent.knownFacts),
+    relationships: new Map(
+      Array.from(agent.relationships.entries()).map(([k, v]) => [
+        k,
+        { ...v },
+      ]),
+    ),
+    memory: {
+      observedEvents: [...agent.memory.observedEvents],
+      knownSecrets: new Set(agent.memory.knownSecrets),
+      sentiments: new Map(agent.memory.sentiments),
+      recentDecisions: [...agent.memory.recentDecisions],
+    },
+    knowledgeScope: [...agent.knowledgeScope],
+    personality: [...agent.personality],
+  }
+}
+
+function cloneAgentsToMap(agents: NovelAgent[]): Map<string, NovelAgent> {
+  const map = new Map<string, NovelAgent>()
+  for (const a of agents) {
+    map.set(a.characterId, cloneAgent(a))
+  }
+  return map
+}
+
+// ── 内部辅助:生成唯一 ID ──
+
+let eventCounter = 0
+function nextEventId(): string {
+  eventCounter++
+  return `evt_${Date.now()}_${eventCounter}`
+}
+
+// ── 内部辅助:构建 Agent 系统提示词 ──
+
+function buildSystemPrompt(agent: NovelAgent): string {
+  const personalityLine =
+    agent.personality.length > 0
+      ? `你的性格关键词:${agent.personality.join("、")}`
+      : ""
+  const styleLine = agent.speakingStyle
+    ? `你的说话风格:${agent.speakingStyle}`
+    : ""
+
+  return [
+    `你正在扮演小说中的真实角色「${agent.name}」。你不是AI助手,你就是这个角色本人。`,
+    "",
+    "【核心原则 - 必须严格遵守】",
+    "1. 你是小说中的真实角色,只能基于你知道的信息行动,绝不能使用你不知道的信息。",
+    "2. 绝对禁止全知视角:你不知道其他角色的内心想法,不知道没有发生在你面前的事情,不知道剧情走向。",
+    "3. 严格遵循你的性格特征、说话风格和行为逻辑,不要跳出角色。",
+    "4. 你的每个行为都应该有合理的动机,符合角色设定。",
+    "",
+    personalityLine,
+    styleLine,
+    "",
+    "【行为类型说明】你只能选择以下一种行为类型:",
+    "- evaluate:评价某人或某事,表达你的看法和判断",
+    "- pushPlot:主动采取推动剧情发展的关键行动",
+    "- observe:观察周围环境、人物或事态(不改变现状,只是获取信息)",
+    "- react:对其他角色刚做出的行为做出即时反应",
+    "- speak:与其他角色对话(说出台词)",
+    "- ally:寻求结盟、合作、示好",
+    "- confront:对抗、质疑、挑衅",
+    "- conceal:隐瞒信息、假装不知道、掩饰真实想法",
+    "- investigate:调查、探索、打听消息",
+    "",
+    "【输出格式】你必须输出一个严格的JSON对象,不要输出任何其他文字,不要使用markdown代码块:",
+    "{",
+    '  "type": "行为类型(从上面列表选一个)",',
+    '  "content": "行为的具体内容/说的话/内心想法",',
+    '  "target": "目标角色名(可选,没有目标就不填)",',
+    '  "visibility": "all(所有人可见) 或 target_only(仅目标可见) 或 self(仅自己可见/内心活动)",',
+    '  "motivation": "你为什么做出这个行为的内心动机",',
+    '  "plot_push": "这个行为如何推动剧情向节点目标发展"',
+    "}",
+    "",
+    "【可见性规则】",
+    "- 公开的言行(speak/ally/confront/pushPlot的公开部分)用 all",
+    "- 私下对话(speak带target)用 target_only",
+    "- 内心想法(evaluate/observe的心理活动/conceal)用 self",
+    "",
+    "只输出JSON对象,不要输出任何其他文字。",
+  ]
+    .filter((line) => line !== null && line !== undefined)
+    .join("\n")
+}
+
+// ── 内部辅助:构建用户消息(上下文 + 指令) ──
+
+function buildUserMessage(
+  context: string,
+  node: StoryNode,
+  agentName: string,
+  injectionEvent?: string,
+  modeHint?: string,
+): string {
+  const parts: string[] = [context]
+  if (modeHint) {
+    parts.push("")
+    parts.push("【行为倾向】")
+    parts.push(modeHint)
+  }
+  if (injectionEvent) {
+    parts.push("")
+    parts.push("【突发事件】")
+    parts.push(injectionEvent)
+  }
+  parts.push("")
+  parts.push(
+    `当前是节点「${node.title}」,节点目标是:${node.goal}。请根据以上信息,以「${agentName}」的视角决定你接下来要做的一个行为,并严格按JSON格式输出。`,
+  )
+  return parts.join("\n")
+}
+
+// ── 内部辅助:从 LLM 文本中提取 JSON ──
+
+function extractJson(text: string): string | null {
+  const trimmed = text.trim()
+
+  // 直接解析
+  try {
+    JSON.parse(trimmed)
+    return trimmed
+  } catch {
+    // 继续
+  }
+
+  // 从 markdown 代码块中提取
+  const codeBlockMatch = /```(?:json)?\s*([\s\S]*?)```/.exec(trimmed)
+  if (codeBlockMatch) {
+    const candidate = codeBlockMatch[1].trim()
+    try {
+      JSON.parse(candidate)
+      return candidate
+    } catch {
+      // 继续
+    }
+  }
+
+  // 从文本中查找第一个 JSON 对象
+  const objMatch = /\{[\s\S]*\}/.exec(trimmed)
+  if (objMatch) {
+    const candidate = objMatch[0]
+    try {
+      JSON.parse(candidate)
+      return candidate
+    } catch {
+      // 继续
+    }
+  }
+
+  return null
+}
+
+// ── 内部辅助:验证行为类型是否合法 ──
+
+const VALID_ACTION_TYPES: AgentActionType[] = [
+  "evaluate",
+  "pushPlot",
+  "observe",
+  "react",
+  "speak",
+  "ally",
+  "confront",
+  "conceal",
+  "investigate",
+]
+
+function isValidActionType(t: string): t is AgentActionType {
+  return (VALID_ACTION_TYPES as string[]).includes(t)
+}
+
+function isValidVisibility(v: string): v is ActionVisibility {
+  return v === "all" || v === "target_only" || v === "self"
+}
+
+// ── 内部辅助:解析 LLM 输出为行为(健壮:失败时作为 observe 处理) ──
+
+interface ParsedAction {
+  action: AgentAction
+  motivation: string
+  plotPush: string
+  visibility: ActionVisibility
+}
+
+function parseAgentAction(raw: string): ParsedAction {
+  const fallback: ParsedAction = {
+    action: {
+      type: "observe",
+      content: raw.trim().slice(0, 200) || "沉默地观察周围",
+      visibility: "self",
+    },
+    motivation: "",
+    plotPush: "",
+    visibility: "self",
+  }
+
+  const jsonText = extractJson(raw)
+  if (!jsonText) {
+    return fallback
+  }
+
+  try {
+    const data = JSON.parse(jsonText) as Record<string, unknown>
+    const typeRaw = String(data.type ?? "observe").toLowerCase()
+    const type: AgentActionType = isValidActionType(typeRaw)
+      ? typeRaw
+      : "observe"
+    const content = String(data.content ?? "").trim() || fallback.action.content
+    const target =
+      data.target !== undefined && data.target !== null && data.target !== ""
+        ? String(data.target)
+        : undefined
+    const visRaw = String(data.visibility ?? "all").toLowerCase()
+    const visibility: ActionVisibility = isValidVisibility(visRaw)
+      ? visRaw
+      : type === "speak" && target
+        ? "target_only"
+        : type === "evaluate" || type === "observe" || type === "conceal"
+          ? "self"
+          : "all"
+    const motivation = String(data.motivation ?? "").trim()
+    const plotPush = String(data.plot_push ?? "").trim()
+
+    // 根据行为类型推断默认可见性(LLM未明确指定时)
+    let finalVisibility = visibility
+    if (!data.visibility) {
+      if (type === "evaluate" || type === "observe" || type === "conceal") {
+        finalVisibility = "self"
+      } else if ((type === "speak" || type === "ally" || type === "confront") && target) {
+        finalVisibility = "target_only"
+      } else {
+        finalVisibility = "all"
+      }
+    }
+
+    return {
+      action: {
+        type,
+        content,
+        target,
+        visibility: finalVisibility,
+        motivation: motivation || undefined,
+        plot_push: plotPush || undefined,
+      },
+      motivation,
+      plotPush,
+      visibility: finalVisibility,
+    }
+  } catch {
+    return fallback
+  }
+}
+
+// ── 内部辅助:根据名字或 ID 查找目标 Agent ──
+
+function resolveTarget(
+  targetName: string | undefined,
+  agents: Map<string, NovelAgent>,
+): NovelAgent | undefined {
+  if (!targetName) return undefined
+  for (const agent of agents.values()) {
+    if (agent.name === targetName || agent.characterId === targetName) {
+      return agent
+    }
+  }
+  return undefined
+}
+
+// ── 内部辅助:根据可见性确定 observableBy 列表 ──
+
+function determineObservableBy(
+  actor: NovelAgent,
+  target: NovelAgent | undefined,
+  visibility: ActionVisibility,
+  allAgents: Map<string, NovelAgent>,
+): string[] {
+  switch (visibility) {
+    case "self":
+      return [actor.characterId]
+    case "target_only":
+      if (target) {
+        return [actor.characterId, target.characterId]
+      }
+      return [actor.characterId]
+    case "all":
+    default: {
+      const ids: string[] = []
+      for (const id of allAgents.keys()) {
+        ids.push(id)
+      }
+      return ids
+    }
+  }
+}
+
+// ── 内部辅助:计算事件对角色的影响 ──
+
+function computeImpacts(
+  actor: NovelAgent,
+  parsed: ParsedAction,
+  target: NovelAgent | undefined,
+): EventImpact[] {
+  const impacts: EventImpact[] = []
+  const { action } = parsed
+
+  // 对目标的情感影响
+  if (target) {
+    switch (action.type) {
+      case "ally":
+        impacts.push({
+          characterId: target.characterId,
+          type: "relationship",
+          detail: `${actor.name}向${target.name}示好结盟,好感度上升`,
+        })
+        break
+      case "confront":
+        impacts.push({
+          characterId: target.characterId,
+          type: "relationship",
+          detail: `${actor.name}与${target.name}对抗,好感度下降`,
+        })
+        break
+      case "speak":
+        impacts.push({
+          characterId: target.characterId,
+          type: "knowledge",
+          detail: `${target.name}听到了${actor.name}说的话`,
+        })
+        break
+      case "react":
+        impacts.push({
+          characterId: target.characterId,
+          type: "sentiment",
+          detail: `${actor.name}对${target.name}的行为做出了反应`,
+        })
+        break
+    }
+  }
+
+  // 对自己的影响
+  switch (action.type) {
+    case "investigate":
+    case "observe":
+      impacts.push({
+        characterId: actor.characterId,
+        type: "knowledge",
+        detail: `${actor.name}获取了新信息`,
+      })
+      break
+    case "conceal":
+      impacts.push({
+        characterId: actor.characterId,
+        type: "knowledge",
+        detail: `${actor.name}隐藏了某些信息`,
+      })
+      break
+    case "evaluate":
+      impacts.push({
+        characterId: actor.characterId,
+        type: "sentiment",
+        detail: `${actor.name}形成了看法/评价`,
+      })
+      break
+  }
+
+  // 公开事件对所有人产生知识影响
+  if (action.visibility === "all") {
+    impacts.push({
+      characterId: "__all__",
+      type: "knowledge",
+      detail: `所有人都观察到了${actor.name}的公开行为`,
+    })
+  }
+
+  return impacts
+}
+
+// ── 内部辅助:将事件应用到 Agent 记忆 ──
+
+function applyEventToMemory(
+  agent: NovelAgent,
+  event: TimelineEvent,
+): void {
+  agent.memory.observedEvents.push(event.id)
+
+  // 处理影响
+  for (const impact of event.impacts) {
+    if (impact.characterId === "__all__" || impact.characterId === agent.characterId) {
+      if (impact.type === "knowledge") {
+        // 知识类影响:加入到 knowledgeScope
+        if (!agent.knowledgeScope.includes(impact.detail)) {
+          agent.knowledgeScope.push(impact.detail)
+        }
+      } else if (impact.type === "sentiment") {
+        // 情感变化
+        agent.emotionalState = impact.detail.includes("正面") ? "positive" : agent.emotionalState
+      } else if (impact.type === "relationship" && event.targetId) {
+        // 关系变化
+        const current = agent.memory.sentiments.get(event.targetId) ?? 0
+        let delta = 0
+        if (impact.detail.includes("上升") || impact.detail.includes("结盟") || impact.detail.includes("示好")) {
+          delta = 10
+        } else if (impact.detail.includes("下降") || impact.detail.includes("对抗") || impact.detail.includes("冲突")) {
+          delta = -10
+        }
+        agent.memory.sentiments.set(
+          event.targetId,
+          Math.max(-100, Math.min(100, current + delta)),
+        )
+        // 同步到旧 relationships 字段以兼容
+        const oldRel = agent.relationships.get(event.targetId)
+        if (oldRel) {
+          oldRel.sentiment = Math.max(-100, Math.min(100, current + delta))
+          oldRel.relationType = delta > 0 ? "ally" : delta < 0 ? "hostile" : oldRel.relationType
+        }
+      }
+    }
+  }
+
+  // 记录最近决策(仅对行为发起者)
+  if (event.actorId === agent.characterId) {
+    agent.memory.recentDecisions.push(
+      `[R${event.round + 1}] ${event.actionType}: ${event.content.slice(0, 50)}`,
+    )
+    if (agent.memory.recentDecisions.length > 20) {
+      agent.memory.recentDecisions = agent.memory.recentDecisions.slice(-20)
+    }
+  }
+}
+
+// ── 内部辅助:创建时间线事件 ──
+
+function createTimelineEvent(
+  actor: NovelAgent,
+  parsed: ParsedAction,
+  target: NovelAgent | undefined,
+  round: number,
+  nodeIndex: number,
+  allAgents: Map<string, NovelAgent>,
+): TimelineEvent {
+  const observableBy = determineObservableBy(
+    actor,
+    target,
+    parsed.visibility,
+    allAgents,
+  )
+  const impacts = computeImpacts(actor, parsed, target)
+
+  return {
+    id: nextEventId(),
+    round,
+    nodeIndex,
+    actorId: actor.characterId,
+    actorName: actor.name,
+    actionType: parsed.action.type,
+    content: parsed.action.content,
+    targetId: target?.characterId,
+    targetName: target?.name,
+    observableBy,
+    impacts,
+    timestamp: new Date().toISOString(),
+  }
+}
+
+// ── 内部辅助:将 TimelineEvent 转换为旧 SimulationEvent(兼容报告生成) ──
+
+function timelineEventToSimulationEvent(
+  tlEvent: TimelineEvent,
+  agent: NovelAgent,
+  parsed: ParsedAction,
+  node: StoryNode,
+  round: number,
+): SimulationEvent {
+  const stateChanges: string[] = []
+  if (parsed.motivation) {
+    stateChanges.push(`动机:${parsed.motivation}`)
+  }
+  if (parsed.plotPush) {
+    stateChanges.push(`剧情推动:${parsed.plotPush}`)
+  }
+  for (const impact of tlEvent.impacts) {
+    if (impact.characterId !== "__all__") {
+      stateChanges.push(impact.detail)
+    }
+  }
+
+  // 将新行为类型映射为旧 AgentAction 结构(扁平接口,直接赋值)
+  const action: AgentAction = {
+    type: parsed.action.type,
+    content: parsed.action.content,
+    target: parsed.action.target,
+    visibility: parsed.action.visibility,
+    motivation: parsed.motivation,
+    plot_push: parsed.plotPush,
+  }
+
+  return {
+    type: "agent-action",
+    agent: cloneAgent(agent),
+    action,
+    round,
+    node,
+    stateChanges,
+    timestamp: tlEvent.timestamp,
+    timelineEvent: tlEvent,
+  }
+}
+
+// ── 内部辅助:为旧 SimulationEvent 生成行为描述 ──
+
+function formatActionDescription(action: AgentAction, agentName: string): string {
+  const { type, content, target } = action
+  switch (type) {
+    case "speak":
+      return target
+        ? `${agentName} 对 ${target} 说:「${content}」`
+        : `${agentName} 说:「${content}」`
+    case "evaluate":
+      return `${agentName} 心中评价:${content}`
+    case "pushPlot":
+      return `${agentName} 采取行动:${content}`
+    case "observe":
+      return `${agentName} 观察到:${content}`
+    case "react":
+      return target
+        ? `${agentName} 对 ${target} 的反应:${content}`
+        : `${agentName} 做出反应:${content}`
+    case "ally":
+      return target
+        ? `${agentName} 向 ${target} 示好结盟:${content}`
+        : `${agentName} 寻求合作:${content}`
+    case "confront":
+      return target
+        ? `${agentName} 与 ${target} 对抗:${content}`
+        : `${agentName} 采取对抗姿态:${content}`
+    case "conceal":
+      return `${agentName} 隐瞒了内心想法:${content}`
+    case "investigate":
+      return `${agentName} 调查:${content}`
+    case "act":
+      return `${agentName} 行动:${content}`
+    case "decide":
+      return `${agentName} 做出决定:${content}`
+    case "conflict":
+      return target
+        ? `${agentName} 与 ${target} 发生冲突:${content}`
+        : `${agentName} 冲突:${content}`
+    case "cooperate":
+      return target
+        ? `${agentName} 与 ${target} 合作:${content}`
+        : `${agentName} 合作:${content}`
+    case "withhold":
+      return `${agentName} 隐瞒信息:${content}`
+    default:
+      return `${agentName}:${content}`
+  }
+}
+
+// ── 内部辅助:检查节点是否达成目标(简单启发式) ──
+
+const RANDOM_EVENTS = [
+  "一阵异样的风声掠过,似乎预示着某种变故即将到来。",
+  "远处传来模糊的响动,所有角色都感到了一丝不安。",
+  "天色骤变,云层翻涌,仿佛有什么大事正在酝酿。",
+  "一个不起眼的线索被发现,可能改变所有人的判断。",
+  "时间流逝比预想的更快,紧迫感在角色间蔓延。",
+  "一个意外来客出现在众人视野中。",
+  "一段被遗忘的记忆突然浮现,影响着某个角色的判断。",
+  "环境的微妙变化让角色们重新审视当前局势。",
+]
+
+function generateRandomEvent(): SimulationEvent | null {
+  const idx = Math.floor(Math.random() * RANDOM_EVENTS.length)
+  return {
+    type: "info",
+    timestamp: new Date().toISOString(),
+    message: `【随机事件】${RANDOM_EVENTS[idx]}`,
+  }
+}
+
+function isNodeGoalReached(
+  _node: StoryNode,
+  nodeTimelineEvents: TimelineEvent[],
+  maxRounds: number,
+  currentRound: number,
+): boolean {
+  // 达到最大轮次
+  if (currentRound >= maxRounds - 1) {
+    return true
+  }
+  // 启发式:产生了 pushPlot 类型的关键事件(剧情推动行为)
+  const pushPlotCount = nodeTimelineEvents.filter(
+    (e) => e.actionType === "pushPlot",
+  ).length
+  if (pushPlotCount >= 2) {
+    return true
+  }
+  // 启发式:节点内事件数足够多(>=6个事件表示有足够互动)
+  if (nodeTimelineEvents.length >= 6) {
+    return true
+  }
+  return false
+}
+
+// ── 内部辅助:单个 Agent 决策并产生事件 ──
+
+async function agentDecideAndAct(
+  agent: NovelAgent,
+  node: StoryNode,
+  state: SimulationState,
+  llmConfig: LlmConfig,
+  extraction: ExtractionResult,
+  recentEventDescs: string[],
+  injectionEvent: string | undefined,
+  signal?: AbortSignal,
+  modeHint?: string,
+): Promise<{ parsed: ParsedAction; tlEvent: TimelineEvent; simEvent: SimulationEvent } | null> {
+  // 1. 观察:筛选该 Agent 可见的时间线事件
+  const visibleEvents = getVisibleEvents(
+    agent.characterId,
+    state.timelineEvents,
+    10,
+  )
+
+  // 2. 构建上下文(基于认知边界)
+  const context = buildAgentContext(
+    agent,
+    node,
+    recentEventDescs.slice(-8),
+    extraction.worldRules,
+    visibleEvents,
+  )
+
+  // 3. 构建 LLM 消息
+  const messages: ChatMessage[] = [
+    { role: "system", content: buildSystemPrompt(agent) },
+    {
+      role: "user",
+      content: buildUserMessage(context, node, agent.name, injectionEvent, modeHint),
+    },
+  ]
+
+  // 4. 调用 LLM
+  const rawResponse = await collectStream(llmConfig, messages, signal)
+  if (signal?.aborted) return null
+
+  // 5. 解析行为
+  const parsed = parseAgentAction(rawResponse)
+
+  // 6. 解析目标
+  const target = resolveTarget(parsed.action.target, state.activeAgents)
+
+  // 7. 创建时间线事件
+  const tlEvent = createTimelineEvent(
+    agent,
+    parsed,
+    target,
+    state.currentRound,
+    node.index,
+    state.activeAgents,
+  )
+
+  // 8. 应用事件到相关 Agent 的记忆
+  for (const id of tlEvent.observableBy) {
+    const observer = state.activeAgents.get(id)
+    if (observer) {
+      applyEventToMemory(observer, tlEvent)
+    }
+  }
+
+  // 9. 写入状态
+  state.timelineEvents.push(tlEvent)
+
+  // 10. 转换为旧 SimulationEvent
+  const simEvent = timelineEventToSimulationEvent(
+    tlEvent,
+    agent,
+    parsed,
+    node,
+    state.currentRound,
+  )
+
+  return { parsed, tlEvent, simEvent }
+}
+
+// ── 主入口:运行仿真(多智能体,基于认知边界) ──
+
+export async function runSimulation(
+  input: SimulationInput,
+  extraction: ExtractionResult,
+  callbacks: SimulationCallbacks,
+  signal?: AbortSignal,
+): Promise<SimulationEvent[]> {
+  const events: SimulationEvent[] = []
+  const { agents, framework, wordBudget, llmConfig, injectionEvent, maxRoundsPerNode } = input
+  const mode = input.mode || framework.simulationMode || "hybrid"
+  const modeConfig: ModeConfig = getModeConfig(mode)
+  const totalNodes = framework.nodes.length
+  // 使用用户指定的轮数,若未指定则自动计算并乘以模式系数
+  const calculatedRounds = calcMaxRoundsPerNode(wordBudget) || MAX_ROUNDS_PER_NODE_FALLBACK
+  const baseRounds = Math.max(1, maxRoundsPerNode ?? calculatedRounds)
+  const maxRounds = Math.max(1, Math.round(baseRounds * modeConfig.roundsMultiplier))
+  let aborted = false
+
+  // 初始化仿真状态
+  const state: SimulationState = {
+    currentRound: 0,
+    timelineEvents: [],
+    activeAgents: cloneAgentsToMap(agents),
+    worldState: {},
+  }
+
+  try {
+    for (let ni = 0; ni < totalNodes; ni++) {
+      if (signal?.aborted) {
+        aborted = true
+        break
+      }
+
+      const node = framework.nodes[ni]
+
+      // 确定参与角色:从 involvedCharacters(角色名)过滤 agents
+      const nodeAgentIds = new Set<string>()
+      for (const a of agents) {
+        if (node.involvedCharacters.includes(a.name)) {
+          nodeAgentIds.add(a.characterId)
+        }
+      }
+      // 防御:若过滤结果为空,使用全部 agents
+      let nodeAgentList: NovelAgent[]
+      if (nodeAgentIds.size === 0) {
+        nodeAgentList = Array.from(state.activeAgents.values())
+      } else {
+        nodeAgentList = Array.from(state.activeAgents.values()).filter((a) =>
+          nodeAgentIds.has(a.characterId),
+        )
+      }
+
+      // 设置本轮活跃 Agent
+      const activeMap = new Map<string, NovelAgent>()
+      for (const a of nodeAgentList) {
+        activeMap.set(a.characterId, a)
+      }
+      state.activeAgents = activeMap
+
+      // 产出 node-start 事件
+      const startEvent: SimulationEvent = {
+        type: "node-start",
+        node,
+        timestamp: new Date().toISOString(),
+      }
+      events.push(startEvent)
+      callbacks.onEvent(startEvent)
+
+      callbacks.onProgress(
+        Math.round((ni / totalNodes) * 100),
+        `开始节点 ${ni + 1}/${totalNodes}:${node.title}`,
+      )
+
+      // 当前节点内的事件描述(供 recentEvents 使用)
+      const recentEventDescs: string[] = []
+      const nodeTimelineEvents: TimelineEvent[] = []
+
+      // 节点内多轮交互
+      for (let round = 0; round < maxRounds; round++) {
+        if (signal?.aborted) {
+          aborted = true
+          break
+        }
+
+        state.currentRound = round
+
+        // 根据模式决定本轮活跃 Agent 子集
+        let roundAgentList = nodeAgentList
+        if (modeConfig.agentSubsetRatio < 1 && nodeAgentList.length > 1) {
+          const subsetSize = Math.max(1, Math.ceil(nodeAgentList.length * modeConfig.agentSubsetRatio))
+          // 简单的随机选择:打乱后取前 N 个
+          const shuffled = [...nodeAgentList].sort(() => Math.random() - 0.5)
+          roundAgentList = shuffled.slice(0, subsetSize)
+        }
+
+        // a. 每个活跃 Agent 观察并决策
+        for (const agent of roundAgentList) {
+          if (signal?.aborted) {
+            aborted = true
+            break
+          }
+
+          // 获取最新状态的 agent 引用(可能被之前的事件更新了记忆)
+          const currentAgent = state.activeAgents.get(agent.characterId)
+          if (!currentAgent) continue
+
+          let result: Awaited<ReturnType<typeof agentDecideAndAct>> | null = null
+          try {
+            result = await agentDecideAndAct(
+              currentAgent,
+              node,
+              state,
+              llmConfig,
+              extraction,
+              recentEventDescs,
+              round === 0 ? injectionEvent : undefined,
+              signal,
+              modeConfig.behaviorHint,
+            )
+          } catch (agentErr) {
+            // 单个 Agent 失败不中断整个推演,记录事件后跳过
+            console.warn(`[simulation] Agent ${currentAgent.name} 决策失败,跳过本轮:`, agentErr)
+            const warnEvent: SimulationEvent = {
+              type: "info",
+              timestamp: new Date().toISOString(),
+              message: `${currentAgent.name} 本轮无行动(API错误),继续推演`,
+            }
+            events.push(warnEvent)
+            callbacks.onEvent(warnEvent)
+            continue
+          }
+
+          if (!result) {
+            if (signal?.aborted) {
+              aborted = true
+              break
+            }
+            // null 且非 abort,跳过该 agent 继续
+            continue
+          }
+
+          const { parsed, tlEvent, simEvent } = result
+
+          events.push(simEvent)
+          callbacks.onEvent(simEvent)
+          callbacks.onTimelineEvent?.(tlEvent)
+
+          recentEventDescs.push(
+            formatActionDescription(parsed.action, currentAgent.name),
+          )
+          nodeTimelineEvents.push(tlEvent)
+
+          // d. 如果行为有目标且目标是可见的,触发目标的 react(反应链,限制深度)
+          if (
+            tlEvent.targetId &&
+            parsed.action.type !== "react" &&
+            parsed.visibility !== "self"
+          ) {
+            const targetAgent = state.activeAgents.get(tlEvent.targetId)
+            if (targetAgent && REACT_CHAIN_LIMIT > 0) {
+              await triggerReaction(
+                targetAgent,
+                currentAgent,
+                tlEvent,
+                node,
+                state,
+                llmConfig,
+                extraction,
+                recentEventDescs,
+                events,
+                callbacks,
+                nodeTimelineEvents,
+                signal,
+              )
+            }
+          }
+        }
+
+        if (aborted) break
+
+        // e. 随机事件(根据模式概率触发)
+        if (modeConfig.randomEventChance > 0 && Math.random() < modeConfig.randomEventChance) {
+          const randomEvent = generateRandomEvent()
+          if (randomEvent) {
+            events.push(randomEvent)
+            callbacks.onEvent(randomEvent)
+            const tlEvent: TimelineEvent = {
+              id: `tl-rand-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
+              actorId: "system",
+              actorName: "系统事件",
+              actionType: "pushPlot",
+              content: randomEvent.message || "",
+              observableBy: Array.from(state.activeAgents.keys()),
+              round,
+              nodeIndex: node.index,
+              timestamp: new Date().toISOString(),
+              impacts: [],
+              targetId: undefined,
+              targetName: undefined,
+            }
+            state.timelineEvents.push(tlEvent)
+            nodeTimelineEvents.push(tlEvent)
+            callbacks.onTimelineEvent?.(tlEvent)
+            recentEventDescs.push(`[系统事件] ${randomEvent.message}`)
+          }
+        }
+
+        // f. 检查节点目标是否达成
+        if (isNodeGoalReached(node, nodeTimelineEvents, maxRounds, round)) {
+          break
+        }
+      }
+
+      if (aborted) break
+
+      // 产出 node-complete 事件
+      const completeEvent: SimulationEvent = {
+        type: "node-complete",
+        node,
+        timestamp: new Date().toISOString(),
+      }
+      events.push(completeEvent)
+      callbacks.onEvent(completeEvent)
+
+      callbacks.onProgress(
+        Math.round(((ni + 1) / totalNodes) * 100),
+        `完成节点 ${ni + 1}/${totalNodes}:${node.title}`,
+      )
+    }
+
+    if (!aborted && !signal?.aborted) {
+      callbacks.onComplete(events)
+    }
+
+    return events
+  } catch (err) {
+    const error = err instanceof Error ? err : new Error(String(err))
+    // 如果是 abort 导致的错误,正常返回已收集事件
+    if (signal?.aborted || error.name === "AbortError") {
+      return events
+    }
+    // 其他致命错误才回调 onError 并抛出
+    console.error("[simulation] 仿真引擎致命错误:", error)
+    callbacks.onError(error)
+    // 如果已经收集了一些事件,仍然返回它们而非抛出,让上层能部分使用结果
+    if (events.length > 0) {
+      const errorEvent: SimulationEvent = {
+        type: "info",
+        timestamp: new Date().toISOString(),
+        message: `推演过程中遇到错误:${error.message},已返回已推演内容`,
+      }
+      events.push(errorEvent)
+      callbacks.onEvent(errorEvent)
+      callbacks.onComplete(events)
+      return events
+    }
+    throw error
+  }
+}
+
+// ── 内部辅助:触发目标 Agent 对事件的反应(react 行为) ──
+
+async function triggerReaction(
+  targetAgent: NovelAgent,
+  actor: NovelAgent,
+  triggerEvent: TimelineEvent,
+  node: StoryNode,
+  state: SimulationState,
+  llmConfig: LlmConfig,
+  extraction: ExtractionResult,
+  recentEventDescs: string[],
+  events: SimulationEvent[],
+  callbacks: SimulationCallbacks,
+  nodeTimelineEvents: TimelineEvent[],
+  signal?: AbortSignal,
+): Promise<void> {
+  if (signal?.aborted) return
+
+  // 构建反应专用上下文:强调对刚才事件的反应
+  const visibleEvents = getVisibleEvents(
+    targetAgent.characterId,
+    state.timelineEvents,
+    10,
+  )
+
+  const reactionNote = `\n\n【刚才发生的事情】\n${actor.name}刚刚对你做出了行为:[${triggerEvent.actionType}] ${triggerEvent.content}\n请你立即对此做出反应(react类型行为)。`
+
+  const baseContext = buildAgentContext(
+    targetAgent,
+    node,
+    recentEventDescs.slice(-8),
+    extraction.worldRules,
+    visibleEvents,
+  )
+
+  const context = baseContext + reactionNote
+
+  const messages: ChatMessage[] = [
+    { role: "system", content: buildSystemPrompt(targetAgent) },
+    {
+      role: "user",
+      content:
+        context +
+        `\n\n请以「${targetAgent.name}」的视角,对${actor.name}刚才的行为立即做出反应,输出JSON。`,
+    },
+  ]
+
+  try {
+    const rawResponse = await collectStream(llmConfig, messages, signal)
+    if (signal?.aborted) return
+
+    const parsed = parseAgentAction(rawResponse)
+    // 强制行为类型为 react
+    parsed.action.type = "react"
+    parsed.action.target = actor.name
+
+    const tlEvent = createTimelineEvent(
+      targetAgent,
+      parsed,
+      actor,
+      state.currentRound,
+      node.index,
+      state.activeAgents,
+    )
+
+    // 应用记忆
+    for (const id of tlEvent.observableBy) {
+      const observer = state.activeAgents.get(id)
+      if (observer) {
+        applyEventToMemory(observer, tlEvent)
+      }
+    }
+
+    state.timelineEvents.push(tlEvent)
+    nodeTimelineEvents.push(tlEvent)
+
+    const simEvent = timelineEventToSimulationEvent(
+      tlEvent,
+      targetAgent,
+      parsed,
+      node,
+      state.currentRound,
+    )
+
+    events.push(simEvent)
+    callbacks.onEvent(simEvent)
+    callbacks.onTimelineEvent?.(tlEvent)
+
+    recentEventDescs.push(
+      formatActionDescription(parsed.action, targetAgent.name),
+    )
+  } catch {
+    // 反应失败不中断主流程
+  }
+}

+ 236 - 0
src/lib/novel/story-simulation/simulation-modes/decision-tree.ts

@@ -0,0 +1,236 @@
+import { runSimulation, type SimulationCallbacks } from "../simulation-engine"
+import type { ChatMessage } from "@/lib/llm-client"
+import { streamChat } from "@/lib/llm-client"
+import type { LlmConfig } from "@/stores/wiki-store"
+import type {
+  ExtractionResult,
+  NovelAgent,
+  SimulationEvent,
+  SimulationInput,
+  StoryFramework,
+} from "../types"
+
+// 每个分支最多推演的节点数,用于限制深度、控制 token 消耗。
+const MAX_BRANCH_NODES = 2
+// 为关键角色生成的决策选项数量。
+const DECISION_OPTION_COUNT = 3
+
+interface DecisionOption {
+  title: string
+  description: string
+}
+
+// 将 streamChat 的流式回调收拢为一个完整字符串(与 simulation-engine 内部实现一致)。
+async function collectStream(
+  config: LlmConfig,
+  messages: ChatMessage[],
+  signal?: AbortSignal,
+): Promise<string> {
+  let result = ""
+  let streamError: Error | null = null
+
+  await streamChat(
+    config,
+    messages,
+    {
+      onToken: (token) => {
+        result += token
+      },
+      onDone: () => {},
+      onError: (err) => {
+        streamError = err
+      },
+    },
+    signal,
+  )
+
+  if (streamError) throw streamError
+  return result
+}
+
+// 从 LLM 文本中解析 JSON 数组形式的决策选项,失败返回 null。
+function parseDecisionOptions(raw: string): DecisionOption[] | null {
+  const trimmed = raw.trim()
+
+  const tryParse = (text: string): DecisionOption[] | null => {
+    try {
+      const parsed = JSON.parse(text) as unknown
+      if (!Array.isArray(parsed)) return null
+      const options: DecisionOption[] = []
+      for (const item of parsed) {
+        if (item && typeof item === "object") {
+          const obj = item as Record<string, unknown>
+          const title = obj.title !== undefined ? String(obj.title) : ""
+          const description =
+            obj.description !== undefined ? String(obj.description) : ""
+          if (title || description) options.push({ title, description })
+        } else if (typeof item === "string" && item.trim()) {
+          options.push({ title: item.trim(), description: item.trim() })
+        }
+      }
+      return options.length > 0 ? options : null
+    } catch {
+      return null
+    }
+  }
+
+  // 直接解析
+  const direct = tryParse(trimmed)
+  if (direct) return direct
+
+  // 从 markdown 代码块中提取
+  const codeBlockMatch = /```(?:json)?\s*([\s\S]*?)```/.exec(trimmed)
+  if (codeBlockMatch) {
+    const fromBlock = tryParse(codeBlockMatch[1].trim())
+    if (fromBlock) return fromBlock
+  }
+
+  // 从文本中查找第一个 JSON 数组
+  const arrayMatch = /\[[\s\S]*\]/.exec(trimmed)
+  if (arrayMatch) {
+    const fromArray = tryParse(arrayMatch[0])
+    if (fromArray) return fromArray
+  }
+
+  return null
+}
+
+// 选取关键角色:优先取第一个节点涉及的 agent,否则取第一个 agent。
+function pickKeyAgent(input: SimulationInput): NovelAgent | undefined {
+  const firstNode = input.framework.nodes[0]
+  if (firstNode) {
+    const involved = input.agents.find((a) =>
+      firstNode.involvedCharacters.includes(a.name),
+    )
+    if (involved) return involved
+  }
+  return input.agents[0]
+}
+
+// 构建生成决策选项的 LLM 消息。
+function buildDecisionMessages(
+  input: SimulationInput,
+  extraction: ExtractionResult,
+): ChatMessage[] {
+  const agent = pickKeyAgent(input)
+  const framework = input.framework
+  const firstNode = framework.nodes[0]
+
+  const system = [
+    "你是一位小说剧情推演助手。",
+    "你的任务是为关键角色生成几个不同方向的决策选项,用于分支推演。",
+    "只输出一个 JSON 数组,数组中每个元素是一个对象:",
+    '  { "title": "决策标题(简短)", "description": "决策的具体内容与动机" }',
+    `请生成 ${DECISION_OPTION_COUNT} 个选项,每个选项代表一种截然不同的行动方向。`,
+    "不要输出任何其他文字。",
+  ].join("\n")
+
+  const user = [
+    `故事前提:${framework.premise}`,
+    firstNode ? `当前节点:${firstNode.title}(核心冲突:${firstNode.coreConflict})` : "",
+    `关键角色:${agent?.name ?? "主角"}`,
+    agent ? `角色设定:${agent.profile}` : "",
+    `世界规则:${extraction.worldRules || "(无)"}`,
+    "",
+    `请为「${agent?.name ?? "主角"}」生成 ${DECISION_OPTION_COUNT} 个决策选项。`,
+  ]
+    .filter((line) => line !== undefined && line !== "")
+    .join("\n")
+
+  return [
+    { role: "system", content: system },
+    { role: "user", content: user },
+  ]
+}
+
+// 限制框架节点数,控制每个分支的推演深度。
+function trimFramework(
+  framework: StoryFramework,
+  maxNodes: number,
+): StoryFramework {
+  if (framework.nodes.length <= maxNodes) return framework
+  return {
+    ...framework,
+    nodes: framework.nodes.slice(0, maxNodes),
+  }
+}
+
+export async function runDecisionTreeSimulation(
+  input: SimulationInput,
+  extraction: ExtractionResult,
+  callbacks: SimulationCallbacks,
+  signal?: AbortSignal,
+): Promise<SimulationEvent[]> {
+  const allEvents: SimulationEvent[] = []
+
+  // 1. 为关键角色生成决策选项
+  let options: DecisionOption[] | null = null
+  try {
+    const messages = buildDecisionMessages(input, extraction)
+    const raw = await collectStream(input.llmConfig, messages, signal)
+    if (signal?.aborted) return allEvents
+    options = parseDecisionOptions(raw)
+  } catch {
+    options = null
+  }
+
+  // 无法生成决策选项时,回退到普通 runSimulation
+  if (!options || options.length === 0) {
+    callbacks.onProgress(0, "无法生成决策选项,回退到普通推演")
+    return runSimulation(
+      { ...input, mode: "decision-tree" },
+      extraction,
+      callbacks,
+      signal,
+    )
+  }
+
+  const trimmedFramework = trimFramework(input.framework, MAX_BRANCH_NODES)
+  const branchCount = options.length
+
+  // 2. 对每个决策选项推演一条分支
+  for (let i = 0; i < branchCount; i++) {
+    if (signal?.aborted) break
+
+    const option = options[i]
+    const injectionEvent = `【决策分支 ${i + 1}】${option.title}:${option.description}`
+
+    callbacks.onProgress(
+      Math.round((i / branchCount) * 100),
+      `推演决策分支 ${i + 1}/${branchCount}:${option.title}`,
+    )
+
+    // 子分支内部事件不转发给外部回调,避免输出过多
+    const branchCallbacks: SimulationCallbacks = {
+      onEvent: () => {},
+      onProgress: () => {},
+      onComplete: () => {},
+      onError: () => {},
+    }
+
+    try {
+      const branchInput: SimulationInput = {
+        ...input,
+        mode: "decision-tree",
+        framework: trimmedFramework,
+        injectionEvent,
+      }
+      const branchEvents = await runSimulation(
+        branchInput,
+        extraction,
+        branchCallbacks,
+        signal,
+      )
+      allEvents.push(...branchEvents)
+    } catch {
+      // 单个分支失败不影响其他分支的推演
+    }
+  }
+
+  if (!signal?.aborted) {
+    callbacks.onProgress(100, "决策树推演完成")
+    callbacks.onComplete(allEvents)
+  }
+
+  return allEvents
+}

+ 11 - 0
src/lib/novel/story-simulation/simulation-modes/event-driven.ts

@@ -0,0 +1,11 @@
+import { runSimulation, type SimulationCallbacks } from "../simulation-engine"
+import type { SimulationInput, ExtractionResult, SimulationEvent } from "../types"
+
+export async function runEventDrivenSimulation(
+  input: SimulationInput,
+  extraction: ExtractionResult,
+  callbacks: SimulationCallbacks,
+  signal?: AbortSignal,
+): Promise<SimulationEvent[]> {
+  return runSimulation({ ...input, mode: "event-driven" }, extraction, callbacks, signal)
+}

+ 11 - 0
src/lib/novel/story-simulation/simulation-modes/free-emergence.ts

@@ -0,0 +1,11 @@
+import { runSimulation, type SimulationCallbacks } from "../simulation-engine"
+import type { SimulationInput, ExtractionResult, SimulationEvent } from "../types"
+
+export async function runFreeEmergenceSimulation(
+  input: SimulationInput,
+  extraction: ExtractionResult,
+  callbacks: SimulationCallbacks,
+  signal?: AbortSignal,
+): Promise<SimulationEvent[]> {
+  return runSimulation({ ...input, mode: "free-emergence", injectionEvent: undefined }, extraction, callbacks, signal)
+}

+ 11 - 0
src/lib/novel/story-simulation/simulation-modes/hybrid.ts

@@ -0,0 +1,11 @@
+import { runSimulation, type SimulationCallbacks } from "../simulation-engine"
+import type { SimulationInput, ExtractionResult, SimulationEvent } from "../types"
+
+export async function runHybridSimulation(
+  input: SimulationInput,
+  extraction: ExtractionResult,
+  callbacks: SimulationCallbacks,
+  signal?: AbortSignal,
+): Promise<SimulationEvent[]> {
+  return runSimulation({ ...input, mode: "hybrid" }, extraction, callbacks, signal)
+}

+ 367 - 0
src/lib/novel/story-simulation/simulation-report-agent.ts

@@ -0,0 +1,367 @@
+import type { ChatMessage } from "@/lib/llm-client"
+import { streamChat } from "@/lib/llm-client"
+import type { LlmConfig } from "@/stores/wiki-store"
+import type {
+  CharacterAnalysis,
+  SimulationEvent,
+  SimulationMode,
+  SimulationReport,
+  StoryBranch,
+  StoryFramework,
+} from "@/lib/novel/story-simulation/types"
+
+// ── 对外接口 ──
+
+export interface ReportGenerationOptions {
+  events: SimulationEvent[]
+  framework: StoryFramework
+  mode: SimulationMode
+  llmConfig: LlmConfig
+  onProgress?: (label: string) => void
+  signal?: AbortSignal
+}
+
+// ── 内部辅助:将 streamChat 的流式回调收拢为一个完整字符串 ──
+
+async function collectStream(
+  config: LlmConfig,
+  messages: ChatMessage[],
+  signal?: AbortSignal,
+): Promise<string> {
+  let result = ""
+  let streamError: Error | null = null
+
+  await streamChat(
+    config,
+    messages,
+    {
+      onToken: (token) => {
+        result += token
+      },
+      onDone: () => {},
+      onError: (err) => {
+        streamError = err
+      },
+    },
+    signal,
+  )
+
+  if (streamError) throw streamError
+  return result
+}
+
+// ── 内部辅助:从 LLM 文本中提取 JSON(支持裸 JSON 与代码块) ──
+
+function extractJson(text: string): string | null {
+  const trimmed = text.trim()
+
+  // 直接解析
+  try {
+    JSON.parse(trimmed)
+    return trimmed
+  } catch {
+    // 继续
+  }
+
+  // 从 markdown 代码块中提取
+  const codeBlockMatch = /```(?:json)?\s*([\s\S]*?)```/.exec(trimmed)
+  if (codeBlockMatch) {
+    const candidate = codeBlockMatch[1].trim()
+    try {
+      JSON.parse(candidate)
+      return candidate
+    } catch {
+      // 继续
+    }
+  }
+
+  // 从文本中查找第一个 JSON 对象
+  const objMatch = /\{[\s\S]*\}/.exec(trimmed)
+  if (objMatch) {
+    const candidate = objMatch[0]
+    try {
+      JSON.parse(candidate)
+      return candidate
+    } catch {
+      // 继续
+    }
+  }
+
+  return null
+}
+
+// ── 内部辅助:将单个事件序列化为文本行 ──
+
+function formatEvent(event: SimulationEvent): string {
+  const time = event.timestamp
+
+  switch (event.type) {
+    case "node-start": {
+      const node = event.node
+      if (!node) return `[${time}] 节点开始(节点信息缺失)`
+      return `[${time}] 节点开始:第 ${node.index + 1} 个节点「${node.title}」(${node.phase}),核心冲突:${node.coreConflict},目标:${node.goal}`
+    }
+    case "node-complete": {
+      const node = event.node
+      if (!node) return `[${time}] 节点完成(节点信息缺失)`
+      return `[${time}] 节点完成:第 ${node.index + 1} 个节点「${node.title}」`
+    }
+    case "agent-action": {
+      const { agent, action, round, node, stateChanges } = event
+      const name = agent ? agent.name : "未知角色"
+      const nodeTitle = node ? node.title : ""
+      const roundLabel = round !== undefined ? `第 ${round + 1} 轮` : ""
+
+      let actionDesc = ""
+      if (action) {
+        switch (action.type) {
+          case "speak":
+            actionDesc = action.target
+              ? `对 ${action.target} 说:「${action.content}」`
+              : `自言自语:「${action.content}」`
+            break
+          case "act":
+            actionDesc = `行动:${action.content}`
+            break
+          case "react":
+            actionDesc = `对 ${action.target} 做出反应:${action.content}`
+            break
+          case "decide":
+            actionDesc = `做出决定:${action.content}`
+            break
+          case "investigate":
+            actionDesc = `调查:${action.content}`
+            break
+          case "conflict":
+            actionDesc = `与 ${action.target} 发生冲突:${action.content}`
+            break
+          case "cooperate":
+            actionDesc = `与 ${action.target} 合作:${action.content}`
+            break
+          case "withhold":
+            actionDesc = `隐瞒信息:${action.content}`
+            break
+        }
+      }
+
+      const changes =
+        stateChanges && stateChanges.length > 0
+          ? `;状态变更:${stateChanges.join(",")}`
+          : ""
+
+      return `[${time}] 角色行为:${name}(节点「${nodeTitle}」${roundLabel})${actionDesc}${changes}`
+    }
+    default:
+      return `[${time}] 未知事件`
+  }
+}
+
+function serializeEvents(events: SimulationEvent[]): string {
+  return events.map(formatEvent).join("\n")
+}
+
+// ── 内部辅助:构建系统提示词 ──
+
+function buildSystemPrompt(framework: StoryFramework, mode: SimulationMode): string {
+  return [
+    "你是一位资深的小说推演分析师。请基于仿真引擎产出的角色行为与节点事件记录,生成一份结构化的推演报告。",
+    "",
+    `当前故事框架:「${framework.title}」,前提:${framework.premise}`,
+    `仿真模式:${mode}`,
+    "",
+    "请严格按以下 JSON 结构输出报告,不要输出任何其他文字:",
+    "{",
+    '  "characterAnalyses": [',
+    "    {",
+    '      "characterId": "角色ID",',
+    '      "name": "角色名",',
+    '      "behaviors": [',
+    '        { "node": "所属节点标题", "action": "行为概述", "motivation": "动机说明" }',
+    "      ],",
+    '      "stateChanges": ["状态变更描述"],',
+    '      "consistencyScore": 0到100的整数,人设一致性评分',
+    "    }",
+    "  ],",
+    '  "branches": [',
+    "    {",
+    '      "title": "走向标题",',
+    '      "summary": "走向摘要",',
+    '      "keyEvents": ["关键事件"],',
+    '      "probability": "high | medium | low",',
+    '      "pros": "优势",',
+    '      "cons": "不足",',
+    '      "recommendation": true或false,是否推荐',
+    "    }",
+    "  ],",
+    '  "recommendation": "综合推荐建议"',
+    "}",
+    "",
+    "要求:",
+    "1. characterAnalyses:对每个出场角色分析其行为与动机,并给出 0-100 的人设一致性评分。",
+    "2. branches:给出 2-3 条可能的剧情走向,每条包含标题、摘要、关键事件、发生概率(高/中/低)、优势、不足、是否推荐。",
+    "3. recommendation:给出综合推荐建议。",
+    "4. 只输出 JSON 对象,不要包含 markdown 代码块标记或任何解释性文字。",
+  ].join("\n")
+}
+
+// ── 内部辅助:构建用户提示词 ──
+
+function buildUserPrompt(framework: StoryFramework, events: SimulationEvent[]): string {
+  const nodeLines = framework.nodes
+    .map(
+      (n) =>
+        `- 节点 ${n.index + 1}「${n.phase}」${n.title}:核心冲突「${n.coreConflict}」,目标「${n.goal}」`,
+    )
+    .join("\n")
+
+  const parts: string[] = [
+    "【故事框架】",
+    `标题:${framework.title}`,
+    `前提:${framework.premise}`,
+    `目标字数:${framework.targetWords}`,
+    `来源章节数:${framework.sourceChapters}`,
+  ]
+  if (framework.userIdea) {
+    parts.push(`用户构想:${framework.userIdea}`)
+  }
+  parts.push("故事节点:")
+  parts.push(nodeLines)
+  parts.push("")
+  parts.push("【仿真事件记录】")
+  parts.push(serializeEvents(events))
+  parts.push("")
+  parts.push("请根据以上仿真记录,生成结构化推演报告(只输出 JSON)。")
+
+  return parts.join("\n")
+}
+
+// ── 内部辅助:解析报告字段(健壮:字段缺失或类型不符时回退为安全默认值) ──
+
+function clampScore(raw: unknown): number {
+  const n = Number(raw)
+  if (!Number.isFinite(n)) return 0
+  const rounded = Math.round(n)
+  if (rounded < 0) return 0
+  if (rounded > 100) return 100
+  return rounded
+}
+
+function parseBoolean(raw: unknown): boolean {
+  if (typeof raw === "boolean") return raw
+  if (typeof raw === "number") return raw !== 0
+  const v = String(raw ?? "")
+    .trim()
+    .toLowerCase()
+  return v === "true" || v === "1" || v === "yes" || v === "是" || v === "推荐"
+}
+
+function parseProbability(raw: unknown): "high" | "medium" | "low" {
+  const v = String(raw ?? "")
+    .trim()
+    .toLowerCase()
+  if (v === "high" || v === "高") return "high"
+  if (v === "low" || v === "低") return "low"
+  return "medium"
+}
+
+function parseCharacterAnalyses(raw: unknown): CharacterAnalysis[] {
+  if (!Array.isArray(raw)) return []
+  return raw.map((item) => {
+    const obj = (item ?? {}) as Record<string, unknown>
+    const behaviorsRaw = Array.isArray(obj.behaviors) ? obj.behaviors : []
+    return {
+      characterId: String(obj.characterId ?? ""),
+      name: String(obj.name ?? ""),
+      behaviors: behaviorsRaw.map((b) => {
+        const bo = (b ?? {}) as Record<string, unknown>
+        return {
+          node: String(bo.node ?? ""),
+          action: String(bo.action ?? ""),
+          motivation: String(bo.motivation ?? ""),
+        }
+      }),
+      stateChanges: Array.isArray(obj.stateChanges)
+        ? obj.stateChanges.map((s) => String(s))
+        : [],
+      consistencyScore: clampScore(obj.consistencyScore),
+    }
+  })
+}
+
+function parseBranches(raw: unknown): StoryBranch[] {
+  if (!Array.isArray(raw)) return []
+  return raw.map((item) => {
+    const obj = (item ?? {}) as Record<string, unknown>
+    return {
+      title: String(obj.title ?? ""),
+      summary: String(obj.summary ?? ""),
+      keyEvents: Array.isArray(obj.keyEvents)
+        ? obj.keyEvents.map((e) => String(e))
+        : [],
+      probability: parseProbability(obj.probability),
+      pros: String(obj.pros ?? ""),
+      cons: String(obj.cons ?? ""),
+      recommendation: parseBoolean(obj.recommendation),
+    }
+  })
+}
+
+// ── 主入口:生成推演报告 ──
+
+export async function generateSimulationReport(
+  options: ReportGenerationOptions,
+): Promise<SimulationReport> {
+  const { events, framework, mode, llmConfig, onProgress, signal } = options
+  const createdAt = new Date().toISOString()
+
+  onProgress?.("正在构建推演提示词")
+  const messages: ChatMessage[] = [
+    { role: "system", content: buildSystemPrompt(framework, mode) },
+    { role: "user", content: buildUserPrompt(framework, events) },
+  ]
+
+  onProgress?.("正在调用模型生成推演报告")
+  const rawResponse = await collectStream(llmConfig, messages, signal)
+
+  onProgress?.("正在解析推演报告")
+  const jsonText = extractJson(rawResponse)
+
+  // 降级报告:无法解析出 JSON 时,用空数组 + 原始文本作为 recommendation
+  if (!jsonText) {
+    return {
+      frameworkId: framework.id,
+      mode,
+      characterAnalyses: [],
+      branches: [],
+      recommendation: rawResponse.trim() || "模型未返回可解析的推演报告",
+      createdAt,
+    }
+  }
+
+  try {
+    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() || "模型未提供综合推荐建议"
+
+    return {
+      frameworkId: framework.id,
+      mode,
+      characterAnalyses,
+      branches,
+      recommendation,
+      createdAt,
+    }
+  } catch {
+    // 解析异常时同样降级:保留原始文本,避免中断调用方流程
+    return {
+      frameworkId: framework.id,
+      mode,
+      characterAnalyses: [],
+      branches: [],
+      recommendation: rawResponse.trim() || "模型返回的推演报告无法解析",
+      createdAt,
+    }
+  }
+}

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

@@ -0,0 +1,156 @@
+/**
+ * Agent 状态序列化/反序列化
+ * 将 NovelAgent 和 SimulationState 中的 Set/Map 转换为可 JSON 序列化的格式,
+ * 以便保存推演结果后,加载历史结果时也能进行角色采访。
+ */
+
+import type {
+  AgentMemory,
+  AgentRelation,
+  NovelAgent,
+  SimulationState,
+} from "./types"
+
+// ── 可序列化的类型定义 ──
+
+interface SerializedAgentMemory {
+  observedEvents: string[]
+  knownSecrets: string[]
+  sentiments: Record<string, number>
+  recentDecisions: string[]
+}
+
+interface SerializedNovelAgent {
+  characterId: string
+  name: string
+  profile: string
+  aura: unknown | null
+  cognition: { knows: string[]; doesNotKnow: string[] } | null
+  soul: string
+  currentGoal: string
+  emotionalState: string
+  knownFacts: string[]
+  relationships: Array<{
+    targetId: string
+    relationType: string
+    sentiment: number
+  }>
+  powerLevel: string
+  memory: SerializedAgentMemory
+  knowledgeScope: string[]
+  personality: string[]
+  speakingStyle: string
+}
+
+interface SerializedSimulationState {
+  currentRound: number
+  timelineEvents: unknown[]
+  activeAgents: Record<string, SerializedNovelAgent>
+  worldState: Record<string, unknown>
+}
+
+export interface SerializedSimulationSnapshot {
+  agents: SerializedNovelAgent[]
+  state: SerializedSimulationState
+}
+
+// ── 序列化 ──
+
+function serializeMemory(memory: AgentMemory): SerializedAgentMemory {
+  return {
+    observedEvents: Array.from(memory.observedEvents),
+    knownSecrets: Array.from(memory.knownSecrets),
+    sentiments: Object.fromEntries(memory.sentiments),
+    recentDecisions: Array.from(memory.recentDecisions),
+  }
+}
+
+export function serializeAgent(agent: NovelAgent): SerializedNovelAgent {
+  return {
+    characterId: agent.characterId,
+    name: agent.name,
+    profile: agent.profile,
+    aura: agent.aura,
+    cognition: agent.cognition,
+    soul: agent.soul,
+    currentGoal: agent.currentGoal,
+    emotionalState: agent.emotionalState,
+    knownFacts: Array.from(agent.knownFacts),
+    relationships: Array.from(agent.relationships.values()),
+    powerLevel: agent.powerLevel,
+    memory: serializeMemory(agent.memory),
+    knowledgeScope: agent.knowledgeScope,
+    personality: agent.personality,
+    speakingStyle: agent.speakingStyle,
+  }
+}
+
+export function serializeSimulationState(
+  state: SimulationState,
+  agents: NovelAgent[],
+): SerializedSimulationSnapshot {
+  const serializedAgents = agents.map(serializeAgent)
+  return {
+    agents: serializedAgents,
+    state: {
+      currentRound: state.currentRound,
+      timelineEvents: state.timelineEvents,
+      activeAgents: Object.fromEntries(
+        serializedAgents.map((a) => [a.characterId, a]),
+      ),
+      worldState: state.worldState,
+    },
+  }
+}
+
+// ── 反序列化 ──
+
+function deserializeMemory(s: SerializedAgentMemory): AgentMemory {
+  return {
+    observedEvents: s.observedEvents,
+    knownSecrets: new Set(s.knownSecrets),
+    sentiments: new Map(Object.entries(s.sentiments)),
+    recentDecisions: s.recentDecisions,
+  }
+}
+
+export function deserializeAgent(s: SerializedNovelAgent): NovelAgent {
+  const relationships = new Map<string, AgentRelation>()
+  for (const r of s.relationships) {
+    relationships.set(r.targetId, r)
+  }
+  return {
+    characterId: s.characterId,
+    name: s.name,
+    profile: s.profile,
+    aura: s.aura as NovelAgent["aura"],
+    cognition: s.cognition,
+    soul: s.soul,
+    currentGoal: s.currentGoal,
+    emotionalState: s.emotionalState,
+    knownFacts: new Set(s.knownFacts),
+    relationships,
+    powerLevel: s.powerLevel,
+    memory: deserializeMemory(s.memory),
+    knowledgeScope: s.knowledgeScope,
+    personality: s.personality,
+    speakingStyle: s.speakingStyle,
+  }
+}
+
+export function deserializeSimulationSnapshot(
+  snapshot: SerializedSimulationSnapshot,
+): { agents: NovelAgent[]; state: SimulationState } {
+  const agents = snapshot.agents.map(deserializeAgent)
+  const activeAgents = new Map<string, NovelAgent>()
+  for (const a of agents) {
+    activeAgents.set(a.characterId, a)
+  }
+  const state: SimulationState = {
+    currentRound: snapshot.state.currentRound,
+    timelineEvents: snapshot.state.timelineEvents as SimulationState["timelineEvents"],
+    activeAgents,
+    worldState: snapshot.state.worldState,
+  }
+  return { agents, state }
+}

+ 181 - 0
src/lib/novel/story-simulation/story-draft-generator.ts

@@ -0,0 +1,181 @@
+import type { ChatMessage } from "@/lib/llm-client"
+import { streamChat } from "@/lib/llm-client"
+import type { LlmConfig } from "@/stores/wiki-store"
+import type {
+  CharacterAnalysis,
+  DraftChapter,
+  SimulationReport,
+  StoryBranch,
+  StoryDraft,
+  StoryFramework,
+  StoryNode,
+} from "@/lib/novel/story-simulation/types"
+
+// ── 对外接口 ──
+
+export interface DraftGenerationOptions {
+  framework: StoryFramework
+  report: SimulationReport
+  selectedBranch: StoryBranch
+  llmConfig: LlmConfig
+  onProgress?: (label: string) => void
+  onChapterGenerated?: (chapter: DraftChapter) => void
+  signal?: AbortSignal
+}
+
+// ── 内部辅助:将 streamChat 的流式回调收拢为一个完整字符串 ──
+
+async function collectStream(
+  config: LlmConfig,
+  messages: ChatMessage[],
+  signal?: AbortSignal,
+): Promise<string> {
+  let result = ""
+  let streamError: Error | null = null
+
+  await streamChat(
+    config,
+    messages,
+    {
+      onToken: (token) => {
+        result += token
+      },
+      onDone: () => {},
+      onError: (err) => {
+        streamError = err
+      },
+    },
+    signal,
+  )
+
+  if (streamError) throw streamError
+  return result
+}
+
+// ── 内部辅助:统计正文字数(按非空白字符近似,中文每字计 1) ──
+
+function countWords(text: string): number {
+  return text.replace(/\s/g, "").length
+}
+
+// ── 内部辅助:构建单章提示词 ──
+
+function buildChapterPrompt(
+  node: StoryNode,
+  branch: StoryBranch,
+  relatedAnalyses: CharacterAnalysis[],
+  chapterIndex: number,
+  totalChapters: number,
+  targetWords: number,
+): string {
+  const lines: string[] = []
+  lines.push(`当前是第 ${chapterIndex + 1} / ${totalChapters} 章,对应故事框架中的「${node.title}」节点。`)
+  lines.push("")
+  lines.push("【当前节点信息】")
+  lines.push(`阶段:${node.phase}`)
+  lines.push(`节点标题:${node.title}`)
+  lines.push(`核心冲突:${node.coreConflict}`)
+  lines.push(`涉及角色:${node.involvedCharacters.join("、") || "(未指定)"}`)
+  lines.push(`本章目标:${node.goal}`)
+  lines.push(`预期结果:${node.expectedOutcome}`)
+  if (node.causeFromPrev) {
+    lines.push(`承接上文的因果:${node.causeFromPrev}`)
+  }
+  lines.push("")
+  lines.push("【选择的推演走向分支】")
+  lines.push(`分支标题:${branch.title}`)
+  lines.push(`分支概要:${branch.summary}`)
+  lines.push(`关键事件:${branch.keyEvents.length > 0 ? branch.keyEvents.join(";") : "(无)"}`)
+  lines.push("")
+  lines.push("【相关角色的行为分析】")
+  if (relatedAnalyses.length === 0) {
+    lines.push("(无相关角色的行为分析数据)")
+  } else {
+    for (const analysis of relatedAnalyses) {
+      lines.push(`角色:${analysis.name}`)
+      // 优先取与当前节点相关的行为;若没有则展示该角色的全部行为,保留上下文。
+      const nodeBehaviors = analysis.behaviors.filter((b) => b.node === node.title)
+      const behaviors = nodeBehaviors.length > 0 ? nodeBehaviors : analysis.behaviors
+      for (const b of behaviors) {
+        lines.push(`  - 行为:${b.action}(动机:${b.motivation})`)
+      }
+      if (analysis.stateChanges.length > 0) {
+        lines.push(`  状态变化:${analysis.stateChanges.join(";")}`)
+      }
+    }
+  }
+  lines.push("")
+  lines.push("【写作要求】")
+  lines.push("1. 请根据以上框架节点、走向分支和角色行为分析,撰写本章正文。")
+  lines.push(`2. 本章目标字数约 ${targetWords} 字。`)
+  lines.push("3. 只输出正文内容,不要输出章节标题,也不要输出任何说明、标注或元信息。")
+  lines.push("4. 保持叙事连贯,自然承接上一章。")
+  return lines.join("\n")
+}
+
+// ── 主流程:按框架节点逐章生成故事草稿 ──
+
+export async function generateStoryDraft(options: DraftGenerationOptions): Promise<StoryDraft> {
+  const { framework, report, selectedBranch, llmConfig, onProgress, onChapterGenerated, signal } = options
+
+  const nodes = framework.nodes
+  const nodeCount = nodes.length
+  const perChapterTarget =
+    nodeCount > 0 ? Math.max(1, Math.round(framework.targetWords / nodeCount)) : framework.targetWords
+
+  const chapters: DraftChapter[] = []
+  let totalWords = 0
+
+  const systemPrompt =
+    "你是一位专业的小说作者。请根据提供的故事框架与角色行为分析,撰写高质量的章节正文。" +
+    "要求叙事连贯、人物性格一致、冲突推进合理。只输出正文,不要输出章节标题或任何说明性文字。"
+
+  for (let i = 0; i < nodeCount; i += 1) {
+    const node = nodes[i]
+    onProgress?.(`正在生成第 ${i + 1}/${nodeCount} 章:${node.title}`)
+
+    // 从推演报告中筛选当前节点涉及的角色行为分析
+    const involvedNames = new Set(node.involvedCharacters)
+    const relatedAnalyses = report.characterAnalyses.filter(
+      (analysis) => involvedNames.has(analysis.name) || involvedNames.has(analysis.characterId),
+    )
+
+    const userPrompt = buildChapterPrompt(
+      node,
+      selectedBranch,
+      relatedAnalyses,
+      i,
+      nodeCount,
+      perChapterTarget,
+    )
+
+    const messages: ChatMessage[] = [
+      { role: "system", content: systemPrompt },
+      { role: "user", content: userPrompt },
+    ]
+
+    const content = await collectStream(llmConfig, messages, signal)
+    const trimmed = content.trim()
+
+    const chapter: DraftChapter = {
+      title: node.title,
+      content: trimmed,
+      correspondingNode: node.index,
+      rawContent: trimmed,
+    }
+
+    chapters.push(chapter)
+    totalWords += countWords(trimmed)
+    onChapterGenerated?.(chapter)
+  }
+
+  const draft: StoryDraft = {
+    branchId: selectedBranch.title,
+    frameworkId: framework.id,
+    chapters,
+    totalWords,
+    createdAt: new Date().toISOString(),
+  }
+
+  return draft
+}

+ 400 - 0
src/lib/novel/story-simulation/story-extractor.ts

@@ -0,0 +1,400 @@
+/**
+ * 全维度内容提取器
+ *
+ * 从小说项目中提取角色特征、章节内容、记忆库、世界规则等,
+ * 用于后续的仿真推演。所有文件读取均带容错处理,单个文件缺失
+ * 不会中断整体提取流程。
+ */
+
+import { readFile, listDirectory } from "@/commands/fs"
+import { normalizePath } from "@/lib/path-utils"
+import { parseFrontmatter } from "@/lib/frontmatter"
+import { readSoulDoc } from "@/lib/novel/soul-doc"
+import { loadCognitionState } from "@/lib/novel/character-cognition"
+import { loadForeshadowingTracker } from "@/lib/novel/foreshadowing-tracker"
+import { getTimelineEvents } from "@/lib/novel/timeline"
+import {
+  loadCharacterStates,
+  characterStatesToContextText,
+} from "@/lib/novel/character-state"
+import { loadSnapshot, listSnapshots } from "@/lib/novel/chapter-ingest"
+import {
+  listCharacterAuras,
+  getCharacterAuraBindings,
+  loadCharacterAuraSkillDocument,
+} from "@/lib/novel/character-aura"
+import type {
+  ExtractionResult,
+  ExtractedCharacter,
+  ExtractedChapterContent,
+  ExtractedMemoryData,
+} from "./types"
+
+// ── 对外接口 ──
+
+export interface ExtractionOptions {
+  sourceChapters: number
+  onProgress?: (progress: number, label: string) => void
+}
+
+/**
+ * 从小说项目中提取全维度内容。
+ *
+ * 提取维度包括:大纲、灵魂文档、最近 N 章正文、记忆库
+ * (角色状态 / 认知 / 伏笔 / 时间线 / 正史 / 冲突)、角色
+ * 完整特征(档案 + 光环 + 认知 + 技能)、世界规则与力量体系。
+ */
+export async function extractStoryContent(
+  projectPath: string,
+  options: ExtractionOptions,
+): Promise<ExtractionResult> {
+  const pp = normalizePath(projectPath)
+  const { sourceChapters, onProgress } = options
+  const report = (progress: number, label: string): void => {
+    onProgress?.(progress, label)
+  }
+
+  // 1. 读取大纲(5%)
+  report(5, "正在读取大纲...")
+  const outlineContent = await readOutlines(pp)
+
+  // 2. 读取项目灵魂文档(15%)
+  report(15, "正在读取灵魂文档...")
+  const soulDoc = await readSoulDoc(pp)
+
+  // 3. 读取最近 N 章内容(25%)
+  report(25, `正在读取最近 ${sourceChapters} 章内容...`)
+  const chapterContents = await readRecentChapters(pp, sourceChapters)
+
+  // 4. 读取记忆库(40%)
+  report(40, "正在读取记忆库...")
+  const memoryData = await readMemoryData(pp)
+
+  // 5. 读取角色完整特征(55%)
+  report(55, "正在提取角色完整特征...")
+  const characters = await extractCharacters(pp)
+
+  // 6. 从大纲中提取世界规则和力量体系(70%)
+  report(70, "正在从大纲中提取世界规则与力量体系...")
+  const worldRules = extractWorldRules(outlineContent)
+  const powerSystem = extractPowerSystem(outlineContent)
+
+  // 7. 汇总结果(85% → 100%)
+  report(85, "正在汇总提取结果...")
+
+  const result: ExtractionResult = {
+    characters,
+    chapterContents,
+    memoryData,
+    worldRules,
+    powerSystem,
+    foreshadowing: memoryData.foreshadowingTracker,
+    timeline: memoryData.timeline,
+    outlineContent,
+    soulDoc,
+  }
+
+  report(100, "全维度内容提取完成")
+  return result
+}
+
+// ── 内部实现 ──
+
+/**
+ * 从 frontmatter 值(string | string[])中取字符串。
+ */
+function fmString(value: string | string[] | undefined): string {
+  if (value === undefined) return ""
+  return Array.isArray(value) ? (value[0] ?? "") : value
+}
+
+/**
+ * 从 frontmatter 值中取数字,无法解析时返回 NaN。
+ */
+function fmNumber(value: string | string[] | undefined): number {
+  const num = Number(fmString(value))
+  return Number.isFinite(num) ? num : NaN
+}
+
+/**
+ * 读取 wiki/outlines/ 目录下所有大纲文件,按文件名排序后拼接。
+ */
+async function readOutlines(pp: string): Promise<string> {
+  const outlinesDir = `${pp}/wiki/outlines`
+  let nodes
+  try {
+    nodes = await listDirectory(outlinesDir)
+  } catch {
+    return ""
+  }
+
+  const mdFiles = nodes
+    .filter((n) => !n.is_dir && n.name.endsWith(".md"))
+    .sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }))
+
+  const contents: string[] = []
+  for (const node of mdFiles) {
+    try {
+      contents.push(await readFile(node.path))
+    } catch {
+      // 单个文件读取失败,跳过
+    }
+  }
+  return contents.join("\n\n---\n\n")
+}
+
+/**
+ * 读取最近 N 章内容。从 wiki/chapters/ 目录按章节号排序后取最后 N 章,
+ * 每章的摘要从对应章节快照中获取。
+ */
+async function readRecentChapters(
+  pp: string,
+  count: number,
+): Promise<ExtractedChapterContent[]> {
+  const chaptersDir = `${pp}/wiki/chapters`
+  let nodes
+  try {
+    nodes = await listDirectory(chaptersDir)
+  } catch {
+    return []
+  }
+
+  const mdFiles = nodes.filter((n) => !n.is_dir && n.name.endsWith(".md"))
+
+  // 解析每个章节文件,获取章节号、标题和正文
+  const parsed: { number: number; title: string; content: string }[] = []
+  for (const node of mdFiles) {
+    try {
+      const raw = await readFile(node.path)
+      const result = parseFrontmatter(raw)
+      const fm = result.frontmatter
+      const chapterNumber = fmNumber(fm?.chapter_number)
+      if (!Number.isFinite(chapterNumber)) continue
+      const title = fmString(fm?.title) || node.name.replace(/\.md$/, "")
+      parsed.push({ number: chapterNumber, title, content: result.body })
+    } catch {
+      // 单个章节解析失败,跳过
+    }
+  }
+
+  // 按章节号排序(numeric)
+  parsed.sort((a, b) => a.number - b.number)
+
+  // 取最后 N 章
+  const recent = parsed.slice(-count)
+
+  // 为每章补充摘要(从快照获取)
+  const results: ExtractedChapterContent[] = []
+  for (const ch of recent) {
+    let summary = ""
+    try {
+      const snapshot = await loadSnapshot(pp, ch.number)
+      if (snapshot) summary = snapshot.summary
+    } catch {
+      // 无快照,摘要留空
+    }
+    results.push({
+      chapterNumber: ch.number,
+      title: ch.title,
+      summary,
+      content: ch.content,
+    })
+  }
+
+  return results
+}
+
+/**
+ * 读取记忆库数据:角色状态、角色认知、伏笔追踪、时间线、正史、冲突。
+ */
+async function readMemoryData(pp: string): Promise<ExtractedMemoryData> {
+  // 角色状态 → 转为文本
+  const characterStates = await loadCharacterStates(pp)
+    .then((store) => characterStatesToContextText(store))
+    .catch(() => "")
+
+  // 角色认知状态
+  const characterCognition = await loadCognitionState(pp).catch(() => null)
+
+  // 伏笔追踪
+  const foreshadowingTracker = await loadForeshadowingTracker(pp).catch(
+    () => null,
+  )
+
+  // 时间线 → 提取事件文本
+  const timeline: string[] = await getTimelineEvents(pp)
+    .then((entries) => entries.map((e) => e.event))
+    .catch(() => [])
+
+  // 正史设定
+  const canonFacts = await readFile(
+    `${pp}/wiki/memory/canon-facts.md`,
+  ).catch(() => "")
+
+  // 冲突记录
+  const conflicts = await readFile(
+    `${pp}/wiki/memory/conflicts.md`,
+  ).catch(() => "")
+
+  return {
+    characterStates,
+    characterCognition,
+    foreshadowingTracker,
+    timeline,
+    canonFacts,
+    conflicts,
+  }
+}
+
+/**
+ * 提取角色完整特征。
+ *
+ * 从章节快照中获取角色名列表,然后匹配光环(aura)、
+ * 认知(cognition)和技能(skill)数据,并读取角色档案页。
+ */
+async function extractCharacters(pp: string): Promise<ExtractedCharacter[]> {
+  // 从章节快照中收集角色名
+  const snapshotNumbers = (await listSnapshots(pp).catch(() => [])).filter(
+    (n) => n > 0,
+  )
+
+  const characterNames = new Set<string>()
+  for (const num of snapshotNumbers) {
+    try {
+      const snapshot = await loadSnapshot(pp, num)
+      if (snapshot) {
+        for (const name of snapshot.characters) {
+          const trimmed = name.trim()
+          if (trimmed) characterNames.add(trimmed)
+        }
+      }
+    } catch {
+      // 单个快照加载失败,跳过
+    }
+  }
+
+  if (characterNames.size === 0) return []
+
+  // 加载光环数据和绑定关系
+  const auras = await listCharacterAuras(pp).catch(() => [])
+  const bindings = await getCharacterAuraBindings(pp).catch(() => [])
+
+  // 加载角色认知状态
+  const cognitionState = await loadCognitionState(pp).catch(() => null)
+
+  const characters: ExtractedCharacter[] = []
+  for (const name of characterNames) {
+    // 匹配光环绑定(按角色名或别名)
+    const binding = bindings.find(
+      (b) => b.characterName === name || (b.aliases && b.aliases.includes(name)),
+    )
+    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
+
+    // 读取技能文档(来自光环的 skillFolder)
+    let skillContent = ""
+    if (aura) {
+      try {
+        skillContent = await loadCharacterAuraSkillDocument(aura, pp)
+      } catch {
+        // 技能文档读取失败,留空
+      }
+    }
+
+    // 读取角色档案页(wiki/entities/{name}.md)
+    let profile = ""
+    try {
+      profile = await readFile(`${pp}/wiki/entities/${name}.md`)
+    } catch {
+      // 无角色档案页,留空
+    }
+
+    characters.push({
+      id: name,
+      name,
+      profile,
+      aura,
+      cognition,
+      // 角色级灵魂文档在当前系统中尚无独立存储,留空;
+      // 项目级灵魂文档已在 ExtractionResult.soulDoc 中单独提供。
+      soul: "",
+      skillContent,
+    })
+  }
+
+  return characters
+}
+
+/**
+ * 从大纲内容中提取世界规则。
+ *
+ * 查找标题中包含"世界规则""世界观""法则"等关键词的章节,
+ * 返回该章节标题下方、下一个同级标题之前的正文内容。
+ */
+function extractWorldRules(outlineContent: string): string {
+  return extractSectionByKeyword(outlineContent, [
+    "世界规则",
+    "世界法则",
+    "世界观设定",
+    "世界设定",
+    "设定规则",
+    "法则体系",
+    "世界规则设定",
+  ])
+}
+
+/**
+ * 从大纲内容中提取力量体系。
+ *
+ * 查找标题中包含"力量体系""修炼体系""能力体系"等关键词的章节。
+ */
+function extractPowerSystem(outlineContent: string): string {
+  return extractSectionByKeyword(outlineContent, [
+    "力量体系",
+    "修炼体系",
+    "能力体系",
+    "战力体系",
+    "魔法体系",
+    "超凡体系",
+    "力量设定",
+    "修炼设定",
+  ])
+}
+
+/**
+ * 通用 Markdown 章节提取:按标题关键词定位章节,返回标题下方正文。
+ *
+ * 遍历所有标题行,找到第一个包含任一关键词的标题后,
+ * 收集该标题之后、直到下一个标题行之间的所有内容。
+ */
+function extractSectionByKeyword(
+  content: string,
+  keywords: string[],
+): string {
+  const lines = content.split("\n")
+  for (let i = 0; i < lines.length; i++) {
+    const line = lines[i]
+    if (!/^#{1,6}\s/.test(line)) continue
+
+    const headingLower = line.toLowerCase()
+    if (!keywords.some((kw) => headingLower.includes(kw.toLowerCase()))) continue
+
+    // 收集标题下方内容,直到下一个标题行
+    const sectionLines: string[] = []
+    for (let j = i + 1; j < lines.length; j++) {
+      if (/^#{1,6}\s/.test(lines[j])) break
+      sectionLines.push(lines[j])
+    }
+    const section = sectionLines.join("\n").trim()
+    if (section) return section
+  }
+  return ""
+}

+ 450 - 0
src/lib/novel/story-simulation/story-framework-generator.ts

@@ -0,0 +1,450 @@
+/**
+ * 故事框架生成器
+ *
+ * 调用 LLM 分析已写内容(角色、章节概要、世界规则、力量体系、
+ * 伏笔、时间线、灵魂文档)与用户思路,生成遵循"起承转合"四段式
+ * 结构的故事框架。框架由 premise(核心前提)与若干 StoryNode 组成,
+ * 节点之间以因果链串联。
+ *
+ * 设计原则:
+ * - 只做生成,不读写文件(输入由调用方通过 ExtractionResult 传入)。
+ * - JSON 解析健壮:兼容 ```json 代码块与裸 JSON,失败时返回合理的
+ *   空框架,绝不抛出未捕获异常。
+ */
+
+import type { LlmConfig } from "@/stores/wiki-store"
+import { streamChat, type ChatMessage } from "@/lib/llm-client"
+import type {
+  ExtractionResult,
+  StoryFramework,
+  StoryNode,
+  SimulationMode,
+} from "./types"
+import { calcNodeCount } from "./types"
+
+// ── 对外接口 ──
+
+export interface FrameworkGenerationOptions {
+  extraction: ExtractionResult
+  mode: SimulationMode
+  targetWords: number
+  userIdea?: string
+  llmConfig: LlmConfig
+  onProgress?: (label: string) => void
+}
+
+/**
+ * 生成故事框架。
+ *
+ * 流程:
+ * 1. calcNodeCount(targetWords) 确定节点数量
+ * 2. 基于提取结果构建提示词
+ * 3. 调用 streamChat 收集完整响应
+ * 4. 解析 JSON 为 StoryFramework
+ * 5. 生成唯一 ID 与标题
+ *
+ * 任何环节失败均返回合理的空框架(premise 携带原因,nodes 为空)。
+ */
+export async function generateStoryFramework(
+  options: FrameworkGenerationOptions,
+): Promise<StoryFramework> {
+  const { extraction, mode, targetWords, userIdea, llmConfig, onProgress } =
+    options
+
+  const nodeCount = calcNodeCount(targetWords)
+  const sourceChapters = extraction.chapterContents.length
+
+  onProgress?.("正在构建提示词...")
+  const messages = buildMessages(extraction, mode, targetWords, nodeCount, userIdea)
+
+  onProgress?.("正在调用模型生成故事框架...")
+  const raw = await collectStream(llmConfig, messages)
+
+  onProgress?.("正在解析框架...")
+  const parsed = parseFrameworkJson(raw)
+  if (!parsed) {
+    return buildEmptyFramework(
+      options,
+      "模型未返回可解析的框架内容,请重试或更换模型。",
+    )
+  }
+
+  const nodes = normalizeNodes(parsed.nodes, nodeCount)
+  if (nodes.length === 0) {
+    return buildEmptyFramework(
+      options,
+      "模型返回的框架不包含任何节点,请重试或调整目标字数。",
+    )
+  }
+
+  const framework: StoryFramework = {
+    id: `framework-${Date.now()}`,
+    title: buildTitle(parsed.premise, userIdea),
+    shortTitle: typeof parsed.shortTitle === 'string' && parsed.shortTitle.trim()
+      ? parsed.shortTitle.trim().slice(0, 10)
+      : buildShortTitle(parsed.premise, userIdea),
+    premise: strOr(parsed.premise, ""),
+    targetWords,
+    simulationMode: mode,
+    userIdea,
+    sourceChapters,
+    nodes,
+    createdAt: new Date().toISOString(),
+  }
+
+  onProgress?.("故事框架生成完成")
+  return framework
+}
+
+// ── 提示词构建 ──
+
+function buildMessages(
+  extraction: ExtractionResult,
+  mode: SimulationMode,
+  targetWords: number,
+  nodeCount: number,
+  userIdea?: string,
+): ChatMessage[] {
+  const systemPrompt = [
+    '你是一位资深的故事架构师。请根据用户提供的已写内容与设定,生成一个遵循"起承转合"四段式结构的故事框架。',
+    "",
+    "要求:",
+    "1. 根据目标字数,生成指定数量的关键节点,覆盖起、承、转、合四个阶段。",
+    "2. 每个节点必须包含以下字段:",
+    '   - phase:阶段标识,取值仅为 "起"、"承"、"转"、"合" 之一',
+    "   - title:节点标题(简短有力)",
+    "   - coreConflict:该节点的核心冲突",
+    "   - involvedCharacters:涉及的角色名称数组",
+    "   - goal:该节点要达成的叙事目标",
+    '   - causeFromPrev:由上一节点导致的直接原因(第一个节点填"故事开端")',
+    "   - expectedOutcome:预期导致的结局或转折",
+    "3. 节点之间必须有明确的因果链:后一节点的 causeFromPrev 应承接前一节点的 expectedOutcome。",
+    "4. premise:用一两句话概括整个故事的核心前提。",
+    "5. shortTitle:为这个故事框架取一个简短标题(4-8个字),用于侧边栏显示,如'指认风波'、'毒影疑云'。",
+    "6. 只输出一个 JSON 对象,不要输出任何解释、注释或多余文字。",
+    "",
+    "输出格式(严格遵循):",
+    "{",
+    '  "premise": "故事核心前提",',
+    '  "shortTitle": "简短标题",',
+    '  "nodes": [',
+    "    {",
+    '      "phase": "起",',
+    '      "title": "...",',
+    '      "coreConflict": "...",',
+    '      "involvedCharacters": ["角色A", "角色B"],',
+    '      "goal": "...",',
+    '      "causeFromPrev": "故事开端",',
+    '      "expectedOutcome": "..."',
+    "    }",
+    "  ]",
+    "}",
+  ].join("\n")
+
+  const userPrompt = buildUserPrompt(
+    extraction,
+    mode,
+    targetWords,
+    nodeCount,
+    userIdea,
+  )
+
+  return [
+    { role: "system", content: systemPrompt },
+    { role: "user", content: userPrompt },
+  ]
+}
+
+function buildUserPrompt(
+  extraction: ExtractionResult,
+  mode: SimulationMode,
+  targetWords: number,
+  nodeCount: number,
+  userIdea?: string,
+): string {
+  const sections: string[] = []
+
+  sections.push(`# 生成任务`)
+  sections.push(`目标字数:${targetWords}`)
+  sections.push(`需要生成的节点数量:${nodeCount}`)
+  sections.push(`仿真模式:${mode}`)
+  if (userIdea) {
+    sections.push(`用户思路:${userIdea}`)
+  }
+
+  // 角色信息
+  sections.push("")
+  sections.push("# 角色信息")
+  if (extraction.characters.length === 0) {
+    sections.push("(暂无已提取角色)")
+  } else {
+    for (const c of extraction.characters) {
+      sections.push(`## ${c.name}`)
+      if (c.profile) sections.push(`档案:${truncate(c.profile, 2000)}`)
+      if (c.aura) sections.push(`光环:${JSON.stringify(c.aura)}`)
+      if (c.cognition) {
+        sections.push(
+          `认知:知道[${c.cognition.knows.join("、")}];不知道[${c.cognition.doesNotKnow.join("、")}]`,
+        )
+      }
+      if (c.skillContent) sections.push(`技能:${truncate(c.skillContent, 1500)}`)
+      if (c.soul) sections.push(`灵魂:${truncate(c.soul, 1500)}`)
+    }
+  }
+
+  // 章节概要
+  sections.push("")
+  sections.push("# 已写章节概要")
+  if (extraction.chapterContents.length === 0) {
+    sections.push("(暂无已写章节)")
+  } else {
+    for (const ch of extraction.chapterContents) {
+      const summary = ch.summary?.trim() || truncate(ch.content, 800)
+      sections.push(`第${ch.chapterNumber}章《${ch.title}》:${summary}`)
+    }
+  }
+
+  // 世界规则
+  sections.push("")
+  sections.push("# 世界规则")
+  sections.push(extraction.worldRules?.trim() || "(未提取到世界规则)")
+
+  // 力量体系
+  sections.push("")
+  sections.push("# 力量体系")
+  sections.push(extraction.powerSystem?.trim() || "(未提取到力量体系)")
+
+  // 伏笔
+  sections.push("")
+  sections.push("# 伏笔追踪")
+  if (extraction.foreshadowing) {
+    sections.push(JSON.stringify(extraction.foreshadowing))
+  } else {
+    sections.push("(暂无伏笔记录)")
+  }
+
+  // 时间线
+  sections.push("")
+  sections.push("# 时间线")
+  if (extraction.timeline.length > 0) {
+    sections.push(extraction.timeline.map((e, i) => `${i + 1}. ${e}`).join("\n"))
+  } else {
+    sections.push("(暂无时间线事件)")
+  }
+
+  // 灵魂文档
+  sections.push("")
+  sections.push("# 灵魂文档")
+  sections.push(extraction.soulDoc?.trim() || "(未提取到灵魂文档)")
+
+  sections.push("")
+  sections.push(
+    `请基于以上内容生成 ${nodeCount} 个节点的故事框架,严格按系统提示要求的 JSON 格式输出。`,
+  )
+
+  return sections.join("\n")
+}
+
+// ── 流式收集 ──
+
+/**
+ * 调用 streamChat 并累积完整文本响应。
+ * 若流式过程中出错,抛出携带错误信息的 Error,由调用方决定回退策略。
+ */
+function collectStream(
+  llmConfig: LlmConfig,
+  messages: ChatMessage[],
+): Promise<string> {
+  return new Promise<string>((resolve, reject) => {
+    let response = ""
+    streamChat(
+      llmConfig,
+      messages,
+      {
+        onToken: (token) => {
+          response += token
+        },
+        onDone: () => {
+          resolve(response)
+        },
+        onError: (err) => {
+          reject(err)
+        },
+      },
+    ).catch((err) => {
+      reject(err instanceof Error ? err : new Error(String(err)))
+    })
+  })
+}
+
+// ── JSON 解析 ──
+
+interface ParsedFramework {
+  premise?: unknown
+  shortTitle?: unknown
+  nodes?: unknown
+}
+
+/**
+ * 从模型输出中提取框架 JSON。
+ *
+ * 兼容三种情况:
+ * 1. ```json ... ``` 代码块包裹
+ * 2. 裸 JSON 对象
+ * 3. JSON 前后夹杂多余文字(取最外层 { ... })
+ *
+ * 解析失败返回 null。
+ */
+function parseFrameworkJson(raw: string): ParsedFramework | null {
+  const text = raw.trim()
+  if (!text) return null
+
+  // 1. 提取 ```json / ``` 代码块
+  const fenceMatch = text.match(/```(?:json)?\s*([\s\S]*?)```/i)
+  const candidate = fenceMatch ? fenceMatch[1].trim() : text
+
+  // 2. 直接解析
+  const direct = tryParse(candidate)
+  if (direct) return direct
+
+  // 3. 截取最外层 { ... }
+  const start = candidate.indexOf("{")
+  const end = candidate.lastIndexOf("}")
+  if (start !== -1 && end !== -1 && end > start) {
+    const slice = candidate.slice(start, end + 1)
+    const sliced = tryParse(slice)
+    if (sliced) return sliced
+  }
+
+  return null
+}
+
+function tryParse(text: string): ParsedFramework | null {
+  try {
+    const obj = JSON.parse(text)
+    if (obj && typeof obj === "object") return obj as ParsedFramework
+  } catch {
+    // 忽略,交由外层回退
+  }
+  return null
+}
+
+// ── 节点归一化 ──
+
+const VALID_PHASES: ReadonlySet<string> = new Set(["起", "承", "转", "合"])
+
+/**
+ * 将模型返回的节点数组归一化为合法的 StoryNode[]。
+ *
+ * - 截断到目标节点数量(允许少于目标,但不补齐空节点)。
+ * - 校验 phase 取值,非法时按位置比例推导,保证起承转合分布合理。
+ * - 缺失字段填充合理默认值。
+ */
+function normalizeNodes(raw: unknown, count: number): StoryNode[] {
+  if (!Array.isArray(raw)) return []
+  const list = raw.slice(0, count)
+  const total = list.length
+  const nodes: StoryNode[] = []
+  for (let i = 0; i < total; i++) {
+    const n = (list[i] ?? {}) as Record<string, unknown>
+    nodes.push({
+      index: i,
+      phase: normalizePhase(n.phase, i, total),
+      title: strOr(n.title, `节点 ${i + 1}`),
+      coreConflict: strOr(n.coreConflict, ""),
+      involvedCharacters: arrOr(n.involvedCharacters, []),
+      goal: strOr(n.goal, ""),
+      causeFromPrev: strOr(n.causeFromPrev, i === 0 ? "故事开端" : ""),
+      expectedOutcome: strOr(n.expectedOutcome, ""),
+    })
+  }
+  return nodes
+}
+
+function normalizePhase(
+  value: unknown,
+  index: number,
+  total: number,
+): StoryNode["phase"] {
+  if (typeof value === "string" && VALID_PHASES.has(value)) {
+    return value as StoryNode["phase"]
+  }
+  return derivePhase(index, total)
+}
+
+/**
+ * 按节点位置比例推导阶段,保证起承转合四段分布。
+ */
+function derivePhase(index: number, total: number): StoryNode["phase"] {
+  if (total <= 0) return "起"
+  const ratio = index / total
+  if (ratio < 0.25) return "起"
+  if (ratio < 0.5) return "承"
+  if (ratio < 0.75) return "转"
+  return "合"
+}
+
+// ── 空框架回退 ──
+
+function buildEmptyFramework(
+  options: FrameworkGenerationOptions,
+  reason: string,
+): StoryFramework {
+  return {
+    id: `framework-${Date.now()}`,
+    title: "故事框架(生成失败,已回退为空框架)",
+    shortTitle: "生成失败",
+    premise: reason,
+    targetWords: options.targetWords,
+    simulationMode: options.mode,
+    userIdea: options.userIdea,
+    sourceChapters: options.extraction.chapterContents.length,
+    nodes: [],
+    createdAt: new Date().toISOString(),
+  }
+}
+
+// ── 工具函数 ──
+
+function buildTitle(premise: unknown, userIdea?: string): string {
+  if (userIdea) return truncate(userIdea, 30)
+  if (typeof premise === "string" && premise.trim()) {
+    return truncate(premise.trim(), 30)
+  }
+  return "故事框架"
+}
+
+function buildShortTitle(premise: unknown, userIdea?: string): string {
+  const source = userIdea || (typeof premise === "string" ? premise : "")
+  if (!source) return "故事框架"
+  // 取前8个字符作为简短标题
+  return source.trim().slice(0, 8)
+}
+
+function truncate(text: string, max: number): string {
+  const t = text.trim()
+  if (t.length <= max) return t
+  return `${t.slice(0, max)}…`
+}
+
+function strOr(value: unknown, fallback: string): string {
+  if (typeof value === "string" && value.trim()) return value.trim()
+  return fallback
+}
+
+/**
+ * 将未知值归一化为字符串数组。
+ * 支持:数组、以常见分隔符切分的字符串。
+ */
+function arrOr(value: unknown, fallback: string[]): string[] {
+  if (Array.isArray(value)) {
+    return value.map((v) => String(v).trim()).filter(Boolean)
+  }
+  if (typeof value === "string" && value.trim()) {
+    return value
+      .split(/[,,、;;\n]/)
+      .map((s) => s.trim())
+      .filter(Boolean)
+  }
+  return fallback
+}

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

@@ -0,0 +1,459 @@
+import type { CharacterAura } from "@/lib/novel/character-aura"
+import type { CognitionState } from "@/lib/novel/character-cognition"
+import type { ForeshadowingStore } from "@/lib/novel/foreshadowing-tracker"
+import type { LlmConfig } from "@/stores/wiki-store"
+
+// ── 仿真模式 ──
+export type SimulationMode = "event-driven" | "free-emergence" | "decision-tree" | "hybrid"
+
+// ── Agent 行为类型 ──
+export type AgentActionType =
+  | "evaluate"    // 角色评价/看法
+  | "pushPlot"    // 事态推动/主动行动
+  | "observe"     // 观察/感知
+  | "react"       // 对他人行为的反应
+  | "speak"       // 对话
+  | "ally"        // 结盟/合作
+  | "confront"    // 对抗
+  | "conceal"     // 隐瞒
+  | "investigate" // 调查
+  // 保留旧类型以兼容已有代码(新引擎不再产生)
+  | "act"
+  | "decide"
+  | "conflict"
+  | "cooperate"
+  | "withhold"
+
+export type ActionVisibility = "all" | "target_only" | "self"
+
+/**
+ * Agent 行为(扁平化结构,与 LLM 输出 JSON 对齐)
+ */
+export interface AgentAction {
+  type: AgentActionType
+  content: string
+  target?: string
+  /** 行为可见性:公开(all)/仅目标可见(target_only)/仅自己可见(self) */
+  visibility?: ActionVisibility
+  /** 行为动机 */
+  motivation?: string
+  /** 如何推动剧情 */
+  plot_push?: string
+}
+
+// ── 事件影响类型 ──
+export type EventImpactType = "sentiment" | "knowledge" | "relationship"
+
+export interface EventImpact {
+  characterId: string
+  type: EventImpactType
+  detail: string
+}
+
+// ── 时间线事件(新仿真引擎核心事件结构) ──
+export interface TimelineEvent {
+  id: string
+  round: number
+  nodeIndex: number
+  actorId: string
+  actorName: string
+  actionType: AgentActionType
+  content: string
+  targetId?: string
+  targetName?: string
+  /** 能观察到该事件的角色 ID 列表 */
+  observableBy: string[]
+  /** 事件对角色的影响 */
+  impacts: EventImpact[]
+  timestamp: string
+}
+
+// ── Agent 记忆 ──
+export interface AgentMemory {
+  /** 已观察到的事件 ID 列表 */
+  observedEvents: string[]
+  /** 已知秘密集合 */
+  knownSecrets: Set<string>
+  /** 对其他角色的情感值:key=角色ID, value=-100~100 */
+  sentiments: Map<string, number>
+  /** 最近决策记录 */
+  recentDecisions: string[]
+}
+
+// ── Agent ──
+export interface NovelAgent {
+  characterId: string
+  name: string
+  profile: string
+  aura: CharacterAura | null
+  cognition: { knows: string[]; doesNotKnow: string[] } | null
+  soul: string
+  currentGoal: string
+  emotionalState: string
+  /** @deprecated 使用 memory.sentiments 替代,保留以兼容旧代码 */
+  knownFacts: Set<string>
+  /** @deprecated 使用 memory.sentiments 替代,保留以兼容旧代码 */
+  relationships: Map<string, AgentRelation>
+  powerLevel: string
+  /** 新增:Agent 记忆 */
+  memory: AgentMemory
+  /** 新增:角色知道的信息范围 */
+  knowledgeScope: string[]
+  /** 新增:性格关键词数组 */
+  personality: string[]
+  /** 新增:说话风格描述 */
+  speakingStyle: string
+}
+
+export interface AgentRelation {
+  targetId: string
+  relationType: string
+  sentiment: number
+}
+
+// ── 仿真状态(新引擎核心状态) ──
+export interface SimulationState {
+  currentRound: number
+  timelineEvents: TimelineEvent[]
+  activeAgents: Map<string, NovelAgent>
+  worldState: Record<string, unknown>
+}
+
+// ── Agent 对话(采访/私聊) ──
+export interface AgentChatMessage {
+  id: string
+  role: "agent" | "user"
+  agentId?: string
+  agentName?: string
+  content: string
+  timestamp: string
+}
+
+export interface AgentChatSession {
+  agentId: string
+  agentName: string
+  messages: AgentChatMessage[]
+}
+
+// ── 提取结果 ──
+export interface ExtractionResult {
+  characters: ExtractedCharacter[]
+  chapterContents: ExtractedChapterContent[]
+  memoryData: ExtractedMemoryData
+  worldRules: string
+  powerSystem: string
+  foreshadowing: ForeshadowingStore | null
+  timeline: string[]
+  outlineContent: string
+  soulDoc: string
+}
+
+export interface ExtractedCharacter {
+  id: string
+  name: string
+  profile: string
+  aura: CharacterAura | null
+  cognition: { knows: string[]; doesNotKnow: string[] } | null
+  soul: string
+  skillContent: string
+}
+
+export interface ExtractedChapterContent {
+  chapterNumber: number
+  title: string
+  summary: string
+  content: string
+}
+
+export interface ExtractedMemoryData {
+  characterStates: string
+  characterCognition: CognitionState | null
+  foreshadowingTracker: ForeshadowingStore | null
+  timeline: string[]
+  canonFacts: string
+  conflicts: string
+}
+
+// ── 故事框架 ──
+export interface StoryFramework {
+  id: string
+  title: string
+  /** 简短标题,不超过10字 */
+  shortTitle?: string
+  premise: string
+  targetWords: number
+  simulationMode: SimulationMode
+  userIdea?: string
+  sourceChapters: number
+  nodes: StoryNode[]
+  createdAt: string
+}
+
+export interface StoryNode {
+  index: number
+  phase: "起" | "承" | "转" | "合"
+  title: string
+  coreConflict: string
+  involvedCharacters: string[]
+  goal: string
+  causeFromPrev: string
+  expectedOutcome: string
+}
+
+// ── 仿真事件(保留以兼容报告生成器和旧流程) ──
+export interface SimulationEvent {
+  type: "agent-action" | "node-complete" | "node-start" | "info"
+  agent?: NovelAgent
+  action?: AgentAction
+  round?: number
+  node?: StoryNode
+  stateChanges?: string[]
+  timestamp: string
+  /** info 类型事件的消息 */
+  message?: string
+  /** 关联的时间线事件(新引擎填充) */
+  timelineEvent?: TimelineEvent
+}
+
+// ── 推演报告 ──
+export interface SimulationReport {
+  frameworkId: string
+  mode: SimulationMode
+  characterAnalyses: CharacterAnalysis[]
+  branches: StoryBranch[]
+  recommendation: string
+  createdAt: string
+}
+
+export interface CharacterAnalysis {
+  characterId: string
+  name: string
+  behaviors: { node: string; action: string; motivation: string }[]
+  stateChanges: string[]
+  consistencyScore: number
+}
+
+export interface StoryBranch {
+  title: string
+  summary: string
+  keyEvents: string[]
+  probability: "high" | "medium" | "low"
+  pros: string
+  cons: string
+  recommendation: boolean
+}
+
+// ── 故事草稿 ──
+export interface StoryDraft {
+  branchId: string
+  frameworkId: string
+  chapters: DraftChapter[]
+  totalWords: number
+  createdAt: string
+}
+
+export interface DraftChapter {
+  title: string
+  content: string
+  correspondingNode: number
+  /** 原始 AI 生成内容(编辑前的备份),未编辑时为 undefined */
+  rawContent?: string
+}
+
+// ── 框架绑定 ──
+export interface FrameworkBinding {
+  frameworkId: string
+  frameworkTitle: string
+  targetChapterCount: number
+  chapterAllocation: ChapterAllocation[]
+  boundAt: string
+}
+
+export interface ChapterAllocation {
+  nodeIndex: number
+  nodeTitle: string
+  startChapter: number
+  endChapter: number
+}
+
+// ── 仿真输入 ──
+export interface SimulationInput {
+  agents: NovelAgent[]
+  framework: StoryFramework
+  mode: SimulationMode
+  wordBudget: number
+  llmConfig: LlmConfig
+  userIdea?: string
+  injectionEvent?: string
+  /** 每个节点的仿真轮数,不传则根据字数自动计算 */
+  maxRoundsPerNode?: number
+}
+
+// ── 仿真配置 ──
+export interface SimulationConfig {
+  mode: SimulationMode
+  userIdea?: string
+  targetWords: number
+  sourceChapters: number
+}
+
+// ── 字数预算 ──
+export const WORD_BUDGET_PRESETS = [10000, 30000, 50000] as const
+
+export function calcNodeCount(targetWords: number): number {
+  if (targetWords <= 10000) return 4
+  if (targetWords <= 30000) return 6
+  return 8
+}
+
+export function calcMaxRoundsPerNode(wordBudget: number): number {
+  return Math.max(2, Math.floor(wordBudget / 10000))
+}
+
+// ── 仿真模式配置 ──
+
+export interface ModeConfig {
+  /** 轮数乘数 */
+  roundsMultiplier: number
+  /** 注入到 prompt 中的行为倾向提示 */
+  behaviorHint: string
+  /** 随机事件触发概率(0-1,0=不触发) */
+  randomEventChance: number
+  /** 每轮活跃 Agent 比例(1=全部,0.5=随机一半) */
+  agentSubsetRatio: number
+  /** 是否强制按节点目标推进 */
+  strictNodeProgression: boolean
+}
+
+const MODE_CONFIGS: Record<SimulationMode, ModeConfig> = {
+  "event-driven": {
+    roundsMultiplier: 0.8,
+    behaviorHint:
+      "你倾向于推动事态发展(pushPlot),以达成节点目标为首要任务。谨慎使用观察行为,优先采取主动行动推动剧情。",
+    randomEventChance: 0.1,
+    agentSubsetRatio: 1,
+    strictNodeProgression: true,
+  },
+  "free-emergence": {
+    roundsMultiplier: 1.5,
+    behaviorHint:
+      "你倾向于自由表达和互动(evaluate/speak/observe),关注自身情感和与他人关系的变化。剧情会在角色互动中自然涌现,不必急于达成节点目标。",
+    randomEventChance: 0.25,
+    agentSubsetRatio: 0.7,
+    strictNodeProgression: false,
+  },
+  "decision-tree": {
+    roundsMultiplier: 1.0,
+    behaviorHint:
+      "你倾向于做出关键决策和对抗(confront/decide/react)。每个行为都应体现你在面对选择时的权衡与取舍,重点关注决策的因果链。",
+    randomEventChance: 0.15,
+    agentSubsetRatio: 0.5,
+    strictNodeProgression: true,
+  },
+  hybrid: {
+    roundsMultiplier: 1.2,
+    behaviorHint:
+      "你可以灵活选择行为类型:有时推动剧情,有时观察评价,有时与他人对话。根据当前情境和角色性格自然选择最合适的行为。",
+    randomEventChance: 0.2,
+    agentSubsetRatio: 0.85,
+    strictNodeProgression: false,
+  },
+}
+
+export function getModeConfig(mode: SimulationMode): ModeConfig {
+  return MODE_CONFIGS[mode] ?? MODE_CONFIGS.hybrid
+}
+
+export function calcMaxAgentsPerRound(activeAgentCount: number): number {
+  return Math.min(8, activeAgentCount)
+}
+
+// ── 模式可视化说明 ──
+
+export interface ModeVisualInfo {
+  /** 模式名称 */
+  name: string
+  /** 简短描述 */
+  shortDesc: string
+  /** 详细特点 */
+  features: string[]
+  /** 适合场景 */
+  bestFor: string
+  /** 轮数相对多少 */
+  roundsLabel: string
+  /** 随机事件多少 */
+  randomnessLabel: string
+  /** 剧情自由度 */
+  freedomLabel: string
+  /** 标签颜色 */
+  color: string
+  /** emoji图标 */
+  icon: string
+}
+
+export const MODE_VISUAL_INFO: Record<SimulationMode, ModeVisualInfo> = {
+  "event-driven": {
+    name: "事件驱动",
+    shortDesc: "按框架节点推进,节奏紧凑,剧情走向明确",
+    features: [
+      "严格按节点目标推进剧情",
+      "角色倾向于主动行动推动事态",
+      "随机事件较少,走向可控",
+      "每轮所有角色都参与互动",
+    ],
+    bestFor: "已有明确大纲,需要快速产出符合预期的剧情",
+    roundsLabel: "适中 (0.8x)",
+    randomnessLabel: "低 (10%)",
+    freedomLabel: "低",
+    color: "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300",
+    icon: "🎯",
+  },
+  "free-emergence": {
+    name: "自由涌现",
+    shortDesc: "角色自由互动,剧情自然发展,惊喜多",
+    features: [
+      "不强制节点目标,剧情自然涌现",
+      "角色更关注情感和关系变化",
+      "随机事件较多,可能有意外发展",
+      "每轮随机选择部分角色活跃",
+    ],
+    bestFor: "探索角色可能性,寻找灵感和意外剧情",
+    roundsLabel: "较多 (1.5x)",
+    randomnessLabel: "高 (25%)",
+    freedomLabel: "高",
+    color: "bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300",
+    icon: "🌊",
+  },
+  "decision-tree": {
+    name: "决策树",
+    shortDesc: "聚焦关键抉择,因果清晰,冲突感强",
+    features: [
+      "强调角色面临选择时的权衡",
+      "重点关注决策的因果链",
+      "对抗和决策类行为更多",
+      "每轮聚焦关键角色互动",
+    ],
+    bestFor: "强剧情冲突、权谋斗争、关键抉择场景",
+    roundsLabel: "标准 (1.0x)",
+    randomnessLabel: "中 (15%)",
+    freedomLabel: "中",
+    color: "bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300",
+    icon: "🌳",
+  },
+  hybrid: {
+    name: "混合模式",
+    shortDesc: "平衡推进与自由,综合表现佳,推荐首选",
+    features: [
+      "灵活选择行为类型,平衡推进与互动",
+      "既有主线推进,也有角色自由发挥",
+      "随机事件适中,既有惊喜也不失控",
+      "大部分角色参与,互动丰富",
+    ],
+    bestFor: "大多数场景,平衡可控性和创造性",
+    roundsLabel: "较多 (1.2x)",
+    randomnessLabel: "中 (20%)",
+    freedomLabel: "中高",
+    color: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300",
+    icon: "⚖️",
+  },
+}

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

@@ -0,0 +1,192 @@
+import { create } from "zustand"
+import type {
+  AgentChatMessage,
+  SimulationMode,
+  StoryFramework,
+  SimulationReport,
+  StoryDraft,
+  ExtractionResult,
+  FrameworkBinding,
+  TimelineEvent,
+} 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
+}
+
+export type SimulationPhase =
+  | "idle"
+  | "configuring"
+  | "extracting"
+  | "framework-generating"
+  | "framework-confirming"
+  | "simulating"
+  | "report-generating"
+  | "report-viewing"
+  | "draft-generating"
+  | "draft-viewing"
+
+export interface StorySimulationState {
+  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
+  /** 仿真过程中的时间线事件(实时流) */
+  timelineEvents: TimelineEvent[]
+  /** 当前正在采访的角色 */
+  activeChatAgent: { id: string; name: string } | null
+  /** 采访对话消息 */
+  agentChatMessages: AgentChatMessage[]
+  /** 列表刷新计数(用于触发 framework-list 重新加载) */
+  listRefreshKey: number
+  /** 当前框架下已保存的推演结果 */
+  savedResults: SavedSimulationResult[]
+  /** 当前选中查看的历史结果ID */
+  selectedResultId: string | null
+  /** 是否显示采访历史面板 */
+  showInterviewHistory: boolean
+  /** 已保存的采访列表 */
+  savedInterviews: SavedInterview[]
+  /** 当前查看的采访详情 */
+  viewingInterview: SavedInterview | null
+  /** 对比模式下要对比的结果ID(null表示不对比) */
+  compareWithResultId: string | null
+  /** 当前续聊的采访ID(用于保存时判断覆盖/另存) */
+  continuingInterviewId: 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
+  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
+  reset: () => void
+}
+
+export const useStorySimulationStore = create<StorySimulationState>((set) => ({
+  phase: "idle",
+  mode: "event-driven",
+  userIdea: "",
+  targetWords: 10000,
+  sourceChapters: 10,
+  simulationRounds: 0,
+  extractionResult: null,
+  currentFramework: null,
+  currentReport: null,
+  currentDraft: null,
+  frameworks: [],
+  selectedFrameworkId: null,
+  binding: null,
+  error: null,
+  progress: 0,
+  progressLabel: "",
+  timelineEvents: [],
+  activeChatAgent: null,
+  agentChatMessages: [],
+  listRefreshKey: 0,
+  savedResults: [],
+  selectedResultId: null,
+  showInterviewHistory: false,
+  savedInterviews: [],
+  viewingInterview: null,
+  compareWithResultId: null,
+  continuingInterviewId: null,
+
+  setPhase: (phase) => set({ phase }),
+  setMode: (mode) => set({ mode }),
+  setUserIdea: (userIdea) => set({ userIdea }),
+  setTargetWords: (targetWords) => set({ targetWords }),
+  setSourceChapters: (sourceChapters) => set({ sourceChapters }),
+  setSimulationRounds: (simulationRounds) => set({ simulationRounds }),
+  setExtractionResult: (extractionResult) => set({ extractionResult }),
+  setCurrentFramework: (currentFramework) => set({ currentFramework }),
+  setCurrentReport: (currentReport) => set({ currentReport }),
+  setCurrentDraft: (currentDraft) => set({ currentDraft }),
+  setFrameworks: (frameworks) => set({ frameworks }),
+  setSelectedFrameworkId: (selectedFrameworkId) => set({ selectedFrameworkId }),
+  setBinding: (binding) => set({ binding }),
+  setError: (error) => set({ error }),
+  setProgress: (progress, progressLabel) => set({ progress, progressLabel }),
+  setTimelineEvents: (timelineEvents) => set({ timelineEvents }),
+  addTimelineEvent: (event) =>
+    set((state) => ({ timelineEvents: [...state.timelineEvents, event] })),
+  setActiveChatAgent: (activeChatAgent) => set({ activeChatAgent }),
+  addAgentChatMessage: (message) =>
+    set((state) => ({ agentChatMessages: [...state.agentChatMessages, message] })),
+  clearAgentChat: () => set({ agentChatMessages: [], activeChatAgent: null }),
+  bumpListRefresh: () => set((state) => ({ listRefreshKey: state.listRefreshKey + 1 })),
+  setSavedResults: (savedResults) => set({ savedResults }),
+  setSelectedResultId: (selectedResultId) => set({ selectedResultId }),
+  setShowInterviewHistory: (showInterviewHistory) => set({ showInterviewHistory }),
+  setSavedInterviews: (savedInterviews) => set({ savedInterviews }),
+  setViewingInterview: (viewingInterview) => set({ viewingInterview }),
+  setCompareWithResultId: (compareWithResultId) => set({ compareWithResultId }),
+  setContinuingInterviewId: (continuingInterviewId) => set({ continuingInterviewId }),
+  setAgentChatMessages: (agentChatMessages) => set({ agentChatMessages }),
+  reset: () =>
+    set({
+      phase: "idle",
+      extractionResult: null,
+      currentFramework: null,
+      currentReport: null,
+      currentDraft: null,
+      error: null,
+      progress: 0,
+      progressLabel: "",
+      timelineEvents: [],
+      activeChatAgent: null,
+      agentChatMessages: [],
+      savedResults: [],
+      selectedResultId: null,
+      showInterviewHistory: false,
+      savedInterviews: [],
+      viewingInterview: null,
+      compareWithResultId: null,
+      continuingInterviewId: null,
+    }),
+}))

+ 5 - 1
src/stores/wiki-store.ts

@@ -480,7 +480,7 @@ interface WikiState {
   chatExpanded: boolean
   chatDockPosition: ChatDockPosition
   searchPanelOpen: boolean
-  activeView: "wiki" | "sources" | "search" | "graph" | "lint" | "soul" | "dismantling" | "bookAnalysis" | "settings" | "trash" | "reviewCenter"
+  activeView: "wiki" | "sources" | "search" | "graph" | "lint" | "soul" | "dismantling" | "bookAnalysis" | "settings" | "trash" | "reviewCenter" | "storySimulation"
   activeSettingsCategory: SettingsCategoryId | null
   selectedSoulId: string | null
   selectedSoulTab: "project" | "character"
@@ -530,6 +530,7 @@ interface WikiState {
   theme: "light" | "dark" | "deep-blue" | "system"
   uiFontSizeScale: number
   dataVersion: number
+  bindingVersion: number
 
   setProject: (project: WikiProject | null) => void
   setFileTree: (tree: FileNode[]) => void
@@ -591,6 +592,7 @@ interface WikiState {
   setTheme: (theme: "light" | "dark" | "deep-blue" | "system") => void
   setUiFontSizeScale: (scale: number) => void
   bumpDataVersion: () => void
+  bumpBindingVersion: () => void
 }
 
 export const useWikiStore = create<WikiState>((set) => ({
@@ -643,6 +645,7 @@ export const useWikiStore = create<WikiState>((set) => ({
   activePresetId: null,
 
   dataVersion: 0,
+  bindingVersion: 0,
 
   setProject: (project) => set({ project }),
   setFileTree: (fileTree) => set({ fileTree }),
@@ -793,6 +796,7 @@ export const useWikiStore = create<WikiState>((set) => ({
     set({ uiFontSizeScale: clamped })
   },
   bumpDataVersion: () => set((state) => ({ dataVersion: state.dataVersion + 1 })),
+  bumpBindingVersion: () => set((state) => ({ bindingVersion: state.bindingVersion + 1 })),
 }))
 
 export type { WikiState, LlmConfig, SearchApiConfig, EmbeddingConfig, MultimodalConfig, OutputLanguage, ProxyConfig, ScheduledImportConfig, SourceWatchConfig }