fix(agent): run multi-chapter writes as one sequential task

This commit is contained in:
Ma
2026-07-29 19:59:18 +08:00
parent b0cc9a54fc
commit 66449f05c4
10 changed files with 380 additions and 10 deletions
@@ -680,6 +680,39 @@ describe("agent deterministic writing tools", () => {
expect(pipeline.writeNextChapter).toHaveBeenCalledWith("harbor", 2600);
});
it("runs a requested chapter batch through one writer operation", async () => {
const pipeline = {
writeNextChapter: vi.fn(),
writeChapters: vi.fn(async () => [
{ chapterNumber: 4, title: "第四章", wordCount: 2600, status: "ready-for-review" },
{ chapterNumber: 5, title: "第五章", wordCount: 2550, status: "ready-for-review" },
{ chapterNumber: 6, title: "第六章", wordCount: 2490, status: "audit-failed" },
]),
};
const tool = createSubAgentTool(pipeline as never, "harbor");
const result = await tool.execute("tool-writer-batch", {
agent: "writer",
bookId: "harbor",
chapterCount: 5,
chapterWordCount: 2600,
instruction: "连续写五章",
} as any);
expect(pipeline.writeChapters).toHaveBeenCalledWith(
"harbor",
5,
expect.objectContaining({ wordCount: 2600 }),
);
expect(pipeline.writeNextChapter).not.toHaveBeenCalled();
expect(result.details).toMatchObject({
kind: "chapters_written",
requestedCount: 5,
completedCount: 3,
stoppedStatus: "audit-failed",
});
});
it("runs the writer pipeline inside the tool AbortSignal scope", async () => {
const controller = new AbortController();
const pipeline = {
@@ -0,0 +1,106 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { PipelineRunner, type ChapterPipelineResult } from "../pipeline/runner.js";
function chapter(
chapterNumber: number,
status: ChapterPipelineResult["status"] = "ready-for-review",
): ChapterPipelineResult {
return {
chapterNumber,
title: `Chapter ${chapterNumber}`,
wordCount: 1800,
auditResult: {
passed: status === "ready-for-review",
issues: [],
summary: status,
overallScore: status === "ready-for-review" ? 90 : 50,
tokenUsage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
},
revised: false,
status,
};
}
describe("PipelineRunner.writeChapters", () => {
const roots: string[] = [];
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
it("holds one book lock while writing sequential chapters", async () => {
const root = await mkdtemp(join(tmpdir(), "inkos-batch-"));
roots.push(root);
const runner = new PipelineRunner({
client: {} as never,
model: "test-model",
projectRoot: root,
});
const release = vi.fn(async () => undefined);
const acquireBookLock = vi.fn(async () => release);
const writeLocked = vi.fn()
.mockResolvedValueOnce(chapter(3))
.mockResolvedValueOnce(chapter(4))
.mockResolvedValueOnce(chapter(5));
const onChapterComplete = vi.fn();
const internals = runner as unknown as {
state: { acquireBookLock: typeof acquireBookLock };
_writeNextChapterLocked: typeof writeLocked;
};
internals.state = { acquireBookLock };
internals._writeNextChapterLocked = writeLocked;
const results = await runner.writeChapters("demo-book", 3, { onChapterComplete });
expect(results.map((result) => result.chapterNumber)).toEqual([3, 4, 5]);
expect(acquireBookLock).toHaveBeenCalledOnce();
expect(acquireBookLock).toHaveBeenCalledWith("demo-book");
expect(writeLocked).toHaveBeenCalledTimes(3);
expect(onChapterComplete).toHaveBeenNthCalledWith(1, chapter(3), 1, 3);
expect(onChapterComplete).toHaveBeenNthCalledWith(3, chapter(5), 3, 3);
expect(release).toHaveBeenCalledOnce();
});
it("stops the batch after the first chapter that needs review", async () => {
const root = await mkdtemp(join(tmpdir(), "inkos-batch-"));
roots.push(root);
const runner = new PipelineRunner({
client: {} as never,
model: "test-model",
projectRoot: root,
});
const release = vi.fn(async () => undefined);
const writeLocked = vi.fn()
.mockResolvedValueOnce(chapter(8, "audit-failed"))
.mockResolvedValueOnce(chapter(9));
const internals = runner as unknown as {
state: { acquireBookLock: () => Promise<typeof release> };
_writeNextChapterLocked: typeof writeLocked;
};
internals.state = { acquireBookLock: async () => release };
internals._writeNextChapterLocked = writeLocked;
const results = await runner.writeChapters("demo-book", 5);
expect(results).toHaveLength(1);
expect(results[0]?.status).toBe("audit-failed");
expect(writeLocked).toHaveBeenCalledOnce();
expect(release).toHaveBeenCalledOnce();
});
it("rejects invalid batch sizes before taking the lock", async () => {
const root = await mkdtemp(join(tmpdir(), "inkos-batch-"));
roots.push(root);
const runner = new PipelineRunner({
client: {} as never,
model: "test-model",
projectRoot: root,
});
await expect(runner.writeChapters("demo-book", 0)).rejects.toThrow(/chapterCount/i);
await expect(runner.writeChapters("demo-book", 21)).rejects.toThrow(/chapterCount/i);
});
});
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
AutomationModeSchema,
ActionPayloadSchema,
ActionSourceSchema,
BookCreationDraftSchema,
InteractionIntentTypeSchema,
@@ -67,6 +68,15 @@ describe("interaction models", () => {
expect(normalizeRequestedIntent("")).toBeUndefined();
expect(normalizePlayMode("open")).toBe("open");
expect(normalizePlayMode(null)).toBeUndefined();
expect(ActionPayloadSchema.parse({
writeNext: { chapterCount: 5 },
})).toEqual({
writeNext: { chapterCount: 5 },
});
expect(ActionPayloadSchema.safeParse({
writeNext: { chapterCount: 21 },
}).success).toBe(false);
});
it("validates structured script and storyboard creation payloads", () => {
@@ -118,6 +128,9 @@ describe("interaction models", () => {
expect(isExplicitWriteChapterCommand("开始写第一章。")).toBe(true);
expect(isExplicitWriteChapterCommand("请写下一章,写完后落盘。")).toBe(true);
expect(isExplicitWriteChapterCommand("write chapter 1")).toBe(true);
expect(isExplicitWriteChapterCommand("连续写5章")).toBe(false);
expect(isExplicitWriteChapterCommand("write 5 chapters")).toBe(false);
expect(isExplicitWriteChapterCommand("写第5章")).toBe(false);
expect(isExplicitWriteChapterCommand("继续")).toBe(false);
expect(isExplicitWriteChapterCommand("我们讨论一下要不要写下一章")).toBe(false);
expect(isExplicitWriteChapterCommand("我觉得第一章应该怎么写?")).toBe(false);
@@ -515,7 +515,7 @@ function buildBookPrompt(bookId: string, isZh: boolean): string {
## 可用工具
- sub_agent:委托子智能体执行当前书重操作:
- agent="writer" 续写下一章,永远接着最后一章往下写,不能指定章节号。参数:chapterWordCount。
- agent="writer" 从最后一章继续顺序写,不能指定任意章节号。参数:chapterCount(连续写几章,1-20,默认 1)、chapterWordCount。
- agent="auditor" 审计已有章节。参数:chapterNumber 指定第几章;不传则审最新章。
- agent="reviser" 修改已有章节。必须传 chapterNumber。参数:chapterNumber, mode: spot-fix/polish/rewrite/rework/anti-detect。
- agent="exporter" 导出书籍。参数:format: txt/md/epub, approvedOnly: true/false。
@@ -539,6 +539,7 @@ function buildBookPrompt(bookId: string, isZh: boolean): string {
- 用户要求续写、写下一章、继续正文时,必须调用 sub_agent(agent="writer");不要先 read/ls 再自己写正文。
- sub_agent 成功返回后,本轮直接结束。不要继续调用 read、ls、patch_chapter_text,也不要再补写正文。
- 用户说“写下一章 / 继续写 / 再来一章” → sub_agent(agent="writer")。
- 用户说“连续写 N 章 / 再写 N 章” → 只调用一次 sub_agent(agent="writer", chapterCount=N),不要重复或并发调用 writer。
- 用户说“审第 N 章 / 看看这一章问题” → sub_agent(agent="auditor", chapterNumber=N)。
- 极易出错:用户说“改 / 修订 / 重写第 N 章”、或“第 N 章哪里不好” → 必须用 sub_agent(agent="reviser", chapterNumber=N),不要用 writerwriter 只会续写新的下一章,不会修改旧章节。
- 极易出错:用户说“写下一章 / 继续写 / 再来一章” → 才用 sub_agent(agent="writer"),不要把它理解成 reviser。
@@ -575,7 +576,7 @@ ${commonOutputRules(true)}`
## Available Tools
- sub_agent: delegate active-book heavy operations:
- agent="writer" writes the next chapter, always appending after the latest chapter. It cannot target a specific chapter number. Params: chapterWordCount.
- agent="writer" writes forward from the latest chapter. It cannot target an arbitrary chapter number. Params: chapterCount (1-20 consecutive chapters, default 1), chapterWordCount.
- agent="auditor" audits an existing chapter. Params: chapterNumber; omit for latest.
- agent="reviser" revises an existing chapter. chapterNumber is required. Params: chapterNumber, mode: spot-fix/polish/rewrite/rework/anti-detect.
- agent="exporter" exports the book. Params: format: txt/md/epub, approvedOnly: true/false.
@@ -599,6 +600,7 @@ ${commonOutputRules(true)}`
- When the user asks to continue or write the next chapter, you must call sub_agent(agent="writer"); do not read/list files first and then write prose yourself.
- After a successful sub_agent result, end the current turn immediately. Do not keep calling read, ls, patch_chapter_text, or add extra prose.
- "write next / continue / one more chapter" → sub_agent(agent="writer").
- "write N consecutive chapters / write N more chapters" → call sub_agent once with agent="writer", chapterCount=N; never repeat or parallelize writer calls.
- "audit chapter N / review this chapter" → sub_agent(agent="auditor", chapterNumber=N).
- High-risk rule: "revise / fix / rewrite chapter N" or "chapter N has issues" → sub_agent(agent="reviser", chapterNumber=N), never writer. writer only appends a new next chapter; it does not edit an old chapter.
- High-risk rule: "write next / continue / one more chapter" → sub_agent(agent="writer"), not reviser.
+40 -1
View File
@@ -566,6 +566,11 @@ const SubAgentParams = Type.Object({
description: "Optional book ID. In active-book sessions, omit it to use the current active book; if provided, it must match the current active book. For architect creation, this optionally sets the new book ID.",
})),
chapterNumber: Type.Optional(Type.Number({ description: "auditor/reviser: target chapter number. Omit to use the latest chapter." })),
chapterCount: Type.Optional(Type.Integer({
minimum: 1,
maximum: 20,
description: "writer only: number of consecutive new chapters to write in this operation. Default: 1. InkOS writes them sequentially under one book lock.",
})),
// -- architect params --
title: Type.Optional(Type.String({ description: "architect only: explicit book title. Required when creating a book." })),
genre: Type.Optional(Type.String({ description: "architect only: genre (xuanhuan, urban, mystery, romance, scifi, fantasy, wuxia, general, etc.)" })),
@@ -687,7 +692,7 @@ export function createSubAgentTool(
_signal?: AbortSignal,
onUpdate?: AgentToolUpdateCallback,
): Promise<AgentToolResult<unknown>> {
const { agent, instruction, bookId, title, chapterNumber, genre, platform, language, targetChapters, chapterWordCount, revise, feedback, mode, format, approvedOnly } = params;
const { agent, instruction, bookId, title, chapterNumber, chapterCount, genre, platform, language, targetChapters, chapterWordCount, revise, feedback, mode, format, approvedOnly } = params;
const progress = (msg: string) => {
onUpdate?.(textResult(msg));
@@ -770,6 +775,40 @@ export function createSubAgentTool(
case "writer": {
const targetBookId = resolveToolBookId("writer", bookId, activeBookId);
const requestedCount = chapterCount ?? 1;
if (requestedCount > 1) {
progress(`Writing ${requestedCount} consecutive chapters for "${targetBookId}"...`);
const results = await runPipelineWithAbortSignal(
pipeline,
_signal,
() => pipeline.writeChapters(targetBookId, requestedCount, {
wordCount: chapterWordCount,
onChapterComplete(result, completedCount, totalCount) {
progress(`Writer finished chapter ${result.chapterNumber} (${completedCount}/${totalCount}) for "${targetBookId}".`);
},
}),
);
const last = results.at(-1);
const stoppedStatus = last?.status !== "ready-for-review" ? last?.status : undefined;
return textResult(
stoppedStatus
? `Writer completed ${results.length} of ${requestedCount} requested chapters for "${targetBookId}" and stopped because chapter ${last?.chapterNumber} ended with status "${stoppedStatus}".`
: `Writer completed ${results.length} consecutive chapters for "${targetBookId}".`,
{
kind: "chapters_written",
bookId: targetBookId,
requestedCount,
completedCount: results.length,
chapters: results.map((result) => ({
chapterNumber: result.chapterNumber,
title: result.title,
wordCount: result.wordCount,
status: result.status,
})),
...(stoppedStatus ? { stoppedStatus } : {}),
},
);
}
progress(`Writing next chapter for "${targetBookId}"...`);
const result = await runPipelineWithAbortSignal(
pipeline,
+2 -1
View File
@@ -219,6 +219,7 @@ export {
ScriptTargetFormatSchema,
ShortRunActionPayloadSchema,
StoryboardCreateActionPayloadSchema,
WriteNextActionPayloadSchema,
type ActionSource,
type ActionPayload,
type RequestedIntent,
@@ -534,7 +535,7 @@ export { arbitrateRuntimeStateDeltaHooks, type HookArbiterDecision } from "./uti
export { analyzeHookHealth } from "./utils/hook-health.js";
// Pipeline
export { PipelineRunner, type PipelineConfig, type ChapterPipelineResult, type DraftResult, type PlanChapterResult, type ComposeChapterResult, type ReviseResult, type TruthFiles, type BookStatusInfo, type ImportChaptersInput, type ImportChaptersResult, type TokenUsageSummary } from "./pipeline/runner.js";
export { PipelineRunner, type PipelineConfig, type ChapterPipelineResult, type WriteChaptersOptions, type DraftResult, type PlanChapterResult, type ComposeChapterResult, type ReviseResult, type TruthFiles, type BookStatusInfo, type ImportChaptersInput, type ImportChaptersResult, type TokenUsageSummary } from "./pipeline/runner.js";
export { Scheduler, type SchedulerConfig } from "./pipeline/scheduler.js";
export { detectChapter, detectAndRewrite, loadDetectionHistory, type DetectChapterResult, type DetectAndRewriteResult } from "./pipeline/detection-runner.js";
export { runScriptCreation, runStoryboardCreation, runInteractiveFilmCreation, createStoryboardAssetsManifest, type ScriptCreationRunOptions, type ScriptCreationRunResult, type StoryboardAssetsManifest, type StoryboardCreationRunOptions, type StoryboardCreationRunResult, type InteractiveFilmCreationRunOptions, type InteractiveFilmCreationRunResult, type StoryboardImageAsset, type StoryboardImageAssetVariant } from "./pipeline/script-storyboard-runner.js";
@@ -47,6 +47,10 @@ export const CreateBookActionPayloadSchema = z.object({
chapterWordCount: z.number().int().min(1).optional(),
}).strict();
export const WriteNextActionPayloadSchema = z.object({
chapterCount: z.number().int().min(1).max(20).default(1),
}).strict();
// charsPerChapter 的单位随语言变化:zh 是每章汉字数(900-1200),en 是每章英文单词数(600-800)。
// 这两个区间与 short-fiction-runner 的执行层校验共用同一组常量,保证确认卡和执行层不再各说各话。
export function shortRunCharsPerChapterRange(language: "zh" | "en"): {
@@ -167,6 +171,7 @@ export const TranslationCreateActionPayloadSchema = z.object({
export const ActionPayloadSchema = z.object({
createBook: CreateBookActionPayloadSchema.optional(),
writeNext: WriteNextActionPayloadSchema.optional(),
shortRun: ShortRunActionPayloadSchema.optional(),
playStart: PlayStartActionPayloadSchema.optional(),
generateCover: GenerateCoverActionPayloadSchema.optional(),
@@ -251,8 +256,8 @@ export function isExplicitWriteChapterCommand(instruction: string): boolean {
if (!trimmed) return false;
const zhWriteChapter =
/^(?:请|帮我|麻烦|现在|直接|开始|继续|接着|再)?\s*(?:写|续写|创作|生成)(?:出|一下)?\s*(?:第?\s*[一二三四五六七八九十百千万\d]+\s*章|下一章|一章|正文|章节)(?:\s|[,。,.!?;:]|$)/.test(trimmed);
/^(?:请|帮我|麻烦|现在|直接|开始|继续|接着|再)?\s*(?:写|续写|创作|生成)(?:出|一下)?\s*(?:第?\s*一\s*章|第?\s*1\s*章|下一章|一章|正文|章节)(?:\s|[,。,.!?;:]|$)/.test(trimmed);
if (zhWriteChapter) return true;
return /^(?:please\s+)?(?:write|continue|draft|generate)\s+(?:the\s+)?(?:next\s+)?chapter(?:\s+\d+|\s+one)?\b/i.test(trimmed);
return /^(?:please\s+)?(?:write|continue|draft|generate)\s+(?:(?:the\s+)?next\s+chapter|chapter(?:\s+(?:1|one))?)\b/i.test(trimmed);
}
+41
View File
@@ -303,6 +303,16 @@ export interface ChapterPipelineResult {
readonly tokenUsage?: TokenUsageSummary;
}
export interface WriteChaptersOptions {
readonly wordCount?: number;
readonly temperatureOverride?: number;
readonly onChapterComplete?: (
result: ChapterPipelineResult,
completedCount: number,
requestedCount: number,
) => void;
}
// Atomic operation results
export interface DraftResult {
readonly chapterNumber: number;
@@ -1672,6 +1682,37 @@ export class PipelineRunner {
}
}
async writeChapters(
bookId: string,
chapterCount: number,
options: WriteChaptersOptions = {},
): Promise<ReadonlyArray<ChapterPipelineResult>> {
if (!Number.isInteger(chapterCount) || chapterCount < 1 || chapterCount > 20) {
throw new Error(`chapterCount must be an integer between 1 and 20; received ${chapterCount}.`);
}
this.throwIfOperationAborted();
const releaseLock = await this.state.acquireBookLock(bookId);
try {
const results: ChapterPipelineResult[] = [];
for (let index = 0; index < chapterCount; index += 1) {
this.throwIfOperationAborted();
const result = await this._writeNextChapterLocked(
bookId,
options.wordCount,
options.temperatureOverride,
this.config.externalContext,
);
results.push(result);
options.onChapterComplete?.(result, results.length, chapterCount);
if (result.status !== "ready-for-review") break;
}
return results;
} finally {
await releaseLock();
}
}
async repairChapterState(bookId: string, chapterNumber?: number): Promise<ChapterPipelineResult> {
const releaseLock = await this.state.acquireBookLock(bookId);
try {
+60
View File
@@ -18,6 +18,7 @@ const evaluateBookQualityMock = vi.fn();
const reviseDraftMock = vi.fn();
const resyncChapterArtifactsMock = vi.fn();
const writeNextChapterMock = vi.fn();
const writeChaptersMock = vi.fn();
const rollbackToChapterMock = vi.fn();
const saveChapterIndexMock = vi.fn();
const loadChapterIndexMock = vi.fn();
@@ -217,6 +218,7 @@ vi.mock("@actalk/inkos-core", async (importOriginal) => {
reviseDraft = reviseDraftMock;
resyncChapterArtifacts = resyncChapterArtifactsMock;
writeNextChapter = writeNextChapterMock;
writeChapters = writeChaptersMock;
}
class MockConsolidatorAgent {
@@ -412,6 +414,7 @@ describe("createStudioServer daemon lifecycle", () => {
reviseDraftMock.mockReset();
resyncChapterArtifactsMock.mockReset();
writeNextChapterMock.mockReset();
writeChaptersMock.mockReset();
rollbackToChapterMock.mockReset();
saveChapterIndexMock.mockReset();
loadChapterIndexMock.mockReset();
@@ -473,6 +476,16 @@ describe("createStudioServer daemon lifecycle", () => {
status: "ready-for-review",
auditResult: { passed: true, issues: [], summary: "rewritten" },
});
writeChaptersMock.mockResolvedValue([
{
chapterNumber: 3,
title: "Rewritten Chapter",
wordCount: 1800,
revised: false,
status: "ready-for-review",
auditResult: { passed: true, issues: [], summary: "rewritten" },
},
]);
createLLMClientMock.mockReset();
createLLMClientMock.mockReturnValue({});
createLLMTranslationModelMock.mockReset();
@@ -4184,6 +4197,53 @@ describe("createStudioServer daemon lifecycle", () => {
);
}, 60_000);
it("runs a confirmed multi-chapter write sequentially through the existing write_next intent", async () => {
writeChaptersMock.mockResolvedValueOnce([
{
chapterNumber: 3,
title: "第三章",
wordCount: 1800,
revised: false,
status: "ready-for-review",
auditResult: { passed: true, issues: [], summary: "ok" },
},
{
chapterNumber: 4,
title: "第四章",
wordCount: 1750,
revised: false,
status: "ready-for-review",
auditResult: { passed: true, issues: [], summary: "ok" },
},
]);
const { createStudioServer } = await import("./server.js");
const app = createStudioServer(cloneProjectConfig() as never, root);
const response = await app.request("http://localhost/api/v1/agent", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
instruction: "连续写两章",
activeBookId: "demo-book",
sessionId: "agent-session-1",
sessionKind: "book",
actionSource: "button",
requestedIntent: "write_next",
actionPayload: { writeNext: { chapterCount: 2 } },
}),
});
const body = await response.json();
expect(response.status, JSON.stringify(body)).toBe(200);
expect(body.response).toContain("已连续完成 2 章");
expect(writeChaptersMock).toHaveBeenCalledWith(
"demo-book",
2,
expect.objectContaining({ onChapterComplete: expect.any(Function) }),
);
expect(writeNextChapterMock).not.toHaveBeenCalled();
}, 60_000);
it("does not present audit-failed direct write-next as completed", async () => {
writeNextChapterMock.mockResolvedValueOnce({
chapterNumber: 3,
+74 -4
View File
@@ -1430,12 +1430,21 @@ interface WriteNextChapterToolResult {
readonly isError?: boolean;
readonly content: ReadonlyArray<{ readonly type: "text"; readonly text: string }>;
readonly details: {
readonly kind: "chapter_written";
readonly kind: "chapter_written" | "chapters_written";
readonly bookId: string;
readonly chapterNumber: number;
readonly chapterNumber?: number;
readonly title?: string;
readonly wordCount: number;
readonly wordCount?: number;
readonly status?: string;
readonly requestedCount?: number;
readonly completedCount?: number;
readonly stoppedStatus?: string;
readonly chapters?: ReadonlyArray<{
readonly chapterNumber: number;
readonly title?: string;
readonly wordCount: number;
readonly status?: string;
}>;
};
}
@@ -1471,6 +1480,7 @@ function createWriteNextChapterTool(
pipeline: PipelineRunner,
bookId: string,
lang: StudioLanguage,
chapterCount = 1,
): {
readonly name: "sub_agent";
readonly execute: (
@@ -1483,6 +1493,65 @@ function createWriteNextChapterTool(
return {
name: "sub_agent",
async execute(_toolCallId, _params, signal, onUpdate) {
if (chapterCount > 1) {
onUpdate?.({
content: [{
type: "text",
text: pick(
lang,
`正在为 ${bookId} 连续写 ${chapterCount} 章…`,
`Writing ${chapterCount} consecutive chapters for ${bookId}...`,
),
}],
});
const results = await pipeline.runWithAbortSignal(
signal,
() => pipeline.writeChapters(bookId, chapterCount, {
onChapterComplete(result, completedCount, requestedCount) {
onUpdate?.({
content: [{
type: "text",
text: pick(
lang,
`${completedCount}/${requestedCount} 章已落盘:第 ${result.chapterNumber} 章《${result.title}》。`,
`${completedCount}/${requestedCount} persisted: chapter ${result.chapterNumber} "${result.title}".`,
),
}],
});
},
}),
);
const last = results.at(-1);
const stoppedStatus = last?.status !== "ready-for-review" ? last?.status : undefined;
const responseText = stoppedStatus
? pick(
lang,
`已完成 ${results.length}/${chapterCount} 章;第 ${last?.chapterNumber} 章状态为 ${stoppedStatus},批量写作已停止,请复核后再继续。`,
`Completed ${results.length}/${chapterCount} chapters. Chapter ${last?.chapterNumber} ended with ${stoppedStatus}, so the batch stopped for review.`,
)
: pick(
lang,
`已连续完成 ${results.length} 章(第 ${results[0]?.chapterNumber} 章至第 ${last?.chapterNumber} 章)。`,
`Completed ${results.length} consecutive chapters (chapters ${results[0]?.chapterNumber}-${last?.chapterNumber}).`,
);
return {
...(stoppedStatus ? { isError: true } : {}),
content: [{ type: "text", text: responseText }],
details: {
kind: "chapters_written",
bookId,
requestedCount: chapterCount,
completedCount: results.length,
chapters: results.map((result) => ({
chapterNumber: result.chapterNumber,
title: result.title,
wordCount: result.wordCount,
status: result.status,
})),
...(stoppedStatus ? { stoppedStatus } : {}),
},
};
}
onUpdate?.({
content: [{
type: "text",
@@ -1572,7 +1641,8 @@ async function executeConfirmedProductionAction(args: {
if (!args.bookId) {
throw new ApiError(400, "BOOK_ID_REQUIRED", pick(lang, "写下一章需要先打开一本书。", "Writing the next chapter requires an active book."));
}
tool = createWriteNextChapterTool(args.pipeline, args.bookId, lang);
const chapterCount = actionPayload?.writeNext?.chapterCount ?? 1;
tool = createWriteNextChapterTool(args.pipeline, args.bookId, lang, chapterCount);
agent = "writer";
params = { agent: "writer", bookId: args.bookId };
} else if (args.requestedIntent === "generate_cover") {