Jelajahi Sumber

feat(story-simulation): 实现故事框架生成器

Mochocyang 3 bulan lalu
induk
melakukan
7116297b54
1 mengubah file dengan 436 tambahan dan 0 penghapusan
  1. 436 0
      src/lib/novel/story-simulation/story-framework-generator.ts

+ 436 - 0
src/lib/novel/story-simulation/story-framework-generator.ts

@@ -0,0 +1,436 @@
+/**
+ * 故事框架生成器
+ *
+ * 调用 LLM 分析已写内容(角色、章节概要、世界规则、力量体系、
+ * 伏笔、时间线、灵魂文档)与用户思路,生成遵循"起承转合"四段式
+ * 结构的故事框架。框架由 premise(核心前提)与若干 StoryNode 组成,
+ * 节点之间以因果链串联。
+ *
+ * 设计原则:
+ * - 只做生成,不读写文件(输入由调用方通过 ExtractionResult 传入)。
+ * - JSON 解析健壮:兼容 ```json 代码块与裸 JSON,失败时返回合理的
+ *   空框架,绝不抛出未捕获异常。
+ */
+
+import type { LlmConfig } from "@/stores/wiki-store"
+import { streamChat, type ChatMessage } from "@/lib/llm-client"
+import type {
+  ExtractionResult,
+  StoryFramework,
+  StoryNode,
+  SimulationMode,
+} from "./types"
+import { calcNodeCount } from "./types"
+
+// ── 对外接口 ──
+
+export interface FrameworkGenerationOptions {
+  extraction: ExtractionResult
+  mode: SimulationMode
+  targetWords: number
+  userIdea?: string
+  llmConfig: LlmConfig
+  onProgress?: (label: string) => void
+}
+
+/**
+ * 生成故事框架。
+ *
+ * 流程:
+ * 1. calcNodeCount(targetWords) 确定节点数量
+ * 2. 基于提取结果构建提示词
+ * 3. 调用 streamChat 收集完整响应
+ * 4. 解析 JSON 为 StoryFramework
+ * 5. 生成唯一 ID 与标题
+ *
+ * 任何环节失败均返回合理的空框架(premise 携带原因,nodes 为空)。
+ */
+export async function generateStoryFramework(
+  options: FrameworkGenerationOptions,
+): Promise<StoryFramework> {
+  const { extraction, mode, targetWords, userIdea, llmConfig, onProgress } =
+    options
+
+  const nodeCount = calcNodeCount(targetWords)
+  const sourceChapters = extraction.chapterContents.length
+
+  onProgress?.("正在构建提示词...")
+  const messages = buildMessages(extraction, mode, targetWords, nodeCount, userIdea)
+
+  onProgress?.("正在调用模型生成故事框架...")
+  const raw = await collectStream(llmConfig, messages)
+
+  onProgress?.("正在解析框架...")
+  const parsed = parseFrameworkJson(raw)
+  if (!parsed) {
+    return buildEmptyFramework(
+      options,
+      "模型未返回可解析的框架内容,请重试或更换模型。",
+    )
+  }
+
+  const nodes = normalizeNodes(parsed.nodes, nodeCount)
+  if (nodes.length === 0) {
+    return buildEmptyFramework(
+      options,
+      "模型返回的框架不包含任何节点,请重试或调整目标字数。",
+    )
+  }
+
+  const framework: StoryFramework = {
+    id: `framework-${Date.now()}`,
+    title: buildTitle(parsed.premise, userIdea),
+    premise: strOr(parsed.premise, ""),
+    targetWords,
+    simulationMode: mode,
+    userIdea,
+    sourceChapters,
+    nodes,
+    createdAt: new Date().toISOString(),
+  }
+
+  onProgress?.("故事框架生成完成")
+  return framework
+}
+
+// ── 提示词构建 ──
+
+function buildMessages(
+  extraction: ExtractionResult,
+  mode: SimulationMode,
+  targetWords: number,
+  nodeCount: number,
+  userIdea?: string,
+): ChatMessage[] {
+  const systemPrompt = [
+    '你是一位资深的故事架构师。请根据用户提供的已写内容与设定,生成一个遵循"起承转合"四段式结构的故事框架。',
+    "",
+    "要求:",
+    "1. 根据目标字数,生成指定数量的关键节点,覆盖起、承、转、合四个阶段。",
+    "2. 每个节点必须包含以下字段:",
+    '   - phase:阶段标识,取值仅为 "起"、"承"、"转"、"合" 之一',
+    "   - title:节点标题(简短有力)",
+    "   - coreConflict:该节点的核心冲突",
+    "   - involvedCharacters:涉及的角色名称数组",
+    "   - goal:该节点要达成的叙事目标",
+    '   - causeFromPrev:由上一节点导致的直接原因(第一个节点填"故事开端")',
+    "   - expectedOutcome:预期导致的结局或转折",
+    "3. 节点之间必须有明确的因果链:后一节点的 causeFromPrev 应承接前一节点的 expectedOutcome。",
+    "4. premise:用一两句话概括整个故事的核心前提。",
+    "5. 只输出一个 JSON 对象,不要输出任何解释、注释或多余文字。",
+    "",
+    "输出格式(严格遵循):",
+    "{",
+    '  "premise": "故事核心前提",',
+    '  "nodes": [',
+    "    {",
+    '      "phase": "起",',
+    '      "title": "...",',
+    '      "coreConflict": "...",',
+    '      "involvedCharacters": ["角色A", "角色B"],',
+    '      "goal": "...",',
+    '      "causeFromPrev": "故事开端",',
+    '      "expectedOutcome": "..."',
+    "    }",
+    "  ]",
+    "}",
+  ].join("\n")
+
+  const userPrompt = buildUserPrompt(
+    extraction,
+    mode,
+    targetWords,
+    nodeCount,
+    userIdea,
+  )
+
+  return [
+    { role: "system", content: systemPrompt },
+    { role: "user", content: userPrompt },
+  ]
+}
+
+function buildUserPrompt(
+  extraction: ExtractionResult,
+  mode: SimulationMode,
+  targetWords: number,
+  nodeCount: number,
+  userIdea?: string,
+): string {
+  const sections: string[] = []
+
+  sections.push(`# 生成任务`)
+  sections.push(`目标字数:${targetWords}`)
+  sections.push(`需要生成的节点数量:${nodeCount}`)
+  sections.push(`仿真模式:${mode}`)
+  if (userIdea) {
+    sections.push(`用户思路:${userIdea}`)
+  }
+
+  // 角色信息
+  sections.push("")
+  sections.push("# 角色信息")
+  if (extraction.characters.length === 0) {
+    sections.push("(暂无已提取角色)")
+  } else {
+    for (const c of extraction.characters) {
+      sections.push(`## ${c.name}`)
+      if (c.profile) sections.push(`档案:${truncate(c.profile, 2000)}`)
+      if (c.aura) sections.push(`光环:${JSON.stringify(c.aura)}`)
+      if (c.cognition) {
+        sections.push(
+          `认知:知道[${c.cognition.knows.join("、")}];不知道[${c.cognition.doesNotKnow.join("、")}]`,
+        )
+      }
+      if (c.skillContent) sections.push(`技能:${truncate(c.skillContent, 1500)}`)
+      if (c.soul) sections.push(`灵魂:${truncate(c.soul, 1500)}`)
+    }
+  }
+
+  // 章节概要
+  sections.push("")
+  sections.push("# 已写章节概要")
+  if (extraction.chapterContents.length === 0) {
+    sections.push("(暂无已写章节)")
+  } else {
+    for (const ch of extraction.chapterContents) {
+      const summary = ch.summary?.trim() || truncate(ch.content, 800)
+      sections.push(`第${ch.chapterNumber}章《${ch.title}》:${summary}`)
+    }
+  }
+
+  // 世界规则
+  sections.push("")
+  sections.push("# 世界规则")
+  sections.push(extraction.worldRules?.trim() || "(未提取到世界规则)")
+
+  // 力量体系
+  sections.push("")
+  sections.push("# 力量体系")
+  sections.push(extraction.powerSystem?.trim() || "(未提取到力量体系)")
+
+  // 伏笔
+  sections.push("")
+  sections.push("# 伏笔追踪")
+  if (extraction.foreshadowing) {
+    sections.push(JSON.stringify(extraction.foreshadowing))
+  } else {
+    sections.push("(暂无伏笔记录)")
+  }
+
+  // 时间线
+  sections.push("")
+  sections.push("# 时间线")
+  if (extraction.timeline.length > 0) {
+    sections.push(extraction.timeline.map((e, i) => `${i + 1}. ${e}`).join("\n"))
+  } else {
+    sections.push("(暂无时间线事件)")
+  }
+
+  // 灵魂文档
+  sections.push("")
+  sections.push("# 灵魂文档")
+  sections.push(extraction.soulDoc?.trim() || "(未提取到灵魂文档)")
+
+  sections.push("")
+  sections.push(
+    `请基于以上内容生成 ${nodeCount} 个节点的故事框架,严格按系统提示要求的 JSON 格式输出。`,
+  )
+
+  return sections.join("\n")
+}
+
+// ── 流式收集 ──
+
+/**
+ * 调用 streamChat 并累积完整文本响应。
+ * 若流式过程中出错,抛出携带错误信息的 Error,由调用方决定回退策略。
+ */
+function collectStream(
+  llmConfig: LlmConfig,
+  messages: ChatMessage[],
+): Promise<string> {
+  return new Promise<string>((resolve, reject) => {
+    let response = ""
+    streamChat(
+      llmConfig,
+      messages,
+      {
+        onToken: (token) => {
+          response += token
+        },
+        onDone: () => {
+          resolve(response)
+        },
+        onError: (err) => {
+          reject(err)
+        },
+      },
+    ).catch((err) => {
+      reject(err instanceof Error ? err : new Error(String(err)))
+    })
+  })
+}
+
+// ── JSON 解析 ──
+
+interface ParsedFramework {
+  premise?: unknown
+  nodes?: unknown
+}
+
+/**
+ * 从模型输出中提取框架 JSON。
+ *
+ * 兼容三种情况:
+ * 1. ```json ... ``` 代码块包裹
+ * 2. 裸 JSON 对象
+ * 3. JSON 前后夹杂多余文字(取最外层 { ... })
+ *
+ * 解析失败返回 null。
+ */
+function parseFrameworkJson(raw: string): ParsedFramework | null {
+  const text = raw.trim()
+  if (!text) return null
+
+  // 1. 提取 ```json / ``` 代码块
+  const fenceMatch = text.match(/```(?:json)?\s*([\s\S]*?)```/i)
+  const candidate = fenceMatch ? fenceMatch[1].trim() : text
+
+  // 2. 直接解析
+  const direct = tryParse(candidate)
+  if (direct) return direct
+
+  // 3. 截取最外层 { ... }
+  const start = candidate.indexOf("{")
+  const end = candidate.lastIndexOf("}")
+  if (start !== -1 && end !== -1 && end > start) {
+    const slice = candidate.slice(start, end + 1)
+    const sliced = tryParse(slice)
+    if (sliced) return sliced
+  }
+
+  return null
+}
+
+function tryParse(text: string): ParsedFramework | null {
+  try {
+    const obj = JSON.parse(text)
+    if (obj && typeof obj === "object") return obj as ParsedFramework
+  } catch {
+    // 忽略,交由外层回退
+  }
+  return null
+}
+
+// ── 节点归一化 ──
+
+const VALID_PHASES: ReadonlySet<string> = new Set(["起", "承", "转", "合"])
+
+/**
+ * 将模型返回的节点数组归一化为合法的 StoryNode[]。
+ *
+ * - 截断到目标节点数量(允许少于目标,但不补齐空节点)。
+ * - 校验 phase 取值,非法时按位置比例推导,保证起承转合分布合理。
+ * - 缺失字段填充合理默认值。
+ */
+function normalizeNodes(raw: unknown, count: number): StoryNode[] {
+  if (!Array.isArray(raw)) return []
+  const list = raw.slice(0, count)
+  const total = list.length
+  const nodes: StoryNode[] = []
+  for (let i = 0; i < total; i++) {
+    const n = (list[i] ?? {}) as Record<string, unknown>
+    nodes.push({
+      index: i,
+      phase: normalizePhase(n.phase, i, total),
+      title: strOr(n.title, `节点 ${i + 1}`),
+      coreConflict: strOr(n.coreConflict, ""),
+      involvedCharacters: arrOr(n.involvedCharacters, []),
+      goal: strOr(n.goal, ""),
+      causeFromPrev: strOr(n.causeFromPrev, i === 0 ? "故事开端" : ""),
+      expectedOutcome: strOr(n.expectedOutcome, ""),
+    })
+  }
+  return nodes
+}
+
+function normalizePhase(
+  value: unknown,
+  index: number,
+  total: number,
+): StoryNode["phase"] {
+  if (typeof value === "string" && VALID_PHASES.has(value)) {
+    return value as StoryNode["phase"]
+  }
+  return derivePhase(index, total)
+}
+
+/**
+ * 按节点位置比例推导阶段,保证起承转合四段分布。
+ */
+function derivePhase(index: number, total: number): StoryNode["phase"] {
+  if (total <= 0) return "起"
+  const ratio = index / total
+  if (ratio < 0.25) return "起"
+  if (ratio < 0.5) return "承"
+  if (ratio < 0.75) return "转"
+  return "合"
+}
+
+// ── 空框架回退 ──
+
+function buildEmptyFramework(
+  options: FrameworkGenerationOptions,
+  reason: string,
+): StoryFramework {
+  return {
+    id: `framework-${Date.now()}`,
+    title: "故事框架(生成失败,已回退为空框架)",
+    premise: reason,
+    targetWords: options.targetWords,
+    simulationMode: options.mode,
+    userIdea: options.userIdea,
+    sourceChapters: options.extraction.chapterContents.length,
+    nodes: [],
+    createdAt: new Date().toISOString(),
+  }
+}
+
+// ── 工具函数 ──
+
+function buildTitle(premise: unknown, userIdea?: string): string {
+  if (userIdea) return truncate(userIdea, 30)
+  if (typeof premise === "string" && premise.trim()) {
+    return truncate(premise.trim(), 30)
+  }
+  return "故事框架"
+}
+
+function truncate(text: string, max: number): string {
+  const t = text.trim()
+  if (t.length <= max) return t
+  return `${t.slice(0, max)}…`
+}
+
+function strOr(value: unknown, fallback: string): string {
+  if (typeof value === "string" && value.trim()) return value.trim()
+  return fallback
+}
+
+/**
+ * 将未知值归一化为字符串数组。
+ * 支持:数组、以常见分隔符切分的字符串。
+ */
+function arrOr(value: unknown, fallback: string[]): string[] {
+  if (Array.isArray(value)) {
+    return value.map((v) => String(v).trim()).filter(Boolean)
+  }
+  if (typeof value === "string" && value.trim()) {
+    return value
+      .split(/[,,、;;\n]/)
+      .map((s) => s.trim())
+      .filter(Boolean)
+  }
+  return fallback
+}