Parcourir la source

feat(interaction): add shared create and export flows

Ma il y a 7 mois
Parent
commit
70ef3dbd9e

+ 179 - 0
docs/plans/2026-04-10-interaction-create-export-runtime.md

@@ -0,0 +1,179 @@
+# Interaction Create/Export Runtime Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Finish the remaining shared interaction-layer work by wiring `create_book` and `export_book` into the shared runtime and moving Studio's create/export flows onto the same execution path.
+
+**Architecture:** Keep the existing natural-language interaction path for chat/TUI/OpenClaw, but add a structured interaction entrypoint for UI-driven actions. Shared runtime tools will own create/export behavior so CLI, Studio, and external agents can reuse the same capability surface.
+
+**Tech Stack:** TypeScript, Vitest, Hono, Commander, InkOS core pipeline/session tooling.
+
+---
+
+### Task 1: Add failing core tests for create/export runtime behavior
+
+**Files:**
+- Modify: `packages/core/src/__tests__/interaction-runtime.test.ts`
+- Modify: `packages/core/src/__tests__/project-interaction.test.ts`
+
+**Step 1: Write failing tests**
+
+- Add a runtime test proving `create_book` calls a new `createBook` tool, binds the created book, and returns its summary text.
+- Add a runtime test proving `export_book` calls a new `exportBook` tool with format/approved-only options and returns export metadata text.
+- Add a project-control test proving structured interaction requests persist session updates for `create_book`.
+
+**Step 2: Run tests to verify they fail**
+
+Run:
+
+```bash
+pnpm --dir packages/core exec vitest run \
+  src/__tests__/interaction-runtime.test.ts \
+  src/__tests__/project-interaction.test.ts
+```
+
+Expected: failing tests due to missing runtime tool methods / structured request helper.
+
+### Task 2: Implement shared create/export runtime support
+
+**Files:**
+- Modify: `packages/core/src/interaction/intents.ts`
+- Modify: `packages/core/src/interaction/runtime.ts`
+- Modify: `packages/core/src/interaction/project-control.ts`
+- Modify: `packages/core/src/interaction/project-tools.ts`
+
+**Step 1: Extend interaction request/runtime contracts**
+
+- Add structured fields for create/export:
+  - `title`
+  - `genre`
+  - `platform`
+  - `chapterWordCount`
+  - `targetChapters`
+  - `format`
+  - `approvedOnly`
+  - `outputPath`
+- Add runtime tool methods:
+  - `createBook`
+  - `exportBook`
+
+**Step 2: Implement structured project interaction helper**
+
+- Add `processProjectInteractionRequest(...)` alongside the existing natural-language helper.
+- It should:
+  - load session
+  - optionally bind `activeBookId`
+  - execute `runInteractionRequest`
+  - persist the updated session
+
+**Step 3: Implement create/export tool behavior**
+
+- `createBook`:
+  - derive a book config
+  - call `PipelineRunner.initBook`
+  - return interaction metadata with `responseText`
+- `exportBook`:
+  - centralize the existing CLI export logic into the shared tool
+  - return output path / exported chapter count in interaction metadata
+
+**Step 4: Run tests**
+
+Run:
+
+```bash
+pnpm --dir packages/core exec vitest run \
+  src/__tests__/interaction-runtime.test.ts \
+  src/__tests__/project-interaction.test.ts
+```
+
+Expected: PASS.
+
+### Task 3: Add CLI routing/tests for shared create/export
+
+**Files:**
+- Modify: `packages/core/src/interaction/nl-router.ts`
+- Modify: `packages/core/src/__tests__/interaction-nl-router.test.ts`
+- Modify: `packages/cli/src/__tests__/interact-command.test.ts`
+
+**Step 1: Write failing tests**
+
+- Add NL routing coverage for:
+  - `/new <title>`
+  - `/export`
+  - `/export md`
+- Add interact command coverage for structured/shared export output where useful.
+
+**Step 2: Implement minimal routing**
+
+- Route slash commands into `create_book` / `export_book`.
+- Keep this scoped to explicit commands, not full ideation.
+
+**Step 3: Run tests**
+
+Run:
+
+```bash
+pnpm --dir packages/core exec vitest run src/__tests__/interaction-nl-router.test.ts
+pnpm --dir packages/cli exec vitest run src/__tests__/interact-command.test.ts
+```
+
+Expected: PASS.
+
+### Task 4: Move Studio create/export endpoints onto shared interaction execution
+
+**Files:**
+- Modify: `packages/studio/src/api/server.ts`
+- Modify: `packages/studio/src/api/server.test.ts`
+
+**Step 1: Write failing tests**
+
+- Add server tests proving:
+  - `/api/books/create` routes through shared structured interaction execution
+  - `/api/books/:id/export-save` routes through shared structured interaction execution
+
+**Step 2: Implement endpoint changes**
+
+- Use the same shared interaction tools built from pipeline + state.
+- Replace direct `initBook` / custom export-save logic with calls to the shared structured helper.
+- Preserve existing response shape expected by the Studio UI.
+
+**Step 3: Run tests**
+
+Run:
+
+```bash
+pnpm --dir packages/studio exec vitest run src/api/server.test.ts
+```
+
+Expected: PASS.
+
+### Task 5: Final verification
+
+**Files:**
+- No new files
+
+**Step 1: Run focused test/build suite**
+
+```bash
+pnpm --dir packages/core exec vitest run \
+  src/__tests__/interaction-runtime.test.ts \
+  src/__tests__/project-interaction.test.ts \
+  src/__tests__/interaction-nl-router.test.ts
+pnpm --dir packages/cli exec vitest run src/__tests__/interact-command.test.ts
+pnpm --dir packages/studio exec vitest run src/api/server.test.ts
+pnpm --dir packages/core run build
+pnpm --dir packages/cli run build
+pnpm --dir packages/studio run build
+```
+
+Expected: all green.
+
+**Step 2: Commit**
+
+```bash
+git add packages/core/src/interaction packages/core/src/__tests__ \
+  packages/cli/src/__tests__/interact-command.test.ts \
+  packages/studio/src/api/server.ts packages/studio/src/api/server.test.ts \
+  docs/plans/2026-04-10-interaction-create-export-runtime.md
+git commit -m "feat(interaction): add shared create and export flows"
+```

+ 22 - 0
packages/cli/src/__tests__/interaction-tools.test.ts

@@ -45,6 +45,17 @@ describe("interaction tools adapter", () => {
     const state = {
       ensureControlDocuments: vi.fn(async () => {}),
       bookDir: vi.fn((bookId: string) => join(projectRoot, "books", bookId)),
+      loadBookConfig: vi.fn(async () => ({
+        id: "harbor",
+        title: "Harbor",
+        platform: "other" as const,
+        genre: "other",
+        status: "outlining" as const,
+        targetChapters: 200,
+        chapterWordCount: 3000,
+        createdAt: "2026-04-10T00:00:00.000Z",
+        updatedAt: "2026-04-10T00:00:00.000Z",
+      })),
       loadChapterIndex: vi.fn(async () => []),
       saveChapterIndex: vi.fn(async () => undefined),
       listBooks: vi.fn(async () => ["harbor"]),
@@ -67,6 +78,17 @@ describe("interaction tools adapter", () => {
     const state = {
       ensureControlDocuments: vi.fn(async () => {}),
       bookDir: vi.fn((bookId: string) => join(projectRoot, "books", bookId)),
+      loadBookConfig: vi.fn(async () => ({
+        id: "harbor",
+        title: "Harbor",
+        platform: "other" as const,
+        genre: "other",
+        status: "outlining" as const,
+        targetChapters: 200,
+        chapterWordCount: 3000,
+        createdAt: "2026-04-10T00:00:00.000Z",
+        updatedAt: "2026-04-10T00:00:00.000Z",
+      })),
       loadChapterIndex: vi.fn(async () => []),
       saveChapterIndex: vi.fn(async () => undefined),
       listBooks: vi.fn(async () => ["harbor"]),

+ 1 - 1
packages/cli/src/tui/tools.ts

@@ -7,7 +7,7 @@ import {
 import { buildPipelineConfig, loadConfig } from "../utils.js";
 
 type CliPipelineLike = Pick<PipelineRunner, "writeNextChapter" | "reviseDraft">;
-type CliStateLike = Pick<StateManager, "ensureControlDocuments" | "bookDir" | "loadChapterIndex" | "saveChapterIndex" | "listBooks">;
+type CliStateLike = Pick<StateManager, "ensureControlDocuments" | "bookDir" | "loadBookConfig" | "loadChapterIndex" | "saveChapterIndex" | "listBooks">;
 
 export function createCliInteractionToolsFromDeps(
   pipeline: CliPipelineLike,

+ 32 - 0
packages/core/src/__tests__/interaction-nl-router.test.ts

@@ -58,6 +58,24 @@ describe("interaction natural-language router", () => {
     });
   });
 
+  it("maps export-style natural language requests to export_book", () => {
+    expect(routeNaturalLanguageIntent("导出全书", { activeBookId: "harbor" })).toEqual({
+      intent: "export_book",
+      bookId: "harbor",
+      format: "txt",
+    });
+    expect(routeNaturalLanguageIntent("导出全书为 epub", { activeBookId: "harbor" })).toEqual({
+      intent: "export_book",
+      bookId: "harbor",
+      format: "epub",
+    });
+    expect(routeNaturalLanguageIntent("export book as epub", { activeBookId: "harbor" })).toEqual({
+      intent: "export_book",
+      bookId: "harbor",
+      format: "epub",
+    });
+  });
+
   it("maps greetings to chat instead of status explanation", () => {
     expect(routeNaturalLanguageIntent("hi", { activeBookId: "harbor" })).toEqual({
       intent: "chat",
@@ -90,6 +108,10 @@ describe("interaction natural-language router", () => {
     expect(routeNaturalLanguageIntent("/books", { activeBookId: "harbor" })).toEqual({
       intent: "list_books",
     });
+    expect(routeNaturalLanguageIntent("/new Night Harbor", { activeBookId: "harbor" })).toEqual({
+      intent: "create_book",
+      title: "Night Harbor",
+    });
     expect(routeNaturalLanguageIntent("/open beta", { activeBookId: "harbor" })).toEqual({
       intent: "select_book",
       bookId: "beta",
@@ -131,6 +153,16 @@ describe("interaction natural-language router", () => {
       targetText: "旧名字",
       replacementText: "新名字",
     });
+    expect(routeNaturalLanguageIntent("/export", { activeBookId: "harbor" })).toEqual({
+      intent: "export_book",
+      bookId: "harbor",
+      format: "txt",
+    });
+    expect(routeNaturalLanguageIntent("/export md", { activeBookId: "harbor" })).toEqual({
+      intent: "export_book",
+      bookId: "harbor",
+      format: "md",
+    });
   });
 
   it("maps rename and chapter patch requests from natural language", () => {

+ 81 - 0
packages/core/src/__tests__/interaction-runtime.test.ts

@@ -5,6 +5,8 @@ import { runInteractionRequest } from "../interaction/runtime.js";
 function makeTools(overrides: Partial<Parameters<typeof runInteractionRequest>[0]["tools"]> = {}) {
   return {
     listBooks: vi.fn(async () => ["harbor"]),
+    createBook: vi.fn(),
+    exportBook: vi.fn(),
     writeNextChapter: vi.fn(),
     reviseDraft: vi.fn(),
     patchChapterText: vi.fn(),
@@ -17,6 +19,85 @@ function makeTools(overrides: Partial<Parameters<typeof runInteractionRequest>[0
 }
 
 describe("interaction runtime", () => {
+  it("routes create_book through the shared create tool and binds the created book", async () => {
+    const createBook = vi.fn(async () => ({
+      bookId: "night-harbor",
+      title: "Night Harbor",
+      __interaction: {
+        responseText: "Created Night Harbor.",
+      },
+    }));
+
+    const result = await runInteractionRequest({
+      session: InteractionSessionSchema.parse({
+        sessionId: "session-create",
+        projectRoot: "/tmp/project",
+        automationMode: "semi",
+        messages: [],
+        events: [],
+      }),
+      request: {
+        intent: "create_book",
+        title: "Night Harbor",
+        genre: "urban",
+        platform: "tomato",
+        targetChapters: 120,
+        chapterWordCount: 2800,
+      },
+      tools: makeTools({
+        createBook,
+      }),
+    });
+
+    expect(createBook).toHaveBeenCalledWith({
+      title: "Night Harbor",
+      genre: "urban",
+      platform: "tomato",
+      targetChapters: 120,
+      chapterWordCount: 2800,
+    });
+    expect(result.session.activeBookId).toBe("night-harbor");
+    expect(result.responseText).toContain("Created Night Harbor.");
+  });
+
+  it("routes export_book through the shared export tool", async () => {
+    const exportBook = vi.fn(async () => ({
+      outputPath: "/tmp/project/exports/harbor.md",
+      chaptersExported: 9,
+      __interaction: {
+        responseText: "Exported harbor to /tmp/project/exports/harbor.md",
+      },
+    }));
+
+    const result = await runInteractionRequest({
+      session: InteractionSessionSchema.parse({
+        sessionId: "session-export",
+        projectRoot: "/tmp/project",
+        activeBookId: "harbor",
+        automationMode: "semi",
+        messages: [],
+        events: [],
+      }),
+      request: {
+        intent: "export_book",
+        bookId: "harbor",
+        format: "md",
+        approvedOnly: true,
+        outputPath: "/tmp/project/exports/harbor.md",
+      },
+      tools: makeTools({
+        exportBook,
+      }),
+    });
+
+    expect(exportBook).toHaveBeenCalledWith("harbor", {
+      format: "md",
+      approvedOnly: true,
+      outputPath: "/tmp/project/exports/harbor.md",
+    });
+    expect(result.responseText).toContain("Exported harbor");
+  });
+
   it("keeps write_next completed in auto mode", async () => {
     const writeNextChapter = vi.fn(async () => ({
       chapterNumber: 7,

+ 33 - 0
packages/core/src/__tests__/interaction-tools.test.ts

@@ -44,6 +44,17 @@ describe("interaction tools", () => {
     const state = {
       ensureControlDocuments: vi.fn(async () => {}),
       bookDir: vi.fn((bookId: string) => join(projectRoot, "books", bookId)),
+      loadBookConfig: vi.fn(async () => ({
+        id: "harbor",
+        title: "Harbor",
+        platform: "other" as const,
+        genre: "other",
+        status: "outlining" as const,
+        targetChapters: 200,
+        chapterWordCount: 3000,
+        createdAt: "2026-04-10T00:00:00.000Z",
+        updatedAt: "2026-04-10T00:00:00.000Z",
+      })),
       loadChapterIndex: vi.fn(async () => []),
       saveChapterIndex: vi.fn(async () => undefined),
       listBooks: vi.fn(async () => ["harbor"]),
@@ -93,6 +104,17 @@ describe("interaction tools", () => {
     const state = {
       ensureControlDocuments: vi.fn(async () => {}),
       bookDir: vi.fn((bookId: string) => join(projectRoot, "books", bookId)),
+      loadBookConfig: vi.fn(async () => ({
+        id: "harbor",
+        title: "Harbor",
+        platform: "other" as const,
+        genre: "other",
+        status: "outlining" as const,
+        targetChapters: 200,
+        chapterWordCount: 3000,
+        createdAt: "2026-04-10T00:00:00.000Z",
+        updatedAt: "2026-04-10T00:00:00.000Z",
+      })),
       loadChapterIndex: vi.fn(async () => []),
       saveChapterIndex: vi.fn(async () => undefined),
       listBooks: vi.fn(async () => ["harbor"]),
@@ -133,6 +155,17 @@ describe("interaction tools", () => {
       {
         ensureControlDocuments: vi.fn(async () => {}),
         bookDir: vi.fn((bookId: string) => join(projectRoot, "books", bookId)),
+        loadBookConfig: vi.fn(async () => ({
+          id: "harbor",
+          title: "Harbor",
+          platform: "other" as const,
+          genre: "other",
+          status: "outlining" as const,
+          targetChapters: 200,
+          chapterWordCount: 3000,
+          createdAt: "2026-04-10T00:00:00.000Z",
+          updatedAt: "2026-04-10T00:00:00.000Z",
+        })),
         loadChapterIndex: vi.fn(async () => []),
         saveChapterIndex: vi.fn(async () => undefined),
         listBooks: vi.fn(async () => ["harbor"]),

+ 52 - 1
packages/core/src/__tests__/project-interaction.test.ts

@@ -7,7 +7,10 @@ import {
   loadProjectSession,
   persistProjectSession,
 } from "../interaction/project-session-store.js";
-import { processProjectInteractionInput } from "../interaction/project-control.js";
+import {
+  processProjectInteractionInput,
+  processProjectInteractionRequest,
+} from "../interaction/project-control.js";
 
 let projectRoot: string;
 
@@ -132,4 +135,52 @@ describe("project interaction control", () => {
     expect(result.session.activeBookId).toBe("harbor");
     expect(result.request.intent).toBe("select_book");
   });
+
+  it("persists structured create_book requests into the shared project session", async () => {
+    await persistProjectSession(projectRoot, createProjectSession(projectRoot));
+
+    const tools = {
+      listBooks: vi.fn(async () => ["harbor"]),
+      createBook: vi.fn(async () => ({
+        bookId: "night-harbor",
+        title: "Night Harbor",
+        __interaction: {
+          responseText: "Created Night Harbor.",
+        },
+      })),
+      exportBook: vi.fn(async () => ({ ok: true })),
+      writeNextChapter: vi.fn(async () => ({ ok: true })),
+      reviseDraft: vi.fn(async () => ({ ok: true })),
+      patchChapterText: vi.fn(async () => ({ ok: true })),
+      renameEntity: vi.fn(async () => ({ ok: true })),
+      updateCurrentFocus: vi.fn(async () => ({ ok: true })),
+      updateAuthorIntent: vi.fn(async () => ({ ok: true })),
+      writeTruthFile: vi.fn(async () => ({ ok: true })),
+    };
+
+    const result = await processProjectInteractionRequest({
+      projectRoot,
+      request: {
+        intent: "create_book",
+        title: "Night Harbor",
+        genre: "urban",
+        platform: "tomato",
+        chapterWordCount: 2800,
+        targetChapters: 120,
+      },
+      tools,
+    });
+
+    expect(tools.createBook).toHaveBeenCalledWith({
+      title: "Night Harbor",
+      genre: "urban",
+      platform: "tomato",
+      chapterWordCount: 2800,
+      targetChapters: 120,
+    });
+    expect(result.session.activeBookId).toBe("night-harbor");
+
+    const persisted = await loadProjectSession(projectRoot);
+    expect(persisted.activeBookId).toBe("night-harbor");
+  });
 });

+ 4 - 1
packages/core/src/index.ts

@@ -113,7 +113,10 @@ export {
   routeNaturalLanguageIntent,
   type NaturalLanguageRoutingContext,
 } from "./interaction/nl-router.js";
-export { processProjectInteractionInput } from "./interaction/project-control.js";
+export {
+  processProjectInteractionInput,
+  processProjectInteractionRequest,
+} from "./interaction/project-control.js";
 export { createInteractionToolsFromDeps } from "./interaction/project-tools.js";
 export {
   normalizeTruthFileName,

+ 9 - 0
packages/core/src/interaction/intents.ts

@@ -29,7 +29,16 @@ export const InteractionRequestSchema = z.object({
   intent: InteractionIntentTypeSchema,
   bookId: z.string().min(1).optional(),
   chapterNumber: z.number().int().min(1).optional(),
+  title: z.string().min(1).optional(),
+  genre: z.string().min(1).optional(),
+  platform: z.string().min(1).optional(),
+  language: z.enum(["zh", "en"]).optional(),
+  chapterWordCount: z.number().int().min(1).optional(),
+  targetChapters: z.number().int().min(1).optional(),
   fileName: z.string().min(1).optional(),
+  format: z.enum(["txt", "md", "epub"]).optional(),
+  approvedOnly: z.boolean().optional(),
+  outputPath: z.string().min(1).optional(),
   oldValue: z.string().min(1).optional(),
   newValue: z.string().min(1).optional(),
   targetText: z.string().min(1).optional(),

+ 26 - 0
packages/core/src/interaction/nl-router.ts

@@ -40,6 +40,14 @@ export function routeNaturalLanguageIntent(
     };
   }
 
+  const newCommand = trimmed.match(/^\/new\s+(.+)$/i);
+  if (newCommand) {
+    return {
+      intent: "create_book",
+      title: newCommand[1]!.trim(),
+    };
+  }
+
   const openCommand = trimmed.match(/^\/open\s+(.+)$/i);
   if (openCommand) {
     return {
@@ -133,6 +141,15 @@ export function routeNaturalLanguageIntent(
     };
   }
 
+  const slashExport = trimmed.match(/^\/export(?:\s+(txt|md|epub))?$/i);
+  if (slashExport) {
+    return {
+      intent: "export_book",
+      ...(bookId ? { bookId } : {}),
+      format: (slashExport[1]?.toLowerCase() as "txt" | "md" | "epub" | undefined) ?? "txt",
+    };
+  }
+
   const rewriteMatch = trimmed.match(/(?:rewrite chapter|重写第)\s*(\d+)\s*(?:章)?/i);
   if (rewriteMatch) {
     return {
@@ -197,6 +214,15 @@ export function routeNaturalLanguageIntent(
     };
   }
 
+  if (/^(导出全书(?:为\s*(epub|md|txt))?|export book(?: as)?\s*(epub|md|txt)?)$/i.test(trimmed)) {
+    const matchedFormat = trimmed.match(/(epub|md|txt)/i)?.[1]?.toLowerCase() as "txt" | "md" | "epub" | undefined;
+    return {
+      intent: "export_book",
+      ...(bookId ? { bookId } : {}),
+      format: matchedFormat ?? "txt",
+    };
+  }
+
   return {
     intent: "chat",
     ...(bookId ? { bookId } : {}),

+ 57 - 0
packages/core/src/interaction/project-control.ts

@@ -1,5 +1,6 @@
 import { appendInteractionEvent, appendInteractionMessage } from "./session.js";
 import { routeNaturalLanguageIntent } from "./nl-router.js";
+import type { InteractionRequest } from "./intents.js";
 import type { InteractionRuntimeTools } from "./runtime.js";
 import { runInteractionRequest } from "./runtime.js";
 import {
@@ -8,6 +9,53 @@ import {
   resolveSessionActiveBook,
 } from "./project-session-store.js";
 
+async function processProjectInteractionRequestInternal(params: {
+  readonly projectRoot: string;
+  readonly request: InteractionRequest;
+  readonly tools: InteractionRuntimeTools;
+  readonly activeBookId?: string;
+}) {
+  const session = await loadProjectSession(params.projectRoot);
+  const restoredBookId = await resolveSessionActiveBook(params.projectRoot, session);
+  const resolvedBookId = params.activeBookId ?? params.request.bookId ?? restoredBookId;
+  const sessionWithBook = resolvedBookId && session.activeBookId !== resolvedBookId
+    ? { ...session, activeBookId: resolvedBookId }
+    : session;
+
+  try {
+    const result = await runInteractionRequest({
+      session: sessionWithBook,
+      request: params.request,
+      tools: params.tools,
+    });
+    await persistProjectSession(params.projectRoot, result.session);
+    return {
+      ...result,
+      request: params.request,
+    };
+  } catch (error) {
+    const detail = error instanceof Error ? error.message : String(error);
+    const failedSession = appendInteractionEvent({
+      ...sessionWithBook,
+      currentExecution: {
+        status: "failed",
+        bookId: sessionWithBook.activeBookId,
+        chapterNumber: sessionWithBook.activeChapterNumber,
+        stageLabel: `failed ${params.request.intent}`,
+      },
+    }, {
+      kind: "task.failed",
+      timestamp: Date.now(),
+      status: "failed",
+      bookId: sessionWithBook.activeBookId,
+      chapterNumber: sessionWithBook.activeChapterNumber,
+      detail,
+    });
+    await persistProjectSession(params.projectRoot, failedSession);
+    throw error;
+  }
+}
+
 export async function processProjectInteractionInput(params: {
   readonly projectRoot: string;
   readonly input: string;
@@ -61,3 +109,12 @@ export async function processProjectInteractionInput(params: {
     throw error;
   }
 }
+
+export async function processProjectInteractionRequest(params: {
+  readonly projectRoot: string;
+  readonly request: InteractionRequest;
+  readonly tools: InteractionRuntimeTools;
+  readonly activeBookId?: string;
+}) {
+  return processProjectInteractionRequestInternal(params);
+}

+ 156 - 4
packages/core/src/interaction/project-tools.ts

@@ -1,5 +1,5 @@
-import { writeFile } from "node:fs/promises";
-import { join } from "node:path";
+import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
+import { dirname, join } from "node:path";
 import type {
   InteractionEvent,
   Logger,
@@ -7,13 +7,17 @@ import type {
   StateManager,
   ReviseMode,
   LLMClient,
+  BookConfig,
+  Platform,
 } from "../index.js";
 import { chatCompletion } from "../index.js";
 import { executeEditTransaction } from "./edit-controller.js";
 import type { InteractionRuntimeTools } from "./runtime.js";
 
-type PipelineLike = Pick<PipelineRunner, "writeNextChapter" | "reviseDraft">;
-type StateLike = Pick<StateManager, "ensureControlDocuments" | "bookDir" | "loadChapterIndex" | "saveChapterIndex" | "listBooks">;
+type PipelineLike = Pick<PipelineRunner, "writeNextChapter" | "reviseDraft"> & {
+  readonly initBook?: PipelineRunner["initBook"];
+};
+type StateLike = Pick<StateManager, "ensureControlDocuments" | "bookDir" | "loadBookConfig" | "loadChapterIndex" | "saveChapterIndex" | "listBooks">;
 type InstrumentablePipelineLike = PipelineLike & {
   readonly config?: {
     logger?: Logger;
@@ -22,6 +26,121 @@ type InstrumentablePipelineLike = PipelineLike & {
   };
 };
 
+function normalizePlatform(platform?: string): Platform {
+  switch (platform) {
+    case "tomato":
+    case "feilu":
+    case "qidian":
+      return platform;
+    default:
+      return "other";
+  }
+}
+
+function deriveBookId(title: string): string {
+  return title
+    .toLowerCase()
+    .replace(/[^a-z0-9\u4e00-\u9fff]/g, "-")
+    .replace(/-+/g, "-")
+    .slice(0, 30);
+}
+
+function buildBookConfig(input: {
+  readonly title: string;
+  readonly genre?: string;
+  readonly platform?: string;
+  readonly language?: "zh" | "en";
+  readonly chapterWordCount?: number;
+  readonly targetChapters?: number;
+}): BookConfig {
+  const now = new Date().toISOString();
+  return {
+    id: deriveBookId(input.title),
+    title: input.title,
+    platform: normalizePlatform(input.platform),
+    genre: input.genre ?? "other",
+    status: "outlining",
+    targetChapters: input.targetChapters ?? 200,
+    chapterWordCount: input.chapterWordCount ?? 3000,
+    ...(input.language ? { language: input.language } : {}),
+    createdAt: now,
+    updatedAt: now,
+  };
+}
+
+async function exportBookToPath(state: StateLike, bookId: string, options: {
+  readonly format?: "txt" | "md" | "epub";
+  readonly approvedOnly?: boolean;
+  readonly outputPath?: string;
+}) {
+  const format = options.format ?? "txt";
+  const index = await state.loadChapterIndex(bookId);
+  const book = await state.loadBookConfig(bookId);
+  const chapters = options.approvedOnly
+    ? index.filter((chapter) => chapter.status === "approved")
+    : index;
+
+  if (chapters.length === 0) {
+    throw new Error("No chapters to export.");
+  }
+
+  const bookDir = state.bookDir(bookId);
+  const chaptersDir = join(bookDir, "chapters");
+  const projectRoot = dirname(dirname(bookDir));
+  const outputPath = options.outputPath ?? join(projectRoot, `${bookId}_export.${format}`);
+
+  if (format === "epub") {
+    const sections: string[] = [
+      "<!DOCTYPE html>",
+      `<html><head><meta charset="utf-8"><title>${book.title}</title><style>body{font-family:serif;max-width:40em;margin:auto;padding:2em;line-height:1.8}h2{margin-top:3em}</style></head><body>`,
+      `<h1>${book.title}</h1>`,
+    ];
+
+    for (const chapter of chapters) {
+      const padded = String(chapter.number).padStart(4, "0");
+      const files = await readdir(chaptersDir);
+      const match = files.find((file) => file.startsWith(padded) && file.endsWith(".md"));
+      if (!match) {
+        continue;
+      }
+      const markdown = await readFile(join(chaptersDir, match), "utf-8");
+      const title = markdown.match(/^#\s+(.+)/m)?.[1] ?? match.replace(/\.md$/, "");
+      const htmlBody = markdown
+        .split("\n")
+        .filter((line) => !line.startsWith("#"))
+        .map((line) => line.trim() ? `<p>${line}</p>` : "")
+        .join("\n");
+      sections.push(`<h2>${title}</h2>`);
+      sections.push(htmlBody);
+    }
+    sections.push("</body></html>");
+    await mkdir(dirname(outputPath), { recursive: true });
+    await writeFile(outputPath, sections.join("\n"), "utf-8");
+  } else {
+    const parts: string[] = [];
+    parts.push(format === "md" ? `# ${book.title}\n\n---\n` : `${book.title}\n\n`);
+    for (const chapter of chapters) {
+      const padded = String(chapter.number).padStart(4, "0");
+      const files = await readdir(chaptersDir);
+      const match = files.find((file) => file.startsWith(padded) && file.endsWith(".md"));
+      if (!match) {
+        continue;
+      }
+      parts.push(await readFile(join(chaptersDir, match), "utf-8"));
+      parts.push("\n\n");
+    }
+    await mkdir(dirname(outputPath), { recursive: true });
+    await writeFile(outputPath, parts.join(format === "md" ? "\n---\n\n" : "\n"), "utf-8");
+  }
+
+  return {
+    outputPath,
+    chaptersExported: chapters.length,
+    totalWords: chapters.reduce((sum, chapter) => sum + chapter.wordCount, 0),
+    format,
+  };
+}
+
 function mapStageMessageToStatus(message: string): InteractionEvent["status"] | undefined {
   const lower = message.trim().toLowerCase();
   if (
@@ -201,6 +320,39 @@ export function createInteractionToolsFromDeps(
 
   return {
     listBooks: () => state.listBooks(),
+    createBook: async (input) => {
+      const book = buildBookConfig(input);
+      if (!pipeline.initBook) {
+        throw new Error("Pipeline does not support shared book creation.");
+      }
+      await pipeline.initBook(book);
+      return {
+        bookId: book.id,
+        title: book.title,
+        __interaction: {
+          responseText: `Created ${book.title} (${book.id}).`,
+          details: {
+            bookId: book.id,
+            title: book.title,
+          },
+        },
+      };
+    },
+    exportBook: async (bookId, options) => {
+      const result = await exportBookToPath(state, bookId, options);
+      return {
+        ...result,
+        __interaction: {
+          responseText: `Exported ${bookId} to ${result.outputPath} (${result.chaptersExported} chapters).`,
+          details: {
+            outputPath: result.outputPath,
+            chaptersExported: result.chaptersExported,
+            totalWords: result.totalWords,
+            format: result.format,
+          },
+        },
+      };
+    },
     chat: async (input, options) => {
       const bookLabel = options.bookId ?? "none";
       const response = instrumentedPipeline.config?.client && instrumentedPipeline.config?.model

+ 89 - 0
packages/core/src/interaction/runtime.ts

@@ -14,6 +14,19 @@ type ReviseMode = "local-fix" | "rewrite";
 
 export interface InteractionRuntimeTools {
   readonly listBooks: () => Promise<ReadonlyArray<string>>;
+  readonly createBook?: (input: {
+    readonly title: string;
+    readonly genre?: string;
+    readonly platform?: string;
+    readonly language?: "zh" | "en";
+    readonly chapterWordCount?: number;
+    readonly targetChapters?: number;
+  }) => Promise<unknown>;
+  readonly exportBook?: (bookId: string, options: {
+    readonly format?: "txt" | "md" | "epub";
+    readonly approvedOnly?: boolean;
+    readonly outputPath?: string;
+  }) => Promise<unknown>;
   readonly chat?: (
     input: string,
     options: {
@@ -42,6 +55,7 @@ export interface InteractionRuntimeTools {
 export interface InteractionRuntimeResult {
   readonly session: InteractionSession;
   readonly responseText?: string;
+  readonly details?: Readonly<Record<string, unknown>>;
 }
 
 interface InteractionToolMetadata {
@@ -50,6 +64,7 @@ interface InteractionToolMetadata {
   readonly currentExecution?: ExecutionState;
   readonly pendingDecision?: PendingDecision;
   readonly responseText?: string;
+  readonly details?: Readonly<Record<string, unknown>>;
 }
 
 function extractToolMetadata(value: unknown): InteractionToolMetadata {
@@ -89,6 +104,19 @@ function buildTaskStartedState(
         chapterNumber: session.activeChapterNumber,
         stageLabel: "preparing chapter inputs",
       };
+    case "create_book":
+      return {
+        status: "planning",
+        bookId: request.bookId ?? session.activeBookId,
+        stageLabel: "creating book foundation",
+      };
+    case "export_book":
+      return {
+        status: "persisting",
+        bookId: request.bookId ?? session.activeBookId,
+        chapterNumber: session.activeChapterNumber,
+        stageLabel: "exporting book artifacts",
+      };
     case "revise_chapter":
     case "rewrite_chapter":
       return {
@@ -240,6 +268,41 @@ export async function runInteractionRequest(params: {
   });
 
   switch (request.intent) {
+    case "create_book": {
+      if (!params.tools.createBook) {
+        throw new Error("Book creation is not implemented in the interaction runtime yet.");
+      }
+      if (!request.title) {
+        throw new Error("Book creation requires a title.");
+      }
+      const toolResult = await params.tools.createBook({
+        title: request.title,
+        genre: request.genre,
+        platform: request.platform,
+        language: request.language,
+        chapterWordCount: request.chapterWordCount,
+        targetChapters: request.targetChapters,
+      });
+      const metadata = extractToolMetadata(toolResult);
+      const createdBookId = typeof toolResult === "object" && toolResult !== null && "bookId" in toolResult
+        && typeof (toolResult as { bookId?: unknown }).bookId === "string"
+        ? (toolResult as { bookId: string }).bookId
+        : undefined;
+      if (!createdBookId) {
+        throw new Error("Create-book tool did not return a book id.");
+      }
+      session = bindActiveBook(session, createdBookId);
+      session = appendToolEvents(session, metadata.events);
+      const completed = {
+        ...markCompleted(session),
+        currentExecution: metadata.currentExecution ?? markCompleted(session).currentExecution,
+      };
+      return {
+        session: addEvent(completed, "task.completed", "completed", `Created ${createdBookId}.`),
+        responseText: metadata.responseText ?? `Created ${createdBookId}.`,
+        details: metadata.details,
+      };
+    }
     case "write_next":
     case "continue_book": {
       const bookId = request.bookId ?? session.activeBookId;
@@ -511,6 +574,32 @@ export async function runInteractionRequest(params: {
         ),
       };
     }
+    case "export_book": {
+      const bookId = request.bookId ?? session.activeBookId;
+      if (!params.tools.exportBook) {
+        throw new Error("Book export is not implemented in the interaction runtime yet.");
+      }
+      if (!bookId) {
+        throw new Error("No active book is bound to the interaction session.");
+      }
+      const toolResult = await params.tools.exportBook(bookId, {
+        format: request.format,
+        approvedOnly: request.approvedOnly,
+        outputPath: request.outputPath,
+      });
+      const metadata = extractToolMetadata(toolResult);
+      session = bindActiveBook(session, bookId, metadata.activeChapterNumber);
+      session = appendToolEvents(session, metadata.events);
+      const completed = {
+        ...markCompleted(session),
+        currentExecution: metadata.currentExecution ?? markCompleted(session).currentExecution,
+      };
+      return {
+        session: addEvent(completed, "task.completed", "completed", `Exported ${bookId}.`),
+        responseText: metadata.responseText ?? `Exported ${bookId}.`,
+        details: metadata.details,
+      };
+    }
     case "switch_mode":
       session = markCompleted(session);
       return {

+ 27 - 0
packages/studio/src/api/server.test.ts

@@ -582,6 +582,33 @@ describe("createStudioServer daemon lifecycle", () => {
     expect(resyncChapterArtifactsMock).toHaveBeenCalledWith("demo-book", 3);
   });
 
+  it("routes export-save through the shared structured interaction runtime", async () => {
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const response = await app.request("http://localhost/api/books/demo-book/export-save", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({ format: "md", approvedOnly: true }),
+    });
+
+    expect(response.status).toBe(200);
+    expect(processProjectInteractionRequestMock).toHaveBeenCalledWith(expect.objectContaining({
+      projectRoot: root,
+      activeBookId: "demo-book",
+      request: expect.objectContaining({
+        intent: "export_book",
+        bookId: "demo-book",
+        format: "md",
+        approvedOnly: true,
+      }),
+    }));
+    await expect(response.json()).resolves.toMatchObject({
+      ok: true,
+      chapters: 2,
+    });
+  });
+
   it("routes /api/agent through the shared interaction control layer", async () => {
     processProjectInteractionInputMock.mockResolvedValue({
       request: { intent: "write_next", bookId: "demo-book" },

+ 23 - 42
packages/studio/src/api/server.ts

@@ -209,7 +209,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
         bookCreateStatus.delete(createdBookId);
         broadcast("book:created", { bookId: createdBookId });
       },
-      (e) => {
+      (e: unknown) => {
         const error = e instanceof Error ? e.message : String(e);
         bookCreateStatus.set(bookId, { status: "error", error });
         broadcast("book:error", { bookId, error });
@@ -759,50 +759,31 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
   app.post("/api/books/:id/export-save", async (c) => {
     const id = c.req.param("id");
     const { format, approvedOnly } = await c.req.json<{ format?: string; approvedOnly?: boolean }>().catch(() => ({ format: "txt", approvedOnly: false }));
-    const bookDir = state.bookDir(id);
-    const chaptersDir = join(bookDir, "chapters");
     const fmt = format ?? "txt";
 
     try {
-      const book = await state.loadBookConfig(id);
-      const index = await state.loadChapterIndex(id);
-      const approvedNums = new Set(
-        approvedOnly ? index.filter((ch) => ch.status === "approved").map((ch) => ch.number) : [],
-      );
-
-      const files = await readdir(chaptersDir);
-      const mdFiles = files.filter((f) => f.endsWith(".md") && /^\d{4}/.test(f)).sort();
-      const filteredFiles = approvedOnly
-        ? mdFiles.filter((f) => approvedNums.has(parseInt(f.slice(0, 4), 10)))
-        : mdFiles;
-      const contents = await Promise.all(
-        filteredFiles.map((f) => readFile(join(chaptersDir, f), "utf-8")),
-      );
-
-      const { writeFile: writeFileFs } = await import("node:fs/promises");
-      let outputPath: string;
-      let body: string;
-
-      if (fmt === "md") {
-        body = contents.join("\n\n---\n\n");
-        outputPath = join(bookDir, `${id}.md`);
-      } else if (fmt === "epub") {
-        const chapters = contents.map((content, i) => {
-          const title = content.match(/^#\s+(.+)$/m)?.[1] ?? `Chapter ${i + 1}`;
-          const html = content.split("\n").filter((l) => !l.startsWith("#")).map((l) => l.trim() ? `<p>${l}</p>` : "").join("\n");
-          return { title, html };
-        });
-        const toc = chapters.map((ch, i) => `<li><a href="#ch${i}">${ch.title}</a></li>`).join("\n");
-        const chapterHtml = chapters.map((ch, i) => `<h2 id="ch${i}">${ch.title}</h2>\n${ch.html}`).join("\n<hr/>\n");
-        body = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>${book.title}</title><style>body{font-family:serif;max-width:40em;margin:auto;padding:2em;line-height:1.8}h2{margin-top:3em}</style></head><body><h1>${book.title}</h1><nav><ol>${toc}</ol></nav><hr/>${chapterHtml}</body></html>`;
-        outputPath = join(bookDir, `${id}.html`);
-      } else {
-        body = contents.join("\n\n");
-        outputPath = join(bookDir, `${id}.txt`);
-      }
-
-      await writeFileFs(outputPath, body, "utf-8");
-      return c.json({ ok: true, path: outputPath, format: fmt, chapters: filteredFiles.length });
+      const pipeline = new PipelineRunner(await buildPipelineConfig());
+      const tools = createInteractionToolsFromDeps(pipeline, state);
+      const bookDir = state.bookDir(id);
+      const outputPath = join(bookDir, `${id}.${fmt === "epub" ? "epub" : fmt}`);
+      const result = await processProjectInteractionRequest({
+        projectRoot: root,
+        request: {
+          intent: "export_book",
+          bookId: id,
+          format: fmt as "txt" | "md" | "epub",
+          approvedOnly,
+          outputPath,
+        },
+        tools,
+        activeBookId: id,
+      });
+      return c.json({
+        ok: true,
+        path: (result.details?.outputPath as string | undefined) ?? outputPath,
+        format: fmt,
+        chapters: (result.details?.chaptersExported as number | undefined) ?? 0,
+      });
     } catch (e) {
       return c.json({ error: String(e) }, 500);
     }