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

feat(ui): 剧情推演室UI重构 - 框架列表移入SidebarPanel、实时事件流、角色采访面板

Mochocyang 3 месяцев назад
Родитель
Сommit
b698add9bb

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

@@ -23,8 +23,13 @@ import { GraphSidebarPanel } from "./graph-sidebar-panel"
 import { SoulSidebarPanel } from "./soul-sidebar-panel"
 import { ReviewCenterSidebarPanel } from "./review-center-sidebar-panel"
 import { BookAnalysisSidebarPanel } from "./book-analysis-sidebar-panel"
+import { FrameworkList } from "@/components/novel/story-simulation/framework-list"
 
 import { useWikiStore } from "@/stores/wiki-store"
+import { useStorySimulationStore } from "@/stores/story-simulation-store"
+import { loadFrameworks } from "@/lib/novel/story-simulation/framework-store"
+import { loadBinding } from "@/lib/novel/story-simulation/framework-binding"
+import type { StoryFramework } from "@/lib/novel/story-simulation/types"
 import { createDirectory, fileExists, listDirectory, preprocessFile, readFile, writeFile } from "@/commands/fs"
 import { countChapterBodyWords } from "@/lib/chapter-word-count"
 import { buildChapterTotalWordCountLabel } from "@/lib/chapter-display"
@@ -337,6 +342,72 @@ export function DismantlingSidebarPanel() {
 
 void DismantlingSidebarPanel
 
+/** 剧情推演室侧边栏:头部标题+新建按钮 + 框架列表 */
+function StorySimulationSidebarPanel() {
+  const projectPath = useWikiStore((s) => s.project?.path)
+  const setFrameworks = useStorySimulationStore((s) => s.setFrameworks)
+  const setBinding = useStorySimulationStore((s) => s.setBinding)
+  const setCurrentFramework = useStorySimulationStore((s) => s.setCurrentFramework)
+  const setPhase = useStorySimulationStore((s) => s.setPhase)
+  const reset = useStorySimulationStore((s) => s.reset)
+
+  // 进入视图时加载框架列表和绑定
+  useEffect(() => {
+    if (!projectPath) return
+    let cancelled = false
+    void (async () => {
+      try {
+        const [list, currentBinding] = await Promise.all([
+          loadFrameworks(projectPath),
+          loadBinding(projectPath),
+        ])
+        if (cancelled) return
+        setFrameworks(list)
+        setBinding(currentBinding)
+      } catch {
+        // 忽略加载错误
+      }
+    })()
+    return () => {
+      cancelled = true
+    }
+  }, [projectPath, setFrameworks, setBinding])
+
+  const handleSelectFramework = (framework: StoryFramework) => {
+    setCurrentFramework(framework)
+    setPhase("framework-confirming")
+  }
+
+  const handleNewFramework = () => {
+    reset()
+    setPhase("configuring")
+  }
+
+  return (
+    <div className="flex h-full flex-col">
+      <div className="flex shrink-0 items-center justify-between border-b px-3 py-2">
+        <div className="text-sm font-semibold text-foreground">故事框架</div>
+        <Button
+          type="button"
+          size="sm"
+          variant="ghost"
+          className="h-7 px-2 text-xs"
+          onClick={handleNewFramework}
+        >
+          <Plus className="mr-1 h-3.5 w-3.5" />
+          新建框架
+        </Button>
+      </div>
+      <div className="min-h-0 flex-1">
+        <FrameworkList
+          onSelectFramework={handleSelectFramework}
+          onNewFramework={handleNewFramework}
+        />
+      </div>
+    </div>
+  )
+}
+
 function inferModeFromPath(path: string): "knowledge" | "files" {
   const normalized = normalizePath(path)
   if (normalized.includes("/wiki/outlines/")) return "files"
@@ -1057,6 +1128,10 @@ export function SidebarPanel() {
     }
   }, [activeView, loadMemoryCenter, novelMode, project?.path])
 
+  if (activeView === "storySimulation") {
+    return <StorySimulationSidebarPanel />
+  }
+
   if (activeView === "graph") {
     return <GraphSidebarPanel />
   }

+ 25 - 1
src/components/novel/story-simulation/framework-confirm-panel.tsx

@@ -1,4 +1,6 @@
+import { useState } from "react"
 import { useTranslation } from "react-i18next"
+import { Check } from "lucide-react"
 
 import { Button } from "@/components/ui/button"
 import { useStorySimulationStore } from "@/stores/story-simulation-store"
@@ -8,6 +10,7 @@ import { cn } from "@/lib/utils"
 interface FrameworkConfirmPanelProps {
   onConfirm: () => void
   onRegenerate: () => void
+  onSave?: () => void
 }
 
 // 起/承/转/合 阶段对应的标签配色
@@ -21,12 +24,21 @@ const PHASE_STYLES: Record<StoryNode["phase"], string> = {
 export function FrameworkConfirmPanel({
   onConfirm,
   onRegenerate,
+  onSave,
 }: FrameworkConfirmPanelProps) {
   const { t } = useTranslation()
   const currentFramework = useStorySimulationStore((s) => s.currentFramework)
+  const [savedTip, setSavedTip] = useState(false)
 
   if (!currentFramework) return null
 
+  const handleSave = () => {
+    if (!onSave) return
+    onSave()
+    setSavedTip(true)
+    setTimeout(() => setSavedTip(false), 2000)
+  }
+
   return (
     <div className="flex flex-col gap-4">
       {/* 顶部:标题 + 操作按钮 */}
@@ -34,10 +46,22 @@ export function FrameworkConfirmPanel({
         <h3 className="text-lg font-semibold">
           {t("storySimulation.frameworkTitle")}
         </h3>
-        <div className="flex gap-2">
+        <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" />
+                  已保存
+                </>
+              ) : (
+                "保存框架"
+              )}
+            </Button>
+          )}
           <Button onClick={onConfirm}>
             {t("storySimulation.confirmFramework")}
           </Button>

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

@@ -1,11 +1,11 @@
 import { useEffect, useState } from "react"
-import { Plus, Link2, Unlink } from "lucide-react"
+import { Link2, Trash2 } from "lucide-react"
 
 import { useWikiStore } from "@/stores/wiki-store"
 import {
   useStorySimulationStore,
 } from "@/stores/story-simulation-store"
-import { loadFrameworks } from "@/lib/novel/story-simulation/framework-store"
+import { deleteFramework, loadFrameworks } from "@/lib/novel/story-simulation/framework-store"
 import { loadBinding } from "@/lib/novel/story-simulation/framework-binding"
 import { Button } from "@/components/ui/button"
 import type { StoryFramework } from "@/lib/novel/story-simulation/types"
@@ -15,21 +15,38 @@ import { FrameworkBindingDialog } from "./framework-binding-dialog"
 interface FrameworkListProps {
   onSelectFramework: (framework: StoryFramework) => void
   onNewFramework: () => void
+  /** 刷新计数:外部 bump 时触发重新加载 */
+  refreshKey?: number
+}
+
+/** 计算卡片显示标题:优先 shortTitle,否则截取 title 前 8 字。 */
+function displayTitle(fw: StoryFramework): string {
+  if (fw.shortTitle && fw.shortTitle.trim().length > 0) {
+    return fw.shortTitle
+  }
+  if (fw.title.length <= 8) return fw.title
+  return fw.title.slice(0, 8) + "..."
 }
 
 export function FrameworkList({
   onSelectFramework,
   onNewFramework,
+  refreshKey,
 }: FrameworkListProps) {
   const projectPath = useWikiStore((s) => s.project?.path)
   const frameworks = useStorySimulationStore((s) => s.frameworks)
   const setFrameworks = useStorySimulationStore((s) => s.setFrameworks)
   const binding = useStorySimulationStore((s) => s.binding)
   const setBinding = useStorySimulationStore((s) => s.setBinding)
+  const currentFrameworkId = useStorySimulationStore(
+    (s) => s.currentFramework?.id ?? null,
+  )
+  const listRefreshKey = useStorySimulationStore((s) => s.listRefreshKey)
 
   const [loading, setLoading] = useState(true)
   const [dialogFramework, setDialogFramework] =
     useState<StoryFramework | null>(null)
+  const [deletingId, setDeletingId] = useState<string | null>(null)
 
   useEffect(() => {
     if (!projectPath) {
@@ -57,71 +74,109 @@ export function FrameworkList({
     return () => {
       cancelled = true
     }
-  }, [projectPath, setFrameworks, setBinding])
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [projectPath, setFrameworks, setBinding, refreshKey, listRefreshKey])
+
+  const handleDelete = async (framework: StoryFramework) => {
+    if (!projectPath) return
+    const confirmed = window.confirm(
+      `确定删除框架「${framework.title}」吗?删除后不可恢复。`,
+    )
+    if (!confirmed) return
+    setDeletingId(framework.id)
+    try {
+      await deleteFramework(projectPath, framework.id)
+      const list = await loadFrameworks(projectPath)
+      setFrameworks(list)
+    } catch {
+      // 删除失败不做额外提示
+    } finally {
+      setDeletingId(null)
+    }
+  }
 
   if (!projectPath) {
     return (
-      <div className="flex h-full items-center justify-center p-8 text-sm text-muted-foreground">
+      <div className="flex h-full items-center justify-center p-4 text-xs text-muted-foreground">
         请先打开一个项目
       </div>
     )
   }
 
   return (
-    <div className="flex h-full flex-col gap-3 p-4">
-      <div className="flex items-center justify-between">
-        <h3 className="text-base font-medium">故事框架</h3>
-        <Button size="sm" onClick={onNewFramework}>
-          <Plus className="mr-1 h-4 w-4" />
-          新建框架
-        </Button>
-      </div>
-
+    <div className="flex h-full flex-col">
       {loading ? (
-        <div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
+        <div className="flex flex-1 items-center justify-center text-xs text-muted-foreground">
           加载中...
         </div>
       ) : frameworks.length === 0 ? (
-        <div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
-          暂无故事框架
+        <div className="flex flex-1 flex-col items-center justify-center gap-3 p-4">
+          <div className="text-xs text-muted-foreground">暂无故事框架</div>
+          <Button size="sm" variant="outline" onClick={onNewFramework}>
+            新建框架
+          </Button>
         </div>
       ) : (
-        <div className="flex flex-1 flex-col gap-2 overflow-auto">
+        <div className="flex-1 space-y-1 overflow-y-auto p-2">
           {frameworks.map((framework) => {
             const isBound = binding?.frameworkId === framework.id
+            const isSelected = currentFrameworkId === framework.id
             return (
               <div
                 key={framework.id}
-                className="flex items-center gap-3 rounded-lg border p-3 hover:bg-muted/50"
+                className={`group flex w-full items-center gap-1 rounded-md border px-2 py-1.5 text-left transition ${
+                  isSelected
+                    ? "border-primary bg-primary/10"
+                    : "bg-background hover:bg-muted"
+                }`}
               >
                 <button
                   type="button"
-                  className="flex flex-1 flex-col items-start gap-1 text-left"
+                  className="flex min-w-0 flex-1 flex-col items-start"
                   onClick={() => onSelectFramework(framework)}
                 >
-                  <span className="font-medium">{framework.title}</span>
-                  <span className="text-xs text-muted-foreground">
-                    {framework.nodes.length} 个节点 · 目标{" "}
-                    {framework.targetWords} 字
+                  <span
+                    className="w-full truncate text-sm font-medium"
+                    title={framework.title}
+                  >
+                    {displayTitle(framework)}
+                  </span>
+                  <span className="mt-0.5 text-[11px] text-muted-foreground">
+                    {framework.nodes.length} 节点 · {framework.targetWords} 字
+                    {isBound ? " · 已绑定" : ""}
                   </span>
                 </button>
-                <Button
-                  size="sm"
-                  variant={isBound ? "outline" : "default"}
-                  onClick={() => setDialogFramework(framework)}
-                >
-                  {isBound ? (
-                    <>
-                      <Unlink className="mr-1 h-4 w-4" />
-                      取消绑定
-                    </>
-                  ) : (
-                    <>
-                      <Link2 className="mr-1 h-4 w-4" />
-                      绑定到 AI 会话
-                    </>
-                  )}
-                </Button>
+                <div className="flex shrink-0 items-center opacity-60 group-hover:opacity-100">
+                  <Button
+                    type="button"
+                    size="icon"
+                    variant="ghost"
+                    className="h-7 w-7"
+                    title={isBound ? "管理绑定" : "绑定到 AI 会话"}
+                    onClick={(e) => {
+                      e.stopPropagation()
+                      setDialogFramework(framework)
+                    }}
+                  >
+                    <Link2
+                      className={`h-3.5 w-3.5 ${isBound ? "text-primary" : ""}`}
+                    />
+                  </Button>
+                  <Button
+                    type="button"
+                    size="icon"
+                    variant="ghost"
+                    className="h-7 w-7 text-muted-foreground hover:text-destructive"
+                    title="删除框架"
+                    disabled={deletingId === framework.id}
+                    onClick={(e) => {
+                      e.stopPropagation()
+                      void handleDelete(framework)
+                    }}
+                  >
+                    <Trash2 className="h-3.5 w-3.5" />
+                  </Button>
+                </div>
               </div>
             )
           })}

+ 90 - 1
src/components/novel/story-simulation/simulation-report-view.tsx

@@ -1,5 +1,5 @@
 import { useTranslation } from "react-i18next"
-import { RefreshCw, Sparkles, TrendingUp } from "lucide-react"
+import { MessageCircle, RefreshCw, Sparkles, TrendingUp } from "lucide-react"
 import { Button } from "@/components/ui/button"
 import { useStorySimulationStore } from "@/stores/story-simulation-store"
 import type { StoryBranch } from "@/lib/novel/story-simulation/types"
@@ -13,14 +13,43 @@ const PROBABILITY_COLORS: Record<string, string> = {
 interface SimulationReportViewProps {
   onResimulate: () => void
   onGenerateDraft: (branch: StoryBranch) => void
+  onInterviewAgent: (agentId: string, agentName: string) => void
+}
+
+/** 将 actionType 映射为中文动词短语 */
+function actionLabel(type: string): string {
+  switch (type) {
+    case "evaluate":
+      return "评价"
+    case "pushPlot":
+      return "推动事态"
+    case "observe":
+      return "观察"
+    case "react":
+      return "反应"
+    case "speak":
+      return "说"
+    case "ally":
+      return "结盟"
+    case "confront":
+      return "对抗"
+    case "conceal":
+      return "隐瞒"
+    case "investigate":
+      return "调查"
+    default:
+      return "行动"
+  }
 }
 
 export function SimulationReportView({
   onResimulate,
   onGenerateDraft,
+  onInterviewAgent,
 }: SimulationReportViewProps) {
   const { t } = useTranslation()
   const report = useStorySimulationStore((s) => s.currentReport)
+  const timelineEvents = useStorySimulationStore((s) => s.timelineEvents)
 
   if (!report) return null
 
@@ -39,6 +68,66 @@ export function SimulationReportView({
 
       <div className="flex-1 overflow-y-auto p-4">
         <div className="mx-auto max-w-3xl space-y-6">
+          {/* 关键剧情事件时间线 */}
+          {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>
+                      <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">
+                          → {ev.targetName}
+                        </span>
+                      )}
+                    </div>
+                    <p className="mt-1 text-sm leading-relaxed text-foreground/90">
+                      {ev.content}
+                    </p>
+                  </div>
+                ))}
+              </div>
+            </section>
+          )}
+
+          {/* 角色采访区 */}
+          {report.characterAnalyses.length > 0 && (
+            <section>
+              <h3 className="mb-3 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
+                <MessageCircle className="h-3.5 w-3.5" />
+                采访角色
+              </h3>
+              <div className="flex flex-wrap gap-2">
+                {report.characterAnalyses.map((char) => (
+                  <Button
+                    key={char.characterId}
+                    variant="outline"
+                    size="sm"
+                    onClick={() =>
+                      onInterviewAgent(char.characterId, char.name)
+                    }
+                  >
+                    <MessageCircle className="mr-1 h-3.5 w-3.5" />
+                    与 {char.name} 对话
+                  </Button>
+                ))}
+              </div>
+            </section>
+          )}
+
           {/* 角色行为分析 */}
           {report.characterAnalyses.length > 0 && (
             <section>

+ 391 - 41
src/components/novel/story-simulation/story-simulation-view.tsx

@@ -1,5 +1,6 @@
-import { useRef } from "react"
+import { useEffect, useRef, useState } from "react"
 import { useTranslation } from "react-i18next"
+import { Send, X } from "lucide-react"
 
 import { useWikiStore } from "@/stores/wiki-store"
 import { useStorySimulationStore } from "@/stores/story-simulation-store"
@@ -14,17 +15,22 @@ import { generateSimulationReport } from "@/lib/novel/story-simulation/simulatio
 import { generateStoryDraft } from "@/lib/novel/story-simulation/story-draft-generator"
 import { saveFramework } from "@/lib/novel/story-simulation/framework-store"
 import { resolveDefaultModel } from "@/lib/novel/model-resolver"
+import { interviewAgent } from "@/lib/novel/story-simulation/agent-interview"
 import type {
+  AgentChatMessage,
   ExtractionResult,
+  NovelAgent,
+  SimulationState,
   StoryBranch,
   StoryFramework,
+  TimelineEvent,
 } from "@/lib/novel/story-simulation/types"
 
-import { FrameworkList } from "./framework-list"
 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 { Button } from "@/components/ui/button"
 
 const PROGRESS_PHASES = [
   "extracting",
@@ -34,12 +40,46 @@ const PROGRESS_PHASES = [
   "draft-generating",
 ] as const
 
+/** 将 actionType 映射为中文动词短语(用于实时事件流展示) */
+function actionPhrase(type: string, actorName: string, targetName?: string): string {
+  switch (type) {
+    case "evaluate":
+      return `${actorName} 心中评价`
+    case "pushPlot":
+      return `${actorName} 推动事态`
+    case "observe":
+      return `${actorName} 观察到`
+    case "react":
+      return targetName
+        ? `${actorName} 对 ${targetName} 的反应`
+        : `${actorName} 做出反应`
+    case "speak":
+      return targetName
+        ? `${actorName} 对 ${targetName} 说`
+        : `${actorName} 说`
+    case "ally":
+      return targetName
+        ? `${actorName} 向 ${targetName} 示好`
+        : `${actorName} 寻求合作`
+    case "confront":
+      return targetName
+        ? `${actorName} 与 ${targetName} 对抗`
+        : `${actorName} 采取对抗姿态`
+    case "conceal":
+      return `${actorName} 隐瞒内心`
+    case "investigate":
+      return `${actorName} 调查`
+    default:
+      return `${actorName} 行动`
+  }
+}
+
 /**
- * 故事推演室主视图。
+ * 故事推演室主视图(重构后)。
  *
- * 左右两栏布局:左栏展示已保存的框架列表,右栏根据当前 phase
- * 切换不同面板,串联提取 → 框架生成 → 确认 → 仿真 → 报告 → 草稿
- * 的完整流程。
+ * 单栏全宽布局:框架列表已迁移到左侧 SidebarPanel。
+ * 主区域根据 phase 切换:配置 → 框架确认 → 推演(实时事件流)→ 报告 → 草稿。
+ * 报告阶段可弹出 Agent 采访面板与角色对话。
  */
 export function StorySimulationView() {
   const { t } = useTranslation()
@@ -57,6 +97,8 @@ export function StorySimulationView() {
   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 setPhase = useStorySimulationStore((s) => s.setPhase)
   const setExtractionResult = useStorySimulationStore(
@@ -69,12 +111,35 @@ export function StorySimulationView() {
   const setCurrentDraft = useStorySimulationStore((s) => s.setCurrentDraft)
   const setError = useStorySimulationStore((s) => s.setError)
   const setProgress = useStorySimulationStore((s) => s.setProgress)
+  const setTimelineEvents = useStorySimulationStore((s) => s.setTimelineEvents)
+  const addTimelineEvent = useStorySimulationStore((s) => s.addTimelineEvent)
+  const setActiveChatAgent = useStorySimulationStore((s) => s.setActiveChatAgent)
+  const addAgentChatMessage = useStorySimulationStore((s) => s.addAgentChatMessage)
+  const agentChatMessages = useStorySimulationStore((s) => s.agentChatMessages)
+  const clearAgentChat = useStorySimulationStore((s) => s.clearAgentChat)
+  const bumpListRefresh = useStorySimulationStore((s) => s.bumpListRefresh)
   const reset = useStorySimulationStore((s) => s.reset)
 
-  // 当前阶段的进度基线。对于只回调 label(无 progress 数值)的步骤,
-  // 用此基线保持进度条位置,仅更新文字。
+  // 保存仿真后的 agents 和 state 供采访使用
+  const lastAgentsRef = useRef<NovelAgent[]>([])
+  const lastSimulationStateRef = useRef<SimulationState | null>(null)
+
+  // 采访输入框
+  const [chatInput, setChatInput] = useState("")
+  const [chatSending, setChatSending] = useState(false)
+  const chatStreamRef = useRef("")
+  const chatLogRef = useRef<HTMLDivElement | null>(null)
+
+  // 当前阶段的进度基线
   const phaseBaseProgressRef = useRef(0)
 
+  // 采访面板打开时自动滚动到底部
+  useEffect(() => {
+    if (activeChatAgent && chatLogRef.current) {
+      chatLogRef.current.scrollTop = chatLogRef.current.scrollHeight
+    }
+  }, [agentChatMessages, activeChatAgent])
+
   // ── 核心流程 ──
 
   /** 提取内容并生成故事框架,进入框架确认阶段。 */
@@ -85,6 +150,7 @@ export function StorySimulationView() {
     }
     setError(null)
     setCurrentFramework(null)
+    setTimelineEvents([])
     try {
       // 1. 提取内容
       setPhase("extracting")
@@ -121,13 +187,25 @@ export function StorySimulationView() {
     }
   }
 
-  /** 确认框架:保存 → 构建角色 → 仿真 → 生成报告。 */
+  /** 保存当前框架到磁盘,并刷新侧边栏列表。 */
+  const handleSaveFramework = async () => {
+    if (!projectPath || !currentFramework) return
+    try {
+      await saveFramework(projectPath, currentFramework)
+      bumpListRefresh()
+    } catch (err) {
+      setError(err instanceof Error ? err.message : String(err))
+    }
+  }
+
+  /** 确认框架:必要时先保存 → 构建角色 → 仿真 → 生成报告。 */
   const handleConfirmFramework = async () => {
     if (!projectPath || !currentFramework) {
       setError("缺少项目路径或故事框架")
       return
     }
     setError(null)
+    setTimelineEvents([])
     try {
       // 若尚无提取结果(如从历史框架进入),先提取
       let extraction = extractionResult
@@ -142,23 +220,36 @@ export function StorySimulationView() {
         setExtractionResult(extraction)
       }
 
-      // 1. 保存框架
-      await saveFramework(projectPath, currentFramework)
+      // 开始推演前,若框架尚未保存到磁盘则先保存
+      // 简单判断:检查 frameworks 列表里是否有当前 id
+      const existing = useStorySimulationStore
+        .getState()
+        .frameworks.find((f) => f.id === currentFramework.id)
+      if (!existing) {
+        await saveFramework(projectPath, currentFramework)
+        bumpListRefresh()
+      }
 
-      // 2. 构建角色并运行仿真
+      // 构建角色并运行仿真
       setPhase("simulating")
       phaseBaseProgressRef.current = 50
       setProgress(50, t("storySimulation.simulating"))
       const agents = buildAgents(extraction, currentFramework)
+      lastAgentsRef.current = agents
       const llmConfig = resolveDefaultModel(baseLlmConfig)
+
+      const collectedTimeline: TimelineEvent[] = []
+
       const callbacks: SimulationCallbacks = {
         onEvent: () => {},
-        // 仿真进度映射到 50%–75% 区间
         onProgress: (p, label) =>
           setProgress(50 + Math.floor(p / 2), label),
         onComplete: () => {},
-        // 错误由外层 try/catch 统一处理
         onError: () => {},
+        onTimelineEvent: (event) => {
+          collectedTimeline.push(event)
+          addTimelineEvent(event)
+        },
       }
       const events = await runSimulation(
         {
@@ -173,7 +264,15 @@ export function StorySimulationView() {
         callbacks,
       )
 
-      // 3. 生成推演报告
+      // 保存仿真状态供采访使用
+      lastSimulationStateRef.current = {
+        currentRound: 0,
+        timelineEvents: collectedTimeline,
+        activeAgents: new Map(agents.map((a) => [a.characterId, a])),
+        worldState: {},
+      }
+
+      // 生成推演报告
       setPhase("report-generating")
       phaseBaseProgressRef.current = 80
       setProgress(80, "正在生成推演报告...")
@@ -200,6 +299,8 @@ export function StorySimulationView() {
 
   /** 重新推演:回退到框架确认阶段。 */
   const handleResimulate = () => {
+    setTimelineEvents([])
+    setCurrentReport(null)
     setPhase("framework-confirming")
   }
 
@@ -231,21 +332,100 @@ export function StorySimulationView() {
     }
   }
 
-  /** 新建框架:重置状态并进入配置阶段。 */
-  const handleNewFramework = () => {
-    reset()
-    setPhase("configuring")
+  /** 草稿视图返回报告视图。 */
+  const handleBackToReport = () => {
+    setPhase("report-viewing")
   }
 
-  /** 从列表选择一个已有框架,进入框架确认阶段。 */
-  const handleSelectFramework = (framework: StoryFramework) => {
-    setCurrentFramework(framework)
-    setPhase("framework-confirming")
+  /** 打开 Agent 采访面板。 */
+  const handleInterviewAgent = (agentId: string, agentName: string) => {
+    clearAgentChat()
+    setActiveChatAgent({ id: agentId, name: agentName })
   }
 
-  /** 草稿视图返回报告视图。 */
-  const handleBackToReport = () => {
-    setPhase("report-viewing")
+  /** 关闭采访面板。 */
+  const handleCloseChat = () => {
+    clearAgentChat()
+  }
+
+  /** 发送采访消息。 */
+  const handleSendChat = async () => {
+    const text = chatInput.trim()
+    if (!text || chatSending || !activeChatAgent) return
+    if (!lastSimulationStateRef.current) {
+      setError("没有可用的仿真状态,无法采访")
+      return
+    }
+    const llmConfig = resolveDefaultModel(baseLlmConfig)
+    const agent = lastAgentsRef.current.find(
+      (a) => a.characterId === activeChatAgent.id,
+    )
+    if (!agent) {
+      setError(`找不到角色:${activeChatAgent.name}`)
+      return
+    }
+
+    setChatInput("")
+    setChatSending(true)
+    chatStreamRef.current = ""
+
+    // 先添加一条占位的 agent 消息,流式更新
+    const placeholderId = `msg_${Date.now()}_stream`
+    const placeholder: AgentChatMessage = {
+      id: placeholderId,
+      role: "agent",
+      agentId: agent.characterId,
+      agentName: agent.name,
+      content: "",
+      timestamp: new Date().toISOString(),
+    }
+
+    try {
+      // 把已有消息(除 placeholder)组织成 session
+      const existingMessages = agentChatMessages
+      const session = {
+        agentId: agent.characterId,
+        agentName: agent.name,
+        messages: [...existingMessages],
+      }
+
+      addAgentChatMessage({
+        id: `msg_${Date.now()}_user`,
+        role: "user",
+        content: text,
+        timestamp: new Date().toISOString(),
+      })
+      addAgentChatMessage(placeholder)
+
+      await interviewAgent({
+        llmConfig,
+        agent,
+        simulationState: lastSimulationStateRef.current,
+        userPrompt: text,
+        session,
+        onToken: (token) => {
+          chatStreamRef.current += token
+          // 直接更新最后一条消息:通过替换 store 中的消息
+          // 简化:用 setAgentChatMessages 替换整条消息;这里用 addAgentChatMessage 不好做增量
+          // 我们用一个小技巧:拿到当前消息列表,替换最后一条 content
+          const state = useStorySimulationStore.getState()
+          const msgs = [...state.agentChatMessages]
+          const lastIdx = msgs.length - 1
+          if (lastIdx >= 0 && msgs[lastIdx].id === placeholderId) {
+            msgs[lastIdx] = { ...msgs[lastIdx], content: chatStreamRef.current }
+            useStorySimulationStore.setState({ agentChatMessages: msgs })
+          }
+        },
+        onDone: () => {},
+        onError: (err) => {
+          setError(err.message)
+        },
+      })
+    } catch (err) {
+      setError(err instanceof Error ? err.message : String(err))
+    } finally {
+      setChatSending(false)
+    }
   }
 
   // ── 渲染 ──
@@ -273,16 +453,8 @@ export function StorySimulationView() {
 
   return (
     <div className="flex h-full">
-      {/* 左栏:框架列表 */}
-      <div className="w-56 shrink-0 border-r">
-        <FrameworkList
-          onSelectFramework={handleSelectFramework}
-          onNewFramework={handleNewFramework}
-        />
-      </div>
-
-      {/* 右栏:根据 phase 切换面板 */}
-      <div className="flex flex-1 flex-col overflow-hidden">
+      {/* 主区域:单栏全宽 */}
+      <div className="flex min-w-0 flex-1 flex-col overflow-hidden">
         {error && (
           <div className="flex items-center justify-between gap-3 border-b border-red-500/30 bg-red-500/10 px-4 py-2 text-sm text-red-600 dark:text-red-400">
             <span>
@@ -298,23 +470,47 @@ export function StorySimulationView() {
           </div>
         )}
 
-        {isProgressPhase ? (
+        {isProgressPhase && phase !== "simulating" ? (
           <ProgressPanel
             progress={progress}
             label={progressLabel || progressTitle}
           />
+        ) : phase === "simulating" ? (
+          <SimulatingTimelinePanel
+            progress={progress}
+            label={progressLabel || progressTitle}
+            events={timelineEvents}
+          />
         ) : phase === "framework-confirming" ? (
           <div className="flex-1 overflow-y-auto p-4">
             <FrameworkConfirmPanel
               onConfirm={() => void handleConfirmFramework()}
               onRegenerate={handleRegenerateFramework}
+              onSave={() => void handleSaveFramework()}
             />
           </div>
         ) : phase === "report-viewing" ? (
-          <SimulationReportView
-            onResimulate={handleResimulate}
-            onGenerateDraft={(branch) => void handleGenerateDraft(branch)}
-          />
+          <div className="flex min-h-0 flex-1">
+            <div className="min-w-0 flex-1 overflow-hidden">
+              <SimulationReportView
+                onResimulate={handleResimulate}
+                onGenerateDraft={(branch) => void handleGenerateDraft(branch)}
+                onInterviewAgent={(id, name) => handleInterviewAgent(id, name)}
+              />
+            </div>
+            {activeChatAgent && (
+              <AgentChatPanel
+                agentName={activeChatAgent.name}
+                messages={agentChatMessages}
+                input={chatInput}
+                onInputChange={setChatInput}
+                onSend={() => void handleSendChat()}
+                onClose={handleCloseChat}
+                sending={chatSending}
+                chatLogRef={chatLogRef}
+              />
+            )}
+          </div>
         ) : phase === "draft-viewing" ? (
           <StoryDraftView onBack={handleBackToReport} />
         ) : (
@@ -349,3 +545,157 @@ function ProgressPanel({
     </div>
   )
 }
+
+/** 仿真中面板:进度条 + 实时时间线事件流。 */
+function SimulatingTimelinePanel({
+  progress,
+  label,
+  events,
+}: {
+  progress: number
+  label: string
+  events: TimelineEvent[]
+}) {
+  const clamped = Math.min(100, Math.max(0, progress))
+  const logRef = useRef<HTMLDivElement | null>(null)
+
+  useEffect(() => {
+    if (logRef.current) {
+      logRef.current.scrollTop = logRef.current.scrollHeight
+    }
+  }, [events.length])
+
+  return (
+    <div className="flex flex-1 flex-col p-6">
+      <div className="mb-4 flex flex-col items-center gap-2">
+        <div className="text-base font-medium">{label}</div>
+        <div className="h-2 w-64 max-w-full overflow-hidden rounded-full bg-muted">
+          <div
+            className="h-full rounded-full bg-primary transition-all"
+            style={{ width: `${clamped}%` }}
+          />
+        </div>
+        <div className="text-xs text-muted-foreground">{clamped}%</div>
+      </div>
+      <div
+        ref={logRef}
+        className="flex-1 overflow-y-auto rounded-lg border bg-muted/30 p-3 text-sm"
+      >
+        {events.length === 0 ? (
+          <div className="py-8 text-center text-xs text-muted-foreground">
+            等待角色行动...
+          </div>
+        ) : (
+          <div className="space-y-1.5">
+            {events.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>
+                <span className="font-medium">{ev.actorName}</span>
+                <span className="text-muted-foreground">
+                  {" "}
+                  {actionPhrase(ev.actionType, "", ev.targetName)}:
+                </span>
+                <span>{ev.content}</span>
+              </div>
+            ))}
+          </div>
+        )}
+      </div>
+    </div>
+  )
+}
+
+/** Agent 采访对话面板(右侧内联面板)。 */
+function AgentChatPanel({
+  agentName,
+  messages,
+  input,
+  onInputChange,
+  onSend,
+  onClose,
+  sending,
+  chatLogRef,
+}: {
+  agentName: string
+  messages: AgentChatMessage[]
+  input: string
+  onInputChange: (v: string) => void
+  onSend: () => void
+  onClose: () => void
+  sending: boolean
+  chatLogRef: React.RefObject<HTMLDivElement | null>
+}) {
+  return (
+    <div className="flex w-80 shrink-0 flex-col border-l">
+      <div className="flex shrink-0 items-center justify-between border-b px-3 py-2">
+        <div className="text-sm font-semibold">与 {agentName} 对话</div>
+        <Button
+          type="button"
+          size="icon"
+          variant="ghost"
+          className="h-7 w-7"
+          onClick={onClose}
+          title="关闭对话"
+        >
+          <X className="h-4 w-4" />
+        </Button>
+      </div>
+      <div
+        ref={chatLogRef}
+        className="flex-1 space-y-2 overflow-y-auto p-3 text-sm"
+      >
+        {messages.length === 0 ? (
+          <div className="py-8 text-center text-xs text-muted-foreground">
+            你可以向 {agentName} 提问,了解他/她的想法。
+          </div>
+        ) : (
+          messages.map((msg) => (
+            <div
+              key={msg.id}
+              className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}
+            >
+              <div
+                className={`max-w-[85%] rounded-lg px-3 py-2 text-sm ${
+                  msg.role === "user"
+                    ? "bg-primary text-primary-foreground"
+                    : "bg-muted"
+                }`}
+              >
+                {msg.content || (sending && msg.role === "agent" ? "..." : "")}
+              </div>
+            </div>
+          ))
+        )}
+      </div>
+      <div className="shrink-0 border-t p-2">
+        <div className="flex gap-2">
+          <input
+            type="text"
+            value={input}
+            onChange={(e) => onInputChange(e.target.value)}
+            onKeyDown={(e) => {
+              if (e.key === "Enter" && !e.shiftKey) {
+                e.preventDefault()
+                onSend()
+              }
+            }}
+            placeholder="输入你的问题..."
+            className="flex-1 rounded-md border bg-background px-2 py-1.5 text-sm outline-none focus:ring-1 focus:ring-ring"
+            disabled={sending}
+          />
+          <Button
+            type="button"
+            size="icon"
+            className="h-8 w-8"
+            onClick={onSend}
+            disabled={sending || !input.trim()}
+          >
+            <Send className="h-4 w-4" />
+          </Button>
+        </div>
+      </div>
+    </div>
+  )
+}

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

@@ -76,6 +76,9 @@ export function frameworkToMarkdown(framework: StoryFramework): string {
   lines.push(`id: ${yamlString(framework.id)}`)
   lines.push(`type: "story-framework"`)
   lines.push(`title: ${yamlString(framework.title)}`)
+  if (framework.shortTitle) {
+    lines.push(`shortTitle: ${yamlString(framework.shortTitle)}`)
+  }
   lines.push(`createdAt: ${yamlString(framework.createdAt)}`)
   lines.push(`sourceChapters: ${framework.sourceChapters}`)
   lines.push(`targetWords: ${framework.targetWords}`)
@@ -239,6 +242,8 @@ export function markdownToFramework(
 
     const id = fmStr(fm.id) || fallbackId || `fw-${Date.now()}`
     const title = fmStr(fm.title) || "未命名框架"
+    const shortTitleValue = fmStr(fm.shortTitle)
+    const shortTitle = shortTitleValue || undefined
     const createdAt = fmStr(fm.createdAt) || new Date().toISOString()
     const sourceChapters = parseInt(fmStr(fm.sourceChapters), 10) || 0
     const targetWords = parseInt(fmStr(fm.targetWords), 10) || 0
@@ -258,6 +263,7 @@ export function markdownToFramework(
     return {
       id,
       title,
+      shortTitle,
       premise,
       targetWords,
       simulationMode,

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

@@ -1,11 +1,13 @@
 import { create } from "zustand"
 import type {
+  AgentChatMessage,
   SimulationMode,
   StoryFramework,
   SimulationReport,
   StoryDraft,
   ExtractionResult,
   FrameworkBinding,
+  TimelineEvent,
 } from "@/lib/novel/story-simulation/types"
 
 export type SimulationPhase =
@@ -36,6 +38,14 @@ export interface StorySimulationState {
   error: string | null
   progress: number
   progressLabel: string
+  /** 仿真过程中的时间线事件(实时流) */
+  timelineEvents: TimelineEvent[]
+  /** 当前正在采访的角色 */
+  activeChatAgent: { id: string; name: string } | null
+  /** 采访对话消息 */
+  agentChatMessages: AgentChatMessage[]
+  /** 列表刷新计数(用于触发 framework-list 重新加载) */
+  listRefreshKey: number
 
   setPhase: (phase: SimulationPhase) => void
   setMode: (mode: SimulationMode) => void
@@ -51,6 +61,12 @@ export interface StorySimulationState {
   setBinding: (binding: FrameworkBinding | null) => void
   setError: (error: string | null) => void
   setProgress: (progress: number, label: string) => void
+  setTimelineEvents: (events: TimelineEvent[]) => void
+  addTimelineEvent: (event: TimelineEvent) => void
+  setActiveChatAgent: (agent: { id: string; name: string } | null) => void
+  addAgentChatMessage: (message: AgentChatMessage) => void
+  clearAgentChat: () => void
+  bumpListRefresh: () => void
   reset: () => void
 }
 
@@ -70,6 +86,10 @@ export const useStorySimulationStore = create<StorySimulationState>((set) => ({
   error: null,
   progress: 0,
   progressLabel: "",
+  timelineEvents: [],
+  activeChatAgent: null,
+  agentChatMessages: [],
+  listRefreshKey: 0,
 
   setPhase: (phase) => set({ phase }),
   setMode: (mode) => set({ mode }),
@@ -85,6 +105,14 @@ export const useStorySimulationStore = create<StorySimulationState>((set) => ({
   setBinding: (binding) => set({ binding }),
   setError: (error) => set({ error }),
   setProgress: (progress, progressLabel) => set({ progress, progressLabel }),
+  setTimelineEvents: (timelineEvents) => set({ timelineEvents }),
+  addTimelineEvent: (event) =>
+    set((state) => ({ timelineEvents: [...state.timelineEvents, event] })),
+  setActiveChatAgent: (activeChatAgent) => set({ activeChatAgent }),
+  addAgentChatMessage: (message) =>
+    set((state) => ({ agentChatMessages: [...state.agentChatMessages, message] })),
+  clearAgentChat: () => set({ agentChatMessages: [], activeChatAgent: null }),
+  bumpListRefresh: () => set((state) => ({ listRefreshKey: state.listRefreshKey + 1 })),
   reset: () =>
     set({
       phase: "idle",
@@ -95,5 +123,8 @@ export const useStorySimulationStore = create<StorySimulationState>((set) => ({
       error: null,
       progress: 0,
       progressLabel: "",
+      timelineEvents: [],
+      activeChatAgent: null,
+      agentChatMessages: [],
     }),
 }))