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

feat: expose interactive branch tools to agents

Ma 7 месяцев назад
Родитель
Сommit
8cef5edc22

+ 219 - 0
packages/core/src/__tests__/pipeline-agent.test.ts

@@ -75,6 +75,225 @@ describe("agent pipeline tools", () => {
     expect(toolNames).toContain("compose_chapter");
     expect(toolNames).toContain("update_author_intent");
     expect(toolNames).toContain("update_current_focus");
+    expect(toolNames).toContain("get_branch_tree");
+    expect(toolNames).toContain("get_branch_choices");
+    expect(toolNames).toContain("choose_branch");
+    expect(toolNames).toContain("switch_branch");
+  });
+
+  it("exposes interactive branch tools through the agent tool surface", async () => {
+    const branchTree = {
+      version: 1 as const,
+      rootNodeId: "root",
+      activeNodeId: "root",
+      nodes: [
+        {
+          nodeId: "root",
+          parentNodeId: null,
+          sourceChapterId: null,
+          sourceChapterNumber: 0,
+          branchDepth: 0,
+          branchLabel: "Main Route",
+          status: "awaiting-choice" as const,
+          snapshotRef: { chapterNumber: 1 },
+          selectedChoiceId: null,
+          chapterIds: ["ch-0001"],
+          displayPath: "main",
+        },
+      ],
+      choices: [
+        {
+          choiceId: "choice-root-a",
+          fromNodeId: "root",
+          toNodeId: "node-a",
+          label: "Route A",
+          intent: "Take route A.",
+          immediateGoal: "Advance route A.",
+          expectedCost: "Lose route B.",
+          expectedRisk: "Conflict rises.",
+          hookPressure: "A hook advances.",
+          characterPressure: "A pressure rises.",
+          tone: "tense",
+          selected: false,
+        },
+      ],
+    };
+
+    vi.spyOn(pipeline, "getInteractiveBranchTree").mockResolvedValue(branchTree);
+    vi.spyOn(pipeline, "listInteractiveChoices").mockResolvedValue({
+      bookId,
+      activeNodeId: "root",
+      activeNodeStatus: "awaiting-choice",
+      choices: branchTree.choices,
+    });
+    vi.spyOn(pipeline, "chooseInteractiveBranch").mockResolvedValue({
+      bookId,
+      activeNodeId: "node-a",
+      restoredChapter: 1,
+      selectedChoiceId: "choice-root-a",
+    });
+    vi.spyOn(pipeline, "switchInteractiveBranch").mockResolvedValue({
+      bookId,
+      activeNodeId: "node-b",
+      restoredChapter: 1,
+    });
+
+    await expect(
+      executeAgentTool(pipeline, state, config, "get_branch_tree", { bookId }),
+    ).resolves.toBe(JSON.stringify(branchTree));
+
+    await expect(
+      executeAgentTool(pipeline, state, config, "get_branch_choices", { bookId }),
+    ).resolves.toBe(JSON.stringify({
+      bookId,
+      activeNodeId: "root",
+      activeNodeStatus: "awaiting-choice",
+      choices: branchTree.choices,
+    }));
+
+    await expect(
+      executeAgentTool(pipeline, state, config, "choose_branch", {
+        bookId,
+        choiceId: "choice-root-a",
+      }),
+    ).resolves.toBe(JSON.stringify({
+      bookId,
+      activeNodeId: "node-a",
+      restoredChapter: 1,
+      selectedChoiceId: "choice-root-a",
+    }));
+
+    await expect(
+      executeAgentTool(pipeline, state, config, "switch_branch", {
+        bookId,
+        nodeId: "node-b",
+      }),
+    ).resolves.toBe(JSON.stringify({
+      bookId,
+      activeNodeId: "node-b",
+      restoredChapter: 1,
+    }));
+  });
+
+  it("returns active-branch scoped status for interactive books", async () => {
+    const baseBook = await state.loadBookConfig(bookId);
+    await state.saveBookConfig(bookId, {
+      ...baseBook,
+      narrativeMode: "interactive-tree",
+    });
+    await state.saveChapterIndex(bookId, [
+      {
+        number: 1,
+        title: "Seed",
+        status: "approved",
+        wordCount: 100,
+        createdAt: "2026-03-30T00:00:00.000Z",
+        updatedAt: "2026-03-30T00:00:00.000Z",
+        auditIssues: [],
+        lengthWarnings: [],
+      },
+      {
+        number: 2,
+        title: "Route A",
+        status: "ready-for-review",
+        wordCount: 120,
+        createdAt: "2026-03-30T00:00:00.000Z",
+        updatedAt: "2026-03-30T00:00:00.000Z",
+        auditIssues: [],
+        lengthWarnings: [],
+      },
+      {
+        number: 3,
+        title: "Route B",
+        status: "ready-for-review",
+        wordCount: 140,
+        createdAt: "2026-03-30T00:00:00.000Z",
+        updatedAt: "2026-03-30T00:00:00.000Z",
+        auditIssues: [],
+        lengthWarnings: [],
+      },
+    ]);
+    await state.saveBranchTree(bookId, {
+      version: 1,
+      rootNodeId: "root",
+      activeNodeId: "node-a",
+      nodes: [
+        {
+          nodeId: "root",
+          parentNodeId: null,
+          sourceChapterId: null,
+          sourceChapterNumber: 0,
+          branchDepth: 0,
+          branchLabel: "Main Route",
+          status: "completed",
+          snapshotRef: { chapterNumber: 1 },
+          selectedChoiceId: "choice-root-a",
+          chapterIds: ["ch-0001"],
+          displayPath: "main",
+        },
+        {
+          nodeId: "node-a",
+          parentNodeId: "root",
+          sourceChapterId: "ch-0001",
+          sourceChapterNumber: 1,
+          branchDepth: 1,
+          branchLabel: "Route A",
+          status: "active",
+          snapshotRef: { chapterNumber: 2 },
+          selectedChoiceId: null,
+          chapterIds: ["ch-0002"],
+          displayPath: "main.a",
+        },
+        {
+          nodeId: "node-b",
+          parentNodeId: "root",
+          sourceChapterId: "ch-0001",
+          sourceChapterNumber: 1,
+          branchDepth: 1,
+          branchLabel: "Route B",
+          status: "dormant",
+          snapshotRef: { chapterNumber: 1 },
+          selectedChoiceId: null,
+          chapterIds: ["ch-0003"],
+          displayPath: "main.b",
+        },
+      ],
+      choices: [
+        {
+          choiceId: "choice-root-a",
+          fromNodeId: "root",
+          toNodeId: "node-a",
+          label: "Route A",
+          intent: "Take route A.",
+          immediateGoal: "Advance route A.",
+          expectedCost: "Lose route B.",
+          expectedRisk: "Conflict rises.",
+          hookPressure: "A hook advances.",
+          characterPressure: "A pressure rises.",
+          tone: "tense",
+          selected: true,
+        },
+      ],
+    });
+
+    const result = JSON.parse(await executeAgentTool(
+      pipeline,
+      state,
+      config,
+      "get_book_status",
+      { bookId },
+    ));
+
+    expect(result.chaptersWritten).toBe(2);
+    expect(result.totalWords).toBe(220);
+    expect(result.chapters.map((chapter: { number: number }) => chapter.number)).toEqual([1, 2]);
+    expect(result.activeBranch).toEqual({
+      activeNodeId: "node-a",
+      displayPath: "main.a",
+      status: "active",
+      visibleChapterNumbers: [1, 2],
+      pendingChoiceCount: 0,
+    });
   });
 
   it("plans and composes chapters through the agent tool surface", async () => {

+ 78 - 1
packages/core/src/pipeline/agent.ts

@@ -114,7 +114,7 @@ const TOOLS: ReadonlyArray<ToolDefinition> = [
   },
   {
     name: "get_book_status",
-    description: "获取书籍状态概览:章数、字数、最近章节审计情况。",
+    description: "获取书籍状态概览:章数、字数、最近章节审计情况。互动书默认返回当前 active branch 的状态。",
     parameters: {
       type: "object",
       properties: {
@@ -142,6 +142,52 @@ const TOOLS: ReadonlyArray<ToolDefinition> = [
       properties: {},
     },
   },
+  {
+    name: "get_branch_tree",
+    description: "读取互动小说的分支树。用于查看当前 active branch、历史分叉和可切回的分支节点。",
+    parameters: {
+      type: "object",
+      properties: {
+        bookId: { type: "string", description: "互动书籍ID" },
+      },
+      required: ["bookId"],
+    },
+  },
+  {
+    name: "get_branch_choices",
+    description: "读取当前 active branch 的待读者选项。用于在互动小说里决定接下来走哪条分支。",
+    parameters: {
+      type: "object",
+      properties: {
+        bookId: { type: "string", description: "互动书籍ID" },
+      },
+      required: ["bookId"],
+    },
+  },
+  {
+    name: "choose_branch",
+    description: "在当前 active branch 等待选择时,正式选中一个读者选项并切换到对应分支。",
+    parameters: {
+      type: "object",
+      properties: {
+        bookId: { type: "string", description: "互动书籍ID" },
+        choiceId: { type: "string", description: "待选分支的 choiceId" },
+      },
+      required: ["bookId", "choiceId"],
+    },
+  },
+  {
+    name: "switch_branch",
+    description: "切换到一个已有互动分支节点,并恢复该分支对应的状态快照。",
+    parameters: {
+      type: "object",
+      properties: {
+        bookId: { type: "string", description: "互动书籍ID" },
+        nodeId: { type: "string", description: "目标分支节点ID" },
+      },
+      required: ["bookId", "nodeId"],
+    },
+  },
   {
     name: "write_full_pipeline",
     description: "完整管线:写草稿 → 审计 → 自动修订(如需要)。一键完成。",
@@ -247,6 +293,10 @@ export async function runAgentLoop(
 | get_book_status | 查看书的章数、字数、审计状态 |
 | read_truth_files | 读取长期记忆(状态卡、资源账本、伏笔池)和设定(世界观、卷纲、本书规则) |
 | create_book | 建书,生成世界观、卷纲、本书规则(自动加载题材 genre profile) |
+| get_branch_tree | 读取互动小说的分支树 |
+| get_branch_choices | 读取当前 active branch 的待读者选项 |
+| choose_branch | 选中一个互动分支选项 |
+| switch_branch | 切换到已有互动分支并恢复快照 |
 | plan_chapter | 先生成 chapter intent,确认本章目标/冲突/优先级 |
 | compose_chapter | 再生成 runtime context/rule stack,确认实际输入 |
 | write_draft | 写【下一章】草稿(只能续写最新章之后,不能补历史章) |
@@ -289,6 +339,7 @@ export async function runAgentLoop(
 - 用户说了书名/bookId → 直接操作,不需要先 list_books
 - 每完成一步,简要汇报进展
 - 当用户要求“先把注意力拉回某条线”时,优先 update_current_focus,然后 plan_chapter / compose_chapter,再决定是否 write_draft 或 write_full_pipeline
+- 互动小说如果当前分支在等待选择,先用 get_branch_choices / choose_branch 或 switch_branch,不要直接调用 write_draft / write_full_pipeline
 - 仿写流程:用户提供参考文本 → import_style → 生成 style_guide.md,后续写作自动参照
 - 番外流程:先 create_book 建番外书 → import_canon 导入正传正典 → 然后正常 write_draft
 - 续写流程:用户提供已有章节 → import_chapters → 然后 write_draft 续写
@@ -491,6 +542,32 @@ export async function executeAgentTool(
       return JSON.stringify(books);
     }
 
+    case "get_branch_tree": {
+      const result = await pipeline.getInteractiveBranchTree(args.bookId as string);
+      return JSON.stringify(result);
+    }
+
+    case "get_branch_choices": {
+      const result = await pipeline.listInteractiveChoices(args.bookId as string);
+      return JSON.stringify(result);
+    }
+
+    case "choose_branch": {
+      const result = await pipeline.chooseInteractiveBranch(
+        args.bookId as string,
+        args.choiceId as string,
+      );
+      return JSON.stringify(result);
+    }
+
+    case "switch_branch": {
+      const result = await pipeline.switchInteractiveBranch(
+        args.bookId as string,
+        args.nodeId as string,
+      );
+      return JSON.stringify(result);
+    }
+
     case "write_full_pipeline": {
       const bookId = args.bookId as string;
       const writeGuardError = await getSequentialWriteGuardError(state, bookId, "write_full_pipeline");

+ 10 - 1
packages/core/src/pipeline/runner.ts

@@ -125,6 +125,13 @@ export interface BookStatusInfo {
   readonly totalWords: number;
   readonly nextChapter: number;
   readonly chapters: ReadonlyArray<ChapterMeta>;
+  readonly activeBranch?: {
+    activeNodeId: string;
+    displayPath: string;
+    status: "active" | "awaiting-choice" | "dormant" | "completed";
+    visibleChapterNumbers: ReadonlyArray<number>;
+    pendingChoiceCount: number;
+  };
 }
 
 export interface InteractiveBranchChoicesResult {
@@ -933,7 +940,8 @@ export class PipelineRunner {
   /** Get book status overview. */
   async getBookStatus(bookId: string): Promise<BookStatusInfo> {
     const book = await this.state.loadBookConfig(bookId);
-    const chapters = await this.state.loadChapterIndex(bookId);
+    const chapters = await this.state.loadVisibleChapterIndex(bookId);
+    const activeBranch = await this.state.loadActiveBranchView(bookId);
     const nextChapter = await this.state.getNextChapterNumber(bookId);
     const totalWords = chapters.reduce((sum, ch) => sum + ch.wordCount, 0);
 
@@ -947,6 +955,7 @@ export class PipelineRunner {
       totalWords,
       nextChapter,
       chapters: [...chapters],
+      ...(activeBranch ? { activeBranch } : {}),
     };
   }