浏览代码

feat(interaction): share project control with studio

Ma 7 月之前
父节点
当前提交
01f9e62c0e

+ 6 - 59
packages/cli/src/tui/session-store.ts

@@ -1,59 +1,6 @@
-import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
-import { join } from "node:path";
-import { InteractionSessionSchema, type InteractionSession } from "@actalk/inkos-core";
-
-const SESSION_DIR = ".inkos";
-const SESSION_FILE = "session.json";
-
-function sessionPath(projectRoot: string): string {
-  return join(projectRoot, SESSION_DIR, SESSION_FILE);
-}
-
-export function createProjectSession(projectRoot: string): InteractionSession {
-  return InteractionSessionSchema.parse({
-    sessionId: `${Date.now()}`,
-    projectRoot,
-    automationMode: "semi",
-    messages: [],
-  });
-}
-
-export async function loadProjectSession(projectRoot: string): Promise<InteractionSession> {
-  try {
-    const raw = await readFile(sessionPath(projectRoot), "utf-8");
-    return InteractionSessionSchema.parse(JSON.parse(raw));
-  } catch {
-    return createProjectSession(projectRoot);
-  }
-}
-
-export async function persistProjectSession(
-  projectRoot: string,
-  session: InteractionSession,
-): Promise<void> {
-  const dir = join(projectRoot, SESSION_DIR);
-  await mkdir(dir, { recursive: true });
-  await writeFile(sessionPath(projectRoot), JSON.stringify(session, null, 2), "utf-8");
-}
-
-export async function resolveSessionActiveBook(
-  projectRoot: string,
-  session: InteractionSession,
-): Promise<string | undefined> {
-  const booksDir = join(projectRoot, "books");
-  const entries = await readdir(booksDir, { withFileTypes: true }).catch(() => []);
-  const bookIds = entries
-    .filter((entry) => entry.isDirectory())
-    .map((entry) => entry.name)
-    .sort();
-
-  if (session.activeBookId && bookIds.includes(session.activeBookId)) {
-    return session.activeBookId;
-  }
-
-  if (bookIds.length === 1) {
-    return bookIds[0];
-  }
-
-  return undefined;
-}
+export {
+  createProjectSession,
+  loadProjectSession,
+  persistProjectSession,
+  resolveSessionActiveBook,
+} from "@actalk/inkos-core";

+ 24 - 23
packages/cli/src/tui/tools.ts

@@ -1,34 +1,35 @@
-import { writeFile } from "node:fs/promises";
-import { join } from "node:path";
-import { PipelineRunner, StateManager, type ReviseMode } from "@actalk/inkos-core";
-import type { InteractionRuntimeTools } from "@actalk/inkos-core";
-import { buildPipelineConfig, createClient, loadConfig } from "../utils.js";
+import {
+  PipelineRunner,
+  StateManager,
+  createInteractionToolsFromDeps,
+  type InteractionRuntimeTools,
+} from "@actalk/inkos-core";
+import { buildPipelineConfig, loadConfig } from "../utils.js";
 
 
-type PipelineLike = Pick<PipelineRunner, "writeNextChapter" | "reviseDraft">;
-type StateLike = Pick<StateManager, "ensureControlDocuments" | "bookDir">;
+type CliPipelineLike = Pick<PipelineRunner, "writeNextChapter" | "reviseDraft">;
+type CliStateLike = Pick<StateManager, "ensureControlDocuments" | "bookDir">;
 
 
-export function createInteractionToolsFromDeps(
+export function createCliInteractionToolsFromDeps(
+  pipeline: CliPipelineLike,
+  state: CliStateLike,
+): InteractionRuntimeTools {
+  return createInteractionToolsFromDeps(pipeline, state);
+}
+
+// Backward-compatible export for the current CLI tests during the extraction phase.
+export function createInteractionToolsFromDepsCompat(
   _projectRoot: string,
   _projectRoot: string,
-  pipeline: PipelineLike,
-  state: StateLike,
+  pipeline: CliPipelineLike,
+  state: CliStateLike,
 ): InteractionRuntimeTools {
 ): InteractionRuntimeTools {
-  return {
-    writeNextChapter: (bookId) => pipeline.writeNextChapter(bookId),
-    reviseDraft: (bookId, chapterNumber, mode) => pipeline.reviseDraft(bookId, chapterNumber, mode as ReviseMode),
-    updateCurrentFocus: async (bookId, content) => {
-      await state.ensureControlDocuments(bookId);
-      await writeFile(join(state.bookDir(bookId), "story", "current_focus.md"), content, "utf-8");
-    },
-    updateAuthorIntent: async (bookId, content) => {
-      await state.ensureControlDocuments(bookId);
-      await writeFile(join(state.bookDir(bookId), "story", "author_intent.md"), content, "utf-8");
-    },
-  };
+  return createInteractionToolsFromDeps(pipeline, state);
 }
 }
 
 
+export { createInteractionToolsFromDepsCompat as createInteractionToolsFromDeps };
+
 export async function createInteractionTools(projectRoot: string): Promise<InteractionRuntimeTools> {
 export async function createInteractionTools(projectRoot: string): Promise<InteractionRuntimeTools> {
   const config = await loadConfig();
   const config = await loadConfig();
   const pipeline = new PipelineRunner(buildPipelineConfig(config, projectRoot));
   const pipeline = new PipelineRunner(buildPipelineConfig(config, projectRoot));
   const state = new StateManager(projectRoot);
   const state = new StateManager(projectRoot);
-  return createInteractionToolsFromDeps(projectRoot, pipeline, state);
+  return createInteractionToolsFromDeps(pipeline, state);
 }
 }

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

@@ -0,0 +1,80 @@
+import { mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { beforeAll, describe, expect, it, vi } from "vitest";
+import { createInteractionToolsFromDeps } from "../interaction/project-tools.js";
+
+let projectRoot: string;
+
+describe("interaction tools", () => {
+  beforeAll(async () => {
+    projectRoot = await mkdtemp(join(tmpdir(), "inkos-core-interaction-tools-"));
+    await mkdir(join(projectRoot, "books", "harbor", "story"), { recursive: true });
+  });
+
+  it("delegates writeNextChapter and reviseDraft to the pipeline", async () => {
+    const pipeline = {
+      writeNextChapter: vi.fn(async () => ({
+        chapterNumber: 1,
+        title: "Draft",
+        wordCount: 1000,
+        revised: false,
+        status: "ready-for-review" as const,
+        auditResult: { passed: true, issues: [], summary: "ok" },
+      })),
+      reviseDraft: vi.fn(async () => ({
+        chapterNumber: 3,
+        wordCount: 1200,
+        fixedIssues: [],
+        applied: true,
+        status: "ready-for-review" as const,
+      })),
+    };
+    const state = {
+      ensureControlDocuments: vi.fn(async () => {}),
+      bookDir: vi.fn((bookId: string) => join(projectRoot, "books", bookId)),
+    };
+
+    const tools = createInteractionToolsFromDeps(pipeline, state);
+
+    await tools.writeNextChapter("harbor");
+    await tools.reviseDraft("harbor", 3, "rewrite");
+
+    expect(pipeline.writeNextChapter).toHaveBeenCalledWith("harbor");
+    expect(pipeline.reviseDraft).toHaveBeenCalledWith("harbor", 3, "rewrite");
+  });
+
+  it("writes current_focus and author_intent into canonical story paths", async () => {
+    const tools = createInteractionToolsFromDeps(
+      {
+        writeNextChapter: vi.fn(async () => ({
+          chapterNumber: 1,
+          title: "Draft",
+          wordCount: 1000,
+          revised: false,
+          status: "ready-for-review" as const,
+          auditResult: { passed: true, issues: [], summary: "ok" },
+        })),
+        reviseDraft: vi.fn(async () => ({
+          chapterNumber: 3,
+          wordCount: 1200,
+          fixedIssues: [],
+          applied: true,
+          status: "ready-for-review" as const,
+        })),
+      },
+      {
+        ensureControlDocuments: vi.fn(async () => {}),
+        bookDir: vi.fn((bookId: string) => join(projectRoot, "books", bookId)),
+      },
+    );
+
+    await tools.updateCurrentFocus("harbor", "# Current Focus\n\nBring focus back.\n");
+    await tools.updateAuthorIntent("harbor", "# Author Intent\n\nWrite a harbor mystery.\n");
+
+    await expect(readFile(join(projectRoot, "books", "harbor", "story", "current_focus.md"), "utf-8"))
+      .resolves.toContain("Bring focus back");
+    await expect(readFile(join(projectRoot, "books", "harbor", "story", "author_intent.md"), "utf-8"))
+      .resolves.toContain("harbor mystery");
+  });
+});

+ 67 - 0
packages/core/src/__tests__/project-interaction.test.ts

@@ -0,0 +1,67 @@
+import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
+import {
+  createProjectSession,
+  persistProjectSession,
+} from "../interaction/project-session-store.js";
+import { processProjectInteractionInput } from "../interaction/project-control.js";
+
+let projectRoot: string;
+
+describe("project interaction control", () => {
+  beforeAll(async () => {
+    projectRoot = await mkdtemp(join(tmpdir(), "inkos-project-control-"));
+    await mkdir(join(projectRoot, "books", "harbor"), { recursive: true });
+    await writeFile(join(projectRoot, "books", "harbor", "book.json"), "{}", "utf-8");
+  });
+
+  afterAll(async () => {
+    // tmpdir cleanup omitted
+  });
+
+  it("routes continue through the persisted active book", async () => {
+    await persistProjectSession(projectRoot, {
+      ...createProjectSession(projectRoot),
+      activeBookId: "harbor",
+    });
+
+    const tools = {
+      writeNextChapter: vi.fn(async () => ({ ok: true })),
+      reviseDraft: vi.fn(async () => ({ ok: true })),
+      updateCurrentFocus: vi.fn(async () => ({ ok: true })),
+      updateAuthorIntent: vi.fn(async () => ({ ok: true })),
+    };
+
+    const result = await processProjectInteractionInput({
+      projectRoot,
+      input: "continue",
+      tools,
+    });
+
+    expect(tools.writeNextChapter).toHaveBeenCalledWith("harbor");
+    expect(result.session.activeBookId).toBe("harbor");
+    expect(result.request.intent).toBe("write_next");
+  });
+
+  it("persists mode switches in the project session", async () => {
+    await persistProjectSession(projectRoot, {
+      ...createProjectSession(projectRoot),
+      activeBookId: "harbor",
+    });
+
+    const result = await processProjectInteractionInput({
+      projectRoot,
+      input: "切换到全自动",
+      tools: {
+        writeNextChapter: vi.fn(async () => ({ ok: true })),
+        reviseDraft: vi.fn(async () => ({ ok: true })),
+        updateCurrentFocus: vi.fn(async () => ({ ok: true })),
+        updateAuthorIntent: vi.fn(async () => ({ ok: true })),
+      },
+    });
+
+    expect(result.session.automationMode).toBe("auto");
+  });
+});

+ 9 - 0
packages/core/src/index.ts

@@ -98,11 +98,20 @@ export {
   updateAutomationMode,
   updateAutomationMode,
   appendInteractionMessage,
   appendInteractionMessage,
 } from "./interaction/session.js";
 } from "./interaction/session.js";
+export {
+  resolveProjectSessionPath,
+  createProjectSession,
+  loadProjectSession,
+  persistProjectSession,
+  resolveSessionActiveBook,
+} from "./interaction/project-session-store.js";
 export { routeInteractionRequest } from "./interaction/request-router.js";
 export { routeInteractionRequest } from "./interaction/request-router.js";
 export {
 export {
   routeNaturalLanguageIntent,
   routeNaturalLanguageIntent,
   type NaturalLanguageRoutingContext,
   type NaturalLanguageRoutingContext,
 } from "./interaction/nl-router.js";
 } from "./interaction/nl-router.js";
+export { processProjectInteractionInput } from "./interaction/project-control.js";
+export { createInteractionToolsFromDeps } from "./interaction/project-tools.js";
 export {
 export {
   runInteractionRequest,
   runInteractionRequest,
   type InteractionRuntimeTools,
   type InteractionRuntimeTools,

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

@@ -0,0 +1,41 @@
+import { appendInteractionMessage } from "./session.js";
+import { routeNaturalLanguageIntent } from "./nl-router.js";
+import type { InteractionRuntimeTools } from "./runtime.js";
+import { runInteractionRequest } from "./runtime.js";
+import {
+  loadProjectSession,
+  persistProjectSession,
+  resolveSessionActiveBook,
+} from "./project-session-store.js";
+
+export async function processProjectInteractionInput(params: {
+  readonly projectRoot: string;
+  readonly input: string;
+  readonly tools: InteractionRuntimeTools;
+  readonly activeBookId?: string;
+}) {
+  const session = await loadProjectSession(params.projectRoot);
+  const restoredBookId = await resolveSessionActiveBook(params.projectRoot, session);
+  const resolvedBookId = params.activeBookId ?? restoredBookId;
+  const sessionWithBook = resolvedBookId && session.activeBookId !== resolvedBookId
+    ? { ...session, activeBookId: resolvedBookId }
+    : session;
+  const userSession = appendInteractionMessage(sessionWithBook, {
+    role: "user",
+    content: params.input,
+    timestamp: Date.now(),
+  });
+  const request = routeNaturalLanguageIntent(params.input, {
+    activeBookId: userSession.activeBookId,
+  });
+  const result = await runInteractionRequest({
+    session: userSession,
+    request,
+    tools: params.tools,
+  });
+  await persistProjectSession(params.projectRoot, result.session);
+  return {
+    ...result,
+    request,
+  };
+}

+ 59 - 0
packages/core/src/interaction/project-session-store.ts

@@ -0,0 +1,59 @@
+import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+import { InteractionSessionSchema, type InteractionSession } from "./session.js";
+
+const SESSION_DIR = ".inkos";
+const SESSION_FILE = "session.json";
+
+export function resolveProjectSessionPath(projectRoot: string): string {
+  return join(projectRoot, SESSION_DIR, SESSION_FILE);
+}
+
+export function createProjectSession(projectRoot: string): InteractionSession {
+  return InteractionSessionSchema.parse({
+    sessionId: `${Date.now()}`,
+    projectRoot,
+    automationMode: "semi",
+    messages: [],
+  });
+}
+
+export async function loadProjectSession(projectRoot: string): Promise<InteractionSession> {
+  try {
+    const raw = await readFile(resolveProjectSessionPath(projectRoot), "utf-8");
+    return InteractionSessionSchema.parse(JSON.parse(raw));
+  } catch {
+    return createProjectSession(projectRoot);
+  }
+}
+
+export async function persistProjectSession(
+  projectRoot: string,
+  session: InteractionSession,
+): Promise<void> {
+  const dir = join(projectRoot, SESSION_DIR);
+  await mkdir(dir, { recursive: true });
+  await writeFile(resolveProjectSessionPath(projectRoot), JSON.stringify(session, null, 2), "utf-8");
+}
+
+export async function resolveSessionActiveBook(
+  projectRoot: string,
+  session: InteractionSession,
+): Promise<string | undefined> {
+  const booksDir = join(projectRoot, "books");
+  const entries = await readdir(booksDir, { withFileTypes: true }).catch(() => []);
+  const bookIds = entries
+    .filter((entry) => entry.isDirectory())
+    .map((entry) => entry.name)
+    .sort();
+
+  if (session.activeBookId && bookIds.includes(session.activeBookId)) {
+    return session.activeBookId;
+  }
+
+  if (bookIds.length === 1) {
+    return bookIds[0];
+  }
+
+  return undefined;
+}

+ 25 - 0
packages/core/src/interaction/project-tools.ts

@@ -0,0 +1,25 @@
+import { writeFile } from "node:fs/promises";
+import { join } from "node:path";
+import type { PipelineRunner, StateManager, ReviseMode } from "../index.js";
+import type { InteractionRuntimeTools } from "./runtime.js";
+
+type PipelineLike = Pick<PipelineRunner, "writeNextChapter" | "reviseDraft">;
+type StateLike = Pick<StateManager, "ensureControlDocuments" | "bookDir">;
+
+export function createInteractionToolsFromDeps(
+  pipeline: PipelineLike,
+  state: StateLike,
+): InteractionRuntimeTools {
+  return {
+    writeNextChapter: (bookId) => pipeline.writeNextChapter(bookId),
+    reviseDraft: (bookId, chapterNumber, mode) => pipeline.reviseDraft(bookId, chapterNumber, mode as ReviseMode),
+    updateCurrentFocus: async (bookId, content) => {
+      await state.ensureControlDocuments(bookId);
+      await writeFile(join(state.bookDir(bookId), "story", "current_focus.md"), content, "utf-8");
+    },
+    updateAuthorIntent: async (bookId, content) => {
+      await state.ensureControlDocuments(bookId);
+      await writeFile(join(state.bookDir(bookId), "story", "author_intent.md"), content, "utf-8");
+    },
+  };
+}

+ 145 - 2
packages/studio/src/api/server.test.ts

@@ -16,6 +16,11 @@ const createLLMClientMock = vi.fn(() => ({}));
 const chatCompletionMock = vi.fn();
 const chatCompletionMock = vi.fn();
 const loadProjectConfigMock = vi.fn();
 const loadProjectConfigMock = vi.fn();
 const pipelineConfigs: unknown[] = [];
 const pipelineConfigs: unknown[] = [];
+const processProjectInteractionInputMock = vi.fn();
+const processProjectInteractionRequestMock = vi.fn();
+const createInteractionToolsFromDepsMock = vi.fn(() => ({}));
+const loadProjectSessionMock = vi.fn();
+const resolveSessionActiveBookMock = vi.fn();
 
 
 const logger = {
 const logger = {
   child: () => logger,
   child: () => logger,
@@ -52,6 +57,10 @@ vi.mock("@actalk/inkos-core", () => {
       return 1;
       return 1;
     }
     }
 
 
+    async ensureControlDocuments(): Promise<void> {
+      // no-op in tests
+    }
+
     bookDir(id: string): string {
     bookDir(id: string): string {
       return join(this.root, "books", id);
       return join(this.root, "books", id);
     }
     }
@@ -97,6 +106,11 @@ vi.mock("@actalk/inkos-core", () => {
     computeAnalytics: vi.fn(() => ({})),
     computeAnalytics: vi.fn(() => ({})),
     chatCompletion: chatCompletionMock,
     chatCompletion: chatCompletionMock,
     loadProjectConfig: loadProjectConfigMock,
     loadProjectConfig: loadProjectConfigMock,
+    processProjectInteractionInput: processProjectInteractionInputMock,
+    processProjectInteractionRequest: processProjectInteractionRequestMock,
+    createInteractionToolsFromDeps: createInteractionToolsFromDepsMock,
+    loadProjectSession: loadProjectSessionMock,
+    resolveSessionActiveBook: resolveSessionActiveBookMock,
     GLOBAL_ENV_PATH: join(tmpdir(), "inkos-global.env"),
     GLOBAL_ENV_PATH: join(tmpdir(), "inkos-global.env"),
   };
   };
 });
 });
@@ -183,6 +197,35 @@ describe("createStudioServer daemon lifecycle", () => {
       usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 },
       usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 },
     });
     });
     loadProjectConfigMock.mockReset();
     loadProjectConfigMock.mockReset();
+    processProjectInteractionInputMock.mockReset();
+    processProjectInteractionRequestMock.mockReset();
+    createInteractionToolsFromDepsMock.mockReset();
+    loadProjectSessionMock.mockReset();
+    resolveSessionActiveBookMock.mockReset();
+    createInteractionToolsFromDepsMock.mockReturnValue({});
+    processProjectInteractionRequestMock.mockResolvedValue({
+      request: { intent: "create_book" },
+      session: {
+        sessionId: "session-structured",
+        projectRoot: root,
+        activeBookId: "new-book",
+        automationMode: "semi",
+        messages: [],
+        events: [],
+      },
+      details: {
+        bookId: "new-book",
+        outputPath: join(root, "books", "demo-book", "demo-book.txt"),
+        chaptersExported: 2,
+      },
+    });
+    loadProjectSessionMock.mockResolvedValue({
+      sessionId: "session-1",
+      projectRoot: root,
+      automationMode: "semi",
+      messages: [],
+    });
+    resolveSessionActiveBookMock.mockResolvedValue(undefined);
     loadProjectConfigMock.mockImplementation(async () => {
     loadProjectConfigMock.mockImplementation(async () => {
       const raw = JSON.parse(await readFile(join(root, "inkos.json"), "utf-8")) as Record<string, unknown>;
       const raw = JSON.parse(await readFile(join(root, "inkos.json"), "utf-8")) as Record<string, unknown>;
       return {
       return {
@@ -400,12 +443,12 @@ describe("createStudioServer daemon lifecycle", () => {
     await expect(response.json()).resolves.toMatchObject({
     await expect(response.json()).resolves.toMatchObject({
       error: expect.stringContaining('Book "existing-book" already exists'),
       error: expect.stringContaining('Book "existing-book" already exists'),
     });
     });
-    expect(initBookMock).not.toHaveBeenCalled();
+    expect(processProjectInteractionRequestMock).not.toHaveBeenCalled();
     await expect(access(join(root, "books", "existing-book", "story", "story_bible.md"))).resolves.toBeUndefined();
     await expect(access(join(root, "books", "existing-book", "story", "story_bible.md"))).resolves.toBeUndefined();
   });
   });
 
 
   it("reports async create failures through the create-status endpoint", async () => {
   it("reports async create failures through the create-status endpoint", async () => {
-    initBookMock.mockRejectedValueOnce(new Error("INKOS_LLM_API_KEY not set"));
+    processProjectInteractionRequestMock.mockRejectedValueOnce(new Error("INKOS_LLM_API_KEY not set"));
 
 
     const { createStudioServer } = await import("./server.js");
     const { createStudioServer } = await import("./server.js");
     const app = createStudioServer(cloneProjectConfig() as never, root);
     const app = createStudioServer(cloneProjectConfig() as never, root);
@@ -476,6 +519,39 @@ describe("createStudioServer daemon lifecycle", () => {
     expect(saveChapterIndexMock).not.toHaveBeenCalled();
     expect(saveChapterIndexMock).not.toHaveBeenCalled();
   });
   });
 
 
+  it("routes create requests 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/create", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({
+        title: "New Book",
+        genre: "urban",
+        platform: "qidian",
+        language: "zh",
+        chapterWordCount: 2600,
+        targetChapters: 88,
+      }),
+    });
+
+    expect(response.status).toBe(200);
+    expect(createInteractionToolsFromDepsMock).toHaveBeenCalledTimes(1);
+    expect(processProjectInteractionRequestMock).toHaveBeenCalledWith(expect.objectContaining({
+      projectRoot: root,
+      request: {
+        intent: "create_book",
+        title: "New Book",
+        genre: "urban",
+        language: "zh",
+        platform: "qidian",
+        chapterWordCount: 2600,
+        targetChapters: 88,
+      },
+    }));
+  });
+
   it("passes one-off brief into revise requests through pipeline config", async () => {
   it("passes one-off brief into revise requests through pipeline config", async () => {
     const { createStudioServer } = await import("./server.js");
     const { createStudioServer } = await import("./server.js");
     const app = createStudioServer(cloneProjectConfig() as never, root);
     const app = createStudioServer(cloneProjectConfig() as never, root);
@@ -505,4 +581,71 @@ describe("createStudioServer daemon lifecycle", () => {
     expect(pipelineConfigs.at(-1)).toMatchObject({ externalContext: "以师债线为准同步状态。" });
     expect(pipelineConfigs.at(-1)).toMatchObject({ externalContext: "以师债线为准同步状态。" });
     expect(resyncChapterArtifactsMock).toHaveBeenCalledWith("demo-book", 3);
     expect(resyncChapterArtifactsMock).toHaveBeenCalledWith("demo-book", 3);
   });
   });
+
+  it("routes /api/agent through the shared interaction control layer", async () => {
+    processProjectInteractionInputMock.mockResolvedValue({
+      request: { intent: "write_next", bookId: "demo-book" },
+      session: {
+        sessionId: "session-1",
+        projectRoot: root,
+        activeBookId: "demo-book",
+        automationMode: "semi",
+        messages: [
+          { role: "user", content: "continue", timestamp: 1 },
+          { role: "assistant", content: "Completed write_next for demo-book.", timestamp: 2 },
+        ],
+      },
+    });
+
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const response = await app.request("http://localhost/api/agent", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({ instruction: "continue", activeBookId: "demo-book" }),
+    });
+
+    expect(response.status).toBe(200);
+    await expect(response.json()).resolves.toMatchObject({
+      response: "Completed write_next for demo-book.",
+      request: { intent: "write_next", bookId: "demo-book" },
+      session: expect.objectContaining({
+        activeBookId: "demo-book",
+      }),
+    });
+    expect(createInteractionToolsFromDepsMock).toHaveBeenCalledTimes(1);
+    expect(processProjectInteractionInputMock).toHaveBeenCalledWith(expect.objectContaining({
+      projectRoot: root,
+      input: "continue",
+      activeBookId: "demo-book",
+    }));
+  });
+
+  it("returns the shared interaction session state", async () => {
+    loadProjectSessionMock.mockResolvedValue({
+      sessionId: "session-2",
+      projectRoot: root,
+      activeBookId: "demo-book",
+      automationMode: "auto",
+      messages: [
+        { role: "user", content: "continue", timestamp: 1 },
+      ],
+    });
+    resolveSessionActiveBookMock.mockResolvedValue("demo-book");
+
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const response = await app.request("http://localhost/api/interaction/session");
+
+    expect(response.status).toBe(200);
+    await expect(response.json()).resolves.toMatchObject({
+      session: expect.objectContaining({
+        activeBookId: "demo-book",
+        automationMode: "auto",
+      }),
+      activeBookId: "demo-book",
+    });
+  });
 });
 });

+ 62 - 19
packages/studio/src/api/server.ts

@@ -7,8 +7,13 @@ import {
   PipelineRunner,
   PipelineRunner,
   createLLMClient,
   createLLMClient,
   createLogger,
   createLogger,
+  createInteractionToolsFromDeps,
   computeAnalytics,
   computeAnalytics,
   loadProjectConfig,
   loadProjectConfig,
+  loadProjectSession,
+  processProjectInteractionInput,
+  processProjectInteractionRequest,
+  resolveSessionActiveBook,
   type PipelineConfig,
   type PipelineConfig,
   type ProjectConfig,
   type ProjectConfig,
   type LogSink,
   type LogSink,
@@ -182,10 +187,27 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
     bookCreateStatus.set(bookId, { status: "creating" });
     bookCreateStatus.set(bookId, { status: "creating" });
 
 
     const pipeline = new PipelineRunner(await buildPipelineConfig());
     const pipeline = new PipelineRunner(await buildPipelineConfig());
-    pipeline.initBook(bookConfig).then(
-      () => {
-        bookCreateStatus.delete(bookId);
-        broadcast("book:created", { bookId });
+    const tools = createInteractionToolsFromDeps(pipeline, state);
+    processProjectInteractionRequest({
+      projectRoot: root,
+      request: {
+        intent: "create_book",
+        title: body.title,
+        genre: body.genre,
+        language: body.language === "en" ? "en" : body.language === "zh" ? "zh" : undefined,
+        platform: body.platform,
+        chapterWordCount: body.chapterWordCount,
+        targetChapters: body.targetChapters,
+      },
+      tools,
+    }).then(
+      (result: {
+        readonly session: { readonly activeBookId?: string };
+        readonly details?: Readonly<Record<string, unknown>>;
+      }) => {
+        const createdBookId = (result.details?.bookId as string | undefined) ?? result.session.activeBookId ?? bookId;
+        bookCreateStatus.delete(createdBookId);
+        broadcast("book:created", { bookId: createdBookId });
       },
       },
       (e) => {
       (e) => {
         const error = e instanceof Error ? e.message : String(e);
         const error = e instanceof Error ? e.message : String(e);
@@ -542,27 +564,41 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
 
 
   // --- Agent chat ---
   // --- Agent chat ---
 
 
+  app.get("/api/interaction/session", async (c) => {
+    const session = await loadProjectSession(root);
+    const activeBookId = await resolveSessionActiveBook(root, session);
+    return c.json({
+      session: activeBookId && session.activeBookId !== activeBookId
+        ? { ...session, activeBookId }
+        : session,
+      activeBookId,
+    });
+  });
+
   app.post("/api/agent", async (c) => {
   app.post("/api/agent", async (c) => {
-    const { instruction } = await c.req.json<{ instruction: string }>();
+    const { instruction, activeBookId } = await c.req.json<{ instruction: string; activeBookId?: string }>();
     if (!instruction?.trim()) {
     if (!instruction?.trim()) {
       return c.json({ error: "No instruction provided" }, 400);
       return c.json({ error: "No instruction provided" }, 400);
     }
     }
 
 
-    broadcast("agent:start", { instruction });
+    broadcast("agent:start", { instruction, activeBookId });
 
 
     try {
     try {
-      const { runAgentLoop } = await import("@actalk/inkos-core");
-
-      const result = await runAgentLoop(
-        await buildPipelineConfig(),
-        instruction
-      );
+      const pipeline = new PipelineRunner(await buildPipelineConfig());
+      const tools = createInteractionToolsFromDeps(pipeline, state);
+      const result = await processProjectInteractionInput({
+        projectRoot: root,
+        input: instruction,
+        tools,
+        activeBookId,
+      });
+      const response = result.session.messages.at(-1)?.content ?? "Acknowledged.";
 
 
-      broadcast("agent:complete", { instruction, response: result });
-      return c.json({ response: result });
+      broadcast("agent:complete", { instruction, activeBookId, response });
+      return c.json({ response, session: result.session, request: result.request });
     } catch (e) {
     } catch (e) {
       const msg = e instanceof Error ? e.message : String(e);
       const msg = e instanceof Error ? e.message : String(e);
-      broadcast("agent:error", { instruction, error: msg });
+      broadcast("agent:error", { instruction, activeBookId, error: msg });
       return c.json({ response: msg });
       return c.json({ response: msg });
     }
     }
   });
   });
@@ -623,19 +659,26 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
   app.post("/api/books/:id/revise/:chapter", async (c) => {
   app.post("/api/books/:id/revise/:chapter", async (c) => {
     const id = c.req.param("id");
     const id = c.req.param("id");
     const chapterNum = parseInt(c.req.param("chapter"), 10);
     const chapterNum = parseInt(c.req.param("chapter"), 10);
-    const body: { mode?: string; brief?: string } = await c.req
-      .json<{ mode?: string; brief?: string }>()
-      .catch(() => ({ mode: "spot-fix" }));
+    const bookDir = state.bookDir(id);
+    const body = await c.req.json<{ mode?: string; brief?: string }>().catch(() => ({ mode: "local-fix" }));
 
 
     broadcast("revise:start", { bookId: id, chapter: chapterNum });
     broadcast("revise:start", { bookId: id, chapter: chapterNum });
     try {
     try {
+      const book = await state.loadBookConfig(id);
+      const chaptersDir = join(bookDir, "chapters");
+      const files = await readdir(chaptersDir);
+      const paddedNum = String(chapterNum).padStart(4, "0");
+      const match = files.find((f) => f.startsWith(paddedNum) && f.endsWith(".md"));
+      if (!match) return c.json({ error: "Chapter not found" }, 404);
+
       const pipeline = new PipelineRunner(await buildPipelineConfig({
       const pipeline = new PipelineRunner(await buildPipelineConfig({
         externalContext: body.brief,
         externalContext: body.brief,
       }));
       }));
+      const normalizedMode = body.mode === "spot-fix" ? "local-fix" : (body.mode ?? "local-fix");
       const result = await pipeline.reviseDraft(
       const result = await pipeline.reviseDraft(
         id,
         id,
         chapterNum,
         chapterNum,
-        (body.mode ?? "spot-fix") as "spot-fix" | "polish" | "rewrite" | "rework" | "anti-detect",
+        normalizedMode as "polish" | "rewrite" | "rework" | "local-fix" | "anti-detect",
       );
       );
       broadcast("revise:complete", { bookId: id, chapter: chapterNum });
       broadcast("revise:complete", { bookId: id, chapter: chapterNum });
       return c.json(result);
       return c.json(result);

+ 1 - 1
packages/studio/src/components/ChatBar.tsx

@@ -303,7 +303,7 @@ export function ChatPanel({ open, onClose, t, sse, activeBookId }: {
       const data = await fetchJson<{ response?: string; error?: string }>("/agent", {
       const data = await fetchJson<{ response?: string; error?: string }>("/agent", {
         method: "POST",
         method: "POST",
         headers: { "Content-Type": "application/json" },
         headers: { "Content-Type": "application/json" },
-        body: JSON.stringify({ instruction: text }),
+        body: JSON.stringify({ instruction: text, activeBookId }),
       });
       });
       setLoading(false);
       setLoading(false);
       setMessages((prev) => [...prev, {
       setMessages((prev) => [...prev, {