Kaynağa Gözat

feat: atomic pipeline operations (writeDraft, auditDraft, reviseDraft)

Break monolithic writeNextChapter into composable atomic methods.
Add readTruthFiles, getBookStatus, externalContext support.
Replace dynamic readdir imports with static imports.
Fix chapter status 'draft' -> 'drafted' to match schema.
majx_mac 8 ay önce
ebeveyn
işleme
d8289d861e

+ 6 - 2
packages/core/src/agents/architect.ts

@@ -17,8 +17,12 @@ export class ArchitectAgent extends BaseAgent {
     return "architect";
   }
 
-  async generateFoundation(book: BookConfig): Promise<ArchitectOutput> {
-    const systemPrompt = `你是一个专业的网络小说架构师。你的任务是为一本新小说生成完整的基础设定。
+  async generateFoundation(book: BookConfig, externalContext?: string): Promise<ArchitectOutput> {
+    const contextBlock = externalContext
+      ? `\n\n## 外部指令\n以下是来自外部系统(如 OpenClaw)的创作指令,请将其融入设定中:\n\n${externalContext}\n`
+      : "";
+
+    const systemPrompt = `你是一个专业的网络小说架构师。你的任务是为一本新小说生成完整的基础设定。${contextBlock}
 
 要求:
 - 平台:${book.platform}

+ 8 - 1
packages/core/src/agents/writer.ts

@@ -8,6 +8,7 @@ export interface WriteChapterInput {
   readonly book: BookConfig;
   readonly bookDir: string;
   readonly chapterNumber: number;
+  readonly externalContext?: string;
 }
 
 export interface WriteChapterOutput {
@@ -52,6 +53,7 @@ export class WriterAgent extends BaseAgent {
       hooks,
       recentChapters,
       wordCount: book.chapterWordCount,
+      externalContext: input.externalContext,
     });
 
     const response = await this.chat(
@@ -250,9 +252,14 @@ ${styleGuide}
     readonly hooks: string;
     readonly recentChapters: string;
     readonly wordCount: number;
+    readonly externalContext?: string;
   }): string {
-    return `请续写第${params.chapterNumber}章。
+    const contextBlock = params.externalContext
+      ? `\n## 外部指令\n以下是来自外部系统的创作指令,请在本章中融入:\n\n${params.externalContext}\n`
+      : "";
 
+    return `请续写第${params.chapterNumber}章。
+${contextBlock}
 ## 当前状态卡
 ${params.currentState}
 

+ 281 - 6
packages/core/src/pipeline/runner.ts

@@ -7,12 +7,13 @@ import { WriterAgent } from "../agents/writer.js";
 import { ContinuityAuditor } from "../agents/continuity.js";
 import { ReviserAgent } from "../agents/reviser.js";
 import { RadarAgent } from "../agents/radar.js";
+import type { RadarSource } from "../agents/radar-source.js";
 import { StateManager } from "../state/manager.js";
 import { dispatchNotification } from "../notify/dispatcher.js";
 import type { AgentContext } from "../agents/base.js";
-import type { AuditResult } from "../agents/continuity.js";
+import type { AuditResult, AuditIssue } from "../agents/continuity.js";
 import type { RadarResult } from "../agents/radar.js";
-import { readFile, writeFile } from "node:fs/promises";
+import { readFile, readdir, writeFile } from "node:fs/promises";
 import { join } from "node:path";
 
 export interface PipelineConfig {
@@ -20,6 +21,8 @@ export interface PipelineConfig {
   readonly model: string;
   readonly projectRoot: string;
   readonly notifyChannels?: ReadonlyArray<NotifyChannel>;
+  readonly radarSources?: ReadonlyArray<RadarSource>;
+  readonly externalContext?: string;
 }
 
 export interface ChapterPipelineResult {
@@ -31,6 +34,41 @@ export interface ChapterPipelineResult {
   readonly status: "approved" | "needs-review";
 }
 
+// Atomic operation results
+export interface DraftResult {
+  readonly chapterNumber: number;
+  readonly title: string;
+  readonly wordCount: number;
+  readonly filePath: string;
+}
+
+export interface ReviseResult {
+  readonly chapterNumber: number;
+  readonly wordCount: number;
+  readonly fixedIssues: ReadonlyArray<string>;
+}
+
+export interface TruthFiles {
+  readonly currentState: string;
+  readonly particleLedger: string;
+  readonly pendingHooks: string;
+  readonly storyBible: string;
+  readonly volumeOutline: string;
+  readonly styleGuide: string;
+}
+
+export interface BookStatusInfo {
+  readonly bookId: string;
+  readonly title: string;
+  readonly genre: string;
+  readonly platform: string;
+  readonly status: string;
+  readonly chaptersWritten: number;
+  readonly totalWords: number;
+  readonly nextChapter: number;
+  readonly chapters: ReadonlyArray<ChapterMeta>;
+}
+
 export class PipelineRunner {
   private readonly state: StateManager;
   private readonly config: PipelineConfig;
@@ -49,9 +87,13 @@ export class PipelineRunner {
     };
   }
 
+  // ---------------------------------------------------------------------------
+  // Atomic operations (composable by OpenClaw or agent mode)
+  // ---------------------------------------------------------------------------
+
   async runRadar(): Promise<RadarResult> {
-    const radar = new RadarAgent(this.agentCtx());
-    return radar.scan(["tomato", "feilu"]);
+    const radar = new RadarAgent(this.agentCtx(), this.config.radarSources);
+    return radar.scan();
   }
 
   async initBook(book: BookConfig): Promise<void> {
@@ -60,11 +102,224 @@ export class PipelineRunner {
 
     await this.state.saveBookConfig(book.id, book);
 
-    const foundation = await architect.generateFoundation(book);
+    const foundation = await architect.generateFoundation(book, this.config.externalContext);
     await architect.writeFoundationFiles(bookDir, foundation);
     await this.state.saveChapterIndex(book.id, []);
   }
 
+  /** Write a single draft chapter. Saves chapter file + truth files + index + snapshot. */
+  async writeDraft(bookId: string, context?: string): Promise<DraftResult> {
+    const releaseLock = await this.state.acquireBookLock(bookId);
+    try {
+      const book = await this.state.loadBookConfig(bookId);
+      const bookDir = this.state.bookDir(bookId);
+      const chapterNumber = await this.state.getNextChapterNumber(bookId);
+
+      const writer = new WriterAgent(this.agentCtx(bookId));
+      const output = await writer.writeChapter({
+        book,
+        bookDir,
+        chapterNumber,
+        externalContext: context ?? this.config.externalContext,
+      });
+
+      // Save chapter file
+      const chaptersDir = join(bookDir, "chapters");
+      const paddedNum = String(chapterNumber).padStart(4, "0");
+      const sanitized = output.title.replace(/[/\\?%*:|"<>]/g, "").replace(/\s+/g, "_").slice(0, 50);
+      const filename = `${paddedNum}_${sanitized}.md`;
+      const filePath = join(chaptersDir, filename);
+
+      await writeFile(filePath, `# 第${chapterNumber}章 ${output.title}\n\n${output.content}`, "utf-8");
+
+      // Save truth files
+      await writer.saveChapter(bookDir, output);
+
+      // Update index
+      const existingIndex = await this.state.loadChapterIndex(bookId);
+      const now = new Date().toISOString();
+      const newEntry: ChapterMeta = {
+        number: chapterNumber,
+        title: output.title,
+        status: "drafted",
+        wordCount: output.wordCount,
+        createdAt: now,
+        updatedAt: now,
+        auditIssues: [],
+      };
+      await this.state.saveChapterIndex(bookId, [...existingIndex, newEntry]);
+
+      // Snapshot
+      await this.state.snapshotState(bookId, chapterNumber);
+
+      return { chapterNumber, title: output.title, wordCount: output.wordCount, filePath };
+    } finally {
+      await releaseLock();
+    }
+  }
+
+  /** Audit the latest (or specified) chapter. Read-only, no lock needed. */
+  async auditDraft(bookId: string, chapterNumber?: number): Promise<AuditResult & { readonly chapterNumber: number }> {
+    const bookDir = this.state.bookDir(bookId);
+    const targetChapter = chapterNumber ?? (await this.state.getNextChapterNumber(bookId)) - 1;
+    if (targetChapter < 1) {
+      throw new Error(`No chapters to audit for "${bookId}"`);
+    }
+
+    const content = await this.readChapterContent(bookDir, targetChapter);
+    const auditor = new ContinuityAuditor(this.agentCtx(bookId));
+    const result = await auditor.auditChapter(bookDir, content, targetChapter);
+
+    // Update index with audit result
+    const index = await this.state.loadChapterIndex(bookId);
+    const updated = index.map((ch) =>
+      ch.number === targetChapter
+        ? {
+            ...ch,
+            status: (result.passed ? "ready-for-review" : "audit-failed") as ChapterMeta["status"],
+            updatedAt: new Date().toISOString(),
+            auditIssues: result.issues.map((i) => `[${i.severity}] ${i.description}`),
+          }
+        : ch,
+    );
+    await this.state.saveChapterIndex(bookId, updated);
+
+    return { ...result, chapterNumber: targetChapter };
+  }
+
+  /** Revise the latest (or specified) chapter based on audit issues. */
+  async reviseDraft(bookId: string, chapterNumber?: number): Promise<ReviseResult> {
+    const releaseLock = await this.state.acquireBookLock(bookId);
+    try {
+      const bookDir = this.state.bookDir(bookId);
+      const targetChapter = chapterNumber ?? (await this.state.getNextChapterNumber(bookId)) - 1;
+      if (targetChapter < 1) {
+        throw new Error(`No chapters to revise for "${bookId}"`);
+      }
+
+      // Read the current audit issues from index
+      const index = await this.state.loadChapterIndex(bookId);
+      const chapterMeta = index.find((ch) => ch.number === targetChapter);
+      if (!chapterMeta) {
+        throw new Error(`Chapter ${targetChapter} not found in index`);
+      }
+
+      // Re-audit to get structured issues (index only stores strings)
+      const content = await this.readChapterContent(bookDir, targetChapter);
+      const auditor = new ContinuityAuditor(this.agentCtx(bookId));
+      const auditResult = await auditor.auditChapter(bookDir, content, targetChapter);
+
+      if (auditResult.passed) {
+        return { chapterNumber: targetChapter, wordCount: content.length, fixedIssues: ["No issues to fix"] };
+      }
+
+      const reviser = new ReviserAgent(this.agentCtx(bookId));
+      const reviseOutput = await reviser.reviseChapter(bookDir, content, targetChapter, auditResult.issues);
+
+      if (reviseOutput.revisedContent.length === 0) {
+        throw new Error("Reviser returned empty content");
+      }
+
+      // Save revised chapter file
+      const chaptersDir = join(bookDir, "chapters");
+      const files = await readdir(chaptersDir);
+      const paddedNum = String(targetChapter).padStart(4, "0");
+      const existingFile = files.find((f) => f.startsWith(paddedNum) && f.endsWith(".md"));
+      if (existingFile) {
+        await writeFile(
+          join(chaptersDir, existingFile),
+          `# 第${targetChapter}章 ${chapterMeta.title}\n\n${reviseOutput.revisedContent}`,
+          "utf-8",
+        );
+      }
+
+      // Update truth files
+      const storyDir = join(bookDir, "story");
+      if (reviseOutput.updatedState !== "(状态卡未更新)") {
+        await writeFile(join(storyDir, "current_state.md"), reviseOutput.updatedState, "utf-8");
+      }
+      if (reviseOutput.updatedLedger !== "(账本未更新)") {
+        await writeFile(join(storyDir, "particle_ledger.md"), reviseOutput.updatedLedger, "utf-8");
+      }
+      if (reviseOutput.updatedHooks !== "(伏笔池未更新)") {
+        await writeFile(join(storyDir, "pending_hooks.md"), reviseOutput.updatedHooks, "utf-8");
+      }
+
+      // Update index
+      const updatedIndex = index.map((ch) =>
+        ch.number === targetChapter
+          ? {
+              ...ch,
+              status: "ready-for-review" as ChapterMeta["status"],
+              wordCount: reviseOutput.wordCount,
+              updatedAt: new Date().toISOString(),
+            }
+          : ch,
+      );
+      await this.state.saveChapterIndex(bookId, updatedIndex);
+
+      // Re-snapshot
+      await this.state.snapshotState(bookId, targetChapter);
+
+      return {
+        chapterNumber: targetChapter,
+        wordCount: reviseOutput.wordCount,
+        fixedIssues: reviseOutput.fixedIssues,
+      };
+    } finally {
+      await releaseLock();
+    }
+  }
+
+  /** Read all truth files for a book. */
+  async readTruthFiles(bookId: string): Promise<TruthFiles> {
+    const bookDir = this.state.bookDir(bookId);
+    const storyDir = join(bookDir, "story");
+    const readSafe = async (path: string): Promise<string> => {
+      try {
+        return await readFile(path, "utf-8");
+      } catch {
+        return "(文件不存在)";
+      }
+    };
+
+    const [currentState, particleLedger, pendingHooks, storyBible, volumeOutline, styleGuide] =
+      await Promise.all([
+        readSafe(join(storyDir, "current_state.md")),
+        readSafe(join(storyDir, "particle_ledger.md")),
+        readSafe(join(storyDir, "pending_hooks.md")),
+        readSafe(join(storyDir, "story_bible.md")),
+        readSafe(join(storyDir, "volume_outline.md")),
+        readSafe(join(storyDir, "style_guide.md")),
+      ]);
+
+    return { currentState, particleLedger, pendingHooks, storyBible, volumeOutline, styleGuide };
+  }
+
+  /** Get book status overview. */
+  async getBookStatus(bookId: string): Promise<BookStatusInfo> {
+    const book = await this.state.loadBookConfig(bookId);
+    const chapters = await this.state.loadChapterIndex(bookId);
+    const nextChapter = await this.state.getNextChapterNumber(bookId);
+    const totalWords = chapters.reduce((sum, ch) => sum + ch.wordCount, 0);
+
+    return {
+      bookId,
+      title: book.title,
+      genre: book.genre,
+      platform: book.platform,
+      status: book.status,
+      chaptersWritten: chapters.length,
+      totalWords,
+      nextChapter,
+      chapters: [...chapters],
+    };
+  }
+
+  // ---------------------------------------------------------------------------
+  // Full pipeline (convenience — runs draft + audit + revise in one shot)
+  // ---------------------------------------------------------------------------
+
   async writeNextChapter(bookId: string): Promise<ChapterPipelineResult> {
     const releaseLock = await this.state.acquireBookLock(bookId);
     try {
@@ -85,6 +340,7 @@ export class PipelineRunner {
       book,
       bookDir,
       chapterNumber,
+      externalContext: this.config.externalContext,
     });
 
     // 2. Audit chapter
@@ -143,7 +399,7 @@ export class PipelineRunner {
     // 4. Save chapter (original or revised)
     const chaptersDir = join(bookDir, "chapters");
     const paddedNum = String(chapterNumber).padStart(4, "0");
-    const title = revised ? output.title : output.title;
+    const title = output.title;
     const filename = `${paddedNum}_${title.replace(/[/\\?%*:|"<>]/g, "").replace(/\s+/g, "_").slice(0, 50)}.md`;
 
     await writeFile(
@@ -203,4 +459,23 @@ export class PipelineRunner {
       status: auditResult.passed ? "approved" : "needs-review",
     };
   }
+
+  // ---------------------------------------------------------------------------
+  // Helpers
+  // ---------------------------------------------------------------------------
+
+  private async readChapterContent(bookDir: string, chapterNumber: number): Promise<string> {
+    const chaptersDir = join(bookDir, "chapters");
+    const files = await readdir(chaptersDir);
+    const paddedNum = String(chapterNumber).padStart(4, "0");
+    const chapterFile = files.find((f) => f.startsWith(paddedNum) && f.endsWith(".md"));
+    if (!chapterFile) {
+      throw new Error(`Chapter ${chapterNumber} file not found in ${chaptersDir}`);
+    }
+    const raw = await readFile(join(chaptersDir, chapterFile), "utf-8");
+    // Strip the title line
+    const lines = raw.split("\n");
+    const contentStart = lines.findIndex((l, i) => i > 0 && l.trim().length > 0);
+    return contentStart >= 0 ? lines.slice(contentStart).join("\n") : raw;
+  }
 }