Преглед изворни кода

feat: 完成4种仿真模式差异化、采访持久化、导入进度条功能

Mochocyang пре 3 месеци
родитељ
комит
7abbf3489d

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

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

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

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

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

@@ -371,6 +371,7 @@ function StorySimulationSidebarPanel() {
         report: r.report,
         draft: r.draft,
         timelineEvents: r.timelineEvents,
+        agentSnapshot: r.agentSnapshot,
         createdAt: r.report.createdAt,
       })))
     } catch {

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

@@ -33,6 +33,7 @@ export function FrameworkBindingDialog({
   onBound,
 }: FrameworkBindingDialogProps) {
   const projectPath = useWikiStore((s) => s.project?.path)
+  const bumpBindingVersion = useWikiStore((s) => s.bumpBindingVersion)
   const binding = useStorySimulationStore((s) => s.binding)
   const setBinding = useStorySimulationStore((s) => s.setBinding)
 
@@ -57,6 +58,7 @@ export function FrameworkBindingDialog({
     try {
       const updated = await saveBinding(projectPath, framework, chapterCount)
       setBinding(updated)
+      bumpBindingVersion()
       onBound()
       onOpenChange(false)
     } catch (err) {
@@ -74,6 +76,7 @@ export function FrameworkBindingDialog({
     try {
       await clearBinding(projectPath)
       setBinding(null)
+      bumpBindingVersion()
       onBound()
       onOpenChange(false)
     } catch (err) {

+ 358 - 65
src/components/novel/story-simulation/framework-confirm-panel.tsx

@@ -1,8 +1,10 @@
 import { useState } from "react"
 import { useTranslation } from "react-i18next"
-import { Check } from "lucide-react"
+import { Check, Pencil, X } from "lucide-react"
 
 import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Textarea } from "@/components/ui/textarea"
 import { useStorySimulationStore } from "@/stores/story-simulation-store"
 import type { StoryNode } from "@/lib/novel/story-simulation/types"
 import { cn } from "@/lib/utils"
@@ -28,7 +30,13 @@ export function FrameworkConfirmPanel({
 }: FrameworkConfirmPanelProps) {
   const { t } = useTranslation()
   const currentFramework = useStorySimulationStore((s) => s.currentFramework)
+  const setCurrentFramework = useStorySimulationStore((s) => s.setCurrentFramework)
   const [savedTip, setSavedTip] = useState(false)
+  const [editingTitle, setEditingTitle] = useState(false)
+  const [editingPremise, setEditingPremise] = useState(false)
+  const [titleDraft, setTitleDraft] = useState("")
+  const [shortTitleDraft, setShortTitleDraft] = useState("")
+  const [premiseDraft, setPremiseDraft] = useState("")
 
   if (!currentFramework) return null
 
@@ -39,41 +47,175 @@ export function FrameworkConfirmPanel({
     setTimeout(() => setSavedTip(false), 2000)
   }
 
+  const startEditTitle = () => {
+    setTitleDraft(currentFramework.title)
+    setShortTitleDraft(currentFramework.shortTitle || "")
+    setEditingTitle(true)
+  }
+
+  const saveTitle = () => {
+    if (!titleDraft.trim()) return
+    setCurrentFramework({
+      ...currentFramework,
+      title: titleDraft.trim(),
+      shortTitle: shortTitleDraft.trim() || undefined,
+    })
+    setEditingTitle(false)
+  }
+
+  const cancelEditTitle = () => {
+    setEditingTitle(false)
+  }
+
+  const startEditPremise = () => {
+    setPremiseDraft(currentFramework.premise)
+    setEditingPremise(true)
+  }
+
+  const savePremise = () => {
+    setCurrentFramework({
+      ...currentFramework,
+      premise: premiseDraft,
+    })
+    setEditingPremise(false)
+  }
+
+  const cancelEditPremise = () => {
+    setEditingPremise(false)
+  }
+
+  const updateNode = (nodeIndex: number, updates: Partial<StoryNode>) => {
+    setCurrentFramework({
+      ...currentFramework,
+      nodes: currentFramework.nodes.map((n) =>
+        n.index === nodeIndex ? { ...n, ...updates } : n,
+      ),
+    })
+  }
+
   return (
     <div className="flex flex-col gap-4">
       {/* 顶部:标题 + 操作按钮 */}
       <div className="flex items-center justify-between gap-2">
-        <h3 className="text-lg font-semibold">
-          {t("storySimulation.frameworkTitle")}
-        </h3>
-        <div className="flex items-center gap-2">
-          <Button variant="outline" onClick={onRegenerate}>
-            {t("storySimulation.regenerateFramework")}
-          </Button>
-          {onSave && (
-            <Button variant="outline" onClick={handleSave}>
-              {savedTip ? (
-                <>
-                  <Check className="mr-1 h-4 w-4 text-emerald-500" />
-                  已保存
-                </>
-              ) : (
-                "保存框架"
+        <div className="flex min-w-0 flex-1 items-center gap-2">
+          {editingTitle ? (
+            <div className="flex flex-1 flex-wrap items-center gap-2">
+              <Input
+                value={titleDraft}
+                onChange={(e) => setTitleDraft(e.target.value)}
+                placeholder="框架标题"
+                className="h-8 flex-1 min-w-[200px] text-base font-semibold"
+                autoFocus
+                onKeyDown={(e) => {
+                  if (e.key === "Enter") saveTitle()
+                  if (e.key === "Escape") cancelEditTitle()
+                }}
+              />
+              <Input
+                value={shortTitleDraft}
+                onChange={(e) => setShortTitleDraft(e.target.value)}
+                placeholder="短标题(可选)"
+                className="h-8 w-32 text-sm"
+                onKeyDown={(e) => {
+                  if (e.key === "Enter") saveTitle()
+                  if (e.key === "Escape") cancelEditTitle()
+                }}
+              />
+              <Button size="sm" variant="ghost" className="h-8 w-8 p-0" onClick={saveTitle}>
+                <Check className="h-4 w-4 text-emerald-500" />
+              </Button>
+              <Button size="sm" variant="ghost" className="h-8 w-8 p-0" onClick={cancelEditTitle}>
+                <X className="h-4 w-4 text-muted-foreground" />
+              </Button>
+            </div>
+          ) : (
+            <>
+              <h3 className="truncate text-lg font-semibold">
+                {currentFramework.title}
+              </h3>
+              {currentFramework.shortTitle && (
+                <span className="shrink-0 rounded bg-primary/10 px-1.5 py-0.5 text-xs text-primary">
+                  {currentFramework.shortTitle}
+                </span>
               )}
-            </Button>
+              <Button
+                size="sm"
+                variant="ghost"
+                className="h-7 w-7 p-0 opacity-50 hover:opacity-100"
+                onClick={startEditTitle}
+                title="编辑标题"
+              >
+                <Pencil className="h-3.5 w-3.5" />
+              </Button>
+            </>
           )}
-          <Button onClick={onConfirm}>
-            {t("storySimulation.confirmFramework")}
-          </Button>
         </div>
+        {!editingTitle && (
+          <div className="flex shrink-0 items-center gap-2">
+            <Button variant="outline" onClick={onRegenerate}>
+              {t("storySimulation.regenerateFramework")}
+            </Button>
+            {onSave && (
+              <Button variant="outline" onClick={handleSave}>
+                {savedTip ? (
+                  <>
+                    <Check className="mr-1 h-4 w-4 text-emerald-500" />
+                    已保存
+                  </>
+                ) : (
+                  "保存框架"
+                )}
+              </Button>
+            )}
+            <Button onClick={onConfirm}>
+              {t("storySimulation.confirmFramework")}
+            </Button>
+          </div>
+        )}
       </div>
 
-      {/* 前提区 */}
+      {/* 前提区 - 支持编辑 */}
       <div className="rounded-lg bg-muted p-4">
-        <div className="text-sm font-medium text-muted-foreground">
-          {t("storySimulation.frameworkPremise")}
+        <div className="mb-2 flex items-center justify-between">
+          <div className="text-sm font-medium text-muted-foreground">
+            {t("storySimulation.frameworkPremise")}
+          </div>
+          {!editingPremise && (
+            <Button
+              size="sm"
+              variant="ghost"
+              className="h-6 w-6 p-0 opacity-50 hover:opacity-100"
+              onClick={startEditPremise}
+              title="编辑前提"
+            >
+              <Pencil className="h-3 w-3" />
+            </Button>
+          )}
         </div>
-        <p className="mt-1 text-sm leading-relaxed">{currentFramework.premise}</p>
+        {editingPremise ? (
+          <div className="space-y-2">
+            <Textarea
+              value={premiseDraft}
+              onChange={(e) => setPremiseDraft(e.target.value)}
+              rows={3}
+              className="text-sm"
+              autoFocus
+            />
+            <div className="flex justify-end gap-2">
+              <Button size="sm" variant="ghost" onClick={cancelEditPremise}>
+                取消
+              </Button>
+              <Button size="sm" onClick={savePremise}>
+                <Check className="mr-1 h-3.5 w-3.5" />
+                保存
+              </Button>
+            </div>
+          </div>
+        ) : (
+          <p className="text-sm leading-relaxed whitespace-pre-wrap">
+            {currentFramework.premise || "(无前提)"}
+          </p>
+        )}
       </div>
 
       {/* 节点列表 */}
@@ -81,59 +223,210 @@ export function FrameworkConfirmPanel({
         <div className="text-sm font-medium text-muted-foreground">
           {t("storySimulation.frameworkNodes")}
         </div>
-        {currentFramework.nodes.map((node) => (
-          <FrameworkNodeCard key={node.index} node={node} />
-        ))}
+        {currentFramework.nodes
+          .slice()
+          .sort((a, b) => a.index - b.index)
+          .map((node) => (
+            <FrameworkNodeCard
+              key={node.index}
+              node={node}
+              onUpdate={(updates) => updateNode(node.index, updates)}
+            />
+          ))}
       </div>
     </div>
   )
 }
 
-function FrameworkNodeCard({ node }: { node: StoryNode }) {
+function FrameworkNodeCard({
+  node,
+  onUpdate,
+}: {
+  node: StoryNode
+  onUpdate: (updates: Partial<StoryNode>) => void
+}) {
   const { t } = useTranslation()
+  const [editing, setEditing] = useState(false)
+  const [draft, setDraft] = useState<Omit<StoryNode, "involvedCharacters"> & { involvedCharacters: string }>({
+    ...node,
+    involvedCharacters: node.involvedCharacters.join("、"),
+  })
+
+  const startEdit = () => {
+    setDraft({
+      ...node,
+      involvedCharacters: node.involvedCharacters.join("、"),
+    })
+    setEditing(true)
+  }
+
+  const save = () => {
+    onUpdate({
+      title: draft.title.trim() || node.title,
+      coreConflict: draft.coreConflict,
+      involvedCharacters: String(draft.involvedCharacters)
+        .split(/[,,、]/)
+        .map((s) => s.trim())
+        .filter(Boolean),
+      goal: draft.goal,
+      causeFromPrev: draft.causeFromPrev,
+      expectedOutcome: draft.expectedOutcome,
+    })
+    setEditing(false)
+  }
+
+  const cancel = () => {
+    setEditing(false)
+  }
+
+  const involvedCharsStr = String(draft.involvedCharacters)
 
   return (
     <div className="rounded-lg border p-4">
       {/* 阶段标签 + 节点标题 */}
-      <div className="mb-3 flex items-center gap-2">
-        <span
-          className={cn(
-            "rounded px-1.5 py-0.5 text-xs font-medium",
-            PHASE_STYLES[node.phase]
+      <div className="mb-3 flex items-center justify-between gap-2">
+        <div className="flex min-w-0 items-center gap-2">
+          <span
+            className={cn(
+              "shrink-0 rounded px-1.5 py-0.5 text-xs font-medium",
+              PHASE_STYLES[node.phase]
+            )}
+          >
+            {node.phase}
+          </span>
+          {editing ? (
+            <Input
+              value={draft.title}
+              onChange={(e) => setDraft({ ...draft, title: e.target.value })}
+              className="h-7 flex-1 text-sm font-medium"
+              autoFocus
+              onKeyDown={(e) => {
+                if (e.key === "Enter") save()
+                if (e.key === "Escape") cancel()
+              }}
+            />
+          ) : (
+            <span className="truncate font-medium">{node.title}</span>
           )}
-        >
-          {node.phase}
-        </span>
-        <span className="font-medium">{node.title}</span>
+        </div>
+        {!editing ? (
+          <Button
+            size="sm"
+            variant="ghost"
+            className="h-7 w-7 p-0 opacity-50 hover:opacity-100"
+            onClick={startEdit}
+            title="编辑节点"
+          >
+            <Pencil className="h-3.5 w-3.5" />
+          </Button>
+        ) : (
+          <div className="flex shrink-0 items-center gap-1">
+            <Button size="sm" variant="ghost" className="h-7 w-7 p-0" onClick={save}>
+              <Check className="h-4 w-4 text-emerald-500" />
+            </Button>
+            <Button size="sm" variant="ghost" className="h-7 w-7 p-0" onClick={cancel}>
+              <X className="h-4 w-4 text-muted-foreground" />
+            </Button>
+          </div>
+        )}
       </div>
 
-      {/* 节点字段:dl/dt/dd 网格布局 */}
-      <dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-sm">
-        <dt className="whitespace-nowrap text-muted-foreground">
-          {t("storySimulation.coreConflict")}
-        </dt>
-        <dd className="leading-relaxed">{node.coreConflict}</dd>
-
-        <dt className="whitespace-nowrap text-muted-foreground">
-          {t("storySimulation.involvedCharacters")}
-        </dt>
-        <dd className="leading-relaxed">{node.involvedCharacters.join("、")}</dd>
-
-        <dt className="whitespace-nowrap text-muted-foreground">
-          {t("storySimulation.goal")}
-        </dt>
-        <dd className="leading-relaxed">{node.goal}</dd>
-
-        <dt className="whitespace-nowrap text-muted-foreground">
-          {t("storySimulation.cause")}
-        </dt>
-        <dd className="leading-relaxed">{node.causeFromPrev}</dd>
-
-        <dt className="whitespace-nowrap text-muted-foreground">
-          {t("storySimulation.expectedOutcome")}
-        </dt>
-        <dd className="leading-relaxed">{node.expectedOutcome}</dd>
-      </dl>
+      {/* 节点字段 */}
+      {editing ? (
+        <div className="space-y-3 text-sm">
+          <FieldEdit label={t("storySimulation.coreConflict")}>
+            <Textarea
+              value={draft.coreConflict}
+              onChange={(e) => setDraft({ ...draft, coreConflict: e.target.value })}
+              rows={2}
+              className="text-sm"
+            />
+          </FieldEdit>
+          <FieldEdit label={t("storySimulation.involvedCharacters")}>
+            <Input
+              value={involvedCharsStr}
+              onChange={(e) =>
+                setDraft({
+                  ...draft,
+                  involvedCharacters: e.target.value,
+                })
+              }
+              placeholder="用逗号或顿号分隔"
+              className="h-8 text-sm"
+            />
+          </FieldEdit>
+          <FieldEdit label={t("storySimulation.goal")}>
+            <Textarea
+              value={draft.goal}
+              onChange={(e) => setDraft({ ...draft, goal: e.target.value })}
+              rows={2}
+              className="text-sm"
+            />
+          </FieldEdit>
+          <FieldEdit label={t("storySimulation.cause")}>
+            <Textarea
+              value={draft.causeFromPrev}
+              onChange={(e) => setDraft({ ...draft, causeFromPrev: e.target.value })}
+              rows={2}
+              className="text-sm"
+            />
+          </FieldEdit>
+          <FieldEdit label={t("storySimulation.expectedOutcome")}>
+            <Textarea
+              value={draft.expectedOutcome}
+              onChange={(e) => setDraft({ ...draft, expectedOutcome: e.target.value })}
+              rows={2}
+              className="text-sm"
+            />
+          </FieldEdit>
+        </div>
+      ) : (
+        <dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-sm">
+          <dt className="whitespace-nowrap text-muted-foreground">
+            {t("storySimulation.coreConflict")}
+          </dt>
+          <dd className="leading-relaxed">{node.coreConflict}</dd>
+
+          <dt className="whitespace-nowrap text-muted-foreground">
+            {t("storySimulation.involvedCharacters")}
+          </dt>
+          <dd className="leading-relaxed">
+            {Array.isArray(node.involvedCharacters)
+              ? node.involvedCharacters.join("、")
+              : node.involvedCharacters}
+          </dd>
+
+          <dt className="whitespace-nowrap text-muted-foreground">
+            {t("storySimulation.goal")}
+          </dt>
+          <dd className="leading-relaxed">{node.goal}</dd>
+
+          <dt className="whitespace-nowrap text-muted-foreground">
+            {t("storySimulation.cause")}
+          </dt>
+          <dd className="leading-relaxed">{node.causeFromPrev}</dd>
+
+          <dt className="whitespace-nowrap text-muted-foreground">
+            {t("storySimulation.expectedOutcome")}
+          </dt>
+          <dd className="leading-relaxed">{node.expectedOutcome}</dd>
+        </dl>
+      )}
+    </div>
+  )
+}
+
+function FieldEdit({
+  label,
+  children,
+}: {
+  label: string
+  children: React.ReactNode
+}) {
+  return (
+    <div>
+      <div className="mb-1 text-xs text-muted-foreground">{label}</div>
+      {children}
     </div>
   )
 }

+ 194 - 52
src/components/novel/story-simulation/simulation-report-view.tsx

@@ -1,11 +1,11 @@
 import { useMemo, useState } from "react"
 import { useTranslation } from "react-i18next"
-import { MessageCircle, RefreshCw, Sparkles, TrendingUp, Network, Download } from "lucide-react"
+import { MessageCircle, RefreshCw, Sparkles, TrendingUp, Network, Download, ChevronDown, ChevronRight } from "lucide-react"
 import { Button } from "@/components/ui/button"
 import { useStorySimulationStore } from "@/stores/story-simulation-store"
 import { useWikiStore } from "@/stores/wiki-store"
 import { exportReport } from "@/lib/novel/story-simulation/report-export"
-import type { StoryBranch } from "@/lib/novel/story-simulation/types"
+import type { StoryBranch, TimelineEvent, StoryFramework } from "@/lib/novel/story-simulation/types"
 
 const PROBABILITY_COLORS: Record<string, string> = {
   high: "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300",
@@ -17,6 +17,8 @@ interface SimulationReportViewProps {
   onResimulate: () => void
   onGenerateDraft: (branch: StoryBranch) => void
   onInterviewAgent: (agentId: string, agentName: string) => void
+  onViewDraft?: () => void
+  hasDraft?: boolean
 }
 
 /** 将 actionType 映射为中文动词短语 */
@@ -49,6 +51,8 @@ export function SimulationReportView({
   onResimulate,
   onGenerateDraft,
   onInterviewAgent,
+  onViewDraft,
+  hasDraft,
 }: SimulationReportViewProps) {
   const { t } = useTranslation()
   const projectPath = useWikiStore((s) => s.project?.path)
@@ -153,6 +157,12 @@ export function SimulationReportView({
             <Download className="mr-1 h-3.5 w-3.5" />
             {exporting ? "导出中..." : "导出报告"}
           </Button>
+          {hasDraft && onViewDraft && (
+            <Button variant="default" size="sm" onClick={onViewDraft}>
+              <Sparkles className="mr-1 h-3.5 w-3.5" />
+              查看草稿
+            </Button>
+          )}
           <Button variant="outline" size="sm" onClick={onResimulate}>
             <RefreshCw className="mr-1 h-3.5 w-3.5" />
             {t("storySimulation.resimulate")}
@@ -173,57 +183,14 @@ export function SimulationReportView({
             </section>
           )}
 
-          {/* 关键剧情事件时间线 */}
+          {/* 关键剧情事件时间线 - 按节点分组折叠 */}
           {timelineEvents.length > 0 && (
-            <section>
-              <h3 className="mb-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
-                关键剧情事件
-              </h3>
-              <div className="space-y-2">
-                {timelineEvents.map((ev) => (
-                  <div
-                    key={ev.id}
-                    className="rounded-md border px-3 py-2 text-sm"
-                  >
-                    <div className="flex items-center gap-2">
-                      <span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-[11px] text-muted-foreground">
-                        节点{ev.nodeIndex + 1}·R{ev.round + 1}
-                      </span>
-                      <button
-                        type="button"
-                        className="font-medium text-primary hover:underline"
-                        onClick={() => onInterviewAgent(ev.actorId, ev.actorName)}
-                      >
-                        {ev.actorName}
-                      </button>
-                      <span className="text-xs text-muted-foreground">
-                        {actionLabel(ev.actionType)}
-                      </span>
-                      {ev.targetName && (
-                        <>
-                          <span className="text-xs text-muted-foreground">→</span>
-                          <button
-                            type="button"
-                            className="text-xs text-primary hover:underline"
-                            onClick={() => {
-                              const targetId = ev.targetId || nameToId.get(ev.targetName || "")
-                              if (targetId && ev.targetName) {
-                                onInterviewAgent(targetId, ev.targetName)
-                              }
-                            }}
-                          >
-                            {ev.targetName}
-                          </button>
-                        </>
-                      )}
-                    </div>
-                    <p className="mt-1 text-sm leading-relaxed text-foreground/90">
-                      {ev.content}
-                    </p>
-                  </div>
-                ))}
-              </div>
-            </section>
+            <TimelineGroupedEvents
+              events={timelineEvents}
+              framework={currentFramework}
+              nameToId={nameToId}
+              onInterviewAgent={onInterviewAgent}
+            />
           )}
 
           {/* 角色采访区 */}
@@ -535,3 +502,178 @@ function RelationshipGraph({ data }: { data: RelationshipGraphData }) {
     </div>
   )
 }
+
+// ── 按节点分组折叠的时间线组件 ──
+
+function TimelineGroupedEvents({
+  events,
+  framework,
+  nameToId,
+  onInterviewAgent,
+}: {
+  events: TimelineEvent[]
+  framework?: StoryFramework | null
+  nameToId: Map<string, string>
+  onInterviewAgent: (agentId: string, agentName: string) => void
+}) {
+  // 折叠状态:key = nodeIndex,value = 是否折叠
+  const [collapsedNodes, setCollapsedNodes] = useState<Set<number>>(new Set())
+
+  // 构建节点索引映射
+  const nodeMap = useMemo(() => {
+    const map = new Map<number, { title: string; phase: string }>()
+    if (framework) {
+      for (const node of framework.nodes) {
+        map.set(node.index, { title: node.title, phase: node.phase })
+      }
+    }
+    return map
+  }, [framework])
+
+  // 阶段中文标签
+  const phaseLabel = (phase: string): string => {
+    const map: Record<string, string> = { 起: "起", 承: "承", 转: "转", 合: "合" }
+    return map[phase] || phase
+  }
+
+  // 按节点分组事件
+  const groupedEvents = useMemo(() => {
+    const groups = new Map<number, TimelineEvent[]>()
+    for (const ev of events) {
+      const idx = ev.nodeIndex
+      if (!groups.has(idx)) groups.set(idx, [])
+      groups.get(idx)!.push(ev)
+    }
+    return Array.from(groups.entries())
+      .sort(([a], [b]) => a - b)
+      .map(([nodeIndex, evs]) => ({
+        nodeIndex,
+        nodeInfo: nodeMap.get(nodeIndex),
+        events: evs,
+      }))
+  }, [events, nodeMap])
+
+  const toggleNode = (idx: number) => {
+    setCollapsedNodes((prev) => {
+      const next = new Set(prev)
+      if (next.has(idx)) {
+        next.delete(idx)
+      } else {
+        next.add(idx)
+      }
+      return next
+    })
+  }
+
+  const expandAll = () => setCollapsedNodes(new Set())
+  const collapseAll = () => {
+    const allNodes = new Set(groupedEvents.map((g) => g.nodeIndex))
+    setCollapsedNodes(allNodes)
+  }
+
+  return (
+    <section>
+      <div className="mb-3 flex items-center justify-between">
+        <h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
+          关键剧情事件
+        </h3>
+        <div className="flex items-center gap-1 text-xs">
+          <button
+            type="button"
+            className="text-muted-foreground hover:text-foreground"
+            onClick={expandAll}
+          >
+            全部展开
+          </button>
+          <span className="text-muted-foreground">|</span>
+          <button
+            type="button"
+            className="text-muted-foreground hover:text-foreground"
+            onClick={collapseAll}
+          >
+            全部折叠
+          </button>
+        </div>
+      </div>
+      <div className="space-y-3">
+        {groupedEvents.map(({ nodeIndex, nodeInfo, events: nodeEvents }) => {
+          const isCollapsed = collapsedNodes.has(nodeIndex)
+          const phase = nodeInfo?.phase || "起"
+          const nodeTitle = nodeInfo?.title || `节点 ${nodeIndex + 1}`
+          return (
+            <div key={nodeIndex} className="rounded-md border bg-background/50">
+              {/* 节点标题栏 - 可点击折叠 */}
+              <button
+                type="button"
+                className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-accent/50"
+                onClick={() => toggleNode(nodeIndex)}
+              >
+                {isCollapsed ? (
+                  <ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
+                ) : (
+                  <ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
+                )}
+                <span className="rounded bg-primary/10 px-1.5 py-0.5 text-[11px] font-medium text-primary">
+                  {phaseLabel(phase)}
+                </span>
+                <span className="text-sm font-medium">
+                  节点 {nodeIndex + 1}:{nodeTitle}
+                </span>
+                <span className="ml-auto text-[11px] text-muted-foreground">
+                  {nodeEvents.length} 条事件
+                </span>
+              </button>
+              {/* 节点事件列表 */}
+              {!isCollapsed && (
+                <div className="space-y-2 border-t p-3">
+                  {nodeEvents.map((ev) => (
+                    <div
+                      key={ev.id}
+                      className="rounded-md border bg-muted/20 px-3 py-2 text-sm"
+                    >
+                      <div className="flex items-center gap-2">
+                        <span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-[11px] text-muted-foreground">
+                          R{ev.round + 1}
+                        </span>
+                        <button
+                          type="button"
+                          className="font-medium text-primary hover:underline"
+                          onClick={() => onInterviewAgent(ev.actorId, ev.actorName)}
+                        >
+                          {ev.actorName}
+                        </button>
+                        <span className="text-xs text-muted-foreground">
+                          {actionLabel(ev.actionType)}
+                        </span>
+                        {ev.targetName && (
+                          <>
+                            <span className="text-xs text-muted-foreground">→</span>
+                            <button
+                              type="button"
+                              className="text-xs text-primary hover:underline"
+                              onClick={() => {
+                                const targetId = ev.targetId || nameToId.get(ev.targetName || "")
+                                if (targetId && ev.targetName) {
+                                  onInterviewAgent(targetId, ev.targetName)
+                                }
+                              }}
+                            >
+                              {ev.targetName}
+                            </button>
+                          </>
+                        )}
+                      </div>
+                      <p className="mt-1 text-sm leading-relaxed text-foreground/90">
+                        {ev.content}
+                      </p>
+                    </div>
+                  ))}
+                </div>
+              )}
+            </div>
+          )
+        })}
+      </div>
+    </section>
+  )
+}

+ 319 - 2
src/components/novel/story-simulation/story-draft-view.tsx

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

+ 364 - 59
src/components/novel/story-simulation/story-simulation-view.tsx

@@ -1,6 +1,6 @@
-import { useEffect, useRef, useState } from "react"
+import { useEffect, useRef, useState, useMemo } from "react"
 import { useTranslation } from "react-i18next"
-import { Send, X, Download } from "lucide-react"
+import { Send, X, Download, ChevronDown, ChevronRight, Save, Loader2 } from "lucide-react"
 
 import { useWikiStore } from "@/stores/wiki-store"
 import { useStorySimulationStore } from "@/stores/story-simulation-store"
@@ -16,7 +16,9 @@ import { generateStoryDraft } from "@/lib/novel/story-simulation/story-draft-gen
 import { saveFramework, saveSimulationResult, loadSimulationResults } from "@/lib/novel/story-simulation/framework-store"
 import { resolveDefaultModel } from "@/lib/novel/model-resolver"
 import { interviewAgent } from "@/lib/novel/story-simulation/agent-interview"
+import { saveInterview } from "@/lib/novel/story-simulation/interview-store"
 import { exportInterview } from "@/lib/novel/story-simulation/interview-export"
+import { serializeSimulationState, deserializeSimulationSnapshot } from "@/lib/novel/story-simulation/simulation-serializer"
 import type {
   AgentChatMessage,
   ExtractionResult,
@@ -122,11 +124,14 @@ export function StorySimulationView() {
   const extractionResult = useStorySimulationStore((s) => s.extractionResult)
   const currentFramework = useStorySimulationStore((s) => s.currentFramework)
   const currentReport = useStorySimulationStore((s) => s.currentReport)
+  const currentDraft = useStorySimulationStore((s) => s.currentDraft)
   const error = useStorySimulationStore((s) => s.error)
   const progress = useStorySimulationStore((s) => s.progress)
   const progressLabel = useStorySimulationStore((s) => s.progressLabel)
   const timelineEvents = useStorySimulationStore((s) => s.timelineEvents)
   const activeChatAgent = useStorySimulationStore((s) => s.activeChatAgent)
+  const savedResults = useStorySimulationStore((s) => s.savedResults)
+  const selectedResultId = useStorySimulationStore((s) => s.selectedResultId)
 
   const setPhase = useStorySimulationStore((s) => s.setPhase)
   const setExtractionResult = useStorySimulationStore(
@@ -152,10 +157,15 @@ export function StorySimulationView() {
   const lastAgentsRef = useRef<NovelAgent[]>([])
   const lastSimulationStateRef = useRef<SimulationState | null>(null)
 
+  // 取消控制器
+  const abortControllerRef = useRef<AbortController | null>(null)
+  const [isCancelling, setIsCancelling] = useState(false)
+
   // 采访输入框
   const [chatInput, setChatInput] = useState("")
   const [chatSending, setChatSending] = useState(false)
   const [chatExporting, setChatExporting] = useState(false)
+  const [chatSaving, setChatSaving] = useState(false)
   const chatStreamRef = useRef("")
   const chatLogRef = useRef<HTMLDivElement | null>(null)
 
@@ -169,8 +179,32 @@ export function StorySimulationView() {
     }
   }, [agentChatMessages, activeChatAgent])
 
+  // 选择历史结果时,反序列化恢复agent状态,支持采访
+  useEffect(() => {
+    if (!selectedResultId) return
+    const result = savedResults.find(r => r.id === selectedResultId)
+    if (result?.agentSnapshot) {
+      try {
+        const { agents, state } = deserializeSimulationSnapshot(result.agentSnapshot)
+        lastAgentsRef.current = agents
+        lastSimulationStateRef.current = state
+      } catch (err) {
+        console.error("反序列化历史结果失败:", err)
+      }
+    }
+  }, [selectedResultId, savedResults])
+
   // ── 核心流程 ──
 
+  /** 取消当前正在进行的操作 */
+  const handleCancel = () => {
+    if (abortControllerRef.current) {
+      setIsCancelling(true)
+      setError("正在取消...")
+      abortControllerRef.current.abort()
+    }
+  }
+
   /** 提取内容并生成故事框架,进入框架确认阶段。 */
   const handleStart = async () => {
     if (!projectPath) {
@@ -235,6 +269,10 @@ export function StorySimulationView() {
     }
     setError(null)
     setTimelineEvents([])
+    setIsCancelling(false)
+    const ac = new AbortController()
+    abortControllerRef.current = ac
+
     try {
       // 若尚无提取结果(如从历史框架进入),先提取
       let extraction = extractionResult
@@ -292,8 +330,16 @@ export function StorySimulationView() {
         },
         extraction,
         callbacks,
+        ac.signal,
       )
 
+      if (ac.signal.aborted) {
+        setPhase("framework-confirming")
+        setError("推演已取消")
+        setTimeout(() => setError(null), 3000)
+        return
+      }
+
       // 保存仿真状态供采访使用
       lastSimulationStateRef.current = {
         currentRound: 0,
@@ -313,18 +359,32 @@ export function StorySimulationView() {
         llmConfig,
         onProgress: (label) =>
           setProgress(phaseBaseProgressRef.current, label),
+        signal: ac.signal,
       })
+
+      if (ac.signal.aborted) {
+        setPhase("framework-confirming")
+        setError("已取消")
+        setTimeout(() => setError(null), 3000)
+        return
+      }
+
       setCurrentReport(report)
       setPhase("report-viewing")
 
-      // 自动保存推演结果(包含时间线事件)
+      // 自动保存推演结果(包含时间线事件和agent快照)
       try {
+        const agentSnapshot = serializeSimulationState(
+          lastSimulationStateRef.current!,
+          lastAgentsRef.current,
+        )
         await saveSimulationResult(
           projectPath,
           currentFramework.id,
           report,
           undefined,
-          timelineEvents,
+          collectedTimeline,
+          agentSnapshot,
         )
         // 刷新历史结果列表
         const results = await loadSimulationResults(projectPath, currentFramework.id)
@@ -334,14 +394,24 @@ export function StorySimulationView() {
           report: r.report,
           draft: r.draft,
           timelineEvents: r.timelineEvents,
+          agentSnapshot: r.agentSnapshot,
           createdAt: r.report.createdAt,
         })))
       } catch (saveErr) {
         console.error("保存推演结果失败:", saveErr)
       }
     } catch (err) {
-      setError(err instanceof Error ? err.message : String(err))
-      setPhase("framework-confirming")
+      if (ac.signal.aborted) {
+        setPhase("framework-confirming")
+        setError("推演已取消")
+        setTimeout(() => setError(null), 3000)
+      } else {
+        setError(err instanceof Error ? err.message : String(err))
+        setPhase("framework-confirming")
+      }
+    } finally {
+      setIsCancelling(false)
+      abortControllerRef.current = null
     }
   }
 
@@ -364,6 +434,10 @@ export function StorySimulationView() {
       return
     }
     setError(null)
+    setIsCancelling(false)
+    const ac = new AbortController()
+    abortControllerRef.current = ac
+
     try {
       setPhase("draft-generating")
       phaseBaseProgressRef.current = 90
@@ -376,18 +450,31 @@ export function StorySimulationView() {
         llmConfig,
         onProgress: (label) =>
           setProgress(phaseBaseProgressRef.current, label),
+        signal: ac.signal,
       })
+
+      if (ac.signal.aborted) {
+        setPhase("report-viewing")
+        setError("草稿生成已取消")
+        setTimeout(() => setError(null), 3000)
+        return
+      }
+
       setCurrentDraft(draft)
       setPhase("draft-viewing")
 
       // 更新保存的推演结果,添加草稿
       try {
+        const agentSnapshot = lastSimulationStateRef.current
+          ? serializeSimulationState(lastSimulationStateRef.current, lastAgentsRef.current)
+          : undefined
         await saveSimulationResult(
           projectPath,
           currentFramework.id,
           currentReport,
           draft,
           timelineEvents,
+          agentSnapshot,
         )
         // 刷新历史结果列表
         const results = await loadSimulationResults(projectPath, currentFramework.id)
@@ -397,14 +484,24 @@ export function StorySimulationView() {
           report: r.report,
           draft: r.draft,
           timelineEvents: r.timelineEvents,
+          agentSnapshot: r.agentSnapshot,
           createdAt: r.report.createdAt,
         })))
       } catch (saveErr) {
         console.error("更新推演结果草稿失败:", saveErr)
       }
     } catch (err) {
-      setError(err instanceof Error ? err.message : String(err))
-      setPhase("report-viewing")
+      if (ac.signal.aborted) {
+        setPhase("report-viewing")
+        setError("草稿生成已取消")
+        setTimeout(() => setError(null), 3000)
+      } else {
+        setError(err instanceof Error ? err.message : String(err))
+        setPhase("report-viewing")
+      }
+    } finally {
+      setIsCancelling(false)
+      abortControllerRef.current = null
     }
   }
 
@@ -413,6 +510,13 @@ export function StorySimulationView() {
     setPhase("report-viewing")
   }
 
+  /** 从报告视图进入草稿视图。 */
+  const handleViewDraft = () => {
+    if (currentDraft) {
+      setPhase("draft-viewing")
+    }
+  }
+
   /** 打开 Agent 采访面板。 */
   const handleInterviewAgent = (agentId: string, agentName: string) => {
     clearAgentChat()
@@ -443,6 +547,30 @@ export function StorySimulationView() {
     }
   }
 
+  /** 保存采访对话到项目。 */
+  const handleSaveChat = async () => {
+    if (!activeChatAgent || !projectPath || agentChatMessages.length === 0) return
+    setChatSaving(true)
+    try {
+      const session = {
+        agentId: activeChatAgent.id,
+        agentName: activeChatAgent.name,
+        messages: agentChatMessages,
+      }
+      await saveInterview(projectPath, session, {
+        frameworkId: currentFramework?.id,
+        frameworkTitle: currentFramework?.title,
+      })
+      setError(`采访对话已保存(${agentChatMessages.length}条消息)`)
+      setTimeout(() => setError(null), 3000)
+    } catch (err) {
+      setError(err instanceof Error ? err.message : "保存失败")
+      setTimeout(() => setError(null), 5000)
+    } finally {
+      setChatSaving(false)
+    }
+  }
+
   /** 发送采访消息。 */
   const handleSendChat = async () => {
     const text = chatInput.trim()
@@ -569,12 +697,18 @@ export function StorySimulationView() {
           <ProgressPanel
             progress={progress}
             label={progressLabel || progressTitle}
+            onCancel={handleCancel}
+            cancelling={isCancelling}
           />
         ) : phase === "simulating" ? (
           <SimulatingTimelinePanel
             progress={progress}
             label={progressLabel || progressTitle}
             events={timelineEvents}
+            framework={currentFramework}
+            onInterviewAgent={(id, name) => handleInterviewAgent(id, name)}
+            onCancel={handleCancel}
+            cancelling={isCancelling}
           />
         ) : phase === "framework-confirming" ? (
           <div className="flex-1 overflow-y-auto p-4">
@@ -591,6 +725,8 @@ export function StorySimulationView() {
                 onResimulate={handleResimulate}
                 onGenerateDraft={(branch) => void handleGenerateDraft(branch)}
                 onInterviewAgent={(id, name) => handleInterviewAgent(id, name)}
+                onViewDraft={handleViewDraft}
+                hasDraft={!!currentDraft}
               />
             </div>
             {activeChatAgent && (
@@ -602,8 +738,10 @@ export function StorySimulationView() {
                 onSend={() => void handleSendChat()}
                 onClose={handleCloseChat}
                 onExport={() => void handleExportChat()}
+                onSave={() => void handleSaveChat()}
                 sending={chatSending}
                 exporting={chatExporting}
+                saving={chatSaving}
                 chatLogRef={chatLogRef}
               />
             )}
@@ -620,13 +758,17 @@ export function StorySimulationView() {
   )
 }
 
-/** 进度展示面板:文字 + 进度条。 */
+/** 进度展示面板:文字 + 进度条 + 取消按钮。 */
 function ProgressPanel({
   progress,
   label,
+  onCancel,
+  cancelling,
 }: {
   progress: number
   label: string
+  onCancel?: () => void
+  cancelling?: boolean
 }) {
   const clamped = Math.min(100, Math.max(0, progress))
   return (
@@ -639,21 +781,39 @@ function ProgressPanel({
         />
       </div>
       <div className="text-xs text-muted-foreground">{clamped}%</div>
+      {onCancel && (
+        <Button
+          type="button"
+          variant="outline"
+          size="sm"
+          onClick={onCancel}
+          disabled={cancelling}
+          className="mt-2"
+        >
+          {cancelling ? "正在取消..." : "取消"}
+        </Button>
+      )}
     </div>
   )
 }
 
-/** 仿真中面板:进度条 + 实时时间线事件流(带筛选)。 */
+/** 仿真中面板:进度条 + 实时时间线事件流(按节点分组折叠,带筛选)。 */
 function SimulatingTimelinePanel({
   progress,
   label,
   events,
+  framework,
   onInterviewAgent,
+  onCancel,
+  cancelling,
 }: {
   progress: number
   label: string
   events: TimelineEvent[]
+  framework?: StoryFramework | null
   onInterviewAgent?: (agentId: string, agentName: string) => void
+  onCancel?: () => void
+  cancelling?: boolean
 }) {
   const clamped = Math.min(100, Math.max(0, progress))
   const logRef = useRef<HTMLDivElement | null>(null)
@@ -661,23 +821,69 @@ function SimulatingTimelinePanel({
   // 筛选状态
   const [filterActor, setFilterActor] = useState<string>("all")
   const [filterType, setFilterType] = useState<string>("all")
+  // 折叠状态:key = nodeIndex,value = 是否折叠
+  const [collapsedNodes, setCollapsedNodes] = useState<Set<number>>(new Set())
 
   // 从事件中提取所有角色和行动类型
-  const actors = Array.from(new Set(events.map((e) => e.actorName))).sort()
-  const actionTypes = Array.from(new Set(events.map((e) => e.actionType))).sort()
+  const actors = useMemo(
+    () => Array.from(new Set(events.map((e) => e.actorName))).sort(),
+    [events],
+  )
+  const actionTypes = useMemo(
+    () => Array.from(new Set(events.map((e) => e.actionType))).sort(),
+    [events],
+  )
 
-  // 过滤事件
-  const filteredEvents = events.filter((e) => {
-    if (filterActor !== "all" && e.actorName !== filterActor) return false
-    if (filterType !== "all" && e.actionType !== filterType) return false
-    return true
-  })
+  // 构建节点索引映射
+  const nodeMap = useMemo(() => {
+    const map = new Map<number, { title: string; phase: string }>()
+    if (framework) {
+      for (const node of framework.nodes) {
+        map.set(node.index, { title: node.title, phase: node.phase })
+      }
+    }
+    return map
+  }, [framework])
+
+  // 阶段中文标签
+  const phaseLabel = (phase: string): string => {
+    const map: Record<string, string> = { 起: "起", 承: "承", 转: "转", 合: "合" }
+    return map[phase] || phase
+  }
+
+  // 按节点分组事件
+  const groupedEvents = useMemo(() => {
+    // 先过滤事件
+    const filtered = events.filter((e) => {
+      if (filterActor !== "all" && e.actorName !== filterActor) return false
+      if (filterType !== "all" && e.actionType !== filterType) return false
+      return true
+    })
+    // 按 nodeIndex 分组
+    const groups = new Map<number, TimelineEvent[]>()
+    for (const ev of filtered) {
+      const idx = ev.nodeIndex
+      if (!groups.has(idx)) groups.set(idx, [])
+      groups.get(idx)!.push(ev)
+    }
+    // 按节点索引排序
+    return Array.from(groups.entries())
+      .sort(([a], [b]) => a - b)
+      .map(([nodeIndex, evs]) => ({
+        nodeIndex,
+        nodeInfo: nodeMap.get(nodeIndex),
+        events: evs,
+      }))
+  }, [events, filterActor, filterType, nodeMap])
+
+  // 计算过滤后的事件总数
+  const totalFiltered = groupedEvents.reduce((sum, g) => sum + g.events.length, 0)
 
   useEffect(() => {
     if (logRef.current) {
       logRef.current.scrollTop = logRef.current.scrollHeight
     }
-  }, [filteredEvents.length])
+  }, [totalFiltered])
 
   // 行动类型中文标签
   const actionTypeLabel = (type: string): string => {
@@ -700,6 +906,24 @@ function SimulatingTimelinePanel({
     return map[type] || type
   }
 
+  const toggleNode = (idx: number) => {
+    setCollapsedNodes((prev) => {
+      const next = new Set(prev)
+      if (next.has(idx)) {
+        next.delete(idx)
+      } else {
+        next.add(idx)
+      }
+      return next
+    })
+  }
+
+  const expandAll = () => setCollapsedNodes(new Set())
+  const collapseAll = () => {
+    const allNodes = new Set(groupedEvents.map((g) => g.nodeIndex))
+    setCollapsedNodes(allNodes)
+  }
+
   return (
     <div className="flex flex-1 flex-col p-6">
       <div className="mb-4 flex flex-col items-center gap-2">
@@ -710,7 +934,21 @@ function SimulatingTimelinePanel({
             style={{ width: `${clamped}%` }}
           />
         </div>
-        <div className="text-xs text-muted-foreground">{clamped}%</div>
+        <div className="flex items-center gap-3">
+          <div className="text-xs text-muted-foreground">{clamped}%</div>
+          {onCancel && (
+            <Button
+              type="button"
+              variant="outline"
+              size="sm"
+              onClick={onCancel}
+              disabled={cancelling}
+              className="h-7 text-xs"
+            >
+              {cancelling ? "正在取消..." : "取消推演"}
+            </Button>
+          )}
+        </div>
       </div>
 
       {/* 筛选栏 */}
@@ -749,9 +987,26 @@ function SimulatingTimelinePanel({
               清除筛选
             </button>
           )}
-          <span className="ml-auto text-muted-foreground">
-            显示 {filteredEvents.length}/{events.length} 条
-          </span>
+          <div className="ml-auto flex items-center gap-1">
+            <button
+              type="button"
+              className="text-xs text-muted-foreground hover:text-foreground"
+              onClick={expandAll}
+            >
+              全部展开
+            </button>
+            <span className="text-muted-foreground">|</span>
+            <button
+              type="button"
+              className="text-xs text-muted-foreground hover:text-foreground"
+              onClick={collapseAll}
+            >
+              全部折叠
+            </button>
+            <span className="ml-2 text-muted-foreground">
+              显示 {totalFiltered}/{events.length} 条
+            </span>
+          </div>
         </div>
       )}
 
@@ -759,51 +1014,86 @@ function SimulatingTimelinePanel({
         ref={logRef}
         className="flex-1 overflow-y-auto rounded-lg border bg-muted/30 p-3 text-sm"
       >
-        {filteredEvents.length === 0 ? (
+        {groupedEvents.length === 0 ? (
           <div className="py-8 text-center text-xs text-muted-foreground">
             {events.length === 0 ? "等待角色行动..." : "没有符合筛选条件的事件"}
           </div>
         ) : (
-          <div className="space-y-1.5">
-            {filteredEvents.map((ev) => (
-              <div key={ev.id} className="leading-relaxed">
-                <span className="mr-1 rounded bg-muted px-1 py-0.5 text-[10px] text-muted-foreground">
-                  {ev.nodeIndex + 1}-{ev.round + 1}
-                </span>
-                {onInterviewAgent ? (
+          <div className="space-y-3">
+            {groupedEvents.map(({ nodeIndex, nodeInfo, events: nodeEvents }) => {
+              const isCollapsed = collapsedNodes.has(nodeIndex)
+              const phase = nodeInfo?.phase || "起"
+              const nodeTitle = nodeInfo?.title || `节点 ${nodeIndex + 1}`
+              return (
+                <div key={nodeIndex} className="rounded-md border bg-background/50">
+                  {/* 节点标题栏 - 可点击折叠 */}
                   <button
                     type="button"
-                    className="font-medium text-primary hover:underline"
-                    onClick={() => onInterviewAgent(ev.actorId, ev.actorName)}
+                    className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-accent/50"
+                    onClick={() => toggleNode(nodeIndex)}
                   >
-                    {ev.actorName}
+                    {isCollapsed ? (
+                      <ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
+                    ) : (
+                      <ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
+                    )}
+                    <span className="rounded bg-primary/10 px-1.5 py-0.5 text-[11px] font-medium text-primary">
+                      {phaseLabel(phase)}
+                    </span>
+                    <span className="text-sm font-medium">
+                      节点 {nodeIndex + 1}:{nodeTitle}
+                    </span>
+                    <span className="ml-auto text-[11px] text-muted-foreground">
+                      {nodeEvents.length} 条事件
+                    </span>
                   </button>
-                ) : (
-                  <span className="font-medium">{ev.actorName}</span>
-                )}
-                <span className="text-muted-foreground">
-                  {" "}
-                  {ev.targetName && ev.targetId && onInterviewAgent ? (
-                    <>
-                      {actionTypePhraseOnly(ev.actionType)}{" "}
-                      <button
-                        type="button"
-                        className="text-primary hover:underline"
-                        onClick={() => onInterviewAgent(ev.targetId!, ev.targetName!)}
-                      >
-                        {ev.targetName}
-                      </button>
-                      :
-                    </>
-                  ) : (
-                    <>
-                      {actionPhrase(ev.actionType, ev.targetName)}:
-                    </>
+                  {/* 节点事件列表 */}
+                  {!isCollapsed && (
+                    <div className="space-y-1.5 border-t px-3 py-2">
+                      {nodeEvents.map((ev) => (
+                        <div key={ev.id} className="leading-relaxed">
+                          <span className="mr-1 rounded bg-muted px-1 py-0.5 text-[10px] text-muted-foreground">
+                            R{ev.round + 1}
+                          </span>
+                          {onInterviewAgent ? (
+                            <button
+                              type="button"
+                              className="font-medium text-primary hover:underline"
+                              onClick={() => onInterviewAgent(ev.actorId, ev.actorName)}
+                            >
+                              {ev.actorName}
+                            </button>
+                          ) : (
+                            <span className="font-medium">{ev.actorName}</span>
+                          )}
+                          <span className="text-muted-foreground">
+                            {" "}
+                            {ev.targetName && ev.targetId && onInterviewAgent ? (
+                              <>
+                                {actionTypePhraseOnly(ev.actionType)}{" "}
+                                <button
+                                  type="button"
+                                  className="text-primary hover:underline"
+                                  onClick={() => onInterviewAgent(ev.targetId!, ev.targetName!)}
+                                >
+                                  {ev.targetName}
+                                </button>
+                                :
+                              </>
+                            ) : (
+                              <>
+                                {actionPhrase(ev.actionType, ev.targetName)}:
+                              </>
+                            )}
+                          </span>
+                          <span>{ev.content}</span>
+                        </div>
+                      ))}
+                    </div>
                   )}
-                </span>
-                <span>{ev.content}</span>
-              </div>
-            ))}
+                </div>
+              )
+            })}
           </div>
         )}
       </div>
@@ -820,8 +1110,10 @@ function AgentChatPanel({
   onSend,
   onClose,
   onExport,
+  onSave,
   sending,
   exporting,
+  saving,
   chatLogRef,
 }: {
   agentName: string
@@ -831,8 +1123,10 @@ function AgentChatPanel({
   onSend: () => void
   onClose: () => void
   onExport: () => void
+  onSave: () => void
   sending: boolean
   exporting: boolean
+  saving: boolean
   chatLogRef: React.RefObject<HTMLDivElement | null>
 }) {
   return (
@@ -840,6 +1134,17 @@ function AgentChatPanel({
       <div className="flex shrink-0 items-center justify-between border-b px-3 py-2">
         <div className="text-sm font-semibold">与 {agentName} 对话</div>
         <div className="flex items-center gap-1">
+          <Button
+            type="button"
+            size="icon"
+            variant="ghost"
+            className="h-7 w-7"
+            onClick={onSave}
+            title="保存采访对话"
+            disabled={saving || messages.length === 0}
+          >
+            {saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
+          </Button>
           <Button
             type="button"
             size="icon"

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

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

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

@@ -24,6 +24,7 @@ import type {
   StoryNode,
   TimelineEvent,
 } from "./types"
+import type { SerializedSimulationSnapshot } from "./simulation-serializer"
 
 const SIM_ROOT = ".qmai/simulations"
 const FRAMEWORKS_DIR = `${SIM_ROOT}/frameworks`
@@ -402,6 +403,7 @@ export async function saveSimulationResult(
   report: SimulationReport,
   draft?: StoryDraft,
   timelineEvents?: TimelineEvent[],
+  agentSnapshot?: SerializedSimulationSnapshot,
 ): Promise<string> {
   await ensureSimulationDirs(projectPath)
   const resultId = `result-${Date.now()}`
@@ -412,6 +414,7 @@ export async function saveSimulationResult(
     report,
     draft: draft ?? null,
     timelineEvents: timelineEvents ?? [],
+    agentSnapshot: agentSnapshot ?? null,
   }
   await writeFileAtomic(`${dir}/${resultId}.json`, JSON.stringify(payload, null, 2))
   await writeFileAtomic(
@@ -449,6 +452,7 @@ export async function loadSimulationResults(
   report: SimulationReport
   draft?: StoryDraft | null
   timelineEvents?: TimelineEvent[]
+  agentSnapshot?: SerializedSimulationSnapshot | null
 }[]> {
   const dir = frameworkResultsDir(projectPath, frameworkId)
   let entries: FileNode[]
@@ -463,6 +467,7 @@ export async function loadSimulationResults(
     report: SimulationReport
     draft?: StoryDraft | null
     timelineEvents?: TimelineEvent[]
+    agentSnapshot?: SerializedSimulationSnapshot | null
   }[] = []
   for (const entry of entries) {
     if (entry.is_dir) continue
@@ -473,6 +478,7 @@ export async function loadSimulationResults(
         report: SimulationReport
         draft?: StoryDraft | null
         timelineEvents?: TimelineEvent[]
+        agentSnapshot?: SerializedSimulationSnapshot | null
       }
       if (parsed && parsed.report) {
         results.push({
@@ -480,6 +486,7 @@ export async function loadSimulationResults(
           report: parsed.report,
           draft: parsed.draft ?? null,
           timelineEvents: parsed.timelineEvents ?? [],
+          agentSnapshot: parsed.agentSnapshot ?? null,
         })
       }
     } catch {

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

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

+ 119 - 17
src/lib/novel/story-simulation/simulation-engine.ts

@@ -18,7 +18,8 @@ import type {
   StoryNode,
   TimelineEvent,
 } from "@/lib/novel/story-simulation/types"
-import { calcMaxRoundsPerNode } from "@/lib/novel/story-simulation/types"
+import { calcMaxRoundsPerNode, getModeConfig } from "@/lib/novel/story-simulation/types"
+import type { ModeConfig } from "@/lib/novel/story-simulation/types"
 
 // ── 对外接口 ──
 
@@ -166,8 +167,14 @@ function buildUserMessage(
   node: StoryNode,
   agentName: string,
   injectionEvent?: string,
+  modeHint?: string,
 ): string {
   const parts: string[] = [context]
+  if (modeHint) {
+    parts.push("")
+    parts.push("【行为倾向】")
+    parts.push(modeHint)
+  }
   if (injectionEvent) {
     parts.push("")
     parts.push("【突发事件】")
@@ -627,6 +634,26 @@ function formatActionDescription(action: AgentAction, agentName: string): string
 
 // ── 内部辅助:检查节点是否达成目标(简单启发式) ──
 
+const RANDOM_EVENTS = [
+  "一阵异样的风声掠过,似乎预示着某种变故即将到来。",
+  "远处传来模糊的响动,所有角色都感到了一丝不安。",
+  "天色骤变,云层翻涌,仿佛有什么大事正在酝酿。",
+  "一个不起眼的线索被发现,可能改变所有人的判断。",
+  "时间流逝比预想的更快,紧迫感在角色间蔓延。",
+  "一个意外来客出现在众人视野中。",
+  "一段被遗忘的记忆突然浮现,影响着某个角色的判断。",
+  "环境的微妙变化让角色们重新审视当前局势。",
+]
+
+function generateRandomEvent(): SimulationEvent | null {
+  const idx = Math.floor(Math.random() * RANDOM_EVENTS.length)
+  return {
+    type: "info",
+    timestamp: new Date().toISOString(),
+    message: `【随机事件】${RANDOM_EVENTS[idx]}`,
+  }
+}
+
 function isNodeGoalReached(
   _node: StoryNode,
   nodeTimelineEvents: TimelineEvent[],
@@ -662,6 +689,7 @@ async function agentDecideAndAct(
   recentEventDescs: string[],
   injectionEvent: string | undefined,
   signal?: AbortSignal,
+  modeHint?: string,
 ): Promise<{ parsed: ParsedAction; tlEvent: TimelineEvent; simEvent: SimulationEvent } | null> {
   // 1. 观察:筛选该 Agent 可见的时间线事件
   const visibleEvents = getVisibleEvents(
@@ -684,7 +712,7 @@ async function agentDecideAndAct(
     { role: "system", content: buildSystemPrompt(agent) },
     {
       role: "user",
-      content: buildUserMessage(context, node, agent.name, injectionEvent),
+      content: buildUserMessage(context, node, agent.name, injectionEvent, modeHint),
     },
   ]
 
@@ -741,10 +769,13 @@ export async function runSimulation(
 ): Promise<SimulationEvent[]> {
   const events: SimulationEvent[] = []
   const { agents, framework, wordBudget, llmConfig, injectionEvent, maxRoundsPerNode } = input
+  const mode = input.mode || framework.simulationMode || "hybrid"
+  const modeConfig: ModeConfig = getModeConfig(mode)
   const totalNodes = framework.nodes.length
-  // 使用用户指定的轮数,若未指定则自动计算
+  // 使用用户指定的轮数,若未指定则自动计算并乘以模式系数
   const calculatedRounds = calcMaxRoundsPerNode(wordBudget) || MAX_ROUNDS_PER_NODE_FALLBACK
-  const maxRounds = Math.max(1, maxRoundsPerNode ?? calculatedRounds)
+  const baseRounds = Math.max(1, maxRoundsPerNode ?? calculatedRounds)
+  const maxRounds = Math.max(1, Math.round(baseRounds * modeConfig.roundsMultiplier))
   let aborted = false
 
   // 初始化仿真状态
@@ -815,8 +846,17 @@ export async function runSimulation(
 
         state.currentRound = round
 
+        // 根据模式决定本轮活跃 Agent 子集
+        let roundAgentList = nodeAgentList
+        if (modeConfig.agentSubsetRatio < 1 && nodeAgentList.length > 1) {
+          const subsetSize = Math.max(1, Math.ceil(nodeAgentList.length * modeConfig.agentSubsetRatio))
+          // 简单的随机选择:打乱后取前 N 个
+          const shuffled = [...nodeAgentList].sort(() => Math.random() - 0.5)
+          roundAgentList = shuffled.slice(0, subsetSize)
+        }
+
         // a. 每个活跃 Agent 观察并决策
-        for (const agent of nodeAgentList) {
+        for (const agent of roundAgentList) {
           if (signal?.aborted) {
             aborted = true
             break
@@ -826,22 +866,39 @@ export async function runSimulation(
           const currentAgent = state.activeAgents.get(agent.characterId)
           if (!currentAgent) continue
 
-          const result = await agentDecideAndAct(
-            currentAgent,
-            node,
-            state,
-            llmConfig,
-            extraction,
-            recentEventDescs,
-            round === 0 ? injectionEvent : undefined,
-            signal,
-          )
+          let result: Awaited<ReturnType<typeof agentDecideAndAct>> | null = null
+          try {
+            result = await agentDecideAndAct(
+              currentAgent,
+              node,
+              state,
+              llmConfig,
+              extraction,
+              recentEventDescs,
+              round === 0 ? injectionEvent : undefined,
+              signal,
+              modeConfig.behaviorHint,
+            )
+          } catch (agentErr) {
+            // 单个 Agent 失败不中断整个推演,记录事件后跳过
+            console.warn(`[simulation] Agent ${currentAgent.name} 决策失败,跳过本轮:`, agentErr)
+            const warnEvent: SimulationEvent = {
+              type: "info",
+              timestamp: new Date().toISOString(),
+              message: `${currentAgent.name} 本轮无行动(API错误),继续推演`,
+            }
+            events.push(warnEvent)
+            callbacks.onEvent(warnEvent)
+            continue
+          }
 
           if (!result) {
             if (signal?.aborted) {
               aborted = true
+              break
             }
-            break
+            // null 且非 abort,跳过该 agent 继续
+            continue
           }
 
           const { parsed, tlEvent, simEvent } = result
@@ -883,7 +940,34 @@ export async function runSimulation(
 
         if (aborted) break
 
-        // e. 检查节点目标是否达成
+        // e. 随机事件(根据模式概率触发)
+        if (modeConfig.randomEventChance > 0 && Math.random() < modeConfig.randomEventChance) {
+          const randomEvent = generateRandomEvent()
+          if (randomEvent) {
+            events.push(randomEvent)
+            callbacks.onEvent(randomEvent)
+            const tlEvent: TimelineEvent = {
+              id: `tl-rand-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
+              actorId: "system",
+              actorName: "系统事件",
+              actionType: "pushPlot",
+              content: randomEvent.message || "",
+              observableBy: Array.from(state.activeAgents.keys()),
+              round,
+              nodeIndex: node.index,
+              timestamp: new Date().toISOString(),
+              impacts: [],
+              targetId: undefined,
+              targetName: undefined,
+            }
+            state.timelineEvents.push(tlEvent)
+            nodeTimelineEvents.push(tlEvent)
+            callbacks.onTimelineEvent?.(tlEvent)
+            recentEventDescs.push(`[系统事件] ${randomEvent.message}`)
+          }
+        }
+
+        // f. 检查节点目标是否达成
         if (isNodeGoalReached(node, nodeTimelineEvents, maxRounds, round)) {
           break
         }
@@ -913,7 +997,25 @@ export async function runSimulation(
     return events
   } catch (err) {
     const error = err instanceof Error ? err : new Error(String(err))
+    // 如果是 abort 导致的错误,正常返回已收集事件
+    if (signal?.aborted || error.name === "AbortError") {
+      return events
+    }
+    // 其他致命错误才回调 onError 并抛出
+    console.error("[simulation] 仿真引擎致命错误:", error)
     callbacks.onError(error)
+    // 如果已经收集了一些事件,仍然返回它们而非抛出,让上层能部分使用结果
+    if (events.length > 0) {
+      const errorEvent: SimulationEvent = {
+        type: "info",
+        timestamp: new Date().toISOString(),
+        message: `推演过程中遇到错误:${error.message},已返回已推演内容`,
+      }
+      events.push(errorEvent)
+      callbacks.onEvent(errorEvent)
+      callbacks.onComplete(events)
+      return events
+    }
     throw error
   }
 }

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

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

+ 57 - 1
src/lib/novel/story-simulation/types.ts

@@ -202,13 +202,15 @@ export interface StoryNode {
 
 // ── 仿真事件(保留以兼容报告生成器和旧流程) ──
 export interface SimulationEvent {
-  type: "agent-action" | "node-complete" | "node-start"
+  type: "agent-action" | "node-complete" | "node-start" | "info"
   agent?: NovelAgent
   action?: AgentAction
   round?: number
   node?: StoryNode
   stateChanges?: string[]
   timestamp: string
+  /** info 类型事件的消息 */
+  message?: string
   /** 关联的时间线事件(新引擎填充) */
   timelineEvent?: TimelineEvent
 }
@@ -306,6 +308,60 @@ export function calcMaxRoundsPerNode(wordBudget: number): number {
   return Math.max(2, Math.floor(wordBudget / 10000))
 }
 
+// ── 仿真模式配置 ──
+
+export interface ModeConfig {
+  /** 轮数乘数 */
+  roundsMultiplier: number
+  /** 注入到 prompt 中的行为倾向提示 */
+  behaviorHint: string
+  /** 随机事件触发概率(0-1,0=不触发) */
+  randomEventChance: number
+  /** 每轮活跃 Agent 比例(1=全部,0.5=随机一半) */
+  agentSubsetRatio: number
+  /** 是否强制按节点目标推进 */
+  strictNodeProgression: boolean
+}
+
+const MODE_CONFIGS: Record<SimulationMode, ModeConfig> = {
+  "event-driven": {
+    roundsMultiplier: 0.8,
+    behaviorHint:
+      "你倾向于推动事态发展(pushPlot),以达成节点目标为首要任务。谨慎使用观察行为,优先采取主动行动推动剧情。",
+    randomEventChance: 0.1,
+    agentSubsetRatio: 1,
+    strictNodeProgression: true,
+  },
+  "free-emergence": {
+    roundsMultiplier: 1.5,
+    behaviorHint:
+      "你倾向于自由表达和互动(evaluate/speak/observe),关注自身情感和与他人关系的变化。剧情会在角色互动中自然涌现,不必急于达成节点目标。",
+    randomEventChance: 0.25,
+    agentSubsetRatio: 0.7,
+    strictNodeProgression: false,
+  },
+  "decision-tree": {
+    roundsMultiplier: 1.0,
+    behaviorHint:
+      "你倾向于做出关键决策和对抗(confront/decide/react)。每个行为都应体现你在面对选择时的权衡与取舍,重点关注决策的因果链。",
+    randomEventChance: 0.15,
+    agentSubsetRatio: 0.5,
+    strictNodeProgression: true,
+  },
+  hybrid: {
+    roundsMultiplier: 1.2,
+    behaviorHint:
+      "你可以灵活选择行为类型:有时推动剧情,有时观察评价,有时与他人对话。根据当前情境和角色性格自然选择最合适的行为。",
+    randomEventChance: 0.2,
+    agentSubsetRatio: 0.85,
+    strictNodeProgression: false,
+  },
+}
+
+export function getModeConfig(mode: SimulationMode): ModeConfig {
+  return MODE_CONFIGS[mode] ?? MODE_CONFIGS.hybrid
+}
+
 export function calcMaxAgentsPerRound(activeAgentCount: number): number {
   return Math.min(8, activeAgentCount)
 }

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

@@ -9,6 +9,7 @@ import type {
   FrameworkBinding,
   TimelineEvent,
 } from "@/lib/novel/story-simulation/types"
+import type { SerializedSimulationSnapshot } from "@/lib/novel/story-simulation/simulation-serializer"
 
 export interface SavedSimulationResult {
   id: string
@@ -16,6 +17,7 @@ export interface SavedSimulationResult {
   report: SimulationReport
   draft?: StoryDraft | null
   timelineEvents?: TimelineEvent[]
+  agentSnapshot?: SerializedSimulationSnapshot | null
   createdAt: string
 }
 

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

@@ -530,6 +530,7 @@ interface WikiState {
   theme: "light" | "dark" | "deep-blue" | "system"
   uiFontSizeScale: number
   dataVersion: number
+  bindingVersion: number
 
   setProject: (project: WikiProject | null) => void
   setFileTree: (tree: FileNode[]) => void
@@ -591,6 +592,7 @@ interface WikiState {
   setTheme: (theme: "light" | "dark" | "deep-blue" | "system") => void
   setUiFontSizeScale: (scale: number) => void
   bumpDataVersion: () => void
+  bumpBindingVersion: () => void
 }
 
 export const useWikiStore = create<WikiState>((set) => ({
@@ -643,6 +645,7 @@ export const useWikiStore = create<WikiState>((set) => ({
   activePresetId: null,
 
   dataVersion: 0,
+  bindingVersion: 0,
 
   setProject: (project) => set({ project }),
   setFileTree: (fileTree) => set({ fileTree }),
@@ -793,6 +796,7 @@ export const useWikiStore = create<WikiState>((set) => ({
     set({ uiFontSizeScale: clamped })
   },
   bumpDataVersion: () => set((state) => ({ dataVersion: state.dataVersion + 1 })),
+  bumpBindingVersion: () => set((state) => ({ bindingVersion: state.bindingVersion + 1 })),
 }))
 
 export type { WikiState, LlmConfig, SearchApiConfig, EmbeddingConfig, MultimodalConfig, OutputLanguage, ProxyConfig, ScheduledImportConfig, SourceWatchConfig }