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

feat(story-simulation): 实现四种仿真模式

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

+ 236 - 0
src/lib/novel/story-simulation/simulation-modes/decision-tree.ts

@@ -0,0 +1,236 @@
+import { runSimulation, type SimulationCallbacks } from "../simulation-engine"
+import type { ChatMessage } from "@/lib/llm-client"
+import { streamChat } from "@/lib/llm-client"
+import type { LlmConfig } from "@/stores/wiki-store"
+import type {
+  ExtractionResult,
+  NovelAgent,
+  SimulationEvent,
+  SimulationInput,
+  StoryFramework,
+} from "../types"
+
+// 每个分支最多推演的节点数,用于限制深度、控制 token 消耗。
+const MAX_BRANCH_NODES = 2
+// 为关键角色生成的决策选项数量。
+const DECISION_OPTION_COUNT = 3
+
+interface DecisionOption {
+  title: string
+  description: string
+}
+
+// 将 streamChat 的流式回调收拢为一个完整字符串(与 simulation-engine 内部实现一致)。
+async function collectStream(
+  config: LlmConfig,
+  messages: ChatMessage[],
+  signal?: AbortSignal,
+): Promise<string> {
+  let result = ""
+  let streamError: Error | null = null
+
+  await streamChat(
+    config,
+    messages,
+    {
+      onToken: (token) => {
+        result += token
+      },
+      onDone: () => {},
+      onError: (err) => {
+        streamError = err
+      },
+    },
+    signal,
+  )
+
+  if (streamError) throw streamError
+  return result
+}
+
+// 从 LLM 文本中解析 JSON 数组形式的决策选项,失败返回 null。
+function parseDecisionOptions(raw: string): DecisionOption[] | null {
+  const trimmed = raw.trim()
+
+  const tryParse = (text: string): DecisionOption[] | null => {
+    try {
+      const parsed = JSON.parse(text) as unknown
+      if (!Array.isArray(parsed)) return null
+      const options: DecisionOption[] = []
+      for (const item of parsed) {
+        if (item && typeof item === "object") {
+          const obj = item as Record<string, unknown>
+          const title = obj.title !== undefined ? String(obj.title) : ""
+          const description =
+            obj.description !== undefined ? String(obj.description) : ""
+          if (title || description) options.push({ title, description })
+        } else if (typeof item === "string" && item.trim()) {
+          options.push({ title: item.trim(), description: item.trim() })
+        }
+      }
+      return options.length > 0 ? options : null
+    } catch {
+      return null
+    }
+  }
+
+  // 直接解析
+  const direct = tryParse(trimmed)
+  if (direct) return direct
+
+  // 从 markdown 代码块中提取
+  const codeBlockMatch = /```(?:json)?\s*([\s\S]*?)```/.exec(trimmed)
+  if (codeBlockMatch) {
+    const fromBlock = tryParse(codeBlockMatch[1].trim())
+    if (fromBlock) return fromBlock
+  }
+
+  // 从文本中查找第一个 JSON 数组
+  const arrayMatch = /\[[\s\S]*\]/.exec(trimmed)
+  if (arrayMatch) {
+    const fromArray = tryParse(arrayMatch[0])
+    if (fromArray) return fromArray
+  }
+
+  return null
+}
+
+// 选取关键角色:优先取第一个节点涉及的 agent,否则取第一个 agent。
+function pickKeyAgent(input: SimulationInput): NovelAgent | undefined {
+  const firstNode = input.framework.nodes[0]
+  if (firstNode) {
+    const involved = input.agents.find((a) =>
+      firstNode.involvedCharacters.includes(a.name),
+    )
+    if (involved) return involved
+  }
+  return input.agents[0]
+}
+
+// 构建生成决策选项的 LLM 消息。
+function buildDecisionMessages(
+  input: SimulationInput,
+  extraction: ExtractionResult,
+): ChatMessage[] {
+  const agent = pickKeyAgent(input)
+  const framework = input.framework
+  const firstNode = framework.nodes[0]
+
+  const system = [
+    "你是一位小说剧情推演助手。",
+    "你的任务是为关键角色生成几个不同方向的决策选项,用于分支推演。",
+    "只输出一个 JSON 数组,数组中每个元素是一个对象:",
+    '  { "title": "决策标题(简短)", "description": "决策的具体内容与动机" }',
+    `请生成 ${DECISION_OPTION_COUNT} 个选项,每个选项代表一种截然不同的行动方向。`,
+    "不要输出任何其他文字。",
+  ].join("\n")
+
+  const user = [
+    `故事前提:${framework.premise}`,
+    firstNode ? `当前节点:${firstNode.title}(核心冲突:${firstNode.coreConflict})` : "",
+    `关键角色:${agent?.name ?? "主角"}`,
+    agent ? `角色设定:${agent.profile}` : "",
+    `世界规则:${extraction.worldRules || "(无)"}`,
+    "",
+    `请为「${agent?.name ?? "主角"}」生成 ${DECISION_OPTION_COUNT} 个决策选项。`,
+  ]
+    .filter((line) => line !== undefined && line !== "")
+    .join("\n")
+
+  return [
+    { role: "system", content: system },
+    { role: "user", content: user },
+  ]
+}
+
+// 限制框架节点数,控制每个分支的推演深度。
+function trimFramework(
+  framework: StoryFramework,
+  maxNodes: number,
+): StoryFramework {
+  if (framework.nodes.length <= maxNodes) return framework
+  return {
+    ...framework,
+    nodes: framework.nodes.slice(0, maxNodes),
+  }
+}
+
+export async function runDecisionTreeSimulation(
+  input: SimulationInput,
+  extraction: ExtractionResult,
+  callbacks: SimulationCallbacks,
+  signal?: AbortSignal,
+): Promise<SimulationEvent[]> {
+  const allEvents: SimulationEvent[] = []
+
+  // 1. 为关键角色生成决策选项
+  let options: DecisionOption[] | null = null
+  try {
+    const messages = buildDecisionMessages(input, extraction)
+    const raw = await collectStream(input.llmConfig, messages, signal)
+    if (signal?.aborted) return allEvents
+    options = parseDecisionOptions(raw)
+  } catch {
+    options = null
+  }
+
+  // 无法生成决策选项时,回退到普通 runSimulation
+  if (!options || options.length === 0) {
+    callbacks.onProgress(0, "无法生成决策选项,回退到普通推演")
+    return runSimulation(
+      { ...input, mode: "decision-tree" },
+      extraction,
+      callbacks,
+      signal,
+    )
+  }
+
+  const trimmedFramework = trimFramework(input.framework, MAX_BRANCH_NODES)
+  const branchCount = options.length
+
+  // 2. 对每个决策选项推演一条分支
+  for (let i = 0; i < branchCount; i++) {
+    if (signal?.aborted) break
+
+    const option = options[i]
+    const injectionEvent = `【决策分支 ${i + 1}】${option.title}:${option.description}`
+
+    callbacks.onProgress(
+      Math.round((i / branchCount) * 100),
+      `推演决策分支 ${i + 1}/${branchCount}:${option.title}`,
+    )
+
+    // 子分支内部事件不转发给外部回调,避免输出过多
+    const branchCallbacks: SimulationCallbacks = {
+      onEvent: () => {},
+      onProgress: () => {},
+      onComplete: () => {},
+      onError: () => {},
+    }
+
+    try {
+      const branchInput: SimulationInput = {
+        ...input,
+        mode: "decision-tree",
+        framework: trimmedFramework,
+        injectionEvent,
+      }
+      const branchEvents = await runSimulation(
+        branchInput,
+        extraction,
+        branchCallbacks,
+        signal,
+      )
+      allEvents.push(...branchEvents)
+    } catch {
+      // 单个分支失败不影响其他分支的推演
+    }
+  }
+
+  if (!signal?.aborted) {
+    callbacks.onProgress(100, "决策树推演完成")
+    callbacks.onComplete(allEvents)
+  }
+
+  return allEvents
+}

+ 11 - 0
src/lib/novel/story-simulation/simulation-modes/event-driven.ts

@@ -0,0 +1,11 @@
+import { runSimulation, type SimulationCallbacks } from "../simulation-engine"
+import type { SimulationInput, ExtractionResult, SimulationEvent } from "../types"
+
+export async function runEventDrivenSimulation(
+  input: SimulationInput,
+  extraction: ExtractionResult,
+  callbacks: SimulationCallbacks,
+  signal?: AbortSignal,
+): Promise<SimulationEvent[]> {
+  return runSimulation({ ...input, mode: "event-driven" }, extraction, callbacks, signal)
+}

+ 11 - 0
src/lib/novel/story-simulation/simulation-modes/free-emergence.ts

@@ -0,0 +1,11 @@
+import { runSimulation, type SimulationCallbacks } from "../simulation-engine"
+import type { SimulationInput, ExtractionResult, SimulationEvent } from "../types"
+
+export async function runFreeEmergenceSimulation(
+  input: SimulationInput,
+  extraction: ExtractionResult,
+  callbacks: SimulationCallbacks,
+  signal?: AbortSignal,
+): Promise<SimulationEvent[]> {
+  return runSimulation({ ...input, mode: "free-emergence", injectionEvent: undefined }, extraction, callbacks, signal)
+}

+ 11 - 0
src/lib/novel/story-simulation/simulation-modes/hybrid.ts

@@ -0,0 +1,11 @@
+import { runSimulation, type SimulationCallbacks } from "../simulation-engine"
+import type { SimulationInput, ExtractionResult, SimulationEvent } from "../types"
+
+export async function runHybridSimulation(
+  input: SimulationInput,
+  extraction: ExtractionResult,
+  callbacks: SimulationCallbacks,
+  signal?: AbortSignal,
+): Promise<SimulationEvent[]> {
+  return runSimulation({ ...input, mode: "hybrid" }, extraction, callbacks, signal)
+}