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

feat: initialize interactive books during create

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

+ 6 - 0
packages/cli/src/__tests__/cli-integration.test.ts

@@ -229,6 +229,12 @@ describe("CLI integration", () => {
   });
 
   describe("inkos book create", () => {
+    it("exposes narrative mode flag in help output", () => {
+      const output = run(["book", "create", "--help"]);
+      expect(output).toContain("--narrative-mode");
+      expect(output).toContain("interactive-tree");
+    });
+
     it("removes stale incomplete book directories before retrying create", async () => {
       try {
         await stat(join(projectDir, "inkos.json"));

+ 2 - 0
packages/cli/src/commands/book.ts

@@ -24,6 +24,7 @@ bookCommand
   .option("--platform <platform>", "Target platform", "tomato")
   .option("--target-chapters <n>", "Target chapter count", "200")
   .option("--chapter-words <n>", "Words per chapter", "3000")
+  .option("--narrative-mode <mode>", "Narrative mode: linear or interactive-tree", "linear")
   .option("--brief <path>", "Path to creative brief file (.md/.txt) — Architect builds from your ideas instead of generating from scratch")
   .option("--lang <language>", "Writing language: zh (Chinese) or en (English). Defaults from genre.")
   .option("--json", "Output JSON")
@@ -61,6 +62,7 @@ bookCommand
         targetChapters: parseInt(opts.targetChapters, 10),
         chapterWordCount: parseInt(opts.chapterWords, 10),
         language: opts.lang ?? config.language,
+        narrativeMode: opts.narrativeMode,
         createdAt: now,
         updatedAt: now,
       };

+ 55 - 0
packages/core/src/__tests__/pipeline-runner.test.ts

@@ -340,6 +340,61 @@ describe("PipelineRunner", () => {
     }
   });
 
+  it("initializes a root interactive branch tree during book creation", async () => {
+    const root = await mkdtemp(join(tmpdir(), "inkos-init-interactive-book-test-"));
+    const bookId = "interactive-book";
+    const now = "2026-03-30T00:00:00.000Z";
+    const book: BookConfig = {
+      id: bookId,
+      title: "Interactive Book",
+      platform: "tomato",
+      genre: "xuanhuan",
+      status: "outlining",
+      targetChapters: 10,
+      chapterWordCount: 3000,
+      narrativeMode: "interactive-tree",
+      createdAt: now,
+      updatedAt: now,
+    };
+
+    const runner = new PipelineRunner({
+      client: {
+        provider: "openai",
+        apiFormat: "chat",
+        stream: false,
+        defaults: {
+          temperature: 0.7,
+          maxTokens: 4096,
+          thinkingBudget: 0, maxTokensCap: null,
+        },
+      } as ConstructorParameters<typeof PipelineRunner>[0]["client"],
+      model: "test-model",
+      projectRoot: root,
+    });
+
+    vi.spyOn(ArchitectAgent.prototype, "generateFoundation").mockResolvedValue({
+      storyBible: "# Story Bible\n",
+      volumeOutline: "# Volume Outline\n",
+      bookRules: "---\nversion: \"1.0\"\n---\n\n# Book Rules\n",
+      currentState: "# Current State\n",
+      pendingHooks: "# Pending Hooks\n",
+    });
+
+    try {
+      await runner.initBook(book);
+
+      const tree = await readFile(
+        join(root, "books", bookId, "story", "interactive", "branch-tree.json"),
+        "utf-8",
+      );
+
+      expect(tree).toContain("\"rootNodeId\": \"root\"");
+      expect(tree).toContain("\"activeNodeId\": \"root\"");
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+
   it("bootstraps missing control documents for legacy books before writing", async () => {
     const { root, runner, bookId } = await createRunnerFixture();
 

+ 5 - 0
packages/core/src/pipeline/runner.ts

@@ -318,6 +318,11 @@ export class PipelineRunner {
         this.config.externalContext,
       );
 
+      if (book.narrativeMode === "interactive-tree") {
+        this.logStage(stageLanguage, { zh: "初始化互动分支树", en: "initializing interactive branch tree" });
+        await this.state.ensureInteractiveTreeAt(stagingBookDir);
+      }
+
       await this.state.saveChapterIndexAt(stagingBookDir, []);
 
       this.logStage(stageLanguage, { zh: "创建初始快照", en: "creating initial snapshot" });

+ 20 - 4
packages/core/src/state/manager.ts

@@ -153,6 +153,10 @@ export class StateManager {
     return join(this.bookDir(bookId), "story", "interactive");
   }
 
+  interactiveDirAt(bookDir: string): string {
+    return join(bookDir, "story", "interactive");
+  }
+
   async loadProjectConfig(): Promise<Record<string, unknown>> {
     const configPath = join(this.projectRoot, "inkos.json");
     const raw = await readFile(configPath, "utf-8");
@@ -277,7 +281,11 @@ export class StateManager {
   }
 
   async loadBranchTree(bookId: string): Promise<InteractiveBranchTree | null> {
-    const treePath = join(this.interactiveDir(bookId), "branch-tree.json");
+    return this.loadBranchTreeAt(this.bookDir(bookId));
+  }
+
+  async loadBranchTreeAt(bookDir: string): Promise<InteractiveBranchTree | null> {
+    const treePath = join(this.interactiveDirAt(bookDir), "branch-tree.json");
     try {
       const raw = await readFile(treePath, "utf-8");
       return InteractiveBranchTreeSchema.parse(JSON.parse(raw));
@@ -287,7 +295,11 @@ export class StateManager {
   }
 
   async saveBranchTree(bookId: string, tree: InteractiveBranchTree): Promise<void> {
-    const interactiveDir = this.interactiveDir(bookId);
+    await this.saveBranchTreeAt(this.bookDir(bookId), tree);
+  }
+
+  async saveBranchTreeAt(bookDir: string, tree: InteractiveBranchTree): Promise<void> {
+    const interactiveDir = this.interactiveDirAt(bookDir);
     await mkdir(interactiveDir, { recursive: true });
     await writeFile(
       join(interactiveDir, "branch-tree.json"),
@@ -297,7 +309,11 @@ export class StateManager {
   }
 
   async ensureInteractiveTree(bookId: string): Promise<InteractiveBranchTree> {
-    const existing = await this.loadBranchTree(bookId);
+    return this.ensureInteractiveTreeAt(this.bookDir(bookId));
+  }
+
+  async ensureInteractiveTreeAt(bookDir: string): Promise<InteractiveBranchTree> {
+    const existing = await this.loadBranchTreeAt(bookDir);
     if (existing) {
       return existing;
     }
@@ -326,7 +342,7 @@ export class StateManager {
       choices: [],
     };
 
-    await this.saveBranchTree(bookId, rootTree);
+    await this.saveBranchTreeAt(bookDir, rootTree);
     return rootTree;
   }