Explorar el Código

fix(chat): 短完成句时回退 workflow 正文并阻止空章落盘

助手仅返回完成通知时改用 run_chapter_workflow 最终正文;收紧标题提取与字数校验,保存前拒绝无效章节。

Co-authored-by: Cursor <cursoragent@cursor.com>
darknessomi hace 1 mes
padre
commit
d59ce937db

+ 5 - 6
src/components/chat/chat-message.tsx

@@ -62,7 +62,10 @@ interface ChatMessageProps {
   onRegenerate?: () => void;
   novelMode?: boolean;
   projectPath?: string | null;
-  onSaveAsChapter?: (content: string) => void;
+  onSaveAsChapter?: (
+    content: string,
+    toolCalls?: Array<{ name: string; result?: string; status?: string }>,
+  ) => void;
   onContinueNextChapter?: () => void;
   onContinueUnfinished?: () => void;
   onSaveAsDraft?: (content: string) => void;
@@ -218,11 +221,7 @@ export function ChatMessage({
               <button
                 type="button"
                 onClick={() =>
-                  onSaveAsChapter(
-                    getCopyableAssistantContent(message.content, {
-                      toolCalls: message.agentToolCalls,
-                    }),
-                  )
+                  onSaveAsChapter(message.content, message.agentToolCalls)
                 }
                 disabled={isSaving}
                 className="rounded border border-border px-2 py-0.5 text-[11px] text-foreground hover:bg-accent disabled:opacity-50"

+ 6 - 4
src/components/chat/chat-panel.spec.tsx

@@ -284,8 +284,8 @@ describe("chat-panel agent reference integration", () => {
   it("settles running tool calls when the agent session finishes", () => {
     expect(source).toContain("settleRunningAgentToolCalls")
     expect(source).not.toContain("const _settlePattern")
-    expect(source).toContain("agentToolCalls: settleRunningAgentToolCalls(record?.toolCalls.length ? record.toolCalls : message.agentToolCalls)")
-    expect(source).toContain('agentToolCalls: settleRunningAgentToolCalls(message.agentToolCalls, "error")')
+    expect(source).toContain("record?.toolCalls.length ? record.toolCalls : message.agentToolCalls")
+    expect(source).toContain('settleRunningAgentToolCalls(message.agentToolCalls, "error")')
   })
 
   it("settles visible tool calls when generation is cancelled from any chat confirmation path", () => {
@@ -462,7 +462,9 @@ describe("chat-panel post-write check integration (Stage D)", () => {
     const beforeStageDBlock = source.slice(0, stageDIndex)
     expect(beforeStageDBlock).toContain("useChatStore.getState()")
     expect(beforeStageDBlock).toContain("lastAssistantForValidation")
-    expect(beforeStageDBlock).toContain("finalContent = lastAssistantForValidation?.content ??")
+    expect(beforeStageDBlock).toContain("rawFinalContent = lastAssistantForValidation?.content ??")
+    expect(beforeStageDBlock).toContain("getCopyableAssistantContent(rawFinalContent")
+    expect(beforeStageDBlock).toContain("const finalContent = resolvedFinalContent")
     const stageDBlock = source.slice(stageDIndex, stageDIndex + 1200)
     expect(stageDBlock).toContain("const chapterContent = finalContent")
   })
@@ -477,7 +479,7 @@ describe("chat-panel post-write check integration (Stage D)", () => {
   it("skips empty content to avoid false reports", () => {
     const stageDIndex = source.indexOf("=== Stage D: 写后剧情自检 ===")
     const stageDBlock = source.slice(stageDIndex, stageDIndex + 1200)
-    expect(stageDBlock).toContain("if (chapterContent && !hasChapterPlanMarker)")
+    expect(stageDBlock).toContain("if (chapterContent && !hasChapterPlanMarker && chapterProtocolValid)")
   })
 
   it("writes the check result and meta into contextTrace.contextInfo", () => {

+ 74 - 33
src/components/chat/chat-panel.tsx

@@ -100,9 +100,10 @@ import {
   stripContinueUnfinishedDeepChapterContext,
 } from "./chat-resume"
 import {
-  extractChapterBodyFromToolCalls,
   getCopyableAssistantContent,
+  type CopyableToolCall,
 } from "@/lib/chat-copy-content"
+import { validateChapterBeforeSave } from "@/lib/novel/result-save-guard"
 import { decideChapterSaveStrategy, detectGeneratedTargetChapterNumber } from "@/lib/novel/chapter-save-strategy"
 import { loadBinding } from "@/lib/novel/story-simulation/framework-binding"
 import { loadFrameworks } from "@/lib/novel/story-simulation/framework-store"
@@ -1103,16 +1104,31 @@ export function ChatPanel() {
     }
   }, [closeChapterPlanDialog])
 
-  const handleSaveAsChapter = useCallback(async (content: string) => {
+  const handleSaveAsChapter = useCallback(async (
+    content: string,
+    toolCalls?: CopyableToolCall[],
+  ) => {
     if (!project) return
     const pp = normalizePath(project.path)
     setIsSavingChapter(true)
     setChapterSaveStatus("")
     try {
+      const resolvedContent = getCopyableAssistantContent(content, { toolCalls })
+      const saveGuard = validateChapterBeforeSave(resolvedContent)
+      if (!saveGuard.ok) {
+        setChapterSaveStatus(saveGuard.message || "章节结果校验未通过,已取消保存")
+        return
+      }
+
       // 使用带标题提取的清理函数
       const { content: cleanedContent, title: extractedTitle } = cleanGeneratedChapterContentWithTitle(
-        getCopyableAssistantContent(content),
+        resolvedContent,
       )
+      if (!cleanedContent.trim()) {
+        setChapterSaveStatus("章节正文为空,已取消保存")
+        return
+      }
+
       const selectedChapterNumber = await readSelectedChapterNumberForFile(selectedFile)
       const generatedTargetChapterNumber = detectGeneratedTargetChapterNumber(extractedTitle ?? cleanedContent)
       const explicitTargetPath = generatedTargetChapterNumber ? await findChapterFileByNumber(pp, generatedTargetChapterNumber) : null
@@ -1405,44 +1421,53 @@ export function ChatPanel() {
       let accumulatedReasoningContent = ""
 
       const markDone = (record?: AgentRunRecord) => {
-        updateAgentAssistantMessage(assistantMessage.id, (message) => ({
-          ...message,
-          content: message.content || record?.finalText || "Agent未返回内容。",
-          reasoning_content: accumulatedReasoningContent,
-          agentToolCalls: settleRunningAgentToolCalls(record?.toolCalls.length ? record.toolCalls : message.agentToolCalls),
-          agentStages: settleRunningAgentStages(message.agentStages, "done"),
-          references: (() => {
-            const existingReferences = message.references ?? []
-            const existingPaths = new Set(existingReferences.map((reference) => reference.path))
-            const agentReferences = agentToolCallsToMessageReferences(
-              settleRunningAgentToolCalls(record?.toolCalls.length ? record.toolCalls : message.agentToolCalls) ?? [],
-            ).filter((reference) => !existingPaths.has(reference.path))
-            return agentReferences.length > 0
-              ? [...existingReferences, ...agentReferences]
-              : message.references
-          })(),
-          contextTrace: contextTrace || message.contextTrace,
-          isAgentRunning: false,
-        }))
+        updateAgentAssistantMessage(assistantMessage.id, (message) => {
+          const settledTools = settleRunningAgentToolCalls(
+            record?.toolCalls.length ? record.toolCalls : message.agentToolCalls,
+          )
+          const rawContent = message.content || record?.finalText || "Agent未返回内容。"
+          const resolvedContent = getCopyableAssistantContent(rawContent, {
+            toolCalls: settledTools,
+          }) || rawContent
+          return {
+            ...message,
+            content: resolvedContent,
+            reasoning_content: accumulatedReasoningContent,
+            agentToolCalls: settledTools,
+            agentStages: settleRunningAgentStages(message.agentStages, "done"),
+            references: (() => {
+              const existingReferences = message.references ?? []
+              const existingPaths = new Set(existingReferences.map((reference) => reference.path))
+              const agentReferences = agentToolCallsToMessageReferences(
+                settledTools ?? [],
+              ).filter((reference) => !existingPaths.has(reference.path))
+              return agentReferences.length > 0
+                ? [...existingReferences, ...agentReferences]
+                : message.references
+            })(),
+            contextTrace: contextTrace || message.contextTrace,
+            isAgentRunning: false,
+          }
+        })
       }
 
       const markError = (error: Error) => {
         hasAgentError = true
         lastAgentError = error.message || "生成失败"
         updateAgentAssistantMessage(assistantMessage.id, (message) => {
-          const recoveredChapterBody =
-            extractChapterBodyFromToolCalls(message.agentToolCalls) ||
-            getCopyableAssistantContent(message.content)
-          const baseContent = message.content?.trim()
-            ? message.content
-            : recoveredChapterBody
+          const settledTools = settleRunningAgentToolCalls(message.agentToolCalls, "error")
+          const rawContent = message.content ?? ""
+          const recoveredChapterBody = getCopyableAssistantContent(rawContent, {
+            toolCalls: settledTools,
+          })
+          const baseContent = recoveredChapterBody || rawContent.trim()
           return {
             ...message,
             content: baseContent
               ? `${baseContent}\n\n出错:${error.message}`
               : `出错:${error.message}`,
             reasoning_content: accumulatedReasoningContent,
-            agentToolCalls: settleRunningAgentToolCalls(message.agentToolCalls, "error"),
+            agentToolCalls: settledTools,
             agentStages: settleRunningAgentStages(message.agentStages, "error"),
             contextTrace: contextTrace || message.contextTrace,
             isAgentRunning: false,
@@ -1864,9 +1889,24 @@ export function ChatPanel() {
               const lastAssistantForValidation = storeStateForValidation.messages.find(
                 (m) => m.id === assistantMessage.id && m.role === "assistant",
               )
-              const finalContent = lastAssistantForValidation?.content ?? ""
+              const rawFinalContent = lastAssistantForValidation?.content ?? ""
+              const resolvedFinalContent = getCopyableAssistantContent(rawFinalContent, {
+                toolCalls: lastAssistantForValidation?.agentToolCalls ?? record.toolCalls,
+              }) || rawFinalContent
+              if (
+                resolvedFinalContent
+                && resolvedFinalContent !== rawFinalContent
+              ) {
+                updateAgentAssistantMessage(assistantMessage.id, (message) => ({
+                  ...message,
+                  content: resolvedFinalContent,
+                }))
+              }
+              const finalContent = resolvedFinalContent
+              let chapterProtocolValid = false
               if (finalContent) {
                 const protocolTrace = buildResultProtocolTrace("chapter", finalContent)
+                chapterProtocolValid = protocolTrace.valid
                 contextTrace = setContextInfo(contextTrace, { ...traceInfo, resultProtocol: protocolTrace })
               }
               // === Stage D: 写后剧情自检 ===
@@ -1876,9 +1916,10 @@ export function ChatPanel() {
                 effectiveTaskRoute.intent === "continue_chapter"
               ) {
                 const chapterContent = finalContent
-                // 排除含 chapter_plan 标记的内容(计划本身不是正文)与空内容
+                // 排除含 chapter_plan 标记的内容(计划本身不是正文)与空内容;
+                // 短完成句等未通过章节协议的结果也不进入自检/草稿校验。
                 const hasChapterPlanMarker = chapterContent.includes("chapter_plan")
-                if (chapterContent && !hasChapterPlanMarker) {
+                if (chapterContent && !hasChapterPlanMarker && chapterProtocolValid) {
                   void (async () => {
                     try {
                       const result = await runPostWriteCheckAI({
@@ -1901,7 +1942,7 @@ export function ChatPanel() {
                 }
                 // === Stage E: 草稿校验与修复(硬偏差) ===
                 // 仅在已打开项目时触发;未打开项目时跳过(避免空路径调用 skill)
-                if (chapterContent && !hasChapterPlanMarker && projectPath) {
+                if (chapterContent && !hasChapterPlanMarker && chapterProtocolValid && projectPath) {
                   const draftChapterNumber = effectiveTaskRoute.chapterNumber ?? 0
                   void (async () => {
                     try {

+ 82 - 0
src/lib/chat-copy-content.test.ts

@@ -3,8 +3,31 @@ import {
   extractChapterBodyFromToolCalls,
   extractWorkflowFinalContent,
   getCopyableAssistantContent,
+  isThinChapterAssistantContent,
+  shouldPreferWorkflowChapterBody,
 } from "./chat-copy-content"
 
+function buildWorkflowResult(body: string): string {
+  return [
+    "章节工作流完成。",
+    "是否返修:是",
+    "任务书:outline",
+    "",
+    "最终正文:",
+    body,
+  ].join("\n")
+}
+
+const LONG_CHAPTER_BODY = [
+  "# 第32章 查分夜",
+  "",
+  "六月下旬的晚上,长泰广场八楼的灯亮到很晚。".repeat(20),
+  "",
+  "陈远坐在评审位前,屏幕上是云记3.0的代码评审页面。".repeat(15),
+  "",
+  "苏晴把手机递过来,两人继续改搜索层级。".repeat(15),
+].join("\n")
+
 test("copies generated chapter edit content instead of surrounding context", () => {
   const content = [
     "Outline context that should not be copied.",
@@ -108,3 +131,62 @@ test("recovers chapter body from tool calls when message content is only an erro
   expect(copied).not.toContain("HTTP 503")
   expect(copied).not.toContain("任务书:")
 })
+
+test("isThinChapterAssistantContent detects completion notices and short text", () => {
+  expect(isThinChapterAssistantContent("第 32 章正文已按章纲重写完成。")).toBe(true)
+  expect(isThinChapterAssistantContent("短文本")).toBe(true)
+  expect(isThinChapterAssistantContent(LONG_CHAPTER_BODY)).toBe(false)
+})
+
+test("shouldPreferWorkflowChapterBody only when assistant is thin and workflow is long", () => {
+  expect(
+    shouldPreferWorkflowChapterBody("第 32 章正文已按章纲重写完成。", LONG_CHAPTER_BODY),
+  ).toBe(true)
+  expect(shouldPreferWorkflowChapterBody(LONG_CHAPTER_BODY, LONG_CHAPTER_BODY)).toBe(false)
+  expect(shouldPreferWorkflowChapterBody("第 32 章正文已按章纲重写完成。", "短草稿")).toBe(false)
+})
+
+test("falls back to workflow final body when assistant content is a completion notice", () => {
+  const copied = getCopyableAssistantContent("第 32 章正文已按章纲重写完成。", {
+    toolCalls: [
+      {
+        name: "run_chapter_workflow",
+        status: "done",
+        result: buildWorkflowResult(LONG_CHAPTER_BODY),
+      },
+    ],
+  })
+
+  expect(copied).toContain("# 第32章 查分夜")
+  expect(copied).toContain("长泰广场八楼")
+  expect(copied).not.toContain("已按章纲重写完成")
+})
+
+test("keeps full assistant chapter body even when workflow also has final content", () => {
+  const assistantBody = [
+    "# 第32章 查分夜",
+    "",
+    "模型按章纲重写后的完整正文从这里开始。".repeat(40),
+    "",
+    "夜宵后苏晴发来周末见。".repeat(20),
+  ].join("\n")
+
+  const copied = getCopyableAssistantContent(assistantBody, {
+    toolCalls: [
+      {
+        name: "run_chapter_workflow",
+        status: "done",
+        result: buildWorkflowResult(LONG_CHAPTER_BODY),
+      },
+    ],
+  })
+
+  expect(copied).toContain("模型按章纲重写后的完整正文")
+  expect(copied).not.toContain("六月下旬的晚上")
+})
+
+test("keeps short assistant content when workflow body is unavailable", () => {
+  expect(getCopyableAssistantContent("第 32 章正文已按章纲重写完成。")).toBe(
+    "第 32 章正文已按章纲重写完成。",
+  )
+})

+ 42 - 2
src/lib/chat-copy-content.ts

@@ -4,6 +4,16 @@ import { cleanGeneratedChapterContentForSave } from "@/lib/novel/chapter-content
 const WORKFLOW_FINAL_CONTENT_MARKER = "最终正文:"
 const ASSISTANT_ERROR_SUFFIX_RE = /(?:^|\n{1,2})出错:[\s\S]*$/
 
+/** 助手可见内容短于此(去空白)时,视为不足以作为章节正文。 */
+export const THIN_CHAPTER_CONTENT_CHAR_LIMIT = 80
+/** workflow 正文至少这么长(去空白)才值得回退。 */
+export const WORKFLOW_BODY_MIN_CHAR_COUNT = 500
+/** workflow 相对助手内容的最小倍数,避免用略长草稿覆盖短但有效的改写。 */
+export const WORKFLOW_BODY_MIN_RATIO = 5
+
+const COMPLETION_NOTICE_RE =
+  /(?:已按章纲|重写完成|章节工作流完成|正文已.{0,12}完成|已(?:生成|重写|完成).{0,8}正文)/
+
 export type CopyableToolCall = {
   name: string
   result?: string
@@ -43,6 +53,33 @@ function stripAssistantErrorSuffix(content: string): string {
   return content.replace(ASSISTANT_ERROR_SUFFIX_RE, "").trim()
 }
 
+function countCompactChars(text: string): number {
+  return text.replace(/\s/g, "").length
+}
+
+/** 助手可见内容是否像短完成通知或过短伪正文。 */
+export function isThinChapterAssistantContent(content: string): boolean {
+  const trimmed = content.trim()
+  if (!trimmed) return true
+  const compact = countCompactChars(trimmed)
+  if (compact <= THIN_CHAPTER_CONTENT_CHAR_LIMIT) return true
+  if (compact <= 200 && COMPLETION_NOTICE_RE.test(trimmed)) return true
+  return false
+}
+
+/** 是否应优先采用 workflow「最终正文」而非助手可见短文。 */
+export function shouldPreferWorkflowChapterBody(
+  assistantContent: string,
+  workflowBody: string,
+): boolean {
+  if (!workflowBody.trim()) return false
+  if (!isThinChapterAssistantContent(assistantContent)) return false
+  const workflowChars = countCompactChars(workflowBody)
+  const assistantChars = Math.max(countCompactChars(assistantContent), 1)
+  if (workflowChars < WORKFLOW_BODY_MIN_CHAR_COUNT) return false
+  return workflowChars >= assistantChars * WORKFLOW_BODY_MIN_RATIO
+}
+
 /** 从 run_chapter_workflow 工具结果中提取「最终正文」段。 */
 export function extractWorkflowFinalContent(result: string | undefined): string {
   if (!result?.trim()) return ""
@@ -83,11 +120,14 @@ export function getCopyableAssistantContent(
   const fromContent = stripAssistantErrorSuffix(
     stripHiddenAssistantBlocks(parsed.textContent || content),
   )
+  const fromWorkflow = extractChapterBodyFromToolCalls(options?.toolCalls)
+
+  if (fromContent && shouldPreferWorkflowChapterBody(fromContent, fromWorkflow)) {
+    return fromWorkflow
+  }
   if (fromContent) {
     return fromContent
   }
-
-  const fromWorkflow = extractChapterBodyFromToolCalls(options?.toolCalls)
   if (fromWorkflow) return fromWorkflow
 
   return ""

+ 21 - 0
src/lib/novel/chapter-content-cleanup.spec.ts

@@ -3,8 +3,21 @@ import { describe, expect, it } from "vitest"
 import {
   cleanGeneratedChapterContentForSave,
   cleanGeneratedChapterContentWithTitle,
+  isPlausibleChapterTitleLine,
 } from "./chapter-content-cleanup"
 
+describe("isPlausibleChapterTitleLine", () => {
+  it("接受真实章名", () => {
+    expect(isPlausibleChapterTitleLine("# 第32章 查分夜")).toBe(true)
+    expect(isPlausibleChapterTitleLine("第13章 风雪来客")).toBe(true)
+  })
+
+  it("拒绝完成通知伪标题", () => {
+    expect(isPlausibleChapterTitleLine("第 32 章正文已按章纲重写完成。")).toBe(false)
+    expect(isPlausibleChapterTitleLine("# 第32章正文已重写完成")).toBe(false)
+  })
+})
+
 describe("cleanGeneratedChapterContentWithTitle", () => {
   it("提取 Markdown 章节标题,但不把标题重复保存在正文中", () => {
     expect(cleanGeneratedChapterContentWithTitle("# 第12章 夜雨归人\n\n雨落在旧宅门前。\n\n他推门而入。"))
@@ -29,6 +42,14 @@ describe("cleanGeneratedChapterContentWithTitle", () => {
         content: "雨落在旧宅门前。\n\n他推门而入。",
       })
   })
+
+  it("不把完成通知当作章节标题提取", () => {
+    expect(cleanGeneratedChapterContentWithTitle("第 32 章正文已按章纲重写完成。"))
+      .toEqual({
+        title: null,
+        content: "第 32 章正文已按章纲重写完成。",
+      })
+  })
 })
 
 describe("cleanGeneratedChapterContentForSave", () => {

+ 20 - 0
src/lib/novel/chapter-content-cleanup.ts

@@ -20,6 +20,23 @@ function stripThinkingBlocks(content: string): string {
   return result
 }
 
+/** 完成通知类伪标题(如「第 32 章正文已按章纲重写完成。」)不得当作章名。 */
+const CHAPTER_TITLE_STATUS_RE = /(?:完成|重写|已按|生成|工作流)/
+
+/**
+ * 判断一行是否像真实章节标题(「第N章 查分夜」),而不是完成通知。
+ * 要求「第N章」后有分隔与短标题名,且不含完成态动词簇。
+ */
+export function isPlausibleChapterTitleLine(line: string): boolean {
+  const trimmed = line.trim().replace(/^#{1,6}\s*/, "")
+  const match = trimmed.match(/^第\s*\d+\s*章(?:\s*[::\-—–]?\s*|\s+)(.+)$/)
+  if (!match?.[1]) return false
+  const name = match[1].trim()
+  if (!name || name.length > 40) return false
+  if (CHAPTER_TITLE_STATUS_RE.test(name)) return false
+  return true
+}
+
 /**
  * 从内容开头提取章节标题,并返回清理后的行数组和提取到的标题。
  * 标题格式:# 第X章 标题名 或 第X章 标题名
@@ -32,6 +49,9 @@ function extractLeadingTitle(lines: string[]): { lines: string[]; title: string
   while (index < lines.length && !lines[index].trim()) index += 1
 
   const firstLine = lines[index]?.trim() ?? ""
+  if (!isPlausibleChapterTitleLine(firstLine)) {
+    return { lines, title: null }
+  }
 
   // 匹配 # 第X章 标题 格式
   const headingMatch = firstLine.match(/^#{1,6}\s*(第\s*\d+\s*章.*)$/)

+ 9 - 2
src/lib/novel/result-parser.spec.ts

@@ -48,10 +48,17 @@ title: "第三章 初遇"
       expect(result.warnings).toContain("缺少 frontmatter 元数据")
     })
 
-    it("字数过少应该返回警告", () => {
+    it("字数过少应该返回 invalid", () => {
       const content = "短文本"
       const result = validateChapterContent(content)
-      expect(result.warnings.some((w) => w.includes("字数过少"))).toBe(true)
+      expect(result.valid).toBe(false)
+      expect(result.errors.some((e) => e.includes("字数过少"))).toBe(true)
+    })
+
+    it("完成通知短句应该返回 invalid", () => {
+      const result = validateChapterContent("第 32 章正文已按章纲重写完成。")
+      expect(result.valid).toBe(false)
+      expect(result.errors.some((e) => e.includes("字数过少"))).toBe(true)
     })
   })
 

+ 1 - 1
src/lib/novel/result-parser.ts

@@ -92,7 +92,7 @@ export function validateChapterContent(content: string): ChapterValidationResult
     warnings.push("未检测到章节标题")
   }
   if (wordCount < 50) {
-    warnings.push(`字数过少(${wordCount} 字)`)
+    errors.push(`字数过少(${wordCount} 字)`)
   }
   if (wordCount > 20000) {
     warnings.push(`字数超过上限(${wordCount} 字 / 20000 字上限)`)

+ 8 - 0
src/lib/novel/result-save-guard.spec.ts

@@ -16,4 +16,12 @@ describe("result save guard", () => {
     expect(result.ok).toBe(true)
     expect(result.trace.valid).toBe(true)
   })
+
+  it("blocks completion-notice short text before confirming a draft save", () => {
+    const result = validateChapterBeforeSave("第 32 章正文已按章纲重写完成。")
+
+    expect(result.ok).toBe(false)
+    expect(result.trace.valid).toBe(false)
+    expect(result.message).toContain("字数过少")
+  })
 })