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

fix(writing): 审稿超时误报与去AI味失败丢稿

审稿 5 分钟超时被 streamChat 当成正常结束,误报未返回 JSON。
去AI味失败会打断整轮工作流并触发重写;失败时保留已有正文并交付。

Co-authored-by: Cursor <cursoragent@cursor.com>
darknessomi 3 недель назад
Родитель
Сommit
cae3aea674

+ 42 - 0
src/lib/novel/deep-chapter-generation.spec.ts

@@ -1199,6 +1199,48 @@ describe("runDeepChapterGeneration", () => {
       .toEqual(["started", "error"])
   })
 
+  it("keeps the pre-polish draft when final polish fails", async () => {
+    const deps = createDeps()
+    vi.mocked(deps.streamChat).mockImplementation(async (
+      _config: LlmConfig,
+      messages: ChatMessage[],
+      callbacks: StreamCallbacks,
+    ) => {
+      const prompt = messagesPromptText(messages)
+      if (prompt.includes("简单审查") || prompt.includes("去AI味")) {
+        throw new Error("error decoding response body")
+      }
+      const content = prompt.includes("返修")
+        ? chapterText("返修正文内容")
+        : prompt.includes("正文")
+          ? chapterText("初稿正文内容")
+          : "写作任务书内容"
+      callbacks.onToken(content)
+      callbacks.onDone()
+    })
+    const thinking: string[] = []
+    const events: Array<{ type: string; name: string }> = []
+    const delivered: string[] = []
+
+    const result = await runDeepChapterGeneration(
+      { projectPath: "E:/Novel", userRequest: "生成第3章", chapterNumber: 3, llmConfig },
+      {
+        onThinking: (content) => thinking.push(content),
+        onWorkflowEvent: (event) => events.push(event),
+        onFinalContent: (content) => delivered.push(content),
+      },
+      deps,
+    )
+
+    expect(result.finalContent).toContain("初稿正文内容")
+    expect(result.finalContent).not.toContain("最终去AI味正文")
+    expect(delivered[0]).toContain("初稿正文内容")
+    expect(thinking.join("\n")).toContain("已保留去AI味前的正文")
+    expect(events.filter((event) => event.name === "chapter_final_polish").map((event) => event.type))
+      .toEqual(["started", "error"])
+    expect(events.some((event) => event.name === "chapter_complete" && event.type === "completed")).toBe(true)
+  })
+
   it("emits structured activity events for context extraction and stage outputs", async () => {
     const deps = createDeps()
     const activityEvents: AgentActivityEvent[] = []

+ 37 - 21
src/lib/novel/deep-chapter-generation.ts

@@ -1428,6 +1428,8 @@ export async function runDeepChapterGeneration(
     detail: "做最后一遍简单审查,减少复读、机械套话和 AI 味。",
     params: workflowBaseParams,
   };
+  let polishFailureMessage = "";
+  let finalContent = currentContent;
   if (workflowProfile.runFinalPolish) {
     emitDeepChapterStageStarted(
       callbacks,
@@ -1435,9 +1437,8 @@ export async function runDeepChapterGeneration(
       "去AI味",
       "正在做最后一遍简单审查,去除复读、机械套话和 AI 味。",
     );
-  }
-  let finalContent = workflowProfile.runFinalPolish
-    ? await runChapterWorkflowStep(
+    try {
+      finalContent = await runChapterWorkflowStep(
         callbacks,
         finalPolishWorkflowStep,
         () =>
@@ -1459,34 +1460,49 @@ export async function runDeepChapterGeneration(
         (value) =>
           `简单审查与去AI味完成,最终正文约 ${countChapterChars(value)} 字。`,
         (value) => ({ chars: countChapterChars(value) }),
-      )
-    : currentContent;
-  if (!workflowProfile.runFinalPolish) {
+      );
+      emitDeepChapterActivity(callbacks, {
+        id: `deep_chapter:final_polish:output:${Date.now()}`,
+        stageId: "final_polish",
+        kind: "stage_output",
+        title: "去AI味",
+        content: `简单审查与去AI味完成,最终正文约 ${countChapterChars(finalContent)} 字。`,
+      });
+    } catch (err) {
+      rethrowIfUserAbort(err, signal);
+      polishFailureMessage = `简单审查与去AI味失败:${getErrorMessage(err)}。已保留去AI味前的正文。`;
+      finalContent = currentContent;
+      callbacks.onThinking?.(
+        formatStageThinking("阶段6:简单审查与去AI味", polishFailureMessage),
+      );
+      emitDeepChapterActivity(callbacks, {
+        id: `deep_chapter:final_polish:error:${Date.now()}`,
+        stageId: "final_polish",
+        kind: "analysis",
+        title: "去AI味失败",
+        content: polishFailureMessage,
+      });
+    }
+  } else {
     completeChapterWorkflowStep(
       callbacks,
       finalPolishWorkflowStep,
       describeSkippedFinalPolish(workflowProfile),
       { skipped: true, chars: countChapterChars(finalContent) },
     );
-  } else {
-    emitDeepChapterActivity(callbacks, {
-      id: `deep_chapter:final_polish:output:${Date.now()}`,
-      stageId: "final_polish",
-      kind: "stage_output",
-      title: "去AI味",
-      content: `简单审查与去AI味完成,最终正文约 ${countChapterChars(finalContent)} 字。`,
-    });
   }
   callbacks.onThinking?.(
     formatStageThinking(
       "阶段7:完成",
-      workflowProfile.runFinalPolish
-        ? reviewFailureMessage
-          ? "AI 审稿失败;已保留正文并完成最后一遍简单审查与去AI味,请在保存前手动复核。"
-          : revised
-          ? "采用返修并完成简单审查、去AI味后的正文作为最终正文。"
-          : "未发现阻断问题,已完成最后一遍简单审查与去AI味。"
-        : describeSkippedPostDraftCompletion(workflowProfile),
+      polishFailureMessage
+        ? "简单审查与去AI味失败,已保留去AI味前的正文作为最终正文。"
+        : workflowProfile.runFinalPolish
+          ? reviewFailureMessage
+            ? "AI 审稿失败;已保留正文并完成最后一遍简单审查与去AI味,请在保存前手动复核。"
+            : revised
+              ? "采用返修并完成简单审查、去AI味后的正文作为最终正文。"
+              : "未发现阻断问题,已完成最后一遍简单审查与去AI味。"
+          : describeSkippedPostDraftCompletion(workflowProfile),
     ),
   );
   emitDeepChapterActivity(callbacks, {

+ 41 - 1
src/lib/novel/review-adapter.spec.ts

@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"
 import type { LlmConfig } from "@/stores/wiki-store"
 import type { StreamCallbacks } from "@/lib/llm-client"
 import type { ContextPack } from "./context-engine"
-import { buildReviewPrompt, reviewChapter } from "./review-adapter"
+import { buildReviewPrompt, reviewChapter, REVIEW_STAGE_TIMEOUT_MS, REVIEW_TIMEOUT_MESSAGE } from "./review-adapter"
 
 const mocks = vi.hoisted(() => ({
   streamChatMock: vi.fn(),
@@ -70,6 +70,10 @@ vi.mock("./model-resolver", () => ({
   resolveNovelModel: (config: LlmConfig) => config,
 }))
 
+vi.mock("./character-aura", () => ({
+  buildCharacterAuraContext: vi.fn(async () => ""),
+}))
+
 vi.mock("./context-engine", () => ({
   buildContextPack: vi.fn(async () => mocks.contextPack),
   contextPackToPrompt: (pack: ContextPack) => [
@@ -288,6 +292,42 @@ describe("review-adapter staged review", () => {
     })).rejects.toThrow()
   })
 
+  it("throws 审稿模型输出超时 instead of missing JSON when the review stage times out", async () => {
+    vi.useFakeTimers()
+    try {
+      streamChatMock.mockImplementation(async (
+        _config: LlmConfig,
+        _messages: Array<{ role: string; content: string }>,
+        callbacks: StreamCallbacks,
+        signal?: AbortSignal,
+      ) => {
+        await new Promise<void>((resolve) => {
+          const finish = () => {
+            callbacks.onDone()
+            resolve()
+          }
+          if (signal?.aborted) {
+            finish()
+            return
+          }
+          signal?.addEventListener("abort", finish, { once: true })
+        })
+      })
+
+      const promise = reviewChapter("E:/Novel", "正文", 8, {
+        contextPack,
+        throwOnFailure: true,
+      })
+      await vi.advanceTimersByTimeAsync(0)
+      expect(streamChatMock).toHaveBeenCalled()
+      const assertion = expect(promise).rejects.toThrow(REVIEW_TIMEOUT_MESSAGE)
+      await vi.advanceTimersByTimeAsync(REVIEW_STAGE_TIMEOUT_MS)
+      await assertion
+    } finally {
+      vi.useRealTimers()
+    }
+  })
+
   it("throws missing structured review output when the caller requires a successful review", async () => {
     streamChatMock.mockImplementation(async (
       _config: LlmConfig,

+ 12 - 1
src/lib/novel/review-adapter.ts

@@ -92,6 +92,8 @@ const REVIEW_STAGES = [
 
 const REVIEW_CHUNK_SIZE = CHAPTER_BODY_EXCERPT_MAX_CHARS
 const REVIEW_MAX_CHUNKS = 3
+export const REVIEW_STAGE_TIMEOUT_MS = 300_000
+export const REVIEW_TIMEOUT_MESSAGE = "审稿模型输出超时"
 
 /**
  * 把超长章节分段用于审查。章节 ≤ 12000 字时返回单段;
@@ -422,8 +424,12 @@ async function runReviewStage(
     onRequestTrace: callbacks.onRequestTrace,
   }
 
+  let timeoutFired = false
   const timeoutController = new AbortController()
-  const timeoutId = setTimeout(() => timeoutController.abort(), 300000)
+  const timeoutId = setTimeout(() => {
+    timeoutFired = true
+    timeoutController.abort()
+  }, REVIEW_STAGE_TIMEOUT_MS)
 
   const combinedSignal = signal
     ? combineSignals(signal, timeoutController.signal)
@@ -441,6 +447,7 @@ async function runReviewStage(
   } catch (err) {
     clearTimeout(timeoutId)
     if (signal?.aborted) throw new Error("已停止生成")
+    if (timeoutFired) throw new Error(REVIEW_TIMEOUT_MESSAGE)
     if (retryCount < 2) {
       console.warn(`[Novel Review] Stage "${stageTitle}" failed, retrying (${retryCount + 1}/2)...`)
       publishReviewStageThinking(stageThinking, callbacks, stageTitle, "网络波动,正在重试...")
@@ -451,6 +458,10 @@ async function runReviewStage(
   }
 
   if (signal?.aborted) throw new Error("已停止生成")
+  // streamChat 会把超时 abort 当成正常结束(onDone、不抛错),
+  // 此时 content 通常是空的或半截 JSON。必须先按超时抛错,
+  // 不能落到「未返回结构化 JSON」。
+  if (timeoutFired) throw new Error(REVIEW_TIMEOUT_MESSAGE)
   return result.trim()
 }