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

feat(story-simulation): 剧情推演二期线性叙事阶段

- 新增 director-agent、event-pool-generator、sim-agent-tools 等多 Agent 推演模块。
- 新增分支管理、线索板、关系图谱、传闻传播四个推演面板组件。
- 新增 embedding-client 与 node-goal-embedding、rumor-visibility、investigate-feedback 等能力。
- task-router 接入 story_framework_generate、multi_agent_simulate、character_interview 三个意图,ChatPanel 路由到推演流程。
- 顺带修复 unified-skill-model 类型缺失(linked 源、categories/priority/tags 字段),松开生产构建。
- 未提交任何 .md 文档文件。
Mochocyang 2 месяцев назад
Родитель
Сommit
efdfb81b61
34 измененных файлов с 5065 добавлено и 79 удалено
  1. 35 0
      src/components/chat/chat-panel.tsx
  2. 3 0
      src/components/chat/context-trace-panel.tsx
  3. 266 0
      src/components/novel/story-simulation/branch-manager-panel.tsx
  4. 326 0
      src/components/novel/story-simulation/clue-board-panel.tsx
  5. 236 0
      src/components/novel/story-simulation/relationship-graph-panel.tsx
  6. 303 0
      src/components/novel/story-simulation/rumor-propagation-panel.tsx
  7. 26 1
      src/components/novel/story-simulation/simulation-config-panel.tsx
  8. 222 54
      src/components/novel/story-simulation/story-simulation-view.tsx
  9. 4 0
      src/components/skill-library/unified-skill-model.spec.ts
  10. 1 1
      src/components/skill-library/unified-skill-model.ts
  11. 3 0
      src/lib/agent/plugins/confidence-gate-plugin.ts
  12. 51 0
      src/lib/embedding-client.ts
  13. 17 0
      src/lib/novel/story-simulation/agent-profile-builder.ts
  14. 402 0
      src/lib/novel/story-simulation/director-agent.spec.ts
  15. 264 0
      src/lib/novel/story-simulation/director-agent.ts
  16. 162 0
      src/lib/novel/story-simulation/event-pool-generator.spec.ts
  17. 200 0
      src/lib/novel/story-simulation/event-pool-generator.ts
  18. 230 0
      src/lib/novel/story-simulation/investigate-feedback.spec.ts
  19. 5 1
      src/lib/novel/story-simulation/multi-agent-orchestrator.spec.ts
  20. 151 0
      src/lib/novel/story-simulation/multi-agent-orchestrator.ts
  21. 148 0
      src/lib/novel/story-simulation/node-goal-embedding.spec.ts
  22. 127 0
      src/lib/novel/story-simulation/rumor-visibility.spec.ts
  23. 232 0
      src/lib/novel/story-simulation/sim-agent-tools.spec.ts
  24. 216 0
      src/lib/novel/story-simulation/sim-agent-tools.ts
  25. 268 0
      src/lib/novel/story-simulation/simulation-engine.react.spec.ts
  26. 497 22
      src/lib/novel/story-simulation/simulation-engine.ts
  27. 2 0
      src/lib/novel/story-simulation/simulation-serializer.ts
  28. 152 0
      src/lib/novel/story-simulation/staged-event-pool.spec.ts
  29. 83 0
      src/lib/novel/story-simulation/types.ts
  30. 80 0
      src/lib/novel/task-router.story-sim.spec.ts
  31. 36 0
      src/lib/novel/task-router.ts
  32. 130 0
      src/stores/story-simulation-preset.spec.ts
  33. 3 0
      src/stores/story-simulation-store.spec.ts
  34. 184 0
      src/stores/story-simulation-store.ts

+ 35 - 0
src/components/chat/chat-panel.tsx

@@ -19,6 +19,7 @@ import {
 import { useChatStore, type DisplayMessage } from "@/stores/chat-store"
 import { useOutlineChatStore } from "@/stores/outline-chat-store"
 import { useWikiStore } from "@/stores/wiki-store"
+import { useStorySimulationStore } from "@/stores/story-simulation-store"
 import { DeAiSkillPicker } from "@/components/skill-library/de-ai-skill-picker"
 import { ReferenceInput, type InsertReferenceTokens } from "@/components/reference/ReferenceInput"
 import { ReferencePickerDialog } from "@/components/reference/ReferencePickerDialog"
@@ -897,6 +898,40 @@ export function ChatPanel() {
         .slice(-maxHistoryMessages)
       const pp = normalizePath(project.path)
       const taskRoute = novelMode ? routeTask(plainText) : null
+
+      const SIMULATION_INTENTS = new Set([
+        "story_framework_generate",
+        "multi_agent_simulate",
+        "character_interview",
+      ])
+
+      if (taskRoute && SIMULATION_INTENTS.has(taskRoute.intent)) {
+        const { assistantMessage } = appendAgentChatMessages(capturedConvId, userVisibleText || plainText, tokens)
+        setConversationInputDraft(capturedConvId, "")
+        setFallbackReferenceText("")
+        setReferenceTokensByConversation((drafts) => {
+          const withoutCaptured = setReferenceTokensForConversation(drafts, capturedConvId, [])
+          return setReferenceTokensForConversation(withoutCaptured, referenceDraftConversationId, [])
+        })
+
+        const hasFramework = !!activeBinding
+        useStorySimulationStore.getState().initWithPreset({
+          intent: taskRoute.intent,
+          userInput: plainText,
+          hasFramework,
+        })
+
+        setActiveView("storySimulation")
+
+        updateAgentAssistantMessage(assistantMessage.id, (message) => ({
+          ...message,
+          content: "已为你打开剧情推演室并预填配置,请在推演室中继续操作。",
+          isAgentRunning: false,
+        }))
+
+        return
+      }
+
       const sessionAgentSystemPrompt = buildChatAgentSystemPrompt({
         novelMode,
         mode,

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

@@ -74,6 +74,9 @@ const INTENT_LABELS: Record<NovelTaskIntent, string> = {
   timeline_query: "时间线查询",
   setting_query: "设定查询",
   general_chat: "随便聊聊",
+  story_framework_generate: "故事框架生成",
+  multi_agent_simulate: "多智能体推演",
+  character_interview: "角色采访",
 }
 
 const ROUTE_SOURCE_LABELS: Record<RouteSource, string> = {

+ 266 - 0
src/components/novel/story-simulation/branch-manager-panel.tsx

@@ -0,0 +1,266 @@
+import { useState, useMemo, useRef, useEffect } from "react"
+import { Save, Trash2, Edit3, Eye, AlertTriangle, GitBranch } from "lucide-react"
+import type { SimulationBranch } from "@/lib/novel/story-simulation/types"
+import { MODE_VISUAL_INFO } from "@/lib/novel/story-simulation/types"
+import { Button } from "@/components/ui/button"
+
+interface BranchManagerPanelProps {
+  branches: SimulationBranch[]
+  activeBranchId: string | null
+  onSaveBranch: (name: string) => void
+  onDeleteBranch: (id: string) => void
+  onRenameBranch: (id: string, name: string) => void
+  onSwitchBranch: (id: string) => void
+}
+
+export function BranchManagerPanel({
+  branches,
+  activeBranchId,
+  onSaveBranch,
+  onDeleteBranch,
+  onRenameBranch,
+  onSwitchBranch,
+}: BranchManagerPanelProps) {
+  const [newBranchName, setNewBranchName] = useState("")
+  const [editingId, setEditingId] = useState<string | null>(null)
+  const [editingName, setEditingName] = useState("")
+  const inputRef = useRef<HTMLInputElement>(null)
+
+  const sortedBranches = useMemo(() => {
+    return [...branches].sort((a, b) => b.overallScore - a.overallScore)
+  }, [branches])
+
+  const isMaxBranches = branches.length >= 10
+
+  useEffect(() => {
+    if (editingId && inputRef.current) {
+      inputRef.current.focus()
+      inputRef.current.select()
+    }
+  }, [editingId])
+
+  const handleSave = () => {
+    const name = newBranchName.trim()
+    if (!name || isMaxBranches) return
+    onSaveBranch(name)
+    setNewBranchName("")
+  }
+
+  const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
+    if (e.key === "Enter") {
+      handleSave()
+    }
+  }
+
+  const handleStartRename = (branch: SimulationBranch) => {
+    setEditingId(branch.id)
+    setEditingName(branch.name)
+  }
+
+  const handleFinishRename = () => {
+    if (!editingId) return
+    const name = editingName.trim()
+    if (name) {
+      onRenameBranch(editingId, name)
+    }
+    setEditingId(null)
+    setEditingName("")
+  }
+
+  const handleRenameKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
+    if (e.key === "Enter") {
+      handleFinishRename()
+    } else if (e.key === "Escape") {
+      setEditingId(null)
+      setEditingName("")
+    }
+  }
+
+  const handleDelete = (id: string, name: string) => {
+    if (confirm(`确定要删除分支「${name}」吗?`)) {
+      onDeleteBranch(id)
+    }
+  }
+
+  const formatDate = (isoString: string) => {
+    const date = new Date(isoString)
+    return date.toLocaleString("zh-CN", {
+      month: "2-digit",
+      day: "2-digit",
+      hour: "2-digit",
+      minute: "2-digit",
+    })
+  }
+
+  return (
+    <div className="flex h-full min-h-0 flex-col rounded-lg border bg-muted/30">
+      <div className="flex items-center gap-2 border-b px-3 py-2">
+        <GitBranch className="h-4 w-4 text-primary" />
+        <span className="text-sm font-medium">分支管理</span>
+        <span className="ml-auto text-xs text-muted-foreground">
+          {branches.length}/10
+        </span>
+      </div>
+
+      <div className="space-y-2 p-3">
+        <div className="flex gap-2">
+          <input
+            type="text"
+            value={newBranchName}
+            onChange={(e) => setNewBranchName(e.target.value)}
+            onKeyDown={handleKeyDown}
+            placeholder="输入分支名称..."
+            disabled={isMaxBranches}
+            className="h-8 flex-1 rounded border border-input bg-background px-2 text-sm outline-none focus:ring-1 focus:ring-ring disabled:opacity-50"
+          />
+          <Button
+            type="button"
+            size="sm"
+            onClick={handleSave}
+            disabled={!newBranchName.trim() || isMaxBranches}
+            className="h-8"
+          >
+            <Save className="h-3.5 w-3.5 mr-1" />
+            保存
+          </Button>
+        </div>
+
+        {isMaxBranches && (
+          <div className="flex items-center gap-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-2 py-1.5 text-xs text-amber-700 dark:text-amber-400">
+            <AlertTriangle className="h-3.5 w-3.5 shrink-0" />
+            <span>分支数量已达上限(10个),请先删除部分分支</span>
+          </div>
+        )}
+      </div>
+
+      <div className="min-h-0 flex-1 overflow-y-auto px-3 pb-3">
+        {sortedBranches.length === 0 ? (
+          <div className="flex h-full items-center justify-center py-8 text-center text-xs text-muted-foreground">
+            <div>
+              <GitBranch className="mx-auto mb-2 h-8 w-8 opacity-30" />
+              <div>暂无保存的分支</div>
+              <div className="mt-1">推演过程中可随时保存当前状态</div>
+            </div>
+          </div>
+        ) : (
+          <div className="space-y-2">
+            {sortedBranches.map((branch, index) => {
+              const isActive = activeBranchId === branch.id
+              const modeInfo = MODE_VISUAL_INFO[branch.mode]
+              return (
+                <div
+                  key={branch.id}
+                  className={`rounded-md border p-2.5 transition-colors ${
+                    isActive
+                      ? "border-primary bg-primary/5"
+                      : "bg-background/70 hover:bg-muted/30"
+                  }`}
+                >
+                  <div className="flex items-start gap-2">
+                    <div className="flex-1 min-w-0">
+                      <div className="flex items-center gap-2">
+                        {index === 0 && (
+                          <span className="shrink-0 rounded bg-gradient-to-r from-amber-500 to-orange-500 px-1.5 py-0.5 text-[10px] font-medium text-white">
+                            推荐
+                          </span>
+                        )}
+                        {editingId === branch.id ? (
+                          <input
+                            ref={inputRef}
+                            type="text"
+                            value={editingName}
+                            onChange={(e) => setEditingName(e.target.value)}
+                            onBlur={handleFinishRename}
+                            onKeyDown={handleRenameKeyDown}
+                            className="h-6 w-full rounded border border-input bg-background px-1.5 text-sm outline-none focus:ring-1 focus:ring-ring"
+                          />
+                        ) : (
+                          <span className="truncate text-sm font-medium">
+                            {branch.name}
+                          </span>
+                        )}
+                      </div>
+                      <div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-muted-foreground">
+                        <span className="font-semibold text-primary">
+                          {branch.overallScore.toFixed(1)} 分
+                        </span>
+                        <span className={`rounded px-1.5 py-0.5 ${modeInfo?.color || "bg-gray-100 text-gray-700"}`}>
+                          {modeInfo?.name || branch.mode}
+                        </span>
+                        <span>{formatDate(branch.createdAt)}</span>
+                      </div>
+                      <div className="mt-1.5 grid grid-cols-4 gap-1 text-[10px] text-muted-foreground">
+                        <div className="text-center">
+                          <div className="font-medium text-foreground">
+                            {branch.scoreDetails.avgDirectorScore.toFixed(1)}
+                          </div>
+                          <div>导演评分</div>
+                        </div>
+                        <div className="text-center">
+                          <div className="font-medium text-foreground">
+                            {branch.scoreDetails.eventCount}
+                          </div>
+                          <div>事件数</div>
+                        </div>
+                        <div className="text-center">
+                          <div className="font-medium text-foreground">
+                            {Math.round(branch.scoreDetails.characterDiversity * 100)}%
+                          </div>
+                          <div>角色活跃</div>
+                        </div>
+                        <div className="text-center">
+                          <div className="font-medium text-foreground">
+                            {Math.round(branch.scoreDetails.plotProgression * 100)}%
+                          </div>
+                          <div>剧情推进</div>
+                        </div>
+                      </div>
+                    </div>
+
+                    <div className="flex flex-col gap-1">
+                      <Button
+                        type="button"
+                        variant="ghost"
+                        size="sm"
+                        onClick={() => onSwitchBranch(branch.id)}
+                        className="h-7 w-7 p-0"
+                        title="查看此分支"
+                      >
+                        <Eye className="h-3.5 w-3.5" />
+                      </Button>
+                      <Button
+                        type="button"
+                        variant="ghost"
+                        size="sm"
+                        onClick={() => handleStartRename(branch)}
+                        className="h-7 w-7 p-0"
+                        title="重命名"
+                      >
+                        <Edit3 className="h-3.5 w-3.5" />
+                      </Button>
+                      <Button
+                        type="button"
+                        variant="ghost"
+                        size="sm"
+                        onClick={() => handleDelete(branch.id, branch.name)}
+                        className="h-7 w-7 p-0 text-destructive hover:text-destructive"
+                        title="删除"
+                      >
+                        <Trash2 className="h-3.5 w-3.5" />
+                      </Button>
+                    </div>
+                  </div>
+                  {isActive && (
+                    <div className="mt-1.5 border-t pt-1.5 text-[11px] text-primary">
+                      ● 当前显示此分支
+                    </div>
+                  )}
+                </div>
+              )
+            })}
+          </div>
+        )}
+      </div>
+    </div>
+  )
+}

+ 326 - 0
src/components/novel/story-simulation/clue-board-panel.tsx

@@ -0,0 +1,326 @@
+import { useState, useMemo } from "react"
+import { Search, Link2 } from "lucide-react"
+import type { RumorEvent, NovelAgent } from "@/lib/novel/story-simulation/types"
+
+interface ClueItem {
+  id: string
+  content: string
+  type: "confirmed" | "rumor" | "observed" | "told"
+  source: string
+  round: number
+  agentId: string
+  agentName: string
+}
+
+interface ClueBoardPanelProps {
+  agents: Map<string, NovelAgent>
+  rumors: RumorEvent[]
+}
+
+const STOP_WORDS = new Set(["的", "了", "是", "在", "有", "和", "与", "等", "也", "都", "就", "不", "我", "你", "他", "她", "它", "们", "这", "那", "个", "一", "之", "而", "于", "上", "下", "中", "里", "外", "前", "后", "左", "右", "大", "小", "多", "少", "很", "太", "最", "更", "还", "又", "再", "已", "曾", "将", "要", "会", "能", "可", "以", "为", "因", "由", "从", "到", "向", "对", "于", "把", "被", "让", "使", "给", "替", "比", "跟", "同", "和", "及", "或", "但", "而", "且", "并", "然", "则", "虽", "若", "如", "假", "使", "令", "叫", "让", "请", "求", "找", "寻", "查", "看", "听", "说", "讲", "谈", "论", "想", "思", "念", "忘", "记", "知", "道", "明", "白", "清", "楚", "懂", "会", "能", "可", "行", "成", "败", "好", "坏", "对", "错", "真", "假", "新", "旧", "老", "少", "男", "女", "人", "事", "物", "地", "方", "时", "间", "年", "月", "日", "天", "夜", "早", "晚", "今", "明", "昨", "前", "后"])
+
+function extractKeywords(text: string): Set<string> {
+  const keywords = new Set<string>()
+  const chars = text.split("")
+  for (let i = 0; i < chars.length; i++) {
+    const char = chars[i]
+    if (STOP_WORDS.has(char)) continue
+    if (/[\u4e00-\u9fa5]/.test(char)) {
+      keywords.add(char)
+      if (i + 1 < chars.length && /[\u4e00-\u9fa5]/.test(chars[i + 1]) && !STOP_WORDS.has(chars[i] + chars[i + 1])) {
+        const bigram = chars[i] + chars[i + 1]
+        if (!STOP_WORDS.has(bigram)) {
+          keywords.add(bigram)
+        }
+      }
+    }
+  }
+  return keywords
+}
+
+function calcSimilarity(a: Set<string>, b: Set<string>): number {
+  if (a.size === 0 || b.size === 0) return 0
+  let intersection = 0
+  for (const kw of a) {
+    if (b.has(kw)) intersection++
+  }
+  const union = a.size + b.size - intersection
+  return union > 0 ? intersection / union : 0
+}
+
+function getTypeLabel(type: ClueItem["type"]): string {
+  switch (type) {
+    case "confirmed":
+      return "调查证实"
+    case "rumor":
+      return "传闻得知"
+    case "observed":
+      return "亲眼所见"
+    case "told":
+      return "他人告知"
+  }
+}
+
+function getTypeColor(type: ClueItem["type"]): string {
+  switch (type) {
+    case "confirmed":
+      return "border-blue-400 bg-blue-50 dark:border-blue-600 dark:bg-blue-950/30"
+    case "rumor":
+      return "border-amber-400 bg-amber-50 dark:border-amber-600 dark:bg-amber-950/30"
+    case "observed":
+      return "border-emerald-400 bg-emerald-50 dark:border-emerald-600 dark:bg-emerald-950/30"
+    case "told":
+      return "border-purple-400 bg-purple-50 dark:border-purple-600 dark:bg-purple-950/30"
+  }
+}
+
+function getTypeIcon(type: ClueItem["type"]): string {
+  switch (type) {
+    case "confirmed":
+      return "🔵"
+    case "rumor":
+      return "🟡"
+    case "observed":
+      return "🟢"
+    case "told":
+      return "🟣"
+  }
+}
+
+export function ClueBoardPanel({ agents, rumors }: ClueBoardPanelProps) {
+  const agentList = useMemo(() => Array.from(agents.values()), [agents])
+  const [selectedAgentId, setSelectedAgentId] = useState<string | null>(
+    agentList.length > 0 ? agentList[0].characterId : null,
+  )
+  const [selectedClueId, setSelectedClueId] = useState<string | null>(null)
+
+  const visibleRumorsByAgent = useMemo(() => {
+    const map = new Map<string, RumorEvent[]>()
+    for (const agent of agentList) {
+      map.set(agent.characterId, [])
+    }
+    for (const rumor of rumors) {
+      for (const agentId of rumor.observableBy) {
+        if (!map.has(agentId)) map.set(agentId, [])
+        map.get(agentId)!.push(rumor)
+      }
+    }
+    return map
+  }, [rumors, agentList])
+
+  const cluesByAgent = useMemo(() => {
+    const map = new Map<string, ClueItem[]>()
+    for (const agent of agentList) {
+      const clueList: ClueItem[] = []
+      const knownSecrets = agent.memory?.knownSecrets ?? new Set<string>()
+      let idx = 0
+      for (const secret of knownSecrets) {
+        clueList.push({
+          id: `${agent.characterId}-confirmed-${idx++}`,
+          content: secret,
+          type: "confirmed",
+          source: "调查证实",
+          round: 0,
+          agentId: agent.characterId,
+          agentName: agent.name,
+        })
+      }
+      const visibleRumors = visibleRumorsByAgent.get(agent.characterId) ?? []
+      for (const rumor of visibleRumors) {
+        if (rumor.verifiedBy.includes(agent.characterId)) continue
+        clueList.push({
+          id: `${agent.characterId}-rumor-${rumor.id}`,
+          content: rumor.content,
+          type: "rumor",
+          source: `传闻(第${rumor.round + 1}轮)`,
+          round: rumor.round,
+          agentId: agent.characterId,
+          agentName: agent.name,
+        })
+      }
+      map.set(agent.characterId, clueList)
+    }
+    return map
+  }, [agentList, visibleRumorsByAgent])
+
+  const currentClues = useMemo(() => {
+    if (!selectedAgentId) return []
+    return cluesByAgent.get(selectedAgentId) ?? []
+  }, [selectedAgentId, cluesByAgent])
+
+  const selectedClue = useMemo(() => {
+    if (!selectedClueId) return null
+    return currentClues.find((c) => c.id === selectedClueId) ?? null
+  }, [selectedClueId, currentClues])
+
+  const relatedClues = useMemo(() => {
+    if (!selectedClue || currentClues.length <= 1) return []
+    const selectedKeywords = extractKeywords(selectedClue.content)
+    const others = currentClues.filter((c) => c.id !== selectedClue.id)
+    const withScore = others.map((clue) => {
+      const kw = extractKeywords(clue.content)
+      const sharedCount = Array.from(selectedKeywords).filter((k) => kw.has(k)).length
+      const similarity = calcSimilarity(selectedKeywords, kw)
+      return { clue, sharedCount, similarity }
+    })
+    return withScore
+      .filter((item) => item.sharedCount >= 2)
+      .sort((a, b) => b.similarity - a.similarity)
+      .slice(0, 5)
+  }, [selectedClue, currentClues])
+
+  if (agentList.length === 0) {
+    return (
+      <div className="flex h-full items-center justify-center rounded-lg border bg-muted/30 p-6 text-center text-xs text-muted-foreground">
+        暂无角色数据
+      </div>
+    )
+  }
+
+  return (
+    <div className="flex h-full min-h-0 flex-col gap-3">
+      <div className="shrink-0">
+        <div className="mb-2 flex items-center gap-2 overflow-x-auto pb-1">
+          {agentList.map((agent) => {
+            const clueCount = cluesByAgent.get(agent.characterId)?.length ?? 0
+            const isActive = selectedAgentId === agent.characterId
+            return (
+              <button
+                key={agent.characterId}
+                type="button"
+                onClick={() => {
+                  setSelectedAgentId(agent.characterId)
+                  setSelectedClueId(null)
+                }}
+                className={`shrink-0 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors ${
+                  isActive
+                    ? "border-primary bg-primary text-primary-foreground"
+                    : "bg-background/70 hover:bg-muted/50"
+                }`}
+              >
+                <span>{agent.name}</span>
+                <span className={`ml-1.5 rounded px-1.5 py-0.5 text-[10px] ${
+                  isActive
+                    ? "bg-primary-foreground/20 text-primary-foreground"
+                    : "bg-muted text-muted-foreground"
+                }`}>
+                  {clueCount}
+                </span>
+              </button>
+            )
+          })}
+        </div>
+
+        <div className="flex flex-wrap items-center gap-3 text-[11px] text-muted-foreground">
+          <span className="flex items-center gap-1">
+            <span className="text-sm">🟢</span>
+            <span>亲眼所见</span>
+          </span>
+          <span className="flex items-center gap-1">
+            <span className="text-sm">🔵</span>
+            <span>调查证实</span>
+          </span>
+          <span className="flex items-center gap-1">
+            <span className="text-sm">🟡</span>
+            <span>传闻得知</span>
+          </span>
+          <span className="flex items-center gap-1">
+            <span className="text-sm">🟣</span>
+            <span>他人告知</span>
+          </span>
+        </div>
+      </div>
+
+      <div className="min-h-0 flex-1 overflow-y-auto">
+        {currentClues.length === 0 ? (
+          <div className="flex h-full items-center justify-center rounded-md border bg-muted/20 p-6 text-center text-xs text-muted-foreground">
+            <div>
+              <Search className="mx-auto mb-2 h-6 w-6 opacity-50" />
+              <div>该角色暂无线索</div>
+            </div>
+          </div>
+        ) : (
+          <div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-4">
+            {currentClues.map((clue) => (
+              <button
+                key={clue.id}
+                type="button"
+                onClick={() => setSelectedClueId(clue.id)}
+                className={`rounded-md border-2 p-2.5 text-left transition-all ${
+                  selectedClueId === clue.id
+                    ? "ring-2 ring-primary ring-offset-1"
+                    : "hover:shadow-sm"
+                } ${getTypeColor(clue.type)}`}
+              >
+                <div className="mb-1 flex items-center justify-between gap-1">
+                  <span className="text-[10px] font-medium">
+                    {getTypeIcon(clue.type)} {getTypeLabel(clue.type)}
+                  </span>
+                  <span className="text-[10px] text-muted-foreground">
+                    R{clue.round + 1}
+                  </span>
+                </div>
+                <div className="line-clamp-3 text-xs leading-relaxed">
+                  {clue.content}
+                </div>
+              </button>
+            ))}
+          </div>
+        )}
+      </div>
+
+      {selectedClue && (
+        <div className="shrink-0 rounded-md border bg-background/80 p-3">
+          <div className="mb-2 flex items-start justify-between gap-2">
+            <div className="flex items-center gap-2">
+              <span className="text-base">{getTypeIcon(selectedClue.type)}</span>
+              <span className="text-sm font-medium">
+                {getTypeLabel(selectedClue.type)}
+              </span>
+              <span className="text-[11px] text-muted-foreground">
+                · {selectedClue.source}
+              </span>
+            </div>
+            <button
+              type="button"
+              onClick={() => setSelectedClueId(null)}
+              className="text-[11px] text-muted-foreground hover:text-foreground"
+            >
+              收起
+            </button>
+          </div>
+          <div className="mb-3 rounded-md border bg-muted/20 p-2.5 text-xs leading-relaxed">
+            {selectedClue.content}
+          </div>
+
+          {relatedClues.length > 0 && (
+            <div>
+              <div className="mb-1.5 flex items-center gap-1 text-[11px] font-medium">
+                <Link2 className="h-3 w-3" />
+                <span>关联线索</span>
+              </div>
+              <div className="space-y-1.5">
+                {relatedClues.map(({ clue, similarity }) => (
+                  <button
+                    key={clue.id}
+                    type="button"
+                    onClick={() => setSelectedClueId(clue.id)}
+                    className={`flex w-full items-center gap-2 rounded-md border p-2 text-left text-xs transition-colors hover:bg-muted/30 ${getTypeColor(
+                      clue.type,
+                    )}`}
+                  >
+                    <span className="text-sm">{getTypeIcon(clue.type)}</span>
+                    <span className="line-clamp-2 flex-1">{clue.content}</span>
+                    <span className="shrink-0 text-[10px] font-medium text-muted-foreground">
+                      {(similarity * 100).toFixed(0)}%
+                    </span>
+                  </button>
+                ))}
+              </div>
+            </div>
+          )}
+        </div>
+      )}
+    </div>
+  )
+}

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

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

+ 303 - 0
src/components/novel/story-simulation/rumor-propagation-panel.tsx

@@ -0,0 +1,303 @@
+import { useState, useMemo } from "react"
+import { MessageCircle, Users, Eye, CheckCircle, XCircle, Clock, Filter } from "lucide-react"
+import type { RumorEvent, NovelAgent, TimelineEvent } from "@/lib/novel/story-simulation/types"
+
+type RumorFilter = "all" | "unverified" | "verified" | "falsified"
+
+interface RumorPropagationPanelProps {
+  rumors: RumorEvent[]
+  agents: Map<string, NovelAgent>
+  events: TimelineEvent[]
+}
+
+export function RumorPropagationPanel({ rumors, agents, events }: RumorPropagationPanelProps) {
+  const [selectedRumorId, setSelectedRumorId] = useState<string | null>(null)
+  const [filter, setFilter] = useState<RumorFilter>("all")
+
+  const filteredRumors = useMemo(() => {
+    switch (filter) {
+      case "unverified":
+        return rumors.filter((r) => r.verifiedBy.length === 0)
+      case "verified":
+        return rumors.filter((r) => r.verifiedBy.length > 0 && r.distortion < 0.5)
+      case "falsified":
+        return rumors.filter((r) => r.verifiedBy.length > 0 && r.distortion >= 0.5)
+      default:
+        return rumors
+    }
+  }, [rumors, filter])
+
+  const selectedRumor = useMemo(
+    () => rumors.find((r) => r.id === selectedRumorId) ?? null,
+    [rumors, selectedRumorId],
+  )
+
+  const sourceEvent = useMemo(() => {
+    if (!selectedRumor?.sourceId) return null
+    return events.find((e) => e.id === selectedRumor.sourceId) ?? null
+  }, [selectedRumor, events])
+
+  if (rumors.length === 0) {
+    return (
+      <div className="flex h-full items-center justify-center rounded-lg border bg-muted/30 p-6 text-center text-xs text-muted-foreground">
+        暂无传闻数据
+      </div>
+    )
+  }
+
+  return (
+    <div className="flex h-full min-h-0 gap-3">
+      <div className="flex w-72 shrink-0 flex-col gap-2">
+        <div className="flex items-center gap-2">
+          <Filter className="h-3.5 w-3.5 text-muted-foreground" />
+          <select
+            value={filter}
+            onChange={(e) => setFilter(e.target.value as RumorFilter)}
+            className="h-7 flex-1 rounded border border-input bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
+          >
+            <option value="all">全部传闻</option>
+            <option value="unverified">未验证</option>
+            <option value="verified">已验证</option>
+            <option value="falsified">已证伪</option>
+          </select>
+        </div>
+
+        <div className="min-h-0 flex-1 space-y-2 overflow-y-auto">
+          {filteredRumors.length === 0 ? (
+            <div className="rounded-md border bg-muted/20 p-4 text-center text-xs text-muted-foreground">
+              暂无符合条件的传闻
+            </div>
+          ) : (
+            filteredRumors.map((rumor) => (
+              <button
+                key={rumor.id}
+                type="button"
+                onClick={() => setSelectedRumorId(rumor.id)}
+                className={`w-full rounded-md border p-2.5 text-left transition-colors ${
+                  selectedRumorId === rumor.id
+                    ? "border-primary bg-primary/5"
+                    : "bg-background/70 hover:bg-muted/30"
+                }`}
+              >
+                <div className="mb-1.5 flex items-center justify-between gap-2">
+                  <span
+                    className={`rounded px-1.5 py-0.5 text-[10px] font-medium ${
+                      rumor.distortion < 0.3
+                        ? "bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300"
+                        : rumor.distortion < 0.6
+                          ? "bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300"
+                          : "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300"
+                    }`}
+                  >
+                    失真 {(rumor.distortion * 100).toFixed(0)}%
+                  </span>
+                  <span className="text-[10px] text-muted-foreground">
+                    第 {rumor.round + 1} 轮
+                  </span>
+                </div>
+                <div className="mb-1.5 line-clamp-2 text-xs">
+                  {rumor.content}
+                </div>
+                <div className="flex items-center gap-3 text-[10px] text-muted-foreground">
+                  <span className="flex items-center gap-1">
+                    <Users className="h-3 w-3" />
+                    {rumor.believedBy.length}
+                  </span>
+                  <span className="flex items-center gap-1">
+                    <CheckCircle className="h-3 w-3" />
+                    {rumor.verifiedBy.length}
+                  </span>
+                </div>
+              </button>
+            ))
+          )}
+        </div>
+      </div>
+
+      <div className="min-h-0 flex-1 overflow-y-auto rounded-md border bg-background/70 p-3">
+        {selectedRumor ? (
+          <div className="space-y-4">
+            <div>
+              <div className="mb-2 flex items-center gap-2">
+                <MessageCircle className="h-4 w-4 text-primary" />
+                <span className="text-sm font-medium">传闻详情</span>
+              </div>
+              <div className="rounded-md border bg-muted/20 p-3 text-xs">
+                {selectedRumor.content}
+              </div>
+            </div>
+
+            <div className="space-y-3">
+              <div className="flex items-center gap-2">
+                <Clock className="h-4 w-4 text-muted-foreground" />
+                <span className="text-sm font-medium">传播时间线</span>
+              </div>
+
+              <div className="relative ml-3 space-y-4 border-l-2 border-muted pl-4">
+                <div className="relative">
+                  <div className="absolute -left-[21px] top-0.5 h-3 w-3 rounded-full bg-primary" />
+                  <div className="text-xs font-medium">
+                    第 {selectedRumor.round + 1} 轮 · 传闻生成
+                  </div>
+                  {sourceEvent ? (
+                    <div className="mt-1.5 rounded-md border bg-muted/20 p-2 text-xs text-muted-foreground">
+                      <div className="mb-1 font-medium text-foreground">
+                        源事件:{sourceEvent.actorName} 的
+                        {actionTypeLabel(sourceEvent.actionType)}
+                      </div>
+                      <div className="line-clamp-3">{sourceEvent.content}</div>
+                    </div>
+                  ) : (
+                    <div className="mt-1.5 text-xs text-muted-foreground">
+                      (无源事件记录)
+                    </div>
+                  )}
+                </div>
+
+                <div className="relative">
+                  <div className="absolute -left-[21px] top-0.5 h-3 w-3 rounded-full bg-blue-500" />
+                  <div className="text-xs font-medium">角色可见</div>
+                  <div className="mt-1.5 flex flex-wrap gap-1">
+                    {selectedRumor.observableBy.length === 0 ? (
+                      <span className="text-xs text-muted-foreground">无</span>
+                    ) : (
+                      selectedRumor.observableBy.map((agentId) => {
+                        const agent = agents.get(agentId)
+                        return (
+                          <span
+                            key={agentId}
+                            className="rounded bg-blue-50 px-1.5 py-0.5 text-[11px] text-blue-700 dark:bg-blue-900/30 dark:text-blue-300"
+                          >
+                            {agent?.name ?? agentId}
+                          </span>
+                        )
+                      })
+                    )}
+                  </div>
+                </div>
+
+                <div className="relative">
+                  <div className="absolute -left-[21px] top-0.5 h-3 w-3 rounded-full bg-emerald-500" />
+                  <div className="text-xs font-medium">相信传闻</div>
+                  <div className="mt-1.5 flex flex-wrap gap-1">
+                    {selectedRumor.believedBy.length === 0 ? (
+                      <span className="text-xs text-muted-foreground">暂无角色相信</span>
+                    ) : (
+                      selectedRumor.believedBy.map((agentId) => {
+                        const agent = agents.get(agentId)
+                        return (
+                          <span
+                            key={agentId}
+                            className="rounded bg-emerald-50 px-1.5 py-0.5 text-[11px] text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300"
+                          >
+                            {agent?.name ?? agentId}
+                          </span>
+                        )
+                      })
+                    )}
+                  </div>
+                </div>
+
+                <div className="relative">
+                  <div className="absolute -left-[21px] top-0.5 h-3 w-3 rounded-full bg-purple-500" />
+                  <div className="text-xs font-medium">调查验证</div>
+                  <div className="mt-1.5">
+                    {selectedRumor.verifiedBy.length === 0 ? (
+                      <span className="text-xs text-muted-foreground">暂无角色验证</span>
+                    ) : (
+                      <div className="space-y-1">
+                        {selectedRumor.verifiedBy.map((agentId) => {
+                          const agent = agents.get(agentId)
+                          const isTrue = selectedRumor.distortion < 0.5
+                          return (
+                            <div
+                              key={agentId}
+                              className="flex items-center gap-2 rounded-md border bg-muted/20 px-2 py-1 text-xs"
+                            >
+                              {isTrue ? (
+                                <CheckCircle className="h-3.5 w-3.5 text-emerald-500" />
+                              ) : (
+                                <XCircle className="h-3.5 w-3.5 text-red-500" />
+                              )}
+                              <span className="font-medium">
+                                {agent?.name ?? agentId}
+                              </span>
+                              <span className="text-muted-foreground">
+                                {isTrue ? "证实为真" : "证实为假"}
+                              </span>
+                            </div>
+                          )
+                        })}
+                      </div>
+                    )}
+                  </div>
+                </div>
+              </div>
+            </div>
+
+            <div className="grid grid-cols-3 gap-2">
+              <div className="rounded-md border bg-muted/20 p-2 text-center">
+                <div className="text-[10px] text-muted-foreground">失真度</div>
+                <div
+                  className={`text-lg font-semibold ${
+                    selectedRumor.distortion < 0.3
+                      ? "text-green-600 dark:text-green-400"
+                      : selectedRumor.distortion < 0.6
+                        ? "text-amber-600 dark:text-amber-400"
+                        : "text-red-600 dark:text-red-400"
+                  }`}
+                >
+                  {(selectedRumor.distortion * 100).toFixed(0)}%
+                </div>
+              </div>
+              <div className="rounded-md border bg-muted/20 p-2 text-center">
+                <div className="text-[10px] text-muted-foreground">可见人数</div>
+                <div className="text-lg font-semibold">
+                  {selectedRumor.observableBy.length}
+                </div>
+              </div>
+              <div className="rounded-md border bg-muted/20 p-2 text-center">
+                <div className="text-[10px] text-muted-foreground">验证人数</div>
+                <div className="text-lg font-semibold">
+                  {selectedRumor.verifiedBy.length}
+                </div>
+              </div>
+            </div>
+          </div>
+        ) : (
+          <div className="flex h-full items-center justify-center text-xs text-muted-foreground">
+            <div className="text-center">
+              <Eye className="mx-auto mb-2 h-8 w-8 opacity-50" />
+              <div>选择左侧传闻查看传播链</div>
+            </div>
+          </div>
+        )}
+      </div>
+    </div>
+  )
+}
+
+function actionTypeLabel(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 "行动"
+  }
+}

+ 26 - 1
src/components/novel/story-simulation/simulation-config-panel.tsx

@@ -41,11 +41,13 @@ export function SimulationConfigPanel({ onStart }: SimulationConfigPanelProps) {
   const targetWords = useStorySimulationStore((s) => s.targetWords)
   const sourceChapters = useStorySimulationStore((s) => s.sourceChapters)
   const simulationRounds = useStorySimulationStore((s) => s.simulationRounds)
+  const directorEnabled = useStorySimulationStore((s) => s.directorEnabled)
   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 setDirectorEnabled = useStorySimulationStore((s) => s.setDirectorEnabled)
 
   const selectedModeInfo = MODE_VISUAL_INFO[mode]
 
@@ -207,7 +209,30 @@ export function SimulationConfigPanel({ onStart }: SimulationConfigPanelProps) {
         </p>
       </section>
 
-      {/* 6. 开始按钮 */}
+      {/* 6. 高级设置 */}
+      <section className="flex flex-col gap-3">
+        <h3 className="text-sm font-medium">高级设置</h3>
+        <div className="flex items-center gap-2">
+          <input
+            type="checkbox"
+            id="director-enabled"
+            checked={directorEnabled}
+            onChange={(e) => setDirectorEnabled(e.target.checked)}
+            className="h-4 w-4 cursor-pointer rounded border-input"
+          />
+          <label
+            htmlFor="director-enabled"
+            className="cursor-pointer text-sm font-medium"
+          >
+            启用导演 Agent
+          </label>
+        </div>
+        <p className="text-xs text-muted-foreground pl-6">
+          节点结束时自动评估剧情质量,张力不足时会在下一节点注入突发事件以提升戏剧性。(默认关闭)
+        </p>
+      </section>
+
+      {/* 7. 开始按钮 */}
       <div className="flex justify-end pt-2">
         <Button onClick={onStart} size="lg">
           {t("storySimulation.startExtract")}

+ 222 - 54
src/components/novel/story-simulation/story-simulation-view.tsx

@@ -11,6 +11,7 @@ import {
   runSimulation,
   type SimulationCallbacks,
 } from "@/lib/novel/story-simulation/simulation-engine"
+import { generateDynamicEventPool } from "@/lib/novel/story-simulation/event-pool-generator"
 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"
@@ -35,6 +36,10 @@ 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 { RumorPropagationPanel } from "./rumor-propagation-panel"
+import { RelationshipGraphPanel } from "./relationship-graph-panel"
+import { ClueBoardPanel } from "./clue-board-panel"
+import { BranchManagerPanel } from "./branch-manager-panel"
 import { Button } from "@/components/ui/button"
 
 const PROGRESS_PHASES = [
@@ -160,6 +165,16 @@ export function StorySimulationView() {
   const setShowInterviewHistory = useStorySimulationStore((s) => s.setShowInterviewHistory)
   const continuingInterviewId = useStorySimulationStore((s) => s.continuingInterviewId)
   const setContinuingInterviewId = useStorySimulationStore((s) => s.setContinuingInterviewId)
+  const dynamicEventPool = useStorySimulationStore((s) => s.dynamicEventPool)
+  const setDynamicEventPool = useStorySimulationStore((s) => s.setDynamicEventPool)
+  const setCurrentRumors = useStorySimulationStore((s) => s.setCurrentRumors)
+  const setCurrentAgents = useStorySimulationStore((s) => s.setCurrentAgents)
+  const branches = useStorySimulationStore((s) => s.branches)
+  const activeBranchId = useStorySimulationStore((s) => s.activeBranchId)
+  const saveCurrentAsBranch = useStorySimulationStore((s) => s.saveCurrentAsBranch)
+  const deleteBranch = useStorySimulationStore((s) => s.deleteBranch)
+  const renameBranch = useStorySimulationStore((s) => s.renameBranch)
+  const switchToBranch = useStorySimulationStore((s) => s.switchToBranch)
 
   // 保存仿真后的 agents 和 state 供采访使用
   const lastAgentsRef = useRef<NovelAgent[]>([])
@@ -275,11 +290,20 @@ export function StorySimulationView() {
       )
       setExtractionResult(extraction)
 
+      // 1.5 预生成动态事件池
+      const llmConfig = resolveDefaultModel(baseLlmConfig)
+      const characterNames = extraction.characters.map((c) => c.name)
+      const eventPool = await generateDynamicEventPool({
+        llmConfig,
+        worldRules: extraction.worldRules,
+        characters: characterNames,
+      })
+      setDynamicEventPool(eventPool)
+
       // 2. 生成框架
       setPhase("framework-generating")
       phaseBaseProgressRef.current = 30
       setProgress(30, "正在生成故事框架...")
-      const llmConfig = resolveDefaultModel(baseLlmConfig)
       const framework: StoryFramework = await generateStoryFramework({
         extraction,
         mode,
@@ -365,7 +389,11 @@ export function StorySimulationView() {
           collectedTimeline.push(event)
           addTimelineEvent(event)
         },
-        onDebugTrace: addDebugTrace,
+        onDebugTrace: (trace) => {
+          addDebugTrace(trace)
+          setCurrentRumors(trace.rumors)
+          setCurrentAgents(trace.activeAgents)
+        },
       }
       const events = await runSimulation(
         {
@@ -376,6 +404,7 @@ export function StorySimulationView() {
           llmConfig,
           userIdea: userIdea || undefined,
           maxRoundsPerNode: simulationRounds > 0 ? simulationRounds : undefined,
+          dynamicEventPool: (Array.isArray(dynamicEventPool) ? dynamicEventPool.length > 0 : dynamicEventPool.all.length > 0) ? dynamicEventPool : undefined,
         },
         extraction,
         callbacks,
@@ -395,6 +424,8 @@ export function StorySimulationView() {
         timelineEvents: collectedTimeline,
         activeAgents: new Map(agents.map((a) => [a.characterId, a])),
         worldState: {},
+        directorEnabled: false,
+        nextNodeInjectionMap: new Map(),
       }
 
       // 生成推演报告
@@ -775,6 +806,12 @@ export function StorySimulationView() {
               onInterviewAgent={(id, name) => handleInterviewAgent(id, name)}
               onCancel={handleCancel}
               cancelling={isCancelling}
+              branches={branches}
+              activeBranchId={activeBranchId}
+              onSaveBranch={saveCurrentAsBranch}
+              onDeleteBranch={deleteBranch}
+              onRenameBranch={renameBranch}
+              onSwitchBranch={switchToBranch}
             />
           </div>
         ) : phase === "framework-confirming" ? (
@@ -797,7 +834,7 @@ export function StorySimulationView() {
                 onViewInterviewHistory={() => setShowInterviewHistory(true)}
               />
             </div>
-            {activeChatAgent && (
+            {activeChatAgent ? (
               <AgentChatPanel
                 agentName={activeChatAgent.name}
                 messages={agentChatMessages}
@@ -812,6 +849,19 @@ export function StorySimulationView() {
                 saving={chatSaving}
                 chatLogRef={chatLogRef}
               />
+            ) : (
+              <div className="flex w-80 shrink-0 flex-col border-l p-3">
+                <div className="flex-1 overflow-hidden">
+                  <BranchManagerPanel
+                    branches={branches}
+                    activeBranchId={activeBranchId}
+                    onSaveBranch={saveCurrentAsBranch}
+                    onDeleteBranch={deleteBranch}
+                    onRenameBranch={renameBranch}
+                    onSwitchBranch={switchToBranch}
+                  />
+                </div>
+              </div>
             )}
           </div>
         ) : phase === "draft-viewing" ? (
@@ -876,6 +926,12 @@ function SimulatingTimelinePanel({
   onInterviewAgent,
   onCancel,
   cancelling,
+  branches,
+  activeBranchId,
+  onSaveBranch,
+  onDeleteBranch,
+  onRenameBranch,
+  onSwitchBranch,
 }: {
   progress: number
   label: string
@@ -885,10 +941,16 @@ function SimulatingTimelinePanel({
   onInterviewAgent?: (agentId: string, agentName: string) => void
   onCancel?: () => void
   cancelling?: boolean
+  branches: import("@/lib/novel/story-simulation/types").SimulationBranch[]
+  activeBranchId: string | null
+  onSaveBranch: (name: string) => void
+  onDeleteBranch: (id: string) => void
+  onRenameBranch: (id: string, name: string) => void
+  onSwitchBranch: (id: string) => void
 }) {
   const clamped = Math.min(100, Math.max(0, progress))
   const logRef = useRef<HTMLDivElement | null>(null)
-  const [activeStreamView, setActiveStreamView] = useState<"timeline" | "debug">("timeline")
+  const [activeStreamView, setActiveStreamView] = useState<"timeline" | "debug" | "branches">("timeline")
 
   // 筛选状态
   const [filterActor, setFilterActor] = useState<string>("all")
@@ -1047,6 +1109,17 @@ function SimulatingTimelinePanel({
           >
             过程观察
           </button>
+          <button
+            type="button"
+            className={`rounded px-3 py-1.5 ${
+              activeStreamView === "branches"
+                ? "bg-background text-foreground shadow-sm"
+                : "text-muted-foreground hover:text-foreground"
+            }`}
+            onClick={() => setActiveStreamView("branches")}
+          >
+            分支管理
+          </button>
         </div>
       </div>
 
@@ -1199,8 +1272,19 @@ function SimulatingTimelinePanel({
         )}
       </div>
         </>
-      ) : (
+      ) : activeStreamView === "debug" ? (
         <ProcessDebugPanel debugTraces={debugTraces} />
+      ) : (
+        <div className="flex-1 overflow-hidden">
+          <BranchManagerPanel
+            branches={branches}
+            activeBranchId={activeBranchId}
+            onSaveBranch={onSaveBranch}
+            onDeleteBranch={onDeleteBranch}
+            onRenameBranch={onRenameBranch}
+            onSwitchBranch={onSwitchBranch}
+          />
+        </div>
       )}
     </div>
   )
@@ -1211,6 +1295,10 @@ function ProcessDebugPanel({
 }: {
   debugTraces: SimulationDebugTrace[]
 }) {
+  const [activeTab, setActiveTab] = useState<"overview" | "rumors" | "relationships" | "clues">("overview")
+  const currentRumors = useStorySimulationStore((s) => s.currentRumors)
+  const currentAgents = useStorySimulationStore((s) => s.currentAgents)
+  const timelineEvents = useStorySimulationStore((s) => s.timelineEvents)
   const latestTrace = debugTraces[debugTraces.length - 1]
   const displayTraces = debugTraces.slice(-50).reverse()
 
@@ -1223,65 +1311,145 @@ function ProcessDebugPanel({
   }
 
   return (
-    <div className="min-h-0 flex-1 overflow-y-auto rounded-lg border bg-muted/30 p-3 text-sm">
-      <div className="mb-3 grid grid-cols-2 gap-2 md:grid-cols-4">
-        <DebugStat label="全量角色" value={latestTrace.blackboard.allAgentCount} />
-        <DebugStat label="活跃角色" value={latestTrace.blackboard.activeAgentCount} />
-        <DebugStat label="总事件" value={latestTrace.blackboard.totalEventCount} />
-        <DebugStat label="公共事件" value={latestTrace.blackboard.publicEventCount} />
+    <div className="flex min-h-0 flex-1 flex-col rounded-lg border bg-muted/30 text-sm">
+      <div className="flex shrink-0 justify-center border-b p-2">
+        <div className="inline-flex rounded-md border bg-muted/40 p-0.5 text-xs">
+          <button
+            type="button"
+            className={`rounded px-3 py-1.5 ${
+              activeTab === "overview"
+                ? "bg-background text-foreground shadow-sm"
+                : "text-muted-foreground hover:text-foreground"
+            }`}
+            onClick={() => setActiveTab("overview")}
+          >
+            概览
+          </button>
+          <button
+            type="button"
+            className={`rounded px-3 py-1.5 ${
+              activeTab === "rumors"
+                ? "bg-background text-foreground shadow-sm"
+                : "text-muted-foreground hover:text-foreground"
+            }`}
+            onClick={() => setActiveTab("rumors")}
+          >
+            传闻
+          </button>
+          <button
+            type="button"
+            className={`rounded px-3 py-1.5 ${
+              activeTab === "relationships"
+                ? "bg-background text-foreground shadow-sm"
+                : "text-muted-foreground hover:text-foreground"
+            }`}
+            onClick={() => setActiveTab("relationships")}
+          >
+            关系
+          </button>
+          <button
+            type="button"
+            className={`rounded px-3 py-1.5 ${
+              activeTab === "clues"
+                ? "bg-background text-foreground shadow-sm"
+                : "text-muted-foreground hover:text-foreground"
+            }`}
+            onClick={() => setActiveTab("clues")}
+          >
+            线索
+          </button>
+        </div>
       </div>
 
-      <div className="space-y-3">
-        {displayTraces.map((trace) => (
-          <div key={trace.id} className="rounded-md border bg-background/70 p-3">
-            <div className="mb-2 flex flex-wrap items-center gap-2">
-              <span className="rounded bg-primary/10 px-1.5 py-0.5 text-[11px] font-medium text-primary">
-                {trace.type === "round-plan" ? "轮次计划" : "事件写入"}
-              </span>
-              <span className="text-xs text-muted-foreground">
-                节点 {trace.nodeIndex + 1}:{trace.nodeTitle}
-              </span>
-              <span className="text-xs text-muted-foreground">R{trace.round + 1}</span>
-              {trace.strategy && (
-                <span className="text-xs text-muted-foreground">
-                  策略:{trace.strategy === "all-agents" ? "全部角色" : trace.strategy === "subset" ? "部分角色" : "无角色"}
-                </span>
-              )}
+      <div className="min-h-0 flex-1 overflow-y-auto p-3">
+        {activeTab === "overview" && (
+          <div className="space-y-3">
+            <div className="mb-3 grid grid-cols-2 gap-2 md:grid-cols-4">
+              <DebugStat label="全量角色" value={latestTrace.blackboard.allAgentCount} />
+              <DebugStat label="活跃角色" value={latestTrace.blackboard.activeAgentCount} />
+              <DebugStat label="总事件" value={latestTrace.blackboard.totalEventCount} />
+              <DebugStat label="公共事件" value={latestTrace.blackboard.publicEventCount} />
             </div>
 
-            <div className="mb-2 grid gap-2 md:grid-cols-2">
-              <DebugAgentList title="候选 Agent" agents={trace.candidateAgents} />
-              <DebugAgentList title="本轮行动 Agent" agents={trace.selectedAgents} />
-            </div>
+            <div className="space-y-3">
+              {displayTraces.map((trace) => (
+                <div key={trace.id} className="rounded-md border bg-background/70 p-3">
+                  <div className="mb-2 flex flex-wrap items-center gap-2">
+                    <span className="rounded bg-primary/10 px-1.5 py-0.5 text-[11px] font-medium text-primary">
+                      {trace.type === "round-plan" ? "轮次计划" : "事件写入"}
+                    </span>
+                    <span className="text-xs text-muted-foreground">
+                      节点 {trace.nodeIndex + 1}:{trace.nodeTitle}
+                    </span>
+                    <span className="text-xs text-muted-foreground">R{trace.round + 1}</span>
+                    {trace.strategy && (
+                      <span className="text-xs text-muted-foreground">
+                        策略:{trace.strategy === "all-agents" ? "全部角色" : trace.strategy === "subset" ? "部分角色" : "无角色"}
+                      </span>
+                    )}
+                  </div>
 
-            {trace.latestEvent && (
-              <div className="mb-2 rounded border bg-muted/30 px-2 py-1.5 text-xs">
-                <span className="font-medium">最近事件:</span>
-                <span className="text-muted-foreground">
-                  {trace.latestEvent.actorName} / {trace.latestEvent.actionType}
-                </span>
-                <span>:{trace.latestEvent.content}</span>
-              </div>
-            )}
+                  <div className="mb-2 grid gap-2 md:grid-cols-2">
+                    <DebugAgentList title="候选 Agent" agents={trace.candidateAgents} />
+                    <DebugAgentList title="本轮行动 Agent" agents={trace.selectedAgents} />
+                  </div>
 
-            <div className="rounded border bg-muted/20 p-2">
-              <div className="mb-1 text-xs font-medium">Blackboard 可见事件</div>
-              <div className="grid gap-1 md:grid-cols-2">
-                {trace.visibilityByAgent.map((agent) => (
-                  <div key={agent.agentId} className="text-xs text-muted-foreground">
-                    <span className="font-medium text-foreground">{agent.agentName}</span>
-                    <span> 可见事件 {agent.visibleEventCount ?? 0} 条</span>
-                    {agent.recentEvents && agent.recentEvents.length > 0 && (
-                      <span>
-                        :{agent.recentEvents.map((event) => event.id).join("、")}
+                  {trace.latestEvent && (
+                    <div className="mb-2 rounded border bg-muted/30 px-2 py-1.5 text-xs">
+                      <span className="font-medium">最近事件:</span>
+                      <span className="text-muted-foreground">
+                        {trace.latestEvent.actorName} / {trace.latestEvent.actionType}
                       </span>
-                    )}
+                      <span>:{trace.latestEvent.content}</span>
+                    </div>
+                  )}
+
+                  <div className="rounded border bg-muted/20 p-2">
+                    <div className="mb-1 text-xs font-medium">Blackboard 可见事件</div>
+                    <div className="grid gap-1 md:grid-cols-2">
+                      {trace.visibilityByAgent.map((agent) => (
+                        <div key={agent.agentId} className="text-xs text-muted-foreground">
+                          <span className="font-medium text-foreground">{agent.agentName}</span>
+                          <span> 可见事件 {agent.visibleEventCount ?? 0} 条</span>
+                          {agent.recentEvents && agent.recentEvents.length > 0 && (
+                            <span>
+                              :{agent.recentEvents.map((event) => event.id).join("、")}
+                            </span>
+                          )}
+                        </div>
+                      ))}
+                    </div>
                   </div>
-                ))}
-              </div>
+                </div>
+              ))}
             </div>
           </div>
-        ))}
+        )}
+
+        {activeTab === "rumors" && (
+          <div className="h-full">
+            <RumorPropagationPanel
+              rumors={currentRumors}
+              agents={currentAgents}
+              events={timelineEvents}
+            />
+          </div>
+        )}
+
+        {activeTab === "relationships" && (
+          <div className="h-full">
+            <RelationshipGraphPanel agents={currentAgents} />
+          </div>
+        )}
+
+        {activeTab === "clues" && (
+          <div className="h-full">
+            <ClueBoardPanel
+              agents={currentAgents}
+              rumors={currentRumors}
+            />
+          </div>
+        )}
       </div>
     </div>
   )

+ 4 - 0
src/components/skill-library/unified-skill-model.spec.ts

@@ -42,6 +42,7 @@ const writingConfig: UserSkillConfig = {
   version: 1,
   selectedSkillId: "skill:rhythm",
   disabledSkillIds: [],
+  categories: [],
   skills: [
     {
       id: "skill:rhythm",
@@ -52,6 +53,9 @@ const writingConfig: UserSkillConfig = {
       modes: ["strict"],
       content: "检查中段塌陷、重复场景和结尾钩子。",
       source: "uploaded",
+      priority: 0,
+      tags: [],
+      categoryId: "",
       createdAt: 100,
       updatedAt: 200,
     },

+ 1 - 1
src/components/skill-library/unified-skill-model.ts

@@ -14,7 +14,7 @@ import {
 import type { UserSkillConfig } from "@/lib/novel/user-skill-store"
 
 export type UnifiedSkillLibrary = "writing" | "de-ai"
-export type UnifiedSkillSource = DeAiSkillSource | "built-in" | "project" | "uploaded"
+export type UnifiedSkillSource = DeAiSkillSource | "built-in" | "project" | "uploaded" | "linked"
 export type UnifiedSkillStatusFilter = "enabled" | "disabled"
 
 export interface UnifiedSkillEntry {

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

@@ -30,6 +30,9 @@ const INTENT_LABELS: Record<NovelTaskIntent, string> = {
   timeline_query: "时间线查询",
   setting_query: "设定查询",
   general_chat: "随便聊聊",
+  story_framework_generate: "故事框架生成",
+  multi_agent_simulate: "多智能体推演",
+  character_interview: "角色采访",
 }
 
 export function intentToLabel(intent: NovelTaskIntent): string {

+ 51 - 0
src/lib/embedding-client.ts

@@ -0,0 +1,51 @@
+import type { LlmConfig } from "@/stores/wiki-store"
+
+export function cosineSimilarity(a: number[], b: number[]): number {
+  if (a.length === 0 || b.length === 0) return 0
+  if (a.length !== b.length) return 0
+
+  let dotProduct = 0
+  let normA = 0
+  let normB = 0
+
+  for (let i = 0; i < a.length; i++) {
+    dotProduct += a[i] * b[i]
+    normA += a[i] * a[i]
+    normB += b[i] * b[i]
+  }
+
+  if (normA === 0 || normB === 0) return 0
+
+  return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB))
+}
+
+export async function embed(text: string, config: LlmConfig): Promise<number[]> {
+  const baseUrl = (config as { baseUrl?: string }).baseUrl || "https://api.openai.com/v1"
+  const endpoint = `${baseUrl.replace(/\/$/, "")}/embeddings`
+
+  const response = await fetch(endpoint, {
+    method: "POST",
+    headers: {
+      "Content-Type": "application/json",
+      Authorization: `Bearer ${config.apiKey}`,
+    },
+    body: JSON.stringify({
+      model: config.model,
+      input: text,
+    }),
+  })
+
+  if (!response.ok) {
+    const errorText = await response.text().catch(() => "")
+    throw new Error(`Embedding API error: ${response.status} ${response.statusText}${errorText ? ` - ${errorText.slice(0, 200)}` : ""}`)
+  }
+
+  const data = await response.json()
+  const embedding = data?.data?.[0]?.embedding
+
+  if (!Array.isArray(embedding) || embedding.length === 0) {
+    throw new Error("Invalid embedding response: missing data[0].embedding")
+  }
+
+  return embedding as number[]
+}

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

@@ -5,6 +5,7 @@ import type {
   ExtractionResult,
   ExtractedCharacter,
   NovelAgent,
+  RumorEvent,
   StoryFramework,
   StoryNode,
   TimelineEvent,
@@ -219,6 +220,11 @@ export function formatTimelineEvent(event: TimelineEvent): string {
   return `第${event.round + 1}轮 ${visibilityTag} ${event.actorName}${targetDesc} [${event.actionType}]:${event.content}`
 }
 
+function formatRumorEvent(rumor: RumorEvent): string {
+  const distortionPercent = Math.round(rumor.distortion * 100)
+  return `第${rumor.round + 1}轮 [传闻(可信度低,可能失真)] ${rumor.content}(失真度约${distortionPercent}%)`
+}
+
 /**
  * 构建 Agent 决策时的上下文文本。
  *
@@ -230,6 +236,7 @@ export function buildAgentContext(
   recentEvents: string[],
   worldRules: string,
   visibleTimelineEvents?: TimelineEvent[],
+  visibleRumors?: RumorEvent[],
 ): string {
   const sections: string[] = []
 
@@ -327,6 +334,16 @@ export function buildAgentContext(
     }
   }
 
+  // ── 你听到的传闻 ──
+  if (visibleRumors && visibleRumors.length > 0) {
+    sections.push("")
+    sections.push("【你听到的传闻】")
+    sections.push("注意:传闻(可信度低,可能失真),仅供参考,不要当作事实。")
+    for (const rumor of visibleRumors) {
+      sections.push(`- ${formatRumorEvent(rumor)}`)
+    }
+  }
+
   // ── 世界规则 ──
   if (worldRules) {
     sections.push("")

+ 402 - 0
src/lib/novel/story-simulation/director-agent.spec.ts

@@ -0,0 +1,402 @@
+import { describe, expect, it, vi } from "vitest"
+import type { LlmConfig } from "@/stores/wiki-store"
+import type { StoryNode, TimelineEvent, DirectorEvaluation, DirectorScore } from "./types"
+import {
+  shouldInjectEvent,
+  directorEvaluate,
+} from "./director-agent"
+
+vi.mock("@/lib/llm-client", () => ({
+  streamChat: vi.fn(),
+}))
+
+const mockLlmConfig: LlmConfig = {
+  provider: "openai",
+  apiKey: "test-key",
+  model: "test-model",
+  ollamaUrl: "",
+  customEndpoint: "",
+  maxContextSize: 4096,
+}
+
+const mockNode: StoryNode = {
+  index: 0,
+  phase: "起",
+  title: "初入宗门",
+  coreConflict: "主角初入宗门,面临身份危机",
+  involvedCharacters: ["张三", "李四"],
+  goal: "主角成功拜入宗门",
+  causeFromPrev: "",
+  expectedOutcome: "主角通过考验,正式成为宗门弟子",
+}
+
+const mockTimelineEvents: TimelineEvent[] = [
+  {
+    id: "evt_1",
+    round: 0,
+    nodeIndex: 0,
+    actorId: "char_1",
+    actorName: "张三",
+    actionType: "pushPlot",
+    content: "张三来到宗门门口报名",
+    observableBy: ["char_1", "char_2"],
+    impacts: [],
+    timestamp: "2024-01-01T00:00:00.000Z",
+  },
+]
+
+function buildMockScores(overrides: Partial<DirectorScore> = {}): DirectorScore {
+  return {
+    tension: 3,
+    pace: 3,
+    characterUtilization: 3,
+    characterArc: 3,
+    infoDensity: 3,
+    emotionalResonance: 3,
+    logicConsistency: 3,
+    ...overrides,
+  }
+}
+
+describe("shouldInjectEvent", () => {
+  it("shouldInjectEvent 为 true 且有 injectEvent 时返回 true", () => {
+    const eval_: DirectorEvaluation = {
+      scores: buildMockScores({ tension: 2 }),
+      totalScore: 2.5,
+      highlights: ["亮点1"],
+      issues: ["问题1"],
+      suggestion: "建议增加冲突",
+      shouldInjectEvent: true,
+      injectEvent: "一位长老突然出现,质疑主角的身份。",
+    }
+    expect(shouldInjectEvent(eval_)).toBe(true)
+  })
+
+  it("shouldInjectEvent 为 false 时返回 false,即使有 injectEvent", () => {
+    const eval_: DirectorEvaluation = {
+      scores: buildMockScores(),
+      totalScore: 3.0,
+      highlights: ["亮点1"],
+      issues: [],
+      suggestion: "",
+      shouldInjectEvent: false,
+      injectEvent: "一位长老突然出现,质疑主角的身份。",
+    }
+    expect(shouldInjectEvent(eval_)).toBe(false)
+  })
+
+  it("shouldInjectEvent 为 true 但没有 injectEvent 时返回 false", () => {
+    const eval_: DirectorEvaluation = {
+      scores: buildMockScores({ tension: 2 }),
+      totalScore: 2.0,
+      highlights: ["亮点1"],
+      issues: ["问题1"],
+      suggestion: "建议增加冲突",
+      shouldInjectEvent: true,
+    }
+    expect(shouldInjectEvent(eval_)).toBe(false)
+  })
+
+  it("totalScore < 3 且 shouldInjectEvent 为 true 且有 injectEvent 时返回 true", () => {
+    const eval_: DirectorEvaluation = {
+      scores: buildMockScores({ tension: 1, pace: 1, characterUtilization: 1 }),
+      totalScore: 2.0,
+      highlights: [],
+      issues: ["剧情太拖沓", "张力不足"],
+      suggestion: "剧情太拖沓,需要增加冲突",
+      shouldInjectEvent: true,
+      injectEvent: "意外事件发生",
+    }
+    expect(shouldInjectEvent(eval_)).toBe(true)
+  })
+
+  it("totalScore >= 3 时 shouldInjectEvent 应为 false,返回 false", () => {
+    const eval_: DirectorEvaluation = {
+      scores: buildMockScores({ tension: 4 }),
+      totalScore: 3.5,
+      highlights: ["节奏紧凑", "角色鲜明"],
+      issues: [],
+      suggestion: "保持当前节奏",
+      shouldInjectEvent: false,
+    }
+    expect(shouldInjectEvent(eval_)).toBe(false)
+  })
+})
+
+describe("directorEvaluate", () => {
+  it("LLM 返回有效 JSON 时应正确解析 7 维度评分并计算总分", async () => {
+    const { streamChat } = await import("@/lib/llm-client")
+
+    vi.mocked(streamChat).mockImplementationOnce(
+      (_config, _messages, callbacks) => {
+        callbacks.onToken(
+          JSON.stringify({
+            scores: {
+              tension: 2,
+              pace: 2,
+              characterUtilization: 3,
+              characterArc: 2,
+              infoDensity: 2,
+              emotionalResonance: 2,
+              logicConsistency: 3,
+            },
+            totalScore: 2.29,
+            highlights: ["角色互动自然"],
+            issues: ["节奏偏慢", "张力不足"],
+            suggestion: "当前节点节奏偏慢,建议增加冲突事件提升张力",
+            shouldInjectEvent: true,
+            injectEvent: "一位神秘长老突然出现,当众质疑主角的身份来历。",
+          }),
+        )
+        callbacks.onDone()
+        return Promise.resolve()
+      },
+    )
+
+    const result = await directorEvaluate({
+      node: mockNode,
+      nodeTimelineEvents: mockTimelineEvents,
+      worldRules: "这是一个玄幻世界",
+      llmConfig: mockLlmConfig,
+    })
+
+    expect(result.scores.tension).toBe(2)
+    expect(result.scores.pace).toBe(2)
+    expect(result.scores.characterUtilization).toBe(3)
+    expect(result.scores.characterArc).toBe(2)
+    expect(result.scores.infoDensity).toBe(2)
+    expect(result.scores.emotionalResonance).toBe(2)
+    expect(result.scores.logicConsistency).toBe(3)
+    expect(result.totalScore).toBeCloseTo(
+      (2 + 2 + 3 + 2 + 2 + 2 + 3) / 7,
+      2,
+    )
+    expect(result.highlights).toEqual(["角色互动自然"])
+    expect(result.issues).toEqual(["节奏偏慢", "张力不足"])
+    expect(result.suggestion).toBe(
+      "当前节点节奏偏慢,建议增加冲突事件提升张力",
+    )
+    expect(result.shouldInjectEvent).toBe(true)
+    expect(result.injectEvent).toBe(
+      "一位神秘长老突然出现,当众质疑主角的身份来历。",
+    )
+  })
+
+  it("LLM 未返回 totalScore 时应自动计算 7 项平均", async () => {
+    const { streamChat } = await import("@/lib/llm-client")
+
+    vi.mocked(streamChat).mockImplementationOnce(
+      (_config, _messages, callbacks) => {
+        callbacks.onToken(
+          JSON.stringify({
+            scores: {
+              tension: 4,
+              pace: 5,
+              characterUtilization: 4,
+              characterArc: 5,
+              infoDensity: 4,
+              emotionalResonance: 5,
+              logicConsistency: 4,
+            },
+            highlights: ["节奏紧凑", "角色弧光完整"],
+            issues: [],
+            suggestion: "保持当前节奏和质量",
+            shouldInjectEvent: false,
+          }),
+        )
+        callbacks.onDone()
+        return Promise.resolve()
+      },
+    )
+
+    const result = await directorEvaluate({
+      node: mockNode,
+      nodeTimelineEvents: mockTimelineEvents,
+      worldRules: "这是一个玄幻世界",
+      llmConfig: mockLlmConfig,
+    })
+
+    const expectedTotal = (4 + 5 + 4 + 5 + 4 + 5 + 4) / 7
+    expect(result.totalScore).toBeCloseTo(expectedTotal, 2)
+  })
+
+  it("LLM 返回带 markdown 代码块的 JSON 时也能解析", async () => {
+    const { streamChat } = await import("@/lib/llm-client")
+
+    vi.mocked(streamChat).mockImplementationOnce(
+      (_config, _messages, callbacks) => {
+        callbacks.onToken(
+          "```json\n" +
+            JSON.stringify({
+              scores: {
+                tension: 4,
+                pace: 3,
+                characterUtilization: 4,
+                characterArc: 4,
+                infoDensity: 3,
+                emotionalResonance: 4,
+                logicConsistency: 4,
+              },
+              totalScore: 3.71,
+              highlights: ["节奏不错", "角色塑造成功"],
+              issues: [],
+              suggestion: "节奏不错",
+              shouldInjectEvent: false,
+            }) +
+            "\n```",
+        )
+        callbacks.onDone()
+        return Promise.resolve()
+      },
+    )
+
+    const result = await directorEvaluate({
+      node: mockNode,
+      nodeTimelineEvents: mockTimelineEvents,
+      worldRules: "这是一个玄幻世界",
+      llmConfig: mockLlmConfig,
+    })
+
+    expect(result.scores.tension).toBe(4)
+    expect(result.scores.pace).toBe(3)
+    expect(result.scores.characterUtilization).toBe(4)
+    expect(result.scores.characterArc).toBe(4)
+    expect(result.scores.infoDensity).toBe(3)
+    expect(result.scores.emotionalResonance).toBe(4)
+    expect(result.scores.logicConsistency).toBe(4)
+    expect(result.highlights).toEqual(["节奏不错", "角色塑造成功"])
+    expect(result.suggestion).toBe("节奏不错")
+    expect(result.shouldInjectEvent).toBe(false)
+    expect(result.injectEvent).toBeUndefined()
+  })
+
+  it("LLM 失败时应返回默认评估且不阻断", async () => {
+    const { streamChat } = await import("@/lib/llm-client")
+
+    vi.mocked(streamChat).mockImplementationOnce(
+      (_config, _messages, callbacks) => {
+        callbacks.onError(new Error("网络连接失败"))
+        return Promise.resolve()
+      },
+    )
+
+    const result = await directorEvaluate({
+      node: mockNode,
+      nodeTimelineEvents: mockTimelineEvents,
+      worldRules: "这是一个玄幻世界",
+      llmConfig: mockLlmConfig,
+    })
+
+    expect(result.scores.tension).toBe(3)
+    expect(result.scores.pace).toBe(3)
+    expect(result.scores.characterUtilization).toBe(3)
+    expect(result.scores.characterArc).toBe(3)
+    expect(result.scores.infoDensity).toBe(3)
+    expect(result.scores.emotionalResonance).toBe(3)
+    expect(result.scores.logicConsistency).toBe(3)
+    expect(result.totalScore).toBe(3.0)
+    expect(result.highlights).toEqual([])
+    expect(result.issues).toEqual([])
+    expect(result.suggestion).toBe("")
+    expect(result.shouldInjectEvent).toBe(false)
+    expect(result.injectEvent).toBeUndefined()
+  })
+
+  it("LLM 返回无效 JSON 时应返回默认评估", async () => {
+    const { streamChat } = await import("@/lib/llm-client")
+
+    vi.mocked(streamChat).mockImplementationOnce(
+      (_config, _messages, callbacks) => {
+        callbacks.onToken("这不是一个有效的 JSON,只是一段评价文字。")
+        callbacks.onDone()
+        return Promise.resolve()
+      },
+    )
+
+    const result = await directorEvaluate({
+      node: mockNode,
+      nodeTimelineEvents: mockTimelineEvents,
+      worldRules: "这是一个玄幻世界",
+      llmConfig: mockLlmConfig,
+    })
+
+    expect(result.scores.tension).toBe(3)
+    expect(result.scores.pace).toBe(3)
+    expect(result.scores.characterUtilization).toBe(3)
+    expect(result.totalScore).toBe(3.0)
+  })
+
+  it("各维度分数超出范围时应钳制到 1-5 并取整", async () => {
+    const { streamChat } = await import("@/lib/llm-client")
+
+    vi.mocked(streamChat).mockImplementationOnce(
+      (_config, _messages, callbacks) => {
+        callbacks.onToken(
+          JSON.stringify({
+            scores: {
+              tension: 10,
+              pace: -1,
+              characterUtilization: 5.6,
+              characterArc: 2.3,
+              infoDensity: 100,
+              emotionalResonance: 0,
+              logicConsistency: 3.7,
+            },
+            highlights: [],
+            issues: [],
+            suggestion: "",
+            shouldInjectEvent: false,
+          }),
+        )
+        callbacks.onDone()
+        return Promise.resolve()
+      },
+    )
+
+    const result = await directorEvaluate({
+      node: mockNode,
+      nodeTimelineEvents: mockTimelineEvents,
+      worldRules: "这是一个玄幻世界",
+      llmConfig: mockLlmConfig,
+    })
+
+    expect(result.scores.tension).toBe(5)
+    expect(result.scores.pace).toBe(1)
+    expect(result.scores.characterUtilization).toBe(5)
+    expect(result.scores.characterArc).toBe(2)
+    expect(result.scores.infoDensity).toBe(5)
+    expect(result.scores.emotionalResonance).toBe(1)
+    expect(result.scores.logicConsistency).toBe(4)
+  })
+
+  it("highlights 和 issues 不是数组时应容错为空数组", async () => {
+    const { streamChat } = await import("@/lib/llm-client")
+
+    vi.mocked(streamChat).mockImplementationOnce(
+      (_config, _messages, callbacks) => {
+        callbacks.onToken(
+          JSON.stringify({
+            scores: buildMockScores(),
+            totalScore: 3.0,
+            highlights: "这是亮点",
+            issues: 123,
+            suggestion: "测试容错",
+            shouldInjectEvent: false,
+          }),
+        )
+        callbacks.onDone()
+        return Promise.resolve()
+      },
+    )
+
+    const result = await directorEvaluate({
+      node: mockNode,
+      nodeTimelineEvents: mockTimelineEvents,
+      worldRules: "这是一个玄幻世界",
+      llmConfig: mockLlmConfig,
+    })
+
+    expect(result.highlights).toEqual([])
+    expect(result.issues).toEqual([])
+  })
+})

+ 264 - 0
src/lib/novel/story-simulation/director-agent.ts

@@ -0,0 +1,264 @@
+import type { ChatMessage } from "@/lib/llm-client"
+import { streamChat } from "@/lib/llm-client"
+import type { LlmConfig } from "@/stores/wiki-store"
+import type { StoryNode, TimelineEvent, DirectorScore, DirectorEvaluation } from "./types"
+
+export interface DirectorEvaluateInput {
+  node: StoryNode
+  nodeTimelineEvents: TimelineEvent[]
+  worldRules: string
+  llmConfig: LlmConfig
+  signal?: AbortSignal
+}
+
+export function shouldInjectEvent(eval_: DirectorEvaluation): boolean {
+  return eval_.shouldInjectEvent && !!eval_.injectEvent
+}
+
+const DEFAULT_SCORES: DirectorScore = {
+  tension: 3,
+  pace: 3,
+  characterUtilization: 3,
+  characterArc: 3,
+  infoDensity: 3,
+  emotionalResonance: 3,
+  logicConsistency: 3,
+}
+
+const DEFAULT_EVALUATION: DirectorEvaluation = {
+  scores: { ...DEFAULT_SCORES },
+  totalScore: 3.0,
+  highlights: [],
+  issues: [],
+  suggestion: "",
+  shouldInjectEvent: false,
+}
+
+function extractJson(text: string): string | null {
+  const trimmed = text.trim()
+
+  try {
+    JSON.parse(trimmed)
+    return trimmed
+  } catch {
+    // 继续
+  }
+
+  const codeBlockMatch = /```(?:json)?\s*([\s\S]*?)```/.exec(trimmed)
+  if (codeBlockMatch) {
+    const candidate = codeBlockMatch[1].trim()
+    try {
+      JSON.parse(candidate)
+      return candidate
+    } catch {
+      // 继续
+    }
+  }
+
+  const objMatch = /\{[\s\S]*\}/.exec(trimmed)
+  if (objMatch) {
+    const candidate = objMatch[0]
+    try {
+      JSON.parse(candidate)
+      return candidate
+    } catch {
+      // 继续
+    }
+  }
+
+  return null
+}
+
+function clamp(value: number, min: number, max: number): number {
+  return Math.max(min, Math.min(max, value))
+}
+
+function buildSystemPrompt(): string {
+  return [
+    "你是一位资深的小说导演,负责评估故事节点的质量并提供改进建议。",
+    "",
+    "【你的任务】",
+    "评估刚刚完成的故事节点,从以下 7 个维度给出评价(每个维度 1-5 分):",
+    "1. tension(张力):1=平淡无奇,3=有一定起伏,5=高潮迭起扣人心弦",
+    "2. pace(节奏):1=过慢拖沓,3=适中,5=过快急促",
+    "3. characterUtilization(角色利用率):1=大部分角色没出场或没用,3=主要角色有发挥,5=所有角色都充分发挥了作用",
+    "4. characterArc(人物弧光):1=角色毫无成长变化,3=有一定转变,5=角色成长变化鲜明动人",
+    "5. infoDensity(信息密度):1=内容空洞信息量极低,3=信息量适中,5=信息饱满干货多",
+    "6. emotionalResonance(情感共鸣):1=完全没有感染力,3=有一定情感触动,5=强烈共情令人动容",
+    "7. logicConsistency(逻辑自洽):1=bug 百出逻辑混乱,3=基本自洽,5=严丝合缝无懈可击",
+    "",
+    "其他评价内容:",
+    "- highlights(亮点):2-3 条,简要列出本节点的精彩之处",
+    "- issues(问题):1-2 条,简要列出本节点的主要问题",
+    "- suggestion(建议):一句话总结综合改进建议",
+    "- shouldInjectEvent(是否注入事件):布尔值,只有当整体评分较低(totalScore < 3)时才考虑设为 true",
+    "- injectEvent(注入事件):可选,如果 shouldInjectEvent 为 true,请构思一个能提升剧情质量的突发事件,注入到下一个节点开头",
+    "",
+    "【输出格式】",
+    "你必须输出一个严格的JSON对象,不要输出任何其他文字,不要使用markdown代码块:",
+    "{",
+    '  "scores": {',
+    '    "tension": 1到5的整数,',
+    '    "pace": 1到5的整数,',
+    '    "characterUtilization": 1到5的整数,',
+    '    "characterArc": 1到5的整数,',
+    '    "infoDensity": 1到5的整数,',
+    '    "emotionalResonance": 1到5的整数,',
+    '    "logicConsistency": 1到5的整数',
+    "  },",
+    '  "totalScore": 7项的算术平均值(可选,未提供则由系统计算),',
+    '  "highlights": ["亮点1", "亮点2"],',
+    '  "issues": ["问题1"],',
+    '  "suggestion": "综合改进建议文本",',
+    '  "shouldInjectEvent": true 或 false,',
+    '  "injectEvent": "可选,下一个节点的注入事件文本,shouldInjectEvent 为 false 时不要此字段"',
+    "}",
+    "",
+    "【注意】",
+    "- 只有当 totalScore < 3 时,才将 shouldInjectEvent 设为 true 并提供 injectEvent 字段",
+    "- injectEvent 应该是一个具体的、能打破当前局面的突发事件",
+    "- 评估要客观、专业,基于节点内实际发生的事件",
+    "",
+    "只输出JSON对象,不要输出任何其他文字。",
+  ].join("\n")
+}
+
+function buildUserMessage(
+  node: StoryNode,
+  nodeTimelineEvents: TimelineEvent[],
+  worldRules: string,
+): string {
+  const eventsText = nodeTimelineEvents
+    .map((e) => `[${e.round}] ${e.actorName}(${e.actionType}): ${e.content}`)
+    .join("\n")
+
+  return [
+    "【世界观规则】",
+    worldRules,
+    "",
+    "【节点信息】",
+    `节点标题:${node.title}`,
+    `节点阶段:${node.phase}`,
+    `节点目标:${node.goal}`,
+    `核心冲突:${node.coreConflict}`,
+    `涉及角色:${node.involvedCharacters.join("、")}`,
+    `预期结果:${node.expectedOutcome || "无"}`,
+    "",
+    "【节点内发生的事件】",
+    eventsText || "(无事件)",
+    "",
+    "请根据以上信息,以导演视角评估这个节点的质量,并输出JSON。",
+  ].join("\n")
+}
+
+function parseScore(raw: unknown, defaultValue: number = 3): number {
+  const num = typeof raw === "number" ? raw : defaultValue
+  return Math.round(clamp(num, 1, 5))
+}
+
+function parseStringArray(raw: unknown): string[] {
+  if (!Array.isArray(raw)) return []
+  return raw
+    .filter((item): item is string => typeof item === "string")
+    .map((s) => s.trim())
+    .filter((s) => s.length > 0)
+}
+
+function calcTotalScore(scores: DirectorScore): number {
+  const sum =
+    scores.tension +
+    scores.pace +
+    scores.characterUtilization +
+    scores.characterArc +
+    scores.infoDensity +
+    scores.emotionalResonance +
+    scores.logicConsistency
+  return sum / 7
+}
+
+export async function directorEvaluate(
+  input: DirectorEvaluateInput,
+): Promise<DirectorEvaluation> {
+  const { node, nodeTimelineEvents, worldRules, llmConfig, signal } = input
+
+  try {
+    const messages: ChatMessage[] = [
+      { role: "system", content: buildSystemPrompt() },
+      {
+        role: "user",
+        content: buildUserMessage(node, nodeTimelineEvents, worldRules),
+      },
+    ]
+
+    let result = ""
+    let streamError: Error | null = null
+
+    await streamChat(
+      llmConfig,
+      messages,
+      {
+        onToken: (token) => {
+          result += token
+        },
+        onDone: () => {},
+        onError: (err) => {
+          streamError = err
+        },
+      },
+      signal,
+    )
+
+    if (streamError) throw streamError
+    if (signal?.aborted) return { ...DEFAULT_EVALUATION, scores: { ...DEFAULT_SCORES } }
+
+    const jsonText = extractJson(result)
+    if (!jsonText) {
+      return { ...DEFAULT_EVALUATION, scores: { ...DEFAULT_SCORES } }
+    }
+
+    const data = JSON.parse(jsonText) as Record<string, unknown>
+
+    const scoresRaw =
+      typeof data.scores === "object" && data.scores !== null
+        ? (data.scores as Record<string, unknown>)
+        : {}
+
+    const scores: DirectorScore = {
+      tension: parseScore(scoresRaw.tension),
+      pace: parseScore(scoresRaw.pace),
+      characterUtilization: parseScore(scoresRaw.characterUtilization),
+      characterArc: parseScore(scoresRaw.characterArc),
+      infoDensity: parseScore(scoresRaw.infoDensity),
+      emotionalResonance: parseScore(scoresRaw.emotionalResonance),
+      logicConsistency: parseScore(scoresRaw.logicConsistency),
+    }
+
+    const totalScoreRaw =
+      typeof data.totalScore === "number" ? data.totalScore : calcTotalScore(scores)
+    const totalScore = clamp(totalScoreRaw, 1, 5)
+
+    const highlights = parseStringArray(data.highlights)
+    const issues = parseStringArray(data.issues)
+
+    const suggestion = String(data.suggestion ?? "").trim()
+
+    const shouldInjectEvent = data.shouldInjectEvent === true
+
+    const injectEvent =
+      data.injectEvent !== undefined && data.injectEvent !== null && data.injectEvent !== ""
+        ? String(data.injectEvent).trim()
+        : undefined
+
+    return {
+      scores,
+      totalScore,
+      highlights,
+      issues,
+      suggestion,
+      shouldInjectEvent,
+      injectEvent,
+    }
+  } catch {
+    return { ...DEFAULT_EVALUATION, scores: { ...DEFAULT_SCORES } }
+  }
+}

+ 162 - 0
src/lib/novel/story-simulation/event-pool-generator.spec.ts

@@ -0,0 +1,162 @@
+import { describe, expect, it, vi } from "vitest"
+import type { LlmConfig } from "@/stores/wiki-store"
+import { generateDynamicEventPool, stringArrayToStagedPool } from "./event-pool-generator"
+
+vi.mock("@/lib/llm-client", () => ({
+  streamChat: vi.fn(),
+}))
+
+const mockLlmConfig: LlmConfig = {
+  provider: "openai",
+  apiKey: "test-key",
+  model: "test-model",
+  ollamaUrl: "",
+  customEndpoint: "",
+  maxContextSize: 4096,
+}
+
+const mockInput = {
+  llmConfig: mockLlmConfig,
+  worldRules: "这是一个玄幻世界,有修仙者和妖兽。",
+  characters: ["张三", "李四"],
+}
+
+describe("generateDynamicEventPool", () => {
+  it("正常返回四阶段 JSON 对象时应解析为 StagedEventPool", async () => {
+    const { streamChat } = await import("@/lib/llm-client")
+
+    const mockResponse = {
+      setup: ["起阶段事件1", "起阶段事件2", "起阶段事件3"],
+      rising: ["承阶段事件1", "承阶段事件2", "承阶段事件3"],
+      climax: ["转阶段事件1", "转阶段事件2", "转阶段事件3"],
+      resolution: ["合阶段事件1", "合阶段事件2", "合阶段事件3"],
+    }
+
+    vi.mocked(streamChat).mockImplementationOnce(
+      (_config, _messages, callbacks) => {
+        callbacks.onToken(JSON.stringify(mockResponse))
+        callbacks.onDone()
+        return Promise.resolve()
+      },
+    )
+
+    const result = await generateDynamicEventPool(mockInput)
+
+    expect(result.all).toHaveLength(12)
+    expect(result.byStage.setup).toHaveLength(3)
+    expect(result.byStage.rising).toHaveLength(3)
+    expect(result.byStage.climax).toHaveLength(3)
+    expect(result.byStage.resolution).toHaveLength(3)
+    expect(result.byStage.setup[0].text).toBe("起阶段事件1")
+    expect(result.byStage.setup[0].stage).toBe("setup")
+    expect(result.byStage.rising[0].stage).toBe("rising")
+    expect(result.byStage.climax[0].stage).toBe("climax")
+    expect(result.byStage.resolution[0].stage).toBe("resolution")
+  })
+
+  it("LLM 返回字符串数组时应自动转换为 StagedEventPool", async () => {
+    const { streamChat } = await import("@/lib/llm-client")
+
+    vi.mocked(streamChat).mockImplementationOnce(
+      (_config, _messages, callbacks) => {
+        callbacks.onToken(
+          JSON.stringify([
+            "一阵妖风吹过,带来了远方妖兽的嘶吼。",
+            "天空中出现了一道罕见的霞光,似乎预示着宝物出世。",
+            "一位神秘的修仙者出现在城镇边缘。",
+            "后续事件4",
+            "后续事件5",
+            "后续事件6",
+            "后续事件7",
+            "后续事件8",
+          ]),
+        )
+        callbacks.onDone()
+        return Promise.resolve()
+      },
+    )
+
+    const result = await generateDynamicEventPool(mockInput)
+
+    expect(result.all).toHaveLength(8)
+    expect(result.byStage.setup.length).toBeGreaterThan(0)
+    expect(result.byStage.rising.length).toBeGreaterThan(0)
+  })
+
+  it("LLM 返回非 JSON 时应返回空池", async () => {
+    const { streamChat } = await import("@/lib/llm-client")
+
+    vi.mocked(streamChat).mockImplementationOnce(
+      (_config, _messages, callbacks) => {
+        callbacks.onToken("这不是一个 JSON 数组,只是一段普通文本。")
+        callbacks.onDone()
+        return Promise.resolve()
+      },
+    )
+
+    const result = await generateDynamicEventPool(mockInput)
+
+    expect(result.all).toEqual([])
+    expect(result.byStage.setup).toEqual([])
+    expect(result.byStage.rising).toEqual([])
+    expect(result.byStage.climax).toEqual([])
+    expect(result.byStage.resolution).toEqual([])
+  })
+
+  it("LLM 抛错时应返回空池", async () => {
+    const { streamChat } = await import("@/lib/llm-client")
+
+    vi.mocked(streamChat).mockImplementationOnce(
+      (_config, _messages, callbacks) => {
+        callbacks.onError(new Error("网络连接失败"))
+        return Promise.resolve()
+      },
+    )
+
+    const result = await generateDynamicEventPool(mockInput)
+
+    expect(result.all).toEqual([])
+  })
+
+  it("部分阶段为空时应回退到全局池分配", async () => {
+    const { streamChat } = await import("@/lib/llm-client")
+
+    const mockResponse = {
+      setup: ["起阶段事件1"],
+      rising: [],
+      climax: [],
+      resolution: [],
+    }
+
+    vi.mocked(streamChat).mockImplementationOnce(
+      (_config, _messages, callbacks) => {
+        callbacks.onToken(JSON.stringify(mockResponse))
+        callbacks.onDone()
+        return Promise.resolve()
+      },
+    )
+
+    const result = await generateDynamicEventPool(mockInput)
+
+    expect(result.all).toHaveLength(1)
+  })
+})
+
+describe("stringArrayToStagedPool", () => {
+  it("空数组返回空池", () => {
+    const result = stringArrayToStagedPool([])
+    expect(result.all).toEqual([])
+    expect(result.byStage.setup).toEqual([])
+    expect(result.byStage.rising).toEqual([])
+    expect(result.byStage.climax).toEqual([])
+    expect(result.byStage.resolution).toEqual([])
+  })
+
+  it("8个事件按阶段分配", () => {
+    const events = ["e1", "e2", "e3", "e4", "e5", "e6", "e7", "e8"]
+    const result = stringArrayToStagedPool(events)
+    expect(result.all).toHaveLength(8)
+    expect(result.byStage.setup[0].text).toBe("e1")
+    expect(result.byStage.setup[0].stage).toBe("setup")
+  })
+})

+ 200 - 0
src/lib/novel/story-simulation/event-pool-generator.ts

@@ -0,0 +1,200 @@
+import { streamChat } from "@/lib/llm-client"
+import type { LlmConfig } from "@/stores/wiki-store"
+import type { ChatMessage } from "@/lib/llm-client"
+
+export type EventStage = "setup" | "rising" | "climax" | "resolution"
+
+export interface StagedEvent {
+  id: string
+  text: string
+  stage: EventStage
+}
+
+export interface StagedEventPool {
+  byStage: Record<EventStage, StagedEvent[]>
+  all: StagedEvent[]
+}
+
+export interface EventPoolGeneratorInput {
+  llmConfig: LlmConfig
+  worldRules: string
+  characters: string[]
+}
+
+const STAGE_ORDER: EventStage[] = ["setup", "rising", "climax", "resolution"]
+
+export function getNodeStage(nodeIndex: number, totalNodes: number): EventStage {
+  if (nodeIndex === 0) return "setup"
+  if (nodeIndex === totalNodes - 1) return "resolution"
+  if (nodeIndex >= Math.floor(totalNodes * 0.75)) return "climax"
+  return "rising"
+}
+
+export function pickStagedEvent(
+  pool: StagedEventPool,
+  usedIds: Set<string>,
+  nodeIndex: number,
+  totalNodes: number,
+): StagedEvent | null {
+  const stage = getNodeStage(nodeIndex, totalNodes)
+  const stageEvents = pool.byStage[stage]?.filter((e) => !usedIds.has(e.id)) ?? []
+
+  if (stageEvents.length > 0) {
+    const randomIdx = Math.floor(Math.random() * stageEvents.length)
+    return stageEvents[randomIdx]
+  }
+
+  const globalAvailable = pool.all.filter((e) => !usedIds.has(e.id))
+  if (globalAvailable.length > 0) {
+    const randomIdx = Math.floor(Math.random() * globalAvailable.length)
+    return globalAvailable[randomIdx]
+  }
+
+  return null
+}
+
+let eventIdCounter = 0
+
+function nextEventId(): string {
+  eventIdCounter++
+  return `evt_${Date.now()}_${eventIdCounter}`
+}
+
+function createEmptyStagedPool(): StagedEventPool {
+  return {
+    byStage: { setup: [], rising: [], climax: [], resolution: [] },
+    all: [],
+  }
+}
+
+export function stringArrayToStagedPool(events: string[]): StagedEventPool {
+  const pool = createEmptyStagedPool()
+  const total = events.length
+  if (total === 0) return pool
+
+  for (let i = 0; i < total; i++) {
+    const stage = getNodeStage(i, total)
+    const event: StagedEvent = {
+      id: nextEventId(),
+      text: events[i],
+      stage,
+    }
+    pool.byStage[stage].push(event)
+    pool.all.push(event)
+  }
+
+  return pool
+}
+
+export async function generateDynamicEventPool(
+  input: EventPoolGeneratorInput,
+): Promise<StagedEventPool> {
+  const { llmConfig, worldRules, characters } = input
+
+  const systemPrompt = `你是一个专业的小说剧情事件设计师。请根据给定的世界观设定和角色,按四阶段分类生成24条贴合世界观的随机事件(每阶段6条)。
+
+四阶段定义:
+- setup(起):故事开端,铺垫背景、引入角色、设定悬念
+- rising(承):剧情发展,矛盾升级,线索浮现
+- climax(转):高潮转折,冲突爆发,局势逆转
+- resolution(合):结局收尾,矛盾解决,余韵悠长
+
+要求:
+1. 生成恰好24条随机事件,每阶段6条
+2. 每条事件都是一句话,简洁生动,能够推动剧情发展或增加戏剧张力
+3. 事件必须贴合给定的世界观设定
+4. 事件类型多样化:环境变化、意外发现、神秘来客、突发危机、线索浮现、情感波动等
+5. 只输出 JSON 对象,不要任何其他解释、 Markdown 格式或前后缀文字
+
+输出格式:
+{
+  "setup": ["事件1", "事件2", ...],
+  "rising": ["事件1", "事件2", ...],
+  "climax": ["事件1", "事件2", ...],
+  "resolution": ["事件1", "事件2", ...]
+}`
+
+  const userPrompt = `世界观设定:
+${worldRules}
+
+主要角色:
+${characters.join("、")}
+
+请按四阶段生成24条贴合以上世界观和角色的随机事件(每阶段6条)。`
+
+  const messages: ChatMessage[] = [
+    { role: "system", content: systemPrompt },
+    { role: "user", content: userPrompt },
+  ]
+
+  let result = ""
+  let streamError: Error | null = null
+
+  try {
+    await streamChat(llmConfig, messages, {
+      onToken: (token) => {
+        result += token
+      },
+      onDone: () => {},
+      onError: (err) => {
+        streamError = err
+      },
+    })
+
+    if (streamError) {
+      console.error("[event-pool-generator] LLM 调用出错:", streamError)
+      return createEmptyStagedPool()
+    }
+
+    const trimmed = result.trim()
+    const parsed = JSON.parse(trimmed)
+
+    if (
+      parsed &&
+      typeof parsed === "object" &&
+      STAGE_ORDER.every((stage) => Array.isArray(parsed[stage]))
+    ) {
+      const pool = createEmptyStagedPool()
+
+      for (const stage of STAGE_ORDER) {
+        const stageEvents: string[] = parsed[stage]
+        for (const text of stageEvents) {
+          if (typeof text === "string") {
+            const event: StagedEvent = {
+              id: nextEventId(),
+              text,
+              stage,
+            }
+            pool.byStage[stage].push(event)
+            pool.all.push(event)
+          }
+        }
+      }
+
+      if (pool.all.length === 0) {
+        console.warn("[event-pool-generator] LLM 返回的事件池为空")
+        return createEmptyStagedPool()
+      }
+
+      const minPerStage = Math.min(...STAGE_ORDER.map((s) => pool.byStage[s].length))
+      if (minPerStage === 0) {
+        console.warn("[event-pool-generator] 部分阶段事件为空,使用全局池填充")
+        const allTexts = pool.all.map((e) => e.text)
+        return stringArrayToStagedPool(allTexts)
+      }
+
+      return pool
+    }
+
+    if (Array.isArray(parsed) && parsed.every((item) => typeof item === "string")) {
+      console.warn("[event-pool-generator] LLM 返回字符串数组,自动转换为分阶段池")
+      return stringArrayToStagedPool(parsed)
+    }
+
+    console.warn("[event-pool-generator] LLM 返回格式无法解析")
+    return createEmptyStagedPool()
+  } catch (err) {
+    console.error("[event-pool-generator] 解析或调用失败:", err)
+    return createEmptyStagedPool()
+  }
+}

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

@@ -0,0 +1,230 @@
+import { describe, expect, it } from "vitest"
+
+import type { NovelAgent, RumorEvent } from "@/lib/novel/story-simulation/types"
+import {
+  createSimulationBlackboard,
+  decideRumorTruth,
+  findMatchingRumor,
+  recordRumorEvent,
+  verifyRumor,
+  type SimulationBlackboard,
+} from "@/lib/novel/story-simulation/multi-agent-orchestrator"
+
+function makeAgent(id: string, name: string): NovelAgent {
+  return {
+    characterId: id,
+    name,
+    profile: `${name} profile`,
+    aura: null,
+    cognition: null,
+    soul: "",
+    currentGoal: "完成当前节点目标",
+    emotionalState: "neutral",
+    knownFacts: new Set(),
+    relationships: new Map(),
+    powerLevel: "normal",
+    memory: {
+      observedEvents: [],
+      knownSecrets: new Set(),
+      sentiments: new Map(),
+      recentDecisions: [],
+    },
+    knowledgeScope: [],
+    personality: [],
+    speakingStyle: "",
+  }
+}
+
+function makeRumorEvent(
+  id: string,
+  content: string,
+  observableBy: string[],
+  distortion = 0.5,
+): RumorEvent {
+  return {
+    id,
+    round: 0,
+    nodeIndex: 0,
+    sourceId: null,
+    content,
+    distortion,
+    observableBy,
+    believedBy: [],
+    verifiedBy: [],
+    timestamp: "2026-07-04T00:00:00.000Z",
+  }
+}
+
+function setupBlackboardWithRumors(): {
+  agentA: NovelAgent
+  agentB: NovelAgent
+  blackboard: SimulationBlackboard
+} {
+  const agentA = makeAgent("a", "甲")
+  const agentB = makeAgent("b", "乙")
+  const blackboard = createSimulationBlackboard({ agents: [agentA, agentB] })
+  return { agentA, agentB, blackboard }
+}
+
+describe("findMatchingRumor", () => {
+  it("能按描述精确匹配传闻", () => {
+    const { blackboard } = setupBlackboardWithRumors()
+    const rumor = makeRumorEvent(
+      "r1",
+      "听说城主私通敌国,准备在今晚开城门",
+      ["a", "b"],
+    )
+    recordRumorEvent(blackboard, rumor)
+
+    const result = findMatchingRumor(blackboard, "a", "听说城主私通敌国,准备在今晚开城门")
+    expect(result).not.toBeNull()
+    expect(result?.id).toBe("r1")
+  })
+
+  it("能通过关键词重叠度匹配传闻", () => {
+    const { blackboard } = setupBlackboardWithRumors()
+    const rumor = makeRumorEvent(
+      "r1",
+      "听说城主私通敌国,准备在今晚开城门",
+      ["a", "b"],
+    )
+    recordRumorEvent(blackboard, rumor)
+
+    const result = findMatchingRumor(blackboard, "a", "城主私通敌国")
+    expect(result).not.toBeNull()
+    expect(result?.id).toBe("r1")
+  })
+
+  it("找不到匹配的传闻返回 null", () => {
+    const { blackboard } = setupBlackboardWithRumors()
+    const rumor = makeRumorEvent("r1", "今天天气真好", ["a"])
+    recordRumorEvent(blackboard, rumor)
+
+    const result = findMatchingRumor(blackboard, "a", "城主私通敌国")
+    expect(result).toBeNull()
+  })
+
+  it("只在 agent 可见的传闻中查找", () => {
+    const { blackboard } = setupBlackboardWithRumors()
+    const rumor = makeRumorEvent("r1", "听说城主私通敌国", ["b"])
+    recordRumorEvent(blackboard, rumor)
+
+    const resultA = findMatchingRumor(blackboard, "a", "城主私通敌国")
+    expect(resultA).toBeNull()
+
+    const resultB = findMatchingRumor(blackboard, "b", "城主私通敌国")
+    expect(resultB).not.toBeNull()
+    expect(resultB?.id).toBe("r1")
+  })
+})
+
+describe("verifyRumor", () => {
+  it("confirmed 写入 knownSecrets 并加入 verifiedBy 和 believedBy", () => {
+    const { agentA, blackboard } = setupBlackboardWithRumors()
+    const rumor = makeRumorEvent(
+      "r1",
+      "听说城主私通敌国",
+      ["a"],
+      0.1,
+    )
+    recordRumorEvent(blackboard, rumor)
+
+    const result = verifyRumor(blackboard, "a", "r1", "confirmed")
+
+    expect(result).toContain("确认属实")
+    expect(result).toContain("城主私通敌国")
+    expect(agentA.memory.knownSecrets.has("城主私通敌国")).toBe(true)
+    expect(rumor.verifiedBy).toContain("a")
+    expect(rumor.believedBy).toContain("a")
+  })
+
+  it("debunked 从可见传闻中移除并加入 observedEvents", () => {
+    const { agentA, blackboard } = setupBlackboardWithRumors()
+    const rumor = makeRumorEvent(
+      "r1",
+      "听说城主私通敌国",
+      ["a"],
+      0.9,
+    )
+    recordRumorEvent(blackboard, rumor)
+
+    const beforeVisible = blackboard.visibleRumorsByAgent.get("a")!.length
+    const result = verifyRumor(blackboard, "a", "r1", "debunked")
+    const afterVisible = blackboard.visibleRumorsByAgent.get("a")!.length
+
+    expect(result).toContain("证实为假")
+    expect(afterVisible).toBe(beforeVisible - 1)
+    expect(agentA.memory.observedEvents.length).toBeGreaterThan(0)
+    expect(rumor.verifiedBy).toContain("a")
+  })
+
+  it("partial 写入 knownSecrets(带前缀)并加入 observedEvents", () => {
+    const { agentA, blackboard } = setupBlackboardWithRumors()
+    const rumor = makeRumorEvent(
+      "r1",
+      "听说城主私通敌国",
+      ["a"],
+      0.5,
+    )
+    recordRumorEvent(blackboard, rumor)
+
+    const result = verifyRumor(blackboard, "a", "r1", "partial")
+
+    expect(result).toContain("部分属实")
+    const hasPartialSecret = Array.from(agentA.memory.knownSecrets).some((s) =>
+      s.includes("部分属实"),
+    )
+    expect(hasPartialSecret).toBe(true)
+    expect(agentA.memory.observedEvents.length).toBeGreaterThan(0)
+    expect(rumor.verifiedBy).toContain("a")
+  })
+
+  it("confirmed 去掉听说/据说/传言/有消息称/据传前缀", () => {
+
+    const testCases = [
+      { prefix: "听说", content: "听说城主私通敌国" },
+      { prefix: "据说", content: "据说城主私通敌国" },
+      { prefix: "传言", content: "传言城主私通敌国" },
+      { prefix: "有消息称", content: "有消息称城主私通敌国" },
+      { prefix: "据传", content: "据传城主私通敌国" },
+    ]
+
+    for (const tc of testCases) {
+      const agent = makeAgent(tc.prefix, tc.prefix)
+      const bb = createSimulationBlackboard({ agents: [agent] })
+      const rumor = makeRumorEvent(`r-${tc.prefix}`, tc.content, [tc.prefix], 0.1)
+      recordRumorEvent(bb, rumor)
+
+      verifyRumor(bb, tc.prefix, `r-${tc.prefix}`, "confirmed")
+
+      expect(agent.memory.knownSecrets.has("城主私通敌国")).toBe(true)
+    }
+  })
+})
+
+describe("decideRumorTruth", () => {
+  it("低 distortion 高概率 confirmed(100 次 > 60 次)", () => {
+    let confirmedCount = 0
+    for (let i = 0; i < 100; i++) {
+      const result = decideRumorTruth(0.1)
+      if (result === "confirmed") confirmedCount++
+    }
+    expect(confirmedCount).toBeGreaterThan(60)
+  })
+
+  it("高 distortion 高概率 debunked(100 次 > 25 次)", () => {
+    let debunkedCount = 0
+    for (let i = 0; i < 100; i++) {
+      const result = decideRumorTruth(0.9)
+      if (result === "debunked") debunkedCount++
+    }
+    expect(debunkedCount).toBeGreaterThan(25)
+  })
+
+  it("返回值只能是 confirmed/debunked/partial", () => {
+    for (let d = 0; d <= 1; d += 0.1) {
+      const result = decideRumorTruth(d)
+      expect(["confirmed", "debunked", "partial"]).toContain(result)
+    }
+  })
+})

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

@@ -90,6 +90,8 @@ function makeState(agents: NovelAgent[]): SimulationState {
     timelineEvents: [],
     activeAgents: new Map(agents.map((agent) => [agent.characterId, agent])),
     worldState: {},
+    directorEnabled: false,
+    nextNodeInjectionMap: new Map(),
   }
 }
 
@@ -241,6 +243,7 @@ describe("createBlackboardDebugTrace", () => {
       activeAgentCount: 3,
       totalEventCount: 2,
       publicEventCount: 1,
+      rumorCount: 0,
     })
     expect(trace.candidateAgents.map((agent) => agent.agentId)).toEqual(["a", "c"])
     expect(trace.selectedAgents.map((agent) => agent.agentId)).toEqual(["a", "c"])
@@ -254,7 +257,8 @@ describe("createBlackboardDebugTrace", () => {
         (event) => event.id,
       ),
     ).toEqual(["public"])
-    expect(Object.prototype.hasOwnProperty.call(trace, "activeAgents")).toBe(false)
+    expect(trace.rumors).toBeInstanceOf(Array)
+    expect(trace.activeAgents).toBeInstanceOf(Map)
   })
 
   it("limits each agent recent visible event summaries to the newest events", () => {

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

@@ -1,6 +1,7 @@
 import type {
   ModeConfig,
   NovelAgent,
+  RumorEvent,
   SimulationDebugTrace,
   SimulationDebugVisibleEvent,
   SimulationState,
@@ -30,6 +31,8 @@ export interface SimulationBlackboard {
   events: TimelineEvent[]
   publicEvents: TimelineEvent[]
   visibleEventsByAgent: Map<string, TimelineEvent[]>
+  rumors: RumorEvent[]
+  visibleRumorsByAgent: Map<string, RumorEvent[]>
   roundPlans: MultiAgentRoundPlan[]
 }
 
@@ -72,6 +75,10 @@ export function createSimulationBlackboard(
     visibleEventsByAgent: new Map(
       input.agents.map((agent) => [agent.characterId, [] as TimelineEvent[]]),
     ),
+    rumors: [],
+    visibleRumorsByAgent: new Map(
+      input.agents.map((agent) => [agent.characterId, [] as RumorEvent[]]),
+    ),
     roundPlans: [],
   }
 
@@ -137,6 +144,7 @@ export function createBlackboardDebugTrace(
       activeAgentCount: blackboard.activeAgents.size,
       totalEventCount: blackboard.events.length,
       publicEventCount: blackboard.publicEvents.length,
+      rumorCount: blackboard.rumors.length,
     },
     visibilityByAgent: Array.from(blackboard.allAgents.values()).map((agent) => {
       const allVisibleEvents = blackboard.visibleEventsByAgent.get(agent.characterId) ?? []
@@ -152,6 +160,8 @@ export function createBlackboardDebugTrace(
       }
     }),
     latestEvent: input.latestEvent ? toDebugVisibleEvent(input.latestEvent) : undefined,
+    rumors: blackboard.rumors,
+    activeAgents: blackboard.activeAgents,
     timestamp,
   }
 }
@@ -174,6 +184,32 @@ export function recordBlackboardEvent(
   }
 }
 
+export function recordRumorEvent(
+  blackboard: SimulationBlackboard,
+  rumor: RumorEvent,
+): void {
+  blackboard.rumors.push(rumor)
+
+  for (const agentId of rumor.observableBy) {
+    if (!blackboard.visibleRumorsByAgent.has(agentId)) {
+      blackboard.visibleRumorsByAgent.set(agentId, [])
+    }
+    blackboard.visibleRumorsByAgent.get(agentId)!.push(rumor)
+  }
+}
+
+export function getBlackboardVisibleRumors(
+  blackboard: SimulationBlackboard,
+  agentId: string,
+  limit?: number,
+): RumorEvent[] {
+  const rumors = blackboard.visibleRumorsByAgent.get(agentId) ?? []
+  if (limit === undefined || limit <= 0 || rumors.length <= limit) {
+    return [...rumors]
+  }
+  return rumors.slice(-limit)
+}
+
 export function planMultiAgentRound(
   input: PlanMultiAgentRoundInput,
 ): MultiAgentRoundPlan {
@@ -256,3 +292,118 @@ function toDebugVisibleEvent(event: TimelineEvent): SimulationDebugVisibleEvent
     nodeIndex: event.nodeIndex,
   }
 }
+
+export type RumorVerificationResult = "confirmed" | "debunked" | "partial"
+
+function splitKeywords(text: string): string[] {
+  return text
+    .split(/[ ,。?!、]/)
+    .map((s) => s.trim())
+    .filter((s) => s.length > 0)
+}
+
+export function findMatchingRumor(
+  blackboard: SimulationBlackboard,
+  agentId: string,
+  description: string,
+): RumorEvent | null {
+  const visibleRumors = blackboard.visibleRumorsByAgent.get(agentId) ?? []
+  if (visibleRumors.length === 0) return null
+
+  for (const rumor of visibleRumors) {
+    if (rumor.content.includes(description) || description.includes(rumor.content)) {
+      return rumor
+    }
+  }
+
+  const descKeywords = new Set(splitKeywords(description))
+  let bestMatch: RumorEvent | null = null
+  let bestScore = 0
+
+  for (const rumor of visibleRumors) {
+    const rumorKeywords = splitKeywords(rumor.content)
+    const rumorKeywordSet = new Set(rumorKeywords)
+    let overlap = 0
+    for (const kw of descKeywords) {
+      if (rumorKeywordSet.has(kw)) overlap++
+    }
+    const totalKeywords = Math.max(descKeywords.size, rumorKeywords.length)
+    const score = totalKeywords > 0 ? overlap / totalKeywords : 0
+    if (score > bestScore) {
+      bestScore = score
+      bestMatch = rumor
+    }
+  }
+
+  return bestScore >= 0.2 ? bestMatch : null
+}
+
+export function decideRumorTruth(distortion: number): RumorVerificationResult {
+  const rand = Math.random()
+  if (distortion < 0.3) {
+    if (rand < 0.9) return "confirmed"
+    return "partial"
+  } else if (distortion <= 0.6) {
+    if (rand < 0.4) return "confirmed"
+    if (rand < 0.7) return "partial"
+    return "debunked"
+  } else {
+    if (rand < 0.2) return "confirmed"
+    if (rand < 0.5) return "partial"
+    return "debunked"
+  }
+}
+
+function stripRumorPrefixes(content: string): string {
+  const prefixes = ["听说", "据说", "传言", "有消息称", "据传"]
+  let result = content
+  for (const prefix of prefixes) {
+    if (result.startsWith(prefix)) {
+      result = result.slice(prefix.length)
+      break
+    }
+  }
+  return result
+}
+
+export function verifyRumor(
+  blackboard: SimulationBlackboard,
+  agentId: string,
+  rumorId: string,
+  result: RumorVerificationResult,
+): string {
+  const rumor = blackboard.rumors.find((r) => r.id === rumorId)
+  if (!rumor) return "错误:找不到指定的传闻"
+
+  const agent = blackboard.allAgents.get(agentId)
+  if (!agent) return "错误:找不到指定的角色"
+
+  if (!rumor.verifiedBy.includes(agentId)) {
+    rumor.verifiedBy.push(agentId)
+  }
+
+  const strippedContent = stripRumorPrefixes(rumor.content)
+
+  if (result === "confirmed") {
+    agent.memory.knownSecrets.add(strippedContent)
+    if (!rumor.believedBy.includes(agentId)) {
+      rumor.believedBy.push(agentId)
+    }
+    return `经过调查,确认属实:${strippedContent}`
+  } else if (result === "debunked") {
+    const visibleRumors = blackboard.visibleRumorsByAgent.get(agentId)
+    if (visibleRumors) {
+      const idx = visibleRumors.findIndex((r) => r.id === rumorId)
+      if (idx !== -1) {
+        visibleRumors.splice(idx, 1)
+      }
+    }
+    agent.memory.observedEvents.push(rumorId)
+    return `经过调查,证实为假:${strippedContent}`
+  } else {
+    const partialContent = `(部分属实)${strippedContent}`
+    agent.memory.knownSecrets.add(partialContent)
+    agent.memory.observedEvents.push(rumorId)
+    return `经过调查,部分属实:${strippedContent}`
+  }
+}

+ 148 - 0
src/lib/novel/story-simulation/node-goal-embedding.spec.ts

@@ -0,0 +1,148 @@
+import { describe, expect, it, vi } from "vitest"
+import { cosineSimilarity } from "@/lib/embedding-client"
+import { isNodeGoalReachedWithEmbedding } from "@/lib/novel/story-simulation/simulation-engine"
+import type { StoryNode, TimelineEvent } from "@/lib/novel/story-simulation/types"
+import type { LlmConfig } from "@/stores/wiki-store"
+
+vi.mock("@/lib/embedding-client", () => ({
+  cosineSimilarity: vi.fn((a: number[], b: number[]) => {
+    if (a.length === 0 || b.length === 0) return 0
+    let dot = 0
+    let normA = 0
+    let normB = 0
+    for (let i = 0; i < a.length; i++) {
+      dot += a[i] * b[i]
+      normA += a[i] * a[i]
+      normB += b[i] * b[i]
+    }
+    if (normA === 0 || normB === 0) return 0
+    return dot / (Math.sqrt(normA) * Math.sqrt(normB))
+  }),
+  embed: vi.fn(),
+}))
+
+import { embed } from "@/lib/embedding-client"
+
+function makeNode(expectedOutcome: string): StoryNode {
+  return {
+    index: 0,
+    phase: "起",
+    title: "测试节点",
+    coreConflict: "冲突",
+    involvedCharacters: ["甲", "乙"],
+    goal: "目标",
+    causeFromPrev: "无",
+    expectedOutcome,
+  }
+}
+
+function makeTimelineEvent(content: string, actionType: TimelineEvent["actionType"] = "speak"): TimelineEvent {
+  return {
+    id: `evt_${Date.now()}_${Math.random()}`,
+    round: 0,
+    nodeIndex: 0,
+    actorId: "a",
+    actorName: "甲",
+    actionType,
+    content,
+    observableBy: ["a", "b"],
+    impacts: [],
+    timestamp: "2026-07-04T00:00:00.000Z",
+  }
+}
+
+function makeLlmConfig(): LlmConfig {
+  return {
+    provider: "openai",
+    apiKey: "test-key",
+    model: "gpt-4",
+    ollamaUrl: "",
+    customEndpoint: "",
+    maxContextSize: 2048,
+  }
+}
+
+describe("cosineSimilarity", () => {
+  it("相同向量返回 1", () => {
+    const a = [1, 0, 0]
+    expect(cosineSimilarity(a, a)).toBeCloseTo(1)
+  })
+
+  it("正交向量返回 0", () => {
+    const a = [1, 0, 0]
+    const b = [0, 1, 0]
+    expect(cosineSimilarity(a, b)).toBeCloseTo(0)
+  })
+
+  it("相反向量返回 -1", () => {
+    const a = [1, 0, 0]
+    const b = [-1, 0, 0]
+    expect(cosineSimilarity(a, b)).toBeCloseTo(-1)
+  })
+
+  it("空向量返回 0", () => {
+    expect(cosineSimilarity([], [1, 2, 3])).toBe(0)
+    expect(cosineSimilarity([1, 2, 3], [])).toBe(0)
+    expect(cosineSimilarity([], [])).toBe(0)
+  })
+})
+
+describe("isNodeGoalReachedWithEmbedding", () => {
+  it("相似度 >= 0.75 时返回 true", async () => {
+    const node = makeNode("主角发现了真相")
+    const events = [makeTimelineEvent("主角终于发现了隐藏的真相")]
+
+    vi.mocked(embed).mockResolvedValueOnce([1, 0, 0])
+    vi.mocked(embed).mockResolvedValueOnce([0.8, 0.6, 0])
+
+    const result = await isNodeGoalReachedWithEmbedding(node, events, 5, 0, makeLlmConfig())
+    expect(result).toBe(true)
+  })
+
+  it("相似度 < 0.75 时返回 false", async () => {
+    const node = makeNode("主角发现了真相")
+    const events = [makeTimelineEvent("主角在喝茶聊天")]
+
+    vi.mocked(embed).mockResolvedValueOnce([1, 0, 0])
+    vi.mocked(embed).mockResolvedValueOnce([0.5, 0.866, 0])
+
+    const result = await isNodeGoalReachedWithEmbedding(node, events, 5, 0, makeLlmConfig())
+    expect(result).toBe(false)
+  })
+
+  it("embedding 失败时降级为旧启发式", async () => {
+    const node = makeNode("主角发现了真相")
+    const events = [
+      makeTimelineEvent("事件1", "pushPlot"),
+      makeTimelineEvent("事件2", "pushPlot"),
+    ]
+
+    vi.mocked(embed).mockRejectedValueOnce(new Error("API error"))
+
+    const result = await isNodeGoalReachedWithEmbedding(node, events, 5, 0, makeLlmConfig())
+    expect(result).toBe(true)
+  })
+
+  it("maxRounds 兜底仍保留", async () => {
+    const node = makeNode("主角发现了真相")
+    const events: TimelineEvent[] = []
+
+    const result = await isNodeGoalReachedWithEmbedding(node, events, 5, 4, makeLlmConfig())
+    expect(result).toBe(true)
+  })
+
+  it("没有 expectedOutcome 时降级为旧启发式", async () => {
+    const node = makeNode("")
+    const events = [
+      makeTimelineEvent("事件1"),
+      makeTimelineEvent("事件2"),
+      makeTimelineEvent("事件3"),
+      makeTimelineEvent("事件4"),
+      makeTimelineEvent("事件5"),
+      makeTimelineEvent("事件6"),
+    ]
+
+    const result = await isNodeGoalReachedWithEmbedding(node, events, 5, 0, makeLlmConfig())
+    expect(result).toBe(true)
+  })
+})

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

@@ -0,0 +1,127 @@
+import { describe, expect, it } from "vitest"
+
+import type { NovelAgent, RumorEvent } from "@/lib/novel/story-simulation/types"
+import {
+  createSimulationBlackboard,
+  getBlackboardVisibleRumors,
+  recordRumorEvent,
+} from "@/lib/novel/story-simulation/multi-agent-orchestrator"
+
+function makeAgent(id: string, name: string): NovelAgent {
+  return {
+    characterId: id,
+    name,
+    profile: `${name} profile`,
+    aura: null,
+    cognition: null,
+    soul: "",
+    currentGoal: "完成当前节点目标",
+    emotionalState: "neutral",
+    knownFacts: new Set(),
+    relationships: new Map(),
+    powerLevel: "normal",
+    memory: {
+      observedEvents: [],
+      knownSecrets: new Set(),
+      sentiments: new Map(),
+      recentDecisions: [],
+    },
+    knowledgeScope: [],
+    personality: [],
+    speakingStyle: "",
+  }
+}
+
+function makeRumorEvent(id: string, observableBy: string[]): RumorEvent {
+  return {
+    id,
+    round: 0,
+    nodeIndex: 0,
+    sourceId: null,
+    content: id,
+    distortion: 0.5,
+    observableBy,
+    believedBy: [],
+    verifiedBy: [],
+    timestamp: "2026-07-04T00:00:00.000Z",
+  }
+}
+
+describe("recordRumorEvent", () => {
+  it("writes rumor to blackboard and visibleRumorsByAgent", () => {
+    const agents = [makeAgent("a", "甲"), makeAgent("b", "乙"), makeAgent("c", "丙")]
+    const blackboard = createSimulationBlackboard({ agents })
+    const rumor = makeRumorEvent("rumor-1", ["a", "b"])
+
+    recordRumorEvent(blackboard, rumor)
+
+    expect(blackboard.rumors.map((r) => r.id)).toEqual(["rumor-1"])
+    expect(blackboard.visibleRumorsByAgent.get("a")?.map((r) => r.id)).toEqual(["rumor-1"])
+    expect(blackboard.visibleRumorsByAgent.get("b")?.map((r) => r.id)).toEqual(["rumor-1"])
+    expect(blackboard.visibleRumorsByAgent.get("c")).toEqual([])
+  })
+
+  it("initializes visibleRumorsByAgent for agents not in the map yet", () => {
+    const agents = [makeAgent("a", "甲")]
+    const blackboard = createSimulationBlackboard({ agents })
+    const rumor = makeRumorEvent("rumor-1", ["b"])
+
+    recordRumorEvent(blackboard, rumor)
+
+    expect(blackboard.visibleRumorsByAgent.has("b")).toBe(true)
+    expect(blackboard.visibleRumorsByAgent.get("b")?.map((r) => r.id)).toEqual(["rumor-1"])
+  })
+})
+
+describe("getBlackboardVisibleRumors", () => {
+  it("returns only rumors visible to the requested agent", () => {
+    const agents = [makeAgent("a", "甲"), makeAgent("b", "乙")]
+    const visible = makeRumorEvent("visible", ["a"])
+    const hidden = makeRumorEvent("hidden", ["b"])
+    const blackboard = createSimulationBlackboard({ agents })
+    recordRumorEvent(blackboard, visible)
+    recordRumorEvent(blackboard, hidden)
+
+    expect(getBlackboardVisibleRumors(blackboard, "a").map((r) => r.id)).toEqual(["visible"])
+  })
+
+  it("returns empty array for agent with no visible rumors", () => {
+    const agents = [makeAgent("a", "甲")]
+    const blackboard = createSimulationBlackboard({ agents })
+
+    expect(getBlackboardVisibleRumors(blackboard, "a")).toEqual([])
+  })
+
+  it("keeps the newest visible rumors when a limit is provided", () => {
+    const agents = [makeAgent("a", "甲")]
+    const blackboard = createSimulationBlackboard({ agents })
+    recordRumorEvent(blackboard, makeRumorEvent("old", ["a"]))
+    recordRumorEvent(blackboard, makeRumorEvent("middle", ["a"]))
+    recordRumorEvent(blackboard, makeRumorEvent("new", ["a"]))
+
+    expect(getBlackboardVisibleRumors(blackboard, "a", 2).map((r) => r.id)).toEqual([
+      "middle",
+      "new",
+    ])
+  })
+
+  it("returns all visible rumors when limit is not provided or larger than count", () => {
+    const agents = [makeAgent("a", "甲")]
+    const blackboard = createSimulationBlackboard({ agents })
+    recordRumorEvent(blackboard, makeRumorEvent("r1", ["a"]))
+    recordRumorEvent(blackboard, makeRumorEvent("r2", ["a"]))
+
+    expect(getBlackboardVisibleRumors(blackboard, "a")).toHaveLength(2)
+    expect(getBlackboardVisibleRumors(blackboard, "a", 10)).toHaveLength(2)
+  })
+
+  it("认知边界:不在 observableBy 的角色看不到传闻", () => {
+    const agents = [makeAgent("a", "甲"), makeAgent("b", "乙"), makeAgent("c", "丙")]
+    const blackboard = createSimulationBlackboard({ agents })
+    const secretRumor = makeRumorEvent("secret", ["a", "b"])
+    recordRumorEvent(blackboard, secretRumor)
+
+    const agentCRumors = getBlackboardVisibleRumors(blackboard, "c")
+    expect(agentCRumors).toEqual([])
+  })
+})

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

@@ -0,0 +1,232 @@
+import { describe, expect, it } from "vitest"
+import type { NovelAgent, TimelineEvent } from "@/lib/novel/story-simulation/types"
+import {
+  createSimulationBlackboard,
+  recordBlackboardEvent,
+  type SimulationBlackboard,
+} from "@/lib/novel/story-simulation/multi-agent-orchestrator"
+import { createSimAgentTools } from "@/lib/novel/story-simulation/sim-agent-tools"
+
+function makeAgent(id: string, name: string): NovelAgent {
+  return {
+    characterId: id,
+    name,
+    profile: `${name}的档案`,
+    aura: null,
+    cognition: null,
+    soul: "",
+    currentGoal: "完成当前目标",
+    emotionalState: "neutral",
+    knownFacts: new Set(),
+    relationships: new Map(),
+    powerLevel: "normal",
+    memory: {
+      observedEvents: [],
+      knownSecrets: new Set(),
+      sentiments: new Map(),
+      recentDecisions: [],
+    },
+    knowledgeScope: [],
+    personality: [],
+    speakingStyle: "",
+  }
+}
+
+function makeTimelineEvent(
+  id: string,
+  round: number,
+  actorId: string,
+  actorName: string,
+  observableBy: string[],
+  content: string,
+): TimelineEvent {
+  return {
+    id,
+    round,
+    nodeIndex: 0,
+    actorId,
+    actorName,
+    actionType: "speak",
+    content,
+    observableBy,
+    impacts: [],
+    timestamp: "2026-07-04T00:00:00.000Z",
+  }
+}
+
+function setupTwoAgents(): {
+  agentA: NovelAgent
+  agentB: NovelAgent
+  blackboard: SimulationBlackboard
+} {
+  const agentA = makeAgent("a", "甲")
+  const agentB = makeAgent("b", "乙")
+  const blackboard = createSimulationBlackboard({ agents: [agentA, agentB] })
+  return { agentA, agentB, blackboard }
+}
+
+describe("createSimAgentTools", () => {
+  it("creates a registry with 5 tools", () => {
+    const { agentA, blackboard } = setupTwoAgents()
+    const registry = createSimAgentTools(agentA, blackboard)
+    expect(registry.has("recall")).toBe(true)
+    expect(registry.has("observe")).toBe(true)
+    expect(registry.has("inquire")).toBe(true)
+    expect(registry.has("introspect")).toBe(true)
+    expect(registry.has("investigate")).toBe(true)
+    expect(registry.list().length).toBe(5)
+  })
+})
+
+describe("recall tool", () => {
+  it("returns visible events for the agent", async () => {
+    const { agentA, blackboard } = setupTwoAgents()
+
+    const event1 = makeTimelineEvent("e1", 0, "b", "乙", ["a", "b"], "你好甲")
+    const event2 = makeTimelineEvent("e2", 0, "a", "甲", ["a", "b"], "你好乙")
+    const event3 = makeTimelineEvent("e3", 0, "b", "乙", ["b"], "我私下想")
+
+    recordBlackboardEvent(blackboard, event1)
+    recordBlackboardEvent(blackboard, event2)
+    recordBlackboardEvent(blackboard, event3)
+
+    const registry = createSimAgentTools(agentA, blackboard)
+    const tool = registry.get("recall")!
+    const result = await tool.execute({})
+
+    expect(result).toContain("乙")
+    expect(result).toContain("你好甲")
+    expect(result).toContain("你好乙")
+    expect(result).not.toContain("我私下想")
+  })
+
+  it("respects the limit parameter", async () => {
+    const { agentA, blackboard } = setupTwoAgents()
+
+    for (let i = 0; i < 10; i++) {
+      const evt = makeTimelineEvent(`e${i}`, 0, "b", "乙", ["a", "b"], `事件${i}`)
+      recordBlackboardEvent(blackboard, evt)
+    }
+
+    const registry = createSimAgentTools(agentA, blackboard)
+    const tool = registry.get("recall")!
+    const result = await tool.execute({ limit: 3 })
+
+    const lines = result.split("\n").filter((l) => l.startsWith("- "))
+    expect(lines.length).toBe(3)
+  })
+
+  it("returns empty message when no events", async () => {
+    const { agentA, blackboard } = setupTwoAgents()
+    const registry = createSimAgentTools(agentA, blackboard)
+    const tool = registry.get("recall")!
+    const result = await tool.execute({})
+
+    expect(result).toContain("暂无可见事件")
+  })
+})
+
+describe("observe tool", () => {
+  it("returns public events from current round excluding self", async () => {
+    const { agentA, blackboard } = setupTwoAgents()
+
+    const round0Public = makeTimelineEvent("e1", 0, "b", "乙", ["a", "b"], "公开言论")
+    const round0Self = makeTimelineEvent("e2", 0, "a", "甲", ["a", "b"], "自己说的话")
+    const round1Public = makeTimelineEvent("e3", 1, "b", "乙", ["a", "b"], "下一轮的话")
+
+    recordBlackboardEvent(blackboard, round0Public)
+    recordBlackboardEvent(blackboard, round0Self)
+    recordBlackboardEvent(blackboard, round1Public)
+
+    const registry = createSimAgentTools(agentA, blackboard)
+    const tool = registry.get("observe")!
+    const result = await tool.execute({})
+
+    expect(result).toContain("公开言论")
+    expect(result).not.toContain("自己说的话")
+    expect(result).not.toContain("下一轮的话")
+  })
+
+  it("returns empty when no public events in current round", async () => {
+    const { agentA, blackboard } = setupTwoAgents()
+    const registry = createSimAgentTools(agentA, blackboard)
+    const tool = registry.get("observe")!
+    const result = await tool.execute({})
+
+    expect(result).toContain("暂无可见事件")
+  })
+})
+
+describe("inquire tool", () => {
+  it("writes an event to the blackboard targeted at another agent", async () => {
+    const { agentA, blackboard } = setupTwoAgents()
+    const registry = createSimAgentTools(agentA, blackboard)
+    const tool = registry.get("inquire")!
+
+    const beforeCount = blackboard.events.length
+    const result = await tool.execute({ target: "乙", question: "你今天好吗?" })
+
+    expect(result).toContain("乙")
+    expect(result).toContain("你今天好吗?")
+    expect(blackboard.events.length).toBe(beforeCount + 1)
+
+    const lastEvent = blackboard.events[blackboard.events.length - 1]
+    expect(lastEvent.actorId).toBe("a")
+    expect(lastEvent.actorName).toBe("甲")
+    expect(lastEvent.targetName).toBe("乙")
+    expect(lastEvent.content).toBe("你今天好吗?")
+    expect(lastEvent.observableBy).toContain("a")
+    expect(lastEvent.observableBy).toContain("b")
+  })
+
+  it("returns error when target or question is missing", async () => {
+    const { agentA, blackboard } = setupTwoAgents()
+    const registry = createSimAgentTools(agentA, blackboard)
+    const tool = registry.get("inquire")!
+
+    const result1 = await tool.execute({ target: "乙" })
+    expect(result1).toContain("错误")
+
+    const result2 = await tool.execute({ question: "你好" })
+    expect(result2).toContain("错误")
+  })
+})
+
+describe("introspect tool", () => {
+  it("returns the agent's internal state", async () => {
+    const agent = makeAgent("a", "甲")
+    agent.currentGoal = "寻找真相"
+    agent.emotionalState = "curious"
+    agent.personality = ["机智", "冷静"]
+    agent.speakingStyle = "简洁明了"
+    agent.knowledgeScope = ["知道一些秘密"]
+    agent.memory.recentDecisions = ["决定调查"]
+
+    const blackboard = createSimulationBlackboard({ agents: [agent] })
+    const registry = createSimAgentTools(agent, blackboard)
+    const tool = registry.get("introspect")!
+    const result = await tool.execute({})
+
+    expect(result).toContain("内心审视")
+    expect(result).toContain("寻找真相")
+    expect(result).toContain("curious")
+    expect(result).toContain("机智")
+    expect(result).toContain("简洁明了")
+    expect(result).toContain("知道一些秘密")
+    expect(result).toContain("决定调查")
+  })
+
+  it("includes sentiments toward other agents", async () => {
+    const agentA = makeAgent("a", "甲")
+    const agentB = makeAgent("b", "乙")
+    agentA.memory.sentiments.set("b", 20)
+
+    const blackboard = createSimulationBlackboard({ agents: [agentA, agentB] })
+    const registry = createSimAgentTools(agentA, blackboard)
+    const tool = registry.get("introspect")!
+    const result = await tool.execute({})
+
+    expect(result).toContain("对他人的情感")
+    expect(result).toContain("20")
+  })
+})

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

@@ -0,0 +1,216 @@
+import { ToolRegistry } from "@/lib/agent/registry"
+import type { Tool } from "@/lib/agent/types"
+import {
+  decideRumorTruth,
+  findMatchingRumor,
+  getBlackboardVisibleEvents,
+  recordBlackboardEvent,
+  type SimulationBlackboard,
+  verifyRumor,
+} from "@/lib/novel/story-simulation/multi-agent-orchestrator"
+import type { NovelAgent, TimelineEvent } from "@/lib/novel/story-simulation/types"
+import { formatTimelineEvent } from "@/lib/novel/story-simulation/agent-profile-builder"
+
+function formatEventsText(events: TimelineEvent[]): string {
+  if (events.length === 0) {
+    return "(暂无可见事件)"
+  }
+  return events.map((e) => `- ${formatTimelineEvent(e)}`).join("\n")
+}
+
+function buildIntrospectText(agent: NovelAgent): string {
+  const lines: string[] = []
+
+  lines.push("【内心审视】")
+  lines.push(`当前目标:${agent.currentGoal}`)
+  lines.push(`情绪状态:${agent.emotionalState}`)
+
+  if (agent.personality.length > 0) {
+    lines.push(`性格关键词:${agent.personality.join("、")}`)
+  }
+  if (agent.speakingStyle) {
+    lines.push(`说话风格:${agent.speakingStyle}`)
+  }
+
+  if (agent.memory.sentiments.size > 0) {
+    lines.push("")
+    lines.push("【对他人的情感】")
+    for (const [otherId, value] of agent.memory.sentiments.entries()) {
+      if (otherId === agent.characterId) continue
+      lines.push(`- 角色[${otherId}]:好感度 ${value}`)
+    }
+  }
+
+  if (agent.memory.recentDecisions.length > 0) {
+    lines.push("")
+    lines.push("【最近的决策】")
+    for (const d of agent.memory.recentDecisions.slice(-5)) {
+      lines.push(`- ${d}`)
+    }
+  }
+
+  if (agent.knowledgeScope.length > 0) {
+    lines.push("")
+    lines.push("【知道的信息】")
+    lines.push(agent.knowledgeScope.join(";"))
+  }
+
+  return lines.join("\n")
+}
+
+function resolveAgentByName(
+  targetName: string,
+  blackboard: SimulationBlackboard,
+): NovelAgent | undefined {
+  for (const agent of blackboard.allAgents.values()) {
+    if (agent.name === targetName || agent.characterId === targetName) {
+      return agent
+    }
+  }
+  return undefined
+}
+
+let inquireEventCounter = 0
+function nextInquireEventId(): string {
+  inquireEventCounter++
+  return `inquire_${Date.now()}_${inquireEventCounter}`
+}
+
+export function createSimAgentTools(
+  agent: NovelAgent,
+  blackboard: SimulationBlackboard,
+): ToolRegistry {
+  const registry = new ToolRegistry()
+
+  const recallTool: Tool = {
+    name: "recall",
+    description: "回忆历史上你亲眼所见的事件,帮助理解当前局势。",
+    category: "read",
+    parameters: {
+      limit: {
+        type: "integer",
+        description: "最多回忆多少条事件(默认20条)",
+        required: false,
+      },
+    },
+    execute: async (params) => {
+      const limit = typeof params.limit === "number" ? params.limit : 20
+      const events = getBlackboardVisibleEvents(blackboard, agent.characterId, limit)
+      return formatEventsText(events)
+    },
+  }
+
+  const observeTool: Tool = {
+    name: "observe",
+    description: "观察当前轮次其他角色的公开行为和言论。",
+    category: "read",
+    parameters: {},
+    execute: async () => {
+      const currentRound = blackboard.roundPlans.length > 0
+        ? blackboard.roundPlans[blackboard.roundPlans.length - 1].round
+        : 0
+      const currentRoundEvents = blackboard.publicEvents.filter(
+        (e) => e.round === currentRound && e.actorId !== agent.characterId,
+      )
+      return formatEventsText(currentRoundEvents)
+    },
+  }
+
+  const inquireTool: Tool = {
+    name: "inquire",
+    description: "向另一个角色提出问题,对方下一轮可以看到你的问题并可能回应。",
+    category: "write",
+    permission: "auto",
+    parameters: {
+      target: {
+        type: "string",
+        description: "目标角色的名字",
+        required: true,
+      },
+      question: {
+        type: "string",
+        description: "你想问的问题内容",
+        required: true,
+      },
+    },
+    execute: async (params) => {
+      const targetName = String(params.target ?? "")
+      const question = String(params.question ?? "")
+      if (!targetName || !question) {
+        return "错误:必须指定 target 和 question 参数"
+      }
+      const target = resolveAgentByName(targetName, blackboard)
+      const targetId = target?.characterId ?? targetName
+      const targetDisplayName = target?.name ?? targetName
+
+      const event: TimelineEvent = {
+        id: nextInquireEventId(),
+        round: blackboard.roundPlans.length > 0
+          ? blackboard.roundPlans[blackboard.roundPlans.length - 1].round
+          : 0,
+        nodeIndex: 0,
+        actorId: agent.characterId,
+        actorName: agent.name,
+        actionType: "speak",
+        content: question,
+        targetId,
+        targetName: targetDisplayName,
+        observableBy: [agent.characterId, targetId],
+        impacts: [
+          {
+            characterId: targetId,
+            type: "knowledge",
+            detail: `${targetDisplayName}听到了${agent.name}的提问`,
+          },
+        ],
+        timestamp: new Date().toISOString(),
+      }
+      recordBlackboardEvent(blackboard, event)
+      return `已向 ${targetDisplayName} 提出问题:${question}`
+    },
+  }
+
+  const introspectTool: Tool = {
+    name: "introspect",
+    description: "审视自己的内心状态:情绪、目标、性格、说话风格、对他人的情感等。",
+    category: "read",
+    parameters: {},
+    execute: async () => {
+      return buildIntrospectText(agent)
+    },
+  }
+
+  const investigateTool: Tool = {
+    name: "investigate",
+    description: "调查验证你听到的某条传闻的真伪。传入传闻的内容描述。",
+    category: "write",
+    permission: "auto",
+    parameters: {
+      rumorDescription: {
+        type: "string",
+        description: "传闻的内容描述",
+        required: true,
+      },
+    },
+    execute: async (params) => {
+      const rumorDescription = String(params.rumorDescription ?? "")
+      if (!rumorDescription) {
+        return "错误:必须指定 rumorDescription 参数"
+      }
+      const matchedRumor = findMatchingRumor(blackboard, agent.characterId, rumorDescription)
+      if (!matchedRumor) {
+        return "未找到匹配的传闻,请确认你描述的传闻内容是否准确。"
+      }
+      const result = decideRumorTruth(matchedRumor.distortion)
+      return verifyRumor(blackboard, agent.characterId, matchedRumor.id, result)
+    },
+  }
+
+  registry.register(recallTool)
+  registry.register(observeTool)
+  registry.register(inquireTool)
+  registry.register(introspectTool)
+  registry.register(investigateTool)
+
+  return registry
+}

+ 268 - 0
src/lib/novel/story-simulation/simulation-engine.react.spec.ts

@@ -0,0 +1,268 @@
+import { describe, expect, it, vi } from "vitest"
+import type { NovelAgent, SimulationState, StoryNode, ExtractionResult } from "@/lib/novel/story-simulation/types"
+import {
+  createSimulationBlackboard,
+} from "@/lib/novel/story-simulation/multi-agent-orchestrator"
+import { agentDecideAndActWithReact } from "@/lib/novel/story-simulation/simulation-engine"
+import { ModelDoesNotSupportToolsError } from "@/lib/agent/runner"
+
+let mockRun: any
+
+vi.mock("@/lib/agent/runner", () => ({
+  AgentRunner: class {
+    run(...args: any[]) {
+      return mockRun(...args)
+    }
+  },
+  ModelDoesNotSupportToolsError: class extends Error {
+    constructor() {
+      super("当前模型不支持工具调用")
+      this.name = "ModelDoesNotSupportToolsError"
+    }
+  },
+}))
+
+function makeAgent(id: string, name: string): NovelAgent {
+  return {
+    characterId: id,
+    name,
+    profile: `${name}的档案`,
+    aura: null,
+    cognition: null,
+    soul: "",
+    currentGoal: "完成当前目标",
+    emotionalState: "neutral",
+    knownFacts: new Set(),
+    relationships: new Map(),
+    powerLevel: "normal",
+    memory: {
+      observedEvents: [],
+      knownSecrets: new Set(),
+      sentiments: new Map(),
+      recentDecisions: [],
+    },
+    knowledgeScope: [],
+    personality: [],
+    speakingStyle: "",
+  }
+}
+
+function makeNode(): StoryNode {
+  return {
+    index: 0,
+    phase: "起",
+    title: "开端",
+    coreConflict: "冲突",
+    involvedCharacters: ["甲", "乙"],
+    goal: "推进剧情",
+    causeFromPrev: "无",
+    expectedOutcome: "完成开端",
+  }
+}
+
+function makeExtraction(): ExtractionResult {
+  return {
+    characters: [],
+    chapterContents: [],
+    memoryData: {
+      characterStates: "",
+      characterCognition: null,
+      foreshadowingTracker: null,
+      timeline: [],
+      canonFacts: "",
+      conflicts: "",
+    },
+    worldRules: "",
+    powerSystem: "",
+    foreshadowing: null,
+    timeline: [],
+    outlineContent: "",
+    soulDoc: "",
+  }
+}
+
+function makeState(agents: NovelAgent[]): SimulationState {
+  return {
+    currentRound: 0,
+    timelineEvents: [],
+    activeAgents: new Map(agents.map((a) => [a.characterId, a])),
+    worldState: {},
+    directorEnabled: false,
+    nextNodeInjectionMap: new Map(),
+  }
+}
+
+describe("agentDecideAndActWithReact", () => {
+  it("parses action from AgentRunner finalText and creates timeline event", async () => {
+    const agentA = makeAgent("a", "甲")
+    const agentB = makeAgent("b", "乙")
+    const agents = [agentA, agentB]
+    const node = makeNode()
+    const state = makeState(agents)
+    const extraction = makeExtraction()
+    const blackboard = createSimulationBlackboard({ agents })
+
+    mockRun = vi.fn().mockResolvedValue({
+      toolCalls: [],
+      roundsUsed: 1,
+      finalText: JSON.stringify({
+        type: "speak",
+        content: "你好乙",
+        target: "乙",
+        visibility: "target_only",
+        motivation: "打招呼",
+        plot_push: "建立联系",
+      }),
+    })
+
+    const result = await agentDecideAndActWithReact(
+      agentA,
+      node,
+      state,
+      {} as any,
+      extraction,
+      [],
+      undefined,
+      blackboard,
+    )
+
+    expect(result).not.toBeNull()
+    expect(result!.parsed.action.type).toBe("speak")
+    expect(result!.parsed.action.content).toBe("你好乙")
+    expect(result!.parsed.action.target).toBe("乙")
+    expect(result!.tlEvent.actorId).toBe("a")
+    expect(result!.tlEvent.content).toBe("你好乙")
+
+    expect(mockRun).toHaveBeenCalledTimes(1)
+    const callArgs = mockRun.mock.calls[0]
+    expect(callArgs[0].maxRounds).toBe(3)
+    expect(callArgs[0].tools.length).toBe(5)
+  })
+
+  it("throws ModelDoesNotSupportToolsError when model does not support tools", async () => {
+    const agentA = makeAgent("a", "甲")
+    const agents = [agentA]
+    const node = makeNode()
+    const state = makeState(agents)
+    const extraction = makeExtraction()
+    const blackboard = createSimulationBlackboard({ agents })
+
+    mockRun = vi.fn().mockImplementation(async (_config, _registry, _messages, callbacks) => {
+      callbacks.onError(new ModelDoesNotSupportToolsError())
+      return { toolCalls: [], roundsUsed: 0, finalText: "" }
+    })
+
+    await expect(
+      agentDecideAndActWithReact(
+        agentA,
+        node,
+        state,
+        {} as any,
+        extraction,
+        [],
+        undefined,
+        blackboard,
+      ),
+    ).rejects.toThrow(ModelDoesNotSupportToolsError)
+  })
+
+  it("returns null when signal is aborted", async () => {
+    const agentA = makeAgent("a", "甲")
+    const agents = [agentA]
+    const node = makeNode()
+    const state = makeState(agents)
+    const extraction = makeExtraction()
+    const blackboard = createSimulationBlackboard({ agents })
+
+    mockRun = vi.fn().mockResolvedValue({
+      toolCalls: [],
+      roundsUsed: 0,
+      finalText: "",
+    })
+
+    const controller = new AbortController()
+    controller.abort()
+
+    const result = await agentDecideAndActWithReact(
+      agentA,
+      node,
+      state,
+      {} as any,
+      extraction,
+      [],
+      undefined,
+      blackboard,
+      controller.signal,
+    )
+
+    expect(result).toBeNull()
+  })
+
+  it("handles malformed JSON gracefully with fallback", async () => {
+    const agentA = makeAgent("a", "甲")
+    const agents = [agentA]
+    const node = makeNode()
+    const state = makeState(agents)
+    const extraction = makeExtraction()
+    const blackboard = createSimulationBlackboard({ agents })
+
+    mockRun = vi.fn().mockResolvedValue({
+      toolCalls: [],
+      roundsUsed: 1,
+      finalText: "这不是有效的JSON格式",
+    })
+
+    const result = await agentDecideAndActWithReact(
+      agentA,
+      node,
+      state,
+      {} as any,
+      extraction,
+      [],
+      undefined,
+      blackboard,
+    )
+
+    expect(result).not.toBeNull()
+    expect(result!.parsed.action.type).toBe("observe")
+  })
+
+  it("passes injectionEvent and modeHint to user message", async () => {
+    const agentA = makeAgent("a", "甲")
+    const agents = [agentA]
+    const node = makeNode()
+    const state = makeState(agents)
+    const extraction = makeExtraction()
+    const blackboard = createSimulationBlackboard({ agents })
+
+    mockRun = vi.fn().mockResolvedValue({
+      toolCalls: [],
+      roundsUsed: 1,
+      finalText: JSON.stringify({
+        type: "observe",
+        content: "观察周围",
+        visibility: "self",
+      }),
+    })
+
+    await agentDecideAndActWithReact(
+      agentA,
+      node,
+      state,
+      {} as any,
+      extraction,
+      [],
+      "突发地震",
+      blackboard,
+      undefined,
+      "主动出击",
+    )
+
+    expect(mockRun).toHaveBeenCalledTimes(1)
+    const callArgs = mockRun.mock.calls[0]
+    const messages = callArgs[2]
+    const userMessage = messages.find((m: any) => m.role === "user")
+    expect(userMessage.content).toContain("突发地震")
+    expect(userMessage.content).toContain("主动出击")
+  })
+})

+ 497 - 22
src/lib/novel/story-simulation/simulation-engine.ts

@@ -1,16 +1,28 @@
 import type { ChatMessage } from "@/lib/llm-client"
 import { streamChat } from "@/lib/llm-client"
 import type { LlmConfig } from "@/stores/wiki-store"
+import { cosineSimilarity, embed } from "@/lib/embedding-client"
 import { buildAgentContext } from "@/lib/novel/story-simulation/agent-profile-builder"
+import { AgentRunner, ModelDoesNotSupportToolsError } from "@/lib/agent/runner"
+import type { AgentConfig, AgentMessage, AgentRunCallbacks } from "@/lib/agent/types"
+import { createSimAgentTools } from "@/lib/novel/story-simulation/sim-agent-tools"
 import {
   createBlackboardDebugTrace,
   createSimulationBlackboard,
   getBlackboardVisibleEvents,
+  getBlackboardVisibleRumors,
   planMultiAgentRound,
   recordBlackboardEvent,
+  recordRumorEvent,
   selectNodeAgentCandidates,
   type SimulationBlackboard,
 } from "@/lib/novel/story-simulation/multi-agent-orchestrator"
+import { directorEvaluate, shouldInjectEvent } from "./director-agent"
+import {
+  pickStagedEvent,
+  stringArrayToStagedPool,
+} from "./event-pool-generator"
+import type { StagedEventPool } from "./event-pool-generator"
 import type {
   ActionVisibility,
   AgentAction,
@@ -18,6 +30,7 @@ import type {
   EventImpact,
   ExtractionResult,
   NovelAgent,
+  RumorEvent,
   SimulationDebugTrace,
   SimulationEvent,
   SimulationInput,
@@ -654,12 +667,76 @@ const RANDOM_EVENTS = [
   "环境的微妙变化让角色们重新审视当前局势。",
 ]
 
-function generateRandomEvent(): SimulationEvent | null {
-  const idx = Math.floor(Math.random() * RANDOM_EVENTS.length)
+function generateRandomEvent(
+  dynamicPool?: string[] | StagedEventPool,
+  usedIndices?: Set<number> | Set<string>,
+  nodeIndex?: number,
+  totalNodes?: number,
+): { event: SimulationEvent | null; usedIndex?: number; usedEventId?: string } {
+  let eventText: string
+  let usedIndex: number | undefined
+  let usedEventId: string | undefined
+
+  if (dynamicPool) {
+    const isStagedPool =
+      typeof dynamicPool === "object" &&
+      dynamicPool !== null &&
+      "byStage" in dynamicPool &&
+      "all" in dynamicPool
+
+    if (isStagedPool && nodeIndex !== undefined && totalNodes !== undefined) {
+      const stagedPool = dynamicPool as StagedEventPool
+      const usedIds = (usedIndices as Set<string>) || new Set<string>()
+      const picked = pickStagedEvent(stagedPool, usedIds, nodeIndex, totalNodes)
+
+      if (picked) {
+        eventText = picked.text
+        usedEventId = picked.id
+      } else {
+        const idx = Math.floor(Math.random() * RANDOM_EVENTS.length)
+        eventText = RANDOM_EVENTS[idx]
+      }
+    } else {
+      const poolArray = isStagedPool
+        ? (dynamicPool as StagedEventPool).all.map((e) => e.text)
+        : (dynamicPool as string[])
+
+      if (poolArray.length > 0) {
+        const availableIndices: number[] = []
+        const usedNumSet = usedIndices as Set<number> | undefined
+        for (let i = 0; i < poolArray.length; i++) {
+          if (!usedNumSet || !usedNumSet.has(i)) {
+            availableIndices.push(i)
+          }
+        }
+
+        if (availableIndices.length > 0) {
+          const randomIdx = Math.floor(Math.random() * availableIndices.length)
+          const poolIdx = availableIndices[randomIdx]
+          eventText = poolArray[poolIdx]
+          usedIndex = poolIdx
+        } else {
+          const idx = Math.floor(Math.random() * RANDOM_EVENTS.length)
+          eventText = RANDOM_EVENTS[idx]
+        }
+      } else {
+        const idx = Math.floor(Math.random() * RANDOM_EVENTS.length)
+        eventText = RANDOM_EVENTS[idx]
+      }
+    }
+  } else {
+    const idx = Math.floor(Math.random() * RANDOM_EVENTS.length)
+    eventText = RANDOM_EVENTS[idx]
+  }
+
   return {
-    type: "info",
-    timestamp: new Date().toISOString(),
-    message: `【随机事件】${RANDOM_EVENTS[idx]}`,
+    event: {
+      type: "info",
+      timestamp: new Date().toISOString(),
+      message: `【随机事件】${eventText}`,
+    },
+    usedIndex,
+    usedEventId,
   }
 }
 
@@ -687,6 +764,113 @@ function isNodeGoalReached(
   return false
 }
 
+const EMBEDDING_SIMILARITY_THRESHOLD = 0.75
+
+export async function isNodeGoalReachedWithEmbedding(
+  node: StoryNode,
+  nodeTimelineEvents: TimelineEvent[],
+  maxRounds: number,
+  currentRound: number,
+  llmConfig: LlmConfig,
+): Promise<boolean> {
+  if (currentRound >= maxRounds - 1) {
+    return true
+  }
+
+  const expectedOutcome = node.expectedOutcome?.trim()
+  if (!expectedOutcome) {
+    return isNodeGoalReached(node, nodeTimelineEvents, maxRounds, currentRound)
+  }
+
+  if (nodeTimelineEvents.length === 0) {
+    return false
+  }
+
+  try {
+    const eventsText = nodeTimelineEvents
+      .map((e) => `${e.actorName}:${e.content}`)
+      .join("\n")
+
+    const expectedEmbedding = await embed(expectedOutcome, llmConfig)
+    const eventsEmbedding = await embed(eventsText, llmConfig)
+
+    const similarity = cosineSimilarity(expectedEmbedding, eventsEmbedding)
+    return similarity >= EMBEDDING_SIMILARITY_THRESHOLD
+  } catch (err) {
+    console.warn("[simulation] embedding 判定失败,降级为启发式判定:", err)
+    return isNodeGoalReached(node, nodeTimelineEvents, maxRounds, currentRound)
+  }
+}
+
+// ── 内部辅助:从事件衍生传闻 ──
+
+let rumorCounter = 0
+function nextRumorId(): string {
+  rumorCounter++
+  return `rumor_${Date.now()}_${rumorCounter}`
+}
+
+function maybeDeriveRumor(
+  event: TimelineEvent,
+  blackboard: SimulationBlackboard,
+  activeAgentIds: string[],
+  random: () => number = Math.random,
+): RumorEvent | null {
+  const totalActive = activeAgentIds.length
+  if (totalActive === 0) return null
+
+  if (event.observableBy.length >= totalActive) {
+    return null
+  }
+
+  if (random() >= 0.2) {
+    return null
+  }
+
+  const actorId = event.actorId
+  const targetId = event.targetId
+  const excludedIds = new Set<string>()
+  if (actorId) excludedIds.add(actorId)
+  if (targetId) excludedIds.add(targetId)
+  for (const id of event.observableBy) {
+    excludedIds.add(id)
+  }
+
+  const eligibleAgents = activeAgentIds.filter((id) => !excludedIds.has(id))
+  if (eligibleAgents.length === 0) return null
+
+  const minCount = Math.min(2, eligibleAgents.length)
+  const maxCount = Math.min(4, eligibleAgents.length)
+  const count = Math.floor(random() * (maxCount - minCount + 1)) + minCount
+
+  const shuffled = [...eligibleAgents]
+  for (let i = shuffled.length - 1; i > 0; i--) {
+    const j = Math.floor(random() * (i + 1))
+    const tmp = shuffled[i]
+    shuffled[i] = shuffled[j]
+    shuffled[j] = tmp
+  }
+  const observableBy = shuffled.slice(0, count)
+
+  const distortion = 0.3 + random() * 0.4
+
+  const rumor: RumorEvent = {
+    id: nextRumorId(),
+    round: event.round,
+    nodeIndex: event.nodeIndex,
+    sourceId: actorId ?? null,
+    content: `据说${event.content}`,
+    distortion,
+    observableBy,
+    believedBy: [],
+    verifiedBy: [],
+    timestamp: new Date().toISOString(),
+  }
+
+  recordRumorEvent(blackboard, rumor)
+  return rumor
+}
+
 // ── 内部辅助:单个 Agent 决策并产生事件 ──
 
 async function agentDecideAndAct(
@@ -701,8 +885,9 @@ async function agentDecideAndAct(
   signal?: AbortSignal,
   modeHint?: string,
 ): Promise<{ parsed: ParsedAction; tlEvent: TimelineEvent; simEvent: SimulationEvent } | null> {
-  // 1. 观察:筛选该 Agent 可见的时间线事件
+  // 1. 观察:筛选该 Agent 可见的时间线事件和传闻
   const visibleEvents = getBlackboardVisibleEvents(blackboard, agent.characterId, 10)
+  const visibleRumors = getBlackboardVisibleRumors(blackboard, agent.characterId, 5)
 
   // 2. 构建上下文(基于认知边界)
   const context = buildAgentContext(
@@ -711,6 +896,7 @@ async function agentDecideAndAct(
     recentEventDescs.slice(-8),
     extraction.worldRules,
     visibleEvents,
+    visibleRumors,
   )
 
   // 3. 构建 LLM 消息
@@ -765,6 +951,198 @@ async function agentDecideAndAct(
   return { parsed, tlEvent, simEvent }
 }
 
+// ── ReAct 路径:使用 AgentRunner 进行工具调用循环 ──
+
+function buildReactSystemPrompt(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,
+    "",
+    "【可用工具】",
+    "你可以使用以下工具来帮助你做出决策:",
+    "- recall:回忆历史上你亲眼所见的事件",
+    "- observe:观察当前轮次其他角色的公开行为",
+    "- inquire:向另一个角色提出问题(对方下一轮可见)",
+    "- introspect:审视自己的内心状态(情绪、目标、性格等)",
+    "",
+    "【决策流程】",
+    "1. 你可以先使用工具收集信息(回忆、观察、提问、内省)",
+    "2. 收集足够信息后,输出你的最终行为决策",
+    "",
+    "【行为类型说明】你只能选择以下一种行为类型:",
+    "- 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")
+}
+
+export async function agentDecideAndActWithReact(
+  agent: NovelAgent,
+  node: StoryNode,
+  state: SimulationState,
+  llmConfig: LlmConfig,
+  extraction: ExtractionResult,
+  recentEventDescs: string[],
+  injectionEvent: string | undefined,
+  blackboard: SimulationBlackboard,
+  signal?: AbortSignal,
+  modeHint?: string,
+): Promise<{ parsed: ParsedAction; tlEvent: TimelineEvent; simEvent: SimulationEvent } | null> {
+  const visibleEvents = getBlackboardVisibleEvents(blackboard, agent.characterId, 10)
+  const visibleRumors = getBlackboardVisibleRumors(blackboard, agent.characterId, 5)
+
+  const context = buildAgentContext(
+    agent,
+    node,
+    recentEventDescs.slice(-8),
+    extraction.worldRules,
+    visibleEvents,
+    visibleRumors,
+  )
+
+  const userMessageParts: string[] = [context]
+  if (modeHint) {
+    userMessageParts.push("")
+    userMessageParts.push("【行为倾向】")
+    userMessageParts.push(modeHint)
+  }
+  if (injectionEvent) {
+    userMessageParts.push("")
+    userMessageParts.push("【突发事件】")
+    userMessageParts.push(injectionEvent)
+  }
+  userMessageParts.push("")
+  userMessageParts.push(
+    `当前是节点「${node.title}」,节点目标是:${node.goal}。请根据以上信息,以「${agent.name}」的视角决定你接下来要做的一个行为。你可以先使用工具收集信息,然后输出最终的JSON行为决策。`,
+  )
+  const userMessageText = userMessageParts.join("\n")
+
+  const registry = createSimAgentTools(agent, blackboard)
+  const tools = registry.list()
+
+  const config: AgentConfig = {
+    maxRounds: 3,
+    tools,
+    systemPrompt: buildReactSystemPrompt(agent),
+    llmConfig,
+  }
+
+  const messages: AgentMessage[] = [
+    { role: "system", content: buildReactSystemPrompt(agent) },
+    { role: "user", content: userMessageText },
+  ]
+
+  let finalText = ""
+
+  const callbacks: AgentRunCallbacks = {
+    onText: (chunk) => {
+      finalText += chunk
+    },
+    onToolCall: () => {},
+    onToolResult: () => {},
+    onToolError: () => {},
+    onDone: () => {},
+    onError: (err) => {
+      if (err instanceof ModelDoesNotSupportToolsError) {
+        throw err
+      }
+    },
+  }
+
+  const runner = new AgentRunner()
+
+  try {
+    const record = await runner.run(config, registry, messages, callbacks, signal)
+    if (signal?.aborted) return null
+
+    if (record.finalText) {
+      finalText = record.finalText
+    }
+  } catch (err) {
+    if (err instanceof ModelDoesNotSupportToolsError) {
+      throw err
+    }
+    if (!finalText) {
+      throw err
+    }
+  }
+
+  if (!finalText.trim()) {
+    return null
+  }
+
+  const parsed = parseAgentAction(finalText)
+  const target = resolveTarget(parsed.action.target, state.activeAgents)
+  const tlEvent = createTimelineEvent(
+    agent,
+    parsed,
+    target,
+    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)
+
+  const simEvent = timelineEventToSimulationEvent(
+    tlEvent,
+    agent,
+    parsed,
+    node,
+    state.currentRound,
+  )
+
+  return { parsed, tlEvent, simEvent }
+}
+
 // ── 主入口:运行仿真(多智能体,基于认知边界) ──
 
 export async function runSimulation(
@@ -774,7 +1152,7 @@ export async function runSimulation(
   signal?: AbortSignal,
 ): Promise<SimulationEvent[]> {
   const events: SimulationEvent[] = []
-  const { agents, framework, wordBudget, llmConfig, injectionEvent, maxRoundsPerNode } = input
+  const { agents, framework, wordBudget, llmConfig, injectionEvent, maxRoundsPerNode, dynamicEventPool } = input
   const mode = input.mode || framework.simulationMode || "hybrid"
   const modeConfig: ModeConfig = getModeConfig(mode)
   const totalNodes = framework.nodes.length
@@ -784,12 +1162,37 @@ export async function runSimulation(
   const maxRounds = Math.max(1, Math.round(baseRounds * modeConfig.roundsMultiplier))
   let aborted = false
 
+  const isStagedPool =
+    dynamicEventPool &&
+    typeof dynamicEventPool === "object" &&
+    !Array.isArray(dynamicEventPool) &&
+    "byStage" in dynamicEventPool &&
+    "all" in dynamicEventPool
+
+  const stagedPool: StagedEventPool | undefined = isStagedPool
+    ? (dynamicEventPool as StagedEventPool)
+    : dynamicEventPool && Array.isArray(dynamicEventPool) && dynamicEventPool.length > 0
+      ? stringArrayToStagedPool(dynamicEventPool)
+      : undefined
+
+  const stringPool: string[] | undefined = dynamicEventPool
+    ? (Array.isArray(dynamicEventPool)
+        ? dynamicEventPool
+        : (dynamicEventPool as StagedEventPool).all.map((e) => e.text))
+    : undefined
+
+  const usedEventIds: Set<string> | undefined = stagedPool ? new Set() : undefined
+
   // 初始化仿真状态
   const state: SimulationState = {
     currentRound: 0,
     timelineEvents: [],
     activeAgents: cloneAgentsToMap(agents),
     worldState: {},
+    dynamicEventPool: stringPool && stringPool.length > 0 ? stringPool : undefined,
+    usedEventIndices: stringPool && stringPool.length > 0 ? new Set() : undefined,
+    directorEnabled: modeConfig.directorEnabled ?? false,
+    nextNodeInjectionMap: new Map(),
   }
   const blackboard = createSimulationBlackboard({
     agents: Array.from(state.activeAgents.values()),
@@ -828,6 +1231,11 @@ export async function runSimulation(
         `开始节点 ${ni + 1}/${totalNodes}:${node.title}`,
       )
 
+      // 当前节点的注入事件:优先从导演注入映射取,其次用初始注入事件(仅第一个节点)
+      const directorInjection = state.nextNodeInjectionMap.get(node.index)
+      const initialInjection = ni === 0 ? injectionEvent : undefined
+      const nodeInjectionEvent = directorInjection || initialInjection
+
       // 当前节点内的事件描述(供 recentEvents 使用)
       const recentEventDescs: string[] = []
       const nodeTimelineEvents: TimelineEvent[] = []
@@ -875,20 +1283,41 @@ export async function runSimulation(
 
           let result: Awaited<ReturnType<typeof agentDecideAndAct>> | null = null
           try {
-            result = await agentDecideAndAct(
-              currentAgent,
-              node,
-              state,
-              llmConfig,
-              extraction,
-              recentEventDescs,
-              round === 0 ? injectionEvent : undefined,
-              blackboard,
-              signal,
-              modeConfig.behaviorHint,
-            )
+            try {
+              result = await agentDecideAndActWithReact(
+                currentAgent,
+                node,
+                state,
+                llmConfig,
+                extraction,
+                recentEventDescs,
+                round === 0 ? nodeInjectionEvent : undefined,
+                blackboard,
+                signal,
+                modeConfig.behaviorHint,
+              )
+            } catch (reactErr) {
+              if (reactErr instanceof ModelDoesNotSupportToolsError) {
+                console.warn(
+                  `[simulation] Agent ${currentAgent.name} ReAct 路径不支持工具调用,降级为普通路径`,
+                )
+                result = await agentDecideAndAct(
+                  currentAgent,
+                  node,
+                  state,
+                  llmConfig,
+                  extraction,
+                  recentEventDescs,
+                  round === 0 ? nodeInjectionEvent : undefined,
+                  blackboard,
+                  signal,
+                  modeConfig.behaviorHint,
+                )
+              } else {
+                throw reactErr
+              }
+            }
           } catch (agentErr) {
-            // 单个 Agent 失败不中断整个推演,记录事件后跳过
             console.warn(`[simulation] Agent ${currentAgent.name} 决策失败,跳过本轮:`, agentErr)
             const warnEvent: SimulationEvent = {
               type: "info",
@@ -913,6 +1342,7 @@ export async function runSimulation(
 
           events.push(simEvent)
           recordBlackboardEvent(blackboard, tlEvent)
+          maybeDeriveRumor(tlEvent, blackboard, Array.from(state.activeAgents.keys()))
           callbacks.onEvent(simEvent)
           callbacks.onTimelineEvent?.(tlEvent)
           callbacks.onDebugTrace?.(
@@ -962,8 +1392,19 @@ export async function runSimulation(
 
         // e. 随机事件(根据模式概率触发)
         if (modeConfig.randomEventChance > 0 && Math.random() < modeConfig.randomEventChance) {
-          const randomEvent = generateRandomEvent()
+          const { event: randomEvent, usedIndex, usedEventId } = generateRandomEvent(
+            stagedPool ?? state.dynamicEventPool,
+            usedEventIds ?? state.usedEventIndices,
+            node.index,
+            totalNodes,
+          )
           if (randomEvent) {
+            if (usedEventId !== undefined && usedEventIds) {
+              usedEventIds.add(usedEventId)
+            }
+            if (usedIndex !== undefined && state.usedEventIndices) {
+              state.usedEventIndices.add(usedIndex)
+            }
             events.push(randomEvent)
             callbacks.onEvent(randomEvent)
             const tlEvent: TimelineEvent = {
@@ -999,13 +1440,40 @@ export async function runSimulation(
         }
 
         // f. 检查节点目标是否达成
-        if (isNodeGoalReached(node, nodeTimelineEvents, maxRounds, round)) {
+        const goalReached = await isNodeGoalReachedWithEmbedding(
+          node,
+          nodeTimelineEvents,
+          maxRounds,
+          round,
+          llmConfig,
+        )
+        if (goalReached) {
           break
         }
       }
 
       if (aborted) break
 
+      // 导演 Agent 评估(仅在启用时)
+      if (state.directorEnabled && ni < totalNodes - 1) {
+        try {
+          const directorEval = await directorEvaluate({
+            node,
+            nodeTimelineEvents,
+            worldRules: extraction.worldRules,
+            llmConfig,
+            signal,
+          })
+
+          if (shouldInjectEvent(directorEval) && directorEval.injectEvent) {
+            const nextNodeIndex = ni + 1
+            state.nextNodeInjectionMap.set(nextNodeIndex, directorEval.injectEvent)
+          }
+        } catch (directorErr) {
+          console.warn("[simulation] 导演 Agent 评估失败,继续推演:", directorErr)
+        }
+      }
+
       // 产出 node-complete 事件
       const completeEvent: SimulationEvent = {
         type: "node-complete",
@@ -1076,6 +1544,11 @@ async function triggerReaction(
     targetAgent.characterId,
     10,
   )
+  const visibleRumors = getBlackboardVisibleRumors(
+    blackboard,
+    targetAgent.characterId,
+    5,
+  )
 
   const reactionNote = `\n\n【刚才发生的事情】\n${actor.name}刚刚对你做出了行为:[${triggerEvent.actionType}] ${triggerEvent.content}\n请你立即对此做出反应(react类型行为)。`
 
@@ -1085,6 +1558,7 @@ async function triggerReaction(
     recentEventDescs.slice(-8),
     extraction.worldRules,
     visibleEvents,
+    visibleRumors,
   )
 
   const context = baseContext + reactionNote
@@ -1127,6 +1601,7 @@ async function triggerReaction(
 
     state.timelineEvents.push(tlEvent)
     recordBlackboardEvent(blackboard, tlEvent)
+    maybeDeriveRumor(tlEvent, blackboard, Array.from(state.activeAgents.keys()))
     nodeTimelineEvents.push(tlEvent)
     callbacks.onDebugTrace?.(
       createBlackboardDebugTrace(blackboard, {

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

@@ -151,6 +151,8 @@ export function deserializeSimulationSnapshot(
     timelineEvents: snapshot.state.timelineEvents as SimulationState["timelineEvents"],
     activeAgents,
     worldState: snapshot.state.worldState,
+    directorEnabled: false,
+    nextNodeInjectionMap: new Map(),
   }
   return { agents, state }
 }

+ 152 - 0
src/lib/novel/story-simulation/staged-event-pool.spec.ts

@@ -0,0 +1,152 @@
+import { describe, expect, it } from "vitest"
+import {
+  getNodeStage,
+  pickStagedEvent,
+  type EventStage,
+  type StagedEvent,
+  type StagedEventPool,
+} from "./event-pool-generator"
+
+function makeEvent(id: string, text: string, stage: EventStage): StagedEvent {
+  return { id, text, stage }
+}
+
+function makeTestPool(): StagedEventPool {
+  const setup = [
+    makeEvent("s1", "起阶段事件1", "setup"),
+    makeEvent("s2", "起阶段事件2", "setup"),
+    makeEvent("s3", "起阶段事件3", "setup"),
+  ]
+  const rising = [
+    makeEvent("r1", "承阶段事件1", "rising"),
+    makeEvent("r2", "承阶段事件2", "rising"),
+    makeEvent("r3", "承阶段事件3", "rising"),
+  ]
+  const climax = [
+    makeEvent("c1", "转阶段事件1", "climax"),
+    makeEvent("c2", "转阶段事件2", "climax"),
+    makeEvent("c3", "转阶段事件3", "climax"),
+  ]
+  const resolution = [
+    makeEvent("re1", "合阶段事件1", "resolution"),
+    makeEvent("re2", "合阶段事件2", "resolution"),
+    makeEvent("re3", "合阶段事件3", "resolution"),
+  ]
+  const all = [...setup, ...rising, ...climax, ...resolution]
+  return { byStage: { setup, rising, climax, resolution }, all }
+}
+
+describe("getNodeStage", () => {
+  it("4节点:节点0为setup,节点3为resolution,中间为rising", () => {
+    expect(getNodeStage(0, 4)).toBe("setup")
+    expect(getNodeStage(1, 4)).toBe("rising")
+    expect(getNodeStage(2, 4)).toBe("rising")
+    expect(getNodeStage(3, 4)).toBe("resolution")
+  })
+
+  it("6节点:节点0为setup,节点5为resolution,4-5阈值为climax", () => {
+    expect(getNodeStage(0, 6)).toBe("setup")
+    expect(getNodeStage(1, 6)).toBe("rising")
+    expect(getNodeStage(2, 6)).toBe("rising")
+    expect(getNodeStage(3, 6)).toBe("rising")
+    expect(getNodeStage(4, 6)).toBe("climax")
+    expect(getNodeStage(5, 6)).toBe("resolution")
+  })
+
+  it("8节点:节点0为setup,节点7为resolution,6-7为climax(但7是resolution)", () => {
+    expect(getNodeStage(0, 8)).toBe("setup")
+    expect(getNodeStage(1, 8)).toBe("rising")
+    expect(getNodeStage(2, 8)).toBe("rising")
+    expect(getNodeStage(3, 8)).toBe("rising")
+    expect(getNodeStage(4, 8)).toBe("rising")
+    expect(getNodeStage(5, 8)).toBe("rising")
+    expect(getNodeStage(6, 8)).toBe("climax")
+    expect(getNodeStage(7, 8)).toBe("resolution")
+  })
+
+  it("边界:nodeIndex === 0 始终是 setup", () => {
+    expect(getNodeStage(0, 1)).toBe("setup")
+    expect(getNodeStage(0, 2)).toBe("setup")
+    expect(getNodeStage(0, 10)).toBe("setup")
+  })
+
+  it("边界:nodeIndex === totalNodes - 1 始终是 resolution", () => {
+    expect(getNodeStage(0, 1)).toBe("setup")
+    expect(getNodeStage(1, 2)).toBe("resolution")
+    expect(getNodeStage(9, 10)).toBe("resolution")
+  })
+})
+
+describe("pickStagedEvent", () => {
+  it("优先从当前阶段池抽取", () => {
+    const pool = makeTestPool()
+    const usedIds = new Set<string>()
+    const result = pickStagedEvent(pool, usedIds, 0, 4)
+
+    expect(result).not.toBeNull()
+    expect(result?.stage).toBe("setup")
+    expect(usedIds.has(result!.id)).toBe(false)
+  })
+
+  it("阶段池耗尽后回退全局池", () => {
+    const pool = makeTestPool()
+    const usedIds = new Set<string>(["s1", "s2", "s3"])
+
+    const result = pickStagedEvent(pool, usedIds, 0, 4)
+
+    expect(result).not.toBeNull()
+    expect(result?.stage).not.toBe("setup")
+    expect(usedIds.has(result!.id)).toBe(false)
+  })
+
+  it("全局池耗尽返回 null", () => {
+    const pool = makeTestPool()
+    const allIds = pool.all.map((e) => e.id)
+    const usedIds = new Set<string>(allIds)
+
+    const result = pickStagedEvent(pool, usedIds, 0, 4)
+
+    expect(result).toBeNull()
+  })
+
+  it("各阶段抽取不重复(usedIds 生效)", () => {
+    const pool = makeTestPool()
+    const usedIds = new Set<string>()
+    const pickedIds = new Set<string>()
+
+    for (let i = 0; i < 12; i++) {
+      const result = pickStagedEvent(pool, usedIds, 0, 4)
+      if (result) {
+        expect(pickedIds.has(result.id)).toBe(false)
+        pickedIds.add(result.id)
+        usedIds.add(result.id)
+      }
+    }
+
+    expect(pickedIds.size).toBe(12)
+    expect(pickStagedEvent(pool, usedIds, 0, 4)).toBeNull()
+  })
+
+  it("不同节点阶段从对应阶段抽取", () => {
+    const pool = makeTestPool()
+
+    const setupResult = pickStagedEvent(pool, new Set(), 0, 4)
+    expect(setupResult?.stage).toBe("setup")
+
+    const risingResult = pickStagedEvent(pool, new Set(), 1, 4)
+    expect(risingResult?.stage).toBe("rising")
+
+    const resolutionResult = pickStagedEvent(pool, new Set(), 3, 4)
+    expect(resolutionResult?.stage).toBe("resolution")
+  })
+
+  it("空池返回 null", () => {
+    const emptyPool: StagedEventPool = {
+      byStage: { setup: [], rising: [], climax: [], resolution: [] },
+      all: [],
+    }
+
+    const result = pickStagedEvent(emptyPool, new Set(), 0, 4)
+    expect(result).toBeNull()
+  })
+})

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

@@ -68,6 +68,20 @@ export interface TimelineEvent {
   timestamp: string
 }
 
+export interface RumorEvent {
+  id: string
+  round: number
+  nodeIndex: number
+  sourceId: string | null
+  content: string
+  distortion: number
+  observableBy: string[]
+  believedBy: string[]
+  /** 已验证此传闻的角色 ID 列表 */
+  verifiedBy: string[]
+  timestamp: string
+}
+
 export interface SimulationDebugVisibleEvent {
   id: string
   actorName: string
@@ -99,9 +113,12 @@ export interface SimulationDebugTrace {
     activeAgentCount: number
     totalEventCount: number
     publicEventCount: number
+    rumorCount: number
   }
   visibilityByAgent: SimulationDebugAgent[]
   latestEvent?: SimulationDebugVisibleEvent
+  rumors: RumorEvent[]
+  activeAgents: Map<string, NovelAgent>
   timestamp: string
 }
 
@@ -149,11 +166,26 @@ export interface AgentRelation {
 }
 
 // ── 仿真状态(新引擎核心状态) ──
+export interface StagedEvent {
+  id: string
+  text: string
+  stage: "setup" | "rising" | "climax" | "resolution"
+}
+
+export interface StagedEventPool {
+  byStage: Record<"setup" | "rising" | "climax" | "resolution", StagedEvent[]>
+  all: StagedEvent[]
+}
+
 export interface SimulationState {
   currentRound: number
   timelineEvents: TimelineEvent[]
   activeAgents: Map<string, NovelAgent>
   worldState: Record<string, unknown>
+  dynamicEventPool?: string[]
+  usedEventIndices?: Set<number>
+  directorEnabled: boolean
+  nextNodeInjectionMap: Map<number, string>
 }
 
 // ── Agent 对话(采访/私聊) ──
@@ -324,6 +356,8 @@ export interface SimulationInput {
   injectionEvent?: string
   /** 每个节点的仿真轮数,不传则根据字数自动计算 */
   maxRoundsPerNode?: number
+  /** LLM 预生成的动态事件池(支持字符串数组或分阶段池) */
+  dynamicEventPool?: string[] | StagedEventPool
 }
 
 // ── 仿真配置 ──
@@ -360,6 +394,8 @@ export interface ModeConfig {
   agentSubsetRatio: number
   /** 是否强制按节点目标推进 */
   strictNodeProgression: boolean
+  /** 是否启用导演 Agent */
+  directorEnabled?: boolean
 }
 
 const MODE_CONFIGS: Record<SimulationMode, ModeConfig> = {
@@ -370,6 +406,7 @@ const MODE_CONFIGS: Record<SimulationMode, ModeConfig> = {
     randomEventChance: 0.1,
     agentSubsetRatio: 1,
     strictNodeProgression: true,
+    directorEnabled: false,
   },
   "free-emergence": {
     roundsMultiplier: 1.5,
@@ -378,6 +415,7 @@ const MODE_CONFIGS: Record<SimulationMode, ModeConfig> = {
     randomEventChance: 0.25,
     agentSubsetRatio: 0.7,
     strictNodeProgression: false,
+    directorEnabled: false,
   },
   "decision-tree": {
     roundsMultiplier: 1.0,
@@ -386,6 +424,7 @@ const MODE_CONFIGS: Record<SimulationMode, ModeConfig> = {
     randomEventChance: 0.15,
     agentSubsetRatio: 0.5,
     strictNodeProgression: true,
+    directorEnabled: false,
   },
   hybrid: {
     roundsMultiplier: 1.2,
@@ -394,6 +433,7 @@ const MODE_CONFIGS: Record<SimulationMode, ModeConfig> = {
     randomEventChance: 0.2,
     agentSubsetRatio: 0.85,
     strictNodeProgression: false,
+    directorEnabled: false,
   },
 }
 
@@ -428,6 +468,49 @@ export interface ModeVisualInfo {
   icon: string
 }
 
+// ── 导演评价 ──
+
+export interface DirectorScore {
+  tension: number
+  pace: number
+  characterUtilization: number
+  characterArc: number
+  infoDensity: number
+  emotionalResonance: number
+  logicConsistency: number
+}
+
+export interface DirectorEvaluation {
+  scores: DirectorScore
+  totalScore: number
+  highlights: string[]
+  issues: string[]
+  suggestion: string
+  shouldInjectEvent: boolean
+  injectEvent?: string
+}
+
+// ── 仿真分支 ──
+
+export interface SimulationBranch {
+  id: string
+  name: string
+  frameworkId: string
+  mode: SimulationMode
+  createdAt: string
+  timelineEvents: TimelineEvent[]
+  rumors: RumorEvent[]
+  finalAgentSnapshots: { agentId: string; name: string; knownSecrets: string[]; sentiments: [string, number][] }[]
+  directorEvaluations: DirectorEvaluation[]
+  overallScore: number
+  scoreDetails: {
+    avgDirectorScore: number
+    eventCount: number
+    characterDiversity: number
+    plotProgression: number
+  }
+}
+
 export const MODE_VISUAL_INFO: Record<SimulationMode, ModeVisualInfo> = {
   "event-driven": {
     name: "事件驱动",

+ 80 - 0
src/lib/novel/task-router.story-sim.spec.ts

@@ -0,0 +1,80 @@
+import { describe, expect, it } from "vitest"
+import { routeTask } from "./task-router"
+
+describe("routeTask story simulation intents", () => {
+  describe("story_framework_generate", () => {
+    it("识别'故事框架'关键词", () => {
+      const route = routeTask("生成故事框架")
+      expect(route.intent).toBe("story_framework_generate")
+    })
+
+    it("识别'剧情框架'关键词", () => {
+      const route = routeTask("帮我创建剧情框架")
+      expect(route.intent).toBe("story_framework_generate")
+    })
+
+    it("识别'生成框架'关键词", () => {
+      const route = routeTask("生成框架")
+      expect(route.intent).toBe("story_framework_generate")
+    })
+
+    it("识别以'故事框架生成'开头的请求", () => {
+      const route = routeTask("故事框架生成一个悬疑小说")
+      expect(route.intent).toBe("story_framework_generate")
+    })
+  })
+
+  describe("multi_agent_simulate", () => {
+    it("识别'推演剧情'关键词", () => {
+      const route = routeTask("推演剧情走向")
+      expect(route.intent).toBe("multi_agent_simulate")
+    })
+
+    it("识别'多智能体推演'关键词", () => {
+      const route = routeTask("多智能体推演")
+      expect(route.intent).toBe("multi_agent_simulate")
+    })
+
+    it("识别'剧情走向'关键词", () => {
+      const route = routeTask("分析一下剧情走向")
+      expect(route.intent).toBe("multi_agent_simulate")
+    })
+
+    it("识别'推演一下'关键词", () => {
+      const route = routeTask("推演一下主角发现真相后的发展")
+      expect(route.intent).toBe("multi_agent_simulate")
+    })
+
+    it("识别'推演剧情走向'关键词", () => {
+      const route = routeTask("推演剧情走向")
+      expect(route.intent).toBe("multi_agent_simulate")
+    })
+
+    it("识别以'推演'开头的请求", () => {
+      const route = routeTask("推演第三章之后的剧情")
+      expect(route.intent).toBe("multi_agent_simulate")
+    })
+  })
+
+  describe("character_interview", () => {
+    it("识别'角色采访'关键词", () => {
+      const route = routeTask("角色采访李明")
+      expect(route.intent).toBe("character_interview")
+    })
+
+    it("识别'采访角色'关键词", () => {
+      const route = routeTask("采访角色主角")
+      expect(route.intent).toBe("character_interview")
+    })
+
+    it("识别'问角色'关键词", () => {
+      const route = routeTask("问角色一个问题")
+      expect(route.intent).toBe("character_interview")
+    })
+
+    it("识别以'角色采访'开头的请求", () => {
+      const route = routeTask("角色采访一下女主角")
+      expect(route.intent).toBe("character_interview")
+    })
+  })
+})

+ 36 - 0
src/lib/novel/task-router.ts

@@ -18,6 +18,9 @@ export type NovelTaskIntent =
   | "timeline_query"       // 时间线查询
   | "setting_query"        // 设定查询
   | "general_chat"         // 一般对话
+  | "story_framework_generate"  // 故事框架生成
+  | "multi_agent_simulate"      // 多智能体推演
+  | "character_interview"       // 角色采访
 
 export const MODIFY_INTENTS: Set<NovelTaskIntent> = new Set([
   "rewrite_chapter",
@@ -182,6 +185,33 @@ const INTENT_PATTERNS: IntentPattern[] = [
     keywords: ["设定", "世界观", "正史", "规则", "能力体系"],
     weight: 6,
   },
+  {
+    intent: "story_framework_generate",
+    patterns: [
+      /^(生成|创建|写).*(故事框架|剧情框架)/,
+      /^(故事框架|剧情框架).*(生成|创建)/,
+    ],
+    keywords: ["故事框架", "剧情框架", "生成框架"],
+    weight: 10,
+  },
+  {
+    intent: "multi_agent_simulate",
+    patterns: [
+      /^(推演|推演一下|推演剧情|推演剧情走向|多智能体推演)/,
+      /(推演剧情|推演一下|多智能体推演|剧情走向)/,
+    ],
+    keywords: ["推演剧情", "多智能体推演", "剧情走向", "推演一下", "推演剧情走向"],
+    weight: 10,
+  },
+  {
+    intent: "character_interview",
+    patterns: [
+      /^(角色采访|采访角色|问角色)/,
+      /(角色采访|采访角色|问角色)/,
+    ],
+    keywords: ["角色采访", "采访角色", "问角色"],
+    weight: 10,
+  },
 ]
 
 const CHAPTER_NUMBER_PATTERNS = [
@@ -399,6 +429,9 @@ export function buildTaskDirective(route: TaskRouteResult): string {
     timeline_query: "用户在查询时间线。请根据时间线数据回答当前时间进展。",
     setting_query: "用户在查询设定信息。请根据正史设定和世界观回答。",
     general_chat: "",
+    story_framework_generate: "用户要求生成故事框架。请跳转到剧情推演室进行框架生成。",
+    multi_agent_simulate: "用户要求多智能体推演。请跳转到剧情推演室进行推演。",
+    character_interview: "用户要求角色采访。请跳转到剧情推演室进行角色采访。",
   }
 
   const directive = directives[route.intent]
@@ -423,6 +456,9 @@ function intentToLabel(intent: NovelTaskIntent): string {
     timeline_query: "时间线查询",
     setting_query: "设定查询",
     general_chat: "一般对话",
+    story_framework_generate: "故事框架生成",
+    multi_agent_simulate: "多智能体推演",
+    character_interview: "角色采访",
   }
   return labels[intent] || "未知"
 }

+ 130 - 0
src/stores/story-simulation-preset.spec.ts

@@ -0,0 +1,130 @@
+import { beforeEach, describe, expect, it } from "vitest"
+import { useStorySimulationStore } from "./story-simulation-store"
+import type { SimulationPreset } from "./story-simulation-store"
+
+describe("story simulation store initWithPreset", () => {
+  beforeEach(() => {
+    useStorySimulationStore.setState({
+      phase: "idle",
+      userIdea: "",
+      savedResults: [],
+      currentFramework: null,
+    })
+  })
+
+  it("story_framework_generate: sets phase to configuring", () => {
+    const preset: SimulationPreset = {
+      intent: "story_framework_generate",
+      userInput: "生成一个悬疑故事框架",
+      hasFramework: false,
+    }
+
+    useStorySimulationStore.getState().initWithPreset(preset)
+
+    const state = useStorySimulationStore.getState()
+    expect(state.phase).toBe("configuring")
+    expect(state.userIdea).toBe("生成一个悬疑故事框架")
+  })
+
+  it("multi_agent_simulate: hasFramework=true sets phase to simulating", () => {
+    const preset: SimulationPreset = {
+      intent: "multi_agent_simulate",
+      userInput: "推演一下主角发现真相后的剧情走向",
+      hasFramework: true,
+    }
+
+    useStorySimulationStore.getState().initWithPreset(preset)
+
+    const state = useStorySimulationStore.getState()
+    expect(state.phase).toBe("simulating")
+    expect(state.userIdea).toBe("推演一下主角发现真相后的剧情走向")
+  })
+
+  it("multi_agent_simulate: hasFramework=false sets phase to configuring", () => {
+    const preset: SimulationPreset = {
+      intent: "multi_agent_simulate",
+      userInput: "推演剧情走向",
+      hasFramework: false,
+    }
+
+    useStorySimulationStore.getState().initWithPreset(preset)
+
+    const state = useStorySimulationStore.getState()
+    expect(state.phase).toBe("configuring")
+    expect(state.userIdea).toBe("推演剧情走向")
+  })
+
+  it("character_interview: hasFramework=true and savedResults sets phase to report-viewing", () => {
+    useStorySimulationStore.setState({
+      savedResults: [
+        {
+          id: "result-1",
+          frameworkId: "fw-1",
+          report: {
+            recommendation: "测试推荐",
+            createdAt: "2026-07-03T00:00:00.000Z",
+          } as any,
+          createdAt: "2026-07-03T00:00:00.000Z",
+        },
+      ],
+    })
+
+    const preset: SimulationPreset = {
+      intent: "character_interview",
+      userInput: "采访主角李明",
+      hasFramework: true,
+    }
+
+    useStorySimulationStore.getState().initWithPreset(preset)
+
+    const state = useStorySimulationStore.getState()
+    expect(state.phase).toBe("report-viewing")
+    expect(state.userIdea).toBe("采访主角李明")
+  })
+
+  it("character_interview: hasFramework=true but no savedResults sets phase to configuring", () => {
+    useStorySimulationStore.setState({
+      savedResults: [],
+    })
+
+    const preset: SimulationPreset = {
+      intent: "character_interview",
+      userInput: "问角色一个问题",
+      hasFramework: true,
+    }
+
+    useStorySimulationStore.getState().initWithPreset(preset)
+
+    const state = useStorySimulationStore.getState()
+    expect(state.phase).toBe("configuring")
+    expect(state.userIdea).toBe("问角色一个问题")
+  })
+
+  it("character_interview: hasFramework=false sets phase to configuring", () => {
+    const preset: SimulationPreset = {
+      intent: "character_interview",
+      userInput: "角色采访",
+      hasFramework: false,
+    }
+
+    useStorySimulationStore.getState().initWithPreset(preset)
+
+    const state = useStorySimulationStore.getState()
+    expect(state.phase).toBe("configuring")
+    expect(state.userIdea).toBe("角色采访")
+  })
+
+  it("unknown intent defaults to configuring", () => {
+    const preset: SimulationPreset = {
+      intent: "unknown_intent",
+      userInput: "测试输入",
+      hasFramework: true,
+    }
+
+    useStorySimulationStore.getState().initWithPreset(preset)
+
+    const state = useStorySimulationStore.getState()
+    expect(state.phase).toBe("configuring")
+    expect(state.userIdea).toBe("测试输入")
+  })
+})

+ 3 - 0
src/stores/story-simulation-store.spec.ts

@@ -18,6 +18,7 @@ function makeTrace(id: string): SimulationDebugTrace {
       activeAgentCount: 1,
       totalEventCount: 0,
       publicEventCount: 0,
+      rumorCount: 0,
     },
     visibilityByAgent: [
       {
@@ -27,6 +28,8 @@ function makeTrace(id: string): SimulationDebugTrace {
         recentEvents: [],
       },
     ],
+    rumors: [],
+    activeAgents: new Map(),
     timestamp: "2026-07-03T00:00:00.000Z",
   }
 }

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

@@ -9,6 +9,11 @@ import type {
   FrameworkBinding,
   SimulationDebugTrace,
   TimelineEvent,
+  StagedEventPool,
+  RumorEvent,
+  NovelAgent,
+  SimulationBranch,
+  DirectorEvaluation,
 } from "@/lib/novel/story-simulation/types"
 import type { SerializedSimulationSnapshot } from "@/lib/novel/story-simulation/simulation-serializer"
 import type { SavedInterview } from "@/lib/novel/story-simulation/interview-store"
@@ -35,6 +40,12 @@ export type SimulationPhase =
   | "draft-generating"
   | "draft-viewing"
 
+export interface SimulationPreset {
+  intent: string
+  userInput: string
+  hasFramework: boolean
+}
+
 export interface StorySimulationState {
   phase: SimulationPhase
   mode: SimulationMode
@@ -77,6 +88,20 @@ export interface StorySimulationState {
   compareWithResultId: string | null
   /** 当前续聊的采访ID(用于保存时判断覆盖/另存) */
   continuingInterviewId: string | null
+  /** LLM 预生成的动态事件池(支持字符串数组或分阶段池) */
+  dynamicEventPool: string[] | StagedEventPool
+  /** 已使用的事件索引 */
+  usedEventIndices: Set<number>
+  /** 是否启用导演 Agent */
+  directorEnabled: boolean
+  /** 当前所有传闻 */
+  currentRumors: RumorEvent[]
+  /** 当前所有角色快照 */
+  currentAgents: Map<string, NovelAgent>
+  /** 仿真分支列表 */
+  branches: SimulationBranch[]
+  /** 当前激活的分支 ID */
+  activeBranchId: string | null
 
   setPhase: (phase: SimulationPhase) => void
   setMode: (mode: SimulationMode) => void
@@ -110,7 +135,26 @@ export interface StorySimulationState {
   setContinuingInterviewId: (id: string | null) => void
   /** 设置采访消息列表 */
   setAgentChatMessages: (messages: AgentChatMessage[]) => void
+  /** 设置动态事件池 */
+  setDynamicEventPool: (pool: string[] | StagedEventPool) => void
+  /** 设置是否启用导演 Agent */
+  setDirectorEnabled: (enabled: boolean) => void
+  /** 设置当前传闻列表 */
+  setCurrentRumors: (rumors: RumorEvent[]) => void
+  /** 设置当前角色快照 */
+  setCurrentAgents: (agents: Map<string, NovelAgent>) => void
+  /** 保存当前状态为分支 */
+  saveCurrentAsBranch: (name: string) => void
+  /** 删除分支 */
+  deleteBranch: (id: string) => void
+  /** 重命名分支 */
+  renameBranch: (id: string, name: string) => void
+  /** 切换到指定分支 */
+  switchToBranch: (id: string) => void
+  /** 清空所有分支 */
+  clearBranches: () => void
   reset: () => void
+  initWithPreset: (preset: SimulationPreset) => void
 }
 
 export const useStorySimulationStore = create<StorySimulationState>((set) => ({
@@ -142,6 +186,13 @@ export const useStorySimulationStore = create<StorySimulationState>((set) => ({
   viewingInterview: null,
   compareWithResultId: null,
   continuingInterviewId: null,
+  dynamicEventPool: [],
+  usedEventIndices: new Set(),
+  directorEnabled: false,
+  currentRumors: [],
+  currentAgents: new Map(),
+  branches: [],
+  activeBranchId: null,
 
   setPhase: (phase) => set({ phase }),
   setMode: (mode) => set({ mode }),
@@ -177,6 +228,86 @@ export const useStorySimulationStore = create<StorySimulationState>((set) => ({
   setCompareWithResultId: (compareWithResultId) => set({ compareWithResultId }),
   setContinuingInterviewId: (continuingInterviewId) => set({ continuingInterviewId }),
   setAgentChatMessages: (agentChatMessages) => set({ agentChatMessages }),
+  setDynamicEventPool: (dynamicEventPool) => set({ dynamicEventPool }),
+  setDirectorEnabled: (directorEnabled) => set({ directorEnabled }),
+  setCurrentRumors: (currentRumors) => set({ currentRumors }),
+  setCurrentAgents: (currentAgents) => set({ currentAgents }),
+
+  saveCurrentAsBranch: (name) =>
+    set((state) => {
+      if (state.branches.length >= 10) return state
+      if (!state.currentFramework) return state
+
+      const activeAgentCount = state.currentAgents.size
+      const totalAgentCount = Math.max(activeAgentCount, state.currentFramework.nodes.reduce(
+        (acc, node) => Math.max(acc, node.involvedCharacters.length),
+        0,
+      ))
+
+      const finalAgentSnapshots = Array.from(state.currentAgents.values()).map((agent) => ({
+        agentId: agent.characterId,
+        name: agent.name,
+        knownSecrets: Array.from(agent.memory.knownSecrets),
+        sentiments: Array.from(agent.memory.sentiments.entries()) as [string, number][],
+      }))
+
+      const { overallScore, details } = calculateBranchScore(
+        [],
+        state.timelineEvents.length,
+        activeAgentCount,
+        totalAgentCount,
+        0.6,
+      )
+
+      const newBranch: SimulationBranch = {
+        id: `branch_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
+        name,
+        frameworkId: state.currentFramework.id,
+        mode: state.mode,
+        createdAt: new Date().toISOString(),
+        timelineEvents: [...state.timelineEvents],
+        rumors: [...state.currentRumors],
+        finalAgentSnapshots,
+        directorEvaluations: [],
+        overallScore,
+        scoreDetails: details,
+      }
+
+      return {
+        branches: [...state.branches, newBranch],
+      }
+    }),
+
+  deleteBranch: (id) =>
+    set((state) => ({
+      branches: state.branches.filter((b) => b.id !== id),
+      activeBranchId: state.activeBranchId === id ? null : state.activeBranchId,
+    })),
+
+  renameBranch: (id, name) =>
+    set((state) => ({
+      branches: state.branches.map((b) =>
+        b.id === id ? { ...b, name } : b,
+      ),
+    })),
+
+  switchToBranch: (id) =>
+    set((state) => {
+      const branch = state.branches.find((b) => b.id === id)
+      if (!branch) return state
+      return {
+        timelineEvents: [...branch.timelineEvents],
+        currentRumors: [...branch.rumors],
+        activeBranchId: id,
+      }
+    }),
+
+  clearBranches: () =>
+    set({
+      branches: [],
+      activeBranchId: null,
+    }),
+
   reset: () =>
     set({
       phase: "idle",
@@ -198,5 +329,58 @@ export const useStorySimulationStore = create<StorySimulationState>((set) => ({
       viewingInterview: null,
       compareWithResultId: null,
       continuingInterviewId: null,
+      dynamicEventPool: [],
+      usedEventIndices: new Set(),
+      directorEnabled: false,
+      currentRumors: [],
+      currentAgents: new Map(),
+      branches: [],
+      activeBranchId: null,
+    }),
+  initWithPreset: (preset) =>
+    set((state) => {
+      let phase: SimulationPhase = "configuring"
+
+      if (preset.intent === "story_framework_generate") {
+        phase = "configuring"
+      } else if (preset.intent === "multi_agent_simulate") {
+        phase = preset.hasFramework ? "simulating" : "configuring"
+      } else if (preset.intent === "character_interview") {
+        phase = preset.hasFramework && state.savedResults.length > 0 ? "report-viewing" : "configuring"
+      }
+
+      return {
+        userIdea: preset.userInput,
+        phase,
+      }
     }),
 }))
+
+export function calculateBranchScore(
+  directorEvaluations: DirectorEvaluation[],
+  eventCount: number,
+  activeAgentCount: number,
+  totalAgentCount: number,
+  goalProgress: number = 0.6,
+): { overallScore: number; details: { avgDirectorScore: number; eventCount: number; characterDiversity: number; plotProgression: number } } {
+  const avgDirectorScore = directorEvaluations.length > 0
+    ? directorEvaluations.reduce((sum, e) => sum + e.totalScore, 0) / directorEvaluations.length
+    : 3.0
+
+  const eventScore = Math.min(5, eventCount / 4) * 0.2
+  const charScore = (activeAgentCount / Math.max(1, totalAgentCount)) * 5 * 0.15
+  const plotScore = goalProgress * 5 * 0.15
+  const directorScorePart = avgDirectorScore * 0.5
+
+  const overallScore = Math.round((directorScorePart + eventScore + charScore + plotScore) * 10) / 10
+
+  return {
+    overallScore,
+    details: {
+      avgDirectorScore: Math.round(avgDirectorScore * 10) / 10,
+      eventCount,
+      characterDiversity: Math.round((activeAgentCount / Math.max(1, totalAgentCount)) * 100) / 100,
+      plotProgression: Math.round(goalProgress * 100) / 100,
+    },
+  }
+}