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

fix(de-ai): 去AI味对比与送模只使用章节正文

剥离 frontmatter、标题行与代码围栏包装,避免写回范围外的元数据进入模型与对比。

Co-authored-by: Cursor <cursoragent@cursor.com>
darknessomi 1 месяц назад
Родитель
Сommit
0741fa2d8f
3 измененных файлов с 85 добавлено и 8 удалено
  1. 12 8
      src/components/layout/preview-panel.tsx
  2. 58 0
      src/lib/chapter-selection.spec.ts
  3. 15 0
      src/lib/chapter-selection.ts

+ 12 - 8
src/components/layout/preview-panel.tsx

@@ -43,6 +43,7 @@ import { useOutlineGenerationStore, type OutlineGenerationTask } from "@/stores/
 import { useImportProgressStore } from "@/stores/import-progress-store"
 import { useImportProgressStore } from "@/stores/import-progress-store"
 import {
 import {
   buildPolishSelectionMessages,
   buildPolishSelectionMessages,
+  extractDeAiChapterText,
   rebuildChapterBody,
   rebuildChapterBody,
   replaceChapterBodySelection,
   replaceChapterBodySelection,
   splitChapterHeading,
   splitChapterHeading,
@@ -981,7 +982,8 @@ export function PreviewPanel() {
 
 
   const runWholeChapterDeAi = useCallback(async (skillContent: string, skillName: string) => {
   const runWholeChapterDeAi = useCallback(async (skillContent: string, skillName: string) => {
     await syncDiskBeforeAction()
     await syncDiskBeforeAction()
-    const source = wikiEditorRef.current?.getCurrentMarkdown() ?? fileContentRef.current
+    const markdown = wikiEditorRef.current?.getCurrentMarkdown() ?? fileContentRef.current
+    const source = extractDeAiChapterText(markdown)
     if (!source.trim() || !selectedFile || !project) return
     if (!source.trim() || !selectedFile || !project) return
     const state = useWikiStore.getState()
     const state = useWikiStore.getState()
     const llmConfig = resolveNovelModel(state.llmConfig, state.novelConfig, "deAi")
     const llmConfig = resolveNovelModel(state.llmConfig, state.novelConfig, "deAi")
@@ -1013,7 +1015,7 @@ export function PreviewPanel() {
           },
           },
           onDone: () => {
           onDone: () => {
             doneCalled = true
             doneCalled = true
-            useDeAiTaskStore.getState().finishTask(taskId, result)
+            useDeAiTaskStore.getState().finishTask(taskId, extractDeAiChapterText(result))
           },
           },
           onError: (error) => {
           onError: (error) => {
             doneCalled = true
             doneCalled = true
@@ -1025,7 +1027,7 @@ export function PreviewPanel() {
       // 兜底:streamChat 正常返回但未调用 onDone/onError 时,用 result 完成
       // 兜底:streamChat 正常返回但未调用 onDone/onError 时,用 result 完成
       if (!doneCalled) {
       if (!doneCalled) {
         if (result.trim()) {
         if (result.trim()) {
-          useDeAiTaskStore.getState().finishTask(taskId, result)
+          useDeAiTaskStore.getState().finishTask(taskId, extractDeAiChapterText(result))
         } else {
         } else {
           useDeAiTaskStore.getState().failTask(taskId, "去AI味未返回内容")
           useDeAiTaskStore.getState().failTask(taskId, "去AI味未返回内容")
         }
         }
@@ -1736,9 +1738,10 @@ export function PreviewPanel() {
              onConfirm={async (_taskId, chapterId, candidateContent) => {
              onConfirm={async (_taskId, chapterId, candidateContent) => {
               const task = readyTasks.find((t) => t.id === chapterId)
               const task = readyTasks.find((t) => t.id === chapterId)
               if (!task) return
               if (!task) return
+              const body = extractDeAiChapterText(candidateContent)
               try {
               try {
-                await applyDeAiBatchChapter(task.chapterPath, candidateContent)
-                useDeAiTaskStore.getState().updateTask(chapterId, { candidateContent })
+                await applyDeAiBatchChapter(task.chapterPath, body)
+                useDeAiTaskStore.getState().updateTask(chapterId, { candidateContent: body })
                 useDeAiTaskStore.getState().confirmTask(chapterId)
                 useDeAiTaskStore.getState().confirmTask(chapterId)
                 useDeAiTaskStore.getState().closeReview(project.path)
                 useDeAiTaskStore.getState().closeReview(project.path)
                 toast.success(`${task.chapterTitle} 去AI味结果已保存`)
                 toast.success(`${task.chapterTitle} 去AI味结果已保存`)
@@ -1750,7 +1753,7 @@ export function PreviewPanel() {
              onSaveDraft={async (_taskId, chapterId, candidateContent) => {
              onSaveDraft={async (_taskId, chapterId, candidateContent) => {
                const task = readyTasks.find((item) => item.id === chapterId)
                const task = readyTasks.find((item) => item.id === chapterId)
                if (!task) return
                if (!task) return
-               await handleDeAiSaveDraft(task.chapterPath, candidateContent)
+               await handleDeAiSaveDraft(task.chapterPath, extractDeAiChapterText(candidateContent))
              }}
              }}
              onRegenerate={async (_taskId, chapterId) => {
              onRegenerate={async (_taskId, chapterId) => {
               const task = readyTasks.find((t) => t.id === chapterId)
               const task = readyTasks.find((t) => t.id === chapterId)
@@ -1768,13 +1771,14 @@ export function PreviewPanel() {
               }
               }
               let result = ""
               let result = ""
               try {
               try {
+                const source = extractDeAiChapterText(task.sourceContent)
                 await streamChat(
                 await streamChat(
                   llmConfig,
                   llmConfig,
-                  buildDeAiRewriteMessages(task.sourceContent, task.skillContent),
+                  buildDeAiRewriteMessages(source, task.skillContent),
                   {
                   {
                     onToken: (token) => { result += token },
                     onToken: (token) => { result += token },
                     onDone: () => {
                     onDone: () => {
-                      useDeAiTaskStore.getState().finishTask(chapterId, result)
+                      useDeAiTaskStore.getState().finishTask(chapterId, extractDeAiChapterText(result))
                     },
                     },
                     onError: (error) => {
                     onError: (error) => {
                       useDeAiTaskStore.getState().failTask(chapterId, error.message ?? String(error))
                       useDeAiTaskStore.getState().failTask(chapterId, error.message ?? String(error))

+ 58 - 0
src/lib/chapter-selection.spec.ts

@@ -0,0 +1,58 @@
+import { describe, expect, it } from "vitest"
+import { extractDeAiChapterText, replaceWholeChapterBody } from "./chapter-selection"
+
+describe("extractDeAiChapterText", () => {
+  it("strips YAML frontmatter and chapter heading so compare uses body prose only", () => {
+    const markdown = [
+      "---",
+      "type: chapter",
+      "title: 第一章",
+      "chapter_number: 1",
+      "tags: [开篇, 主角]",
+      "---",
+      "",
+      "# 第一章",
+      "",
+      "雨忽然停了。",
+      "他抬起头。",
+    ].join("\n")
+
+    expect(extractDeAiChapterText(markdown)).toBe("雨忽然停了。\n他抬起头。")
+  })
+
+  it("strips wrapping markdown fences from model output", () => {
+    expect(extractDeAiChapterText("```markdown\n改写后的正文\n```")).toBe("改写后的正文")
+  })
+
+  it("keeps plain prose unchanged", () => {
+    expect(extractDeAiChapterText("只有正文")).toBe("只有正文")
+  })
+
+  it("matches the prose that replaceWholeChapterBody will write back", () => {
+    const current = [
+      "---",
+      "type: chapter",
+      "title: 第一章",
+      "---",
+      "# 第一章",
+      "",
+      "旧正文",
+    ].join("\n")
+    const candidate = [
+      "---",
+      "title: 不该写回",
+      "---",
+      "# 假标题",
+      "",
+      "新正文",
+    ].join("\n")
+
+    const written = replaceWholeChapterBody(current, candidate)
+    expect(extractDeAiChapterText(current)).toBe("旧正文")
+    expect(extractDeAiChapterText(candidate)).toBe("新正文")
+    expect(written).not.toContain("旧正文")
+    expect(written).toContain("新正文")
+    expect(written).toContain("# 第一章")
+    expect(written).not.toContain("不该写回")
+  })
+})

+ 15 - 0
src/lib/chapter-selection.ts

@@ -37,6 +37,21 @@ export function replaceWholeChapterBody(currentMarkdown: string, replacementMark
   return rawBlock + rebuildChapterBody(heading, replacementBody)
   return rawBlock + rebuildChapterBody(heading, replacementBody)
 }
 }
 
 
+/**
+ * 去 AI 味对比 / 送模只用“会被写回替换”的正文:去掉 frontmatter、章节标题行、代码围栏包装。
+ * 写回仍走 replaceWholeChapterBody,frontmatter 与原标题会保留。
+ */
+export function extractDeAiChapterText(markdown: string): string {
+  const { body } = parseFrontmatter(markdown)
+  const { body: prose } = splitChapterHeading(body)
+  let normalized = prose.replace(/\r\n?/g, "\n").trim()
+  const fenceMatch = normalized.match(/^```[a-zA-Z]*\s*\n([\s\S]*?)\n?```\s*$/)
+  if (fenceMatch?.[1]) {
+    normalized = fenceMatch[1].replace(/\r\n?/g, "\n").trim()
+  }
+  return normalized
+}
+
 export function buildPolishSelectionMessages(content: string): ChatMessage[] {
 export function buildPolishSelectionMessages(content: string): ChatMessage[] {
   if (!content.trim()) throw new Error("润色内容为空,无法处理")
   if (!content.trim()) throw new Error("润色内容为空,无法处理")
   return [
   return [