Parcourir la source

fix(novel): 修复深度思考停止后仍继续调用模型

前情分析与审稿阶段补齐 AbortSignal 传递,避免 catch 吞掉用户中止错误后继续执行后续阶段;大纲深度思考流结束后检查 abort;停止按钮在 session 缺失时也能正确结束 UI。

Co-authored-by: Cursor <cursoragent@cursor.com>
darknessomi il y a 2 mois
Parent
commit
e8b4d6c4e7

+ 7 - 4
src/components/chat/chat-panel.tsx

@@ -1136,11 +1136,14 @@ export function ChatPanel() {
     const currentStreamingContent = useChatStore.getState().getStreamingContent(convId)
     abortControllersRef.current[convId]?.abort()
     delete abortControllersRef.current[convId]
+    const finalizeStopped = () => {
+      finalizeStream(`${currentStreamingContent ? `${currentStreamingContent}\n\n` : ""}已停止生成。`, [], convId)
+      delete activeStreamSessionsRef.current[convId]
+    }
     if (sessionId !== undefined) {
-      streamSessionGuardRef.current.stop(convId, sessionId, () => {
-        finalizeStream(`${currentStreamingContent ? `${currentStreamingContent}\n\n` : ""}已停止生成。`, [], convId)
-        delete activeStreamSessionsRef.current[convId]
-      })
+      streamSessionGuardRef.current.stop(convId, sessionId, finalizeStopped)
+    } else if (currentStreamingContent !== undefined) {
+      finalizeStopped()
     }
   }, [finalizeStream])
 

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

@@ -662,4 +662,24 @@ describe("runDeepChapterGeneration", () => {
       controller.signal,
     )
   })
+
+  it("stops after review when the user cancels during review", async () => {
+    const controller = new AbortController()
+    const deps: DeepChapterGenerationDeps = {
+      ...createDeps(),
+      reviewChapter: vi.fn(async () => {
+        controller.abort()
+        throw new Error("已停止生成")
+      }),
+    }
+
+    await expect(runDeepChapterGeneration(
+      { projectPath: "E:/Novel", userRequest: "生成第3章", chapterNumber: 3, llmConfig },
+      {},
+      deps,
+      controller.signal,
+    )).rejects.toThrow("已停止生成")
+
+    expect(deps.streamChat).toHaveBeenCalledTimes(2)
+  })
 })

+ 10 - 5
src/lib/novel/deep-chapter-generation.ts

@@ -17,6 +17,7 @@ import {
   buildDeepChapterRevisionPrompt,
   buildStableContextPrefix,
 } from "./deep-chapter-prompts"
+import { USER_ABORT_MESSAGE, rethrowIfUserAbort, throwIfAborted } from "@/lib/user-abort"
 
 export interface DeepChapterGenerationInput {
   projectPath: string
@@ -83,7 +84,6 @@ const defaultDeps: DeepChapterGenerationDeps = {
 const REPEAT_CHECK_MIN_CHARS = 600
 const REPEAT_WINDOW_CHARS = 120
 const REPEAT_HIT_LIMIT = 3
-const USER_ABORT_MESSAGE = "已停止生成"
 /** Legacy deep-chapter context budget (tokens). Kept as the upper bound;
  *  computeNovelContextTokenBudget clamps it down for small context windows. */
 const DEEP_CHAPTER_CONTEXT_TOKEN_BUDGET = 32000
@@ -180,7 +180,7 @@ export async function runDeepChapterGeneration(
   deps: DeepChapterGenerationDeps = defaultDeps,
   signal?: AbortSignal,
 ): Promise<DeepChapterGenerationResult> {
-  assertNotAborted(signal)
+  throwIfAborted(signal)
   const resumeCheckpoint = input.resumeCheckpoint
   const novelConfig = useWikiStore.getState().novelConfig
   const writingConfig = resolveWritingConfig(input.llmConfig)
@@ -202,6 +202,7 @@ export async function runDeepChapterGeneration(
         input.chapterNumber,
         writingConfig,
         3,
+        signal,
       )
       if (previousChaptersAnalysis) {
         callbacks.onThinking?.(formatStageThinking(
@@ -210,10 +211,11 @@ export async function runDeepChapterGeneration(
         ))
       }
     } catch (error) {
+      rethrowIfUserAbort(error, signal)
       console.error("[deep-chapter-generation] 前情分析失败:", error)
     }
   }
-  assertNotAborted(signal)
+  throwIfAborted(signal)
 
   const contextPack = await safeBuildChapterContextPack(
     deps,
@@ -225,6 +227,7 @@ export async function runDeepChapterGeneration(
 
   // 阶段1后:加载智能skill(传递contextPack用于场景检测)
   customDeAiSkill = await loadSmartDeAiSkill(input.projectPath, input.userRequest, contextPack)
+  throwIfAborted(signal)
 
   // 大纲与其余上下文共用同一窗口预算(派生自 maxContextSize)。大纲优先,
   // 但设有上限占比,避免其独占整个窗口;剩余额度再分给记忆/设定/检索上下文。
@@ -381,6 +384,7 @@ export async function runDeepChapterGeneration(
           ? await deps.reviewChapter(input.projectPath, draftContent, input.chapterNumber, { onThinking: callbacks.onThinking, contextPack }, signal)
           : await deps.reviewChapter(input.projectPath, draftContent, input.chapterNumber, { onThinking: callbacks.onThinking, contextPack })
       } catch (err) {
+        rethrowIfUserAbort(err, signal)
         console.error("[Deep Chapter] Review failed:", err)
         reviewResults = []
       }
@@ -478,6 +482,7 @@ export async function runDeepChapterGeneration(
         ))
       }
     } catch (err) {
+      rethrowIfUserAbort(err, signal)
       console.error("[Deep Chapter] 返修后复审失败:", err)
     }
   }
@@ -615,7 +620,7 @@ async function collectModelText(
     streamController.abort()
   }
 
-  assertNotAborted(signal)
+  throwIfAborted(signal)
 
   await deps.streamChat(
     config,
@@ -672,7 +677,7 @@ function countChapterChars(content: string): number {
 }
 
 function assertNotAborted(signal?: AbortSignal): void {
-  if (signal?.aborted) throw new Error(USER_ABORT_MESSAGE)
+  throwIfAborted(signal)
 }
 
 function isRequestCancelledError(error: Error): boolean {

+ 2 - 0
src/lib/novel/deep-outline-generation.ts

@@ -2,6 +2,7 @@ import type { LlmConfig } from "@/stores/wiki-store"
 import { useWikiStore } from "@/stores/wiki-store"
 import { hasUsableLlm } from "@/lib/has-usable-llm"
 import { streamChat, type ChatMessage, type RequestOverrides, type StreamCallbacks } from "@/lib/llm-client"
+import { USER_ABORT_MESSAGE } from "@/lib/user-abort"
 
 export interface DeepOutlineGenerationInput {
   llmConfig: LlmConfig
@@ -130,6 +131,7 @@ async function collectModelText(
     { reasoning: config.reasoning },
   )
 
+  if (signal?.aborted) throw new Error(USER_ABORT_MESSAGE)
   if (streamError) throw streamError
   return content.trim()
 }

+ 5 - 1
src/lib/novel/previous-chapters-analysis.ts

@@ -19,6 +19,7 @@ export async function analyzePreviousChapters(
   currentChapterNumber: number,
   llmConfig: LlmConfig,
   analysisCount: number = 3,
+  signal?: AbortSignal,
 ): Promise<string> {
   if (currentChapterNumber <= 1) return ""
 
@@ -26,6 +27,7 @@ export async function analyzePreviousChapters(
 
   // 读取前N章的完整内容
   for (let i = Math.max(1, currentChapterNumber - analysisCount); i < currentChapterNumber; i++) {
+    if (signal?.aborted) throw new Error("已停止生成")
     try {
       const results = await searchWiki(projectPath, `chapter_number:${i}`)
       if (results.length > 0) {
@@ -55,9 +57,11 @@ export async function analyzePreviousChapters(
       onToken: (token) => { analysis += token },
       onDone: () => {},
       onError: () => {},
-    }
+    },
+    signal,
   )
 
+  if (signal?.aborted) throw new Error("已停止生成")
   return analysis.trim()
 }
 

+ 2 - 0
src/lib/novel/review-adapter.ts

@@ -7,6 +7,7 @@ import { contextPackToPrompt, buildContextPack, type ContextPack } from "./conte
 import { buildCharacterAuraContext } from "./character-aura"
 import { resolveNovelModel } from "./model-resolver"
 import { hasUsableLlm } from "@/lib/has-usable-llm"
+import { rethrowIfUserAbort } from "@/lib/user-abort"
 
 export interface NovelReviewResult {
   severity: "error" | "warning" | "info"
@@ -278,6 +279,7 @@ ${langReminder}`
 
     return chunkResults.flat()
   } catch (err) {
+    rethrowIfUserAbort(err, signal)
     console.error("[Novel Review] Failed:", err)
     return []
   }

+ 24 - 0
src/lib/user-abort.spec.ts

@@ -0,0 +1,24 @@
+import { describe, expect, it } from "vitest"
+import { isUserAbortError, rethrowIfUserAbort, USER_ABORT_MESSAGE } from "./user-abort"
+
+describe("user-abort", () => {
+  it("detects explicit user abort message", () => {
+    expect(isUserAbortError(new Error(USER_ABORT_MESSAGE))).toBe(true)
+  })
+
+  it("detects aborted signal", () => {
+    const controller = new AbortController()
+    controller.abort()
+    expect(isUserAbortError(new Error("network"), controller.signal)).toBe(true)
+  })
+
+  it("rethrows user abort errors", () => {
+    const controller = new AbortController()
+    controller.abort()
+    expect(() => rethrowIfUserAbort(new Error("timeout"), controller.signal)).toThrow(USER_ABORT_MESSAGE)
+  })
+
+  it("ignores unrelated errors", () => {
+    expect(() => rethrowIfUserAbort(new Error("timeout"))).not.toThrow()
+  })
+})

+ 18 - 0
src/lib/user-abort.ts

@@ -0,0 +1,18 @@
+export const USER_ABORT_MESSAGE = "已停止生成"
+
+export function throwIfAborted(signal?: AbortSignal): void {
+  if (signal?.aborted) throw new Error(USER_ABORT_MESSAGE)
+}
+
+export function isUserAbortError(error: unknown, signal?: AbortSignal): boolean {
+  if (signal?.aborted) return true
+  if (!(error instanceof Error)) return false
+  if (error.message === USER_ABORT_MESSAGE) return true
+  if (error.name === "AbortError") return true
+  return /request cancelled|request canceled|aborted/i.test(error.message)
+}
+
+export function rethrowIfUserAbort(error: unknown, signal?: AbortSignal): never | void {
+  if (!isUserAbortError(error, signal)) return
+  throw new Error(USER_ABORT_MESSAGE)
+}