Procházet zdrojové kódy

feat(story-simulation): 历史结果管理优化 - 删除/草稿保存/时间线保存/框架搜索

1. 历史推演结果删除:鼠标悬停显示删除按钮,点击确认后删除(同时删除json和md)
2. 草稿自动保存:生成故事草稿后自动更新保存的推演结果,历史结果带'草稿'标签
3. 时间线事件保存:推演结果保存timelineEvents,查看历史结果时也能看时间线和关系图谱
4. 框架列表搜索:框架数量>3时显示搜索框,支持按标题/短标题/前提搜索
5. 历史结果选中高亮:当前查看的历史结果在侧边栏高亮显示
6. 切换框架时自动清空旧的report/draft/timelineEvents
Mochocyang před 3 měsíci
rodič
revize
0d3c2dfddf

+ 70 - 16
src/components/layout/sidebar-panel.tsx

@@ -27,7 +27,7 @@ import { FrameworkList } from "@/components/novel/story-simulation/framework-lis
 
 import { useWikiStore } from "@/stores/wiki-store"
 import { useStorySimulationStore } from "@/stores/story-simulation-store"
-import { loadFrameworks, loadSimulationResults } from "@/lib/novel/story-simulation/framework-store"
+import { loadFrameworks, loadSimulationResults, deleteSimulationResult } 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"
@@ -349,12 +349,16 @@ function StorySimulationSidebarPanel() {
   const setBinding = useStorySimulationStore((s) => s.setBinding)
   const setCurrentFramework = useStorySimulationStore((s) => s.setCurrentFramework)
   const setCurrentReport = useStorySimulationStore((s) => s.setCurrentReport)
+  const setCurrentDraft = useStorySimulationStore((s) => s.setCurrentDraft)
+  const setTimelineEvents = useStorySimulationStore((s) => s.setTimelineEvents)
   const setPhase = useStorySimulationStore((s) => s.setPhase)
   const setSavedResults = useStorySimulationStore((s) => s.setSavedResults)
   const setSelectedResultId = useStorySimulationStore((s) => s.setSelectedResultId)
   const currentFramework = useStorySimulationStore((s) => s.currentFramework)
   const savedResults = useStorySimulationStore((s) => s.savedResults)
+  const selectedResultId = useStorySimulationStore((s) => s.selectedResultId)
   const reset = useStorySimulationStore((s) => s.reset)
+  const [deletingId, setDeletingId] = useState<string | null>(null)
 
   // 加载指定框架的历史推演结果
   const loadResultsForFramework = useCallback(async (frameworkId: string) => {
@@ -365,6 +369,8 @@ function StorySimulationSidebarPanel() {
         id: r.id,
         frameworkId,
         report: r.report,
+        draft: r.draft,
+        timelineEvents: r.timelineEvents,
         createdAt: r.report.createdAt,
       })))
     } catch {
@@ -406,6 +412,8 @@ function StorySimulationSidebarPanel() {
   const handleSelectFramework = (framework: StoryFramework) => {
     setCurrentFramework(framework)
     setCurrentReport(null)
+    setCurrentDraft(null)
+    setTimelineEvents([])
     setSelectedResultId(null)
     setPhase("framework-confirming")
   }
@@ -414,11 +422,38 @@ function StorySimulationSidebarPanel() {
     const result = savedResults.find(r => r.id === resultId)
     if (result) {
       setCurrentReport(result.report)
+      setCurrentDraft(result.draft || null)
+      setTimelineEvents(result.timelineEvents || [])
       setSelectedResultId(resultId)
       setPhase("report-viewing")
     }
   }
 
+  const handleDeleteResult = async (e: { stopPropagation: () => void }, resultId: string) => {
+    e.stopPropagation() // 防止触发选择
+    if (!projectPath || !currentFramework) return
+    if (!confirm("确定要删除这个推演结果吗?此操作不可撤销。")) return
+
+    setDeletingId(resultId)
+    try {
+      await deleteSimulationResult(projectPath, currentFramework.id, resultId)
+      // 刷新列表
+      await loadResultsForFramework(currentFramework.id)
+      // 如果删除的是当前选中的结果,清空
+      if (selectedResultId === resultId) {
+        setCurrentReport(null)
+        setCurrentDraft(null)
+        setTimelineEvents([])
+        setSelectedResultId(null)
+        setPhase("framework-confirming")
+      }
+    } catch {
+      // 删除失败忽略
+    } finally {
+      setDeletingId(null)
+    }
+  }
+
   const handleNewFramework = () => {
     reset()
     setPhase("configuring")
@@ -455,24 +490,43 @@ function StorySimulationSidebarPanel() {
           </div>
           <div className="max-h-48 overflow-y-auto px-2 pb-2">
             {savedResults.map((result) => (
-              <button
+              <div
                 key={result.id}
-                type="button"
+                className={`group flex items-center gap-1 rounded px-2 py-1.5 text-xs transition-colors cursor-pointer hover:bg-accent ${
+                  selectedResultId === result.id ? "bg-accent" : ""
+                }`}
                 onClick={() => handleSelectResult(result.id)}
-                className="flex w-full flex-col items-start gap-0.5 rounded px-2 py-1.5 text-left text-xs transition-colors hover:bg-accent"
               >
-                <span className="truncate text-foreground">
-                  {new Date(result.createdAt).toLocaleString("zh-CN", {
-                    month: "2-digit",
-                    day: "2-digit",
-                    hour: "2-digit",
-                    minute: "2-digit",
-                  })}
-                </span>
-                <span className="truncate text-[11px] text-muted-foreground">
-                  {result.report.recommendation?.slice(0, 30) || "查看推演结果"}...
-                </span>
-              </button>
+                <div className="flex-1 min-w-0">
+                  <div className="flex items-center gap-1">
+                    <span className="truncate text-foreground">
+                      {new Date(result.createdAt).toLocaleString("zh-CN", {
+                        month: "2-digit",
+                        day: "2-digit",
+                        hour: "2-digit",
+                        minute: "2-digit",
+                      })}
+                    </span>
+                    {result.draft && (
+                      <span className="shrink-0 rounded bg-primary/10 px-1 text-[10px] text-primary">
+                        草稿
+                      </span>
+                    )}
+                  </div>
+                  <span className="block truncate text-[11px] text-muted-foreground">
+                    {result.report.recommendation?.slice(0, 25) || "查看推演结果"}...
+                  </span>
+                </div>
+                <button
+                  type="button"
+                  className="shrink-0 rounded p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-destructive/10 hover:text-destructive group-hover:opacity-100"
+                  onClick={(e) => void handleDeleteResult(e, result.id)}
+                  disabled={deletingId === result.id}
+                  title="删除此结果"
+                >
+                  <Trash2 className="h-3 w-3" />
+                </button>
+              </div>
             ))}
           </div>
         </div>

+ 101 - 65
src/components/novel/story-simulation/framework-list.tsx

@@ -1,5 +1,5 @@
-import { useEffect, useState } from "react"
-import { Link2, Trash2 } from "lucide-react"
+import { useEffect, useMemo, useState } from "react"
+import { Link2, Search, Trash2 } from "lucide-react"
 
 import { useWikiStore } from "@/stores/wiki-store"
 import {
@@ -47,6 +47,19 @@ export function FrameworkList({
   const [dialogFramework, setDialogFramework] =
     useState<StoryFramework | null>(null)
   const [deletingId, setDeletingId] = useState<string | null>(null)
+  const [searchQuery, setSearchQuery] = useState("")
+
+  // 过滤框架
+  const filteredFrameworks = useMemo(() => {
+    if (!searchQuery.trim()) return frameworks
+    const q = searchQuery.toLowerCase().trim()
+    return frameworks.filter(
+      (fw) =>
+        fw.title.toLowerCase().includes(q) ||
+        (fw.shortTitle && fw.shortTitle.toLowerCase().includes(q)) ||
+        fw.premise.toLowerCase().includes(q),
+    )
+  }, [frameworks, searchQuery])
 
   useEffect(() => {
     if (!projectPath) {
@@ -117,70 +130,93 @@ export function FrameworkList({
           </Button>
         </div>
       ) : (
-        <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={`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 min-w-0 flex-1 flex-col items-start"
-                  onClick={() => onSelectFramework(framework)}
-                >
-                  <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>
-                <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>
+        <>
+          {/* 搜索框 */}
+          {frameworks.length > 3 && (
+            <div className="shrink-0 px-2 pt-2">
+              <div className="relative">
+                <Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
+                <input
+                  type="text"
+                  value={searchQuery}
+                  onChange={(e) => setSearchQuery(e.target.value)}
+                  placeholder="搜索框架..."
+                  className="h-7 w-full rounded-md border border-input bg-background pl-7 pr-2 text-xs outline-none focus:border-ring focus:ring-1 focus:ring-ring/50"
+                />
               </div>
-            )
-          })}
-        </div>
+            </div>
+          )}
+          <div className="flex-1 space-y-1 overflow-y-auto p-2">
+            {filteredFrameworks.length === 0 ? (
+              <div className="py-4 text-center text-xs text-muted-foreground">
+                无匹配框架
+              </div>
+            ) : (
+              filteredFrameworks.map((framework) => {
+                const isBound = binding?.frameworkId === framework.id
+                const isSelected = currentFrameworkId === framework.id
+                return (
+                  <div
+                    key={framework.id}
+                    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 min-w-0 flex-1 flex-col items-start"
+                      onClick={() => onSelectFramework(framework)}
+                    >
+                      <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>
+                    <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>
+                )
+              })
+            )}
+          </div>
+        </>
       )}
 
       {dialogFramework && (

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

@@ -291,15 +291,23 @@ export function StorySimulationView() {
       setCurrentReport(report)
       setPhase("report-viewing")
 
-      // 自动保存推演结果
+      // 自动保存推演结果(包含时间线事件)
       try {
-        await saveSimulationResult(projectPath, currentFramework.id, report)
+        await saveSimulationResult(
+          projectPath,
+          currentFramework.id,
+          report,
+          undefined,
+          timelineEvents,
+        )
         // 刷新历史结果列表
         const results = await loadSimulationResults(projectPath, currentFramework.id)
         setSavedResults(results.map(r => ({
           id: r.id,
           frameworkId: currentFramework.id,
           report: r.report,
+          draft: r.draft,
+          timelineEvents: r.timelineEvents,
           createdAt: r.report.createdAt,
         })))
       } catch (saveErr) {
@@ -325,8 +333,8 @@ export function StorySimulationView() {
 
   /** 选择走向分支并生成故事草稿。 */
   const handleGenerateDraft = async (branch: StoryBranch) => {
-    if (!currentFramework || !currentReport) {
-      setError("缺少故事框架或推演报告")
+    if (!projectPath || !currentFramework || !currentReport) {
+      setError("缺少项目路径、故事框架或推演报告")
       return
     }
     setError(null)
@@ -345,6 +353,29 @@ export function StorySimulationView() {
       })
       setCurrentDraft(draft)
       setPhase("draft-viewing")
+
+      // 更新保存的推演结果,添加草稿
+      try {
+        await saveSimulationResult(
+          projectPath,
+          currentFramework.id,
+          currentReport,
+          draft,
+          timelineEvents,
+        )
+        // 刷新历史结果列表
+        const results = await loadSimulationResults(projectPath, currentFramework.id)
+        setSavedResults(results.map(r => ({
+          id: r.id,
+          frameworkId: currentFramework.id,
+          report: r.report,
+          draft: r.draft,
+          timelineEvents: r.timelineEvents,
+          createdAt: r.report.createdAt,
+        })))
+      } catch (saveErr) {
+        console.error("更新推演结果草稿失败:", saveErr)
+      }
     } catch (err) {
       setError(err instanceof Error ? err.message : String(err))
       setPhase("report-viewing")

+ 42 - 4
src/lib/novel/story-simulation/framework-store.ts

@@ -22,6 +22,7 @@ import type {
   StoryDraft,
   StoryFramework,
   StoryNode,
+  TimelineEvent,
 } from "./types"
 
 const SIM_ROOT = ".qmai/simulations"
@@ -400,13 +401,18 @@ export async function saveSimulationResult(
   frameworkId: string,
   report: SimulationReport,
   draft?: StoryDraft,
+  timelineEvents?: TimelineEvent[],
 ): Promise<string> {
   await ensureSimulationDirs(projectPath)
   const resultId = `result-${Date.now()}`
   const dir = frameworkResultsDir(projectPath, frameworkId)
   await createDirectory(dir)
 
-  const payload = { report, draft: draft ?? null }
+  const payload = {
+    report,
+    draft: draft ?? null,
+    timelineEvents: timelineEvents ?? [],
+  }
   await writeFileAtomic(`${dir}/${resultId}.json`, JSON.stringify(payload, null, 2))
   await writeFileAtomic(
     `${dir}/${resultId}.md`,
@@ -415,11 +421,35 @@ export async function saveSimulationResult(
   return resultId
 }
 
+/** 删除指定的推演结果。 */
+export async function deleteSimulationResult(
+  projectPath: string,
+  frameworkId: string,
+  resultId: string,
+): Promise<void> {
+  const dir = frameworkResultsDir(projectPath, frameworkId)
+  try {
+    await deleteFile(`${dir}/${resultId}.json`)
+  } catch {
+    // 文件可能不存在
+  }
+  try {
+    await deleteFile(`${dir}/${resultId}.md`)
+  } catch {
+    // 文件可能不存在
+  }
+}
+
 /** 加载框架的所有推演结果,按 report.createdAt 降序排列。 */
 export async function loadSimulationResults(
   projectPath: string,
   frameworkId: string,
-): Promise<{ id: string; report: SimulationReport }[]> {
+): Promise<{
+  id: string
+  report: SimulationReport
+  draft?: StoryDraft | null
+  timelineEvents?: TimelineEvent[]
+}[]> {
   const dir = frameworkResultsDir(projectPath, frameworkId)
   let entries: FileNode[]
   try {
@@ -428,7 +458,12 @@ export async function loadSimulationResults(
     return []
   }
 
-  const results: { id: string; report: SimulationReport }[] = []
+  const results: {
+    id: string
+    report: SimulationReport
+    draft?: StoryDraft | null
+    timelineEvents?: TimelineEvent[]
+  }[] = []
   for (const entry of entries) {
     if (entry.is_dir) continue
     if (!entry.name.toLowerCase().endsWith(".json")) continue
@@ -436,12 +471,15 @@ export async function loadSimulationResults(
       const content = await readFile(entry.path)
       const parsed = JSON.parse(content) as {
         report: SimulationReport
-        draft?: StoryDraft
+        draft?: StoryDraft | null
+        timelineEvents?: TimelineEvent[]
       }
       if (parsed && parsed.report) {
         results.push({
           id: entry.name.replace(/\.json$/i, ""),
           report: parsed.report,
+          draft: parsed.draft ?? null,
+          timelineEvents: parsed.timelineEvents ?? [],
         })
       }
     } catch {

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

@@ -14,6 +14,8 @@ export interface SavedSimulationResult {
   id: string
   frameworkId: string
   report: SimulationReport
+  draft?: StoryDraft | null
+  timelineEvents?: TimelineEvent[]
   createdAt: string
 }