Explorar el Código

feat: 完成模式可视化说明、历史采访查看、推演结果对比功能

Mochocyang hace 3 meses
padre
commit
746d5f7a09

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

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

+ 84 - 23
src/components/novel/story-simulation/simulation-config-panel.tsx

@@ -3,15 +3,16 @@ import { useTranslation } from "react-i18next"
 import { Button } from "@/components/ui/button"
 import { Input } from "@/components/ui/input"
 import { Textarea } from "@/components/ui/textarea"
-import { WORD_BUDGET_PRESETS, type SimulationMode } from "@/lib/novel/story-simulation/types"
+import { WORD_BUDGET_PRESETS, type SimulationMode, MODE_VISUAL_INFO } from "@/lib/novel/story-simulation/types"
 import { useStorySimulationStore } from "@/stores/story-simulation-store"
 import { cn } from "@/lib/utils"
+import { Info } from "lucide-react"
 
-const MODES = [
-  { mode: "event-driven" as SimulationMode, labelKey: "storySimulation.modeEventDriven", descKey: "storySimulation.modeEventDrivenDesc" },
-  { mode: "free-emergence" as SimulationMode, labelKey: "storySimulation.modeFreeEmergence", descKey: "storySimulation.modeFreeEmergenceDesc" },
-  { mode: "decision-tree" as SimulationMode, labelKey: "storySimulation.modeDecisionTree", descKey: "storySimulation.modeDecisionTreeDesc" },
-  { mode: "hybrid" as SimulationMode, labelKey: "storySimulation.modeHybrid", descKey: "storySimulation.modeHybridDesc" },
+const MODES: { mode: SimulationMode }[] = [
+  { mode: "event-driven" },
+  { mode: "free-emergence" },
+  { mode: "decision-tree" },
+  { mode: "hybrid" },
 ]
 
 const CHAPTER_OPTIONS = [5, 10, 20, 30, 50]
@@ -46,29 +47,89 @@ export function SimulationConfigPanel({ onStart }: SimulationConfigPanelProps) {
   const setSourceChapters = useStorySimulationStore((s) => s.setSourceChapters)
   const setSimulationRounds = useStorySimulationStore((s) => s.setSimulationRounds)
 
+  const selectedModeInfo = MODE_VISUAL_INFO[mode]
+
   return (
     <div className="mx-auto flex w-full max-w-2xl flex-col gap-6 p-6">
       {/* 1. 仿真模式选择 */}
       <section className="flex flex-col gap-3">
-        <h3 className="text-sm font-medium">{t("storySimulation.selectMode")}</h3>
+        <h3 className="flex items-center gap-1.5 text-sm font-medium">
+          {t("storySimulation.selectMode")}
+          <Info className="h-3.5 w-3.5 text-muted-foreground" />
+        </h3>
         <div className="grid grid-cols-2 gap-3">
-          {MODES.map((m) => (
-            <button
-              key={m.mode}
-              type="button"
-              onClick={() => setMode(m.mode)}
-              className={cn(
-                "flex flex-col items-start gap-1 rounded-lg border p-3 text-left transition-colors",
-                mode === m.mode
-                  ? "border-primary bg-primary/5"
-                  : "border-border hover:border-primary/50"
-              )}
-            >
-              <span className="text-sm font-medium">{t(m.labelKey)}</span>
-              <span className="text-xs text-muted-foreground">{t(m.descKey)}</span>
-            </button>
-          ))}
+          {MODES.map((m) => {
+            const info = MODE_VISUAL_INFO[m.mode]
+            const isSelected = mode === m.mode
+            return (
+              <button
+                key={m.mode}
+                type="button"
+                onClick={() => setMode(m.mode)}
+                className={cn(
+                  "flex flex-col items-start gap-2 rounded-lg border p-3 text-left transition-all",
+                  isSelected
+                    ? "border-primary bg-primary/5 ring-2 ring-primary/20"
+                    : "border-border hover:border-primary/50 hover:bg-accent/30"
+                )}
+              >
+                <div className="flex w-full items-center justify-between">
+                  <span className="text-lg">{info.icon}</span>
+                  <span className={cn("rounded-full px-2 py-0.5 text-[10px] font-medium", info.color)}>
+                    {info.freedomLabel}
+                  </span>
+                </div>
+                <span className="text-sm font-medium">{info.name}</span>
+                <span className="text-xs leading-relaxed text-muted-foreground">{info.shortDesc}</span>
+              </button>
+            )
+          })}
         </div>
+
+        {/* 选中模式的详细说明 */}
+        {selectedModeInfo && (
+          <div className="rounded-lg border bg-muted/30 p-4">
+            <div className="mb-3 flex items-center gap-2">
+              <span className="text-xl">{selectedModeInfo.icon}</span>
+              <span className="font-medium">{selectedModeInfo.name}模式</span>
+              <span className={cn("ml-auto rounded-full px-2 py-0.5 text-xs", selectedModeInfo.color)}>
+                推荐
+              </span>
+            </div>
+            
+            <div className="mb-3 grid grid-cols-3 gap-2 text-center text-xs">
+              <div className="rounded-md bg-background p-2">
+                <div className="text-muted-foreground">轮数</div>
+                <div className="mt-0.5 font-medium">{selectedModeInfo.roundsLabel}</div>
+              </div>
+              <div className="rounded-md bg-background p-2">
+                <div className="text-muted-foreground">随机性</div>
+                <div className="mt-0.5 font-medium">{selectedModeInfo.randomnessLabel}</div>
+              </div>
+              <div className="rounded-md bg-background p-2">
+                <div className="text-muted-foreground">自由度</div>
+                <div className="mt-0.5 font-medium">{selectedModeInfo.freedomLabel}</div>
+              </div>
+            </div>
+
+            <div className="mb-2">
+              <p className="mb-1.5 text-xs font-medium text-muted-foreground">模式特点:</p>
+              <ul className="space-y-1">
+                {selectedModeInfo.features.map((feature, i) => (
+                  <li key={i} className="flex items-start gap-1.5 text-xs">
+                    <span className="text-primary">•</span>
+                    <span>{feature}</span>
+                  </li>
+                ))}
+              </ul>
+            </div>
+
+            <div className="rounded-md bg-primary/5 p-2 text-xs">
+              <span className="font-medium text-primary">适用场景:</span>
+              <span className="text-foreground/80"> {selectedModeInfo.bestFor}</span>
+            </div>
+          </div>
+        )}
       </section>
 
       {/* 2. 用户思路 */}

+ 298 - 158
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, ChevronDown, ChevronRight } from "lucide-react"
+import { MessageCircle, RefreshCw, Sparkles, TrendingUp, Network, Download, ChevronDown, ChevronRight, GitCompare, X } from "lucide-react"
 import { Button } from "@/components/ui/button"
-import { useStorySimulationStore } from "@/stores/story-simulation-store"
+import { useStorySimulationStore, type SavedSimulationResult } from "@/stores/story-simulation-store"
 import { useWikiStore } from "@/stores/wiki-store"
 import { exportReport } from "@/lib/novel/story-simulation/report-export"
-import type { StoryBranch, TimelineEvent, StoryFramework } from "@/lib/novel/story-simulation/types"
+import type { StoryBranch, TimelineEvent, StoryFramework, SimulationReport } from "@/lib/novel/story-simulation/types"
 
 const PROBABILITY_COLORS: Record<string, string> = {
   high: "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300",
@@ -19,6 +19,7 @@ interface SimulationReportViewProps {
   onInterviewAgent: (agentId: string, agentName: string) => void
   onViewDraft?: () => void
   hasDraft?: boolean
+  onViewInterviewHistory?: () => void
 }
 
 /** 将 actionType 映射为中文动词短语 */
@@ -47,38 +48,28 @@ function actionLabel(type: string): string {
   }
 }
 
-export function SimulationReportView({
-  onResimulate,
-  onGenerateDraft,
-  onInterviewAgent,
-  onViewDraft,
-  hasDraft,
-}: SimulationReportViewProps) {
-  const { t } = useTranslation()
-  const projectPath = useWikiStore((s) => s.project?.path)
-  const report = useStorySimulationStore((s) => s.currentReport)
-  const currentFramework = useStorySimulationStore((s) => s.currentFramework)
-  const timelineEvents = useStorySimulationStore((s) => s.timelineEvents)
-  const setError = useStorySimulationStore((s) => s.setError)
-  const [exporting, setExporting] = useState(false)
-
-  const handleExport = async () => {
-    if (!projectPath || !report || !currentFramework) return
-    setExporting(true)
-    try {
-      const filePath = await exportReport(projectPath, currentFramework, report, timelineEvents)
-      setError(`报告已导出到:${filePath}`)
-      setTimeout(() => setError(null), 5000)
-    } catch (err) {
-      setError(err instanceof Error ? err.message : "导出失败")
-      setTimeout(() => setError(null), 5000)
-    } finally {
-      setExporting(false)
-    }
+function formatDate(dateStr: string): string {
+  try {
+    const d = new Date(dateStr)
+    return d.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" })
+  } catch {
+    return dateStr
   }
+}
+
+// ── 单个报告内容面板(可复用,用于对比模式) ──
 
-  if (!report) return null
+interface ReportContentProps {
+  report: SimulationReport
+  timelineEvents: TimelineEvent[]
+  framework?: StoryFramework | null
+  onInterviewAgent?: (agentId: string, agentName: string) => void
+  onGenerateDraft?: (branch: StoryBranch) => void
+  title?: string
+  compact?: boolean
+}
 
+function ReportContent({ report, timelineEvents, framework, onInterviewAgent, onGenerateDraft, title, compact }: ReportContentProps) {
   // 构建名字到ID的映射
   const nameToId = useMemo(() => {
     const map = new Map<string, string>()
@@ -92,9 +83,7 @@ export function SimulationReportView({
   const relationshipData = useMemo(() => {
     if (timelineEvents.length === 0) return null
 
-    // 统计角色活跃度
     const activityCount = new Map<string, number>()
-    // 统计角色间互动:key = "A|B"(字母序),value = { count, sentiment }
     const interactions = new Map<string, { count: number; sentiment: number; lastAction: string }>()
 
     for (const ev of timelineEvents) {
@@ -105,20 +94,11 @@ export function SimulationReportView({
         const existing = interactions.get(pair) || { count: 0, sentiment: 0, lastAction: "" }
         let sentimentDelta = 0
         switch (ev.actionType) {
-          case "ally":
-            sentimentDelta = 2
-            break
-          case "speak":
-            sentimentDelta = 0.5
-            break
-          case "confront":
-            sentimentDelta = -2
-            break
-          case "react":
-            sentimentDelta = ev.content.includes("好感") || ev.content.includes("赞同") ? 1 : -1
-            break
-          default:
-            sentimentDelta = 0
+          case "ally": sentimentDelta = 2; break
+          case "speak": sentimentDelta = 0.5; break
+          case "confront": sentimentDelta = -2; break
+          case "react": sentimentDelta = ev.content.includes("好感") || ev.content.includes("赞同") ? 1 : -1; break
+          default: sentimentDelta = 0
         }
         interactions.set(pair, {
           count: existing.count + 1,
@@ -142,38 +122,15 @@ export function SimulationReportView({
 
   return (
     <div className="flex h-full flex-col">
-      <div className="flex items-center justify-between border-b px-4 py-3">
-        <div className="flex items-center gap-2">
-          <TrendingUp className="h-4 w-4 text-primary" />
-          <h2 className="text-sm font-semibold">{t("storySimulation.reportTitle")}</h2>
+      {title && (
+        <div className="border-b bg-muted/30 px-4 py-2 text-sm font-medium text-center">
+          {title}
         </div>
-        <div className="flex items-center gap-2">
-          <Button
-            variant="outline"
-            size="sm"
-            onClick={handleExport}
-            disabled={exporting}
-          >
-            <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")}
-          </Button>
-        </div>
-      </div>
-
+      )}
       <div className="flex-1 overflow-y-auto p-4">
-        <div className="mx-auto max-w-3xl space-y-6">
+        <div className={`mx-auto ${compact ? "max-w-none" : "max-w-3xl"} space-y-6`}>
           {/* 角色关系网络 */}
-          {relationshipData && relationshipData.characters.length > 1 && (
+          {relationshipData && relationshipData.characters.length > 1 && !compact && (
             <section>
               <h3 className="mb-3 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
                 <Network className="h-3.5 w-3.5" />
@@ -183,18 +140,19 @@ export function SimulationReportView({
             </section>
           )}
 
-          {/* 关键剧情事件时间线 - 按节点分组折叠 */}
+          {/* 关键剧情事件时间线 */}
           {timelineEvents.length > 0 && (
             <TimelineGroupedEvents
               events={timelineEvents}
-              framework={currentFramework}
+              framework={framework}
               nameToId={nameToId}
               onInterviewAgent={onInterviewAgent}
+              compact={compact}
             />
           )}
 
           {/* 角色采访区 */}
-          {report.characterAnalyses.length > 0 && (
+          {!compact && report.characterAnalyses.length > 0 && onInterviewAgent && (
             <section>
               <h3 className="mb-3 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
                 <MessageCircle className="h-3.5 w-3.5" />
@@ -206,9 +164,7 @@ export function SimulationReportView({
                     key={char.characterId}
                     variant="outline"
                     size="sm"
-                    onClick={() =>
-                      onInterviewAgent(char.characterId, char.name)
-                    }
+                    onClick={() => onInterviewAgent(char.characterId, char.name)}
                   >
                     <MessageCircle className="mr-1 h-3.5 w-3.5" />
                     与 {char.name} 对话
@@ -222,7 +178,7 @@ export function SimulationReportView({
           {report.characterAnalyses.length > 0 && (
             <section>
               <h3 className="mb-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
-                {t("storySimulation.characterAnalysis")}
+                角色行为分析
               </h3>
               <div className="space-y-3">
                 {report.characterAnalyses.map((char) => (
@@ -230,24 +186,18 @@ export function SimulationReportView({
                     <div className="flex items-center justify-between">
                       <span className="font-medium">{char.name}</span>
                       <span className="rounded px-1.5 py-0.5 text-xs bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300">
-                        {t("storySimulation.consistencyScore")}: {char.consistencyScore}
+                        一致性: {char.consistencyScore}
                       </span>
                     </div>
 
                     {char.behaviors.length > 0 && (
                       <div className="mt-2">
-                        <p className="mb-1 text-xs font-medium text-muted-foreground">
-                          {t("storySimulation.behaviors")}
-                        </p>
+                        <p className="mb-1 text-xs font-medium text-muted-foreground">行为:</p>
                         <ul className="space-y-1">
                           {char.behaviors.map((b, i) => (
                             <li key={i} className="text-sm">
-                              <span className="text-muted-foreground">[{b.node}]</span>{" "}
-                              {b.action}
-                              <span className="text-muted-foreground">
-                                {" "}
-                                — {t("storySimulation.motivation")}: {b.motivation}
-                              </span>
+                              <span className="text-muted-foreground">[{b.node}]</span> {b.action}
+                              <span className="text-muted-foreground"> — 动机: {b.motivation}</span>
                             </li>
                           ))}
                         </ul>
@@ -256,13 +206,9 @@ export function SimulationReportView({
 
                     {char.stateChanges.length > 0 && (
                       <div className="mt-2">
-                        <p className="mb-1 text-xs font-medium text-muted-foreground">
-                          {t("storySimulation.stateChanges")}
-                        </p>
+                        <p className="mb-1 text-xs font-medium text-muted-foreground">状态变化:</p>
                         <ul className="list-disc space-y-0.5 pl-4 text-sm">
-                          {char.stateChanges.map((s, i) => (
-                            <li key={i}>{s}</li>
-                          ))}
+                          {char.stateChanges.map((s, i) => <li key={i}>{s}</li>)}
                         </ul>
                       </div>
                     )}
@@ -276,7 +222,7 @@ export function SimulationReportView({
           {report.branches.length > 0 && (
             <section>
               <h3 className="mb-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
-                {t("storySimulation.storyBranches")}
+                走向分支
               </h3>
               <div className="space-y-3">
                 {report.branches.map((branch, idx) => (
@@ -284,15 +230,10 @@ export function SimulationReportView({
                     <div className="flex flex-wrap items-center gap-2">
                       <span className="font-medium">{branch.title}</span>
                       {branch.recommendation && (
-                        <span className="rounded px-1.5 py-0.5 text-xs bg-primary/10 text-primary">
-                          {t("storySimulation.recommended")}
-                        </span>
+                        <span className="rounded px-1.5 py-0.5 text-xs bg-primary/10 text-primary">推荐</span>
                       )}
-                      <span
-                        className={`rounded px-1.5 py-0.5 text-xs ${PROBABILITY_COLORS[branch.probability]}`}
-                      >
-                        {t("storySimulation.probability")}:{" "}
-                        {t(`storySimulation.probability${branch.probability.charAt(0).toUpperCase()}${branch.probability.slice(1)}`)}
+                      <span className={`rounded px-1.5 py-0.5 text-xs ${PROBABILITY_COLORS[branch.probability]}`}>
+                        概率: {branch.probability === "high" ? "高" : branch.probability === "medium" ? "中" : "低"}
                       </span>
                     </div>
 
@@ -300,13 +241,9 @@ export function SimulationReportView({
 
                     {branch.keyEvents.length > 0 && (
                       <div className="mt-2">
-                        <p className="mb-1 text-xs font-medium text-muted-foreground">
-                          {t("storySimulation.keyEvents")}
-                        </p>
+                        <p className="mb-1 text-xs font-medium text-muted-foreground">关键事件:</p>
                         <ul className="list-disc space-y-0.5 pl-4 text-sm">
-                          {branch.keyEvents.map((e, i) => (
-                            <li key={i}>{e}</li>
-                          ))}
+                          {branch.keyEvents.map((e, i) => <li key={i}>{e}</li>)}
                         </ul>
                       </div>
                     )}
@@ -314,31 +251,29 @@ export function SimulationReportView({
                     <div className="mt-2 grid gap-2 sm:grid-cols-2">
                       {branch.pros && (
                         <div className="rounded-md bg-green-50 p-2 text-sm dark:bg-green-950/30">
-                          <span className="font-medium text-green-700 dark:text-green-400">
-                            {t("storySimulation.pros")}:{" "}
-                          </span>
+                          <span className="font-medium text-green-700 dark:text-green-400">利:</span>
                           {branch.pros}
                         </div>
                       )}
                       {branch.cons && (
                         <div className="rounded-md bg-red-50 p-2 text-sm dark:bg-red-950/30">
-                          <span className="font-medium text-red-700 dark:text-red-400">
-                            {t("storySimulation.cons")}:{" "}
-                          </span>
+                          <span className="font-medium text-red-700 dark:text-red-400">弊:</span>
                           {branch.cons}
                         </div>
                       )}
                     </div>
 
-                    <Button
-                      variant="default"
-                      size="sm"
-                      className="mt-3"
-                      onClick={() => onGenerateDraft(branch)}
-                    >
-                      <Sparkles className="h-3.5 w-3.5" />
-                      {t("storySimulation.generateDraft")}
-                    </Button>
+                    {!compact && onGenerateDraft && (
+                      <Button
+                        variant="default"
+                        size="sm"
+                        className="mt-3"
+                        onClick={() => onGenerateDraft(branch)}
+                      >
+                        <Sparkles className="h-3.5 w-3.5" />
+                        生成草稿
+                      </Button>
+                    )}
                   </div>
                 ))}
               </div>
@@ -351,7 +286,7 @@ export function SimulationReportView({
               <div className="rounded-lg border border-primary/20 bg-primary/5 p-4">
                 <h3 className="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-primary">
                   <Sparkles className="h-3.5 w-3.5" />
-                  {t("storySimulation.recommendation")}
+                  综合推荐
                 </h3>
                 <p className="text-sm leading-relaxed">{report.recommendation}</p>
               </div>
@@ -363,6 +298,201 @@ export function SimulationReportView({
   )
 }
 
+export function SimulationReportView({
+  onResimulate,
+  onGenerateDraft,
+  onInterviewAgent,
+  onViewDraft,
+  hasDraft,
+  onViewInterviewHistory,
+}: SimulationReportViewProps) {
+  const { t } = useTranslation()
+  const projectPath = useWikiStore((s) => s.project?.path)
+  const report = useStorySimulationStore((s) => s.currentReport)
+  const currentFramework = useStorySimulationStore((s) => s.currentFramework)
+  const timelineEvents = useStorySimulationStore((s) => s.timelineEvents)
+  const savedResults = useStorySimulationStore((s) => s.savedResults)
+  const setError = useStorySimulationStore((s) => s.setError)
+  const [exporting, setExporting] = useState(false)
+  const [compareMode, setCompareMode] = useState(false)
+  const [selectedCompareId, setSelectedCompareId] = useState<string | null>(null)
+  const [viewingResultId, setViewingResultId] = useState<string | null>(null)
+
+  // 当前查看的结果(可能是历史结果)
+  const currentResult: SavedSimulationResult | null = useMemo(() => {
+    if (!viewingResultId) return null
+    return savedResults.find(r => r.id === viewingResultId) || null
+  }, [viewingResultId, savedResults])
+
+  const activeReport = currentResult?.report || report
+  const activeTimeline = currentResult?.timelineEvents || timelineEvents
+  const compareResult = selectedCompareId ? savedResults.find(r => r.id === selectedCompareId) : null
+
+  // 可选择对比的结果(排除当前查看的)
+  const comparableResults = useMemo(() => {
+    return savedResults.filter(r => r.id !== viewingResultId && r.report)
+  }, [savedResults, viewingResultId])
+
+  const handleExport = async () => {
+    if (!projectPath || !activeReport || !currentFramework) return
+    setExporting(true)
+    try {
+      const filePath = await exportReport(projectPath, currentFramework, activeReport, activeTimeline)
+      setError(`报告已导出到:${filePath}`)
+      setTimeout(() => setError(null), 5000)
+    } catch (err) {
+      setError(err instanceof Error ? err.message : "导出失败")
+      setTimeout(() => setError(null), 5000)
+    } finally {
+      setExporting(false)
+    }
+  }
+
+  const handleExitCompare = () => {
+    setCompareMode(false)
+    setSelectedCompareId(null)
+  }
+
+  if (!activeReport) return null
+
+  return (
+    <div className="flex h-full flex-col">
+      {/* 顶部工具栏 */}
+      <div className="flex items-center justify-between border-b px-4 py-3">
+        <div className="flex items-center gap-2">
+          <TrendingUp className="h-4 w-4 text-primary" />
+          <h2 className="text-sm font-semibold">{t("storySimulation.reportTitle")}</h2>
+          {currentResult && (
+            <span className="rounded bg-muted px-2 py-0.5 text-[10px] text-muted-foreground">
+              {formatDate(currentResult.createdAt)}
+            </span>
+          )}
+        </div>
+        <div className="flex items-center gap-2">
+          {/* 历史结果选择 */}
+          {savedResults.length > 0 && (
+            <select
+              value={viewingResultId || ""}
+              onChange={(e) => {
+                setViewingResultId(e.target.value || null)
+                setCompareMode(false)
+                setSelectedCompareId(null)
+              }}
+              className="h-7 rounded-md border border-input bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
+            >
+              <option value="">最新推演</option>
+              {savedResults.map((r) => (
+                <option key={r.id} value={r.id}>
+                  {formatDate(r.createdAt)} ({r.report.mode})
+                </option>
+              ))}
+            </select>
+          )}
+          {/* 对比按钮 */}
+          {comparableResults.length > 0 && !compareMode && (
+            <Button
+              variant="outline"
+              size="sm"
+              onClick={() => {
+                setCompareMode(true)
+                if (comparableResults.length > 0) {
+                  setSelectedCompareId(comparableResults[0].id)
+                }
+              }}
+            >
+              <GitCompare className="mr-1 h-3.5 w-3.5" />
+              对比结果
+            </Button>
+          )}
+          {compareMode && (
+            <>
+              <select
+                value={selectedCompareId || ""}
+                onChange={(e) => setSelectedCompareId(e.target.value || null)}
+                className="h-7 rounded-md border border-input bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
+              >
+                {comparableResults.map((r) => (
+                  <option key={r.id} value={r.id}>
+                    对比: {formatDate(r.createdAt)}
+                  </option>
+                ))}
+              </select>
+              <Button variant="ghost" size="sm" onClick={handleExitCompare}>
+                <X className="h-3.5 w-3.5" />
+              </Button>
+            </>
+          )}
+          {onViewInterviewHistory && (
+            <Button
+              variant="outline"
+              size="sm"
+              onClick={onViewInterviewHistory}
+            >
+              <MessageCircle className="mr-1 h-3.5 w-3.5" />
+              采访历史
+            </Button>
+          )}
+          <Button
+            variant="outline"
+            size="sm"
+            onClick={handleExport}
+            disabled={exporting}
+          >
+            <Download className="mr-1 h-3.5 w-3.5" />
+            {exporting ? "导出中..." : "导出报告"}
+          </Button>
+          {!currentResult && hasDraft && onViewDraft && (
+            <Button variant="default" size="sm" onClick={onViewDraft}>
+              <Sparkles className="mr-1 h-3.5 w-3.5" />
+              查看草稿
+            </Button>
+          )}
+          {!currentResult && (
+            <Button variant="outline" size="sm" onClick={onResimulate}>
+              <RefreshCw className="mr-1 h-3.5 w-3.5" />
+              {t("storySimulation.resimulate")}
+            </Button>
+          )}
+        </div>
+      </div>
+
+      {/* 内容区域:单栏或双栏对比 */}
+      {compareMode && compareResult ? (
+        <div className="flex min-h-0 flex-1">
+          <div className="min-w-0 flex-1 border-r">
+            <ReportContent
+              report={activeReport}
+              timelineEvents={activeTimeline}
+              framework={currentFramework}
+              onInterviewAgent={!currentResult ? onInterviewAgent : undefined}
+              onGenerateDraft={!currentResult ? onGenerateDraft : undefined}
+              title={currentResult ? `结果 A (${formatDate(currentResult.createdAt)})` : "结果 A (最新)"}
+              compact={true}
+            />
+          </div>
+          <div className="min-w-0 flex-1">
+            <ReportContent
+              report={compareResult.report}
+              timelineEvents={compareResult.timelineEvents || []}
+              framework={currentFramework}
+              title={`结果 B (${formatDate(compareResult.createdAt)})`}
+              compact={true}
+            />
+          </div>
+        </div>
+      ) : (
+        <ReportContent
+          report={activeReport}
+          timelineEvents={activeTimeline}
+          framework={currentFramework}
+          onInterviewAgent={!currentResult ? onInterviewAgent : undefined}
+          onGenerateDraft={!currentResult ? onGenerateDraft : undefined}
+        />
+      )}
+    </div>
+  )
+}
+
 // ── 角色关系图谱组件(SVG 实现,轻量无依赖) ──
 
 interface RelationNode {
@@ -510,11 +640,13 @@ function TimelineGroupedEvents({
   framework,
   nameToId,
   onInterviewAgent,
+  compact,
 }: {
   events: TimelineEvent[]
   framework?: StoryFramework | null
   nameToId: Map<string, string>
-  onInterviewAgent: (agentId: string, agentName: string) => void
+  onInterviewAgent?: (agentId: string, agentName: string) => void
+  compact?: boolean
 }) {
   // 折叠状态:key = nodeIndex,value = 是否折叠
   const [collapsedNodes, setCollapsedNodes] = useState<Set<number>>(new Set())
@@ -595,7 +727,7 @@ function TimelineGroupedEvents({
           </button>
         </div>
       </div>
-      <div className="space-y-3">
+      <div className={compact ? "space-y-2" : "space-y-3"}>
         {groupedEvents.map(({ nodeIndex, nodeInfo, events: nodeEvents }) => {
           const isCollapsed = collapsedNodes.has(nodeIndex)
           const phase = nodeInfo?.phase || "起"
@@ -605,7 +737,7 @@ function TimelineGroupedEvents({
               {/* 节点标题栏 - 可点击折叠 */}
               <button
                 type="button"
-                className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-accent/50"
+                className={`flex w-full items-center gap-2 text-left hover:bg-accent/50 ${compact ? "px-2 py-1.5" : "px-3 py-2"}`}
                 onClick={() => toggleNode(nodeIndex)}
               >
                 {isCollapsed ? (
@@ -616,50 +748,58 @@ function TimelineGroupedEvents({
                 <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">
+                <span className={`font-medium ${compact ? "text-xs" : "text-sm"}`}>
                   节点 {nodeIndex + 1}:{nodeTitle}
                 </span>
                 <span className="ml-auto text-[11px] text-muted-foreground">
-                  {nodeEvents.length} 条事件
+                  {nodeEvents.length} 条
                 </span>
               </button>
               {/* 节点事件列表 */}
               {!isCollapsed && (
-                <div className="space-y-2 border-t p-3">
+                <div className={`border-t ${compact ? "space-y-1 p-2" : "space-y-2 p-3"}`}>
                   {nodeEvents.map((ev) => (
                     <div
                       key={ev.id}
-                      className="rounded-md border bg-muted/20 px-3 py-2 text-sm"
+                      className={`rounded-md border bg-muted/20 ${compact ? "px-2 py-1.5 text-xs" : "px-3 py-2 text-sm"}`}
                     >
-                      <div className="flex items-center gap-2">
+                      <div className="flex items-center gap-2 flex-wrap">
                         <span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-[11px] text-muted-foreground">
                           R{ev.round + 1}
                         </span>
-                        <button
-                          type="button"
-                          className="font-medium text-primary hover:underline"
-                          onClick={() => onInterviewAgent(ev.actorId, ev.actorName)}
-                        >
-                          {ev.actorName}
-                        </button>
+                        {onInterviewAgent ? (
+                          <button
+                            type="button"
+                            className="font-medium text-primary hover:underline"
+                            onClick={() => onInterviewAgent(ev.actorId, ev.actorName)}
+                          >
+                            {ev.actorName}
+                          </button>
+                        ) : (
+                          <span className="font-medium">{ev.actorName}</span>
+                        )}
                         <span className="text-xs text-muted-foreground">
                           {actionLabel(ev.actionType)}
                         </span>
                         {ev.targetName && (
                           <>
                             <span className="text-xs text-muted-foreground">→</span>
-                            <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>
+                            {onInterviewAgent ? (
+                              <button
+                                type="button"
+                                className="text-xs text-primary hover:underline"
+                                onClick={() => {
+                                  const targetId = ev.targetId || nameToId.get(ev.targetName || "")
+                                  if (targetId && ev.targetName) {
+                                    onInterviewAgent(targetId, ev.targetName)
+                                  }
+                                }}
+                              >
+                                {ev.targetName}
+                              </button>
+                            ) : (
+                              <span className="text-xs">{ev.targetName}</span>
+                            )}
                           </>
                         )}
                       </div>

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

@@ -33,6 +33,7 @@ import { SimulationConfigPanel } from "./simulation-config-panel"
 import { FrameworkConfirmPanel } from "./framework-confirm-panel"
 import { SimulationReportView } from "./simulation-report-view"
 import { StoryDraftView } from "./story-draft-view"
+import { InterviewHistoryView } from "./interview-history-view"
 import { Button } from "@/components/ui/button"
 
 const PROGRESS_PHASES = [
@@ -152,6 +153,7 @@ export function StorySimulationView() {
   const clearAgentChat = useStorySimulationStore((s) => s.clearAgentChat)
   const bumpListRefresh = useStorySimulationStore((s) => s.bumpListRefresh)
   const setSavedResults = useStorySimulationStore((s) => s.setSavedResults)
+  const setShowInterviewHistory = useStorySimulationStore((s) => s.setShowInterviewHistory)
 
   // 保存仿真后的 agents 和 state 供采访使用
   const lastAgentsRef = useRef<NovelAgent[]>([])
@@ -727,6 +729,7 @@ export function StorySimulationView() {
                 onInterviewAgent={(id, name) => handleInterviewAgent(id, name)}
                 onViewDraft={handleViewDraft}
                 hasDraft={!!currentDraft}
+                onViewInterviewHistory={() => setShowInterviewHistory(true)}
               />
             </div>
             {activeChatAgent && (
@@ -754,6 +757,7 @@ export function StorySimulationView() {
           </div>
         )}
       </div>
+      <InterviewHistoryView />
     </div>
   )
 }

+ 26 - 4
src/lib/novel/story-simulation/interview-export.ts

@@ -6,6 +6,7 @@
 import { createDirectory, writeFileAtomic } from "@/commands/fs"
 import { normalizePath } from "@/lib/path-utils"
 import type { AgentChatMessage } from "./types"
+import type { SavedInterview } from "./interview-store"
 
 const SIM_ROOT = ".qmai/simulations"
 const INTERVIEWS_DIR = `${SIM_ROOT}/interviews`
@@ -26,9 +27,24 @@ function interviewFilePath(projectPath: string, agentName: string, timestamp: st
  */
 export async function exportInterview(
   projectPath: string,
-  agentName: string,
-  messages: AgentChatMessage[],
+  agentNameOrInterview: string | SavedInterview,
+  messages?: AgentChatMessage[],
 ): Promise<string> {
+  let agentName: string
+  let msgs: AgentChatMessage[]
+  let frameworkTitle: string | undefined
+  let createdAt: string | undefined
+
+  if (typeof agentNameOrInterview === "string") {
+    agentName = agentNameOrInterview
+    msgs = messages || []
+  } else {
+    agentName = agentNameOrInterview.agentName
+    msgs = agentNameOrInterview.session.messages
+    frameworkTitle = agentNameOrInterview.frameworkTitle
+    createdAt = agentNameOrInterview.createdAt
+  }
+
   const dir = interviewsDir(projectPath)
   await createDirectory(dir)
 
@@ -39,13 +55,19 @@ export async function exportInterview(
   const lines: string[] = []
   lines.push(`# 与「${agentName}」的对话`)
   lines.push("")
+  if (frameworkTitle) {
+    lines.push(`> 故事框架:${frameworkTitle}`)
+  }
+  if (createdAt) {
+    lines.push(`> 创建时间:${new Date(createdAt).toLocaleString("zh-CN")}`)
+  }
   lines.push(`> 导出时间:${now.toLocaleString("zh-CN")}`)
-  lines.push(`> 消息数量:${messages.length}`)
+  lines.push(`> 消息数量:${msgs.length}`)
   lines.push("")
   lines.push("---")
   lines.push("")
 
-  for (const msg of messages) {
+  for (const msg of msgs) {
     const time = new Date(msg.timestamp).toLocaleTimeString("zh-CN", {
       hour: "2-digit",
       minute: "2-digit",

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

@@ -365,3 +365,93 @@ export function getModeConfig(mode: SimulationMode): ModeConfig {
 export function calcMaxAgentsPerRound(activeAgentCount: number): number {
   return Math.min(8, activeAgentCount)
 }
+
+// ── 模式可视化说明 ──
+
+export interface ModeVisualInfo {
+  /** 模式名称 */
+  name: string
+  /** 简短描述 */
+  shortDesc: string
+  /** 详细特点 */
+  features: string[]
+  /** 适合场景 */
+  bestFor: string
+  /** 轮数相对多少 */
+  roundsLabel: string
+  /** 随机事件多少 */
+  randomnessLabel: string
+  /** 剧情自由度 */
+  freedomLabel: string
+  /** 标签颜色 */
+  color: string
+  /** emoji图标 */
+  icon: string
+}
+
+export const MODE_VISUAL_INFO: Record<SimulationMode, ModeVisualInfo> = {
+  "event-driven": {
+    name: "事件驱动",
+    shortDesc: "按框架节点推进,节奏紧凑,剧情走向明确",
+    features: [
+      "严格按节点目标推进剧情",
+      "角色倾向于主动行动推动事态",
+      "随机事件较少,走向可控",
+      "每轮所有角色都参与互动",
+    ],
+    bestFor: "已有明确大纲,需要快速产出符合预期的剧情",
+    roundsLabel: "适中 (0.8x)",
+    randomnessLabel: "低 (10%)",
+    freedomLabel: "低",
+    color: "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300",
+    icon: "🎯",
+  },
+  "free-emergence": {
+    name: "自由涌现",
+    shortDesc: "角色自由互动,剧情自然发展,惊喜多",
+    features: [
+      "不强制节点目标,剧情自然涌现",
+      "角色更关注情感和关系变化",
+      "随机事件较多,可能有意外发展",
+      "每轮随机选择部分角色活跃",
+    ],
+    bestFor: "探索角色可能性,寻找灵感和意外剧情",
+    roundsLabel: "较多 (1.5x)",
+    randomnessLabel: "高 (25%)",
+    freedomLabel: "高",
+    color: "bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300",
+    icon: "🌊",
+  },
+  "decision-tree": {
+    name: "决策树",
+    shortDesc: "聚焦关键抉择,因果清晰,冲突感强",
+    features: [
+      "强调角色面临选择时的权衡",
+      "重点关注决策的因果链",
+      "对抗和决策类行为更多",
+      "每轮聚焦关键角色互动",
+    ],
+    bestFor: "强剧情冲突、权谋斗争、关键抉择场景",
+    roundsLabel: "标准 (1.0x)",
+    randomnessLabel: "中 (15%)",
+    freedomLabel: "中",
+    color: "bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300",
+    icon: "🌳",
+  },
+  hybrid: {
+    name: "混合模式",
+    shortDesc: "平衡推进与自由,综合表现佳,推荐首选",
+    features: [
+      "灵活选择行为类型,平衡推进与互动",
+      "既有主线推进,也有角色自由发挥",
+      "随机事件适中,既有惊喜也不失控",
+      "大部分角色参与,互动丰富",
+    ],
+    bestFor: "大多数场景,平衡可控性和创造性",
+    roundsLabel: "较多 (1.2x)",
+    randomnessLabel: "中 (20%)",
+    freedomLabel: "中高",
+    color: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300",
+    icon: "⚖️",
+  },
+}

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

@@ -10,6 +10,7 @@ import type {
   TimelineEvent,
 } from "@/lib/novel/story-simulation/types"
 import type { SerializedSimulationSnapshot } from "@/lib/novel/story-simulation/simulation-serializer"
+import type { SavedInterview } from "@/lib/novel/story-simulation/interview-store"
 
 export interface SavedSimulationResult {
   id: string
@@ -63,6 +64,14 @@ export interface StorySimulationState {
   savedResults: SavedSimulationResult[]
   /** 当前选中查看的历史结果ID */
   selectedResultId: string | null
+  /** 是否显示采访历史面板 */
+  showInterviewHistory: boolean
+  /** 已保存的采访列表 */
+  savedInterviews: SavedInterview[]
+  /** 当前查看的采访详情 */
+  viewingInterview: SavedInterview | null
+  /** 对比模式下要对比的结果ID(null表示不对比) */
+  compareWithResultId: string | null
 
   setPhase: (phase: SimulationPhase) => void
   setMode: (mode: SimulationMode) => void
@@ -87,6 +96,10 @@ export interface StorySimulationState {
   bumpListRefresh: () => void
   setSavedResults: (results: SavedSimulationResult[]) => void
   setSelectedResultId: (id: string | null) => void
+  setShowInterviewHistory: (show: boolean) => void
+  setSavedInterviews: (interviews: SavedInterview[]) => void
+  setViewingInterview: (interview: SavedInterview | null) => void
+  setCompareWithResultId: (id: string | null) => void
   reset: () => void
 }
 
@@ -113,6 +126,10 @@ export const useStorySimulationStore = create<StorySimulationState>((set) => ({
   listRefreshKey: 0,
   savedResults: [],
   selectedResultId: null,
+  showInterviewHistory: false,
+  savedInterviews: [],
+  viewingInterview: null,
+  compareWithResultId: null,
 
   setPhase: (phase) => set({ phase }),
   setMode: (mode) => set({ mode }),
@@ -139,6 +156,10 @@ export const useStorySimulationStore = create<StorySimulationState>((set) => ({
   bumpListRefresh: () => set((state) => ({ listRefreshKey: state.listRefreshKey + 1 })),
   setSavedResults: (savedResults) => set({ savedResults }),
   setSelectedResultId: (selectedResultId) => set({ selectedResultId }),
+  setShowInterviewHistory: (showInterviewHistory) => set({ showInterviewHistory }),
+  setSavedInterviews: (savedInterviews) => set({ savedInterviews }),
+  setViewingInterview: (viewingInterview) => set({ viewingInterview }),
+  setCompareWithResultId: (compareWithResultId) => set({ compareWithResultId }),
   reset: () =>
     set({
       phase: "idle",
@@ -154,5 +175,9 @@ export const useStorySimulationStore = create<StorySimulationState>((set) => ({
       agentChatMessages: [],
       savedResults: [],
       selectedResultId: null,
+      showInterviewHistory: false,
+      savedInterviews: [],
+      viewingInterview: null,
+      compareWithResultId: null,
     }),
 }))