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

fix-tui-direct-book-and-write-routes

Ma 6 месяцев назад
Родитель
Сommit
15a6a1867e

+ 90 - 0
packages/cli/src/__tests__/tui-agent-session.test.ts

@@ -18,6 +18,15 @@ vi.mock("@actalk/inkos-core", async () => {
   const actual = await vi.importActual<typeof import("@actalk/inkos-core")>("@actalk/inkos-core");
   class PipelineRunnerMock {
     constructor(_config: unknown) {}
+    async initBook(_book: unknown, _options?: unknown) {}
+    async writeNextChapter(_bookId: string) {
+      return {
+        chapterNumber: 1,
+        title: "雨夜",
+        wordCount: 1200,
+        status: "ready-for-review",
+      };
+    }
   }
   return {
     ...actual,
@@ -120,4 +129,85 @@ describe("tui agent session bridge", () => {
       content: "这是 agent 直接返回的回复。",
     }));
   });
+
+  it("stores the created book from architect tool results as the active TUI book", async () => {
+    runAgentSessionMock.mockResolvedValue({
+      responseText: "《夜港》已创建成功。",
+      messages: [
+        {
+          role: "toolResult",
+          details: { kind: "book_created", bookId: "night-harbor", title: "夜港" },
+        },
+        { role: "assistant", content: "《夜港》已创建成功。" },
+      ],
+    });
+
+    const { processTuiAgentInput } = await import("../tui/agent-input.js");
+    const session = createProjectSession(projectRoot);
+
+    const result = await processTuiAgentInput({
+      projectRoot,
+      input: "创建《夜港》",
+      session,
+    });
+
+    expect(result.session.activeBookId).toBe("night-harbor");
+    const persisted = await loadProjectSession(projectRoot);
+    expect(persisted.activeBookId).toBe("night-harbor");
+  });
+
+  it("routes explicit create-book instructions directly to shared book creation", async () => {
+    const initBookSpy = vi.spyOn(
+      (await import("@actalk/inkos-core")).PipelineRunner.prototype as any,
+      "initBook",
+    );
+    const { processTuiAgentInput } = await import("../tui/agent-input.js");
+    const session = createProjectSession(projectRoot);
+
+    const result = await processTuiAgentInput({
+      projectRoot,
+      input: "创建一本10章中文都市悬疑短篇,标题《雾灯小巷》,目标平台番茄,每章约1200字。信息足够,请直接建书。",
+      session,
+    });
+
+    expect(runAgentSessionMock).not.toHaveBeenCalled();
+    expect(initBookSpy).toHaveBeenCalledWith(
+      expect.objectContaining({
+        id: "雾灯小巷",
+        title: "雾灯小巷",
+        genre: "urban",
+        platform: "tomato",
+        targetChapters: 10,
+        chapterWordCount: 1200,
+      }),
+      expect.objectContaining({
+        externalContext: expect.stringContaining("雾灯小巷"),
+      }),
+    );
+    expect(result.session.activeBookId).toBe("雾灯小巷");
+    expect(result.responseText).toContain("已创建");
+    const persisted = await loadProjectSession(projectRoot);
+    expect(persisted.activeBookId).toBe("雾灯小巷");
+  });
+
+  it("routes write-next instructions directly to the writer when a book is active", async () => {
+    const { processTuiAgentInput } = await import("../tui/agent-input.js");
+    const session = {
+      ...createProjectSession(projectRoot),
+      activeBookId: "night-harbor",
+    };
+
+    const result = await processTuiAgentInput({
+      projectRoot,
+      input: "写第1章",
+      session,
+    });
+
+    expect(runAgentSessionMock).not.toHaveBeenCalled();
+    expect(result.responseText).toContain("第 1 章");
+    expect(result.responseText).toContain("雨夜");
+    const persisted = await loadProjectSession(projectRoot);
+    expect(persisted.activeBookId).toBe("night-harbor");
+    expect(persisted.activeChapterNumber).toBe(1);
+  });
 });

+ 168 - 2
packages/cli/src/tui/agent-input.ts

@@ -41,6 +41,71 @@ export async function processTuiAgentInput(params: {
     timestamp: userTimestamp,
   });
 
+  if (!resolvedBookId && isCreateBookInstruction(params.input)) {
+    const book = parseBookCreationRequest(params.input);
+    if (book) {
+      await pipeline.initBook(book, {
+        externalContext: params.input,
+        authorIntent: params.input,
+      });
+      const responseText = `已创建《${book.title}》,接下来可以直接输入“写第1章”。`;
+      nextSession = appendInteractionMessage({
+        ...nextSession,
+        activeBookId: book.id,
+        currentExecution: {
+          status: "completed",
+          bookId: book.id,
+          stageLabel: "architect",
+        },
+      }, {
+        role: "assistant",
+        content: responseText,
+        timestamp: userTimestamp + 1,
+      });
+      await persistProjectSession(params.projectRoot, nextSession);
+      return {
+        responseText,
+        session: nextSession,
+      };
+    }
+  }
+
+  if (resolvedBookId && isWriteNextInstruction(params.input)) {
+    const writeResult = await pipeline.writeNextChapter(resolvedBookId);
+    const chapterNumber = getResultNumber(writeResult, "chapterNumber");
+    const title = getResultString(writeResult, "title");
+    const wordCount = getResultNumber(writeResult, "wordCount");
+    const status = getResultString(writeResult, "status");
+    const responseText = [
+      `已为 ${resolvedBookId} 完成`,
+      chapterNumber ? `第 ${chapterNumber} 章` : "下一章",
+      title ? `《${title}》` : "",
+      wordCount ? `,字数 ${wordCount}` : "",
+      status ? `,状态 ${status}` : "",
+      "。",
+    ].join("");
+    nextSession = appendInteractionMessage({
+      ...nextSession,
+      activeBookId: resolvedBookId,
+      currentExecution: {
+        status: "completed",
+        bookId: resolvedBookId,
+        ...(chapterNumber ? { chapterNumber } : {}),
+        stageLabel: "writer",
+      },
+      ...(chapterNumber ? { activeChapterNumber: chapterNumber } : {}),
+    }, {
+      role: "assistant",
+      content: responseText,
+      timestamp: userTimestamp + 1,
+    });
+    await persistProjectSession(params.projectRoot, nextSession);
+    return {
+      responseText,
+      session: nextSession,
+    };
+  }
+
   const result = await runAgentSession(
     {
       sessionId: params.session.sessionId,
@@ -61,14 +126,17 @@ export async function processTuiAgentInput(params: {
     params.input,
     initialMessages,
   );
+  const createdBookId = extractCreatedBookId(result.messages);
+  const activeBookId = createdBookId ?? resolvedBookId;
 
   if (result.responseText?.trim()) {
     const lastAssistant = result.messages.filter((message: any) => message.role === "assistant").pop() as { thinking?: string } | undefined;
     nextSession = appendInteractionMessage({
       ...nextSession,
+      ...(activeBookId ? { activeBookId } : {}),
       currentExecution: {
         status: "completed",
-        ...(resolvedBookId ? { bookId: resolvedBookId } : {}),
+        ...(activeBookId ? { bookId: activeBookId } : {}),
         ...(params.session.activeChapterNumber ? { chapterNumber: params.session.activeChapterNumber } : {}),
         stageLabel: "agent",
       },
@@ -81,9 +149,10 @@ export async function processTuiAgentInput(params: {
   } else {
     nextSession = {
       ...nextSession,
+      ...(activeBookId ? { activeBookId } : {}),
       currentExecution: {
         status: "completed",
-        ...(resolvedBookId ? { bookId: resolvedBookId } : {}),
+        ...(activeBookId ? { bookId: activeBookId } : {}),
         ...(params.session.activeChapterNumber ? { chapterNumber: params.session.activeChapterNumber } : {}),
         stageLabel: "agent",
       },
@@ -96,3 +165,100 @@ export async function processTuiAgentInput(params: {
     session: nextSession,
   };
 }
+
+function isWriteNextInstruction(instruction: string): boolean {
+  const trimmed = instruction.trim();
+  return /^(continue|继续|继续写|写下一章|write next|下一章|再来一章)$/i.test(trimmed)
+    || /^写第\s*\d+\s*章$/i.test(trimmed)
+    || /(继续写|写下一章|下一章|再来一章|write\s+next)/i.test(trimmed);
+}
+
+function isCreateBookInstruction(instruction: string): boolean {
+  return /(建书|新建书|创建|开书|create\s+(?:a\s+)?book)/i.test(instruction)
+    && /(?:标题|书名|title)\s*[《"“]/i.test(instruction);
+}
+
+function parseBookCreationRequest(instruction: string): {
+  id: string;
+  title: string;
+  genre: string;
+  platform: "tomato" | "feilu" | "qidian" | "other";
+  language: "zh" | "en";
+  status: "outlining";
+  targetChapters: number;
+  chapterWordCount: number;
+  createdAt: string;
+  updatedAt: string;
+} | undefined {
+  const title = extractTitle(instruction);
+  if (!title) return undefined;
+  const now = new Date().toISOString();
+  return {
+    id: deriveBookId(title),
+    title,
+    genre: inferGenre(instruction),
+    platform: inferPlatform(instruction),
+    language: /[\u4e00-\u9fff]/.test(instruction) ? "zh" : "en",
+    status: "outlining",
+    targetChapters: extractNumber(instruction, /(\d+)\s*章/) ?? 200,
+    chapterWordCount: extractNumber(instruction, /(?:每章|章节|章)\D{0,8}(\d{3,5})\s*字/) ?? 3000,
+    createdAt: now,
+    updatedAt: now,
+  };
+}
+
+function extractTitle(instruction: string): string | undefined {
+  const match = instruction.match(/(?:标题|书名|title)\s*[《"“]([^》"”]+)[》"”]/i);
+  const title = match?.[1]?.trim();
+  return title && title.length > 0 ? title : undefined;
+}
+
+function extractNumber(instruction: string, pattern: RegExp): number | undefined {
+  const value = Number.parseInt(instruction.match(pattern)?.[1] ?? "", 10);
+  return Number.isFinite(value) && value > 0 ? value : undefined;
+}
+
+function inferPlatform(instruction: string): "tomato" | "feilu" | "qidian" | "other" {
+  if (/番茄|tomato/i.test(instruction)) return "tomato";
+  if (/飞卢|feilu/i.test(instruction)) return "feilu";
+  if (/起点|qidian/i.test(instruction)) return "qidian";
+  return "other";
+}
+
+function inferGenre(instruction: string): string {
+  if (/都市/.test(instruction)) return "urban";
+  if (/悬疑|推理|mystery/i.test(instruction)) return "mystery";
+  if (/玄幻|xuanhuan/i.test(instruction)) return "xuanhuan";
+  if (/科幻|sci[-\s]?fi/i.test(instruction)) return "sci-fi";
+  if (/言情|甜宠|romance/i.test(instruction)) return "romance";
+  return "other";
+}
+
+function deriveBookId(title: string): string {
+  return title
+    .toLowerCase()
+    .replace(/[^a-z0-9\u4e00-\u9fff]/g, "-")
+    .replace(/-+/g, "-")
+    .replace(/^-|-$/g, "")
+    .slice(0, 30) || `book-${Date.now().toString(36)}`;
+}
+
+function extractCreatedBookId(messages: ReadonlyArray<unknown>): string | undefined {
+  for (const message of messages) {
+    const details = (message as { details?: { kind?: string; bookId?: string } }).details;
+    if (details?.kind === "book_created" && details.bookId) {
+      return details.bookId;
+    }
+  }
+  return undefined;
+}
+
+function getResultString(value: unknown, key: string): string | undefined {
+  const raw = (value as Record<string, unknown>)[key];
+  return typeof raw === "string" && raw.length > 0 ? raw : undefined;
+}
+
+function getResultNumber(value: unknown, key: string): number | undefined {
+  const raw = (value as Record<string, unknown>)[key];
+  return typeof raw === "number" && Number.isFinite(raw) ? raw : undefined;
+}

+ 15 - 1
packages/cli/src/tui/setup.ts

@@ -9,7 +9,7 @@ import {
   brightCyan, brightGreen, brightWhite,
 } from "./ansi.js";
 import { resolveTuiLocale, type TuiLocale } from "./i18n.js";
-import { GLOBAL_ENV_PATH } from "../utils.js";
+import { GLOBAL_ENV_PATH, loadConfig } from "../utils.js";
 import { ensureProjectGitignore } from "../project-bootstrap.js";
 
 const PROVIDERS = ["openai", "anthropic", "custom"] as const;
@@ -301,6 +301,20 @@ export interface ModelInfo {
 }
 
 export async function detectModelInfo(projectRoot: string): Promise<ModelInfo | undefined> {
+  try {
+    const config = await loadConfig({ requireApiKey: false, projectRoot });
+    const service = config.llm.service?.trim();
+    const provider = service || config.llm.provider || "openai";
+    const model = config.llm.model?.trim() || "unknown";
+    return {
+      provider,
+      model,
+      baseUrl: config.llm.baseUrl ?? "",
+    };
+  } catch {
+    // Fall back to legacy env parsing below.
+  }
+
   const paths = [join(projectRoot, ".env"), GLOBAL_ENV_PATH];
   for (const p of paths) {
     const info = await parseEnvModel(p);