瀏覽代碼

fix(outline): 禁止章纲静默落盘并切断双写通道

大纲保存统一走 outlineSaveRequests 确认弹窗,禁用 write_outline_node;
收紧空 content 回填,拒绝将摘要/下一步推荐写入章纲文件。

Co-authored-by: Cursor <cursoragent@cursor.com>
darknessomi 1 月之前
父節點
當前提交
dda40130c8

+ 2 - 2
skills/SkillHub/AI_OUTLINE_OUTPUT_PROTOCOL.md

@@ -1,6 +1,6 @@
 # AI 大纲输出协议
 # AI 大纲输出协议
 
 
-本协议用于 AI 大纲对话区。AI 生成任何大纲、章纲、人物、设定、伏笔内容时,必须同时给出可保存的结构化结果,便于系统自动写入左侧大纲文件树。
+本协议用于 AI 大纲对话区。AI 生成任何大纲、章纲、人物、设定、伏笔内容时,必须同时给出可保存的结构化结果;系统解析后弹出确认,用户确认后才写入左侧大纲文件树。
 
 
 ## 1. 输出边界
 ## 1. 输出边界
 
 
@@ -24,7 +24,7 @@
 
 
 ## 3. 标准结果块
 ## 3. 标准结果块
 
 
-AI 回复中必须包含一个 `outlineSaveRequest` 代码块。系统优先读取此代码块完成自动保存
+AI 回复中必须包含一个 `outlineSaveRequest` 代码块(必须含完整 `content`)。系统优先读取此代码块,弹出确认后由用户决定是否写入;禁止静默落盘,禁止调用 `write_outline_node` 作为替代写入通道
 
 
 ```json
 ```json
 {
 {

+ 1 - 1
src/components/sources/outline-chat-multi-session.spec.tsx

@@ -75,7 +75,7 @@ describe("OutlineChatPanel 多会话运行状态", () => {
 
 
   it("自动保存错误使用右下角持久 Toast 且标题栏不再渲染 saveStatus", () => {
   it("自动保存错误使用右下角持久 Toast 且标题栏不再渲染 saveStatus", () => {
     expect(panelSource).toContain("toast.error(message, {")
     expect(panelSource).toContain("toast.error(message, {")
-    expect(panelSource).toContain('title: "自动保存失败"')
+    expect(panelSource).toContain('title: "大纲保存失败"')
     expect(panelSource).toContain("persistent: true")
     expect(panelSource).toContain("persistent: true")
     expect(panelSource).toContain("dedupeKey: `outline-auto-save:${message}`")
     expect(panelSource).toContain("dedupeKey: `outline-auto-save:${message}`")
     expect(panelSource).not.toContain("{saveStatus &&")
     expect(panelSource).not.toContain("{saveStatus &&")

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

@@ -494,12 +494,15 @@ describe("OutlineChatPanel controls", () => {
     expect(source).toContain("message.agentToolCalls?.length ? message.agentToolCalls : hiddenToolCalls")
     expect(source).toContain("message.agentToolCalls?.length ? message.agentToolCalls : hiddenToolCalls")
   })
   })
 
 
-  it("uses an outline-only tool set that cannot write chapters or memory", () => {
+  it("uses an outline-only tool set that cannot write chapters, memory, or outline nodes", () => {
     expect(source).toContain("OUTLINE_CHAT_DISABLED_TOOLS")
     expect(source).toContain("OUTLINE_CHAT_DISABLED_TOOLS")
     expect(source).toContain('"write_chapter"')
     expect(source).toContain('"write_chapter"')
     expect(source).toContain('"write_memory"')
     expect(source).toContain('"write_memory"')
+    expect(source).toContain('"write_outline_node"')
     expect(source).toContain("disabledTools: OUTLINE_CHAT_DISABLED_TOOLS")
     expect(source).toContain("disabledTools: OUTLINE_CHAT_DISABLED_TOOLS")
-    expect(source).toContain("需要保存大纲时只能使用 write_outline_node")
+    expect(source).toContain("禁止调用 write_outline_node")
+    expect(source).toContain("用户确认后才写入文件")
+    expect(source).toContain("content 字段强制要求")
     expect(source).toContain("核心事件不少于6条")
     expect(source).toContain("核心事件不少于6条")
     expect(source).toContain("用户确认前不得生成完整文件")
     expect(source).toContain("用户确认前不得生成完整文件")
   })
   })
@@ -680,12 +683,14 @@ describe("OutlineChatPanel controls", () => {
     expect(source).toContain("includeWarnings: true")
     expect(source).toContain("includeWarnings: true")
   })
   })
 
 
-  it("auto-saves structured AI outline save requests from assistant output", () => {
+  it("parses structured AI outline save requests and requires user confirmation before writing", () => {
     expect(source).toContain("parseOutlineSaveRequests")
     expect(source).toContain("parseOutlineSaveRequests")
     expect(source).toContain("formatOutlineSaveParseFeedback")
     expect(source).toContain("formatOutlineSaveParseFeedback")
     expect(source).toContain("saveOutlineSaveRequests")
     expect(source).toContain("saveOutlineSaveRequests")
     expect(source).toContain("outlineSaveRequest")
     expect(source).toContain("outlineSaveRequest")
-    expect(source).toContain("已自动保存")
+    expect(source).toContain("检测到可保存大纲,请确认后写入")
+    expect(source).toContain("请确认要保存的大纲文件")
+    expect(source).not.toContain("已自动保存")
     expect(source).toContain("AI 大纲输出协议")
     expect(source).toContain("AI 大纲输出协议")
   })
   })
 
 
@@ -712,9 +717,11 @@ describe("OutlineChatPanel controls", () => {
     expect(source).toContain("confirmed: true")
     expect(source).toContain("confirmed: true")
   })
   })
 
 
-  it("does not silently auto-save character requests without confirmation", () => {
+  it("does not silently auto-save outline requests without confirmation", () => {
     expect(source).toContain("confirmRequired")
     expect(source).toContain("confirmRequired")
     expect(source).toContain("请确认要保存的人物角色")
     expect(source).toContain("请确认要保存的人物角色")
+    expect(source).toContain("请确认要保存的大纲文件")
+    expect(source).toContain("pendingNormalSaveRequestsRef")
   })
   })
 
 
   it("keeps a confirmation fallback when character extraction fails", () => {
   it("keeps a confirmation fallback when character extraction fails", () => {

+ 56 - 68
src/components/sources/outline-chat-panel.tsx

@@ -237,15 +237,12 @@ import {
 
 
 type OutlineSendResult = { started: boolean; sent: boolean };
 type OutlineSendResult = { started: boolean; sent: boolean };
 
 
-const OUTLINE_CHAT_DISABLED_TOOLS = ["write_chapter", "write_memory"];
-const OUTLINE_CHAT_WIZARD_DISABLED_TOOLS = [
-  ...OUTLINE_CHAT_DISABLED_TOOLS,
-  "write_outline_node",
-];
+const OUTLINE_CHAT_DISABLED_TOOLS = ["write_chapter", "write_memory", "write_outline_node"];
+const OUTLINE_CHAT_WIZARD_DISABLED_TOOLS = [...OUTLINE_CHAT_DISABLED_TOOLS];
 
 
 function showOutlineAutoSaveError(message: string) {
 function showOutlineAutoSaveError(message: string) {
   toast.error(message, {
   toast.error(message, {
-    title: "自动保存失败",
+    title: "大纲保存失败",
     persistent: true,
     persistent: true,
     dedupeKey: `outline-auto-save:${message}`,
     dedupeKey: `outline-auto-save:${message}`,
   });
   });
@@ -351,7 +348,7 @@ export function buildOutlineAgentSystemPrompt(options: {
     "你必须通过可用工具读取项目大纲、章节、记忆、推演结果和历史对话后,再进行分析、回答、生成或修改建议。",
     "你必须通过可用工具读取项目大纲、章节、记忆、推演结果和历史对话后,再进行分析、回答、生成或修改建议。",
     "如果用户提供 @ 引用,必须优先按路径、标题或会话ID调用对应读取工具获取正文内容。",
     "如果用户提供 @ 引用,必须优先按路径、标题或会话ID调用对应读取工具获取正文内容。",
     "不要假设引用内容已经注入上下文;不要跳过工具直接空泛回答。",
     "不要假设引用内容已经注入上下文;不要跳过工具直接空泛回答。",
-    "回答必须基于已读取内容进行分析,说明关键判断依据;需要保存大纲时只能使用 write_outline_node;生成可保存内容时必须同时输出 outlineSaveRequest 或 outlineSaveRequests JSON 块供系统自动归档。",
+    "回答必须基于已读取内容进行分析,说明关键判断依据;需要保存大纲时只输出 outlineSaveRequest 或 outlineSaveRequests JSON 块,禁止调用 write_outline_node;系统解析后弹出确认,用户确认后才写入文件。",
     "## AI大纲固定分析流程",
     "## AI大纲固定分析流程",
     "1. 先调用 list_outlines、list_chapters、list_memories、list_deductions 确认可用资料范围。",
     "1. 先调用 list_outlines、list_chapters、list_memories、list_deductions 确认可用资料范围。",
     "2. 再调用 read_outline、read_chapter、read_memory、read_deduction 读取用户 @ 引用和相关项目内容。",
     "2. 再调用 read_outline、read_chapter、read_memory、read_deduction 读取用户 @ 引用和相关项目内容。",
@@ -384,12 +381,12 @@ export function buildOutlineAgentSystemPrompt(options: {
     "Markdown 格式约束:结构化资料使用一级标题,** 必须成对,不要用代码围栏包裹全文,已有表格必须保留合法分隔行。",
     "Markdown 格式约束:结构化资料使用一级标题,** 必须成对,不要用代码围栏包裹全文,已有表格必须保留合法分隔行。",
     "## AI 大纲输出协议",
     "## AI 大纲输出协议",
     "当本轮生成了可保存的大纲、卷纲、章纲、人物、设定、伏笔、组织或质量检查内容时,最终回复末尾必须附加一个 json 代码块,顶层字段为 outlineSaveRequest 或 outlineSaveRequests。",
     "当本轮生成了可保存的大纲、卷纲、章纲、人物、设定、伏笔、组织或质量检查内容时,最终回复末尾必须附加一个 json 代码块,顶层字段为 outlineSaveRequest 或 outlineSaveRequests。",
-    "保存请求必须包含 targetFolder、fileName、fileType、writeMode、referencedSkills、sourceIntent。fileName 必须是 .md 文件,targetFolder 必须是相对路径(仅文件夹名,如「大纲」「人物小传」「章纲」「设定」「伏笔」「组织」「质量检查」「卷纲」),禁止使用绝对路径(如 C:\\... 或 /Users/...)。",
+    "保存请求必须包含 targetFolder、fileName、fileType、writeMode、referencedSkills、sourceIntent、content。fileName 必须是 .md 文件,targetFolder 必须是相对路径(仅文件夹名,如「大纲」「人物小传」「章纲」「设定」「伏笔」「组织」「质量检查」「卷纲」),禁止使用绝对路径(如 C:\\... 或 /Users/...)。",
     "fileType 只能使用以下英文枚举值(禁止使用中文):outline(大纲)、volume-outline(卷纲)、chapter-outline(章纲)、character(人物小传)、setting(设定)、foreshadowing(伏笔)、organization(组织)、quality-report(质量检查)。",
     "fileType 只能使用以下英文枚举值(禁止使用中文):outline(大纲)、volume-outline(卷纲)、chapter-outline(章纲)、character(人物小传)、setting(设定)、foreshadowing(伏笔)、organization(组织)、quality-report(质量检查)。",
     "writeMode 只能使用以下英文枚举值(禁止使用其他值):create(新建文件)、append(追加到已有文件末尾)、replace(替换已有文件全部内容,需用户确认)、patch(局部修改,需用户确认)。禁止使用 overwrite、write、save 等其他值。",
     "writeMode 只能使用以下英文枚举值(禁止使用其他值):create(新建文件)、append(追加到已有文件末尾)、replace(替换已有文件全部内容,需用户确认)、patch(局部修改,需用户确认)。禁止使用 overwrite、write、save 等其他值。",
-    "content 字段说明:content 字段已废弃,不要在 JSON 中填写 content。系统会自动从你的回复正文中提取大纲内容作为保存内容,正文格式就是最终保存的文件格式。",
+    "content 字段强制要求:每个 outlineSaveRequest 必须包含完整 Markdown content,这是最终写入文件的正文。禁止省略 content;禁止只输出确认摘要或下一步推荐却声称已保存。多章时每个章节一个独立 request,各自携带完整章纲 content。",
     "文件名规范:不同类型内容必须使用不同文件名,禁止多项内容写入同一文件。不同角色必须每人一个独立文件(如 角色-主角林风.md、角色-反官方傲.md),严禁将所有角色塞入「角色卡.md」或同一文件。不同势力、不同伏笔、不同卷纲、不同章纲也必须各自独立文件。",
     "文件名规范:不同类型内容必须使用不同文件名,禁止多项内容写入同一文件。不同角色必须每人一个独立文件(如 角色-主角林风.md、角色-反官方傲.md),严禁将所有角色塞入「角色卡.md」或同一文件。不同势力、不同伏笔、不同卷纲、不同章纲也必须各自独立文件。",
-    "内容完整性强制要求:所有在对话正文中展示给用户的大纲内容,系统会自动提取并保存。你必须为每个生成的大纲模块都创建对应的保存请求(outlineSaveRequest),不能遗漏。如果生成了多个模块,使用 outlineSaveRequests 数组,每个模块一个请求对象。",
+    "内容完整性强制要求:你必须为每个生成的大纲模块都创建对应的保存请求(outlineSaveRequest),不能遗漏。如果生成了多个模块,使用 outlineSaveRequests 数组,每个模块一个请求对象。系统不会静默写入;用户确认后才会落盘。",
     "## Markdown 格式强制要求",
     "## Markdown 格式强制要求",
     "所有大纲正文必须使用标准 Markdown 格式输出,严格遵循以下标题层级规范:",
     "所有大纲正文必须使用标准 Markdown 格式输出,严格遵循以下标题层级规范:",
     "- 一级大标题(如全书核心设定、主要人物设定、分卷大纲等)使用 # 标记,独占一行",
     "- 一级大标题(如全书核心设定、主要人物设定、分卷大纲等)使用 # 标记,独占一行",
@@ -406,7 +403,7 @@ export function buildOutlineAgentSystemPrompt(options: {
     "- **身份:** 穿越者→清水村村民→清水社首领→异姓王→隐士",
     "- **身份:** 穿越者→清水村村民→清水社首领→异姓王→隐士",
     "- **核心技能:** 高中/大学化学知识(有机/无机化学基础)、物理常识、急救知识",
     "- **核心技能:** 高中/大学化学知识(有机/无机化学基础)、物理常识、急救知识",
     "- **性格:** 表面冷漠实则心软,前期被动应对,中后期主动布局",
     "- **性格:** 表面冷漠实则心软,前期被动应对,中后期主动布局",
-    "最终回复只输出大纲标题和大纲正文;如果内容需要自动保存,末尾附加 AI 大纲输出协议 JSON 保存块。禁止输出工具调用报告、分析过程、完成报告、下一步行动、无法直接保存的大段说明。",
+    "最终回复只输出大纲标题和大纲正文;如果内容需要保存,末尾附加 AI 大纲输出协议 JSON 保存块(含 content)。禁止输出工具调用报告、分析过程、完成报告、下一步行动、无法直接保存的大段说明。",
     "工具调用过程只应展示在工具调用 UI 中,不要混入最终正文。资料不足以生成完整正文时,先提出最少必要澄清问题,不要用流程说明冒充生成结果。",
     "工具调用过程只应展示在工具调用 UI 中,不要混入最终正文。资料不足以生成完整正文时,先提出最少必要澄清问题,不要用流程说明冒充生成结果。",
     "所有面向用户的回复必须使用中文。",
     "所有面向用户的回复必须使用中文。",
     options.projectName ? `当前项目:${options.projectName}` : "",
     options.projectName ? `当前项目:${options.projectName}` : "",
@@ -1494,6 +1491,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
   const userScrolledUpRef = useRef(false);
   const userScrolledUpRef = useRef(false);
   const lastScrollTopRef = useRef(0);
   const lastScrollTopRef = useRef(0);
   const pendingRepairMetaRef = useRef<Record<string, OutlineSaveRequest[]>>({});
   const pendingRepairMetaRef = useRef<Record<string, OutlineSaveRequest[]>>({});
+  const pendingNormalSaveRequestsRef = useRef<OutlineSaveRequest[]>([]);
 
 
   // Auto-scroll
   // Auto-scroll
   useEffect(() => {
   useEffect(() => {
@@ -1578,6 +1576,17 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           const names = saveResult.saved.map((item) => item.fileName).join("、");
           const names = saveResult.saved.map((item) => item.fileName).join("、");
           setSaveStatus(`已保存 ${saveResult.saved.length} 个文件:${names}`);
           setSaveStatus(`已保存 ${saveResult.saved.length} 个文件:${names}`);
           setSaveConfirmState(null);
           setSaveConfirmState(null);
+          const pendingNormal = pendingNormalSaveRequestsRef.current;
+          if (pendingNormal.length > 0) {
+            pendingNormalSaveRequestsRef.current = [];
+            setSaveConfirmState({
+              title: "请确认要保存的大纲文件",
+              mode: "normal",
+              requests: pendingNormal,
+              characterDrafts: [],
+            });
+            setSaveStatus("检测到可保存大纲,请确认后写入。");
+          }
           return;
           return;
         }
         }
         if (saveResult.skipped.length > 0) {
         if (saveResult.skipped.length > 0) {
@@ -1608,30 +1617,14 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
               content: body,
               content: body,
             }));
             }));
             delete pendingRepairMetaRef.current[conversationId];
             delete pendingRepairMetaRef.current[conversationId];
-            setSaveStatus("正在自动保存修订后的大纲...");
-            try {
-              const projectPath = normalizePath(project.path);
-              if (!canApply()) return;
-              const saveResult = await saveOutlineSaveRequests({
-                outlineRoot: `${projectPath}/wiki/outlines`,
-                requests: fallbackRequests,
-                createDirectory,
-                fileExists,
-                readFile,
-                writeFile,
-              });
-              if (!canApply()) return;
-              if (saveResult.saved.length > 0) {
-                await refreshProjectState(projectPath);
-                const names = saveResult.saved.map((item) => item.fileName).join("、");
-                setSaveStatus(`已保存修订后的大纲文件:${names}`);
-              } else if (saveResult.errors.length > 0) {
-                showOutlineAutoSaveError(saveResult.errors.slice(0, 2).join(";"));
-              }
-            } catch (error) {
-              if (!canApply()) return;
-              showOutlineAutoSaveError(error instanceof Error ? error.message : String(error));
-            }
+            if (!canApply()) return;
+            setSaveConfirmState({
+              title: "请确认要保存的修订大纲",
+              mode: "normal",
+              requests: fallbackRequests,
+              characterDrafts: [],
+            });
+            setSaveStatus("检测到可保存大纲,请确认后写入。");
             return;
             return;
           }
           }
         }
         }
@@ -1642,12 +1635,20 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
       }
       }
       delete pendingRepairMetaRef.current[conversationId];
       delete pendingRepairMetaRef.current[conversationId];
 
 
-      setSaveStatus("正在自动保存大纲...");
+      if (!canApply()) return;
       try {
       try {
-        const projectPath = normalizePath(project.path);
         const split = splitConfirmRequiredSaveRequests(parsed.requests);
         const split = splitConfirmRequiredSaveRequests(parsed.requests);
-        if (split.confirmRequired.length > 0) {
-          const characterContent = split.confirmRequired
+        const characterRequests = split.confirmRequired.filter(
+          (request) => request.fileType === "character",
+        );
+        const normalRequests = split.confirmRequired.filter(
+          (request) => request.fileType !== "character",
+        );
+
+        pendingNormalSaveRequestsRef.current = [];
+
+        if (characterRequests.length > 0) {
+          const characterContent = characterRequests
             .map((request) => request.content)
             .map((request) => request.content)
             .join("\n\n");
             .join("\n\n");
           const extracted = extractCharacterSaveDrafts(characterContent);
           const extracted = extractCharacterSaveDrafts(characterContent);
@@ -1660,9 +1661,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             });
             });
             setSaveStatus("检测到人物小传,请确认要保存的人物角色。");
             setSaveStatus("检测到人物小传,请确认要保存的人物角色。");
           } else {
           } else {
-            const fallbackDrafts = buildFallbackCharacterDraftsFromRequests(
-              split.confirmRequired,
-            );
+            const fallbackDrafts = buildFallbackCharacterDraftsFromRequests(characterRequests);
             setSaveConfirmState({
             setSaveConfirmState({
               title: "请确认要保存的人物角色",
               title: "请确认要保存的人物角色",
               mode: "character",
               mode: "character",
@@ -1673,34 +1672,23 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
               `无法自动拆分角色,请在保存前检查文件名和内容。${extracted.errors.join(";")}`,
               `无法自动拆分角色,请在保存前检查文件名和内容。${extracted.errors.join(";")}`,
             );
             );
           }
           }
+          if (normalRequests.length > 0) {
+            pendingNormalSaveRequestsRef.current = normalRequests;
+          }
+        } else if (normalRequests.length > 0) {
+          setSaveConfirmState({
+            title: "请确认要保存的大纲文件",
+            mode: "normal",
+            requests: normalRequests,
+            characterDrafts: [],
+          });
+          setSaveStatus("检测到可保存大纲,请确认后写入。");
         }
         }
-        if (split.autoSaveable.length === 0) return;
 
 
-        if (!canApply()) return;
-        const saveResult = await saveOutlineSaveRequests({
-          outlineRoot: `${projectPath}/wiki/outlines`,
-          requests: split.autoSaveable,
-          createDirectory,
-          fileExists,
-          readFile,
-          writeFile,
-        });
-        if (!canApply()) return;
-        if (saveResult.saved.length > 0) {
-          await refreshProjectState(projectPath);
-          const names = saveResult.saved.map((item) => item.fileName).join("、");
-          const skipped = saveResult.skipped.length > 0
-            ? `;${saveResult.skipped.slice(0, 2).join(";")}`
-            : "";
-          setSaveStatus(`已自动保存 ${saveResult.saved.length} 个大纲文件:${names}${skipped}`);
-          return;
-        }
-        if (saveResult.skipped.length > 0) {
-          setSaveStatus(saveResult.skipped.slice(0, 2).join(";"));
-          return;
-        }
-        if (saveResult.errors.length > 0 || parsed.errors.length > 0) {
-          showOutlineAutoSaveError([...saveResult.errors, ...parsed.errors].slice(0, 2).join(";"));
+        if (parsed.errors.length > 0 && characterRequests.length === 0 && normalRequests.length === 0) {
+          showOutlineAutoSaveError(
+            [...parsed.errors].slice(0, 2).join(";"),
+          );
         }
         }
       } catch (error) {
       } catch (error) {
         if (!canApply()) return;
         if (!canApply()) return;

+ 2 - 2
src/lib/novel/outline-quality-check.ts

@@ -175,8 +175,8 @@ export function buildOutlineGenerationQualityFeedback(input: {
       "",
       "",
       "重要要求:",
       "重要要求:",
       "1. 必须输出修订后的完整章纲正文(使用标准 Markdown 格式),不能只输出修改摘要或说明。",
       "1. 必须输出修订后的完整章纲正文(使用标准 Markdown 格式),不能只输出修改摘要或说明。",
-      "2. 系统会自动从你的回复正文中提取完整内容并保存,你不需要在 JSON 中重复输出 content。",
-      "3. 在回复末尾附加 outlineSaveRequest JSON,只需包含 targetFolder、fileName、fileType、writeMode、referencedSkills、sourceIntent 等元数据。fileType 只能用英文枚举(outline/volume-outline/chapter-outline/character/setting/foreshadowing/organization/quality-report),writeMode 只能用英文枚举(create/append/replace/patch),targetFolder 必须是相对路径(如「章纲」),禁止绝对路径。",
+      "2. 在回复末尾附加 outlineSaveRequest JSON,必须包含完整 content,以及 targetFolder、fileName、fileType、writeMode、referencedSkills、sourceIntent。fileType 只能用英文枚举(outline/volume-outline/chapter-outline/character/setting/foreshadowing/organization/quality-report),writeMode 只能用英文枚举(create/append/replace/patch),targetFolder 必须是相对路径(如「章纲」),禁止绝对路径。",
+      "3. 系统解析后会弹出确认,用户确认后才写入文件;禁止省略 content。",
       "4. 正文中必须包含完整的章纲所有必填章节,不能省略未修改的部分。",
       "4. 正文中必须包含完整的章纲所有必填章节,不能省略未修改的部分。",
     ].join("\n"),
     ].join("\n"),
   };
   };

+ 74 - 6
src/lib/novel/outline-save-request.spec.ts

@@ -8,6 +8,22 @@ import {
   splitConfirmRequiredSaveRequests,
   splitConfirmRequiredSaveRequests,
 } from "./outline-save-request"
 } from "./outline-save-request"
 
 
+const SAMPLE_CHAPTER_OUTLINE = [
+  "# 章纲-第001章",
+  "",
+  "## 本章目标",
+  "建立开局冲突",
+  "",
+  "## 核心事件",
+  "1. 主角觉醒",
+  "",
+  "## 场景顺序",
+  "1. 客栈",
+  "",
+  "## 章尾钩子",
+  "门外传来脚步声",
+].join("\n")
+
 describe("outline-save-request", () => {
 describe("outline-save-request", () => {
   it("解析 AI 大纲回复中的单个保存请求", () => {
   it("解析 AI 大纲回复中的单个保存请求", () => {
     const result = parseOutlineSaveRequests([
     const result = parseOutlineSaveRequests([
@@ -21,7 +37,7 @@ describe("outline-save-request", () => {
           writeMode: "create",
           writeMode: "create",
           referencedSkills: ["ZhanggangSkill/chapter-outline-builder"],
           referencedSkills: ["ZhanggangSkill/chapter-outline-builder"],
           sourceIntent: "生成第001章章纲",
           sourceIntent: "生成第001章章纲",
-          content: "# 章纲-第001章\n\n正文",
+          content: SAMPLE_CHAPTER_OUTLINE,
         },
         },
       }),
       }),
       "```",
       "```",
@@ -184,7 +200,7 @@ describe("outline-save-request", () => {
     }])
     }])
   })
   })
 
 
-  it("自动保存时将 character 请求分离为需要用户确认", () => {
+  it("所有大纲类型均需用户确认,禁止静默自动保存", () => {
     const result = splitConfirmRequiredSaveRequests([
     const result = splitConfirmRequiredSaveRequests([
       {
       {
         targetFolder: "人物小传",
         targetFolder: "人物小传",
@@ -206,8 +222,59 @@ describe("outline-save-request", () => {
       },
       },
     ])
     ])
 
 
-    expect(result.confirmRequired).toHaveLength(1)
-    expect(result.autoSaveable).toHaveLength(1)
+    expect(result.confirmRequired).toHaveLength(2)
+    expect(result.autoSaveable).toHaveLength(0)
+  })
+
+  it("多请求且正文无一级标题拆分时不共用同一份正文回填", () => {
+    const result = parseOutlineSaveRequests([
+      "### 下一步推荐",
+      "",
+      "当前前10章章纲已完成,可继续:",
+      "",
+      "```json",
+      JSON.stringify({
+        outlineSaveRequests: [
+          {
+            targetFolder: "章纲",
+            fileName: "第1章-分手.md",
+            fileType: "chapter-outline",
+            writeMode: "create",
+            referencedSkills: [],
+            sourceIntent: "确认写入",
+          },
+          {
+            targetFolder: "章纲",
+            fileName: "第2章-摆烂.md",
+            fileType: "chapter-outline",
+            writeMode: "create",
+            referencedSkills: [],
+            sourceIntent: "确认写入",
+          },
+        ],
+      }),
+      "```",
+    ].join("\n"))
+
+    expect(result.requests).toHaveLength(0)
+    expect(result.errors.some((item) => item.includes("缺少 content"))).toBe(true)
+  })
+
+  it("拒绝不像章纲的 chapter-outline content", () => {
+    const result = parseOutlineSaveRequests(JSON.stringify({
+      outlineSaveRequest: {
+        targetFolder: "章纲",
+        fileName: "第1章-分手.md",
+        fileType: "chapter-outline",
+        writeMode: "create",
+        referencedSkills: [],
+        sourceIntent: "确认写入",
+        content: "### 下一步推荐\n\n当前前10章章纲已完成,可继续:",
+      },
+    }))
+
+    expect(result.requests).toHaveLength(0)
+    expect(result.errors.some((item) => item.includes("内容不像章纲"))).toBe(true)
   })
   })
 
 
   it("保存请求解析失败时返回可操作的中文纠错提示", () => {
   it("保存请求解析失败时返回可操作的中文纠错提示", () => {
@@ -225,10 +292,11 @@ describe("outline-save-request", () => {
 
 
     const feedback = formatOutlineSaveParseFeedback(parsed.errors)
     const feedback = formatOutlineSaveParseFeedback(parsed.errors)
 
 
-    expect(feedback).toContain("自动保存失败")
+    expect(feedback).toContain("保存请求解析失败")
     expect(feedback).toContain("请让 AI 重新输出 outlineSaveRequest")
     expect(feedback).toContain("请让 AI 重新输出 outlineSaveRequest")
     expect(feedback).toContain("targetFolder")
     expect(feedback).toContain("targetFolder")
     expect(feedback).toContain("fileName")
     expect(feedback).toContain("fileName")
+    expect(feedback).toContain("content")
     expect(feedback).toContain("不会写入文件")
     expect(feedback).toContain("不会写入文件")
   })
   })
 
 
@@ -277,7 +345,7 @@ describe("outline-save-request", () => {
         writeMode: "overwrite",
         writeMode: "overwrite",
         referencedSkills: [],
         referencedSkills: [],
         sourceIntent: "测试",
         sourceIntent: "测试",
-        content: "正文",
+        content: SAMPLE_CHAPTER_OUTLINE,
       },
       },
     }))
     }))
 
 

+ 34 - 14
src/lib/novel/outline-save-request.ts

@@ -1,5 +1,7 @@
 import { normalizePath } from "@/lib/path-utils"
 import { normalizePath } from "@/lib/path-utils"
 import type { CharacterSaveDraft } from "./character-save-extractor"
 import type { CharacterSaveDraft } from "./character-save-extractor"
+import { cleanNextStepArtifacts } from "./outline-next-step"
+import { isLikelyChapterOutline } from "./outline-quality-check"
 import { stripOutlineFrontmatter } from "./outline-markdown"
 import { stripOutlineFrontmatter } from "./outline-markdown"
 
 
 export type OutlineSaveRequestFileType =
 export type OutlineSaveRequestFileType =
@@ -261,8 +263,14 @@ function splitBodyByH1(body: string): string[] {
   return sections.filter(Boolean)
   return sections.filter(Boolean)
 }
 }
 
 
+/** 章纲必须具备结构字段,避免「下一步推荐/确认摘要」因偶含「章纲」二字被误放行 */
+function hasChapterOutlineStructure(content: string, fileName: string): boolean {
+  if (!isLikelyChapterOutline(content, fileName)) return false
+  return /本章目标|核心事件|场景顺序|章尾钩子|章首钩子/.test(content)
+}
+
 function fillContentFromText(requests: OutlineSaveRequest[], text: string): OutlineSaveRequest[] {
 function fillContentFromText(requests: OutlineSaveRequest[], text: string): OutlineSaveRequest[] {
-  const body = extractBodyContent(text)
+  const body = cleanNextStepArtifacts(extractBodyContent(text))
   if (!body) return requests
   if (!body) return requests
 
 
   const fillOne = (request: OutlineSaveRequest, content: string): OutlineSaveRequest =>
   const fillOne = (request: OutlineSaveRequest, content: string): OutlineSaveRequest =>
@@ -274,10 +282,11 @@ function fillContentFromText(requests: OutlineSaveRequest[], text: string): Outl
 
 
   const sections = splitBodyByH1(body)
   const sections = splitBodyByH1(body)
   if (sections.length >= requests.length) {
   if (sections.length >= requests.length) {
-    return requests.map((request, i) => fillOne(request, sections[i] || body))
+    return requests.map((request, i) => fillOne(request, sections[i] || ""))
   }
   }
 
 
-  return requests.map((request) => fillOne(request, body))
+  // 多文件且无法按一级标题拆分时,禁止用同一份正文填满所有请求
+  return requests
 }
 }
 
 
 export function parseOutlineSaveRequests(text: string): OutlineSaveRequestParseResult {
 export function parseOutlineSaveRequests(text: string): OutlineSaveRequestParseResult {
@@ -301,13 +310,23 @@ export function parseOutlineSaveRequests(text: string): OutlineSaveRequestParseR
   }
   }
 
 
   const filled = fillContentFromText(requests, text)
   const filled = fillContentFromText(requests, text)
-  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,且无法从正文中提取。`)
-    })
-  }
+  const usable: OutlineSaveRequest[] = []
+  filled.forEach((request, index) => {
+    if (!request.content.trim()) {
+      errors.push(`第 ${index + 1} 个保存请求缺少 content,且无法从正文中提取。`)
+      return
+    }
+    if (
+      request.fileType === "chapter-outline"
+      && !hasChapterOutlineStructure(request.content, request.fileName)
+    ) {
+      errors.push(
+        `第 ${index + 1} 个保存请求「${request.fileName}」内容不像章纲(缺少本章目标/核心事件等),已拒绝写入。`,
+      )
+      return
+    }
+    usable.push(request)
+  })
 
 
   return { requests: usable, errors }
   return { requests: usable, errors }
 }
 }
@@ -318,8 +337,8 @@ export function formatOutlineSaveParseFeedback(errors: string[]): string {
   const preview = uniqueErrors.slice(0, 4).join(";")
   const preview = uniqueErrors.slice(0, 4).join(";")
   const remaining = uniqueErrors.length > 4 ? `;另有 ${uniqueErrors.length - 4} 项未列出` : ""
   const remaining = uniqueErrors.length > 4 ? `;另有 ${uniqueErrors.length - 4} 项未列出` : ""
   return [
   return [
-    `自动保存失败:${preview}${remaining}。`,
-    "请让 AI 重新输出 outlineSaveRequest,必须包含 targetFolder、fileName、fileType、writeMode、referencedSkills、sourceIntent。",
+    `保存请求解析失败:${preview}${remaining}。`,
+    "请让 AI 重新输出 outlineSaveRequest,必须包含 targetFolder、fileName、fileType、writeMode、referencedSkills、sourceIntent、content。",
     "当前内容不会写入文件。",
     "当前内容不会写入文件。",
   ].join("")
   ].join("")
 }
 }
@@ -346,8 +365,9 @@ export function splitConfirmRequiredSaveRequests(requests: OutlineSaveRequest[])
   confirmRequired: OutlineSaveRequest[]
   confirmRequired: OutlineSaveRequest[]
 } {
 } {
   return {
   return {
-    autoSaveable: requests.filter((request) => request.fileType !== "character"),
-    confirmRequired: requests.filter((request) => request.fileType === "character"),
+    // 所有大纲类型均需用户确认后写入,禁止静默落盘
+    autoSaveable: [],
+    confirmRequired: [...requests],
   }
   }
 }
 }