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

fix(core,cli,studio): unify real epub export implementation

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

+ 13 - 106
packages/cli/src/commands/export.ts

@@ -1,7 +1,6 @@
 import { Command } from "commander";
-import { StateManager } from "@actalk/inkos-core";
-import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
-import { dirname, join } from "node:path";
+import { StateManager, writeExportArtifact } from "@actalk/inkos-core";
+import { join } from "node:path";
 import { findProjectRoot, resolveBookId, log, logError } from "../utils.js";
 
 export const exportCommand = new Command("export")
@@ -17,62 +16,23 @@ export const exportCommand = new Command("export")
       const bookId = await resolveBookId(bookIdArg, root);
       const state = new StateManager(root);
 
-      const book = await state.loadBookConfig(bookId);
-      const index = await state.loadChapterIndex(bookId);
-      const bookDir = state.bookDir(bookId);
-      const chaptersDir = join(bookDir, "chapters");
-
-      const chapters = opts.approvedOnly
-        ? index.filter((ch) => ch.status === "approved")
-        : index;
-
-      if (chapters.length === 0) {
-        throw new Error("No chapters to export.");
-      }
-
-      if (opts.format === "epub") {
-        await exportEpub(book, chapters, chaptersDir, bookId, root, opts);
-        return;
-      }
-
-      const parts: string[] = [];
-
-      if (opts.format === "md") {
-        parts.push(`# ${book.title}\n`);
-        parts.push(`---\n`);
-      } else {
-        parts.push(`${book.title}\n\n`);
-      }
-
-      for (const ch of chapters) {
-        const paddedNum = String(ch.number).padStart(4, "0");
-        const files = await readdir(chaptersDir);
-        const match = files.find((f) => f.startsWith(paddedNum));
-        if (!match) continue;
-
-        const content = await readFile(join(chaptersDir, match), "utf-8");
-        parts.push(content);
-        parts.push("\n\n");
-      }
-
-      const totalWords = chapters.reduce((sum, ch) => sum + ch.wordCount, 0);
-
-      const outputPath =
-        opts.output ?? join(root, `${bookId}_export.${opts.format}`);
-      await mkdir(dirname(outputPath), { recursive: true });
-      await writeFile(outputPath, parts.join("\n"), "utf-8");
+      const result = await writeExportArtifact(state, bookId, {
+        format: opts.format as "txt" | "md" | "epub",
+        approvedOnly: Boolean(opts.approvedOnly),
+        outputPath: opts.output ?? join(root, `${bookId}_export.${opts.format}`),
+      });
 
       if (opts.json) {
         log(JSON.stringify({
           bookId,
-          chaptersExported: chapters.length,
-          totalWords,
-          format: opts.format,
-          outputPath,
+          chaptersExported: result.chaptersExported,
+          totalWords: result.totalWords,
+          format: result.format,
+          outputPath: result.outputPath,
         }, null, 2));
       } else {
-        log(`Exported ${chapters.length} chapters (${totalWords} words)`);
-        log(`Output: ${outputPath}`);
+        log(`Exported ${result.chaptersExported} chapters (${result.totalWords} words)`);
+        log(`Output: ${result.outputPath}`);
       }
     } catch (e) {
       if (opts.json) {
@@ -83,56 +43,3 @@ export const exportCommand = new Command("export")
       process.exit(1);
     }
   });
-
-async function exportEpub(
-  book: { readonly title: string; readonly language?: string },
-  chapters: ReadonlyArray<{ readonly number: number; readonly wordCount: number }>,
-  chaptersDir: string,
-  bookId: string,
-  root: string,
-  opts: { readonly output?: string; readonly json?: boolean },
-): Promise<void> {
-  const { marked } = await import("marked");
-  const { EPub } = await import("epub-gen-memory");
-
-  const epubChapters: Array<{ title: string; content: string }> = [];
-
-  for (const ch of chapters) {
-    const paddedNum = String(ch.number).padStart(4, "0");
-    const files = await readdir(chaptersDir);
-    const match = files.find((f) => f.startsWith(paddedNum));
-    if (!match) continue;
-
-    const markdown = await readFile(join(chaptersDir, match), "utf-8");
-    const html = await marked.parse(markdown);
-    // Extract title from first heading or fall back to filename
-    const titleMatch = markdown.match(/^#\s+(.+)/m);
-    const title = titleMatch?.[1] ?? match.replace(/\.md$/, "");
-
-    epubChapters.push({ title, content: html });
-  }
-
-  const epubInstance = new EPub(
-    { title: book.title, lang: book.language === "en" ? "en" : "zh-CN" },
-    epubChapters,
-  );
-  const epubBuffer: Buffer = await epubInstance.genEpub();
-
-  const totalWords = chapters.reduce((sum, ch) => sum + ch.wordCount, 0);
-  const outputPath = opts.output ?? join(root, `${bookId}.epub`);
-  await mkdir(dirname(outputPath), { recursive: true });
-  await writeFile(outputPath, epubBuffer);
-
-  if (opts.json) {
-    log(JSON.stringify({
-      bookId,
-      chaptersExported: chapters.length,
-      totalWords,
-      format: "epub",
-      outputPath,
-    }, null, 2));
-  } else {
-    log(`Exported ${chapters.length} chapters (${totalWords} words) to EPUB`);
-    log(`Output: ${outputPath}`);
-  }
-}

+ 1 - 0
packages/core/package.json

@@ -48,6 +48,7 @@
     "@mariozechner/pi-ai": "^0.67.1",
     "@sinclair/typebox": "^0.34.49",
     "dotenv": "^16.4.0",
+    "epub-gen-memory": "^1.0.10",
     "js-yaml": "^4.1.1",
     "zod": "^3.24.0"
   },

+ 1 - 1
packages/core/src/agent/agent-session.ts

@@ -234,7 +234,7 @@ export async function runAgentSession(
         model,
         systemPrompt: buildAgentSystemPrompt(bookId, language),
         tools: [
-          createSubAgentTool(pipeline, bookId),
+          createSubAgentTool(pipeline, bookId, projectRoot),
           createReadTool(projectRoot),
           createReviseChapterTool(pipeline, bookId),
           createWriteTruthFileTool(pipeline, projectRoot, bookId),

+ 22 - 2
packages/core/src/agent/agent-tools.ts

@@ -6,6 +6,7 @@ import { readFile, writeFile, readdir, stat } from "node:fs/promises";
 import { join, normalize, resolve } from "node:path";
 import { StateManager } from "../state/manager.js";
 import { createInteractionToolsFromDeps } from "../interaction/project-tools.js";
+import { writeExportArtifact } from "../interaction/export-artifact.js";
 
 // ---------------------------------------------------------------------------
 // Helpers
@@ -60,7 +61,11 @@ const SubAgentParams = Type.Object({
   bookId: Type.Optional(Type.String({ description: "Book ID — required for all agents except architect" })),
 });
 
-export function createSubAgentTool(pipeline: PipelineRunner, activeBookId: string | null): AgentTool<typeof SubAgentParams> {
+export function createSubAgentTool(
+  pipeline: PipelineRunner,
+  activeBookId: string | null,
+  projectRoot?: string,
+): AgentTool<typeof SubAgentParams> {
   return {
     name: "sub_agent",
     description:
@@ -138,7 +143,22 @@ export function createSubAgentTool(pipeline: PipelineRunner, activeBookId: strin
           }
 
           case "exporter": {
-            return textResult("Export is not yet implemented. Coming soon.");
+            if (!bookId) return textResult("Error: bookId is required for the exporter agent.");
+            if (!projectRoot) return textResult("Error: exporter requires projectRoot.");
+            const inferredFormat = /epub/i.test(instruction)
+              ? "epub"
+              : /markdown|\bmd\b/i.test(instruction)
+                ? "md"
+                : "txt";
+            const approvedOnly = /approved|已通过|通过章节/.test(instruction);
+            const state = new StateManager(projectRoot);
+            const result = await writeExportArtifact(state, bookId, {
+              format: inferredFormat,
+              approvedOnly,
+            });
+            return textResult(
+              `Exported "${bookId}": ${result.chaptersExported} chapters, ${result.totalWords} words → ${result.outputPath}`,
+            );
           }
 
           default:

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

@@ -133,6 +133,7 @@ export {
   processProjectInteractionRequest,
 } from "./interaction/project-control.js";
 export { createInteractionToolsFromDeps } from "./interaction/project-tools.js";
+export { buildExportArtifact, writeExportArtifact } from "./interaction/export-artifact.js";
 export {
   normalizeTruthFileName,
   classifyTruthAuthority,

+ 151 - 0
packages/core/src/interaction/export-artifact.ts

@@ -0,0 +1,151 @@
+import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
+import { dirname, join } from "node:path";
+import { EPub } from "epub-gen-memory";
+
+export interface ExportStateLike {
+  readonly bookDir: (bookId: string) => string;
+  readonly loadBookConfig: (bookId: string) => Promise<{ readonly title: string; readonly language?: string }>;
+  readonly loadChapterIndex: (bookId: string) => Promise<ReadonlyArray<{
+    readonly number: number;
+    readonly status: string;
+    readonly wordCount: number;
+  }>>;
+}
+
+export interface ExportArtifact {
+  readonly outputPath: string;
+  readonly fileName: string;
+  readonly chaptersExported: number;
+  readonly totalWords: number;
+  readonly format: "txt" | "md" | "epub";
+  readonly contentType: string;
+  readonly payload: string | Buffer;
+}
+
+function buildChapterFileLookup(files: ReadonlyArray<string>): ReadonlyMap<number, string> {
+  const lookup = new Map<number, string>();
+  for (const file of files) {
+    if (!file.endsWith(".md") || !/^\d{4}/.test(file)) {
+      continue;
+    }
+    const chapterNumber = parseInt(file.slice(0, 4), 10);
+    if (!lookup.has(chapterNumber)) {
+      lookup.set(chapterNumber, file);
+    }
+  }
+  return lookup;
+}
+
+function escapeHtml(text: string): string {
+  return text
+    .replaceAll("&", "&amp;")
+    .replaceAll("<", "&lt;")
+    .replaceAll(">", "&gt;");
+}
+
+function markdownToSimpleHtml(markdown: string): { title: string; html: string } {
+  const title = markdown.match(/^#\s+(.+)/m)?.[1]?.trim() ?? "Untitled Chapter";
+  const html = markdown
+    .split("\n")
+    .filter((line) => !line.startsWith("#"))
+    .map((line) => line.trim())
+    .filter(Boolean)
+    .map((line) => `<p>${escapeHtml(line)}</p>`)
+    .join("\n");
+  return { title, html };
+}
+
+export async function buildExportArtifact(
+  state: ExportStateLike,
+  bookId: string,
+  options: {
+    readonly format?: "txt" | "md" | "epub";
+    readonly approvedOnly?: boolean;
+    readonly outputPath?: string;
+  },
+): Promise<ExportArtifact> {
+  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}`);
+  const chapterFiles = buildChapterFileLookup(await readdir(chaptersDir));
+  const totalWords = chapters.reduce((sum, chapter) => sum + chapter.wordCount, 0);
+
+  if (format === "epub") {
+    const epubChapters: Array<{ title: string; content: string }> = [];
+    for (const chapter of chapters) {
+      const match = chapterFiles.get(chapter.number);
+      if (!match) {
+        continue;
+      }
+      const markdown = await readFile(join(chaptersDir, match), "utf-8");
+      const { title, html } = markdownToSimpleHtml(markdown);
+      epubChapters.push({ title, content: html });
+    }
+    const epubInstance = new EPub(
+      { title: book.title, lang: book.language === "en" ? "en" : "zh-CN" },
+      epubChapters,
+    );
+    return {
+      outputPath,
+      fileName: `${bookId}.epub`,
+      chaptersExported: chapters.length,
+      totalWords,
+      format,
+      contentType: "application/epub+zip",
+      payload: await epubInstance.genEpub(),
+    };
+  }
+
+  const parts: string[] = [];
+  parts.push(format === "md" ? `# ${book.title}\n\n---\n` : `${book.title}\n\n`);
+  for (const chapter of chapters) {
+    const match = chapterFiles.get(chapter.number);
+    if (!match) {
+      continue;
+    }
+    parts.push(await readFile(join(chaptersDir, match), "utf-8"));
+    parts.push("\n\n");
+  }
+
+  return {
+    outputPath,
+    fileName: `${bookId}.${format}`,
+    chaptersExported: chapters.length,
+    totalWords,
+    format,
+    contentType: format === "md" ? "text/markdown; charset=utf-8" : "text/plain; charset=utf-8",
+    payload: parts.join(format === "md" ? "\n---\n\n" : "\n"),
+  };
+}
+
+export async function writeExportArtifact(
+  state: ExportStateLike,
+  bookId: string,
+  options: {
+    readonly format?: "txt" | "md" | "epub";
+    readonly approvedOnly?: boolean;
+    readonly outputPath?: string;
+  },
+): Promise<Omit<ExportArtifact, "payload" | "contentType" | "fileName">> {
+  const artifact = await buildExportArtifact(state, bookId, options);
+  await mkdir(dirname(artifact.outputPath), { recursive: true });
+  await writeFile(artifact.outputPath, artifact.payload);
+  return {
+    outputPath: artifact.outputPath,
+    chaptersExported: artifact.chaptersExported,
+    totalWords: artifact.totalWords,
+    format: artifact.format,
+  };
+}

+ 2 - 63
packages/core/src/interaction/project-tools.ts

@@ -15,6 +15,7 @@ import { chatCompletion, chatWithTools } from "../index.js";
 import { executeEditTransaction } from "./edit-controller.js";
 import type { InteractionRuntimeTools } from "./runtime.js";
 import type { BookCreationDraft } from "./session.js";
+import { writeExportArtifact } from "./export-artifact.js";
 
 type PipelineLike = Pick<PipelineRunner, "writeNextChapter" | "reviseDraft"> & {
   readonly initBook?: (
@@ -124,69 +125,7 @@ async function exportBookToPath(state: StateLike, bookId: string, options: {
   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}`);
-  const chapterFiles = buildChapterFileLookup(await readdir(chaptersDir));
-
-  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 match = chapterFiles.get(chapter.number);
-      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 match = chapterFiles.get(chapter.number);
-      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,
-  };
+  return writeExportArtifact(state, bookId, options);
 }
 
 function mapStageMessageToStatus(message: string): InteractionEvent["status"] | undefined {

+ 11 - 50
packages/studio/src/api/server.ts

@@ -29,6 +29,7 @@ import {
   getServiceApiKey,
   listModelsForService,
   chatCompletion,
+  buildExportArtifact,
   GLOBAL_ENV_PATH,
   type ResolvedModel,
   type PipelineConfig,
@@ -1654,59 +1655,19 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
     const id = c.req.param("id");
     const format = (c.req.query("format") ?? "txt") as string;
     const approvedOnly = c.req.query("approvedOnly") === "true";
-    const bookDir = state.bookDir(id);
-    const chaptersDir = join(bookDir, "chapters");
 
     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")),
-      );
-
-      if (format === "epub") {
-        // Basic EPUB: XHTML container
-        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 body = chapters.map((ch, i) => `<h2 id="ch${i}">${ch.title}</h2>\n${ch.html}`).join("\n<hr/>\n");
-        const epub = `<!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/>${body}</body></html>`;
-        return new Response(epub, {
-          headers: {
-            "Content-Type": "text/html; charset=utf-8",
-            "Content-Disposition": `attachment; filename="${id}.html"`,
-          },
-        });
-      }
-      if (format === "md") {
-        const body = contents.join("\n\n---\n\n");
-        return new Response(body, {
-          headers: {
-            "Content-Type": "text/markdown; charset=utf-8",
-            "Content-Disposition": `attachment; filename="${id}.md"`,
-          },
-        });
-      }
-      // Default: txt
-      const body = contents.join("\n\n");
-      return new Response(body, {
+      const artifact = await buildExportArtifact(state, id, {
+        format: format as "txt" | "md" | "epub",
+        approvedOnly,
+      });
+      const responseBody = typeof artifact.payload === "string"
+        ? artifact.payload
+        : new Uint8Array(artifact.payload);
+      return new Response(responseBody, {
         headers: {
-          "Content-Type": "text/plain; charset=utf-8",
-          "Content-Disposition": `attachment; filename="${id}.txt"`,
+          "Content-Type": artifact.contentType,
+          "Content-Disposition": `attachment; filename="${artifact.fileName}"`,
         },
       });
     } catch {

+ 3 - 0
pnpm-lock.yaml

@@ -74,6 +74,9 @@ importers:
       dotenv:
         specifier: ^16.4.0
         version: 16.6.1
+      epub-gen-memory:
+        specifier: ^1.0.10
+        version: 1.1.2
       js-yaml:
         specifier: ^4.1.1
         version: 4.1.1