Przeglądaj źródła

feat(story-simulation): 优化功能 - 仿真轮次配置/时间线筛选/对话导出/关系图谱

1. 仿真轮次配置:配置面板增加仿真深度选择(自动/2轮快速/3轮标准/5轮深度/8轮充分)
2. 时间线筛选:仿真过程中可按角色和行为类型筛选事件,显示过滤计数
3. 对话导出:角色采访面板增加导出按钮,将对话记录保存为MD文件到.qmai/simulations/interviews/
4. 角色关系网络图谱:报告页增加SVG关系图,展示角色活跃度、互动次数、情感倾向(友好/中立/敌对)
5. 框架绑定AI会话:确认已完整实现(已通过storyFrameworkBindingDataSource自动注入上下文)
Mochocyang 3 miesięcy temu
rodzic
commit
59e16b26b2

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

@@ -15,6 +15,13 @@ const MODES = [
 ]
 ]
 
 
 const CHAPTER_OPTIONS = [5, 10, 20, 30, 50]
 const CHAPTER_OPTIONS = [5, 10, 20, 30, 50]
+const ROUND_OPTIONS = [
+  { value: 0, label: "自动(按字数)" },
+  { value: 2, label: "快速(2轮)" },
+  { value: 3, label: "标准(3轮)" },
+  { value: 5, label: "深度(5轮)" },
+  { value: 8, label: "充分(8轮)" },
+]
 
 
 const WORD_LABEL_KEYS: Record<number, string> = {
 const WORD_LABEL_KEYS: Record<number, string> = {
   10000: "storySimulation.words10k",
   10000: "storySimulation.words10k",
@@ -32,10 +39,12 @@ export function SimulationConfigPanel({ onStart }: SimulationConfigPanelProps) {
   const userIdea = useStorySimulationStore((s) => s.userIdea)
   const userIdea = useStorySimulationStore((s) => s.userIdea)
   const targetWords = useStorySimulationStore((s) => s.targetWords)
   const targetWords = useStorySimulationStore((s) => s.targetWords)
   const sourceChapters = useStorySimulationStore((s) => s.sourceChapters)
   const sourceChapters = useStorySimulationStore((s) => s.sourceChapters)
+  const simulationRounds = useStorySimulationStore((s) => s.simulationRounds)
   const setMode = useStorySimulationStore((s) => s.setMode)
   const setMode = useStorySimulationStore((s) => s.setMode)
   const setUserIdea = useStorySimulationStore((s) => s.setUserIdea)
   const setUserIdea = useStorySimulationStore((s) => s.setUserIdea)
   const setTargetWords = useStorySimulationStore((s) => s.setTargetWords)
   const setTargetWords = useStorySimulationStore((s) => s.setTargetWords)
   const setSourceChapters = useStorySimulationStore((s) => s.setSourceChapters)
   const setSourceChapters = useStorySimulationStore((s) => s.setSourceChapters)
+  const setSimulationRounds = useStorySimulationStore((s) => s.setSimulationRounds)
 
 
   return (
   return (
     <div className="mx-auto flex w-full max-w-2xl flex-col gap-6 p-6">
     <div className="mx-auto flex w-full max-w-2xl flex-col gap-6 p-6">
@@ -119,7 +128,27 @@ export function SimulationConfigPanel({ onStart }: SimulationConfigPanelProps) {
         </select>
         </select>
       </section>
       </section>
 
 
-      {/* 5. 开始按钮 */}
+      {/* 5. 仿真轮次 */}
+      <section className="flex flex-col gap-3">
+        <h3 className="text-sm font-medium">仿真深度(每节点轮次)</h3>
+        <div className="flex flex-wrap items-center gap-2">
+          {ROUND_OPTIONS.map((opt) => (
+            <Button
+              key={opt.value}
+              variant={simulationRounds === opt.value ? "default" : "outline"}
+              size="sm"
+              onClick={() => setSimulationRounds(opt.value)}
+            >
+              {opt.label}
+            </Button>
+          ))}
+        </div>
+        <p className="text-xs text-muted-foreground">
+          轮次越多,角色互动越丰富,但消耗token越多。自动模式:1万字约1轮,至少2轮。
+        </p>
+      </section>
+
+      {/* 6. 开始按钮 */}
       <div className="flex justify-end pt-2">
       <div className="flex justify-end pt-2">
         <Button onClick={onStart} size="lg">
         <Button onClick={onStart} size="lg">
           {t("storySimulation.startExtract")}
           {t("storySimulation.startExtract")}

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

@@ -1,5 +1,6 @@
+import { useMemo } from "react"
 import { useTranslation } from "react-i18next"
 import { useTranslation } from "react-i18next"
-import { MessageCircle, RefreshCw, Sparkles, TrendingUp } from "lucide-react"
+import { MessageCircle, RefreshCw, Sparkles, TrendingUp, Network } from "lucide-react"
 import { Button } from "@/components/ui/button"
 import { Button } from "@/components/ui/button"
 import { useStorySimulationStore } from "@/stores/story-simulation-store"
 import { useStorySimulationStore } from "@/stores/story-simulation-store"
 import type { StoryBranch } from "@/lib/novel/story-simulation/types"
 import type { StoryBranch } from "@/lib/novel/story-simulation/types"
@@ -53,6 +54,58 @@ export function SimulationReportView({
 
 
   if (!report) return null
   if (!report) return null
 
 
+  // 构建角色关系网络数据
+  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) {
+      activityCount.set(ev.actorName, (activityCount.get(ev.actorName) || 0) + 1)
+      if (ev.targetName) {
+        activityCount.set(ev.targetName, (activityCount.get(ev.targetName) || 0) + 1)
+        const pair = [ev.actorName, ev.targetName].sort().join("|")
+        const existing = interactions.get(pair) || { count: 0, sentiment: 0, lastAction: "" }
+        let sentimentDelta = 0
+        switch (ev.actionType) {
+          case "ally":
+            sentimentDelta = 2
+            break
+          case "speak":
+            sentimentDelta = 0.5
+            break
+          case "confront":
+            sentimentDelta = -2
+            break
+          case "react":
+            sentimentDelta = ev.content.includes("好感") || ev.content.includes("赞同") ? 1 : -1
+            break
+          default:
+            sentimentDelta = 0
+        }
+        interactions.set(pair, {
+          count: existing.count + 1,
+          sentiment: Math.max(-5, Math.min(5, existing.sentiment + sentimentDelta)),
+          lastAction: ev.content.slice(0, 30),
+        })
+      }
+    }
+
+    const characters = Array.from(activityCount.entries())
+      .map(([name, count]) => ({ name, count }))
+      .sort((a, b) => b.count - a.count)
+
+    const edges = Array.from(interactions.entries()).map(([key, data]) => {
+      const [from, to] = key.split("|")
+      return { from, to, ...data }
+    })
+
+    return { characters, edges }
+  }, [timelineEvents])
+
   return (
   return (
     <div className="flex h-full flex-col">
     <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 justify-between border-b px-4 py-3">
@@ -68,6 +121,17 @@ export function SimulationReportView({
 
 
       <div className="flex-1 overflow-y-auto p-4">
       <div className="flex-1 overflow-y-auto p-4">
         <div className="mx-auto max-w-3xl space-y-6">
         <div className="mx-auto max-w-3xl space-y-6">
+          {/* 角色关系网络 */}
+          {relationshipData && relationshipData.characters.length > 1 && (
+            <section>
+              <h3 className="mb-3 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
+                <Network className="h-3.5 w-3.5" />
+                角色关系网络
+              </h3>
+              <RelationshipGraph data={relationshipData} />
+            </section>
+          )}
+
           {/* 关键剧情事件时间线 */}
           {/* 关键剧情事件时间线 */}
           {timelineEvents.length > 0 && (
           {timelineEvents.length > 0 && (
             <section>
             <section>
@@ -272,3 +336,143 @@ export function SimulationReportView({
     </div>
     </div>
   )
   )
 }
 }
+
+// ── 角色关系图谱组件(SVG 实现,轻量无依赖) ──
+
+interface RelationNode {
+  name: string
+  count: number
+}
+
+interface RelationEdge {
+  from: string
+  to: string
+  count: number
+  sentiment: number
+  lastAction: string
+}
+
+interface RelationshipGraphData {
+  characters: RelationNode[]
+  edges: RelationEdge[]
+}
+
+function RelationshipGraph({ data }: { data: RelationshipGraphData }) {
+  const { characters, edges } = data
+  const width = 520
+  const height = 380
+  const cx = width / 2
+  const cy = height / 2
+  const radius = Math.min(cx, cy) - 50
+
+  // 圆形布局:按活跃度排序,主角居中
+  const positions = useMemo(() => {
+    const posMap = new Map<string, { x: number; y: number }>()
+    const maxNodes = Math.min(characters.length, 10) // 最多显示10个角色
+
+    if (characters.length === 0) return posMap
+
+    // 最活跃角色放中心
+    const main = characters[0]
+    posMap.set(main.name, { x: cx, y: cy })
+
+    // 其他角色围一圈
+    const others = characters.slice(1, maxNodes)
+    others.forEach((char, i) => {
+      const angle = (i / others.length) * Math.PI * 2 - Math.PI / 2
+      const x = cx + Math.cos(angle) * radius
+      const y = cy + Math.sin(angle) * radius
+      posMap.set(char.name, { x, y })
+    })
+
+    return posMap
+  }, [characters, cx, cy, radius])
+
+  const maxActivity = characters[0]?.count || 1
+
+  const nodeRadius = (count: number, isMain: boolean) => {
+    if (isMain) return 28
+    return 14 + (count / maxActivity) * 14
+  }
+
+  const edgeColor = (sentiment: number) => {
+    if (sentiment > 1) return "#22c55e" // 绿色-友好
+    if (sentiment < -1) return "#ef4444" // 红色-敌对
+    return "#94a3b8" // 灰色-中立
+  }
+
+  const edgeWidth = (count: number) => Math.max(1, Math.min(4, count / 2))
+
+  return (
+    <div className="rounded-lg border bg-muted/20 p-3">
+      <svg viewBox={`0 0 ${width} ${height}`} className="w-full" style={{ maxHeight: 380 }}>
+        {/* 绘制边 */}
+        {edges.map((edge, i) => {
+          const from = positions.get(edge.from)
+          const to = positions.get(edge.to)
+          if (!from || !to) return null
+          return (
+            <line
+              key={i}
+              x1={from.x}
+              y1={from.y}
+              x2={to.x}
+              y2={to.y}
+              stroke={edgeColor(edge.sentiment)}
+              strokeWidth={edgeWidth(edge.count)}
+              strokeOpacity={0.6}
+            >
+              <title>{`${edge.from} ↔ ${edge.to}\n互动${edge.count}次\n情感倾向:${edge.sentiment > 1 ? "友好" : edge.sentiment < -1 ? "敌对" : "中立"}`}</title>
+            </line>
+          )
+        })}
+
+        {/* 绘制节点 */}
+        {characters.slice(0, 10).map((char, i) => {
+          const pos = positions.get(char.name)
+          if (!pos) return null
+          const isMain = i === 0
+          const r = nodeRadius(char.count, isMain)
+          return (
+            <g key={char.name}>
+              <circle
+                cx={pos.x}
+                cy={pos.y}
+                r={r}
+                fill={isMain ? "hsl(var(--primary))" : "hsl(var(--muted))"}
+                stroke={isMain ? "hsl(var(--primary))" : "hsl(var(--border))"}
+                strokeWidth={2}
+              >
+                <title>{`${char.name}\n参与事件:${char.count}次${isMain ? "\n(核心角色)" : ""}`}</title>
+              </circle>
+              <text
+                x={pos.x}
+                y={pos.y + r + 14}
+                textAnchor="middle"
+                fontSize={11}
+                fill="currentColor"
+                className="fill-muted-foreground"
+              >
+                {char.name.length > 4 ? char.name.slice(0, 4) : char.name}
+              </text>
+            </g>
+          )
+        })}
+      </svg>
+
+      {/* 图例 */}
+      <div className="mt-2 flex flex-wrap items-center justify-center gap-4 text-[11px] text-muted-foreground">
+        <span className="flex items-center gap-1">
+          <span className="inline-block h-0.5 w-4 bg-[#22c55e]" /> 友好
+        </span>
+        <span className="flex items-center gap-1">
+          <span className="inline-block h-0.5 w-4 bg-[#94a3b8]" /> 中立
+        </span>
+        <span className="flex items-center gap-1">
+          <span className="inline-block h-0.5 w-4 bg-[#ef4444]" /> 敌对
+        </span>
+        <span>· 节点大小=活跃度 · 线粗细=互动次数</span>
+      </div>
+    </div>
+  )
+}

+ 137 - 16
src/components/novel/story-simulation/story-simulation-view.tsx

@@ -1,6 +1,6 @@
 import { useEffect, useRef, useState } from "react"
 import { useEffect, useRef, useState } from "react"
 import { useTranslation } from "react-i18next"
 import { useTranslation } from "react-i18next"
-import { Send, X } from "lucide-react"
+import { Send, X, Download } from "lucide-react"
 
 
 import { useWikiStore } from "@/stores/wiki-store"
 import { useWikiStore } from "@/stores/wiki-store"
 import { useStorySimulationStore } from "@/stores/story-simulation-store"
 import { useStorySimulationStore } from "@/stores/story-simulation-store"
@@ -16,6 +16,7 @@ import { generateStoryDraft } from "@/lib/novel/story-simulation/story-draft-gen
 import { saveFramework } from "@/lib/novel/story-simulation/framework-store"
 import { saveFramework } from "@/lib/novel/story-simulation/framework-store"
 import { resolveDefaultModel } from "@/lib/novel/model-resolver"
 import { resolveDefaultModel } from "@/lib/novel/model-resolver"
 import { interviewAgent } from "@/lib/novel/story-simulation/agent-interview"
 import { interviewAgent } from "@/lib/novel/story-simulation/agent-interview"
+import { exportInterview } from "@/lib/novel/story-simulation/interview-export"
 import type {
 import type {
   AgentChatMessage,
   AgentChatMessage,
   ExtractionResult,
   ExtractionResult,
@@ -91,6 +92,7 @@ export function StorySimulationView() {
   const userIdea = useStorySimulationStore((s) => s.userIdea)
   const userIdea = useStorySimulationStore((s) => s.userIdea)
   const targetWords = useStorySimulationStore((s) => s.targetWords)
   const targetWords = useStorySimulationStore((s) => s.targetWords)
   const sourceChapters = useStorySimulationStore((s) => s.sourceChapters)
   const sourceChapters = useStorySimulationStore((s) => s.sourceChapters)
+  const simulationRounds = useStorySimulationStore((s) => s.simulationRounds)
   const extractionResult = useStorySimulationStore((s) => s.extractionResult)
   const extractionResult = useStorySimulationStore((s) => s.extractionResult)
   const currentFramework = useStorySimulationStore((s) => s.currentFramework)
   const currentFramework = useStorySimulationStore((s) => s.currentFramework)
   const currentReport = useStorySimulationStore((s) => s.currentReport)
   const currentReport = useStorySimulationStore((s) => s.currentReport)
@@ -126,6 +128,7 @@ export function StorySimulationView() {
   // 采访输入框
   // 采访输入框
   const [chatInput, setChatInput] = useState("")
   const [chatInput, setChatInput] = useState("")
   const [chatSending, setChatSending] = useState(false)
   const [chatSending, setChatSending] = useState(false)
+  const [chatExporting, setChatExporting] = useState(false)
   const chatStreamRef = useRef("")
   const chatStreamRef = useRef("")
   const chatLogRef = useRef<HTMLDivElement | null>(null)
   const chatLogRef = useRef<HTMLDivElement | null>(null)
 
 
@@ -258,6 +261,7 @@ export function StorySimulationView() {
           wordBudget: targetWords,
           wordBudget: targetWords,
           llmConfig,
           llmConfig,
           userIdea: userIdea || undefined,
           userIdea: userIdea || undefined,
+          maxRoundsPerNode: simulationRounds > 0 ? simulationRounds : undefined,
         },
         },
         extraction,
         extraction,
         callbacks,
         callbacks,
@@ -347,6 +351,25 @@ export function StorySimulationView() {
     clearAgentChat()
     clearAgentChat()
   }
   }
 
 
+  /** 导出对话记录为MD。 */
+  const handleExportChat = async () => {
+    if (!activeChatAgent || !projectPath || agentChatMessages.length === 0) return
+    setChatExporting(true)
+    try {
+      const filePath = await exportInterview(
+        projectPath,
+        activeChatAgent.name,
+        agentChatMessages,
+      )
+      setError(`对话已导出到:${filePath}`)
+      setTimeout(() => setError(null), 5000)
+    } catch (err) {
+      setError(err instanceof Error ? err.message : "导出失败")
+    } finally {
+      setChatExporting(false)
+    }
+  }
+
   /** 发送采访消息。 */
   /** 发送采访消息。 */
   const handleSendChat = async () => {
   const handleSendChat = async () => {
     const text = chatInput.trim()
     const text = chatInput.trim()
@@ -505,7 +528,9 @@ export function StorySimulationView() {
                 onInputChange={setChatInput}
                 onInputChange={setChatInput}
                 onSend={() => void handleSendChat()}
                 onSend={() => void handleSendChat()}
                 onClose={handleCloseChat}
                 onClose={handleCloseChat}
+                onExport={() => void handleExportChat()}
                 sending={chatSending}
                 sending={chatSending}
+                exporting={chatExporting}
                 chatLogRef={chatLogRef}
                 chatLogRef={chatLogRef}
               />
               />
             )}
             )}
@@ -545,7 +570,7 @@ function ProgressPanel({
   )
   )
 }
 }
 
 
-/** 仿真中面板:进度条 + 实时时间线事件流。 */
+/** 仿真中面板:进度条 + 实时时间线事件流(带筛选)。 */
 function SimulatingTimelinePanel({
 function SimulatingTimelinePanel({
   progress,
   progress,
   label,
   label,
@@ -558,11 +583,47 @@ function SimulatingTimelinePanel({
   const clamped = Math.min(100, Math.max(0, progress))
   const clamped = Math.min(100, Math.max(0, progress))
   const logRef = useRef<HTMLDivElement | null>(null)
   const logRef = useRef<HTMLDivElement | null>(null)
 
 
+  // 筛选状态
+  const [filterActor, setFilterActor] = useState<string>("all")
+  const [filterType, setFilterType] = useState<string>("all")
+
+  // 从事件中提取所有角色和行动类型
+  const actors = Array.from(new Set(events.map((e) => e.actorName))).sort()
+  const actionTypes = Array.from(new Set(events.map((e) => e.actionType))).sort()
+
+  // 过滤事件
+  const filteredEvents = events.filter((e) => {
+    if (filterActor !== "all" && e.actorName !== filterActor) return false
+    if (filterType !== "all" && e.actionType !== filterType) return false
+    return true
+  })
+
   useEffect(() => {
   useEffect(() => {
     if (logRef.current) {
     if (logRef.current) {
       logRef.current.scrollTop = logRef.current.scrollHeight
       logRef.current.scrollTop = logRef.current.scrollHeight
     }
     }
-  }, [events.length])
+  }, [filteredEvents.length])
+
+  // 行动类型中文标签
+  const actionTypeLabel = (type: string): string => {
+    const map: Record<string, string> = {
+      evaluate: "评价",
+      pushPlot: "推动",
+      observe: "观察",
+      react: "反应",
+      speak: "对话",
+      ally: "示好",
+      confront: "对抗",
+      conceal: "隐瞒",
+      investigate: "调查",
+      act: "行动",
+      decide: "决策",
+      conflict: "冲突",
+      cooperate: "合作",
+      withhold: "隐瞒",
+    }
+    return map[type] || type
+  }
 
 
   return (
   return (
     <div className="flex flex-1 flex-col p-6">
     <div className="flex flex-1 flex-col p-6">
@@ -576,17 +637,60 @@ function SimulatingTimelinePanel({
         </div>
         </div>
         <div className="text-xs text-muted-foreground">{clamped}%</div>
         <div className="text-xs text-muted-foreground">{clamped}%</div>
       </div>
       </div>
+
+      {/* 筛选栏 */}
+      {events.length > 0 && (
+        <div className="mb-3 flex flex-wrap items-center gap-2 text-xs">
+          <span className="text-muted-foreground">筛选:</span>
+          <select
+            value={filterActor}
+            onChange={(e) => setFilterActor(e.target.value)}
+            className="h-7 rounded border border-input bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
+          >
+            <option value="all">全部角色</option>
+            {actors.map((name) => (
+              <option key={name} value={name}>{name}</option>
+            ))}
+          </select>
+          <select
+            value={filterType}
+            onChange={(e) => setFilterType(e.target.value)}
+            className="h-7 rounded border border-input bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
+          >
+            <option value="all">全部行为</option>
+            {actionTypes.map((type) => (
+              <option key={type} value={type}>{actionTypeLabel(type)}</option>
+            ))}
+          </select>
+          {(filterActor !== "all" || filterType !== "all") && (
+            <button
+              type="button"
+              className="text-xs text-primary hover:underline"
+              onClick={() => {
+                setFilterActor("all")
+                setFilterType("all")
+              }}
+            >
+              清除筛选
+            </button>
+          )}
+          <span className="ml-auto text-muted-foreground">
+            显示 {filteredEvents.length}/{events.length} 条
+          </span>
+        </div>
+      )}
+
       <div
       <div
         ref={logRef}
         ref={logRef}
         className="flex-1 overflow-y-auto rounded-lg border bg-muted/30 p-3 text-sm"
         className="flex-1 overflow-y-auto rounded-lg border bg-muted/30 p-3 text-sm"
       >
       >
-        {events.length === 0 ? (
+        {filteredEvents.length === 0 ? (
           <div className="py-8 text-center text-xs text-muted-foreground">
           <div className="py-8 text-center text-xs text-muted-foreground">
-            等待角色行动...
+            {events.length === 0 ? "等待角色行动..." : "没有符合筛选条件的事件"}
           </div>
           </div>
         ) : (
         ) : (
           <div className="space-y-1.5">
           <div className="space-y-1.5">
-            {events.map((ev) => (
+            {filteredEvents.map((ev) => (
               <div key={ev.id} className="leading-relaxed">
               <div key={ev.id} className="leading-relaxed">
                 <span className="mr-1 rounded bg-muted px-1 py-0.5 text-[10px] text-muted-foreground">
                 <span className="mr-1 rounded bg-muted px-1 py-0.5 text-[10px] text-muted-foreground">
                   {ev.nodeIndex + 1}-{ev.round + 1}
                   {ev.nodeIndex + 1}-{ev.round + 1}
@@ -614,7 +718,9 @@ function AgentChatPanel({
   onInputChange,
   onInputChange,
   onSend,
   onSend,
   onClose,
   onClose,
+  onExport,
   sending,
   sending,
+  exporting,
   chatLogRef,
   chatLogRef,
 }: {
 }: {
   agentName: string
   agentName: string
@@ -623,23 +729,38 @@ function AgentChatPanel({
   onInputChange: (v: string) => void
   onInputChange: (v: string) => void
   onSend: () => void
   onSend: () => void
   onClose: () => void
   onClose: () => void
+  onExport: () => void
   sending: boolean
   sending: boolean
+  exporting: boolean
   chatLogRef: React.RefObject<HTMLDivElement | null>
   chatLogRef: React.RefObject<HTMLDivElement | null>
 }) {
 }) {
   return (
   return (
     <div className="flex w-80 shrink-0 flex-col border-l">
     <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="flex shrink-0 items-center justify-between border-b px-3 py-2">
         <div className="text-sm font-semibold">与 {agentName} 对话</div>
         <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 className="flex items-center gap-1">
+          <Button
+            type="button"
+            size="icon"
+            variant="ghost"
+            className="h-7 w-7"
+            onClick={onExport}
+            title="导出对话为MD"
+            disabled={exporting || messages.length === 0}
+          >
+            <Download className="h-4 w-4" />
+          </Button>
+          <Button
+            type="button"
+            size="icon"
+            variant="ghost"
+            className="h-7 w-7"
+            onClick={onClose}
+            title="关闭对话"
+          >
+            <X className="h-4 w-4" />
+          </Button>
+        </div>
       </div>
       </div>
       <div
       <div
         ref={chatLogRef}
         ref={chatLogRef}

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

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

+ 4 - 5
src/lib/novel/story-simulation/simulation-engine.ts

@@ -740,12 +740,11 @@ export async function runSimulation(
   signal?: AbortSignal,
   signal?: AbortSignal,
 ): Promise<SimulationEvent[]> {
 ): Promise<SimulationEvent[]> {
   const events: SimulationEvent[] = []
   const events: SimulationEvent[] = []
-  const { agents, framework, wordBudget, llmConfig, injectionEvent } = input
+  const { agents, framework, wordBudget, llmConfig, injectionEvent, maxRoundsPerNode } = input
   const totalNodes = framework.nodes.length
   const totalNodes = framework.nodes.length
-  const maxRounds = Math.max(
-    1,
-    calcMaxRoundsPerNode(wordBudget) || MAX_ROUNDS_PER_NODE_FALLBACK,
-  )
+  // 使用用户指定的轮数,若未指定则自动计算
+  const calculatedRounds = calcMaxRoundsPerNode(wordBudget) || MAX_ROUNDS_PER_NODE_FALLBACK
+  const maxRounds = Math.max(1, maxRoundsPerNode ?? calculatedRounds)
   let aborted = false
   let aborted = false
 
 
   // 初始化仿真状态
   // 初始化仿真状态

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

@@ -281,6 +281,8 @@ export interface SimulationInput {
   llmConfig: LlmConfig
   llmConfig: LlmConfig
   userIdea?: string
   userIdea?: string
   injectionEvent?: string
   injectionEvent?: string
+  /** 每个节点的仿真轮数,不传则根据字数自动计算 */
+  maxRoundsPerNode?: number
 }
 }
 
 
 // ── 仿真配置 ──
 // ── 仿真配置 ──

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

@@ -28,6 +28,8 @@ export interface StorySimulationState {
   userIdea: string
   userIdea: string
   targetWords: number
   targetWords: number
   sourceChapters: number
   sourceChapters: number
+  /** 每个节点仿真轮数,0表示自动 */
+  simulationRounds: number
   extractionResult: ExtractionResult | null
   extractionResult: ExtractionResult | null
   currentFramework: StoryFramework | null
   currentFramework: StoryFramework | null
   currentReport: SimulationReport | null
   currentReport: SimulationReport | null
@@ -52,6 +54,7 @@ export interface StorySimulationState {
   setUserIdea: (idea: string) => void
   setUserIdea: (idea: string) => void
   setTargetWords: (words: number) => void
   setTargetWords: (words: number) => void
   setSourceChapters: (count: number) => void
   setSourceChapters: (count: number) => void
+  setSimulationRounds: (rounds: number) => void
   setExtractionResult: (result: ExtractionResult | null) => void
   setExtractionResult: (result: ExtractionResult | null) => void
   setCurrentFramework: (framework: StoryFramework | null) => void
   setCurrentFramework: (framework: StoryFramework | null) => void
   setCurrentReport: (report: SimulationReport | null) => void
   setCurrentReport: (report: SimulationReport | null) => void
@@ -76,6 +79,7 @@ export const useStorySimulationStore = create<StorySimulationState>((set) => ({
   userIdea: "",
   userIdea: "",
   targetWords: 10000,
   targetWords: 10000,
   sourceChapters: 10,
   sourceChapters: 10,
+  simulationRounds: 0,
   extractionResult: null,
   extractionResult: null,
   currentFramework: null,
   currentFramework: null,
   currentReport: null,
   currentReport: null,
@@ -96,6 +100,7 @@ export const useStorySimulationStore = create<StorySimulationState>((set) => ({
   setUserIdea: (userIdea) => set({ userIdea }),
   setUserIdea: (userIdea) => set({ userIdea }),
   setTargetWords: (targetWords) => set({ targetWords }),
   setTargetWords: (targetWords) => set({ targetWords }),
   setSourceChapters: (sourceChapters) => set({ sourceChapters }),
   setSourceChapters: (sourceChapters) => set({ sourceChapters }),
+  setSimulationRounds: (simulationRounds) => set({ simulationRounds }),
   setExtractionResult: (extractionResult) => set({ extractionResult }),
   setExtractionResult: (extractionResult) => set({ extractionResult }),
   setCurrentFramework: (currentFramework) => set({ currentFramework }),
   setCurrentFramework: (currentFramework) => set({ currentFramework }),
   setCurrentReport: (currentReport) => set({ currentReport }),
   setCurrentReport: (currentReport) => set({ currentReport }),