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

feat(story-simulation): 实现框架持久化和 AI 会话绑定

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

+ 24 - 0
src/lib/novel/context-data-sources.ts

@@ -14,6 +14,8 @@ import { getChapterVolumes } from "./volume"
 import { readSoulDoc } from "./soul-doc"
 import { buildWritingStyleContext } from "./writing-style-store"
 import type { DataSource, ContextLoadContext } from "./context-data-source"
+import { loadFrameworks } from "./story-simulation/framework-store"
+import { loadBinding, buildBindingContext } from "./story-simulation/framework-binding"
 
 // 导入现有的辅助函数
 import {
@@ -441,6 +443,27 @@ export const characterAurasDataSource: DataSource<string> = {
   },
 }
 
+/**
+ * 故事框架绑定数据源
+ * 加载当前激活的框架绑定,构建注入 AI 会话的上下文文本。
+ */
+export const storyFrameworkBindingDataSource: DataSource<string> = {
+  name: "storyFrameworkBinding",
+  priority: 19,
+  async load(context: ContextLoadContext): Promise<string> {
+    try {
+      const binding = await loadBinding(context.projectPath)
+      if (!binding) return ""
+      const frameworks = await loadFrameworks(context.projectPath)
+      const framework = frameworks.find((f) => f.id === binding.frameworkId)
+      if (!framework) return ""
+      return buildBindingContext(binding, framework)
+    } catch {
+      return ""
+    }
+  },
+}
+
 /**
  * 获取所有数据源
  */
@@ -464,5 +487,6 @@ export function getAllDataSources(): DataSource<any>[] {
     revisionFeedbackDataSource,
     cognitionTextDataSource,
     soulDocDataSource,
+    storyFrameworkBindingDataSource,
   ]
 }

+ 132 - 0
src/lib/novel/story-simulation/framework-binding.ts

@@ -0,0 +1,132 @@
+/**
+ * AI 会话绑定
+ *
+ * 将一个 StoryFramework 绑定到目标章节数,把章节按"起承转合"节点
+ * 分配,并把绑定信息 + 框架上下文注入到 AI 写作会话中。
+ */
+
+import { createDirectory, deleteFile, readFile, writeFileAtomic } from "@/commands/fs"
+import { normalizePath } from "@/lib/path-utils"
+import type {
+  ChapterAllocation,
+  FrameworkBinding,
+  StoryFramework,
+} from "./types"
+
+const BINDING_FILE = ".qmai/simulations/bindings/active-binding.json"
+
+function bindingFilePath(projectPath: string): string {
+  return `${normalizePath(projectPath)}/${BINDING_FILE}`
+}
+
+/**
+ * 将 targetChapterCount 分配到各节点:
+ * baseChaptersPerNode = floor(target / nodeCount),余数依次分配给前面的节点。
+ */
+function allocateChapters(
+  nodes: StoryFramework["nodes"],
+  targetChapterCount: number,
+): ChapterAllocation[] {
+  const sorted = [...nodes].sort((a, b) => a.index - b.index)
+  if (sorted.length === 0) return []
+
+  const base = Math.floor(targetChapterCount / sorted.length)
+  const remainder = targetChapterCount % sorted.length
+
+  const allocations: ChapterAllocation[] = []
+  let cursor = 1
+  for (let i = 0; i < sorted.length; i++) {
+    const count = base + (i < remainder ? 1 : 0)
+    const startChapter = cursor
+    const endChapter = cursor + count - 1
+    allocations.push({
+      nodeIndex: sorted[i].index,
+      nodeTitle: sorted[i].title,
+      startChapter,
+      endChapter,
+    })
+    cursor += Math.max(count, 0)
+  }
+  return allocations
+}
+
+/** 读取当前激活的框架绑定,不存在时返回 null。 */
+export async function loadBinding(
+  projectPath: string,
+): Promise<FrameworkBinding | null> {
+  try {
+    const content = await readFile(bindingFilePath(projectPath))
+    const parsed = JSON.parse(content) as FrameworkBinding
+    if (!parsed || !parsed.frameworkId) return null
+    return parsed
+  } catch {
+    return null
+  }
+}
+
+/**
+ * 保存绑定:根据框架节点与目标章节数生成章节分配,写入绑定文件。
+ */
+export async function saveBinding(
+  projectPath: string,
+  framework: StoryFramework,
+  targetChapterCount: number,
+): Promise<FrameworkBinding> {
+  const chapterAllocation = allocateChapters(framework.nodes, targetChapterCount)
+  const binding: FrameworkBinding = {
+    frameworkId: framework.id,
+    frameworkTitle: framework.title,
+    targetChapterCount,
+    chapterAllocation,
+    boundAt: new Date().toISOString(),
+  }
+
+  // createDirectory 使用 create_dir_all,会递归创建 .qmai/simulations/bindings
+  await createDirectory(
+    `${normalizePath(projectPath)}/.qmai/simulations/bindings`,
+  )
+  await writeFileAtomic(bindingFilePath(projectPath), JSON.stringify(binding, null, 2))
+  return binding
+}
+
+/** 清除当前激活的框架绑定。 */
+export async function clearBinding(projectPath: string): Promise<void> {
+  try {
+    await deleteFile(bindingFilePath(projectPath))
+  } catch {
+    // 绑定文件可能不存在
+  }
+}
+
+/**
+ * 构建注入 AI 会话的上下文文本:
+ * 框架标题 + 目标章节数 + 章节分配表(第X-Y章 → 起承转合节点)+ 要求。
+ */
+export function buildBindingContext(
+  binding: FrameworkBinding,
+  framework: StoryFramework,
+): string {
+  const lines: string[] = []
+  lines.push("# 故事框架绑定")
+  lines.push("")
+  lines.push(`- 框架标题:${framework.title}`)
+  lines.push(`- 目标章节数:${binding.targetChapterCount}`)
+  lines.push("")
+  lines.push("## 章节分配")
+  for (const allocation of binding.chapterAllocation) {
+    const node = framework.nodes.find((n) => n.index === allocation.nodeIndex)
+    const phaseLabel = node ? `【${node.phase}】` : ""
+    const range =
+      allocation.endChapter < allocation.startChapter
+        ? "无章节分配"
+        : allocation.startChapter === allocation.endChapter
+          ? `第${allocation.startChapter}章`
+          : `第${allocation.startChapter}-${allocation.endChapter}章`
+    lines.push(`- ${range} → ${phaseLabel}${allocation.nodeTitle}`)
+  }
+  lines.push("")
+  lines.push("## 要求")
+  lines.push("- 请严格遵循上述故事框架推进剧情,按章节分配在对应节点完成相应情节。")
+  lines.push("- 保持各节点核心冲突与预期结果的连贯性。")
+  return lines.join("\n")
+}

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

@@ -0,0 +1,452 @@
+/**
+ * 故事框架持久化
+ *
+ * 将 StoryFramework 以 Markdown 文档(YAML frontmatter + 正文)的形式
+ * 持久化到项目的 .qmai/simulations 目录下,并提供加载 / 删除 / 推演结果
+ * 存取能力。
+ */
+
+import {
+  createDirectory,
+  deleteFile,
+  listDirectory,
+  readFile,
+  writeFileAtomic,
+} from "@/commands/fs"
+import { normalizePath } from "@/lib/path-utils"
+import { parseFrontmatter } from "@/lib/frontmatter"
+import type { FileNode } from "@/types/wiki"
+import type {
+  SimulationMode,
+  SimulationReport,
+  StoryDraft,
+  StoryFramework,
+  StoryNode,
+} from "./types"
+
+const SIM_ROOT = ".qmai/simulations"
+const FRAMEWORKS_DIR = `${SIM_ROOT}/frameworks`
+const RESULTS_DIR = `${SIM_ROOT}/results`
+
+const VALID_MODES: SimulationMode[] = [
+  "event-driven",
+  "free-emergence",
+  "decision-tree",
+  "hybrid",
+]
+
+// ── 路径辅助 ──
+
+function frameworksDir(projectPath: string): string {
+  return `${normalizePath(projectPath)}/${FRAMEWORKS_DIR}`
+}
+
+function frameworkFilePath(projectPath: string, frameworkId: string): string {
+  return `${frameworksDir(projectPath)}/${frameworkId}.md`
+}
+
+function frameworkResultsDir(projectPath: string, frameworkId: string): string {
+  return `${normalizePath(projectPath)}/${RESULTS_DIR}/${frameworkId}`
+}
+
+// ── 目录初始化 ──
+
+/**
+ * 创建 .qmai/simulations/{frameworks,results,bindings} 目录。
+ * createDirectory 内部使用 create_dir_all,已存在时不会报错。
+ */
+export async function ensureSimulationDirs(projectPath: string): Promise<void> {
+  const root = `${normalizePath(projectPath)}/${SIM_ROOT}`
+  await createDirectory(`${root}/frameworks`)
+  await createDirectory(`${root}/results`)
+  await createDirectory(`${root}/bindings`)
+}
+
+// ── Markdown 互转 ──
+
+function yamlString(value: string): string {
+  const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
+  return `"${escaped}"`
+}
+
+/** 将 StoryFramework 序列化为 Markdown 文档。 */
+export function frameworkToMarkdown(framework: StoryFramework): string {
+  const lines: string[] = []
+  lines.push("---")
+  lines.push(`id: ${yamlString(framework.id)}`)
+  lines.push(`type: "story-framework"`)
+  lines.push(`title: ${yamlString(framework.title)}`)
+  lines.push(`createdAt: ${yamlString(framework.createdAt)}`)
+  lines.push(`sourceChapters: ${framework.sourceChapters}`)
+  lines.push(`targetWords: ${framework.targetWords}`)
+  lines.push(`simulationMode: ${yamlString(framework.simulationMode)}`)
+  lines.push(`userIdea: ${yamlString(framework.userIdea ?? "")}`)
+  lines.push("---")
+  lines.push("")
+  lines.push("## 前提")
+  lines.push("")
+  lines.push(framework.premise || "(无前提)")
+  lines.push("")
+  lines.push("## 故事节点")
+  lines.push("")
+
+  const sorted = [...framework.nodes].sort((a, b) => a.index - b.index)
+  for (const node of sorted) {
+    lines.push(`### 节点 ${node.index} 【${node.phase}】 ${node.title}`)
+    lines.push(`- 核心冲突:${node.coreConflict}`)
+    lines.push(`- 涉及角色:${node.involvedCharacters.join("、")}`)
+    lines.push(`- 目标:${node.goal}`)
+    lines.push(`- 承接前因:${node.causeFromPrev}`)
+    lines.push(`- 预期结果:${node.expectedOutcome}`)
+    lines.push("")
+  }
+  return lines.join("\n")
+}
+
+function fmStr(v: string | string[] | undefined): string {
+  if (v === undefined) return ""
+  if (Array.isArray(v)) return v.join(", ")
+  return v
+}
+
+/** 提取 body 中某个二级标题(## heading)下的内容,直到下一个二级标题。 */
+function extractSection(body: string, heading: string): string {
+  const lines = body.split("\n")
+  let capturing = false
+  const captured: string[] = []
+  for (const line of lines) {
+    const isH2 = /^##\s+/.test(line)
+    if (isH2) {
+      if (capturing) break // 到达下一个 ## 章节
+      const headingText = line.replace(/^##\s+/, "").trim()
+      if (headingText === heading || headingText.startsWith(heading)) {
+        capturing = true
+      }
+      continue
+    }
+    if (capturing) captured.push(line)
+  }
+  return captured.join("\n").trim()
+}
+
+function parseNodeHeader(
+  line: string,
+  fallbackIndex: number,
+): { index: number; phase: StoryNode["phase"]; title: string } {
+  const indexMatch = line.match(/节点\s*(\d+)/)
+  const index = indexMatch ? parseInt(indexMatch[1], 10) : fallbackIndex
+  const phaseMatch = line.match(/【(起|承|转|合)】/)
+  const phase = phaseMatch ? (phaseMatch[1] as StoryNode["phase"]) : "起"
+
+  let title = ""
+  const bracketEnd = line.lastIndexOf("】")
+  if (bracketEnd >= 0) {
+    title = line.slice(bracketEnd + 1).trim()
+  }
+  if (!title) {
+    title = line.replace(/节点\s*\d+\s*【[起承转合]】/, "").trim()
+  }
+  if (!title) title = "未命名节点"
+  return { index, phase, title }
+}
+
+function parseNodeBody(block: string): {
+  coreConflict: string
+  involvedCharacters: string[]
+  goal: string
+  causeFromPrev: string
+  expectedOutcome: string
+} {
+  const result = {
+    coreConflict: "",
+    involvedCharacters: [] as string[],
+    goal: "",
+    causeFromPrev: "",
+    expectedOutcome: "",
+  }
+  for (const rawLine of block.split("\n")) {
+    const line = rawLine.trim()
+    if (!line.startsWith("-")) continue
+    const m = line.match(/^-\s*(.+?)[::]\s*(.*)$/)
+    if (!m) continue
+    const key = m[1].trim()
+    const value = m[2].trim()
+    switch (key) {
+      case "核心冲突":
+        result.coreConflict = value
+        break
+      case "涉及角色":
+        result.involvedCharacters = value
+          ? value
+              .split(/[,,、]/)
+              .map((s) => s.trim())
+              .filter(Boolean)
+          : []
+        break
+      case "目标":
+        result.goal = value
+        break
+      case "承接前因":
+        result.causeFromPrev = value
+        break
+      case "预期结果":
+        result.expectedOutcome = value
+        break
+      default:
+        break
+    }
+  }
+  return result
+}
+
+function parseNodes(nodesSection: string): StoryNode[] {
+  const nodes: StoryNode[] = []
+  const blocks = nodesSection.split(/^###\s+/m)
+  let order = 0
+  for (const block of blocks) {
+    const trimmed = block.trim()
+    if (!trimmed || !trimmed.startsWith("节点")) continue
+    order += 1
+    const lines = trimmed.split("\n")
+    const { index, phase, title } = parseNodeHeader(lines[0], order)
+    const body = lines.slice(1).join("\n")
+    const fields = parseNodeBody(body)
+    nodes.push({
+      index,
+      phase,
+      title,
+      coreConflict: fields.coreConflict,
+      involvedCharacters: fields.involvedCharacters,
+      goal: fields.goal,
+      causeFromPrev: fields.causeFromPrev,
+      expectedOutcome: fields.expectedOutcome,
+    })
+  }
+  return nodes
+}
+
+/**
+ * 将 Markdown 文档解析回 StoryFramework。
+ * 解析尽量健壮:缺失字段使用默认值,frontmatter 异常时尽量恢复。
+ */
+export function markdownToFramework(
+  content: string,
+  fallbackId?: string,
+): StoryFramework | null {
+  try {
+    const { frontmatter, body } = parseFrontmatter(content)
+    const fm = (frontmatter ?? {}) as Record<string, string | string[]>
+
+    const id = fmStr(fm.id) || fallbackId || `fw-${Date.now()}`
+    const title = fmStr(fm.title) || "未命名框架"
+    const createdAt = fmStr(fm.createdAt) || new Date().toISOString()
+    const sourceChapters = parseInt(fmStr(fm.sourceChapters), 10) || 0
+    const targetWords = parseInt(fmStr(fm.targetWords), 10) || 0
+    const modeValue = fmStr(fm.simulationMode)
+    const simulationMode: SimulationMode = VALID_MODES.includes(
+      modeValue as SimulationMode,
+    )
+      ? (modeValue as SimulationMode)
+      : "hybrid"
+    const userIdeaValue = fmStr(fm.userIdea)
+    const userIdea = userIdeaValue || undefined
+
+    const premise = extractSection(body, "前提")
+    const nodesSection = extractSection(body, "故事节点")
+    const nodes = parseNodes(nodesSection)
+
+    return {
+      id,
+      title,
+      premise,
+      targetWords,
+      simulationMode,
+      userIdea,
+      sourceChapters,
+      nodes,
+      createdAt,
+    }
+  } catch {
+    return null
+  }
+}
+
+// ── 框架 CRUD ──
+
+/** 将框架保存为 MD 文档。 */
+export async function saveFramework(
+  projectPath: string,
+  framework: StoryFramework,
+): Promise<void> {
+  await ensureSimulationDirs(projectPath)
+  const md = frameworkToMarkdown(framework)
+  await writeFileAtomic(frameworkFilePath(projectPath, framework.id), md)
+}
+
+/** 加载所有框架,按 createdAt 降序排列。 */
+export async function loadFrameworks(
+  projectPath: string,
+): Promise<StoryFramework[]> {
+  let entries: FileNode[]
+  try {
+    entries = await listDirectory(frameworksDir(projectPath))
+  } catch {
+    return []
+  }
+
+  const frameworks: StoryFramework[] = []
+  for (const entry of entries) {
+    if (entry.is_dir) continue
+    if (!entry.name.toLowerCase().endsWith(".md")) continue
+    try {
+      const content = await readFile(entry.path)
+      const id = entry.name.replace(/\.md$/i, "")
+      const fw = markdownToFramework(content, id)
+      if (fw) frameworks.push(fw)
+    } catch {
+      // 跳过无法读取的文件
+    }
+  }
+
+  frameworks.sort((a, b) => {
+    if (a.createdAt < b.createdAt) return 1
+    if (a.createdAt > b.createdAt) return -1
+    return 0
+  })
+  return frameworks
+}
+
+/** 删除框架及其关联的推演结果。 */
+export async function deleteFramework(
+  projectPath: string,
+  frameworkId: string,
+): Promise<void> {
+  try {
+    await deleteFile(frameworkFilePath(projectPath, frameworkId))
+  } catch {
+    // 框架文件可能不存在
+  }
+
+  // 删除该框架下的所有推演结果文件
+  const resultsPath = frameworkResultsDir(projectPath, frameworkId)
+  try {
+    const entries = await listDirectory(resultsPath)
+    for (const entry of entries) {
+      if (!entry.is_dir) {
+        try {
+          await deleteFile(entry.path)
+        } catch {
+          // 跳过无法删除的文件
+        }
+      }
+    }
+  } catch {
+    // 结果目录可能不存在
+  }
+}
+
+// ── 推演结果存取 ──
+
+function simulationResultToMarkdown(
+  report: SimulationReport,
+  draft?: StoryDraft,
+): string {
+  const lines: string[] = []
+  lines.push("# 推演结果")
+  lines.push("")
+  lines.push(`- 框架ID:${report.frameworkId}`)
+  lines.push(`- 仿真模式:${report.mode}`)
+  lines.push(`- 生成时间:${report.createdAt}`)
+  lines.push("")
+  lines.push("## 推荐")
+  lines.push(report.recommendation || "(无推荐)")
+  lines.push("")
+  lines.push("## 分支")
+  for (const branch of report.branches) {
+    lines.push(`### ${branch.title}`)
+    lines.push(`- 概率:${branch.probability}`)
+    lines.push(`- 摘要:${branch.summary}`)
+    if (branch.recommendation) lines.push("- 推荐:是")
+  }
+  lines.push("")
+  lines.push("## 角色分析")
+  for (const ca of report.characterAnalyses) {
+    lines.push(`### ${ca.name}`)
+    lines.push(`- 一致性评分:${ca.consistencyScore}`)
+  }
+  if (draft) {
+    lines.push("")
+    lines.push("## 草稿")
+    lines.push(`- 总字数:${draft.totalWords}`)
+    for (const ch of draft.chapters) {
+      lines.push(`### ${ch.title}(对应节点 ${ch.correspondingNode})`)
+    }
+  }
+  return lines.join("\n")
+}
+
+/**
+ * 保存推演结果,同时写入 JSON(结构化)与 MD(人类可读)。
+ * @returns resultId
+ */
+export async function saveSimulationResult(
+  projectPath: string,
+  frameworkId: string,
+  report: SimulationReport,
+  draft?: StoryDraft,
+): Promise<string> {
+  await ensureSimulationDirs(projectPath)
+  const resultId = `result-${Date.now()}`
+  const dir = frameworkResultsDir(projectPath, frameworkId)
+  await createDirectory(dir)
+
+  const payload = { report, draft: draft ?? null }
+  await writeFileAtomic(`${dir}/${resultId}.json`, JSON.stringify(payload, null, 2))
+  await writeFileAtomic(
+    `${dir}/${resultId}.md`,
+    simulationResultToMarkdown(report, draft),
+  )
+  return resultId
+}
+
+/** 加载框架的所有推演结果,按 report.createdAt 降序排列。 */
+export async function loadSimulationResults(
+  projectPath: string,
+  frameworkId: string,
+): Promise<{ id: string; report: SimulationReport }[]> {
+  const dir = frameworkResultsDir(projectPath, frameworkId)
+  let entries: FileNode[]
+  try {
+    entries = await listDirectory(dir)
+  } catch {
+    return []
+  }
+
+  const results: { id: string; report: SimulationReport }[] = []
+  for (const entry of entries) {
+    if (entry.is_dir) continue
+    if (!entry.name.toLowerCase().endsWith(".json")) continue
+    try {
+      const content = await readFile(entry.path)
+      const parsed = JSON.parse(content) as {
+        report: SimulationReport
+        draft?: StoryDraft
+      }
+      if (parsed && parsed.report) {
+        results.push({
+          id: entry.name.replace(/\.json$/i, ""),
+          report: parsed.report,
+        })
+      }
+    } catch {
+      // 跳过无法解析的文件
+    }
+  }
+
+  results.sort((a, b) => {
+    if (a.report.createdAt < b.report.createdAt) return 1
+    if (a.report.createdAt > b.report.createdAt) return -1
+    return 0
+  })
+  return results
+}