Browse Source

fix(outline): 修复 AI 大纲确认后无法写入 replace/patch 的问题

引入 AiChangeReview 组件替代 OutlineSaveConfirmDialog,支持按文件勾选、源码对比、渲染预览切换、双侧同步滚动;新增 MonacoDiffEditor 与 monaco-environment;saveOutlineSaveRequests 新增 confirmed 参数;大纲创建与保存统一使用纯 Markdown;handleAutoSaveOutlineRequests 与 handleSaveAsOutline 补全 reviewItems
Mochocyang 2 months ago
parent
commit
6414288960

+ 62 - 0
package-lock.json

@@ -18,6 +18,7 @@
         "@milkdown/plugin-math": "^7.5.9",
         "@milkdown/react": "^7.21.2",
         "@milkdown/theme-nord": "^7.21.2",
+        "@monaco-editor/react": "^4.7.0",
         "@react-sigma/core": "^5.0.6",
         "@sigma/edge-curve": "^3.1.0",
         "@tailwindcss/vite": "^4.3.2",
@@ -38,6 +39,7 @@
         "katex": "^0.16.47",
         "lucide-react": "^1.23.0",
         "mermaid": "^11.16.0",
+        "monaco-editor": "^0.56.0",
         "opencc-js": "^1.4.0",
         "pinyin-pro": "^3.28.1",
         "react": "^19.2.7",
@@ -2267,6 +2269,29 @@
         }
       }
     },
+    "node_modules/@monaco-editor/loader": {
+      "version": "1.7.0",
+      "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz",
+      "integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==",
+      "license": "MIT",
+      "dependencies": {
+        "state-local": "^1.0.6"
+      }
+    },
+    "node_modules/@monaco-editor/react": {
+      "version": "4.7.0",
+      "resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz",
+      "integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==",
+      "license": "MIT",
+      "dependencies": {
+        "@monaco-editor/loader": "^1.5.0"
+      },
+      "peerDependencies": {
+        "monaco-editor": ">= 0.25.0 < 1",
+        "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+        "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+      }
+    },
     "node_modules/@napi-rs/wasm-runtime": {
       "version": "1.1.6",
       "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
@@ -8307,6 +8332,37 @@
         "obliterator": "^2.0.1"
       }
     },
+    "node_modules/monaco-editor": {
+      "version": "0.56.0",
+      "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.56.0.tgz",
+      "integrity": "sha512-sXboRm3BeBeLm938eaiyLMe0OxzfXIlZvbv4ir/jVgQy1zDhWjgmny0WoN45fuDKhCCQsYMbBJrv/A6jd8aCUg==",
+      "license": "MIT",
+      "dependencies": {
+        "dompurify": "3.4.8",
+        "marked": "14.0.0"
+      }
+    },
+    "node_modules/monaco-editor/node_modules/dompurify": {
+      "version": "3.4.8",
+      "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.8.tgz",
+      "integrity": "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ==",
+      "license": "(MPL-2.0 OR Apache-2.0)",
+      "optionalDependencies": {
+        "@types/trusted-types": "^2.0.7"
+      }
+    },
+    "node_modules/monaco-editor/node_modules/marked": {
+      "version": "14.0.0",
+      "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
+      "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
+      "license": "MIT",
+      "bin": {
+        "marked": "bin/marked.js"
+      },
+      "engines": {
+        "node": ">= 18"
+      }
+    },
     "node_modules/ms": {
       "version": "2.1.3",
       "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -9947,6 +10003,12 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/state-local": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz",
+      "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==",
+      "license": "MIT"
+    },
     "node_modules/statuses": {
       "version": "2.0.2",
       "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",

+ 2 - 0
package.json

@@ -27,6 +27,7 @@
     "@milkdown/plugin-math": "^7.5.9",
     "@milkdown/react": "^7.21.2",
     "@milkdown/theme-nord": "^7.21.2",
+    "@monaco-editor/react": "^4.7.0",
     "@react-sigma/core": "^5.0.6",
     "@sigma/edge-curve": "^3.1.0",
     "@tailwindcss/vite": "^4.3.2",
@@ -47,6 +48,7 @@
     "katex": "^0.16.47",
     "lucide-react": "^1.23.0",
     "mermaid": "^11.16.0",
+    "monaco-editor": "^0.56.0",
     "opencc-js": "^1.4.0",
     "pinyin-pro": "^3.28.1",
     "react": "^19.2.7",

+ 147 - 0
src/components/common/ai-change-review.spec.tsx

@@ -0,0 +1,147 @@
+// @vitest-environment jsdom
+import React from "react"
+import { 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 }: {
+    originalValue: string
+    modifiedValue: string
+    onChange: (value: string) => void
+  }) => {
+    const [value, setValue] = React.useState(modifiedValue)
+    return (
+      <div>
+        <pre aria-label="原始源码">{originalValue}</pre>
+        <textarea
+          aria-label="最新源码"
+          value={value}
+          onChange={(event) => {
+            const next = event.target.value
+            setValue(next)
+            onChange(next)
+          }}
+        />
+      </div>
+    )
+  },
+}))
+
+import { AiChangeReview, type AiChangeReviewItem } from "./ai-change-review"
+
+function findButton(container: HTMLElement, label: string): HTMLButtonElement {
+  const button = Array.from(container.querySelectorAll("button"))
+    .find((item) => item.textContent?.includes(label))
+  if (!button) throw new Error(`未找到按钮:${label}`)
+  return button
+}
+
+describe("AiChangeReview", () => {
+  let container: HTMLDivElement
+  let root: Root
+
+  beforeEach(() => {
+    container = document.createElement("div")
+    document.body.appendChild(container)
+    root = createRoot(container)
+  })
+
+  afterEach(async () => {
+    await act(async () => root.unmount())
+    container.remove()
+  })
+
+  it("按文件勾选并提交选中项;取消勾选的不提交", async () => {
+    const onConfirm = vi.fn()
+    const items: AiChangeReviewItem[] = [{
+      id: "outline",
+      fileName: "总纲.md",
+      originalContent: "# 旧总纲",
+      modifiedContent: "# 新总纲",
+      selected: true,
+    }, {
+      id: "character",
+      fileName: "林默.md",
+      originalContent: "",
+      modifiedContent: "# 林默",
+      selected: true,
+    }]
+
+    await act(async () => {
+      root.render(
+        <AiChangeReview
+          open
+          title="确认 AI 修改"
+          items={items}
+          onClose={() => {}}
+          onConfirm={onConfirm}
+        />,
+      )
+    })
+
+    const characterCheckbox = document.body.querySelector('input[aria-label="保存 林默.md"]') as HTMLInputElement
+    await act(async () => characterCheckbox.click())
+    await act(async () => findButton(document.body, "确认保存").click())
+
+    expect(onConfirm).toHaveBeenCalledOnce()
+    const submitted = onConfirm.mock.calls[0][0] as AiChangeReviewItem[]
+    expect(submitted).toHaveLength(1)
+    expect(submitted[0].id).toBe("outline")
+    expect(submitted[0].modifiedContent).toBe("# 新总纲")
+    expect(submitted.some((i: AiChangeReviewItem) => i.id === "character")).toBe(false)
+  })
+
+  it("未选择任何文件时禁用确认保存", async () => {
+    const onConfirm = vi.fn()
+    const items: AiChangeReviewItem[] = [{
+      id: "outline",
+      fileName: "总纲.md",
+      originalContent: "# 旧总纲",
+      modifiedContent: "# 新总纲",
+      selected: false,
+    }]
+
+    await act(async () => {
+      root.render(
+        <AiChangeReview
+          open
+          title="确认 AI 修改"
+          items={items}
+          onClose={() => {}}
+          onConfirm={onConfirm}
+        />,
+      )
+    })
+
+    const btn = findButton(document.body, "确认保存")
+    expect(btn.disabled).toBe(true)
+  })
+
+  it("可在源码对比和不带符号的渲染预览之间切换", async () => {
+    await act(async () => {
+      root.render(
+        <AiChangeReview
+          open
+          title="确认 AI 修改"
+          items={[{
+            id: "outline",
+            fileName: "总纲.md",
+            originalContent: "# 旧总纲",
+            modifiedContent: "# 新总纲",
+            selected: true,
+          }]}
+          onClose={() => {}}
+          onConfirm={() => {}}
+        />,
+      )
+    })
+
+    expect(document.body.querySelector('textarea[aria-label="最新源码"]')).not.toBeNull()
+    await act(async () => findButton(document.body, "渲染预览").click())
+    expect(document.body.textContent).toContain("原始内容预览")
+    expect(document.body.textContent).toContain("最新内容预览")
+    await act(async () => findButton(document.body, "源码对比").click())
+    expect(document.body.querySelector('textarea[aria-label="最新源码"]')).not.toBeNull()
+  })
+})

+ 302 - 0
src/components/common/ai-change-review.tsx

@@ -0,0 +1,302 @@
+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"
+
+export interface AiChangeReviewItem {
+  id: string
+  fileName: string
+  originalContent: string
+  modifiedContent: string
+  selected: boolean
+  error?: string
+  targetFolder?: string
+  writeMode?: string
+}
+
+interface AiChangeReviewProps {
+  open: boolean
+  title: string
+  items: AiChangeReviewItem[]
+  onClose: () => void
+  onConfirm: (items: AiChangeReviewItem[]) => void
+  description?: string
+}
+
+type ViewMode = "source" | "preview"
+
+export function AiChangeReview({
+  open,
+  title,
+  items: initialItems,
+  onClose,
+  onConfirm,
+  description,
+}: 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])
+
+  const activeItem = useMemo(
+    () => items.find((item) => item.id === activeId) ?? null,
+    [items, activeId],
+  )
+
+  const selectedCount = useMemo(
+    () => items.filter((item) => item.selected).length,
+    [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) =>
+        prev.map((item) =>
+          item.id === activeId ? { ...item, modifiedContent: value } : item,
+        ),
+      )
+    },
+    [activeId],
+  )
+
+  const handleToggle = useCallback((id: string) => {
+    setItems((prev) =>
+      prev.map((item) =>
+        item.id === id ? { ...item, selected: !item.selected } : item,
+      ),
+    )
+  }, [])
+
+  const handleConfirm = useCallback(() => {
+    if (saving) return
+    setSaving(true)
+    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()
+
+  return (
+    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
+      <div className="flex h-[85vh] w-full max-w-7xl flex-col rounded-lg border bg-background shadow-2xl">
+        {/* 标题栏 */}
+        <div className="flex items-center justify-between border-b px-4 py-3">
+          <div className="flex items-center gap-2">
+            <FileText className="h-5 w-5 text-blue-500" />
+            <div>
+              <h3 className="font-semibold">{title}</h3>
+              {description && (
+                <p className="text-xs text-muted-foreground">{description}</p>
+              )}
+            </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"
+              aria-label="关闭"
+            >
+              <X className="h-4 w-4" />
+            </button>
+          </div>
+        </div>
+
+        {/* 主体:文件栏 + 工作区 */}
+        <div className="flex min-h-0 flex-1">
+          {/* 左侧文件栏 */}
+          {items.length > 1 && (
+            <div className="w-40 shrink-0 border-r bg-muted/20 px-2 py-3">
+              <div className="mb-2 text-[10px] font-medium text-muted-foreground px-1">
+                待写入文件
+              </div>
+              {items.map((item) => (
+                <label
+                  key={item.id}
+                  className={`flex cursor-pointer items-start gap-2 rounded-md px-2 py-1.5 text-xs hover:bg-accent ${
+                    activeId === item.id ? "bg-accent/60" : ""
+                  }`}
+                >
+                  <input
+                    aria-label={`保存 ${item.fileName}`}
+                    type="checkbox"
+                    checked={item.selected}
+                    onChange={() => handleToggle(item.id)}
+                    onClick={(e) => e.stopPropagation()}
+                    className="mt-0.5 h-3.5 w-3.5 shrink-0 accent-primary"
+                  />
+                  <span
+                    className="min-w-0 flex-1 truncate"
+                    onClick={() => setActiveId(item.id)}
+                  >
+                    {item.fileName}
+                    {!item.originalContent.trim() && (
+                      <span className="ml-1 text-[10px] text-blue-500">新建</span>
+                    )}
+                    {item.error && (
+                      <span className="block text-[10px] text-red-500">{item.error}</span>
+                    )}
+                  </span>
+                </label>
+              ))}
+            </div>
+          )}
+
+          {/* 中央工作区 */}
+          <div className="flex min-w-0 flex-1 flex-col">
+            {activeItem ? (
+              <>
+                <div className="flex items-center justify-between border-b px-3 py-1.5 text-xs text-muted-foreground">
+                  <span className="font-medium">{activeItem.fileName}</span>
+                  {isNew && (
+                    <span className="text-blue-600 dark:text-blue-400">新建文件</span>
+                  )}
+                </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>
+                )}
+              </>
+            ) : (
+              <div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
+                请从左侧选择文件
+              </div>
+            )}
+          </div>
+        </div>
+
+        {/* 底部栏 */}
+        <div className="flex items-center justify-between border-t px-4 py-3">
+          <span className="text-xs text-muted-foreground">
+            将保存 {selectedCount} 个文件
+          </span>
+          <div className="flex items-center gap-2">
+            <button
+              onClick={onClose}
+              className="rounded-md border px-4 py-1.5 text-sm hover:bg-accent"
+            >
+              取消
+            </button>
+            <button
+              onClick={handleConfirm}
+              disabled={selectedCount === 0 || saving}
+              className="flex items-center gap-1.5 rounded-md bg-primary px-4 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
+            >
+              <Check className="h-4 w-4" />
+              确认保存
+            </button>
+          </div>
+        </div>
+      </div>
+    </div>
+  )
+}

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

@@ -0,0 +1,81 @@
+import { useEffect, useRef, lazy, Suspense } from "react"
+import "./monaco-environment"
+
+interface MonacoDiffEditorProps {
+  originalValue: string
+  modifiedValue: string
+  onChange: (value: string) => void
+  language?: string
+}
+
+const MonacoEditor = lazy(() =>
+  import("@monaco-editor/react").then((mod) => ({ default: mod.DiffEditor })),
+)
+
+export function MonacoDiffEditor({
+  originalValue,
+  modifiedValue,
+  onChange,
+  language = "markdown",
+}: MonacoDiffEditorProps) {
+  const editorRef = useRef<Parameters<NonNullable<import("@monaco-editor/react").DiffEditorProps["onMount"]>>[0] | null>(null)
+  const preventSync = useRef(false)
+
+  useEffect(() => {
+    const editor = editorRef.current
+    if (!editor) return
+    const modified = editor.getModifiedEditor()
+    const disposable = modified.onDidChangeModelContent(() => {
+      if (preventSync.current) return
+      onChange(modified.getValue())
+    })
+    return () => disposable.dispose()
+  }, [onChange])
+
+  useEffect(() => {
+    const editor = editorRef.current
+    if (!editor) return
+    const model = editor.getModel()
+    if (!model) return
+    const currentModified = model.modified.getValue()
+    if (currentModified !== modifiedValue) {
+      preventSync.current = true
+      model.modified.setValue(modifiedValue)
+      preventSync.current = false
+    }
+  }, [modifiedValue])
+
+  return (
+    <Suspense
+      fallback={
+        <div className="flex h-full items-center justify-center text-sm text-muted-foreground">
+          正在加载编辑器…
+        </div>
+      }
+    >
+      <MonacoEditor
+        height="100%"
+        language={language}
+        original={originalValue}
+        modified={modifiedValue}
+        onMount={(editor) => {
+          editorRef.current = editor
+          editor.getModifiedEditor().updateOptions({ readOnly: false })
+        }}
+        options={{
+          readOnly: false,
+          renderSideBySide: true,
+          scrollBeyondLastLine: false,
+          minimap: { enabled: false },
+          lineNumbers: "on",
+          wordWrap: "on",
+          automaticLayout: true,
+          originalEditable: false,
+          diffCodeLens: false,
+          folding: false,
+          renderOverviewRuler: true,
+        }}
+      />
+    </Suspense>
+  )
+}

+ 32 - 0
src/components/common/monaco-environment.ts

@@ -0,0 +1,32 @@
+self.MonacoEnvironment = {
+  getWorker(_moduleId: string, label: string) {
+    if (label === "json") {
+      return new Worker(
+        new URL("monaco-editor/esm/vs/language/json/json.worker?worker", import.meta.url),
+        { type: "module" },
+      )
+    }
+    if (label === "css" || label === "scss" || label === "less") {
+      return new Worker(
+        new URL("monaco-editor/esm/vs/language/css/css.worker?worker", import.meta.url),
+        { type: "module" },
+      )
+    }
+    if (label === "html" || label === "handlebars" || label === "razor") {
+      return new Worker(
+        new URL("monaco-editor/esm/vs/language/html/html.worker?worker", import.meta.url),
+        { type: "module" },
+      )
+    }
+    if (label === "typescript" || label === "javascript") {
+      return new Worker(
+        new URL("monaco-editor/esm/vs/language/typescript/ts.worker?worker", import.meta.url),
+        { type: "module" },
+      )
+    }
+    return new Worker(
+      new URL("monaco-editor/esm/vs/editor/editor.worker?worker", import.meta.url),
+      { type: "module" },
+    )
+  },
+}

+ 2 - 9
src/components/layout/sidebar-panel.tsx

@@ -63,6 +63,7 @@ import {
   type ImportedChapter,
 } from "@/lib/novel/chapter-import"
 import { makeChapterFileName, makeDefaultChapterTitle, makeSafeFileSlug } from "@/lib/wiki-filename"
+import { buildPureOutlineMarkdown } from "@/lib/novel/outline-markdown"
 import { useImportProgressStore } from "@/stores/import-progress-store"
 import { openExternalUrl } from "@/lib/open-external-url"
 import type { ReferenceToken } from "@/lib/reference/types"
@@ -1131,15 +1132,7 @@ export function SidebarPanel() {
         await createDirectory(outlinesRoot).catch(() => {})
         await createDirectory(targetDir).catch(() => {})
         const filePath = await getUniqueWikiPagePath(targetDir, `${makeSafeFileSlug(title)}.md`)
-        const content = [
-          "---",
-          "type: outline",
-          `title: "${title.replace(/"/g, '\\"')}"`,
-          "---",
-          "",
-          `# ${title}`,
-          "",
-        ].join("\n")
+        const content = buildPureOutlineMarkdown(title, "")
         await writeFile(filePath, content)
         setPendingPages((prev) => [
           { path: filePath, title, type: "outline", tags: [] },

+ 9 - 25
src/components/novel/outline-editor.tsx

@@ -18,6 +18,7 @@ import type { OutlineType } from "@/lib/novel/chapter-meta";
 import { loadPlotFrameworkLibrary } from "@/lib/novel/plot-framework-library";
 import type { PlotFramework } from "@/lib/novel/plot-framework";
 import { runVolumeOutlineQualityCheck, type QualityCheckItem } from "@/lib/novel/outline-quality-check";
+import { buildPureOutlineMarkdown } from "@/lib/novel/outline-markdown";
 
 const OUTLINE_TYPES: { value: OutlineType; labelKey: string }[] = [
   { value: "story-outline", labelKey: "novel.outline.type.story" },
@@ -28,7 +29,7 @@ const OUTLINE_TYPES: { value: OutlineType; labelKey: string }[] = [
 interface OutlineCreatorDialogProps {
   open: boolean;
   onOpenChange: (open: boolean) => void;
-  /** 关联的剧情框架 ID(可选,写入章纲 frontmatter 时用) */
+  /** 关联的剧情框架 ID(可选,创建章纲时显示来源) */
   frameworkId?: string;
 }
 
@@ -100,29 +101,8 @@ export function OutlineCreatorDialog({
       const outlinesDir = `${pp}/wiki/outlines`;
       await createDirectory(outlinesDir);
 
-      const escapedTitle = title.trim().replace(/"/g, '\\"');
-      const frontmatterLines = [
-        "---",
-        `title: "${escapedTitle}"`,
-        `type: outline`,
-        `outline_type: ${outlineType}`,
-      ];
-
-      if (outlineType === "volume-outline" && volumeNumber) {
-        frontmatterLines.push(`volume_number: ${volumeNumber}`);
-      }
-      if (outlineType === "chapter-outline" && chapterNumber) {
-        frontmatterLines.push(`chapter_number: ${chapterNumber}`);
-      }
       const effectiveFrameworkId = selectedFrameworkId || frameworkId;
-      if (effectiveFrameworkId && outlineType === "chapter-outline") {
-        frontmatterLines.push(
-          `framework_id: "${effectiveFrameworkId.replace(/"/g, '\\"')}"`,
-        );
-      }
-
-      frontmatterLines.push("---");
-      frontmatterLines.push("");
+      const framework = frameworks.find((item) => item.id === effectiveFrameworkId);
 
       let fileName = title
         .trim()
@@ -137,8 +117,12 @@ export function OutlineCreatorDialog({
       }
 
       const filePath = `${outlinesDir}/${fileName}.md`;
-      const fullContent =
-        frontmatterLines.join("\n") + `# ${title.trim()}\n\n`;
+      const fullContent = buildPureOutlineMarkdown(
+        title.trim(),
+        framework && outlineType === "chapter-outline"
+          ? `> 关联剧情框架:${framework.title}`
+          : "",
+      );
       await writeFile(filePath, fullContent);
 
       const tree = await listDirectory(pp);

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

@@ -696,11 +696,14 @@ describe("OutlineChatPanel controls", () => {
   })
 
   it("uses save confirm dialog for classified outline saves", () => {
-    expect(source).toContain("OutlineSaveConfirmDialog")
+    expect(source).toContain("AiChangeReview")
+    expect(source).toContain("AiChangeReviewItem")
     expect(source).toContain("extractCharacterSaveDrafts")
     expect(source).toContain("classifyOutlineSaveTarget")
     expect(source).toContain("characterDraftsToSaveRequests")
     expect(source).toContain("splitConfirmRequiredSaveRequests")
+    expect(source).toContain("reviewItems")
+    expect(source).toContain("confirmed: true")
   })
 
   it("does not silently auto-save character requests without confirmation", () => {

+ 62 - 19
src/components/sources/outline-chat-panel.tsx

@@ -48,10 +48,6 @@ 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";
@@ -112,6 +108,8 @@ 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,
@@ -1470,6 +1468,7 @@ 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);
@@ -1537,13 +1536,19 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
   }, [isStreaming]);
 
   const executeConfirmedOutlineSave = useCallback(
-    async (payload: OutlineSaveConfirmPayload) => {
+    async (reviewItems: AiChangeReviewItem[]) => {
       if (!project) return;
       const projectPath = normalizePath(project.path);
-      const requests = payload.characterDrafts.length > 0
-        ? characterDraftsToSaveRequests(payload.characterDrafts, "保存人物小传")
-        : payload.requests;
-      if (requests.length === 0) {
+      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) {
         setSaveStatus("没有选择需要保存的内容。");
         return;
       }
@@ -1552,7 +1557,8 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
       try {
         const saveResult = await saveOutlineSaveRequests({
           outlineRoot: `${projectPath}/wiki/outlines`,
-          requests,
+          confirmed: true,
+          requests: confirmRequests,
           createDirectory,
           fileExists,
           readFile,
@@ -1576,7 +1582,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         setSaveStatus(`保存失败:${error instanceof Error ? error.message : String(error)}`);
       }
     },
-    [project],
+    [project, saveConfirmState],
   );
 
   const handleAutoSaveOutlineRequests = useCallback(
@@ -1668,16 +1674,31 @@ 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 {
+            const fallbackDrafts = buildFallbackCharacterDraftsFromRequests(
+              split.confirmRequired,
+            );
             setSaveConfirmState({
               title: "请确认要保存的人物角色",
               mode: "character",
               requests: [],
-              characterDrafts: buildFallbackCharacterDraftsFromRequests(
-                split.confirmRequired,
-              ),
+              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(";")}`,
@@ -3367,6 +3388,13 @@ 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;
         }
@@ -3406,6 +3434,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         setSaveConfirmState({
           title: "保存大纲文件",
           mode: "normal",
+          characterDrafts: [],
           requests: [{
             targetFolder: classification.targetFolder,
             fileName: classification.fileName,
@@ -3415,7 +3444,23 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             sourceIntent: "手动保存 AI 大纲结果",
             content: mdContent,
           }],
-          characterDrafts: [],
+          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(
@@ -3930,12 +3975,10 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           onConfirm={confirmClearHistory}
         />
         {saveConfirmState ? (
-          <OutlineSaveConfirmDialog
+          <AiChangeReview
             open
             title={saveConfirmState.title}
-            mode={saveConfirmState.mode}
-            requests={saveConfirmState.requests}
-            characterDrafts={saveConfirmState.characterDrafts}
+            items={saveConfirmState.reviewItems ?? []}
             onClose={() => setSaveConfirmState(null)}
             onConfirm={executeConfirmedOutlineSave}
           />

+ 52 - 0
src/lib/novel/context-engine-outline-read.spec.ts

@@ -0,0 +1,52 @@
+import { beforeEach, describe, expect, it, vi } from "vitest"
+
+vi.mock("@/commands/fs", () => ({
+  listDirectory: vi.fn(),
+  readFile: vi.fn(),
+}))
+vi.mock("@/lib/search", () => ({
+  searchWiki: vi.fn(),
+  tokenizeQuery: vi.fn(() => []),
+}))
+
+import { listDirectory, readFile } from "@/commands/fs"
+import { searchWiki } from "@/lib/search"
+import { readOutlineContent } from "./context-engine"
+
+describe("纯 Markdown 大纲上下文读取", () => {
+  beforeEach(() => {
+    vi.clearAllMocks()
+    vi.mocked(searchWiki).mockResolvedValue([])
+  })
+
+  it("搜索索引没有 type:outline 时从大纲目录读取并剥离历史 YAML", async () => {
+    vi.mocked(listDirectory).mockResolvedValue([{
+      name: "章纲",
+      path: "C:/book/wiki/outlines/章纲",
+      is_dir: true,
+      children: [{
+        name: "第001章.md",
+        path: "C:/book/wiki/outlines/章纲/第001章.md",
+        is_dir: false,
+      }],
+    }])
+    vi.mocked(readFile).mockResolvedValue([
+      "---",
+      "type: outline",
+      "outline_type: chapter-outline",
+      "---",
+      "",
+      "# 第001章章纲",
+      "",
+      "- 主角进入旧城",
+    ].join("\n"))
+
+    const result = await readOutlineContent("C:/book")
+
+    expect(listDirectory).toHaveBeenCalledWith("C:/book/wiki/outlines")
+    expect(result).toContain("# 第001章章纲")
+    expect(result).toContain("主角进入旧城")
+    expect(result).not.toContain("type: outline")
+    expect(result).not.toContain("outline_type:")
+  })
+})

+ 11 - 2
src/lib/novel/context-engine.ts

@@ -20,6 +20,7 @@ import {
 } from "./context-data-source"
 import { getAllDataSources, getDataSourcesForCategories } from "./context-data-sources"
 import type { DataSourceCategory } from "./classification"
+import { stripOutlineFrontmatter } from "./outline-markdown"
 
 const FIELD_PRIORITY: Record<string, number> = {
   sectionBriefing: 0,
@@ -418,15 +419,23 @@ export async function readOutlineContent(pp: string): Promise<string> {
       const contents = await Promise.all(
         results.map(async (result) => {
           try {
-            return await readFile(result.path)
+            return stripOutlineFrontmatter(await readFile(result.path))
           } catch {
             return ""
           }
         }),
       )
-      return joinNonEmpty(contents, "\n\n---\n\n")
+      return joinNonEmpty(contents, "\n\n")
     }
   } catch {}
+  try {
+    const tree = await listDirectory(`${pp}/wiki/outlines`)
+    const files = flattenOutlineMarkdownFiles(tree).slice(0, 80)
+    const contents = await Promise.all(
+      files.map(async (file) => stripOutlineFrontmatter(await readFile(file.path)).trim()),
+    )
+    return joinNonEmpty(contents, "\n\n")
+  } catch {}
   return ""
 }
 

+ 2 - 11
src/lib/novel/outline-generation.ts

@@ -20,6 +20,7 @@ import {
 } from "./chapter-ingest"
 import { buildContextPack, type ContextPack } from "./context-engine"
 import { resolveDefaultModel, resolveModelConfig, resolveNovelModel } from "@/lib/novel/model-resolver"
+import { buildPureOutlineMarkdown } from "./outline-markdown"
 
 export type OutlineSectionGenerationKey =
   | "chapterOutlines"
@@ -115,17 +116,7 @@ function getStoryOutlineFileName(): string {
 }
 
 function outlinePageMarkdown(title: string, content: string): string {
-  return [
-    "---",
-    "type: outline",
-    `title: "${title.replace(/"/g, '\\"')}"`,
-    "---",
-    "",
-    `# ${title}`,
-    "",
-    content.trim(),
-    "",
-  ].join("\n")
+  return buildPureOutlineMarkdown(title, content)
 }
 
 function appendContextSection(sections: string[], title: string, content: string | string[]) {

+ 2 - 34
src/lib/novel/outline-import.ts

@@ -2,6 +2,7 @@ import { createDirectory, fileExists, listDirectory, readFile, writeFile } from
 import { getFileName, getFileStem, getRelativePath, normalizePath } from "@/lib/path-utils"
 import type { FileNode } from "@/types/wiki"
 import { makeSafeFileSlug } from "@/lib/wiki-filename"
+import { buildPureOutlineMarkdown } from "./outline-markdown"
 
 export const OUTLINE_IMPORT_EXTENSIONS = [
   "md",
@@ -33,41 +34,8 @@ export interface OutlineImportCandidate {
   targetFolders: string[]
 }
 
-function yamlEscape(value: string): string {
-  return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
-}
-
-function sanitizeImportedBody(content: string): string {
-  let next = content.replace(/^\uFEFF/, "").trim()
-  const frontmatterMatch = next.match(/^---\n[\s\S]*?\n---\n?/)
-  if (frontmatterMatch) {
-    next = next.slice(frontmatterMatch[0].length).trim()
-  }
-  return next
-}
-
 function buildOutlineMarkdown(title: string, content: string): string {
-  const body = sanitizeImportedBody(content)
-  const lines = [
-    "---",
-    "type: outline",
-    `title: "${yamlEscape(title)}"`,
-    "---",
-    "",
-  ]
-
-  if (body.startsWith("#")) {
-    lines.push(body)
-  } else {
-    lines.push(`# ${title}`)
-    if (body) {
-      lines.push("")
-      lines.push(body)
-    }
-  }
-
-  lines.push("")
-  return lines.join("\n")
+  return buildPureOutlineMarkdown(title, content.replace(/^\uFEFF/, ""))
 }
 
 function isOutlineImportablePath(path: string): boolean {

+ 35 - 0
src/lib/novel/outline-markdown.spec.ts

@@ -0,0 +1,35 @@
+import { describe, expect, it } from "vitest"
+import { buildPureOutlineMarkdown, stripOutlineFrontmatter } from "./outline-markdown"
+
+describe("纯 Markdown 大纲", () => {
+  it("移除历史 YAML 并保留正文 Markdown 符号", () => {
+    const result = stripOutlineFrontmatter([
+      "---",
+      "type: outline",
+      "outline_type: chapter-outline",
+      "source_intent: \"生成章纲\"",
+      "---",
+      "",
+      "# 第001章章纲",
+      "",
+      "- 主角进入旧城",
+      "- **关键伏笔**浮现",
+    ].join("\n"))
+
+    expect(result).toBe("# 第001章章纲\n\n- 主角进入旧城\n- **关键伏笔**浮现\n")
+    expect(result).not.toContain("type: outline")
+    expect(result).not.toContain("---")
+  })
+
+  it("正文没有一级标题时补充标题", () => {
+    expect(buildPureOutlineMarkdown("故事总纲", "## 第一卷\n\n正文")).toBe(
+      "# 故事总纲\n\n## 第一卷\n\n正文\n",
+    )
+  })
+
+  it("正文已有一级标题时不重复添加", () => {
+    expect(buildPureOutlineMarkdown("不会重复", "# 已有标题\n\n正文\n\n")).toBe(
+      "# 已有标题\n\n正文\n",
+    )
+  })
+})

+ 21 - 0
src/lib/novel/outline-markdown.ts

@@ -0,0 +1,21 @@
+import { parseFrontmatter } from "@/lib/frontmatter"
+
+function withSingleTrailingNewline(value: string): string {
+  const trimmed = value.trim()
+  return trimmed ? `${trimmed}\n` : ""
+}
+
+export function stripOutlineFrontmatter(content: string): string {
+  return withSingleTrailingNewline(parseFrontmatter(content).body)
+}
+
+export function buildPureOutlineMarkdown(title: string, content: string): string {
+  const body = stripOutlineFrontmatter(content).trim()
+  if (/^#\s+\S/m.test(body) && body.match(/^#\s+\S/m)?.index === 0) {
+    return `${body}\n`
+  }
+
+  const cleanTitle = title.trim()
+  if (!cleanTitle) return body ? `${body}\n` : ""
+  return body ? `# ${cleanTitle}\n\n${body}\n` : `# ${cleanTitle}\n`
+}

+ 21 - 0
src/lib/novel/outline-pure-markdown-writers.spec.ts

@@ -0,0 +1,21 @@
+import { readFileSync } from "node:fs"
+import { resolve } from "node:path"
+import { describe, expect, it } from "vitest"
+
+const writerFiles = [
+  "../../components/layout/sidebar-panel.tsx",
+  "../../components/novel/outline-editor.tsx",
+  "outline-generation.ts",
+  "outline-import.ts",
+  "source-outline-import.ts",
+]
+
+describe("所有大纲创建入口使用纯 Markdown", () => {
+  it.each(writerFiles)("%s 不再拼接大纲 YAML", (relativePath) => {
+    const source = readFileSync(resolve(__dirname, relativePath), "utf8")
+
+    expect(source).toContain("buildPureOutlineMarkdown")
+    expect(source).not.toMatch(/["'`]type:\s*outline["'`]/)
+    expect(source).not.toMatch(/["'`]outline_type:/)
+  })
+})

+ 60 - 3
src/lib/novel/outline-save-request.spec.ts

@@ -1,4 +1,4 @@
-import { describe, expect, it } from "vitest"
+import { describe, expect, it, vi } from "vitest"
 import {
   characterDraftsToSaveRequests,
   formatOutlineSaveParseFeedback,
@@ -65,7 +65,7 @@ describe("outline-save-request", () => {
     expect(result.errors.join("\n")).toContain("不能使用绝对路径")
   })
 
-  it("创建文件时自动避开同名文件并写入前置信息", async () => {
+  it("创建文件时自动避开同名文件并写入纯 Markdown", async () => {
     const written = new Map<string, string>()
     const existing = new Set(["C:/book/wiki/outlines/章纲/章纲-第001章.md"])
 
@@ -93,7 +93,64 @@ describe("outline-save-request", () => {
       writeMode: "create",
     }])
     expect(written.get("C:/book/wiki/outlines/章纲/章纲-第001章-2.md"))
-      ?.toContain("source_intent: \"生成第001章章纲\"")
+      .toBe("# 章纲-第001章\n\n正文\n")
+  })
+
+  it.each(["replace", "patch"] as const)("未确认时继续跳过 %s 写入", async (writeMode) => {
+    const writeFile = vi.fn()
+
+    const result = await saveOutlineSaveRequests({
+      outlineRoot: "C:/book/wiki/outlines",
+      requests: [{
+        targetFolder: "章纲",
+        fileName: "章纲-第001章.md",
+        fileType: "chapter-outline",
+        writeMode,
+        referencedSkills: [],
+        sourceIntent: "修改第001章章纲",
+        content: "# 最新章纲\n\n正文",
+      }],
+      createDirectory: async () => {},
+      fileExists: async () => true,
+      readFile: async () => "# 原章纲\n\n旧正文\n",
+      writeFile,
+    })
+
+    expect(result.saved).toEqual([])
+    expect(result.skipped.join("\n")).toContain("需要用户明确确认")
+    expect(writeFile).not.toHaveBeenCalled()
+  })
+
+  it.each(["replace", "patch"] as const)("用户确认后允许 %s 写入原目标文件", async (writeMode) => {
+    const writeFile = vi.fn()
+
+    const result = await saveOutlineSaveRequests({
+      outlineRoot: "C:/book/wiki/outlines",
+      confirmed: true,
+      requests: [{
+        targetFolder: "章纲",
+        fileName: "章纲-第001章.md",
+        fileType: "chapter-outline",
+        writeMode,
+        referencedSkills: [],
+        sourceIntent: "修改第001章章纲",
+        content: "# 最新章纲\n\n正文",
+      }],
+      createDirectory: async () => {},
+      fileExists: async () => true,
+      readFile: async () => "# 原章纲\n\n旧正文\n",
+      writeFile,
+    })
+
+    expect(writeFile).toHaveBeenCalledWith(
+      "C:/book/wiki/outlines/章纲/章纲-第001章.md",
+      "# 最新章纲\n\n正文\n",
+    )
+    expect(result.saved).toEqual([{
+      path: "C:/book/wiki/outlines/章纲/章纲-第001章.md",
+      fileName: "章纲-第001章.md",
+      writeMode,
+    }])
   })
 
   it("把角色保存草稿转换为人物小传保存请求", () => {

+ 16 - 18
src/lib/novel/outline-save-request.ts

@@ -1,5 +1,6 @@
 import { normalizePath } from "@/lib/path-utils"
 import type { CharacterSaveDraft } from "./character-save-extractor"
+import { stripOutlineFrontmatter } from "./outline-markdown"
 
 export type OutlineSaveRequestFileType =
   | "outline"
@@ -318,23 +319,8 @@ export function splitConfirmRequiredSaveRequests(requests: OutlineSaveRequest[])
   }
 }
 
-function escapeYamlString(value: string): string {
-  return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
-}
-
 function buildSaveContent(request: OutlineSaveRequest): string {
-  const skillLine = request.referencedSkills.map((skill) => `  - "${escapeYamlString(skill)}"`).join("\n")
-  const frontmatter = [
-    "---",
-    "type: outline",
-    `outline_type: ${request.fileType}`,
-    `source_intent: "${escapeYamlString(request.sourceIntent)}"`,
-    "referenced_skills:",
-    skillLine || "  []",
-    "---",
-    "",
-  ].join("\n")
-  return `${frontmatter}${request.content.trim()}\n`
+  return stripOutlineFrontmatter(request.content)
 }
 
 async function resolveUniquePath(
@@ -362,6 +348,7 @@ async function resolveUniquePath(
 export async function saveOutlineSaveRequests(input: {
   outlineRoot: string
   requests: OutlineSaveRequest[]
+  confirmed?: boolean
 } & OutlineSaveRequestFs): Promise<OutlineSaveRequestSaveResult> {
   const outlineRoot = normalizePath(input.outlineRoot).replace(/\/+$/, "")
   const result: OutlineSaveRequestSaveResult = { saved: [], skipped: [], errors: [] }
@@ -371,18 +358,29 @@ export async function saveOutlineSaveRequests(input: {
     await input.createDirectory(targetDir)
 
     if (request.writeMode === "replace" || request.writeMode === "patch") {
-      result.skipped.push(`已跳过 ${request.fileName}:${request.writeMode} 需要用户明确确认。`)
+      if (!input.confirmed) {
+        result.skipped.push(`已跳过 ${request.fileName}:${request.writeMode} 需要用户明确确认。`)
+        continue
+      }
+      const targetPath = `${targetDir}/${request.fileName}`
+      await input.writeFile(targetPath, buildSaveContent(request))
+      result.saved.push({ path: targetPath, fileName: request.fileName, writeMode: request.writeMode })
       continue
     }
 
     if (request.writeMode === "append") {
       const targetPath = `${targetDir}/${request.fileName}`
+      if (input.confirmed) {
+        await input.writeFile(targetPath, buildSaveContent(request))
+        result.saved.push({ path: targetPath, fileName: request.fileName, writeMode: request.writeMode })
+        continue
+      }
       if (!input.readFile) {
         result.skipped.push(`已跳过 ${request.fileName}:当前环境缺少追加读取能力。`)
         continue
       }
       const original = await input.fileExists(targetPath) ? await input.readFile(targetPath) : ""
-      await input.writeFile(targetPath, `${original.replace(/\s*$/, "\n\n")}${request.content.trim()}\n`)
+      await input.writeFile(targetPath, `${stripOutlineFrontmatter(original).replace(/\s*$/, "\n\n")}${stripOutlineFrontmatter(request.content).trim()}\n`)
       result.saved.push({ path: targetPath, fileName: request.fileName, writeMode: request.writeMode })
       continue
     }

+ 3 - 21
src/lib/novel/source-outline-import.ts

@@ -1,6 +1,7 @@
 import { createDirectory, fileExists, readFile, writeFile } from "@/commands/fs"
 import { getFileName, getRelativePath, normalizePath } from "@/lib/path-utils"
 import { makeSafeFileSlug } from "@/lib/wiki-filename"
+import { buildPureOutlineMarkdown } from "./outline-markdown"
 
 export type SourceOutlineImportTarget =
   | "story-outline"
@@ -41,10 +42,6 @@ function getTargetConfig(target: SourceOutlineImportTarget): SourceOutlineImport
   return config
 }
 
-function yamlEscape(value: string): string {
-  return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
-}
-
 function stripExtension(fileName: string): string {
   return fileName.replace(/\.[^.]+$/, "")
 }
@@ -77,25 +74,10 @@ function buildSourceSection(sourcePath: string, relativeSourcePath: string, sour
 }
 
 function buildOutlinePage(
-  config: SourceOutlineImportConfig,
   pageTitle: string,
-  relativeSourcePath: string,
   section: string,
 ): string {
-  const frontmatter = [
-    "---",
-    "type: outline",
-    `title: "${yamlEscape(pageTitle)}"`,
-    ...(config.outlineType ? [`outline_type: ${config.outlineType}`] : []),
-    `outline_category: ${config.category}`,
-    `outline_folder: "${yamlEscape(config.folderName)}"`,
-    `sources: ["${yamlEscape(relativeSourcePath)}"]`,
-    "---",
-    "",
-    `# ${pageTitle}`,
-    "",
-  ]
-  return `${frontmatter.join("\n")}${section}`
+  return buildPureOutlineMarkdown(pageTitle, section)
 }
 
 export async function addSourceToOutlineCategory(
@@ -117,6 +99,6 @@ export async function addSourceToOutlineCategory(
 
   await createDirectory(outlinesDir)
   await createDirectory(targetDir)
-  await writeFile(outlinePath, buildOutlinePage(config, pageTitle, relativeSourcePath, section))
+  await writeFile(outlinePath, buildOutlinePage(pageTitle, section))
   return outlinePath
 }