فهرست منبع

fix(outline): 修复手动保存为大纲写入空白文档

extractBodyContent 误删 markdown 围栏导致正文丢失;改为展开正文围栏并仅剥离 json 协议块,展示与保存共用清洗逻辑,空 content 不再落盘。

Co-authored-by: Cursor <cursoragent@cursor.com>
darknessomi 2 ماه پیش
والد
کامیت
4909950fe3

+ 1 - 1
src/components/editor/markdown-wrap.spec.ts

@@ -10,6 +10,6 @@ describe("markdown preview wrapping", () => {
     expect(filePreviewSource).toContain("overflowWrap: \"anywhere\"")
     expect(wikiReaderSource).toContain("overflowWrap: \"anywhere\"")
     expect(outlineChatSource).toContain("overflowWrap: \"anywhere\"")
-    expect(outlineChatSource).toContain("normalizeOutlineMarkdown")
+    expect(outlineChatSource).toContain("prepareOutlineSaveSourceContent")
   })
 })

+ 12 - 12
src/components/sources/outline-chat-panel.tsx

@@ -92,7 +92,7 @@ import {
   parseDynamicOutlinePlan,
 } from "@/lib/novel/outline-dynamic-agent-planner";
 import { buildScopedOutlineSubAgentContext } from "@/lib/novel/outline-agent-context";
-import { normalizeOutlineMarkdown, prepareOutlineSaveDraft } from "@/lib/outline-save";
+import { prepareOutlineSaveDraft, prepareOutlineSaveSourceContent } from "@/lib/outline-save";
 import {
   type CharacterSaveDraft,
   extractCharacterSaveDrafts,
@@ -919,9 +919,7 @@ function OutlineAssistantMessage({
   }>({ textContent: "", edits: [], hasEdits: false });
   const renderedMarkdownContent = useMemo(() => {
     const rawContent = parsed.textContent || answer;
-    const stripped = stripStructuredMarkers(rawContent);
-    const bodyContent = extractBodyContent(stripped);
-    return normalizeOutlineMarkdown(bodyContent || stripped);
+    return prepareOutlineSaveSourceContent(rawContent);
   }, [answer, parsed.textContent]);
   useEffect(() => {
     if (!answer) {
@@ -1603,10 +1601,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
       if (parsed.requests.length === 0) {
         const repairMeta = pendingRepairMetaRef.current[conversationId];
         if (repairMeta && repairMeta.length > 0) {
-          const body = assistantContent
-            .replace(/```(?:json)?\s*[\s\S]*?```/gi, "")
-            .replace(/```[\s\S]*?```/g, "")
-            .trim();
+          const body = extractBodyContent(assistantContent);
           if (body && isLikelyChapterOutline(body, repairMeta[0].fileName)) {
             const fallbackRequests: OutlineSaveRequest[] = repairMeta.map((meta) => ({
               ...meta,
@@ -3567,9 +3562,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         toast.error("当前没有活跃的对话,无法保存大纲");
         return;
       }
-      const stripped = stripStructuredMarkers(content);
-      const bodyContent = extractBodyContent(stripped);
-      const cleanedContent = cleanNextStepArtifacts(bodyContent || stripped);
+      const cleanedContent = prepareOutlineSaveSourceContent(content);
       if (!cleanedContent.trim()) {
         toast.error("内容为空,无法保存为大纲");
         return;
@@ -3578,6 +3571,10 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
       setSaveStatus("");
       try {
         const draft = prepareOutlineSaveDraft(cleanedContent, []);
+        if (!draft.content.trim()) {
+          toast.error("内容为空,无法保存为大纲");
+          return;
+        }
         const classification = classifyOutlineSaveTarget({
           title: draft.title,
           content: draft.content,
@@ -3622,7 +3619,10 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         }
 
         const body = draft.content.replace(/^#\s+.+(?:\r?\n){1,2}/, "").trim();
-        const mdContent = `# ${classification.fileName.replace(/\.md$/i, "")}\n\n${body}`;
+        const titleHeading = classification.fileName.replace(/\.md$/i, "");
+        const mdContent = body
+          ? `# ${titleHeading}\n\n${body}`
+          : draft.content.trim();
         setSaveConfirmState({
           title: "保存大纲文件",
           mode: "normal",

+ 130 - 0
src/lib/novel/outline-save-request.spec.ts

@@ -1,6 +1,7 @@
 import { describe, expect, it, vi } from "vitest"
 import {
   characterDraftsToSaveRequests,
+  extractBodyContent,
   formatOutlineSaveParseFeedback,
   parseOutlineSaveRequests,
   saveOutlineSaveRequests,
@@ -336,4 +337,133 @@ describe("outline-save-request", () => {
     expect(result.requests[1].writeMode).toBe("create")
     expect(result.requests[1].targetFolder).toBe("人物小传")
   })
+
+  it("前言 + markdown 围栏 + json 时提取完整大纲正文", () => {
+    const body = extractBodyContent([
+      "好的,以下是完整大纲:",
+      "",
+      "```markdown",
+      "# 修仙界总纲",
+      "",
+      "## 世界观",
+      "灵气复苏,门派林立。",
+      "```",
+      "",
+      "```json",
+      JSON.stringify({
+        outlineSaveRequest: {
+          targetFolder: "大纲",
+          fileName: "总纲.md",
+          fileType: "outline",
+          writeMode: "create",
+          referencedSkills: [],
+          sourceIntent: "生成总纲",
+        },
+      }),
+      "```",
+    ].join("\n"))
+
+    expect(body).toContain("修仙界总纲")
+    expect(body).toContain("灵气复苏")
+    expect(body).not.toContain("```")
+    expect(body).not.toContain("outlineSaveRequest")
+  })
+
+  it("纯文本大纲 + json 时保留正文并去掉协议块", () => {
+    const body = extractBodyContent([
+      "# 修仙界总纲",
+      "",
+      "## 世界观",
+      "灵气复苏",
+      "",
+      "```json",
+      JSON.stringify({
+        outlineSaveRequest: {
+          targetFolder: "大纲",
+          fileName: "总纲.md",
+          fileType: "outline",
+          writeMode: "create",
+          referencedSkills: [],
+          sourceIntent: "生成总纲",
+        },
+      }),
+      "```",
+    ].join("\n"))
+
+    expect(body).toContain("# 修仙界总纲")
+    expect(body).toContain("灵气复苏")
+    expect(body).not.toContain("outlineSaveRequest")
+  })
+
+  it("JSON 已有 content 时不被短前言覆盖", () => {
+    const result = parseOutlineSaveRequests([
+      "已生成大纲:",
+      "```json",
+      JSON.stringify({
+        outlineSaveRequest: {
+          targetFolder: "大纲",
+          fileName: "总纲.md",
+          fileType: "outline",
+          writeMode: "create",
+          referencedSkills: [],
+          sourceIntent: "测试",
+          content: "# 修仙界总纲\n\n## 世界观\n灵气复苏",
+        },
+      }),
+      "```",
+    ].join("\n"))
+
+    expect(result.errors).toEqual([])
+    expect(result.requests).toHaveLength(1)
+    expect(result.requests[0].content).toContain("灵气复苏")
+    expect(result.requests[0].content).not.toBe("已生成大纲:")
+  })
+
+  it("content 全空且无法从正文提取时剔除 request", () => {
+    const result = parseOutlineSaveRequests([
+      "```json",
+      JSON.stringify({
+        outlineSaveRequest: {
+          targetFolder: "大纲",
+          fileName: "总纲.md",
+          fileType: "outline",
+          writeMode: "create",
+          referencedSkills: [],
+          sourceIntent: "测试",
+        },
+      }),
+      "```",
+    ].join("\n"))
+
+    expect(result.requests).toHaveLength(0)
+    expect(result.errors.join("\n")).toContain("缺少 content")
+  })
+
+  it("无 content 时从 markdown 围栏正文填充保存请求", () => {
+    const result = parseOutlineSaveRequests([
+      "```markdown",
+      "# 修仙界总纲",
+      "",
+      "## 主线",
+      "夺宝筑基",
+      "```",
+      "",
+      "```json",
+      JSON.stringify({
+        outlineSaveRequest: {
+          targetFolder: "大纲",
+          fileName: "总纲.md",
+          fileType: "outline",
+          writeMode: "create",
+          referencedSkills: [],
+          sourceIntent: "生成总纲",
+        },
+      }),
+      "```",
+    ].join("\n"))
+
+    expect(result.errors).toEqual([])
+    expect(result.requests).toHaveLength(1)
+    expect(result.requests[0].content).toContain("夺宝筑基")
+  })
 })

+ 39 - 7
src/lib/novel/outline-save-request.ts

@@ -208,10 +208,38 @@ function collectRawRequests(payload: Record<string, unknown>): unknown[] {
   return []
 }
 
+function isOutlineSaveProtocolJson(inner: string): boolean {
+  const trimmed = inner.trim()
+  if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return false
+  try {
+    const payload = JSON.parse(trimmed) as unknown
+    if (!isRecord(payload)) return false
+    return "outlineSaveRequest" in payload || "outlineSaveRequests" in payload
+  } catch {
+    return /outlineSaveRequests?/i.test(trimmed)
+  }
+}
+
+/**
+ * 从 AI 回复中提取可保存的大纲正文:
+ * - 展开 markdown/md 围栏与无语言标记的正文围栏
+ * - 删除 json 协议块(及无语言标记但内容为 outlineSaveRequest 的围栏)
+ * - 保留其它语言代码围栏原样
+ */
 export function extractBodyContent(text: string): string {
   return text
-    .replace(/```(?:json)?\s*[\s\S]*?```/gi, "")
-    .replace(/```[\s\S]*?```/g, "")
+    .replace(
+      /```([a-zA-Z0-9_+-]*)[ \t]*\r?\n([\s\S]*?)\r?\n[ \t]*```/g,
+      (full, lang: string, inner: string) => {
+        const language = (lang || "").trim().toLowerCase()
+        if (language === "json") return ""
+        if (language === "markdown" || language === "md") return inner.trim()
+        if (!language) {
+          return isOutlineSaveProtocolJson(inner) ? "" : inner.trim()
+        }
+        return full
+      },
+    )
     .trim()
 }
 
@@ -237,16 +265,19 @@ function fillContentFromText(requests: OutlineSaveRequest[], text: string): Outl
   const body = extractBodyContent(text)
   if (!body) return requests
 
+  const fillOne = (request: OutlineSaveRequest, content: string): OutlineSaveRequest =>
+    request.content.trim() ? request : { ...request, content }
+
   if (requests.length === 1) {
-    return requests.map((r) => ({ ...r, content: body }))
+    return requests.map((request) => fillOne(request, body))
   }
 
   const sections = splitBodyByH1(body)
   if (sections.length >= requests.length) {
-    return requests.map((r, i) => ({ ...r, content: sections[i] || body }))
+    return requests.map((request, i) => fillOne(request, sections[i] || body))
   }
 
-  return requests.map((r) => ({ ...r, content: body }))
+  return requests.map((request) => fillOne(request, body))
 }
 
 export function parseOutlineSaveRequests(text: string): OutlineSaveRequestParseResult {
@@ -270,14 +301,15 @@ export function parseOutlineSaveRequests(text: string): OutlineSaveRequestParseR
   }
 
   const filled = fillContentFromText(requests, text)
-  const stillEmpty = filled.filter((r) => !r.content)
+  const usable = filled.filter((request) => request.content.trim())
+  const stillEmpty = filled.filter((request) => !request.content.trim())
   if (stillEmpty.length > 0) {
     stillEmpty.forEach((_, i) => {
       errors.push(`第 ${i + 1} 个保存请求缺少 content,且无法从正文中提取。`)
     })
   }
 
-  return { requests: filled, errors }
+  return { requests: usable, errors }
 }
 
 export function formatOutlineSaveParseFeedback(errors: string[]): string {

+ 32 - 1
src/lib/outline-save.spec.ts

@@ -1,5 +1,5 @@
 import { describe, expect, it } from "vitest"
-import { prepareOutlineSaveDraft } from "./outline-save"
+import { prepareOutlineSaveDraft, prepareOutlineSaveSourceContent } from "./outline-save"
 
 describe("outline save draft", () => {
   it("ignores frontmatter when deriving an outline title", () => {
@@ -49,4 +49,35 @@ describe("outline save draft", () => {
     expect(draft.content).not.toContain("```markdown")
     expect(draft.content).not.toContain("\\#")
   })
+
+  it("prepareOutlineSaveSourceContent 保留 markdown 围栏中的大纲正文", () => {
+    const source = prepareOutlineSaveSourceContent([
+      "好的,以下是完整大纲:",
+      "",
+      "```markdown",
+      "# 修仙界总纲",
+      "",
+      "## 世界观",
+      "灵气复苏,门派林立。",
+      "```",
+      "",
+      "```json",
+      JSON.stringify({
+        outlineSaveRequest: {
+          targetFolder: "大纲",
+          fileName: "总纲.md",
+          fileType: "outline",
+          writeMode: "create",
+          referencedSkills: [],
+          sourceIntent: "生成总纲",
+        },
+      }),
+      "```",
+    ].join("\n"))
+
+    expect(source).toContain("修仙界总纲")
+    expect(source).toContain("灵气复苏")
+    expect(source).not.toContain("outlineSaveRequest")
+    expect(source).not.toContain("```")
+  })
 })

+ 13 - 0
src/lib/outline-save.ts

@@ -1,4 +1,7 @@
 import { parseFrontmatter } from "./frontmatter"
+import { stripStructuredMarkers } from "./novel/outline-intent-clarity"
+import { cleanNextStepArtifacts } from "./novel/outline-next-step"
+import { extractBodyContent } from "./novel/outline-save-request"
 
 export interface OutlineSaveDraft {
   title: string
@@ -7,6 +10,16 @@ export interface OutlineSaveDraft {
 
 const DEFAULT_TITLE_PREFIX = "AI大纲"
 
+/**
+ * 将 AI 大纲回复清洗为可展示/可保存的正文(与「保存为大纲」同源)。
+ */
+export function prepareOutlineSaveSourceContent(raw: string): string {
+  const stripped = stripStructuredMarkers(raw)
+  const bodyContent = extractBodyContent(stripped)
+  const cleaned = cleanNextStepArtifacts(bodyContent || stripped)
+  return normalizeOutlineMarkdown(cleaned).trim()
+}
+
 export function prepareOutlineSaveDraft(content: string, existingTitles: string[]): OutlineSaveDraft {
   const parsed = parseFrontmatter(content)
   const body = normalizeOutlineMarkdown(parsed.body).trim()