Ver Fonte

Merge branch 'aidagangxiugaiduibi' into main

Mochocyang há 2 meses atrás
pai
commit
d8fe9b41f6

+ 89 - 0
aidagangxiugaiduibi-分支说明.md

@@ -0,0 +1,89 @@
+# AI 大纲修改对比分支说明
+
+## 分支名称
+
+`aidagangxiugaiduibi`
+
+## 基线版本
+
+- 基线提交:`a6e3ee053856db7d6e04a4fc4e596224e05f67b7`
+- 创建日期:2026-07-21
+
+## 本分支目标
+
+1. 修复 AI 大纲用户确认后仍无法写入 `replace` 或 `patch` 请求的问题。
+2. 重做 AI 大纲确认保存界面,支持按文件选择、原始内容与最新内容逐行对比、变更高亮和最新内容编辑。
+3. 原始内容与最新内容双侧同步滚动。
+4. 增加“源码对比 / 渲染预览”查看模式。
+5. AI 去 AI 味确认流程复用相同的对比、编辑和预览体验。
+6. 所有大纲新建和后续保存统一使用纯 Markdown,只保留 Markdown 标题和正文,不再写入 YAML 前置信息。
+
+## 修改边界
+
+- 只修改与大纲创建、AI 大纲确认写入、AI 去 AI 味确认和共享差异编辑器直接相关的代码。
+- 不批量清理未被用户选择和确认的历史大纲文件。
+- 不修改章节等非大纲文件现有的 YAML 存储规则。
+- 不删除已有函数;如需改变行为,优先增加明确参数或复用现有入口。
+
+## 成功标准
+
+1. `replace`、`patch` 请求在用户确认后能够写入目标文件,未确认时仍禁止写入。
+2. 用户可按文件勾选、切换文件、编辑最新 Markdown 内容并确认保存。
+3. 源码模式逐行高亮差异,左右两侧同步滚动。
+4. 预览模式显示渲染后的 Markdown,不显示源码符号且保持双侧同步滚动。
+5. 保存成功后刷新左侧大纲树;失败时保留弹窗和用户编辑内容,并显示中文错误。
+6. 新建或再次保存的大纲文件不包含 YAML 前置信息。
+7. AI 大纲和 AI 去 AI 味旧功能测试、类型检查、构建与打包通过。
+
+## 更新记录
+
+### 2026-07-21
+
+- 创建独立分支。
+- 完成需求澄清和交互方案确认。
+- 记录写入失败根因和实施边界。
+
+## Git 状态
+
+- 提交状态:未提交
+- 合并状态:未合并
+
+
+### 2026-07-23
+
+- 修复 AI 大纲意图分析完成后,生成阶段重复触发意图分析导致外层请求循环的问题。
+- 自动续发仅允许从 `intent_analysis` 阶段进入一次生成;增加阶段闸门回归测试。
+- 已完成大纲相关回归、类型检查、源码启动检查、生产构建和便携版打包。
+- 当前版本沿用 `2.2.37`;Git 状态:未提交、未合并、未推送。
+
+### 2026-07-23 大纲保存交互
+
+- 普通大纲保存改用文件夹确认弹窗,不再显示源码对比内容。
+- 保留人物小传勾选、默认文件夹和 `confirmed: true` 保存保护。
+- 已完成保存交互回归、类型检查、源码启动、构建与便携版打包。
+- Git 状态:未提交、未合并、未推送。
+
+
+
+### 2026-07-23 去 AI 味逐行对比
+
+- 新增共享差异工作区,提供行数统计、源码对比、渲染预览、同步滚动和候选稿编辑。
+- 单次选区去 AI 味与批量/章节去 AI 味审核统一接入;AI 润色保持原交互。
+- 编辑后的候选稿会传入替换正文、另存草稿和批量确认写回;重新生成会丢弃当前章未确认编辑。
+- 普通大纲保存仍使用文件夹确认弹窗,不显示差异内容。
+- 专项测试 9 个文件、75 项通过;全量离线回归 4035 项通过、6 项 todo、4 项既有模型相关失败。
+- 类型检查、源码启动、生产构建和便携版打包通过;版本沿用 `2.2.37`。
+- Git 状态:未提交、未合并、未推送。
+
+### 2026-07-23 大纲保存与标题优化
+
+- 修复 AI 大纲保存后内容包含 JSON 格式块的问题,保存时增加 `stripStructuredMarkers` 和 `extractBodyContent` 过滤。
+- 修复 AI 大纲保存内容包含前置分析/前言文本的问题,新增 `stripPrefaceText` 函数自动定位大纲正文起始位置。
+- 优化大纲标题提取逻辑,无需再次调用模型,直接从内容中智能提取标题:
+  - 自动识别内容类型(章纲、卷纲、人物、设定、伏笔、组织、总纲)
+  - 章纲提取"第X章 章节标题"格式
+  - 卷纲提取"第X卷 卷标题"格式
+  - 人物设定优先提取姓名字段
+  - 多级回退机制确保总能生成合适标题
+- 大纲保存相关测试全部通过;类型检查无错误。
+- Git 状态:未提交、未合并、未推送。

+ 102 - 0
src/components/common/ai-change-compare-panel.spec.tsx

@@ -0,0 +1,102 @@
+// @vitest-environment jsdom
+
+import React, { act } from "react"
+import { createRoot, type Root } from "react-dom/client"
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
+
+vi.mock("./monaco-diff-editor", () => ({
+  MonacoDiffEditor: ({
+    originalValue,
+    modifiedValue,
+    onChange,
+    readOnly,
+  }: {
+    originalValue: string
+    modifiedValue: string
+    onChange: (value: string) => void
+    readOnly?: boolean
+  }) => (
+    <div>
+      <pre aria-label={"原始源码"}>{originalValue}</pre>
+      <textarea
+        aria-label={"最新源码"}
+        value={modifiedValue}
+        readOnly={readOnly}
+        onChange={(event) => onChange(event.target.value)}
+      />
+    </div>
+  ),
+}))
+
+import { AiChangeComparePanel, calculateAiChangeLineStats } from "./ai-change-compare-panel"
+
+;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+
+function findButton(label: string): HTMLButtonElement {
+  const button = Array.from(document.body.querySelectorAll("button"))
+    .find((item) => item.textContent?.includes(label))
+  if (!button) throw new Error(`button not found: ${label}`)
+  return button
+}
+
+describe("calculateAiChangeLineStats", () => {
+  it("counts inserted and deleted lines without shifting unchanged lines", () => {
+    expect(calculateAiChangeLineStats("A\nB", "X\nA\nB"))
+      .toEqual({ adds: 1, removes: 0 })
+    expect(calculateAiChangeLineStats("A\nB", "A"))
+      .toEqual({ adds: 0, removes: 1 })
+    expect(calculateAiChangeLineStats("", "A"))
+      .toEqual({ adds: 1, removes: 0 })
+  })
+
+  it("does not report CRLF and LF as content changes", () => {
+    expect(calculateAiChangeLineStats("A\r\nB", "A\nB"))
+      .toEqual({ adds: 0, removes: 0 })
+  })
+})
+
+describe("AiChangeComparePanel", () => {
+  let host: HTMLDivElement
+  let root: Root
+
+  beforeEach(() => {
+    host = document.createElement("div")
+    document.body.appendChild(host)
+    root = createRoot(host)
+  })
+
+  afterEach(async () => {
+    await act(async () => root.unmount())
+    host.remove()
+  })
+
+  it("shows line stats, edits the candidate, and switches to rendered preview", async () => {
+    const onModifiedContentChange = vi.fn()
+    await act(async () => {
+      root.render(
+        <AiChangeComparePanel
+          originalContent={"# old\nold paragraph"}
+          modifiedContent={"# new\nnew paragraph"}
+          onModifiedContentChange={onModifiedContentChange}
+        />,
+      )
+    })
+
+    expect(document.body.textContent).toContain("+2")
+    expect(document.body.textContent).toContain("-2")
+    const editor = document.body.querySelector<HTMLTextAreaElement>(`textarea[aria-label="${"最新源码"}"]`)
+    expect(editor).not.toBeNull()
+    await act(async () => {
+      const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set
+      setter?.call(editor, "# user-edited")
+      editor!.dispatchEvent(new Event("input", { bubbles: true }))
+    })
+    expect(onModifiedContentChange).toHaveBeenCalledWith("# user-edited")
+
+    await act(async () => findButton("渲染预览").click())
+    expect(document.body.textContent).toContain("原始内容预览")
+    expect(document.body.textContent).toContain("最新内容预览")
+    expect(document.body.textContent).toContain("old paragraph")
+    expect(document.body.textContent).toContain("new paragraph")
+  })
+})

+ 143 - 0
src/components/common/ai-change-compare-panel.tsx

@@ -0,0 +1,143 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from "react"
+import { Code, Eye } from "lucide-react"
+import ReactMarkdown from "react-markdown"
+import { MonacoDiffEditor } from "./monaco-diff-editor"
+import { computeLineDiff } from "@/lib/utils/diff"
+
+export interface AiChangeComparePanelProps {
+  originalContent: string
+  modifiedContent: string
+  onModifiedContentChange: (content: string) => void
+  editable?: boolean
+  originalLabel?: string
+  modifiedLabel?: string
+  resetKey?: string
+  className?: string
+}
+
+type ViewMode = "source" | "preview"
+
+export function calculateAiChangeLineStats(
+  originalContent: string,
+  modifiedContent: string,
+): { adds: number; removes: number } {
+  return computeLineDiff(originalContent, modifiedContent).reduce(
+    (stats, line) => {
+      if (line.type === "add") stats.adds += 1
+      else if (line.type === "remove") stats.removes += 1
+      return stats
+    },
+    { adds: 0, removes: 0 },
+  )
+}
+
+export function AiChangeComparePanel({
+  originalContent,
+  modifiedContent,
+  onModifiedContentChange,
+  editable = true,
+  originalLabel = "原始内容",
+  modifiedLabel = "最新内容",
+  resetKey,
+  className = "",
+}: AiChangeComparePanelProps) {
+  const [viewMode, setViewMode] = useState<ViewMode>("source")
+  const originalRef = useRef<HTMLDivElement>(null)
+  const modifiedRef = useRef<HTMLDivElement>(null)
+  const stats = useMemo(
+    () => calculateAiChangeLineStats(originalContent, modifiedContent),
+    [modifiedContent, originalContent],
+  )
+
+  useEffect(() => {
+    setViewMode("source")
+  }, [resetKey])
+
+  const syncScroll = useCallback((source: "original" | "modified") => {
+    if (viewMode !== "preview") return
+    const sourceElement = source === "original" ? originalRef.current : modifiedRef.current
+    const targetElement = source === "original" ? modifiedRef.current : originalRef.current
+    if (!sourceElement || !targetElement) return
+    const ratio = sourceElement.scrollTop / (sourceElement.scrollHeight - sourceElement.clientHeight || 1)
+    targetElement.scrollTop = ratio * (targetElement.scrollHeight - targetElement.clientHeight)
+  }, [viewMode])
+
+  return (
+    <section className={`flex h-full min-h-0 min-w-0 flex-col overflow-hidden ${className}`}>
+      <div className="flex shrink-0 flex-wrap items-center justify-between gap-2 border-b px-3 py-2">
+        <span className="text-xs text-muted-foreground">
+          <span className="text-green-600 dark:text-green-400">+{stats.adds}</span>
+          {" / "}
+          <span className="text-red-500">-{stats.removes}</span>
+          {" 行"}
+        </span>
+        <div className="flex rounded-md border text-xs">
+          <button
+            type="button"
+            onClick={() => setViewMode("source")}
+            className={`flex items-center gap-1 px-2 py-1 ${
+              viewMode === "source" ? "bg-accent font-medium" : "hover:bg-accent/50"
+            }`}
+          >
+            <Code className="h-3.5 w-3.5" />
+            {"源码对比"}
+          </button>
+          <button
+            type="button"
+            onClick={() => setViewMode("preview")}
+            className={`flex items-center gap-1 px-2 py-1 ${
+              viewMode === "preview" ? "bg-accent font-medium" : "hover:bg-accent/50"
+            }`}
+          >
+            <Eye className="h-3.5 w-3.5" />
+            {"渲染预览"}
+          </button>
+        </div>
+      </div>
+
+      <div className="min-h-0 flex-1 overflow-hidden">
+        {viewMode === "source" ? (
+          <MonacoDiffEditor
+            originalValue={originalContent}
+            modifiedValue={modifiedContent}
+            originalLabel={originalLabel}
+            modifiedLabel={modifiedLabel}
+            readOnly={!editable}
+            onChange={onModifiedContentChange}
+          />
+        ) : (
+          <div className="grid h-full min-h-0 grid-cols-1 overflow-y-auto md:grid-cols-2 md:divide-x md:overflow-hidden">
+            <div className="flex min-h-48 flex-col overflow-hidden border-b md:min-h-0 md:border-b-0">
+              <div className="shrink-0 border-b px-3 py-1 text-xs font-medium text-muted-foreground">
+                {originalLabel}{"预览"}
+              </div>
+              <div
+                ref={originalRef}
+                onScroll={() => syncScroll("original")}
+                className="prose prose-sm max-w-none flex-1 overflow-auto p-3 dark:prose-invert"
+              >
+                {originalContent.trim() ? (
+                  <ReactMarkdown>{originalContent}</ReactMarkdown>
+                ) : (
+                  <span className="text-muted-foreground italic">{"(暂无原始内容)"}</span>
+                )}
+              </div>
+            </div>
+            <div className="flex min-h-48 flex-col overflow-hidden md:min-h-0">
+              <div className="shrink-0 border-b px-3 py-1 text-xs font-medium text-green-700 dark:text-green-400">
+                {modifiedLabel}{"预览"}
+              </div>
+              <div
+                ref={modifiedRef}
+                onScroll={() => syncScroll("modified")}
+                className="prose prose-sm max-w-none flex-1 overflow-auto p-3 dark:prose-invert"
+              >
+                <ReactMarkdown>{modifiedContent}</ReactMarkdown>
+              </div>
+            </div>
+          </div>
+        )}
+      </div>
+    </section>
+  )
+}

+ 9 - 111
src/components/common/ai-change-review.tsx

@@ -1,7 +1,6 @@
-import { useState, useMemo, useCallback, useRef, useEffect } from "react"
-import { X, Check, FileText, Eye, Code } from "lucide-react"
-import ReactMarkdown from "react-markdown"
-import { MonacoDiffEditor } from "./monaco-diff-editor"
+import { useState, useMemo, useCallback, useEffect } from "react"
+import { X, Check, FileText } from "lucide-react"
+import { AiChangeComparePanel } from "./ai-change-compare-panel"
 
 export interface AiChangeReviewItem {
   id: string
@@ -23,8 +22,6 @@ interface AiChangeReviewProps {
   description?: string
 }
 
-type ViewMode = "source" | "preview"
-
 export function AiChangeReview({
   open,
   title,
@@ -35,16 +32,12 @@ export function AiChangeReview({
 }: AiChangeReviewProps) {
   const [items, setItems] = useState<AiChangeReviewItem[]>(initialItems)
   const [activeId, setActiveId] = useState<string | null>(null)
-  const [viewMode, setViewMode] = useState<ViewMode>("source")
   const [saving, setSaving] = useState(false)
-  const originalRef = useRef<HTMLDivElement>(null)
-  const modifiedRef = useRef<HTMLDivElement>(null)
 
   useEffect(() => {
     if (open) {
       setItems(initialItems)
       setActiveId(initialItems[0]?.id ?? null)
-      setViewMode("source")
       setSaving(false)
     }
   }, [open, initialItems])
@@ -59,23 +52,6 @@ export function AiChangeReview({
     [items],
   )
 
-  const stats = useMemo(() => {
-    if (!activeItem) return { adds: 0, removes: 0 }
-    const origLines = activeItem.originalContent.split("\n")
-    const modLines = activeItem.modifiedContent.split("\n")
-    const maxLen = Math.max(origLines.length, modLines.length)
-    let adds = 0
-    let removes = 0
-    for (let i = 0; i < maxLen; i++) {
-      const o = origLines[i]
-      const m = modLines[i]
-      if (o === undefined && m !== undefined) adds++
-      else if (o !== undefined && m === undefined) removes++
-      else if (o !== m) { adds++; removes++ }
-    }
-    return { adds, removes }
-  }, [activeItem])
-
   const handleModifiedChange = useCallback(
     (value: string) => {
       setItems((prev) =>
@@ -101,18 +77,6 @@ export function AiChangeReview({
     onConfirm(items.filter((item) => item.selected))
   }, [items, onConfirm, saving])
 
-  const syncScroll = useCallback(
-    (source: "original" | "modified") => {
-      if (viewMode !== "preview") return
-      const srcEl = source === "original" ? originalRef.current : modifiedRef.current
-      const dstEl = source === "original" ? modifiedRef.current : originalRef.current
-      if (!srcEl || !dstEl) return
-      const ratio = srcEl.scrollTop / (srcEl.scrollHeight - srcEl.clientHeight || 1)
-      dstEl.scrollTop = ratio * (dstEl.scrollHeight - dstEl.clientHeight)
-    },
-    [viewMode],
-  )
-
   if (!open) return null
 
   const isNew = !activeItem?.originalContent.trim()
@@ -132,38 +96,6 @@ export function AiChangeReview({
             </div>
           </div>
           <div className="flex items-center gap-3">
-            {activeItem && (
-              <span className="text-xs text-muted-foreground">
-                <span className="text-green-600 dark:text-green-400">+{stats.adds}</span>
-                {" / "}
-                <span className="text-red-500">-{stats.removes}</span>
-                {" 行"}
-              </span>
-            )}
-            <div className="flex rounded-md border text-xs">
-              <button
-                onClick={() => setViewMode("source")}
-                className={`flex items-center gap-1 px-2 py-1 ${
-                  viewMode === "source"
-                    ? "bg-accent font-medium"
-                    : "hover:bg-accent/50"
-                }`}
-              >
-                <Code className="h-3.5 w-3.5" />
-                源码对比
-              </button>
-              <button
-                onClick={() => setViewMode("preview")}
-                className={`flex items-center gap-1 px-2 py-1 ${
-                  viewMode === "preview"
-                    ? "bg-accent font-medium"
-                    : "hover:bg-accent/50"
-                }`}
-              >
-                <Eye className="h-3.5 w-3.5" />
-                渲染预览
-              </button>
-            </div>
             <button
               onClick={onClose}
               className="rounded-md p-1 hover:bg-accent"
@@ -225,46 +157,12 @@ export function AiChangeReview({
                   )}
                 </div>
 
-                {viewMode === "source" ? (
-                  <div className="flex-1 overflow-hidden">
-                    <MonacoDiffEditor
-                      originalValue={activeItem.originalContent || "(该文件尚不存在)"}
-                      modifiedValue={activeItem.modifiedContent}
-                      onChange={handleModifiedChange}
-                    />
-                  </div>
-                ) : (
-                  <div className="grid flex-1 grid-cols-2 divide-x overflow-hidden">
-                    <div className="flex flex-col overflow-hidden">
-                      <div className="border-b px-3 py-1 text-xs font-medium text-muted-foreground">
-                        原始内容预览
-                      </div>
-                      <div
-                        ref={originalRef}
-                        onScroll={() => syncScroll("original")}
-                        className="flex-1 overflow-auto p-3 prose prose-sm dark:prose-invert max-w-none"
-                      >
-                        {isNew ? (
-                          <span className="text-muted-foreground italic">(该文件尚不存在)</span>
-                        ) : (
-                          <ReactMarkdown>{activeItem.originalContent}</ReactMarkdown>
-                        )}
-                      </div>
-                    </div>
-                    <div className="flex flex-col overflow-hidden">
-                      <div className="border-b px-3 py-1 text-xs font-medium text-green-700 dark:text-green-400">
-                        最新内容预览
-                      </div>
-                      <div
-                        ref={modifiedRef}
-                        onScroll={() => syncScroll("modified")}
-                        className="flex-1 overflow-auto p-3 prose prose-sm dark:prose-invert max-w-none"
-                      >
-                        <ReactMarkdown>{activeItem.modifiedContent}</ReactMarkdown>
-                      </div>
-                    </div>
-                  </div>
-                )}
+                <AiChangeComparePanel
+                  originalContent={activeItem.originalContent}
+                  modifiedContent={activeItem.modifiedContent}
+                  onModifiedContentChange={handleModifiedChange}
+                  resetKey={activeItem.id}
+                />
               </>
             ) : (
               <div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">

+ 44 - 0
src/components/common/monaco-diff-editor.spec.tsx

@@ -0,0 +1,44 @@
+// @vitest-environment jsdom
+
+import { act } from "react"
+import { createRoot } from "react-dom/client"
+import { describe, expect, it, vi } from "vitest"
+import { MonacoDiffEditor } from "./monaco-diff-editor"
+
+;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+
+describe("MonacoDiffEditor", () => {
+  it("aligns inserted lines as a real line diff while keeping the candidate editable", () => {
+    const host = document.createElement("div")
+    document.body.appendChild(host)
+    const root = createRoot(host)
+    const onChange = vi.fn()
+
+    act(() => {
+      root.render(
+        <MonacoDiffEditor
+          originalValue={"A\nB"}
+          modifiedValue={"X\nA\nB"}
+          onChange={onChange}
+        />,
+      )
+    })
+
+    expect(document.body.querySelectorAll('[data-diff-type="add"]')).toHaveLength(1)
+    expect(document.body.querySelectorAll('[data-diff-type="remove"]')).toHaveLength(0)
+    expect(document.body.querySelectorAll('[data-diff-type="unchanged"]')).toHaveLength(2)
+    expect(document.body.querySelector('[data-diff-type="add"]')?.textContent).toContain("X")
+
+    const editor = document.body.querySelector<HTMLTextAreaElement>('textarea[aria-label="最新源码"]')
+    expect(editor?.value).toBe("X\nA\nB")
+    act(() => {
+      const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set
+      setter?.call(editor, "edited")
+      editor!.dispatchEvent(new Event("input", { bubbles: true }))
+    })
+    expect(onChange).toHaveBeenCalledWith("edited")
+
+    act(() => root.unmount())
+    host.remove()
+  })
+})

+ 45 - 9
src/components/common/monaco-diff-editor.tsx

@@ -1,19 +1,30 @@
-import { useEffect, useRef } from "react"
+import { useEffect, useMemo, useRef } from "react"
+import { computeLineDiff } from "@/lib/utils/diff"
 
 interface MonacoDiffEditorProps {
   originalValue: string
   modifiedValue: string
   onChange: (value: string) => void
   language?: string
+  originalLabel?: string
+  modifiedLabel?: string
+  readOnly?: boolean
 }
 
 export function MonacoDiffEditor({
   originalValue,
   modifiedValue,
   onChange,
+  originalLabel = "原始内容",
+  modifiedLabel = "最新内容",
+  readOnly = false,
 }: MonacoDiffEditorProps) {
   const textareaRef = useRef<HTMLTextAreaElement | null>(null)
   const preventSync = useRef(false)
+  const diffLines = useMemo(
+    () => computeLineDiff(originalValue, modifiedValue),
+    [modifiedValue, originalValue],
+  )
 
   useEffect(() => {
     const textarea = textareaRef.current
@@ -37,24 +48,49 @@ export function MonacoDiffEditor({
   }, [onChange])
 
   return (
-    <div className="grid h-full grid-cols-2 divide-x overflow-hidden">
-      <div className="flex flex-col overflow-hidden">
+    <div className="grid h-full min-h-0 grid-cols-1 overflow-y-auto md:grid-cols-2 md:divide-x md:overflow-hidden">
+      <div className="flex min-h-48 flex-col overflow-hidden border-b md:min-h-0 md:border-b-0">
         <div className="border-b bg-muted/40 px-3 py-1 text-xs font-medium text-muted-foreground">
-          原始内容
+          {originalLabel} / 逐行差异
+        </div>
+        <div
+          aria-label="逐行差异"
+          className="min-h-0 flex-1 overflow-auto py-2 font-mono text-xs leading-relaxed"
+        >
+          {diffLines.length > 0 ? diffLines.map((line, index) => (
+            <div
+              key={`${line.type}-${line.originalLine ?? 0}-${line.modifiedLine ?? 0}-${index}`}
+              data-diff-type={line.type}
+              className={`grid min-w-max grid-cols-[2.5rem_2.5rem_1.5rem_minmax(12rem,1fr)] px-2 py-0.5 ${
+                line.type === "add"
+                  ? "bg-green-100 text-green-800 dark:bg-green-950/50 dark:text-green-300"
+                  : line.type === "remove"
+                    ? "bg-red-100 text-red-800 dark:bg-red-950/50 dark:text-red-300"
+                    : "text-muted-foreground"
+              }`}
+            >
+              <span className="select-none text-right opacity-50">{line.originalLine ?? ""}</span>
+              <span className="select-none text-right opacity-50">{line.modifiedLine ?? ""}</span>
+              <span className="select-none text-center opacity-70">
+                {line.type === "add" ? "+" : line.type === "remove" ? "-" : " "}
+              </span>
+              <span className="whitespace-pre-wrap break-words pl-2">{line.content || " "}</span>
+            </div>
+          )) : (
+            <div className="px-3 py-2 text-muted-foreground">暂无差异</div>
+          )}
         </div>
-        <pre className="flex-1 overflow-auto whitespace-pre-wrap break-words p-3 text-xs leading-relaxed text-muted-foreground">
-          {originalValue || "(该文件尚不存在)"}
-        </pre>
       </div>
-      <div className="flex flex-col overflow-hidden">
+      <div className="flex min-h-48 flex-col overflow-hidden md:min-h-0">
         <div className="border-b bg-muted/40 px-3 py-1 text-xs font-medium text-green-700 dark:text-green-400">
-          最新内容
+          {modifiedLabel}{readOnly ? "" : "(可编辑)"}
         </div>
         <textarea
           ref={textareaRef}
           aria-label="最新源码"
           className="flex-1 resize-none overflow-auto whitespace-pre-wrap break-words bg-transparent p-3 font-mono text-xs leading-relaxed outline-none"
           spellCheck={false}
+          readOnly={readOnly}
         />
       </div>
     </div>

+ 2 - 0
src/components/layout/preview-panel.test.tsx

@@ -31,6 +31,8 @@ describe("preview-panel final chapter save state", () => {
     expect(source).toContain("TextTransformPreviewDialog")
     expect(source).toContain("handleSelectionAction")
     expect(source).toContain("replaceChapterBodySelection")
+    expect(source).toContain('comparisonMode={selectionTransformAction === "de-ai"}')
+    expect(source).toContain('onCandidateContentChange={selectionTransformAction === "de-ai" ? setSelectionTransformCandidateContent : undefined}')
   })
 
   it("shows the actual skill used by de-AI actions", () => {

+ 7 - 4
src/components/layout/preview-panel.tsx

@@ -1771,11 +1771,12 @@ export function PreviewPanel() {
              currentChapterId={currentReviewChapterId}
              pending={deAiDraftSaving}
             onSelectChapter={(id) => useDeAiTaskStore.getState().setReviewChapter(project.path, id)}
-             onConfirm={async (_taskId, chapterId) => {
+             onConfirm={async (_taskId, chapterId, candidateContent) => {
               const task = readyTasks.find((t) => t.id === chapterId)
               if (!task) return
               try {
-                await applyDeAiBatchChapter(task.chapterPath, task.candidateContent)
+                await applyDeAiBatchChapter(task.chapterPath, candidateContent)
+                useDeAiTaskStore.getState().updateTask(chapterId, { candidateContent })
                 useDeAiTaskStore.getState().confirmTask(chapterId)
                 useDeAiTaskStore.getState().closeReview(project.path)
                 toast.success(`${task.chapterTitle} 去AI味结果已保存`)
@@ -1784,10 +1785,10 @@ export function PreviewPanel() {
                 toast.error(`保存失败:${err instanceof Error ? err.message : String(err)}`)
                }
              }}
-             onSaveDraft={async (_taskId, chapterId) => {
+             onSaveDraft={async (_taskId, chapterId, candidateContent) => {
                const task = readyTasks.find((item) => item.id === chapterId)
                if (!task) return
-               await handleDeAiSaveDraft(task.chapterPath, task.candidateContent)
+               await handleDeAiSaveDraft(task.chapterPath, candidateContent)
              }}
              onRegenerate={async (_taskId, chapterId) => {
               const task = readyTasks.find((t) => t.id === chapterId)
@@ -1839,6 +1840,8 @@ export function PreviewPanel() {
         candidateLabel={selectionTransformAction === "polish" ? "润色结果" : "去AI味结果"}
         sourceContent={selectionTransformSourceContent}
         candidateContent={selectionTransformCandidateContent}
+        comparisonMode={selectionTransformAction === "de-ai"}
+        onCandidateContentChange={selectionTransformAction === "de-ai" ? setSelectionTransformCandidateContent : undefined}
         applyLabel="替换选中文本"
         onApply={handleApplySelectionTransform}
         onClose={handleCloseSelectionTransform}

+ 3 - 1
src/components/novel/de-ai-batch-entry.spec.ts

@@ -14,7 +14,8 @@ describe("de-ai batch entry and settings", () => {
     expect(previewPanel).toContain("runWholeChapterDeAi")
     expect(previewPanel).toContain("registerEditorExternalUpdateHandler")
     expect(previewPanel).toContain("createDeAiBatchChapterApplier")
-    expect(previewPanel).toContain("await applyDeAiBatchChapter(task.chapterPath, task.candidateContent)")
+    expect(previewPanel).toContain("await applyDeAiBatchChapter(task.chapterPath, candidateContent)")
+    expect(previewPanel).toContain("updateTask(chapterId, { candidateContent })")
     expect(previewPanel).toContain("useDeAiTaskStore.getState().closeReview(project.path)")
     expect(previewPanel).toContain("saveDeAiDraftWithoutOverwrite")
     expect(previewPanel).toContain("writeFileIfAbsent")
@@ -25,6 +26,7 @@ describe("de-ai batch entry and settings", () => {
     expect(batchWorkspace).toContain("}, [novelConfig.deAiBatchConcurrency])") // CONCURRENCY_EFFECT_MARKER
     expect(batchWorkspace).toContain("startingRef.current")
     expect(batchWorkspace).toContain("runWorkspaceAction")
+    expect(batchWorkspace).toContain("confirmChapter(taskId, chapterId, candidateContent)")
     expect(batchWorkspace).toContain("createLatestValueSaveQueue")
     expect(batchWorkspace).not.toContain('runWorkspaceAction("config:concurrency"')
     expect(batchWorkspace).toContain("pendingTaskIds.has(reviewTaskId)")

+ 36 - 20
src/components/novel/de-ai-batch-review-dialog.spec.tsx

@@ -127,10 +127,13 @@ describe("DeAiBatchReviewDialog", () => {
     render()
 
     expect(document.body.textContent).toContain("第一章原文")
-    expect(document.body.textContent).toContain("第一章候选")
+    expect(document.body.querySelector<HTMLTextAreaElement>(`textarea[aria-label="${"\u6700\u65b0\u6e90\u7801"}"]`)?.value).toBe("\u7b2c\u4e00\u7ae0\u5019\u9009")
     const desktop = document.body.querySelector('[data-testid="de-ai-review-desktop"]')
     expect(desktop?.className).toContain("md:flex")
-    expect(desktop?.querySelectorAll('[data-testid="de-ai-review-scroll"]')).toHaveLength(3)
+    expect(desktop?.querySelectorAll('[data-testid="de-ai-review-scroll"]')).toHaveLength(1)
+    expect(document.body.textContent).toContain("\u6e90\u7801\u5bf9\u6bd4")
+    expect(document.body.textContent).toContain("\u6e32\u67d3\u9884\u89c8")
+    expect(document.body.querySelector('textarea[aria-label="\u6700\u65b0\u6e90\u7801"]')).not.toBeNull()
   })
 
   it("弹窗受视口约束且头部、正文、底部不越界", () => {
@@ -163,7 +166,7 @@ describe("DeAiBatchReviewDialog", () => {
     vi.spyOn(window, "confirm").mockReturnValue(true)
     act(() => button("取消当前章").click())
 
-    expect(props.onConfirm).toHaveBeenCalledWith("task-a", "chapter-1")
+    expect(props.onConfirm).toHaveBeenCalledWith("task-a", "chapter-1", "\u7b2c\u4e00\u7ae0\u5019\u9009")
     expect(props.onRegenerate).toHaveBeenCalledWith("task-a", "chapter-1")
     expect(window.confirm).toHaveBeenCalledWith("确定取消“第一章”的本次处理吗?原文不会被修改。")
     expect(props.onCancelChapter).toHaveBeenCalledWith("task-a", "chapter-1")
@@ -174,13 +177,34 @@ describe("DeAiBatchReviewDialog", () => {
 
     act(() => button("另存草稿").click())
 
-    expect(props.onSaveDraft).toHaveBeenCalledWith("task-a", "chapter-1")
+    expect(props.onSaveDraft).toHaveBeenCalledWith("task-a", "chapter-1", "\u7b2c\u4e00\u7ae0\u5019\u9009")
+  })
+
+  it("passes the edited candidate to confirm and keeps it while switching chapters", () => {
+    const props = callbacks()
+    const source = record()
+    render("chapter-1", props, source)
+    const editor = document.body.querySelector<HTMLTextAreaElement>('textarea[aria-label="\u6700\u65b0\u6e90\u7801"]')
+    expect(editor).not.toBeNull()
+    act(() => {
+      const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set
+      setter?.call(editor, "user-edited candidate")
+      editor!.dispatchEvent(new Event("input", { bubbles: true }))
+    })
+    act(() => button("\u786e\u8ba4\u5f53\u524d\u7ae0").click())
+    expect(props.onConfirm).toHaveBeenCalledWith("task-a", "chapter-1", "user-edited candidate")
+
+    render("chapter-2", props, source)
+    render("chapter-1", props, source)
+    expect(document.body.querySelector<HTMLTextAreaElement>('textarea[aria-label="\u6700\u65b0\u6e90\u7801"]')?.value)
+      .toBe("user-edited candidate")
   })
 
   it("生成中保留旧候选并禁用确认和重复重新生成", () => {
     render("chapter-2")
 
-    expect(document.body.textContent).toContain("第二章旧候选")
+    expect(document.body.querySelector<HTMLTextAreaElement>(`textarea[aria-label="${"\u6700\u65b0\u6e90\u7801"}"]`)?.value).toBe("\u7b2c\u4e8c\u7ae0\u65e7\u5019\u9009")
+    expect(document.body.querySelector<HTMLTextAreaElement>(`textarea[aria-label="${"\u6700\u65b0\u6e90\u7801"}"]`)?.readOnly).toBe(true)
     expect(document.body.textContent).toContain("生成中")
     expect(button("确认当前章").disabled).toBe(true)
     expect(button("重新生成").disabled).toBe(true)
@@ -195,22 +219,14 @@ describe("DeAiBatchReviewDialog", () => {
     expect(props.onCancelChapter).not.toHaveBeenCalled()
   })
 
-  it("移动端 tabs 具备完整 ARIA 关联、roving tabindex 与左右键切换", () => {
+  it("mobile review keeps chapter selection and shared source/preview controls", () => {
     render()
-    const tabs = Array.from(document.body.querySelectorAll<HTMLButtonElement>('[role="tab"]'))
-    const panels = Array.from(document.body.querySelectorAll<HTMLElement>('[role="tabpanel"]'))
-    expect(tabs).toHaveLength(2)
-    expect(panels).toHaveLength(1)
-    expect(tabs[0]).toMatchObject({ tabIndex: 0 })
-    expect(tabs[1]).toMatchObject({ tabIndex: -1 })
-    expect(tabs[0].getAttribute("aria-controls")).toBe(panels[0].id)
-    expect(panels[0].getAttribute("aria-labelledby")).toBe(tabs[0].id)
-    act(() => tabs[0].dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })))
-    expect(tabs[1].getAttribute("aria-selected")).toBe("true")
-    expect(document.activeElement).toBe(tabs[1])
-    expect(panels[0].getAttribute("aria-labelledby")).toBe(tabs[1].id)
-    act(() => tabs[1].dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowLeft", bubbles: true })))
-    expect(tabs[0].getAttribute("aria-selected")).toBe("true")
+    const mobile = document.body.querySelector('[data-testid="de-ai-review-mobile"]')
+    expect(mobile?.querySelector('[aria-label="\u9009\u62e9\u5ba1\u6838\u7ae0\u8282"]')).not.toBeNull()
+    expect(button("\u6e90\u7801\u5bf9\u6bd4")).toBeTruthy()
+    act(() => button("\u6e32\u67d3\u9884\u89c8").click())
+    expect(document.body.textContent).toContain("\u539f\u6587\u9884\u89c8")
+    expect(document.body.textContent).toContain("\u5019\u9009\u9884\u89c8")
   })
 
   it("极小高度可滚动到操作区且 pending 时禁用章节操作", () => {

+ 85 - 77
src/components/novel/de-ai-batch-review-dialog.tsx

@@ -1,4 +1,4 @@
-import { useState } from "react"
+import { useEffect, useState } from "react"
 import { Button } from "@/components/ui/button"
 import {
   Dialog,
@@ -13,6 +13,7 @@ import {
   ResizablePanel,
   ResizablePanelGroup,
 } from "@/components/ui/resizable"
+import { AiChangeComparePanel } from "@/components/common/ai-change-compare-panel"
 import type { DeAiBatchChapter, DeAiBatchTaskRecord } from "@/lib/novel/de-ai-batch/types"
 
 const CHAPTER_STATUS: Record<DeAiBatchChapter["status"], string> = {
@@ -30,8 +31,8 @@ export interface DeAiBatchReviewDialogProps {
   currentChapterId: string | null
   pending?: boolean
   onSelectChapter(chapterId: string): void
-  onConfirm(taskId: string, chapterId: string): void
-  onSaveDraft?(taskId: string, chapterId: string): void
+  onConfirm(taskId: string, chapterId: string, candidateContent: string): void
+  onSaveDraft?(taskId: string, chapterId: string, candidateContent: string): void
   onRegenerate(taskId: string, chapterId: string): void
   onCancelChapter(taskId: string, chapterId: string): void
   onClose(): void
@@ -65,17 +66,6 @@ function ChapterList({
   )
 }
 
-function TextPane({ title, content, emptyText }: { title: string; content: string | null; emptyText: string }) {
-  return (
-    <div className="flex h-full min-h-0 flex-col">
-      <div className="shrink-0 border-b px-3 py-2 text-xs font-medium text-muted-foreground">{title}</div>
-      <div data-testid="de-ai-review-scroll" className="min-h-0 flex-1 overflow-y-auto p-4 text-sm leading-7 whitespace-pre-wrap">
-        {content || <span className="text-muted-foreground">{emptyText}</span>}
-      </div>
-    </div>
-  )
-}
-
 export function DeAiBatchReviewDialog({
   open,
   record,
@@ -88,20 +78,64 @@ export function DeAiBatchReviewDialog({
   onCancelChapter,
   onClose,
 }: DeAiBatchReviewDialogProps) {
-  const [mobileTab, setMobileTab] = useState<"source" | "candidate">("source")
-  const chapter = record?.chapters.find((item) => item.id === currentChapterId) ?? record?.chapters[0] ?? null
-  const canConfirm = !pending && chapter?.status === "ready" && !!chapter.candidateContent
-  const canSaveDraft = !pending && !!onSaveDraft && !!chapter?.candidateContent && chapter.status !== "generating"
-  const canRegenerate = !pending && !!chapter && chapter.status !== "generating" && chapter.status !== "confirmed"
-  const canCancel = !pending && !!chapter && chapter.status !== "confirmed" && chapter.status !== "cancelled"
+  const [candidateDrafts, setCandidateDrafts] = useState<
+    Record<string, { generation: number; content: string }>
+  >({})
+
+  useEffect(() => {
+    setCandidateDrafts({})
+  }, [open, record?.task.id])
+
+  const chapter = record?.chapters.find((item) => item.id === currentChapterId)
+    ?? record?.chapters[0]
+    ?? null
+  const candidateDraft = chapter ? candidateDrafts[chapter.id] : undefined
+  const candidateContent = chapter && candidateDraft?.generation === chapter.generation
+    ? candidateDraft.content
+    : chapter?.candidateContent ?? ""
+  const candidateEditable = !pending && chapter?.status === "ready"
+  const canConfirm = candidateEditable && !!candidateContent.trim()
+  const canSaveDraft = !pending
+    && !!onSaveDraft
+    && !!candidateContent.trim()
+    && chapter?.status !== "generating"
+  const canRegenerate = !pending
+    && !!chapter
+    && chapter.status !== "generating"
+    && chapter.status !== "confirmed"
+  const canCancel = !pending
+    && !!chapter
+    && chapter.status !== "confirmed"
+    && chapter.status !== "cancelled"
+
+  function handleCandidateContentChange(content: string): void {
+    if (!chapter || !candidateEditable) return
+    setCandidateDrafts((current) => ({
+      ...current,
+      [chapter.id]: { generation: chapter.generation, content },
+    }))
+  }
 
-  function handleMobileTabKey(event: React.KeyboardEvent<HTMLButtonElement>): void {
-    if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return
-    event.preventDefault()
-    const nextTab = mobileTab === "source" ? "candidate" : "source"
-    setMobileTab(nextTab)
-    document.getElementById(`de-ai-review-tab-${nextTab}`)?.focus()
+  function clearCandidateDraft(chapterId: string): void {
+    setCandidateDrafts((current) => {
+      const next = { ...current }
+      delete next[chapterId]
+      return next
+    })
   }
+
+  const comparisonPanel = (
+    <AiChangeComparePanel
+      originalContent={chapter?.sourceContent ?? ""}
+      modifiedContent={candidateContent}
+      originalLabel={"原文"}
+      modifiedLabel={"候选"}
+      editable={candidateEditable}
+      resetKey={chapter ? `${chapter.id}:${chapter.generation}` : undefined}
+      onModifiedContentChange={handleCandidateContentChange}
+    />
+  )
+
   return (
     <Dialog open={open} onOpenChange={(next) => { if (!next) onClose() }}>
       <DialogContent
@@ -110,7 +144,7 @@ export function DeAiBatchReviewDialog({
         className="flex h-[min(52rem,calc(100dvh-2rem))] max-h-[calc(100dvh-2rem)] w-[min(90rem,calc(100vw-2rem))] max-w-[calc(100vw-2rem)] flex-col gap-0 overflow-x-hidden overflow-y-auto p-0 sm:max-w-[calc(100vw-2rem)]"
       >
         <DialogHeader className="shrink-0 border-b px-4 py-3">
-          <DialogTitle>批量去 AI 味审核</DialogTitle>
+          <DialogTitle>{"批量去 AI 味审核"}</DialogTitle>
           <DialogDescription>
             {record?.task.workTitle ?? "未选择作品"}
             {chapter ? ` · ${chapter.title} · ${CHAPTER_STATUS[chapter.status]}` : ""}
@@ -123,78 +157,43 @@ export function DeAiBatchReviewDialog({
               <ChapterList record={record} currentChapterId={chapter?.id ?? null} onSelectChapter={onSelectChapter} />
             </ResizablePanel>
             <ResizableHandle withHandle />
-            <ResizablePanel defaultSize={40} minSize={24}>
-              <TextPane title="原文" content={chapter?.sourceContent ?? null} emptyText="暂无原文副本" />
-            </ResizablePanel>
-            <ResizableHandle withHandle />
-            <ResizablePanel defaultSize={40} minSize={24}>
-              <TextPane title="候选" content={chapter?.candidateContent ?? null} emptyText="候选尚未生成" />
+            <ResizablePanel defaultSize={80} minSize={40}>
+              {comparisonPanel}
             </ResizablePanel>
           </ResizablePanelGroup>
 
           <div data-testid="de-ai-review-mobile" className="flex h-full min-h-0 flex-col md:hidden">
             <div className="shrink-0 space-y-2 border-b p-3">
               <label className="block text-xs font-medium text-muted-foreground" htmlFor="de-ai-review-chapter-select">
-                审核章节
+                {"审核章节"}
               </label>
               <select
                 id="de-ai-review-chapter-select"
-                aria-label="选择审核章节"
+                aria-label={"选择审核章节"}
                 disabled={pending}
                 className="h-9 w-full rounded-md border bg-background px-2 text-sm"
                 value={chapter?.id ?? ""}
                 onChange={(event) => onSelectChapter(event.target.value)}
               >
                 {record?.chapters.map((item) => (
-                  <option key={item.id} value={item.id}>{item.title} · {CHAPTER_STATUS[item.status]}</option>
+                  <option key={item.id} value={item.id}>{item.title} {"·"} {CHAPTER_STATUS[item.status]}</option>
                 ))}
               </select>
-              <div className="grid grid-cols-2 rounded-md bg-muted p-1" role="tablist" aria-label="审核内容">
-                <button
-                  id="de-ai-review-tab-source"
-                  type="button"
-                  role="tab"
-                  aria-controls="de-ai-review-tabpanel"
-                  aria-selected={mobileTab === "source"}
-                  tabIndex={mobileTab === "source" ? 0 : -1}
-                  onKeyDown={handleMobileTabKey}
-                  className={`rounded px-2 py-1.5 text-sm ${mobileTab === "source" ? "bg-background shadow-sm" : ""}`}
-                  onClick={() => setMobileTab("source")}
-                >
-                  原文
-                </button>
-                <button
-                  id="de-ai-review-tab-candidate"
-                  type="button"
-                  role="tab"
-                  aria-controls="de-ai-review-tabpanel"
-                  aria-selected={mobileTab === "candidate"}
-                  tabIndex={mobileTab === "candidate" ? 0 : -1}
-                  onKeyDown={handleMobileTabKey}
-                  className={`rounded px-2 py-1.5 text-sm ${mobileTab === "candidate" ? "bg-background shadow-sm" : ""}`}
-                  onClick={() => setMobileTab("candidate")}
-                >
-                  候选
-                </button>
-              </div>
             </div>
             <div
               id="de-ai-review-tabpanel"
               role="tabpanel"
-              aria-labelledby={`de-ai-review-tab-${mobileTab}`}
               tabIndex={0}
               data-testid="de-ai-review-scroll"
-              className="min-h-0 flex-1 overflow-y-auto p-4 text-sm leading-7 whitespace-pre-wrap"
+              className="min-h-0 flex-1 overflow-hidden"
             >
-              {mobileTab === "source"
-                ? chapter?.sourceContent || "暂无原文副本"
-                : chapter?.candidateContent || "候选尚未生成"}
+              {comparisonPanel}
             </div>
           </div>
         </div>
 
         <DialogFooter className="mx-0 mb-0 shrink-0 rounded-none px-4 py-3">
-          <Button type="button" variant="outline" onClick={onClose}>关闭</Button>
+          <Button type="button" variant="outline" onClick={onClose}>{"关闭"}</Button>
           <Button
             type="button"
             variant="outline"
@@ -202,36 +201,45 @@ export function DeAiBatchReviewDialog({
             onClick={() => {
               if (!record || !chapter) return
               if (window.confirm(`确定取消“${chapter.title}”的本次处理吗?原文不会被修改。`)) {
+                clearCandidateDraft(chapter.id)
                 onCancelChapter(record.task.id, chapter.id)
               }
             }}
           >
-            取消当前章
+            {"取消当前章"}
           </Button>
           <Button
             type="button"
             variant="outline"
             disabled={!canRegenerate}
-            onClick={() => { if (record && chapter) onRegenerate(record.task.id, chapter.id) }}
+            onClick={() => {
+              if (!record || !chapter) return
+              clearCandidateDraft(chapter.id)
+              onRegenerate(record.task.id, chapter.id)
+            }}
           >
-            重新生成
+            {"重新生成"}
           </Button>
           {onSaveDraft ? (
             <Button
               type="button"
               variant="outline"
               disabled={!canSaveDraft}
-              onClick={() => { if (record && chapter) onSaveDraft(record.task.id, chapter.id) }}
+              onClick={() => {
+                if (record && chapter) onSaveDraft(record.task.id, chapter.id, candidateContent)
+              }}
             >
-              另存草稿
+              {"另存草稿"}
             </Button>
           ) : null}
           <Button
             type="button"
             disabled={!canConfirm}
-            onClick={() => { if (record && chapter) onConfirm(record.task.id, chapter.id) }}
+            onClick={() => {
+              if (record && chapter) onConfirm(record.task.id, chapter.id, candidateContent)
+            }}
           >
-            确认当前章
+            {"确认当前章"}
           </Button>
         </DialogFooter>
       </DialogContent>

+ 1 - 1
src/components/novel/de-ai-batch-workspace.tsx

@@ -196,7 +196,7 @@ export function DeAiBatchWorkspace() {
         currentChapterId={reviewChapterId}
         pending={reviewPending}
         onSelectChapter={(chapterId) => useDeAiBatchStore.getState().setReviewChapter(chapterId)}
-        onConfirm={(taskId, chapterId) => runWorkspaceAction(`chapter:${taskId}:${chapterId}`, "确认当前章节", () => useDeAiBatchStore.getState().confirmChapter(taskId, chapterId))}
+        onConfirm={(taskId, chapterId, candidateContent) => runWorkspaceAction(`chapter:${taskId}:${chapterId}`, "确认当前章节", () => useDeAiBatchStore.getState().confirmChapter(taskId, chapterId, candidateContent))}
         onRegenerate={(taskId, chapterId) => runWorkspaceAction(`chapter:${taskId}:${chapterId}`, "重新生成当前章节", () => useDeAiBatchStore.getState().regenerateChapter(taskId, chapterId))}
         onCancelChapter={(taskId, chapterId) => runWorkspaceAction(`chapter:${taskId}:${chapterId}`, "取消当前章节", () => useDeAiBatchStore.getState().cancelChapter(taskId, chapterId))}
         onClose={() => useDeAiBatchStore.getState().closeReview()}

+ 28 - 42
src/components/novel/de-ai-preview-dialog.tsx

@@ -1,11 +1,5 @@
-import {
-  Dialog,
-  DialogContent,
-  DialogHeader,
-  DialogTitle,
-  DialogFooter,
-} from "@/components/ui/dialog"
-import { Button } from "@/components/ui/button"
+import { useEffect, useState } from "react"
+import { TextTransformPreviewDialog } from "./text-transform-preview-dialog"
 
 export interface DeAiPreviewDialogProps {
   open: boolean
@@ -13,8 +7,8 @@ export interface DeAiPreviewDialogProps {
   candidateContent: string
   skillName?: string
   modelName?: string
-  onApply: () => void
-  onSaveDraft: () => void
+  onApply: (candidateContent?: string) => void
+  onSaveDraft: (candidateContent?: string) => void
   onClose: () => void
 }
 
@@ -28,38 +22,30 @@ export function DeAiPreviewDialog({
   onSaveDraft,
   onClose,
 }: DeAiPreviewDialogProps) {
+  const [draftContent, setDraftContent] = useState(candidateContent)
+
+  useEffect(() => {
+    if (open) setDraftContent(candidateContent)
+  }, [candidateContent, open])
+
   return (
-    <Dialog open={open} onOpenChange={(next) => { if (!next) onClose() }}>
-      <DialogContent className="sm:max-w-3xl">
-        <DialogHeader>
-          <DialogTitle>去AI味预览</DialogTitle>
-          {skillName ? (
-            <div className="text-xs text-muted-foreground">
-              本次使用 Skill:{skillName}
-              {modelName ? `,模型:${modelName}` : ""}
-            </div>
-          ) : null}
-        </DialogHeader>
-        <div className="grid grid-cols-2 gap-4">
-          <div className="flex flex-col gap-2">
-            <div className="text-xs font-medium text-muted-foreground">原文</div>
-            <div className="max-h-96 overflow-y-auto rounded-md border bg-muted/20 p-3 text-sm leading-6 whitespace-pre-wrap">
-              {sourceContent}
-            </div>
-          </div>
-          <div className="flex flex-col gap-2">
-            <div className="text-xs font-medium text-muted-foreground">去AI味稿</div>
-            <div className="max-h-96 overflow-y-auto rounded-md border bg-muted/20 p-3 text-sm leading-6 whitespace-pre-wrap">
-              {candidateContent}
-            </div>
-          </div>
-        </div>
-        <DialogFooter>
-          <Button variant="outline" onClick={onClose}>取消</Button>
-          <Button variant="outline" onClick={onSaveDraft}>另存草稿</Button>
-          <Button onClick={onApply}>替换正文</Button>
-        </DialogFooter>
-      </DialogContent>
-    </Dialog>
+    <TextTransformPreviewDialog
+      open={open}
+      title={"去AI味预览"}
+      description={skillName
+        ? `本次使用 Skill:${skillName}${modelName ? `,模型:${modelName}` : ""}`
+        : undefined}
+      sourceLabel={"原文"}
+      candidateLabel={"去AI味稿"}
+      sourceContent={sourceContent}
+      candidateContent={draftContent}
+      comparisonMode
+      onCandidateContentChange={setDraftContent}
+      applyLabel={"替换正文"}
+      secondaryActionLabel={"另存草稿"}
+      onApply={() => onApply(draftContent)}
+      onSecondaryAction={() => onSaveDraft(draftContent)}
+      onClose={onClose}
+    />
   )
 }

+ 40 - 0
src/components/novel/text-transform-preview-dialog.spec.tsx

@@ -37,4 +37,44 @@ describe("TextTransformPreviewDialog", () => {
     act(() => root.unmount())
     document.body.removeChild(container)
   })
+
+  it("renders the shared editable comparison workspace when comparison mode is enabled", () => {
+    const container = document.createElement("div")
+    document.body.appendChild(container)
+    const root = createRoot(container)
+    const onCandidateContentChange = vi.fn()
+
+    act(() => {
+      root.render(
+        <TextTransformPreviewDialog
+          open
+          title={"\u53bbAI\u5473\u9884\u89c8"}
+          sourceLabel={"\u539f\u6587\u7247\u6bb5"}
+          candidateLabel={"\u53bbAI\u5473\u7ed3\u679c"}
+          sourceContent="old paragraph"
+          candidateContent="new paragraph"
+          applyLabel={"\u66ff\u6362\u9009\u4e2d\u6587\u672c"}
+          comparisonMode
+          onApply={() => {}}
+          onClose={() => {}}
+          onCandidateContentChange={onCandidateContentChange}
+        />,
+      )
+    })
+
+    expect(document.body.textContent).toContain("\u6e90\u7801\u5bf9\u6bd4")
+    expect(document.body.textContent).toContain("\u6e32\u67d3\u9884\u89c8")
+    const editor = document.body.querySelector<HTMLTextAreaElement>('textarea[aria-label="\u6700\u65b0\u6e90\u7801"]')
+    expect(editor).not.toBeNull()
+    act(() => {
+      const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set
+      setter?.call(editor, "user-edited paragraph")
+      editor!.dispatchEvent(new Event("input", { bubbles: true }))
+    })
+    expect(onCandidateContentChange).toHaveBeenCalledWith("user-edited paragraph")
+
+    act(() => root.unmount())
+    document.body.removeChild(container)
+  })
+
 })

+ 40 - 21
src/components/novel/text-transform-preview-dialog.tsx

@@ -8,6 +8,7 @@ import {
 } from "@/components/ui/dialog"
 import { Button } from "@/components/ui/button"
 import { Textarea } from "@/components/ui/textarea"
+import { AiChangeComparePanel } from "@/components/common/ai-change-compare-panel"
 
 interface TextTransformPreviewDialogProps {
   open: boolean
@@ -21,6 +22,7 @@ interface TextTransformPreviewDialogProps {
   secondaryActionLabel?: string
   applyDisabled?: boolean
   secondaryActionDisabled?: boolean
+  comparisonMode?: boolean
   onCandidateContentChange?: (content: string) => void
   onApply: () => void
   onSecondaryAction?: () => void
@@ -39,6 +41,7 @@ export function TextTransformPreviewDialog({
   secondaryActionLabel,
   applyDisabled = false,
   secondaryActionDisabled = false,
+  comparisonMode = false,
   onCandidateContentChange,
   onApply,
   onSecondaryAction,
@@ -46,34 +49,50 @@ export function TextTransformPreviewDialog({
 }: TextTransformPreviewDialogProps) {
   return (
     <Dialog open={open} onOpenChange={(next) => { if (!next) onClose() }}>
-      <DialogContent className="sm:max-w-3xl">
-        <DialogHeader>
+      <DialogContent className={comparisonMode
+        ? "flex h-[min(48rem,calc(100dvh-2rem))] max-h-[calc(100dvh-2rem)] w-[min(80rem,calc(100vw-2rem))] max-w-[calc(100vw-2rem)] flex-col overflow-hidden p-0 sm:max-w-[calc(100vw-2rem)]"
+        : "sm:max-w-3xl"
+      }>
+        <DialogHeader className={comparisonMode ? "shrink-0 border-b px-4 py-3" : undefined}>
           <DialogTitle>{title}</DialogTitle>
           {description ? <DialogDescription>{description}</DialogDescription> : null}
         </DialogHeader>
-        <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
-          <div className="flex min-h-0 flex-col gap-2">
-            <div className="text-xs font-medium text-muted-foreground">{sourceLabel}</div>
-            <div className="max-h-96 overflow-y-auto rounded-md border bg-muted/20 p-3 text-sm leading-6 whitespace-pre-wrap">
-              {sourceContent}
-            </div>
+        {comparisonMode ? (
+          <div className="min-h-0 flex-1 overflow-hidden">
+            <AiChangeComparePanel
+              originalContent={sourceContent}
+              modifiedContent={candidateContent}
+              originalLabel={sourceLabel}
+              modifiedLabel={candidateLabel}
+              editable={Boolean(onCandidateContentChange)}
+              onModifiedContentChange={onCandidateContentChange ?? (() => {})}
+            />
           </div>
-          <div className="flex min-h-0 flex-col gap-2">
-            <div className="text-xs font-medium text-muted-foreground">{candidateLabel}</div>
-            {onCandidateContentChange ? (
-              <Textarea
-                className="min-h-40 max-h-96 resize-y overflow-y-auto bg-muted/20 text-sm leading-6 whitespace-pre-wrap"
-                value={candidateContent}
-                onChange={(event) => onCandidateContentChange(event.target.value)}
-              />
-            ) : (
+        ) : (
+          <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
+            <div className="flex min-h-0 flex-col gap-2">
+              <div className="text-xs font-medium text-muted-foreground">{sourceLabel}</div>
               <div className="max-h-96 overflow-y-auto rounded-md border bg-muted/20 p-3 text-sm leading-6 whitespace-pre-wrap">
-                {candidateContent}
+                {sourceContent}
               </div>
-            )}
+            </div>
+            <div className="flex min-h-0 flex-col gap-2">
+              <div className="text-xs font-medium text-muted-foreground">{candidateLabel}</div>
+              {onCandidateContentChange ? (
+                <Textarea
+                  className="min-h-40 max-h-96 resize-y overflow-y-auto bg-muted/20 text-sm leading-6 whitespace-pre-wrap"
+                  value={candidateContent}
+                  onChange={(event) => onCandidateContentChange(event.target.value)}
+                />
+              ) : (
+                <div className="max-h-96 overflow-y-auto rounded-md border bg-muted/20 p-3 text-sm leading-6 whitespace-pre-wrap">
+                  {candidateContent}
+                </div>
+              )}
+            </div>
           </div>
-        </div>
-        <DialogFooter>
+        )}
+        <DialogFooter className={comparisonMode ? "mx-0 mb-0 shrink-0 border-t px-4 py-3" : undefined}>
           <Button variant="outline" onClick={onClose}>取消</Button>
           {secondaryActionLabel && onSecondaryAction ? (
             <Button variant="outline" onClick={onSecondaryAction} disabled={secondaryActionDisabled}>{secondaryActionLabel}</Button>

+ 10 - 4
src/components/sources/outline-chat-panel.spec.tsx

@@ -578,6 +578,8 @@ describe("OutlineChatPanel controls", () => {
     expect(source).toContain("## AI大纲生成工作流")
     expect(source).toContain("提取请求关键词")
     expect(source).toContain("识别用户意图")
+    expect(source).toContain("本轮意图分析已经完成")
+    expect(source).toContain("禁止再次输出 intent_clarity")
     expect(source).toContain("提取对小说创作有用的关键内容")
     expect(source).toContain("结合用户要用的 skill + soul.md 约束")
     expect(source).toContain("最终回复只输出大纲标题和大纲正文")
@@ -695,14 +697,18 @@ describe("OutlineChatPanel controls", () => {
     expect(source).toContain("repairPrompt")
   })
 
-  it("uses save confirm dialog for classified outline saves", () => {
-    expect(source).toContain("AiChangeReview")
-    expect(source).toContain("AiChangeReviewItem")
+  it("uses folder save confirm dialog for classified outline saves", () => {
+    expect(source).toContain("OutlineSaveConfirmDialog")
+    expect(source).toContain("OutlineSaveConfirmPayload")
     expect(source).toContain("extractCharacterSaveDrafts")
     expect(source).toContain("classifyOutlineSaveTarget")
     expect(source).toContain("characterDraftsToSaveRequests")
     expect(source).toContain("splitConfirmRequiredSaveRequests")
-    expect(source).toContain("reviewItems")
+    expect(source).toContain("mode={saveConfirmState.mode}")
+    expect(source).toContain("requests={saveConfirmState.requests}")
+    expect(source).toContain("characterDrafts={saveConfirmState.characterDrafts}")
+    expect(source).not.toContain("<AiChangeReview")
+    expect(source).not.toContain("reviewItems")
     expect(source).toContain("confirmed: true")
   })
 

+ 25 - 59
src/components/sources/outline-chat-panel.tsx

@@ -48,6 +48,10 @@ import {
   AgentToolCallMessage,
   type ToolCallRecord,
 } from "@/components/chat/agent-tool-call-message";
+import {
+  OutlineSaveConfirmDialog,
+  type OutlineSaveConfirmPayload,
+} from "@/components/sources/outline-save-confirm-dialog";
 import { OutlineWizardDialog } from "@/components/sources/outline-wizard-dialog";
 import { NovelGenerationRequestMessage } from "@/components/sources/novel-generation-request-message";
 import { OutlineMultiAgentPanel } from "@/components/sources/outline-multi-agent-panel";
@@ -110,8 +114,6 @@ import {
   saveOutlineSaveRequests,
   splitConfirmRequiredSaveRequests,
 } from "@/lib/novel/outline-save-request";
-import { stripOutlineFrontmatter } from "@/lib/novel/outline-markdown";
-import { AiChangeReview, type AiChangeReviewItem } from "@/components/common/ai-change-review";
 import {
   resolveModelConfig,
   resolveNovelModel,
@@ -190,6 +192,7 @@ import { createWriteOutlineNodeTool } from "@/lib/agent/tools/write-outline-node
 import {
   buildIntentAnalysisPrompt,
   parseIntentClarity,
+  shouldAutoFollowUpGeneration,
   stripStructuredMarkers,
   type IntentClarityResult,
 } from "@/lib/novel/outline-intent-clarity";
@@ -473,7 +476,8 @@ function buildGenerationPrompt(
     `请按「AI大纲生成工作流」生成「${title}」。`,
     scope ? `\n## 已确认范围\n${scope}\n` : "",
     "## PRD 3.1 主流程要求",
-    "1. 提取请求关键词。2. 识别用户意图。3. 读取资料。4. 提取关键内容。",
+    "本轮意图分析已经完成,直接使用已确认范围生成完整大纲正文;禁止再次输出 intent_clarity 标记,也不要重新进入意图分析。",
+    "1. 提取请求关键词。2. 使用已确认范围。3. 读取资料。4. 提取关键内容。",
     "5. 结合 skill + soul.md 生成可直接保存的大纲正文。6. 结果强约束收敛。",
     "",
     "## 本分项内容要求",
@@ -1476,7 +1480,6 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
     mode: "normal" | "character";
     requests: OutlineSaveRequest[];
     characterDrafts: CharacterSaveDraft[];
-    reviewItems?: AiChangeReviewItem[];
   } | null>(null);
   const [copied, setCopied] = useState<string | null>(null);
   const [showScrollToBottom, setShowScrollToBottom] = useState(false);
@@ -1544,19 +1547,13 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
   }, [isStreaming]);
 
   const executeConfirmedOutlineSave = useCallback(
-    async (reviewItems: AiChangeReviewItem[]) => {
+    async (payload: OutlineSaveConfirmPayload) => {
       if (!project) return;
       const projectPath = normalizePath(project.path);
-      const st = saveConfirmState
-      const confirmRequests = st && st.characterDrafts.length > 0
-        ? characterDraftsToSaveRequests(st.characterDrafts, "保存人物小传")
-        : (st?.requests ?? [])
-          .filter((r) => reviewItems.some((ri) => ri.id === `${r.targetFolder}/${r.fileName}`))
-          .map((r) => {
-            const matched = reviewItems.find((ri) => ri.id === `${r.targetFolder}/${r.fileName}`)
-            return matched ? { ...r, content: matched.modifiedContent } : r
-          });
-      if (confirmRequests.length === 0) {
+      const requests = payload.characterDrafts.length > 0
+        ? characterDraftsToSaveRequests(payload.characterDrafts, "保存人物小传")
+        : payload.requests;
+      if (requests.length === 0) {
         setSaveStatus("没有选择需要保存的内容。");
         return;
       }
@@ -1566,7 +1563,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         const saveResult = await saveOutlineSaveRequests({
           outlineRoot: `${projectPath}/wiki/outlines`,
           confirmed: true,
-          requests: confirmRequests,
+          requests,
           createDirectory,
           fileExists,
           readFile,
@@ -1590,7 +1587,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         setSaveStatus(`保存失败:${error instanceof Error ? error.message : String(error)}`);
       }
     },
-    [project, saveConfirmState],
+    [project],
   );
 
   const handleAutoSaveOutlineRequests = useCallback(
@@ -1682,13 +1679,6 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
               mode: "character",
               requests: [],
               characterDrafts: extracted.drafts,
-              reviewItems: extracted.drafts.filter((d) => d.selected).map((d) => ({
-                id: `人物小传/${d.fileName}`,
-                fileName: d.fileName,
-                originalContent: "",
-                modifiedContent: d.content,
-                selected: true,
-              })),
             });
             setSaveStatus("检测到人物小传,请确认要保存的人物角色。");
           } else {
@@ -1700,13 +1690,6 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
               mode: "character",
               requests: [],
               characterDrafts: fallbackDrafts,
-              reviewItems: fallbackDrafts.filter((d) => d.selected).map((d) => ({
-                id: `人物小传/${d.fileName}`,
-                fileName: d.fileName,
-                originalContent: "",
-                modifiedContent: d.content,
-                selected: true,
-              })),
             });
             setSaveStatus(
               `无法自动拆分角色,请在保存前检查文件名和内容。${extracted.errors.join(";")}`,
@@ -2480,7 +2463,10 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             ...message,
             intentClarityResult: intentResult,
           }));
-          if (intentResult.clarity === "clear") {
+          if (
+            intentResult.clarity === "clear" &&
+            shouldAutoFollowUpGeneration(options.intentPhase)
+          ) {
             const capturedStage = outlineWorkflowStages[capturedConvId] ?? "idle";
             if (canTransitionOutlineWorkflow(capturedStage, "sufficiency_check")) {
               setCapturedWorkflowStage("sufficiency_check");
@@ -3436,7 +3422,9 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         toast.error("当前没有活跃的对话,无法保存大纲");
         return;
       }
-      const cleanedContent = cleanNextStepArtifacts(content);
+      const stripped = stripStructuredMarkers(content);
+      const bodyContent = extractBodyContent(stripped);
+      const cleanedContent = cleanNextStepArtifacts(bodyContent || stripped);
       if (!cleanedContent.trim()) {
         toast.error("内容为空,无法保存为大纲");
         return;
@@ -3460,13 +3448,6 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             mode: "character",
             requests: [],
             characterDrafts: extracted.drafts,
-            reviewItems: extracted.drafts.filter((d) => d.selected).map((d) => ({
-              id: `人物小传/${d.fileName}`,
-              fileName: d.fileName,
-              originalContent: "",
-              modifiedContent: d.content,
-              selected: true,
-            })),
           });
           return;
         }
@@ -3516,23 +3497,6 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             sourceIntent: "手动保存 AI 大纲结果",
             content: mdContent,
           }],
-          reviewItems: (() => {
-            const req = {
-              targetFolder: classification.targetFolder,
-              fileName: classification.fileName,
-              fileType: classification.fileType,
-              writeMode: "create" as const,
-            }
-            return [{
-              id: `${req.targetFolder}/${req.fileName}`,
-              fileName: req.fileName,
-              originalContent: "",
-              modifiedContent: stripOutlineFrontmatter(mdContent),
-              selected: true,
-              targetFolder: req.targetFolder,
-              writeMode: req.writeMode,
-            }]
-          })(),
         });
       } catch (err) {
         setSaveStatus(
@@ -4047,10 +4011,12 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           onConfirm={confirmClearHistory}
         />
         {saveConfirmState ? (
-          <AiChangeReview
+          <OutlineSaveConfirmDialog
             open
             title={saveConfirmState.title}
-            items={saveConfirmState.reviewItems ?? []}
+            mode={saveConfirmState.mode}
+            requests={saveConfirmState.requests}
+            characterDrafts={saveConfirmState.characterDrafts}
             onClose={() => setSaveConfirmState(null)}
             onConfirm={executeConfirmedOutlineSave}
           />

+ 14 - 1
src/lib/novel/outline-intent-clarity.spec.ts

@@ -1,5 +1,9 @@
 import { describe, it, expect } from "vitest"
-import { parseIntentClarity, stripStructuredMarkers } from "./outline-intent-clarity"
+import {
+  parseIntentClarity,
+  shouldAutoFollowUpGeneration,
+  stripStructuredMarkers,
+} from "./outline-intent-clarity"
 
 describe("parseIntentClarity", () => {
   it("解析 clear 意图", () => {
@@ -37,6 +41,15 @@ describe("parseIntentClarity", () => {
   })
 })
 
+describe("shouldAutoFollowUpGeneration", () => {
+  it("只允许意图分析阶段自动进入一次生成,阻止生成和修订阶段再次触发", () => {
+    expect(shouldAutoFollowUpGeneration("intent_analysis")).toBe(true)
+    expect(shouldAutoFollowUpGeneration("generation")).toBe(false)
+    expect(shouldAutoFollowUpGeneration("waiting_user_input")).toBe(false)
+    expect(shouldAutoFollowUpGeneration(undefined)).toBe(false)
+  })
+})
+
 describe("stripStructuredMarkers", () => {
   it("移除 intent_clarity 标记块", () => {
     const text = `分析结果如下:\n<!-- intent_clarity -->\n{"clarity":"clear"}\n<!-- /intent_clarity -->\n\n这是正文内容。`

+ 6 - 0
src/lib/novel/outline-intent-clarity.ts

@@ -18,6 +18,12 @@ export interface IntentClarityResult {
 
 const CLARITY_PATTERN = /<!--\s*intent_clarity\s*-->([\s\S]*?)<!--\s*\/intent_clarity\s*-->/i
 
+export function shouldAutoFollowUpGeneration(
+  intentPhase: "intent_analysis" | "generation" | "waiting_user_input" | undefined,
+): boolean {
+  return intentPhase === "intent_analysis"
+}
+
 export function parseIntentClarity(text: string): IntentClarityResult | null {
   const match = text.match(CLARITY_PATTERN)
   if (!match) return null

+ 178 - 11
src/lib/outline-save.ts

@@ -126,6 +126,26 @@ function convertAttributeLines(lines: string[]): string[] {
   })
 }
 
+const OUTLINE_START_KEYWORDS = /^(第[一二三四五六七八九十百千\d]+[章卷节部编]|[总卷章节分卷分章]+[纲要]|章纲|卷纲|总纲|大纲|细纲|故事背景|世界观|核心设定|主要人物|核心主角|人物设定|角色设定|势力分布|修炼体系|能力体系|金手指|伏笔|背景设定)/
+
+function stripPrefaceText(content: string): string {
+  const lines = content.split(/\r?\n/)
+  let startIdx = 0
+  for (let i = 0; i < lines.length; i++) {
+    const trimmed = lines[i].trim()
+    if (!trimmed) continue
+    if (/^#{1,6}\s/.test(trimmed)) {
+      startIdx = i
+      break
+    }
+    if (OUTLINE_START_KEYWORDS.test(trimmed)) {
+      startIdx = i
+      break
+    }
+  }
+  return lines.slice(startIdx).join("\n")
+}
+
 export function normalizeOutlineMarkdown(content: string): string {
   let result = content
     .replace(/```(?:markdown|md)\s*\r?\n([\s\S]*?)\r?\n```/gi, (_, inner: string) => inner.trim())
@@ -135,6 +155,8 @@ export function normalizeOutlineMarkdown(content: string): string {
     .replace(/^\\(\d+\.\s)/gm, "$1")
     .replace(/\\([*_`[\]])/g, "$1")
 
+  result = stripPrefaceText(result)
+
   const lines = result.split(/\r?\n/)
   const withHeadings = convertChineseNumberedHeadings(lines)
   const withAttributes = convertAttributeLines(withHeadings)
@@ -142,22 +164,167 @@ export function normalizeOutlineMarkdown(content: string): string {
   return withAttributes.join("\n")
 }
 
+function detectOutlineContentType(content: string): "chapter-outline" | "character" | "volume-outline" | "setting" | "foreshadowing" | "organization" | "outline" {
+  const text = content.slice(0, 2000)
+  if (/第\s*\d{1,4}\s*章/.test(text)) return "chapter-outline"
+  if (/第\s*(?:\d+|[一二三四五六七八九十百千万]+)\s*卷/.test(text)) return "volume-outline"
+  if (/人物小传|角色小传|人物设定|角色设定/.test(text) && /姓名|身份|性格|外貌/.test(text)) return "character"
+  if (/伏笔|线索|回收/.test(text)) return "foreshadowing"
+  if (/组织|势力|阵营|门派|家族/.test(text)) return "organization"
+  if (/世界观|修炼体系|能力体系|金手指|核心设定|主要设定/.test(text)) return "setting"
+  return "outline"
+}
+
+function extractChapterOutlineTitle(content: string, lines: string[]): string | null {
+  const chapterMatch = content.match(/第\s*(\d{1,4}|[一二三四五六七八九十百千万]+)\s*章\s*([^\n#::,,。.]*)/)
+  if (chapterMatch) {
+    const chapterNum = chapterMatch[1]
+    const chapterTitle = chapterMatch[2]?.trim()
+    if (chapterTitle) {
+      return `第${chapterNum}章 ${chapterTitle}`
+    }
+    return `第${chapterNum}章`
+  }
+  return null
+}
+
+function extractCharacterTitle(content: string, lines: string[]): string | null {
+  const nameMatch = content.match(/(?:姓名|名字|角色名|人物名)[::]\s*([^\n,,。.;;]+)/)
+  if (nameMatch && nameMatch[1].trim()) {
+    return nameMatch[1].trim()
+  }
+  for (const line of lines.slice(0, 10)) {
+    const trimmed = line.trim()
+    const headingMatch = trimmed.match(/^#{1,3}\s+(.+)/)
+    const text = headingMatch ? headingMatch[1].trim() : trimmed
+    if (!text || text.length > 20) continue
+    if (/[::]/.test(text)) {
+      const parts = text.split(/[::]/)
+      if (parts[0].length <= 4 && /姓名|名字|角色|人物/.test(parts[0]) && parts[1]?.trim()) {
+        return parts[1].trim()
+      }
+    }
+    if (/^(男主|女主|男配|女配|反派|主角|配角)/.test(text) && text.length <= 15) {
+      return text
+    }
+    if (/^[\u4e00-\u9fa5]{2,4}$/.test(text) && !/的|与|和|之|是|在|有|为/.test(text)) {
+      return text
+    }
+  }
+  return null
+}
+
+function extractVolumeOutlineTitle(content: string, lines: string[]): string | null {
+  const volumeMatch = content.match(/第\s*(\d+|[一二三四五六七八九十百千万]+)\s*卷\s*([^\n#::,,。.]*)/)
+  if (volumeMatch) {
+    const volumeNum = volumeMatch[1]
+    const volumeTitle = volumeMatch[2]?.trim()
+    if (volumeTitle) {
+      return `第${volumeNum}卷 ${volumeTitle}`
+    }
+    return `第${volumeNum}卷`
+  }
+  return null
+}
+
+function extractGeneralOutlineTitle(content: string, lines: string[]): string | null {
+  const workNameMatch = content.match(/(?:作品名|书名|小说名|作品名称)[::]\s*([^\n,,。.;;]+)/)
+  if (workNameMatch && workNameMatch[1].trim()) {
+    return workNameMatch[1].trim()
+  }
+  for (const line of lines.slice(0, 5)) {
+    const trimmed = line.trim()
+    const h1Match = trimmed.match(/^#\s+(.+)/)
+    if (h1Match) {
+      const title = h1Match[1].trim()
+      if (title.length >= 2 && title.length <= 30) {
+        return title
+      }
+    }
+  }
+  for (const line of lines.slice(0, 10)) {
+    const trimmed = line.trim()
+    const h2Match = trimmed.match(/^##\s+(.+)/)
+    if (h2Match) {
+      const title = h2Match[1].trim()
+      if (/总纲|大纲|全书大纲|故事大纲|整体大纲/.test(title)) {
+        continue
+      }
+      if (title.length >= 2 && title.length <= 25) {
+        return title
+      }
+    }
+  }
+  return null
+}
+
 function extractOutlineTitle(content: string): string {
-  const lines = content.split("\n").map((line) => line.trim()).filter(Boolean)
-  for (const line of lines.slice(0, 8)) {
-    const headingMatch = line.match(/^#+\s+(.+)/)
-    if (headingMatch) return headingMatch[1].trim()
+  const normalized = content.trim()
+  if (!normalized) return `${DEFAULT_TITLE_PREFIX}-${new Date().toISOString().slice(0, 10)}`
+
+  const lines = normalized.split("\n").map((line) => line.trim()).filter(Boolean)
+  if (lines.length === 0) return `${DEFAULT_TITLE_PREFIX}-${new Date().toISOString().slice(0, 10)}`
+
+  for (const line of lines.slice(0, 15)) {
+    const h1Match = line.match(/^#\s+(.+)/)
+    if (h1Match) {
+      const title = h1Match[1].trim()
+      if (title && title.length >= 1 && title.length <= 50) {
+        return title
+      }
+    }
+  }
+
+  const contentType = detectOutlineContentType(normalized)
+
+  let extracted: string | null = null
+  switch (contentType) {
+    case "chapter-outline":
+      extracted = extractChapterOutlineTitle(normalized, lines)
+      break
+    case "character":
+      extracted = extractCharacterTitle(normalized, lines)
+      break
+    case "volume-outline":
+      extracted = extractVolumeOutlineTitle(normalized, lines)
+      break
+    case "outline":
+    case "setting":
+    case "foreshadowing":
+    case "organization":
+    default:
+      extracted = extractGeneralOutlineTitle(normalized, lines)
+      break
+  }
+
+  if (extracted) return extracted
+
+  for (const line of lines.slice(0, 10)) {
+    const trimmed = line.trim()
+    const headingMatch = trimmed.match(/^#{1,6}\s+(.+)/)
+    const text = headingMatch ? headingMatch[1].trim() : trimmed
     if (
-      line.length > 2 &&
-      line.length < 40 &&
-      !line.startsWith("-") &&
-      !line.startsWith("*") &&
-      !line.includes(":")
+      text.length >= 2 &&
+      text.length <= 30 &&
+      !/^[-*+]/.test(text) &&
+      !/^[::]/.test(text) &&
+      !/[,。;?!、]$/.test(text)
     ) {
-      return line
+      return text
     }
   }
-  return `${DEFAULT_TITLE_PREFIX}-${new Date().toISOString().slice(0, 10)}`
+
+  const typeNames: Record<string, string> = {
+    "chapter-outline": "章纲",
+    "volume-outline": "卷纲",
+    "character": "人物设定",
+    "setting": "设定",
+    "foreshadowing": "伏笔",
+    "organization": "组织势力",
+    "outline": "大纲",
+  }
+  const typeLabel = typeNames[contentType] || "大纲"
+  return `${DEFAULT_TITLE_PREFIX}-${typeLabel}-${new Date().toISOString().slice(0, 10)}`
 }
 
 function sanitizeOutlineTitle(title: string): string {

+ 4 - 2
src/lib/utils/diff.ts

@@ -8,8 +8,10 @@ export interface DiffLine {
 }
 
 export function computeLineDiff(original: string, modified: string): DiffLine[] {
-  const origLines = original.length === 0 ? [] : original.split("\n")
-  const modLines = modified.length === 0 ? [] : modified.split("\n")
+  const normalizedOriginal = original.replace(/\r\n?/g, "\n")
+  const normalizedModified = modified.replace(/\r\n?/g, "\n")
+  const origLines = normalizedOriginal.length === 0 ? [] : normalizedOriginal.split("\n")
+  const modLines = normalizedModified.length === 0 ? [] : normalizedModified.split("\n")
   const m = origLines.length
   const n = modLines.length
 

+ 35 - 0
src/stores/de-ai-batch-store.spec.ts

@@ -260,6 +260,41 @@ describe("de-ai batch store", () => {
     expect(storage.saveChapter).toHaveBeenCalledWith(expect.objectContaining({ id: "chapter-1", status: "confirmed" }), "C:/project")
   })
 
+  it("confirms and persists the user-edited candidate content", async () => {
+    const restored = toRecord(input("a"))
+    restored.task.status = "reviewing"
+    restored.chapters[0] = {
+      ...restored.chapters[0],
+      status: "ready",
+      candidateContent: "model candidate",
+    }
+    const { store, applyChapter, storage } = setup({ loaded: [restored] })
+    await store.getState().initializeProject("C:/project")
+
+    expect(await store.getState().confirmChapter(
+      restored.task.id,
+      "chapter-1",
+      "user-edited candidate",
+    )).toBe(true)
+
+    expect(applyChapter).toHaveBeenCalledWith(
+      "C:/project/wiki/chapters/a/chapter-1.md",
+      "user-edited candidate",
+    )
+    expect(store.getState().records[0].chapters[0]).toMatchObject({
+      status: "confirmed",
+      candidateContent: "user-edited candidate",
+    })
+    expect(storage.saveChapter).toHaveBeenCalledWith(
+      expect.objectContaining({
+        id: "chapter-1",
+        status: "confirmed",
+        candidateContent: "user-edited candidate",
+      }),
+      "C:/project",
+    )
+  })
+
   it("取消批次只停止该作品,其他作品继续", async () => {
     const { store } = setup()
     await store.getState().initializeProject("C:/project")

+ 16 - 5
src/stores/de-ai-batch-store.ts

@@ -64,7 +64,7 @@ export interface DeAiBatchState {
   continueTask(taskId: string): Promise<boolean>
   regenerateChapter(taskId: string, chapterId: string): Promise<boolean>
   cancelChapter(taskId: string, chapterId: string): Promise<boolean>
-  confirmChapter(taskId: string, chapterId: string): Promise<boolean>
+  confirmChapter(taskId: string, chapterId: string, candidateContent?: string): Promise<boolean>
   cancelTask(taskId: string): Promise<boolean>
   openReview(taskId: string, chapterId?: string): void
   closeReview(): void
@@ -347,7 +347,11 @@ export function createDeAiBatchStore(options: CreateDeAiBatchStoreOptions) {
       }
     }
 
-    async function confirmChapter(taskId: string, chapterId: string): Promise<boolean> {
+    async function confirmChapter(
+      taskId: string,
+      chapterId: string,
+      candidateContent?: string,
+    ): Promise<boolean> {
       const token = beginChapterAction(taskId, chapterId)
       if (!token) return false
       try {
@@ -356,11 +360,18 @@ export function createDeAiBatchStore(options: CreateDeAiBatchStoreOptions) {
         const context = captureProjectContext(record.task.projectPath)
         if (!context) return false
         const index = record.chapters.findIndex((item) => item.id === chapterId)
-        const chapter = record.chapters[index]
-        if (!chapter || chapter.status !== "ready" || !chapter.candidateContent) return false
+        let chapter = record.chapters[index]
+        if (!chapter || chapter.status !== "ready") return false
+        const expectedCandidate = candidateContent ?? chapter.candidateContent ?? ""
+        if (!expectedCandidate.trim()) return false
         const expectedGeneration = chapter.generation
-        const expectedCandidate = chapter.candidateContent
         const safeSourcePath = assertChapterSourcePath(record.task.projectPath, chapter.sourcePath)
+        if (candidateContent !== undefined && candidateContent !== chapter.candidateContent) {
+          chapter = { ...chapter, candidateContent, updatedAt: now() }
+          record.chapters[index] = chapter
+          await storage.saveChapter(chapter, record.task.projectPath)
+          if (!isProjectContextCurrent(context)) return false
+        }
         await applyChapter(safeSourcePath, expectedCandidate)
         if (!isProjectContextCurrent(context)) return false
         const latest = record.chapters.find((item) => item.id === chapterId)