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

feat(outline): 大纲对话新增计划模式

OutlineWorkflowMode 从 fast|standard 扩展为第三个互斥模式 plan,
按现有大纲结构(OUTLINE_SECTION_GENERATION_CONFIGS /
CHAPTER_OUTLINE_REQUIRED_SECTIONS / VOLUME_OUTLINE_REQUIRED_FIELDS)
派生各模块要素清单,要素不齐时先展开问答再出计划。

新增 outline_plan protocol:needs_input 每问强制 ≥3 个选项并自动补自定义
输入项,要素未齐时把 ready 强制降级为 needs_input,避免信息不足就生成大纲。
问答走 OutlineClarifyCard(一轮多问),计划确认走 OutlinePlanCard
(确认生成 / 修改计划 / 补充信息 / 取消)。

同时接回 outline-workflow-state 里此前空转的 collecting_requirements /
generation_plan / waiting_user_confirm 三个 stage,并修正
shouldShowOutlineWorkflowProcess 在计划模式下不展示工具过程的问题。

顺手清掉两条自 v3.2.15 起就一直失败的过时断言:forceRefresh: true 与
「节省约 0 Token」都是那次提交故意改掉的行为,测试漏改。

Co-authored-by: Cursor <cursoragent@cursor.com>
darknessomi 2 недель назад
Родитель
Сommit
f03bc7707b

+ 2 - 1
src/components/chat/context-trace-panel.spec.tsx

@@ -179,7 +179,8 @@ describe("ContextTracePanel selected skills", () => {
 
     expect(html).toContain("本次命中 1 项")
     expect(html).toContain("命中率 100%")
-    expect(html).toContain("节省约 0 Token")
+    // 没有截断就没有「节省」,不能显示 0 Token 这种无意义口径
+    expect(html).not.toContain("节省约")
     expect(html).not.toContain("Codex 线程累计实际用量")
   })
 

+ 234 - 3
src/components/sources/outline-chat-panel.spec.tsx

@@ -501,9 +501,11 @@ describe("OutlineChatPanel controls", () => {
     expect(source).not.toContain("conversations.map((conv) => (")
   })
 
-  it("标准菜单生成补 forceRefresh,收尾把工具过程留在对话里", () => {
+  it("标准菜单生成不强制清空数据源缓存,收尾把工具过程留在对话里", () => {
     expect(source).toContain("intentPhase: \"intent_analysis\"")
-    expect(source).toContain("forceRefresh: true")
+    // 大纲重新生成/后续生成只透传用户手动触发的强制刷新,不再自己写死 forceRefresh
+    expect(source).toContain("const forceRefresh = options.forceRefresh === true || forceRefreshNext")
+    expect(source).not.toContain("forceRefresh: true")
     expect(source).toContain("workflowMode: outlineMode")
     expect(source).toContain("intentPhase: options.intentPhase")
     expect(source).toContain("标准工作流必须把工具过程留在对话里")
@@ -1015,7 +1017,9 @@ describe("OutlineChatPanel controls", () => {
   it("截断残稿不会自动弹出保存确认", () => {
     expect(source).toContain("!deliverableTruncated")
     expect(source).toContain("isOutlineOutputTruncated")
-    expect(source).toMatch(/if \(intentProtocol\.kind === "none" && !intentProtocolError && !deliverableTruncated\)/)
+    expect(source).toMatch(
+      /intentProtocol\.kind === "none"\s*\n\s*&& !intentProtocolError\s*\n\s*&& !deliverableTruncated/,
+    )
     expect(source).toContain("handleAutoSaveOutlineRequests(capturedConvId, finalContent, isCurrentRun)")
     expect(source).toContain("isSaveableOutlineDeliverable")
     expect(source).toContain("生成完成后自动保存")
@@ -1072,6 +1076,184 @@ describe("OutlineChatPanel controls", () => {
     expect(container.textContent).not.toContain("再交给 AI 分析和追问")
   })
 
+  it("执行模式下拉里有互斥的计划模式选项", async () => {
+    Object.defineProperty(HTMLElement.prototype, "getBoundingClientRect", {
+      configurable: true,
+      value: () => ({
+        x: 10, y: 10, top: 400, left: 20, bottom: 432, right: 120, width: 80, height: 32,
+        toJSON: () => ({}),
+      }),
+    })
+    setOutlineConversations([conversation()], "outline-active")
+    const container = await renderOutlineChatPanel()
+    const trigger = container.querySelector<HTMLButtonElement>('[aria-label="AI 大纲执行模式"]')
+
+    await act(async () => {
+      trigger?.click()
+      await new Promise((resolve) => requestAnimationFrame(resolve))
+      await new Promise((resolve) => requestAnimationFrame(resolve))
+    })
+    const options = Array.from(document.body.querySelectorAll<HTMLButtonElement>("button"))
+      .filter((button) => button.getAttribute("role") === "option")
+    expect(options.map((option) => option.textContent)).toHaveLength(3)
+    const planOption = options.find((option) => option.textContent?.includes("计划"))
+    expect(planOption).toBeDefined()
+
+    await act(async () => { planOption?.click() })
+
+    expect(useWikiStore.getState().outlineWorkflowMode).toBe("plan")
+    expect(outlineModelPreferenceMocks.saveOutlineWorkflowMode).toHaveBeenCalledWith("plan")
+    expect(container.querySelector('[aria-label="AI 大纲执行模式"]')?.textContent).toContain("计划")
+  })
+
+  async function submitOutlineInput(container: HTMLElement, text: string) {
+    const input = container.querySelector<HTMLTextAreaElement>('[aria-label="引用输入框"]')
+    expect(input).not.toBeNull()
+    await act(async () => {
+      const setValue = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set
+      setValue?.call(input, text)
+      input?.dispatchEvent(new Event("input", { bubbles: true }))
+      input?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }))
+      for (let attempt = 0; attempt < 200; attempt += 1) {
+        if (useOutlineChatStore.getState().runStates["outline-active"]?.status !== "running") break
+        await new Promise((resolve) => setTimeout(resolve, 5))
+      }
+    })
+  }
+
+  function outlinePlanBlock(payload: unknown): string {
+    return `<!-- outline_plan -->\n${JSON.stringify(payload)}\n<!-- /outline_plan -->`
+  }
+
+  it("计划模式自由输入先做要素盘点,缺口渲染多问题追问卡片", async () => {
+    useWikiStore.setState({ outlineWorkflowMode: "plan" })
+    const calls: Array<{ system: string; user: string }> = []
+    vi.spyOn(AgentRunner.prototype, "run").mockImplementation(async (_config, _registry, messages, callbacks) => {
+      calls.push({
+        system: agentMessageContentText(messages.find((message) => message.role === "system")?.content ?? ""),
+        user: agentMessageContentText(messages.findLast((message) => message.role === "user")?.content ?? ""),
+      })
+      const text = outlinePlanBlock({
+        status: "needs_input",
+        module: "章节细纲",
+        elements: [{ key: "chapterRange", value: "第236章", source: "user", satisfied: true }],
+        missing: ["本章目标"],
+        questions: [{
+          id: "q1",
+          key: "chapterGoal",
+          question: "本章目标是什么?",
+          options: [
+            { id: "A", label: "推进主线", description: "" },
+            { id: "B", label: "铺垫伏笔", description: "" },
+            { id: "C", label: "兑现爽点", description: "" },
+          ],
+        }],
+      })
+      callbacks.onText(text)
+      callbacks.onDone()
+      return { toolCalls: [], roundsUsed: 1, finalText: text }
+    })
+    setOutlineConversations([conversation()], "outline-active")
+    const container = await renderOutlineChatPanel()
+
+    await submitOutlineInput(container, "把236章大纲补充详细")
+
+    expect(calls).toHaveLength(1)
+    expect(calls[0].system).toContain("## 本轮阶段:计划模式要素盘点")
+    expect(calls[0].system).not.toContain("本轮阶段:意图分析")
+    expect(calls[0].user).toContain("计划模式要素盘点")
+
+    const assistant = useOutlineChatStore.getState().conversations[0].messages
+      .findLast((message) => message.role === "assistant")
+    expect(assistant?.outlinePlanPhase).toBe("element_check")
+    expect(assistant?.outlinePlanProtocol?.status).toBe("needs_input")
+    expect(assistant?.outlinePlanError).toBeUndefined()
+    // 协议 JSON 只走卡片,不能漏进气泡
+    expect(container.textContent).not.toContain("outline_plan")
+    expect(container.textContent).not.toContain("needs_input")
+    expect(container.textContent).toContain("待补要素:本章目标")
+    expect(container.textContent).toContain("推进主线")
+    // 系统自动补齐的自定义输入项
+    expect(container.textContent).toContain("其它(我来补充描述)")
+    // 停机态不被复位,徽章留在收集要素
+    expect(container.textContent).toContain("收集要素")
+    expect(document.body.textContent).not.toContain("请确认要保存的大纲文件")
+  })
+
+  it("计划模式要素齐备才渲染计划卡片,未齐的 ready 被闸门打回追问", async () => {
+    useWikiStore.setState({ outlineWorkflowMode: "plan" })
+    const readyPlan = {
+      summary: "补齐三位主角小传",
+      steps: [{ id: "s1", title: "读取已有大纲", detail: "确认人物出场" }],
+      files: [{
+        targetFolder: "人物小传",
+        fileName: "角色-林风.md",
+        fileType: "character",
+        writeMode: "create",
+        elements: ["moduleRequirement"],
+      }],
+      order: "先主角后配角",
+      risks: [],
+      openQuestions: [],
+    }
+    vi.spyOn(AgentRunner.prototype, "run").mockImplementation(async (_config, _registry, _messages, callbacks) => {
+      const text = outlinePlanBlock({
+        status: "ready",
+        module: "人物小传",
+        elements: [],
+        missing: [],
+        questions: [],
+        plan: readyPlan,
+      })
+      callbacks.onText(text)
+      callbacks.onDone()
+      return { toolCalls: [], roundsUsed: 1, finalText: text }
+    })
+    setOutlineConversations([conversation()], "outline-active")
+    const container = await renderOutlineChatPanel()
+
+    await submitOutlineInput(container, "生成人物小传")
+
+    // 要素全空:ready 被强制降级为追问,不给确认按钮
+    expect(container.querySelector('[aria-label="确认生成计划"]')).toBeNull()
+    expect(container.querySelector('[aria-label="提交补充要素"]')).not.toBeNull()
+    const downgraded = useOutlineChatStore.getState().conversations[0].messages
+      .findLast((message) => message.role === "assistant")
+    expect(downgraded?.outlinePlanProtocol?.status).toBe("needs_input")
+    expect(downgraded?.outlinePlanProtocol?.plan).toBeUndefined()
+
+    vi.restoreAllMocks()
+    vi.spyOn(AgentRunner.prototype, "run").mockImplementation(async (_config, _registry, _messages, callbacks) => {
+      const text = outlinePlanBlock({
+        status: "ready",
+        module: "人物小传",
+        elements: [
+          { key: "generationScope", value: "全部缺失项", source: "user", satisfied: true },
+          { key: "existingBaseline", value: "已有主角设定", source: "project", satisfied: true },
+          { key: "moduleRequirement", value: "补三位主角", source: "user", satisfied: true },
+          { key: "storyConstraints", value: "遵守总纲设定", source: "project", satisfied: true },
+        ],
+        missing: [],
+        questions: [],
+        plan: readyPlan,
+      })
+      callbacks.onText(text)
+      callbacks.onDone()
+      return { toolCalls: [], roundsUsed: 1, finalText: text }
+    })
+
+    await submitOutlineInput(container, "生成人物小传")
+
+    expect(container.querySelector('[aria-label="确认生成计划"]')).not.toBeNull()
+    expect(container.querySelector('[aria-label="修改生成计划"]')).not.toBeNull()
+    expect(container.querySelector('[aria-label="补充生成要素"]')).not.toBeNull()
+    expect(container.querySelector('[aria-label="取消生成计划"]')).not.toBeNull()
+    expect(container.textContent).toContain("人物小传/角色-林风.md")
+    expect(container.textContent).toContain("等待确认计划")
+    // 计划仍未确认,不能进入保存确认
+    expect(document.body.textContent).not.toContain("请确认要保存的大纲文件")
+  })
+
   it("快速模式自由输入跳过意图分析,直接单轮生成", async () => {
     useWikiStore.setState({ outlineWorkflowMode: "fast" })
     const calls: Array<{ system: string; user: string }> = []
@@ -1144,6 +1326,55 @@ describe("OutlineChatPanel controls", () => {
     expect(document.body.textContent).not.toContain("请确认要保存的大纲文件")
   })
 
+  it("计划模式系统提示注入要素盘点规则并去掉意图分析段", () => {
+    const planningPrompt = buildOutlineAgentSystemPrompt({
+      projectName: "测试项目",
+      mode: "plan",
+      planModule: "章节细纲",
+    })
+
+    expect(planningPrompt).toContain("## AI大纲固定分析流程")
+    expect(planningPrompt).toContain("## 计划模式总则")
+    expect(planningPrompt).toContain("## 本轮阶段:计划模式要素盘点")
+    expect(planningPrompt).toContain("<!-- outline_plan -->")
+    expect(planningPrompt).toContain("chapterRange")
+    expect(planningPrompt).not.toContain("## 意图清晰度分析阶段")
+    // 盘点轮只允许输出协议块,不能再要求附加下一步推荐
+    expect(planningPrompt).not.toContain("## 下一步推荐输出")
+
+    const generationPrompt = buildOutlineAgentSystemPrompt({
+      projectName: "测试项目",
+      mode: "plan",
+    })
+
+    expect(generationPrompt).toContain("## 计划模式总则")
+    expect(generationPrompt).not.toContain("## 本轮阶段:计划模式要素盘点")
+    expect(generationPrompt).toContain("## 下一步推荐输出")
+    expect(generationPrompt).toContain("outlineSaveRequest")
+  })
+
+  it("三个入口在计划模式下都走要素盘点,不直接进生成", () => {
+    expect(source).toContain("const startOutlinePlanElementCheck = useCallback")
+    expect(source).toContain('planPhase: "element_check"')
+    expect(source).toContain("buildOutlinePlanElementCheckPrompt")
+    // 分项菜单
+    expect(source).toMatch(/if \(outlineMode === "plan"\) \{\s*\n\s*void startOutlinePlanElementCheck\(capturedConvId, \{\s*\n\s*module: title,/)
+    // 自由输入仍用生成类意图闸门
+    expect(source).toContain("const directRequest = classifyDirectOutlineGenerationRequest(text)")
+    expect(source).toMatch(/if \(outlineMode === "plan"\) \{\s*\n\s*return startOutlinePlanElementCheck\(capturedConvId, \{\s*\n\s*module: directRequest\.module,/)
+    // 向导不再短路到 generation
+    expect(source).toContain("// 计划模式不直接短路到生成:向导需求先当作要素输入做盘点")
+  })
+
+  it("计划模式盘点轮跳过自动保存并把停机态留在界面上", () => {
+    expect(source).toContain("&& !options.planPhase")
+    expect(source).toContain("advanceCapturedWorkflowStages([\n              \"sufficiency_check\",")
+    expect(source).toContain('if (stage === "collecting_requirements" || stage === "waiting_user_confirm") return stages')
+    // 成功收尾和 finally 都必须走带守卫的复位,否则停机态会被立刻抹掉
+    expect(source).toMatch(/saveToDisk\(\);\s*\n\s*resetCapturedWorkflowStageToIdle\(\);/)
+    expect(source).toMatch(/outlineConversationRunRegistry\.remove\(capturedConvId, controller\);\s*\n\s*resetCapturedWorkflowStageToIdle\(\);/)
+  })
+
   it("AI 大纲多 Agent 过程写入消息状态并渲染结构化面板", () => {
     expect(source).toContain('import { OutlineMultiAgentPanel } from "@/components/sources/outline-multi-agent-panel"')
     expect(source).toContain("multiAgentRun")

+ 390 - 30
src/components/sources/outline-chat-panel.tsx

@@ -247,7 +247,21 @@ import {
   extractNextStep,
   buildNextStepPromptSuffix,
 } from "@/lib/novel/outline-next-step";
+import {
+  buildOutlinePlanClarifyAnswerPrompt,
+  buildOutlinePlanElementCheckPrompt,
+  buildOutlinePlanExecutionPrompt,
+  buildOutlinePlanPhaseSystemRules,
+  parseOutlinePlanProtocol,
+  validateOutlinePlanProtocol,
+  type OutlinePlanAnswer,
+  type OutlinePlanPhase,
+  type OutlinePlanProtocol,
+} from "@/lib/novel/outline-plan-protocol";
+import { getOutlinePlanRequiredElements } from "@/lib/novel/outline-plan-elements";
 import { IntentOptionsCard } from "@/components/sources/outline-intent-options-card";
+import { OutlineClarifyCard } from "@/components/sources/outline-clarify-card";
+import { OutlinePlanCard } from "@/components/sources/outline-plan-card";
 import { NextStepCard } from "@/components/sources/outline-next-step-card";
 import { ConversationRunStatusIcon } from "@/components/common/conversation-run-status-icon";
 import { ConversationDeleteConfirmDialog } from "@/components/common/conversation-delete-confirm-dialog";
@@ -498,6 +512,12 @@ const OUTLINE_WORKFLOW_MODE_OPTIONS: Array<{
     description: "完整工作流",
     routeDescription: "先做意图分析或向导多 Agent,再生成可保存的大纲,并保留澄清与分步生成。",
   },
+  {
+    mode: "plan",
+    label: "计划",
+    description: "先问后写",
+    routeDescription: "先按大纲结构盘点要素,缺口用选项追问补齐,再生成计划并等你确认后才开始写。",
+  },
 ];
 
 export function buildOutlineAgentSystemPrompt(options: {
@@ -505,28 +525,45 @@ export function buildOutlineAgentSystemPrompt(options: {
   webResearchContext?: string;
   soulDoc?: string;
   mode?: OutlineWorkflowMode;
+  /** 计划模式的目标模块;只有在要素盘点轮才传,正文生成轮不传。 */
+  planModule?: string;
 }): string {
   const mode = resolveOutlineWorkflowMode(options.mode);
+  const sharedAnalysisRules = [
+    "你必须通过可用工具读取项目大纲、章节、记忆、推演结果和历史对话后,再进行分析、回答、生成或修改建议。",
+    "不要假设引用内容已经注入上下文;不要跳过工具直接空泛回答。",
+    "回答必须基于已读取内容进行分析,说明关键判断依据。",
+    "## AI大纲固定分析流程",
+    "1. 先调用 list_outlines、list_chapters、list_memories、list_deductions 确认可用资料范围。",
+    "2. 再调用 read_outline、read_chapter、read_memory、read_deduction 读取用户 @ 引用和相关项目内容。",
+    "3. 分析冲突、缺口、伏笔、角色动机和章节承接,明确哪些判断来自已读取资料。",
+    "4. 最后再生成大纲建议;没有完成读取和分析前,不要直接给出结论。",
+    "## AI大纲生成工作流",
+    "固定向导提交的小说生成需求必须先进入“需求分析/生成方案”阶段:先判断缺失信息,信息足够时只输出生成方案、文件清单、保存位置和生成顺序,并询问用户是否确认开始生成;用户确认前不得生成完整文件,不得调用保存工具。",
+    "需求分析必须执行充分性闸门:缺少篇幅、频道、题材、故事灵感、核心卖点、作品规模、主要人物方向、世界观/背景方向或预期章节结构时,只追问最关键缺口。",
+    "长篇小说必须先卷后章:先形成核心设定、总纲、卷节拍表、卷时间线和卷纲,再生成章纲;不得从灵感直接跳到全书章纲。",
+    "章纲采用滚动章纲方式:优先生成前 10 章或用户指定范围,后续依据已确认章纲继续补齐,避免一次性生成整本导致承接断裂。",
+    "生成章纲后必须列出新增设定写回清单,包含新增角色、势力、世界观规则、伏笔、地图地点和状态变化;用户确认前不得写入设定文件。",
+  ];
   const workflowRules = mode === "fast"
     ? [
       "快速模式下像普通对话一样直接出结果。可以按需读取必要上下文,但不要主动进入需求分析、意图分析或多 Agent 编排。",
       "用户要求生成或修改大纲时,直接输出可保存的大纲正文;不要先追问方案或等待确认才开始写。",
     ]
+    : mode === "plan"
+    ? [
+      ...sharedAnalysisRules,
+      "## 计划模式总则",
+      "计划模式不做意图清晰度分析,禁止输出 intent_clarity。要素齐备并经用户确认生成计划后,才允许生成大纲正文。",
+      ...(options.planModule
+        ? [buildOutlinePlanPhaseSystemRules(
+          options.planModule,
+          getOutlinePlanRequiredElements(options.planModule),
+        )]
+        : []),
+    ]
     : [
-      "你必须通过可用工具读取项目大纲、章节、记忆、推演结果和历史对话后,再进行分析、回答、生成或修改建议。",
-      "不要假设引用内容已经注入上下文;不要跳过工具直接空泛回答。",
-      "回答必须基于已读取内容进行分析,说明关键判断依据。",
-      "## AI大纲固定分析流程",
-      "1. 先调用 list_outlines、list_chapters、list_memories、list_deductions 确认可用资料范围。",
-      "2. 再调用 read_outline、read_chapter、read_memory、read_deduction 读取用户 @ 引用和相关项目内容。",
-      "3. 分析冲突、缺口、伏笔、角色动机和章节承接,明确哪些判断来自已读取资料。",
-      "4. 最后再生成大纲建议;没有完成读取和分析前,不要直接给出结论。",
-      "## AI大纲生成工作流",
-      "固定向导提交的小说生成需求必须先进入“需求分析/生成方案”阶段:先判断缺失信息,信息足够时只输出生成方案、文件清单、保存位置和生成顺序,并询问用户是否确认开始生成;用户确认前不得生成完整文件,不得调用保存工具。",
-      "需求分析必须执行充分性闸门:缺少篇幅、频道、题材、故事灵感、核心卖点、作品规模、主要人物方向、世界观/背景方向或预期章节结构时,只追问最关键缺口。",
-      "长篇小说必须先卷后章:先形成核心设定、总纲、卷节拍表、卷时间线和卷纲,再生成章纲;不得从灵感直接跳到全书章纲。",
-      "章纲采用滚动章纲方式:优先生成前 10 章或用户指定范围,后续依据已确认章纲继续补齐,避免一次性生成整本导致承接断裂。",
-      "生成章纲后必须列出新增设定写回清单,包含新增角色、势力、世界观规则、伏笔、地图地点和状态变化;用户确认前不得写入设定文件。",
+      ...sharedAnalysisRules,
       "## 意图清晰度分析阶段",
       "仅当系统明确标记本轮为“意图分析”时,才输出 intent_clarity;正文生成阶段严禁再次输出该标记。",
       "当本轮为意图分析时:",
@@ -548,10 +585,13 @@ export function buildOutlineAgentSystemPrompt(options: {
     "需要保存大纲时只输出 outlineSaveRequest 或 outlineSaveRequests JSON 块,禁止调用 write_outline_node;系统解析后弹出确认,用户确认后才写入文件。",
     ...workflowRules,
     "",
-    "## 下一步推荐输出",
-    "生成完成后,在回复末尾附加 <!-- next_step --> JSON 标记块。",
-    "推荐方向仅限大纲体系内(人物小传、组织势力、力量体系等),严禁推荐正文生成。",
-    "必须包含一个 id 为 D 的自定义选项。",
+    // 计划模式的要素盘点轮只允许输出协议块,这里不能再要求附加下一步推荐
+    ...(options.planModule ? [] : [
+      "## 下一步推荐输出",
+      "生成完成后,在回复末尾附加 <!-- next_step --> JSON 标记块。",
+      "推荐方向仅限大纲体系内(人物小传、组织势力、力量体系等),严禁推荐正文生成。",
+      "必须包含一个 id 为 D 的自定义选项。",
+    ]),
     ...(mode === "fast" ? [] : [
       "当用户要求生成、完善或续写任何大纲分项时,必须按 PRD 3.1 主流程执行:提取请求关键词,识别用户意图,按意图读取资料,提取对小说创作有用的关键内容,结合用户要用的 skill + soul.md 约束生成内容,再做结果强约束收敛。",
     ]),
@@ -989,6 +1029,9 @@ function OutlineAssistantMessage({
   onRejectTool,
   onSendMessage,
   onContinueIntentGeneration,
+  onSubmitPlanAnswers,
+  onConfirmPlan,
+  onCancelPlan,
   onResumeMultiAgent,
   resumeMultiAgentDisabled,
   nextStepDisabled,
@@ -1010,6 +1053,17 @@ function OutlineAssistantMessage({
   onRejectTool: (call: ToolCallRecord & { preview?: string }) => void;
   onSendMessage: (text: string, options?: { intentPhase?: "intent_analysis" | "generation" | "waiting_user_input"; scope?: string }) => Promise<boolean>;
   onContinueIntentGeneration: (messageId: string, result: IntentClarityResult) => Promise<void>;
+  onSubmitPlanAnswers: (
+    messageId: string,
+    protocol: OutlinePlanProtocol,
+    answers: OutlinePlanAnswer[],
+  ) => Promise<boolean>;
+  onConfirmPlan: (
+    messageId: string,
+    protocol: OutlinePlanProtocol,
+    planText: string,
+  ) => Promise<boolean>;
+  onCancelPlan: (messageId: string) => void;
   onResumeMultiAgent: (messageId: string) => Promise<void>;
   resumeMultiAgentDisabled: boolean;
   nextStepDisabled: boolean;
@@ -1047,7 +1101,12 @@ function OutlineAssistantMessage({
       ? `意图分析格式无效,尚未开始生成:${intentProtocol.error}`
       : undefined)
     : undefined;
-  const canUseAsOutlineContent = intentProtocol.kind === "none" && !intentProtocolError;
+  const planProtocol = msg.outlinePlanProtocol ?? null;
+  const planDecided = Boolean(msg.outlinePlanDecision);
+  const isPlanProtocolMessage = Boolean(planProtocol) || Boolean(msg.outlinePlanPhase);
+  const canUseAsOutlineContent = intentProtocol.kind === "none"
+    && !intentProtocolError
+    && !isPlanProtocolMessage;
   const historicalClearIntent = !msg.intentClarityResult
     && !msg.intentProtocolError
     && msg.intentPhase !== "generation"
@@ -1064,10 +1123,12 @@ function OutlineAssistantMessage({
   }>({ textContent: "", edits: [], hasEdits: false });
   const renderedMarkdownContent = useMemo(() => {
     const rawContent = parsed.textContent || answer;
+    // 计划模式的协议块只用卡片渲染,气泡里不能漏出 JSON
+    if (isPlanProtocolMessage) return stripStructuredMarkers(rawContent);
     if (intentProtocol.kind === "valid") return stripStructuredMarkers(rawContent);
     if (intentProtocol.kind === "invalid" || msg.intentProtocolError) return "";
     return prepareOutlineSaveSourceContent(rawContent);
-  }, [answer, intentProtocol, msg.intentProtocolError, parsed.textContent]);
+  }, [answer, intentProtocol, isPlanProtocolMessage, msg.intentProtocolError, parsed.textContent]);
   useEffect(() => {
     if (!answer) {
       setParsed({ textContent: "", edits: [], hasEdits: false });
@@ -1139,6 +1200,11 @@ function OutlineAssistantMessage({
           {intentProtocolError}
         </div>
       ) : null}
+      {msg.outlinePlanError && !messageIsStreaming ? (
+        <div role="alert" className="mb-2 rounded border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive">
+          {msg.outlinePlanError}
+        </div>
+      ) : null}
       <StreamingMarkdown
         content={renderedMarkdownContent}
         isStreaming={messageIsStreaming}
@@ -1210,6 +1276,30 @@ function OutlineAssistantMessage({
           </button>
         </div>
       ) : null}
+      {/* 计划模式:要素缺口追问 */}
+      {planProtocol?.status === "needs_input" && !isStreaming ? (
+        <OutlineClarifyCard
+          protocol={planProtocol}
+          onSubmitAnswers={(answers) => onSubmitPlanAnswers(msg.id, planProtocol, answers)}
+          disabled={nextStepDisabled || planDecided}
+          disabledReason={planDecided
+            ? "该轮追问已提交,请在最新消息里继续。"
+            : nextStepDisabledReason}
+        />
+      ) : null}
+      {/* 计划模式:生成计划确认 */}
+      {planProtocol?.status === "ready" && !isStreaming ? (
+        <OutlinePlanCard
+          protocol={planProtocol}
+          onConfirm={(planText) => onConfirmPlan(msg.id, planProtocol, planText)}
+          onSupplement={onFocusInput}
+          onCancel={() => onCancelPlan(msg.id)}
+          disabled={nextStepDisabled || planDecided}
+          disabledReason={planDecided
+            ? "该计划已处理过,请在下方继续对话。"
+            : nextStepDisabledReason}
+        />
+      ) : null}
       {/* 意图不清晰时的推荐选项 */}
       {msg.intentClarityResult?.clarity === "needs_input" && !isStreaming ? (
         <IntentOptionsCard
@@ -2010,6 +2100,9 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         conversationId?: string;
         clearDraft?: boolean;
         intentPhase?: "intent_analysis" | "generation" | "waiting_user_input";
+        /** 计划模式的要素盘点轮;正文生成腿仍复用 intentPhase: "generation"。 */
+        planPhase?: OutlinePlanPhase;
+        planModule?: string;
         novelGenerationRequest?: NovelGenerationRequestPackage;
         systemGenerated?: boolean;
         userMessageVisibility?: "visible" | "internal";
@@ -2074,6 +2167,28 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         if (!isCurrentRun()) return { started: true, sent: false };
         setOutlineWorkflowStages((stages) => setOutlineSessionValue(stages, capturedConvId, stage));
       };
+      // 沿允许的转移链推进阶段,链路走不通就停在最后一个合法阶段
+      const advanceCapturedWorkflowStages = (targets: OutlineWorkflowStage[]) => {
+        if (!isCurrentRun()) return;
+        setOutlineWorkflowStages((stages) => {
+          let stage = stages[capturedConvId] ?? "idle";
+          for (const target of targets) {
+            if (stage === target) continue;
+            if (!canTransitionOutlineWorkflow(stage, target)) break;
+            stage = target;
+          }
+          return setOutlineSessionValue(stages, capturedConvId, stage);
+        });
+      };
+      // 计划模式的停机态要留在界面上等用户操作,不能被收尾复位成 idle
+      const resetCapturedWorkflowStageToIdle = () => {
+        if (!isCurrentRun()) return;
+        setOutlineWorkflowStages((stages) => {
+          const stage = stages[capturedConvId] ?? "idle";
+          if (stage === "collecting_requirements" || stage === "waiting_user_confirm") return stages;
+          return setOutlineSessionValue(stages, capturedConvId, "idle");
+        });
+      };
       if (shouldClearOutlineDraft({
         clearDraft: options.clearDraft !== false,
         invocationConversationId: capturedConvId,
@@ -2094,6 +2209,9 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
       );
       const outlineMode = resolveOutlineWorkflowMode(useWikiStore.getState().outlineWorkflowMode);
       const enableMultiAgent = Boolean(options.enableMultiAgent) && outlineMode !== "fast";
+      const planTargetModule = options.planModule
+        ?? intentContextsRef.current[capturedConvId]?.title
+        ?? "大纲";
       const forceRefresh = options.forceRefresh === true || forceRefreshNext;
       const contextDecision = planOutlineContextReuse({
         hasPriorAssistantAnswer,
@@ -2104,6 +2222,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         systemGenerated: options.systemGenerated,
         workflowMode: outlineMode,
         intentPhase: options.intentPhase,
+        planPhase: options.planPhase,
       });
       const cachedSummary =
         contextDecision.mode === "reuse"
@@ -2118,6 +2237,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         cachedSummary,
         workflowMode: outlineMode,
         intentPhase: options.intentPhase,
+        planPhase: options.planPhase,
         enableMultiAgent,
       });
       if (forceRefreshNext) {
@@ -2154,6 +2274,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         showThinkingProcess: historyPlan.showThinkingProcess,
         isAgentRunning: true,
         intentPhase: options.intentPhase,
+        outlinePlanPhase: options.planPhase,
       });
       clearStreamingContent(capturedConvId);
       userScrolledUpRef.current = false;
@@ -2175,6 +2296,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
       let bestGeneratedText = "";
       let deliverableTruncated = false;
       const outlineBudgetStage: OutlineBudgetStage = options.intentPhase === "intent_analysis"
+        || options.planPhase !== undefined
         ? "analysis"
         : "generation";
       const outlineRequestBudget = planOutlineRequestBudget({
@@ -2245,15 +2367,18 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           (): DeAiSkillConfig | null => null,
         );
         const soulDoc = await readSoulDoc(project.path).catch(() => "");
+        const planModule = options.planPhase ? planTargetModule : undefined;
         const baseSystemPrompt = buildOutlineAgentSystemPrompt({
           projectName: project.name,
           mode: outlineMode,
+          planModule,
         });
         const legacySystemPrompt = buildOutlineAgentSystemPrompt({
           projectName: project.name,
           webResearchContext: webResearchMarkdown,
           soulDoc,
           mode: outlineMode,
+          planModule,
         }) + `\n\n## 本轮上下文策略\n${contextDecision.instruction}\n\n${historyPlan.instruction}`;
         const commonDynamicParts = [
           webResearchMarkdown ? `## 本轮联网资料\n${webResearchMarkdown}` : "",
@@ -2984,6 +3109,25 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           throw new Error(OUTLINE_REASONING_ONLY_ERROR_MESSAGE);
         }
         const rawFinalContent = filteredRawFinalContent.content || "AI大纲未返回内容。";
+        // 计划模式的要素盘点轮:先过协议闸门,未通过一律不放行到生成
+        const planProtocolOutcome = options.planPhase
+          ? parseOutlinePlanProtocol(rawFinalContent)
+          : { kind: "none" as const };
+        const planValidation = planProtocolOutcome.kind === "valid"
+          ? validateOutlinePlanProtocol(
+            planProtocolOutcome.protocol,
+            getOutlinePlanRequiredElements(planProtocolOutcome.protocol.module || planTargetModule),
+          )
+          : null;
+        const planProtocolError = options.planPhase
+          ? planProtocolOutcome.kind === "invalid"
+            ? `计划协议格式无效,尚未开始生成:${planProtocolOutcome.error}`
+            : planProtocolOutcome.kind === "none"
+              ? "计划协议格式无效,尚未开始生成:模型未返回 outline_plan 协议块"
+              : planValidation?.kind === "invalid"
+                ? `计划协议不满足计划模式要求,尚未开始生成:${planValidation.error}`
+                : undefined
+          : undefined;
         const rawIntentProtocol = parseIntentClarityProtocol(rawFinalContent);
         const nextStepExtraction = extractNextStep(rawFinalContent, {
           allowFallback: options.intentPhase === "generation",
@@ -3047,14 +3191,33 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
               )
             : [],
           isAgentRunning: false,
-          nextStepRecommendation: intentProtocolError ? null : nextStepExtraction.recommendation,
+          nextStepRecommendation: intentProtocolError || options.planPhase
+            ? null
+            : nextStepExtraction.recommendation,
           intentProtocolError,
+          outlinePlanProtocol: planValidation && planValidation.kind !== "invalid"
+            ? planValidation.protocol
+            : null,
+          outlinePlanError: planProtocolError,
         }));
         if (!isCurrentRun()) {
           void useOutlineChatStore.getState().saveToDisk();
           return { started: true, sent: false };
         }
 
+        // 计划模式阶段机:追问停在 collecting_requirements,计划停在 waiting_user_confirm
+        if (options.planPhase && planValidation) {
+          if (planValidation.kind === "needs_input") {
+            advanceCapturedWorkflowStages(["collecting_requirements"]);
+          } else if (planValidation.kind === "ready") {
+            advanceCapturedWorkflowStages([
+              "sufficiency_check",
+              "generation_plan",
+              "waiting_user_confirm",
+            ]);
+          }
+        }
+
         // 解析意图清晰度结果
         const intentResult = intentProtocol.kind === "valid" && !intentProtocolError
           ? intentProtocol.result
@@ -3129,7 +3292,13 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             sessionKey: capturedConvId,
           });
         }
-        if (intentProtocol.kind === "none" && !intentProtocolError && !deliverableTruncated) {
+        // 计划模式的盘点轮只产出协议块,没有可保存正文,不能进保存链路
+        if (
+          intentProtocol.kind === "none"
+          && !intentProtocolError
+          && !deliverableTruncated
+          && !options.planPhase
+        ) {
           await handleAutoSaveOutlineRequests(capturedConvId, finalContent, isCurrentRun);
         }
         if (!isCurrentRun()) return { started: true, sent: false };
@@ -3152,7 +3321,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           }));
         }
         void useOutlineChatStore.getState().saveToDisk();
-        setCapturedWorkflowStage("idle");
+        resetCapturedWorkflowStageToIdle();
         finishConversationRun(
           capturedConvId,
           useOutlineChatStore.getState().activeConversationId,
@@ -3218,7 +3387,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         return { started: true, sent: false };
       } finally {
         outlineConversationRunRegistry.remove(capturedConvId, controller);
-        if (isCurrentRun()) setCapturedWorkflowStage("idle");
+        resetCapturedWorkflowStageToIdle();
       }
     },
     [
@@ -3245,11 +3414,51 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
     ],
   );
 
+  // 计划模式统一入口:先把会话推进到要素盘点,再让模型按 outline_plan 协议盘点缺口
+  const startOutlinePlanElementCheck = useCallback(
+    (conversationId: string, input: {
+      module: string;
+      requestHint: string;
+      originalRequest?: string;
+      references?: ReferenceToken[];
+      userDisplayText?: string;
+      clearDraft?: boolean;
+    }) => {
+      if (canTransitionOutlineWorkflow(
+        outlineWorkflowStages[conversationId] ?? "idle",
+        "collecting_requirements",
+      )) {
+        setOutlineWorkflowStages((stages) => (
+          setOutlineSessionValue(stages, conversationId, "collecting_requirements")
+        ));
+      }
+      return handleSend(
+        buildOutlinePlanElementCheckPrompt({
+          module: input.module,
+          requestHint: input.requestHint,
+          originalRequest: input.originalRequest,
+        }),
+        input.references ?? [],
+        {
+          conversationId,
+          planPhase: "element_check",
+          planModule: input.module,
+          systemGenerated: true,
+          clearDraft: input.clearDraft,
+          userDisplayText: input.userDisplayText,
+          preferredSkillNames: getOutlineSkillNames(input.module || input.requestHint),
+        },
+      );
+    },
+    [handleSend, outlineWorkflowStages],
+  );
+
   const handleGenerateSection = useCallback(
     (title: string, requestHint: string) => {
       const capturedConvId = activeConversationId ?? createConversation();
       const config = OUTLINE_SECTION_GENERATION_CONFIGS.find(c => c.title === title);
-      const fastMode = resolveOutlineWorkflowMode(useWikiStore.getState().outlineWorkflowMode) === "fast";
+      const outlineMode = resolveOutlineWorkflowMode(useWikiStore.getState().outlineWorkflowMode);
+      const fastMode = outlineMode === "fast";
       intentContextsRef.current = setOutlineSessionValue(intentContextsRef.current, capturedConvId, {
         title,
         hint: requestHint,
@@ -3265,6 +3474,14 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         });
         return;
       }
+      if (outlineMode === "plan") {
+        void startOutlinePlanElementCheck(capturedConvId, {
+          module: title,
+          requestHint,
+          userDisplayText: `生成${title}`,
+        });
+        return;
+      }
       if (canTransitionOutlineWorkflow(outlineWorkflowStages[capturedConvId] ?? "idle", "intent_analysis")) {
         setOutlineWorkflowStages((stages) => setOutlineSessionValue(stages, capturedConvId, "intent_analysis"));
       }
@@ -3276,14 +3493,22 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         userDisplayText: `生成${title}`,
       });
     },
-    [activeConversationId, createConversation, handleSend, outlineWorkflowStages],
+    [
+      activeConversationId,
+      createConversation,
+      handleSend,
+      outlineWorkflowStages,
+      startOutlinePlanElementCheck,
+    ],
   );
 
   const handleDirectSubmit = useCallback(
     async (text: string, references: ReferenceToken[] = []) => {
-      if (resolveOutlineWorkflowMode(useWikiStore.getState().outlineWorkflowMode) === "fast") {
+      const outlineMode = resolveOutlineWorkflowMode(useWikiStore.getState().outlineWorkflowMode);
+      if (outlineMode === "fast") {
         return handleSend(text, references);
       }
+      // 非生成类输入在计划模式下也照旧直接问答,不进要素盘点
       const directRequest = classifyDirectOutlineGenerationRequest(text);
       if (!directRequest) return handleSend(text, references);
 
@@ -3295,6 +3520,15 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         references: [...references],
         skillNames: getOutlineSkillNames(directRequest.module || text),
       });
+      if (outlineMode === "plan") {
+        return startOutlinePlanElementCheck(capturedConvId, {
+          module: directRequest.module,
+          requestHint: text.trim(),
+          originalRequest: text.trim(),
+          references,
+          userDisplayText: text,
+        });
+      }
       if (canTransitionOutlineWorkflow(outlineWorkflowStages[capturedConvId] ?? "idle", "intent_analysis")) {
         setOutlineWorkflowStages((stages) => setOutlineSessionValue(stages, capturedConvId, "intent_analysis"));
       }
@@ -3303,7 +3537,13 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         intentPhase: "intent_analysis",
       });
     },
-    [activeConversationId, createConversation, handleSend, outlineWorkflowStages],
+    [
+      activeConversationId,
+      createConversation,
+      handleSend,
+      outlineWorkflowStages,
+      startOutlinePlanElementCheck,
+    ],
   );
 
   const handleContinueIntentGeneration = useCallback(
@@ -3345,6 +3585,99 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
     [activeConversationId, canStartConversationRun, handleSend],
   );
 
+  const markOutlinePlanDecision = useCallback(
+    (
+      conversationId: string,
+      messageId: string,
+      decision: NonNullable<OutlineChatMessage["outlinePlanDecision"]>,
+    ) => {
+      updateOutlineAssistantMessage(conversationId, messageId, (message) => ({
+        ...message,
+        outlinePlanDecision: decision,
+      }));
+      void useOutlineChatStore.getState().saveToDisk();
+    },
+    [],
+  );
+
+  // 追问答案回填:走内部消息重跑要素盘点,累计要素靠对话历史传递
+  const handleSubmitOutlinePlanAnswers = useCallback(
+    async (messageId: string, protocol: OutlinePlanProtocol, answers: OutlinePlanAnswer[]) => {
+      const capturedConvId = activeConversationId;
+      if (!capturedConvId || !canStartConversationRun(capturedConvId)) {
+        toast.info("当前会话正在生成,请等待生成完成后再补充信息。", {
+          dedupeKey: "outline-plan:busy",
+        });
+        return false;
+      }
+      markOutlinePlanDecision(capturedConvId, messageId, "answered");
+      const result = await handleSend(
+        buildOutlinePlanClarifyAnswerPrompt({
+          module: protocol.module,
+          answers,
+          collected: protocol.elements,
+        }),
+        [],
+        {
+          conversationId: capturedConvId,
+          planPhase: "element_check",
+          planModule: protocol.module,
+          systemGenerated: true,
+          clearDraft: false,
+          userMessageVisibility: "internal",
+        },
+      );
+      return result.sent;
+    },
+    [activeConversationId, canStartConversationRun, handleSend, markOutlinePlanDecision],
+  );
+
+  // 确认计划:复用既有 generation 腿,markdown 修复与保存确认零改动
+  const handleConfirmOutlinePlan = useCallback(
+    async (messageId: string, protocol: OutlinePlanProtocol, planText: string) => {
+      const capturedConvId = activeConversationId;
+      if (!capturedConvId || !canStartConversationRun(capturedConvId)) {
+        toast.info("当前会话正在生成,请等待生成完成后再确认计划。", {
+          dedupeKey: "outline-plan:busy",
+        });
+        return false;
+      }
+      markOutlinePlanDecision(capturedConvId, messageId, "confirmed");
+      setOutlineWorkflowStages((stages) => setOutlineSessionValue(stages, capturedConvId, "idle"));
+      const intentContext = intentContextsRef.current[capturedConvId];
+      const result = await handleSend(
+        buildOutlinePlanExecutionPrompt({
+          module: protocol.module,
+          planText,
+          elements: protocol.elements,
+        }),
+        intentContext?.references ?? [],
+        {
+          conversationId: capturedConvId,
+          intentPhase: "generation",
+          systemGenerated: true,
+          clearDraft: false,
+          userMessageVisibility: "internal",
+          enableMultiAgent: (protocol.plan?.files.length ?? 0) > 1,
+          preferredSkillNames: intentContext?.skillNames
+            ?? getOutlineSkillNames(protocol.module),
+        },
+      );
+      return result.sent;
+    },
+    [activeConversationId, canStartConversationRun, handleSend, markOutlinePlanDecision],
+  );
+
+  const handleCancelOutlinePlan = useCallback(
+    (messageId: string) => {
+      const capturedConvId = activeConversationId;
+      if (!capturedConvId) return;
+      markOutlinePlanDecision(capturedConvId, messageId, "cancelled");
+      setOutlineWorkflowStages((stages) => setOutlineSessionValue(stages, capturedConvId, "idle"));
+    },
+    [activeConversationId, markOutlinePlanDecision],
+  );
+
   const handleSendMessage = useCallback(
     async (text: string, options?: { intentPhase?: "intent_analysis" | "generation" | "waiting_user_input"; scope?: string }) => {
       const capturedConvId = activeConversationId;
@@ -3796,7 +4129,29 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
 
   const handleSubmitOutlineWizard = useCallback(
     (request: OutlineWizardRequest) => {
-      const fastMode = resolveOutlineWorkflowMode(useWikiStore.getState().outlineWorkflowMode) === "fast";
+      const outlineMode = resolveOutlineWorkflowMode(useWikiStore.getState().outlineWorkflowMode);
+      const fastMode = outlineMode === "fast";
+      if (outlineMode === "plan") {
+        // 计划模式不直接短路到生成:向导需求先当作要素输入做盘点
+        const capturedConvId = activeConversationId ?? createConversation();
+        const wizardPrompt = buildOutlineWizardPrompt(request, { mode: "standard" });
+        const module = request.targets[0] || "完整新书规划";
+        intentContextsRef.current = setOutlineSessionValue(intentContextsRef.current, capturedConvId, {
+          title: module,
+          hint: wizardPrompt,
+          originalRequest: request.inspiration.trim(),
+          references: [...outlineReferenceTokensRef.current],
+          skillNames: getOutlineWizardSkillNames(request),
+        });
+        void startOutlinePlanElementCheck(capturedConvId, {
+          module,
+          requestHint: wizardPrompt,
+          originalRequest: request.inspiration.trim(),
+          references: outlineReferenceTokensRef.current,
+          userDisplayText: createNovelGenerationRequestPackage(request, wizardPrompt).summary,
+        });
+        return;
+      }
       const modelContent = fastMode
         ? buildOutlineWizardPrompt(request, { mode: "fast" })
         : buildOutlineWizardMultiAgentPrompt(request);
@@ -3809,7 +4164,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         systemGenerated: true,
       });
     },
-    [handleSend],
+    [activeConversationId, createConversation, handleSend, startOutlinePlanElementCheck],
   );
 
   const handleStop = useCallback(() => {
@@ -4627,6 +4982,8 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
                 <span className="shrink-0 rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">
                   {outlineWorkflowStage === "intent_analysis" ? "意图分析中" :
                    outlineWorkflowStage === "waiting_user_input" ? "等待选择" :
+                   outlineWorkflowStage === "collecting_requirements" ? "收集要素" :
+                   outlineWorkflowStage === "waiting_user_confirm" ? "等待确认计划" :
                    outlineWorkflowStage === "sufficiency_check" ? "生成中" :
                    "处理中"}
                 </span>
@@ -4755,6 +5112,9 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
                   onRejectTool={handleRejectTool}
                   onSendMessage={handleSendMessage}
                   onContinueIntentGeneration={handleContinueIntentGeneration}
+                  onSubmitPlanAnswers={handleSubmitOutlinePlanAnswers}
+                  onConfirmPlan={handleConfirmOutlinePlan}
+                  onCancelPlan={handleCancelOutlinePlan}
                   onResumeMultiAgent={handleResumeMultiAgent}
                   resumeMultiAgentDisabled={isStreaming}
                   nextStepDisabled={submitDisabled}

+ 190 - 0
src/components/sources/outline-clarify-card.spec.tsx

@@ -0,0 +1,190 @@
+// @vitest-environment jsdom
+import { act } from "react"
+import { createRoot } from "react-dom/client"
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
+
+import { OutlineClarifyCard } from "./outline-clarify-card"
+import {
+  OUTLINE_PLAN_CUSTOM_OPTION_ID,
+  type OutlinePlanProtocol,
+} from "@/lib/novel/outline-plan-protocol"
+
+const roots: Array<{ root: ReturnType<typeof createRoot>; container: HTMLDivElement }> = []
+
+beforeEach(() => {
+  ;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+})
+
+afterEach(async () => {
+  while (roots.length) {
+    const mounted = roots.pop()!
+    await act(async () => mounted.root.unmount())
+    mounted.container.remove()
+  }
+})
+
+function protocol(overrides: Partial<OutlinePlanProtocol> = {}): OutlinePlanProtocol {
+  return {
+    status: "needs_input",
+    module: "章节细纲",
+    elements: [],
+    missing: ["章节范围", "本章目标"],
+    questions: [
+      {
+        id: "q1",
+        key: "chapterRange",
+        question: "要生成哪些章的章纲?",
+        multiple: false,
+        options: [
+          { id: "A", label: "往后 1 章", description: "最稳" },
+          { id: "B", label: "往后 5 章", description: "" },
+          { id: "C", label: "往后 10 章", description: "" },
+          { id: OUTLINE_PLAN_CUSTOM_OPTION_ID, label: "其它(我来补充描述)", description: "" },
+        ],
+      },
+      {
+        id: "q2",
+        key: "chapterGoal",
+        question: "本章目标是什么?",
+        multiple: true,
+        options: [
+          { id: "A", label: "推进主线", description: "" },
+          { id: "B", label: "铺垫伏笔", description: "" },
+          { id: "C", label: "兑现爽点", description: "" },
+          { id: OUTLINE_PLAN_CUSTOM_OPTION_ID, label: "其它(我来补充描述)", description: "" },
+        ],
+      },
+    ],
+    ...overrides,
+  }
+}
+
+async function renderCard(props: Partial<React.ComponentProps<typeof OutlineClarifyCard>> = {}) {
+  const container = document.createElement("div")
+  document.body.appendChild(container)
+  const root = createRoot(container)
+  roots.push({ root, container })
+  const onSubmitAnswers = props.onSubmitAnswers ?? vi.fn(async () => true)
+  await act(async () => {
+    root.render(
+      <OutlineClarifyCard
+        protocol={protocol()}
+        onSubmitAnswers={onSubmitAnswers}
+        {...props}
+      />,
+    )
+  })
+  const options = () => Array.from(container.querySelectorAll('[role="option"]')) as HTMLButtonElement[]
+  const submit = () => container.querySelector('[aria-label="提交补充要素"]') as HTMLButtonElement
+  return { container, onSubmitAnswers, options, submit }
+}
+
+async function setTextareaValue(textarea: HTMLTextAreaElement, value: string) {
+  await act(async () => {
+    Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set?.call(textarea, value)
+    textarea.dispatchEvent(new Event("input", { bubbles: true }))
+  })
+}
+
+describe("OutlineClarifyCard", () => {
+  it("渲染所有问题,每问至少 3 个真实选项外加自定义输入项", async () => {
+    const { container, options } = await renderCard()
+
+    expect(container.textContent).toContain("要生成哪些章的章纲?")
+    expect(container.textContent).toContain("本章目标是什么?")
+    expect(container.textContent).toContain("待补要素:章节范围、本章目标")
+    expect(options()).toHaveLength(8)
+
+    const customOptions = options().filter((option) => option.textContent?.includes("其它"))
+    expect(customOptions).toHaveLength(2)
+    expect(options().slice(0, 3).every((option) => !option.textContent?.includes("其它"))).toBe(true)
+  })
+
+  it("必答问题没填完时禁用提交", async () => {
+    const { options, submit } = await renderCard()
+
+    expect(submit().disabled).toBe(true)
+
+    await act(async () => options()[0].click())
+    expect(submit().disabled).toBe(true)
+
+    await act(async () => options()[4].click())
+    expect(submit().disabled).toBe(false)
+  })
+
+  it("选中自定义项后要求填写补充内容才能提交", async () => {
+    const { container, options, submit } = await renderCard()
+
+    await act(async () => options()[3].click())
+    const textarea = container.querySelector("textarea") as HTMLTextAreaElement
+    expect(textarea).not.toBeNull()
+
+    await act(async () => options()[4].click())
+    expect(submit().disabled).toBe(true)
+
+    await setTextareaValue(textarea, "第 21-25 章")
+    expect(submit().disabled).toBe(false)
+  })
+
+  it("单选互斥、多选可叠加,并把标签汇总成答案交给回调", async () => {
+    const { options, submit, onSubmitAnswers } = await renderCard()
+
+    await act(async () => options()[0].click())
+    await act(async () => options()[1].click())
+    await act(async () => options()[4].click())
+    await act(async () => options()[5].click())
+    await act(async () => {
+      submit().click()
+      await Promise.resolve()
+    })
+
+    expect(onSubmitAnswers).toHaveBeenCalledTimes(1)
+    expect(onSubmitAnswers).toHaveBeenCalledWith([
+      { key: "chapterRange", label: "章节范围", question: "要生成哪些章的章纲?", value: "往后 5 章" },
+      { key: "chapterGoal", label: "本章目标", question: "本章目标是什么?", value: "推进主线;铺垫伏笔" },
+    ])
+  })
+
+  it("提交进行中防止重复点击", async () => {
+    let finish!: (value: boolean) => void
+    const pending = new Promise<boolean>((resolve) => { finish = resolve })
+    const onSubmitAnswers = vi.fn(() => pending)
+    const { options, submit } = await renderCard({ onSubmitAnswers })
+
+    await act(async () => options()[0].click())
+    await act(async () => options()[4].click())
+    await act(async () => {
+      submit().click()
+      submit().click()
+      await Promise.resolve()
+    })
+
+    expect(onSubmitAnswers).toHaveBeenCalledTimes(1)
+    expect(submit().disabled).toBe(true)
+    expect(submit().getAttribute("aria-busy")).toBe("true")
+
+    await act(async () => finish(true))
+    expect(submit().getAttribute("aria-busy")).toBeNull()
+  })
+
+  it("会话忙或卡片已用过时整卡置灰并给出中文原因", async () => {
+    const { options, submit, onSubmitAnswers } = await renderCard({
+      disabled: true,
+      disabledReason: "当前会话正在生成,请等待生成完成后再补充信息。",
+    })
+
+    expect(options().every((option) => option.disabled)).toBe(true)
+    expect(submit().disabled).toBe(true)
+    expect(submit().title).toBe("当前会话正在生成,请等待生成完成后再补充信息。")
+    await act(async () => options()[0].click())
+    expect(onSubmitAnswers).not.toHaveBeenCalled()
+  })
+
+  it("协议不是 needs_input 或没有追问时不渲染", async () => {
+    const { container } = await renderCard({ protocol: protocol({ questions: [] }) })
+    expect(container.textContent).toBe("")
+
+    const ready = await renderCard({ protocol: protocol({ status: "ready" }) })
+    expect(ready.container.textContent).toBe("")
+  })
+})

+ 198 - 0
src/components/sources/outline-clarify-card.tsx

@@ -0,0 +1,198 @@
+import { useMemo, useRef, useState } from "react"
+
+import {
+  findOutlinePlanElementSpec,
+  getOutlinePlanRequiredElements,
+} from "@/lib/novel/outline-plan-elements"
+import {
+  OUTLINE_PLAN_CUSTOM_OPTION_ID,
+  type OutlinePlanAnswer,
+  type OutlinePlanProtocol,
+  type OutlinePlanQuestion,
+} from "@/lib/novel/outline-plan-protocol"
+
+interface OutlineClarifyCardProps {
+  protocol: OutlinePlanProtocol
+  onSubmitAnswers: (answers: OutlinePlanAnswer[]) => Promise<boolean>
+  disabled?: boolean
+  disabledReason?: string
+}
+
+interface QuestionSelection {
+  optionIds: string[]
+  custom: string
+}
+
+function isCustomOption(optionId: string): boolean {
+  return optionId.toUpperCase() === OUTLINE_PLAN_CUSTOM_OPTION_ID
+}
+
+function toggleSelection(
+  selection: QuestionSelection,
+  optionId: string,
+  multiple: boolean,
+): QuestionSelection {
+  if (!multiple) {
+    return {
+      optionIds: selection.optionIds.includes(optionId) ? [] : [optionId],
+      custom: selection.custom,
+    }
+  }
+  return {
+    optionIds: selection.optionIds.includes(optionId)
+      ? selection.optionIds.filter((id) => id !== optionId)
+      : [...selection.optionIds, optionId],
+    custom: selection.custom,
+  }
+}
+
+function buildAnswerValue(
+  question: OutlinePlanQuestion,
+  selection: QuestionSelection,
+): string {
+  const labels = selection.optionIds
+    .filter((optionId) => !isCustomOption(optionId))
+    .map((optionId) => question.options.find((option) => option.id === optionId)?.label ?? "")
+    .filter(Boolean)
+  const custom = selection.optionIds.some(isCustomOption) ? selection.custom.trim() : ""
+  return [...labels, custom].filter(Boolean).join(";")
+}
+
+export function OutlineClarifyCard({
+  protocol,
+  onSubmitAnswers,
+  disabled = false,
+  disabledReason,
+}: OutlineClarifyCardProps) {
+  const [selections, setSelections] = useState<Record<string, QuestionSelection>>({})
+  const [submitting, setSubmitting] = useState(false)
+  const submittingRef = useRef(false)
+  const elementSpecs = useMemo(
+    () => getOutlinePlanRequiredElements(protocol.module),
+    [protocol.module],
+  )
+
+  if (protocol.status !== "needs_input" || protocol.questions.length === 0) return null
+
+  const getSelection = (questionId: string): QuestionSelection =>
+    selections[questionId] ?? { optionIds: [], custom: "" }
+
+  const answers = protocol.questions.map((question) => ({
+    key: question.key || question.id,
+    label: findOutlinePlanElementSpec(elementSpecs, question.key)?.label || question.key || question.id,
+    question: question.question,
+    value: buildAnswerValue(question, getSelection(question.id)),
+  }))
+  const answered = answers.every((answer) => answer.value.trim())
+  const interactionDisabled = disabled || submitting
+
+  const handleToggle = (question: OutlinePlanQuestion, optionId: string) => {
+    if (interactionDisabled) return
+    setSelections((current) => ({
+      ...current,
+      [question.id]: toggleSelection(
+        current[question.id] ?? { optionIds: [], custom: "" },
+        optionId,
+        question.multiple,
+      ),
+    }))
+  }
+
+  const handleCustomChange = (questionId: string, value: string) => {
+    setSelections((current) => ({
+      ...current,
+      [questionId]: { ...(current[questionId] ?? { optionIds: [], custom: "" }), custom: value },
+    }))
+  }
+
+  const handleSubmit = async () => {
+    if (interactionDisabled || !answered || submittingRef.current) return
+    submittingRef.current = true
+    setSubmitting(true)
+    try {
+      await onSubmitAnswers(answers)
+    } finally {
+      submittingRef.current = false
+      setSubmitting(false)
+    }
+  }
+
+  return (
+    <div className="mt-3 rounded-md border border-amber-500/30 bg-amber-50/30 p-3 dark:bg-amber-950/10">
+      <div className="mb-2 text-sm font-medium">
+        生成「{protocol.module}」还缺少要素,请补齐后再生成计划
+      </div>
+      {protocol.missing.length ? (
+        <div className="mb-3 text-xs text-muted-foreground">
+          待补要素:{protocol.missing.join("、")}
+        </div>
+      ) : null}
+      <div className="space-y-4">
+        {protocol.questions.map((question) => {
+          const selection = getSelection(question.id)
+          const customSelected = selection.optionIds.some(isCustomOption)
+          return (
+            <div key={question.id} className="space-y-2">
+              <div className="text-sm font-medium">
+                {question.question}
+                {question.multiple ? (
+                  <span className="ml-2 text-xs font-normal text-muted-foreground">可多选</span>
+                ) : null}
+              </div>
+              <div className="space-y-2">
+                {question.options.map((option) => {
+                  const selected = selection.optionIds.includes(option.id)
+                  return (
+                    <button
+                      key={option.id}
+                      type="button"
+                      role="option"
+                      aria-selected={selected}
+                      className={`w-full rounded-md border px-3 py-2 text-left text-sm transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50 ${
+                        selected ? "border-amber-500 bg-amber-100/60 dark:bg-amber-900/30" : ""
+                      }`}
+                      onClick={() => handleToggle(question, option.id)}
+                      disabled={interactionDisabled}
+                      title={disabled ? disabledReason : undefined}
+                    >
+                      <div className="font-medium">{option.label}</div>
+                      {option.description ? (
+                        <div className="text-xs text-muted-foreground">{option.description}</div>
+                      ) : null}
+                    </button>
+                  )
+                })}
+              </div>
+              {customSelected ? (
+                <textarea
+                  value={selection.custom}
+                  onChange={(event) => handleCustomChange(question.id, event.target.value)}
+                  disabled={interactionDisabled}
+                  aria-label={`${question.question} 自定义补充`}
+                  placeholder="请补充你的具体要求"
+                  className="min-h-16 w-full resize-y rounded-md border bg-background p-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary disabled:cursor-not-allowed disabled:opacity-50"
+                />
+              ) : null}
+            </div>
+          )
+        })}
+      </div>
+      <div className="mt-3 flex items-center gap-2">
+        <button
+          type="button"
+          aria-label="提交补充要素"
+          className="rounded-md bg-amber-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-amber-700 disabled:cursor-not-allowed disabled:opacity-50"
+          onClick={() => void handleSubmit()}
+          disabled={interactionDisabled || !answered}
+          title={disabled ? disabledReason : undefined}
+          aria-busy={submitting || undefined}
+        >
+          {submitting ? "提交中..." : "提交并继续"}
+        </button>
+        {!answered ? (
+          <span className="text-xs text-muted-foreground">每个问题都要选一项或填写补充说明</span>
+        ) : null}
+      </div>
+    </div>
+  )
+}

+ 183 - 0
src/components/sources/outline-plan-card.spec.tsx

@@ -0,0 +1,183 @@
+// @vitest-environment jsdom
+import { act } from "react"
+import { createRoot } from "react-dom/client"
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
+
+import { OutlinePlanCard } from "./outline-plan-card"
+import type { OutlinePlanProtocol } from "@/lib/novel/outline-plan-protocol"
+
+const roots: Array<{ root: ReturnType<typeof createRoot>; container: HTMLDivElement }> = []
+
+beforeEach(() => {
+  ;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+})
+
+afterEach(async () => {
+  while (roots.length) {
+    const mounted = roots.pop()!
+    await act(async () => mounted.root.unmount())
+    mounted.container.remove()
+  }
+})
+
+function protocol(overrides: Partial<OutlinePlanProtocol> = {}): OutlinePlanProtocol {
+  return {
+    status: "ready",
+    module: "章节细纲",
+    elements: [
+      { key: "chapterRange", value: "第 11-15 章", source: "user", satisfied: true },
+      { key: "pov", value: "第三人称", source: "project", satisfied: true },
+      { key: "chapterPosition", value: "", source: "inferred", satisfied: false },
+    ],
+    missing: [],
+    questions: [],
+    plan: {
+      summary: "先补第 11-15 章章纲",
+      steps: [
+        { id: "s1", title: "读取卷纲", detail: "确认本卷目标" },
+        { id: "s2", title: "生成章纲", detail: "按 15 节结构" },
+      ],
+      files: [{
+        targetFolder: "章纲",
+        fileName: "章纲_第11章.md",
+        fileType: "chapter-outline",
+        writeMode: "create",
+        elements: ["chapterGoal"],
+      }],
+      order: "先卷后章",
+      risks: ["时间线可能断裂"],
+      openQuestions: ["第 13 章是否安排反转"],
+    },
+    ...overrides,
+  }
+}
+
+async function renderCard(props: Partial<React.ComponentProps<typeof OutlinePlanCard>> = {}) {
+  const container = document.createElement("div")
+  document.body.appendChild(container)
+  const root = createRoot(container)
+  roots.push({ root, container })
+  const onConfirm = props.onConfirm ?? vi.fn(async () => true)
+  const onSupplement = props.onSupplement ?? vi.fn()
+  const onCancel = props.onCancel ?? vi.fn()
+  await act(async () => {
+    root.render(
+      <OutlinePlanCard
+        protocol={protocol()}
+        onConfirm={onConfirm}
+        onSupplement={onSupplement}
+        onCancel={onCancel}
+        {...props}
+      />,
+    )
+  })
+  const button = (label: string) => container.querySelector(`[aria-label="${label}"]`) as HTMLButtonElement
+  return { container, onConfirm, onSupplement, onCancel, button }
+}
+
+describe("OutlinePlanCard", () => {
+  it("展示要素、步骤、待写文件、顺序、风险和遗留问题", async () => {
+    const { container } = await renderCard()
+
+    expect(container.textContent).toContain("「章节细纲」生成计划,确认后才开始写")
+    expect(container.textContent).toContain("第 11-15 章")
+    expect(container.textContent).toContain("project")
+    expect(container.textContent).not.toContain("chapterPosition")
+    expect(container.textContent).toContain("读取卷纲:确认本卷目标")
+    expect(container.textContent).toContain("章纲/章纲_第11章.md")
+    expect(container.textContent).toContain("chapter-outline · create")
+    expect(container.textContent).toContain("先卷后章")
+    expect(container.textContent).toContain("时间线可能断裂")
+    expect(container.textContent).toContain("第 13 章是否安排反转")
+  })
+
+  it("确认时把渲染后的计划正文回传", async () => {
+    const { button, onConfirm } = await renderCard()
+
+    await act(async () => {
+      button("确认生成计划").click()
+      await Promise.resolve()
+    })
+
+    expect(onConfirm).toHaveBeenCalledTimes(1)
+    const planText = (onConfirm as ReturnType<typeof vi.fn>).mock.calls[0][0] as string
+    expect(planText).toContain("## 生成步骤")
+    expect(planText).toContain("章纲/章纲_第11章.md")
+  })
+
+  it("修改计划后按修改内容确认", async () => {
+    const { container, button, onConfirm } = await renderCard()
+
+    await act(async () => button("修改生成计划").click())
+    const textarea = container.querySelector('[aria-label="编辑生成计划"]') as HTMLTextAreaElement
+    expect(textarea).not.toBeNull()
+    expect(button("修改生成计划")).toBeNull()
+
+    await act(async () => {
+      Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set
+        ?.call(textarea, "## 生成步骤\n1. 只生成第 11 章")
+      textarea.dispatchEvent(new Event("input", { bubbles: true }))
+    })
+    expect(button("确认生成计划").textContent).toContain("按修改后的计划生成")
+
+    await act(async () => {
+      button("确认生成计划").click()
+      await Promise.resolve()
+    })
+
+    expect(onConfirm).toHaveBeenCalledWith("## 生成步骤\n1. 只生成第 11 章")
+  })
+
+  it("补充信息与取消直接回调,不进入生成", async () => {
+    const { button, onSupplement, onCancel, onConfirm } = await renderCard()
+
+    await act(async () => button("补充生成要素").click())
+    await act(async () => button("取消生成计划").click())
+
+    expect(onSupplement).toHaveBeenCalledTimes(1)
+    expect(onCancel).toHaveBeenCalledTimes(1)
+    expect(onConfirm).not.toHaveBeenCalled()
+  })
+
+  it("确认进行中防止重复提交", async () => {
+    let finish!: (value: boolean) => void
+    const pending = new Promise<boolean>((resolve) => { finish = resolve })
+    const onConfirm = vi.fn(() => pending)
+    const { button } = await renderCard({ onConfirm })
+
+    await act(async () => {
+      button("确认生成计划").click()
+      button("确认生成计划").click()
+      await Promise.resolve()
+    })
+
+    expect(onConfirm).toHaveBeenCalledTimes(1)
+    expect(button("确认生成计划").disabled).toBe(true)
+    expect(button("确认生成计划").getAttribute("aria-busy")).toBe("true")
+
+    await act(async () => finish(true))
+    expect(button("确认生成计划").getAttribute("aria-busy")).toBeNull()
+  })
+
+  it("卡片已用过或会话忙时四个按钮全部置灰并给出中文原因", async () => {
+    const { button, onConfirm } = await renderCard({
+      disabled: true,
+      disabledReason: "该计划已处理过,请在下方继续对话。",
+    })
+
+    for (const label of ["确认生成计划", "修改生成计划", "补充生成要素", "取消生成计划"]) {
+      expect(button(label).disabled).toBe(true)
+      expect(button(label).title).toBe("该计划已处理过,请在下方继续对话。")
+    }
+    await act(async () => button("确认生成计划").click())
+    expect(onConfirm).not.toHaveBeenCalled()
+  })
+
+  it("协议不是 ready 或没有计划时不渲染", async () => {
+    const withoutPlan = await renderCard({ protocol: protocol({ plan: undefined }) })
+    expect(withoutPlan.container.textContent).toBe("")
+
+    const needsInput = await renderCard({ protocol: protocol({ status: "needs_input" }) })
+    expect(needsInput.container.textContent).toBe("")
+  })
+})

+ 191 - 0
src/components/sources/outline-plan-card.tsx

@@ -0,0 +1,191 @@
+import { useRef, useState } from "react"
+
+import {
+  formatOutlinePlanMarkdown,
+  type OutlinePlanProtocol,
+} from "@/lib/novel/outline-plan-protocol"
+
+interface OutlinePlanCardProps {
+  protocol: OutlinePlanProtocol
+  /** 确认后按计划正文进入生成阶段;planText 为用户可能编辑过的计划。 */
+  onConfirm: (planText: string) => Promise<boolean>
+  /** 补充信息:聚焦输入框,允许用户再追加要素后重跑要素盘点。 */
+  onSupplement: () => void
+  onCancel: () => void
+  disabled?: boolean
+  disabledReason?: string
+}
+
+export function OutlinePlanCard({
+  protocol,
+  onConfirm,
+  onSupplement,
+  onCancel,
+  disabled = false,
+  disabledReason,
+}: OutlinePlanCardProps) {
+  const plan = protocol.plan
+  const [editing, setEditing] = useState(false)
+  const [editedPlan, setEditedPlan] = useState("")
+  const [submitting, setSubmitting] = useState(false)
+  const submittingRef = useRef(false)
+
+  if (protocol.status !== "ready" || !plan) return null
+
+  const planMarkdown = formatOutlinePlanMarkdown(plan)
+  const interactionDisabled = disabled || submitting
+
+  const handleStartEdit = () => {
+    if (interactionDisabled) return
+    setEditedPlan(planMarkdown)
+    setEditing(true)
+  }
+
+  const handleConfirm = async () => {
+    if (interactionDisabled || submittingRef.current) return
+    const planText = editing ? editedPlan.trim() : planMarkdown
+    if (!planText) return
+    submittingRef.current = true
+    setSubmitting(true)
+    try {
+      await onConfirm(planText)
+    } finally {
+      submittingRef.current = false
+      setSubmitting(false)
+    }
+  }
+
+  const confirmLabel = submitting
+    ? "生成中..."
+    : editing
+      ? "按修改后的计划生成"
+      : "确认,按此计划生成"
+
+  return (
+    <div className="mt-3 rounded-md border border-emerald-500/30 bg-emerald-50/30 p-3 dark:bg-emerald-950/10">
+      <div className="mb-2 text-sm font-medium">「{protocol.module}」生成计划,确认后才开始写</div>
+
+      {protocol.elements.length ? (
+        <div className="mb-3">
+          <div className="mb-1 text-xs font-medium text-muted-foreground">已确认要素</div>
+          <ul className="space-y-0.5 text-xs">
+            {protocol.elements.filter((element) => element.satisfied).map((element) => (
+              <li key={element.key}>
+                <span className="font-medium">{element.key}</span>:{element.value}
+                <span className="ml-1 rounded border px-1 text-[10px] text-muted-foreground">
+                  {element.source}
+                </span>
+              </li>
+            ))}
+          </ul>
+        </div>
+      ) : null}
+
+      {editing ? (
+        <textarea
+          value={editedPlan}
+          onChange={(event) => setEditedPlan(event.target.value)}
+          disabled={interactionDisabled}
+          aria-label="编辑生成计划"
+          className="min-h-48 w-full resize-y rounded-md border bg-background p-2 font-mono text-xs leading-relaxed focus:outline-none focus:ring-1 focus:ring-primary disabled:cursor-not-allowed disabled:opacity-50"
+        />
+      ) : (
+        <div className="space-y-3 text-sm">
+          {plan.summary ? <div>{plan.summary}</div> : null}
+          <div>
+            <div className="mb-1 text-xs font-medium text-muted-foreground">生成步骤</div>
+            <ol className="list-decimal space-y-0.5 pl-5 text-xs">
+              {plan.steps.map((step) => (
+                <li key={step.id}>
+                  <span className="font-medium">{step.title}</span>
+                  {step.detail ? `:${step.detail}` : ""}
+                </li>
+              ))}
+            </ol>
+          </div>
+          <div>
+            <div className="mb-1 text-xs font-medium text-muted-foreground">待写文件</div>
+            <ul className="space-y-0.5 text-xs">
+              {plan.files.map((file) => (
+                <li key={`${file.targetFolder}/${file.fileName}`}>
+                  {[file.targetFolder, file.fileName].filter(Boolean).join("/")}
+                  <span className="ml-1 rounded border px-1 text-[10px] text-muted-foreground">
+                    {[file.fileType, file.writeMode].filter(Boolean).join(" · ")}
+                  </span>
+                </li>
+              ))}
+            </ul>
+          </div>
+          {plan.order ? (
+            <div className="text-xs">
+              <span className="font-medium text-muted-foreground">生成顺序:</span>
+              {plan.order}
+            </div>
+          ) : null}
+          {plan.risks.length ? (
+            <div>
+              <div className="mb-1 text-xs font-medium text-muted-foreground">风险</div>
+              <ul className="list-disc space-y-0.5 pl-5 text-xs">
+                {plan.risks.map((risk) => <li key={risk}>{risk}</li>)}
+              </ul>
+            </div>
+          ) : null}
+          {plan.openQuestions.length ? (
+            <div>
+              <div className="mb-1 text-xs font-medium text-muted-foreground">遗留问题</div>
+              <ul className="list-disc space-y-0.5 pl-5 text-xs">
+                {plan.openQuestions.map((item) => <li key={item}>{item}</li>)}
+              </ul>
+            </div>
+          ) : null}
+        </div>
+      )}
+
+      <div className="mt-3 flex flex-wrap gap-2">
+        <button
+          type="button"
+          aria-label="确认生成计划"
+          className="rounded-md bg-emerald-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-emerald-700 disabled:cursor-not-allowed disabled:opacity-50"
+          onClick={() => void handleConfirm()}
+          disabled={interactionDisabled}
+          title={disabled ? disabledReason : undefined}
+          aria-busy={submitting || undefined}
+        >
+          {confirmLabel}
+        </button>
+        {editing ? null : (
+          <button
+            type="button"
+            aria-label="修改生成计划"
+            className="rounded-md border px-3 py-1.5 text-sm hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
+            onClick={handleStartEdit}
+            disabled={interactionDisabled}
+            title={disabled ? disabledReason : undefined}
+          >
+            修改计划
+          </button>
+        )}
+        <button
+          type="button"
+          aria-label="补充生成要素"
+          className="rounded-md border px-3 py-1.5 text-sm hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
+          onClick={onSupplement}
+          disabled={interactionDisabled}
+          title={disabled ? disabledReason : undefined}
+        >
+          补充信息
+        </button>
+        <button
+          type="button"
+          aria-label="取消生成计划"
+          className="rounded-md border px-3 py-1.5 text-sm text-muted-foreground hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
+          onClick={onCancel}
+          disabled={interactionDisabled}
+          title={disabled ? disabledReason : undefined}
+        >
+          取消
+        </button>
+      </div>
+    </div>
+  )
+}

+ 11 - 1
src/lib/agent/workflow-mode.spec.ts

@@ -3,6 +3,7 @@ import {
   DEFAULT_AI_WORKFLOW_MODE,
   DEFAULT_OUTLINE_WORKFLOW_MODE,
   getWorkflowModeLabel,
+  isOutlineWorkflowMode,
   resolveAiWorkflowMode,
   resolveOutlineWorkflowMode,
   type AiWorkflowMode,
@@ -40,11 +41,20 @@ describe("workflow mode", () => {
     expect(DEFAULT_OUTLINE_WORKFLOW_MODE).toBe("standard")
   })
 
-  it("maps outline workflow mode to fast or standard only", () => {
+  it("maps outline workflow mode to fast, standard or plan only", () => {
     expect(resolveOutlineWorkflowMode("fast")).toBe("fast")
     expect(resolveOutlineWorkflowMode("standard")).toBe("standard")
+    expect(resolveOutlineWorkflowMode("plan")).toBe("plan")
     expect(resolveOutlineWorkflowMode("strict")).toBe("standard")
     expect(resolveOutlineWorkflowMode(null)).toBe("standard")
     expect(resolveOutlineWorkflowMode(undefined)).toBe("standard")
   })
+
+  it("recognises outline workflow modes without accepting writing-only modes", () => {
+    expect(isOutlineWorkflowMode("fast")).toBe(true)
+    expect(isOutlineWorkflowMode("standard")).toBe(true)
+    expect(isOutlineWorkflowMode("plan")).toBe(true)
+    expect(isOutlineWorkflowMode("strict")).toBe(false)
+    expect(isOutlineWorkflowMode(null)).toBe(false)
+  })
 })

+ 5 - 3
src/lib/agent/workflow-mode.ts

@@ -1,17 +1,19 @@
 export type AiWorkflowMode = "fast" | "standard" | "strict"
-export type OutlineWorkflowMode = Extract<AiWorkflowMode, "fast" | "standard">
+/** AI 大纲的执行模式,与写作侧 AiWorkflowMode 解耦,plan 为大纲专属的计划模式。 */
+export type OutlineWorkflowMode = "fast" | "standard" | "plan"
 
 export const DEFAULT_AI_WORKFLOW_MODE: AiWorkflowMode = "standard"
 export const DEFAULT_OUTLINE_WORKFLOW_MODE: OutlineWorkflowMode = "standard"
 
 const AI_WORKFLOW_MODES: readonly AiWorkflowMode[] = ["fast", "standard", "strict"]
+const OUTLINE_WORKFLOW_MODES: readonly OutlineWorkflowMode[] = ["fast", "standard", "plan"]
 
 export function isAiWorkflowMode(value: unknown): value is AiWorkflowMode {
   return typeof value === "string" && (AI_WORKFLOW_MODES as readonly string[]).includes(value)
 }
 
 export function isOutlineWorkflowMode(value: unknown): value is OutlineWorkflowMode {
-  return value === "fast" || value === "standard"
+  return typeof value === "string" && (OUTLINE_WORKFLOW_MODES as readonly string[]).includes(value)
 }
 
 export function resolveAiWorkflowMode(value: unknown): AiWorkflowMode {
@@ -21,7 +23,7 @@ export function resolveAiWorkflowMode(value: unknown): AiWorkflowMode {
 export function resolveOutlineWorkflowMode(
   value: OutlineWorkflowMode | AiWorkflowMode | null | undefined,
 ): OutlineWorkflowMode {
-  return value === "fast" ? "fast" : DEFAULT_OUTLINE_WORKFLOW_MODE
+  return isOutlineWorkflowMode(value) ? value : DEFAULT_OUTLINE_WORKFLOW_MODE
 }
 
 export function getWorkflowModeLabel(mode: AiWorkflowMode): string {

+ 36 - 2
src/lib/novel/outline-context-reuse.spec.ts

@@ -271,6 +271,14 @@ describe("AI 大纲上下文复用策略", () => {
       workflowMode: "standard",
       intentPhase: undefined,
     })).toBe(false)
+    expect(shouldShowOutlineWorkflowProcess({
+      workflowMode: "plan",
+      planPhase: "element_check",
+    })).toBe(true)
+    expect(shouldShowOutlineWorkflowProcess({
+      workflowMode: "plan",
+      intentPhase: "generation",
+    })).toBe(true)
 
     const decision = planOutlineContextReuse({
       hasPriorAssistantAnswer: true,
@@ -317,7 +325,33 @@ describe("AI 大纲上下文复用策略", () => {
     })
     expect(plan.showToolProcess).toBe(false)
     expect(plan.showThinkingProcess).toBe(false)
-    expect(plan.showToolProcessOnError).toBe(true)
-    expect(plan.sources).toContain("过程: 已隐藏重复工具过程")
+  })
+
+  it("计划模式要素盘点强制刷新上下文并说明原因", () => {
+    const decision = planOutlineContextReuse({
+      hasPriorAssistantAnswer: true,
+      attachedReferenceCount: 0,
+      inputText: "请对以下大纲请求做计划模式要素盘点",
+      enableMultiAgent: false,
+      systemGenerated: true,
+      workflowMode: "plan",
+      planPhase: "element_check",
+    })
+
+    expect(decision.mode).toBe("refresh")
+    expect(decision.reason).toBe("计划模式要素盘点需要读取项目已有大纲。")
+
+    const plan = planOutlineAgentHistory({
+      history: [
+        { role: "user", content: "生成章纲" },
+        { role: "assistant", content: "已生成" },
+      ],
+      contextDecision: decision,
+      workflowMode: "plan",
+      planPhase: "element_check",
+    })
+
+    expect(plan.showToolProcess).toBe(true)
+    expect(plan.showThinkingProcess).toBe(true)
   })
 })

+ 7 - 1
src/lib/novel/outline-context-reuse.ts

@@ -2,6 +2,7 @@ import {
   resolveOutlineWorkflowMode,
   type OutlineWorkflowMode,
 } from "@/lib/agent/workflow-mode"
+import type { OutlinePlanPhase } from "./outline-plan-protocol"
 
 export const OUTLINE_CONTEXT_REUSE_DISABLED_TOOLS = [
   "read_chapter",
@@ -38,6 +39,7 @@ interface OutlineContextReuseInput {
   systemGenerated?: boolean
   workflowMode?: OutlineWorkflowMode | null
   intentPhase?: OutlineIntentPhase
+  planPhase?: OutlinePlanPhase
 }
 
 interface OutlineContextReuseDecision {
@@ -55,17 +57,20 @@ interface OutlineAgentHistoryInput {
   summaryInSystem?: boolean
   workflowMode?: OutlineWorkflowMode | null
   intentPhase?: OutlineIntentPhase
+  planPhase?: OutlinePlanPhase
   enableMultiAgent?: boolean
 }
 
 export function shouldShowOutlineWorkflowProcess(input: {
   workflowMode?: OutlineWorkflowMode | null
   intentPhase?: OutlineIntentPhase
+  planPhase?: OutlinePlanPhase
   enableMultiAgent?: boolean
 }): boolean {
-  if (resolveOutlineWorkflowMode(input.workflowMode) !== "standard") return false
+  if (resolveOutlineWorkflowMode(input.workflowMode) === "fast") return false
   return input.intentPhase === "intent_analysis"
     || input.intentPhase === "generation"
+    || input.planPhase !== undefined
     || input.enableMultiAgent === true
 }
 
@@ -222,6 +227,7 @@ function refreshReason(input: OutlineContextReuseInput): string {
   if (!input.hasPriorAssistantAnswer) return "首次生成需要建立上下文。"
   if (input.forceRefresh) return "用户手动要求强制刷新上下文。"
   if (input.enableMultiAgent) return "固定生成向导或多 Agent 任务需要完整上下文。"
+  if (input.planPhase) return "计划模式要素盘点需要读取项目已有大纲。"
   if (shouldShowOutlineWorkflowProcess(input)) return "标准大纲工作流需要完整上下文。"
   if (input.attachedReferenceCount > 0) return "本轮带有新的引用资料。"
   if (REFRESH_KEYWORD_PATTERN.test(input.inputText.trim())) {

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

@@ -1,3 +1,5 @@
+import { stripOutlinePlanMarkers } from "./outline-plan-protocol"
+
 export type IntentClarity = "clear" | "needs_input"
 
 export interface IntentClarityOption {
@@ -240,7 +242,7 @@ export function buildIntentPhaseSystemRules(
 }
 
 export function stripStructuredMarkers(text: string): string {
-  return text
+  return stripOutlinePlanMarkers(text)
     // 1. 移除完整的标记对(现有逻辑)
     .replace(/<!--\s*intent_clarity\s*-->[\s\S]*?<!--\s*\/intent_clarity\s*-->/gi, "")
     .replace(/<!--\s*next_step\s*-->[\s\S]*?<!--\s*\/next_step\s*-->/gi, "")

+ 112 - 0
src/lib/novel/outline-plan-elements.spec.ts

@@ -0,0 +1,112 @@
+import { describe, expect, it } from "vitest"
+import {
+  findOutlinePlanElementSpec,
+  getOutlinePlanRequiredElements,
+  resolveOutlinePlanModuleKind,
+} from "./outline-plan-elements"
+import { OUTLINE_SECTION_GENERATION_CONFIGS } from "./outline-section-configs"
+import { VOLUME_OUTLINE_REQUIRED_FIELDS } from "./outline-templates"
+
+describe("outline plan elements", () => {
+  it("classifies modules into the four outline structure kinds", () => {
+    expect(resolveOutlinePlanModuleKind("故事大纲")).toBe("story")
+    expect(resolveOutlinePlanModuleKind("完整新书规划")).toBe("story")
+    expect(resolveOutlinePlanModuleKind("卷纲")).toBe("volume")
+    expect(resolveOutlinePlanModuleKind("章节细纲")).toBe("chapter")
+    expect(resolveOutlinePlanModuleKind("章纲")).toBe("chapter")
+    expect(resolveOutlinePlanModuleKind("人物小传")).toBe("section")
+  })
+
+  it("derives new book elements from the wizard sufficiency gate", () => {
+    const keys = getOutlinePlanRequiredElements("故事大纲").map((spec) => spec.key)
+
+    expect(keys).toEqual([
+      "length",
+      "channel",
+      "genre",
+      "inspiration",
+      "sellingPoints",
+      "scale",
+      "characterDirection",
+      "worldview",
+      "chapterStructure",
+    ])
+  })
+
+  it("derives volume elements from VOLUME_OUTLINE_REQUIRED_FIELDS", () => {
+    const specs = getOutlinePlanRequiredElements("卷纲")
+
+    expect(specs[0].key).toBe("volumeScope")
+    expect(specs.slice(1).map((spec) => spec.label)).toEqual([
+      ...VOLUME_OUTLINE_REQUIRED_FIELDS,
+    ])
+  })
+
+  it("derives chapter elements from the chapter outline required sections", () => {
+    const specs = getOutlinePlanRequiredElements("章节细纲")
+    const keys = specs.map((spec) => spec.key)
+
+    expect(keys).toContain("chapterRange")
+    expect(keys).toContain("upstreamBasis")
+    expect(keys).toContain("chapterGoal")
+    expect(keys).toContain("coreEventDirection")
+    expect(keys).toContain("sceneCount")
+    expect(keys).toContain("openingHookType")
+    expect(keys).toContain("endingHookType")
+    expect(keys).toContain("foreshadowingState")
+    expect(keys).toContain("wordCountTarget")
+    expect(keys).toContain("pov")
+    expect(keys).toContain("timeAnchor")
+    expect(specs.find((spec) => spec.key === "chapterPosition")?.required).toBe(false)
+  })
+
+  it("derives section elements from the section generation config request hint", () => {
+    const specs = getOutlinePlanRequiredElements("人物小传")
+    const requirement = specs.find((spec) => spec.key === "moduleRequirement")
+    const config = OUTLINE_SECTION_GENERATION_CONFIGS.find((item) => item.title === "人物小传")
+
+    expect(requirement?.hint).toBe(config?.requestHint)
+    expect(specs.some((spec) => spec.key === "itemPriority")).toBe(true)
+  })
+
+  it("omits item priority for single output sections", () => {
+    const specs = getOutlinePlanRequiredElements("背景设定")
+
+    expect(specs.some((spec) => spec.key === "itemPriority")).toBe(false)
+  })
+
+  it("covers every section generation config with a usable element list", () => {
+    for (const config of OUTLINE_SECTION_GENERATION_CONFIGS) {
+      const specs = getOutlinePlanRequiredElements(config.title)
+
+      expect(specs.length).toBeGreaterThan(0)
+      expect(specs.some((spec) => spec.required)).toBe(true)
+    }
+  })
+
+  it("always offers at least three fallback options per element", () => {
+    const modules = [
+      "故事大纲",
+      "卷纲",
+      "章节细纲",
+      ...OUTLINE_SECTION_GENERATION_CONFIGS.map((config) => config.title),
+    ]
+
+    for (const module of modules) {
+      for (const spec of getOutlinePlanRequiredElements(module)) {
+        expect(spec.fallbackOptions.length).toBeGreaterThanOrEqual(3)
+        expect(new Set(spec.fallbackOptions).size).toBe(spec.fallbackOptions.length)
+        expect(spec.label.trim()).not.toBe("")
+        expect(spec.hint.trim()).not.toBe("")
+      }
+    }
+  })
+
+  it("finds element specs by key or by label", () => {
+    const specs = getOutlinePlanRequiredElements("卷纲")
+
+    expect(findOutlinePlanElementSpec(specs, "volumeScope")?.label).toBe("卷范围")
+    expect(findOutlinePlanElementSpec(specs, "本卷目标")?.key).toBe("volume:本卷目标")
+    expect(findOutlinePlanElementSpec(specs, "不存在")).toBeUndefined()
+  })
+})

+ 339 - 0
src/lib/novel/outline-plan-elements.ts

@@ -0,0 +1,339 @@
+import { OUTLINE_SECTION_GENERATION_CONFIGS } from "./outline-section-configs"
+import {
+  CHAPTER_END_HOOK_TYPES,
+  CHAPTER_HOOK_TYPES,
+  CHAPTER_POSITION_TYPES,
+  VOLUME_OUTLINE_REQUIRED_FIELDS,
+} from "./outline-templates"
+import {
+  OUTLINE_WIZARD_CHANNEL_OPTIONS,
+  OUTLINE_WIZARD_LENGTH_OPTIONS,
+  OUTLINE_WIZARD_NARRATIVE_OPTIONS,
+  OUTLINE_WIZARD_SELLING_POINTS,
+  getOutlineWizardGenres,
+} from "./outline-wizard"
+
+/** 计划模式的要素规格;清单全部由现有大纲结构常量派生,不新造第二套 schema。 */
+export interface OutlinePlanElementSpec {
+  key: string
+  label: string
+  hint: string
+  required: boolean
+  /** 追问兜底选项,至少 3 个,保证代码生成的问题也满足「每问 ≥3 选项」。 */
+  fallbackOptions: string[]
+}
+
+export type OutlinePlanModuleKind = "story" | "volume" | "chapter" | "section"
+
+/** 任何要素都能用的通用兜底选项。 */
+const GENERIC_FALLBACK_OPTIONS = [
+  "沿用项目里已有的设定",
+  "由 AI 根据已读取资料推断",
+  "我来补充具体描述",
+]
+
+const STORY_MODULE_PATTERN = /新书|完整新书规划|故事大纲|总纲|全书/
+const VOLUME_MODULE_PATTERN = /卷纲|分卷大纲|卷节拍/
+const CHAPTER_MODULE_PATTERN = /章纲|章节细纲|章节大纲|细纲|章节规划/
+
+/** 判断模块属于哪一类大纲结构,决定要素清单来源。 */
+export function resolveOutlinePlanModuleKind(module: string): OutlinePlanModuleKind {
+  const text = module.trim()
+  if (CHAPTER_MODULE_PATTERN.test(text)) return "chapter"
+  if (VOLUME_MODULE_PATTERN.test(text)) return "volume"
+  if (STORY_MODULE_PATTERN.test(text)) return "story"
+  return "section"
+}
+
+/** 新书/总纲要素:对齐 outline-wizard.ts 固定工作流里的充分性闸门九项。 */
+function getStoryElements(): OutlinePlanElementSpec[] {
+  return [
+    {
+      key: "length",
+      label: "篇幅",
+      hint: "长篇、中短篇还是短篇,决定卷章规模。",
+      required: true,
+      fallbackOptions: OUTLINE_WIZARD_LENGTH_OPTIONS.map((option) => option.label),
+    },
+    {
+      key: "channel",
+      label: "频道",
+      hint: "男频还是女频,决定爽点结构和读者预期。",
+      required: true,
+      fallbackOptions: OUTLINE_WIZARD_CHANNEL_OPTIONS.map((option) => option.label),
+    },
+    {
+      key: "genre",
+      label: "题材",
+      hint: "具体题材标签,决定套路模型和可调用 Skill。",
+      required: true,
+      fallbackOptions: getOutlineWizardGenres("auto")
+        .filter((genre) => genre.value !== "custom")
+        .slice(0, 6)
+        .map((genre) => genre.label),
+    },
+    {
+      key: "inspiration",
+      label: "故事灵感",
+      hint: "一句话核心创意或处理要求,是整本书的起点。",
+      required: true,
+      fallbackOptions: [
+        "延续项目里已有的灵感设定",
+        "由 AI 根据题材推荐一个灵感方向",
+        "我来补充具体灵感",
+      ],
+    },
+    {
+      key: "sellingPoints",
+      label: "核心卖点",
+      hint: "主打的爽点类型,决定情绪节奏和兑现节点。",
+      required: true,
+      fallbackOptions: [...OUTLINE_WIZARD_SELLING_POINTS],
+    },
+    {
+      key: "scale",
+      label: "作品规模",
+      hint: "预计总字数与卷数,决定阶段目标拆分粒度。",
+      required: true,
+      fallbackOptions: ["100 万字以上长篇", "50-100 万字中长篇", "30 万字以内"],
+    },
+    {
+      key: "characterDirection",
+      label: "主要人物方向",
+      hint: "主角身份处境与关键配角阵容方向。",
+      required: true,
+      fallbackOptions: [
+        "沿用项目已有的主要人物",
+        "由 AI 按题材推荐人物阵容",
+        "我来补充人物方向",
+      ],
+    },
+    {
+      key: "worldview",
+      label: "世界观/背景方向",
+      hint: "时代背景、核心规则与力量体系的大方向。",
+      required: true,
+      fallbackOptions: [
+        "沿用项目已有的世界观设定",
+        "由 AI 按题材推荐世界观框架",
+        "我来补充世界观方向",
+      ],
+    },
+    {
+      key: "chapterStructure",
+      label: "预期章节结构",
+      hint: "分几卷、每卷多少章、首批要生成到哪里。",
+      required: true,
+      fallbackOptions: [
+        "单卷推进,先规划前 20 章",
+        "多卷结构,每卷 20-30 章",
+        "由 AI 推荐卷章结构",
+      ],
+    },
+  ]
+}
+
+/** 卷纲要素:直接由 VOLUME_OUTLINE_REQUIRED_FIELDS 派生,外加卷范围。 */
+function getVolumeElements(): OutlinePlanElementSpec[] {
+  const scopeElement: OutlinePlanElementSpec = {
+    key: "volumeScope",
+    label: "卷范围",
+    hint: "第几卷、覆盖哪些章节区间。",
+    required: true,
+    fallbackOptions: [
+      "紧接最新已确认卷纲的下一卷",
+      "重做当前正在写的这一卷",
+      "我来指定卷号和章节区间",
+    ],
+  }
+  const fieldElements = VOLUME_OUTLINE_REQUIRED_FIELDS.map((field) => ({
+    key: `volume:${field}`,
+    label: field,
+    hint: `卷纲必填项「${field}」的方向要求。`,
+    required: true,
+    fallbackOptions: GENERIC_FALLBACK_OPTIONS,
+  }))
+  return [scopeElement, ...fieldElements]
+}
+
+/** 章纲要素:取 CHAPTER_OUTLINE_REQUIRED_SECTIONS 各节所需的上游输入。 */
+function getChapterElements(): OutlinePlanElementSpec[] {
+  return [
+    {
+      key: "chapterRange",
+      label: "章节范围",
+      hint: "要生成哪些章的章纲,滚动章纲一次不超过 10 章。",
+      required: true,
+      fallbackOptions: [
+        "接着最新已确认章纲往后 1 章",
+        "接着往后 5 章",
+        "接着往后 10 章",
+      ],
+    },
+    {
+      key: "upstreamBasis",
+      label: "上层依据",
+      hint: "对应「上层依据」节:总纲目标、卷纲目标、阶段节奏与前后章承接。",
+      required: true,
+      fallbackOptions: GENERIC_FALLBACK_OPTIONS,
+    },
+    {
+      key: "chapterGoal",
+      label: "本章目标",
+      hint: "对应「本章目标」节:剧情、人物、情绪、信息释放和结尾效果。",
+      required: true,
+      fallbackOptions: GENERIC_FALLBACK_OPTIONS,
+    },
+    {
+      key: "coreEventDirection",
+      label: "核心事件方向",
+      hint: "对应「核心事件」节,不少于 6 条事件的推进方向。",
+      required: true,
+      fallbackOptions: GENERIC_FALLBACK_OPTIONS,
+    },
+    {
+      key: "sceneCount",
+      label: "场景数",
+      hint: "对应「场景顺序」节,标准结构为 2-4 个场景。",
+      required: true,
+      fallbackOptions: ["2 个场景", "3 个场景", "4 个场景"],
+    },
+    {
+      key: "openingHookType",
+      label: "章首钩子类型",
+      hint: "对应「章首钩子」节,从章首钩子枚举中选择。",
+      required: true,
+      fallbackOptions: [...CHAPTER_HOOK_TYPES].slice(0, 4),
+    },
+    {
+      key: "endingHookType",
+      label: "章尾钩子类型",
+      hint: "对应「章尾钩子」节,从章尾钩子枚举中选择。",
+      required: true,
+      fallbackOptions: [...CHAPTER_END_HOOK_TYPES].slice(0, 4),
+    },
+    {
+      key: "foreshadowingState",
+      label: "伏笔状态",
+      hint: "对应「伏笔与追踪」节:本章投放、回收还是延后。",
+      required: true,
+      fallbackOptions: [
+        "本章不新增伏笔,只推进已有伏笔",
+        "本章埋设新伏笔",
+        "本章回收已有伏笔",
+      ],
+    },
+    {
+      key: "wordCountTarget",
+      label: "字数目标",
+      hint: "对应「基础信息」节的字数目标。",
+      required: true,
+      fallbackOptions: ["2000 字", "3000 字", "4000 字"],
+    },
+    {
+      key: "pov",
+      label: "视角",
+      hint: "对应「基础信息」节的视角设定。",
+      required: true,
+      fallbackOptions: OUTLINE_WIZARD_NARRATIVE_OPTIONS.map((option) => option.label),
+    },
+    {
+      key: "timeAnchor",
+      label: "时间锚点",
+      hint: "对应「基础信息」节:时间锚点、章内时间跨度与上章时间差。",
+      required: true,
+      fallbackOptions: [
+        "紧接上一章,无时间跳跃",
+        "上一章之后数小时",
+        "上一章之后数天",
+      ],
+    },
+    {
+      key: "chapterPosition",
+      label: "章节定位",
+      hint: "对应「基础信息」节的章节定位标签。",
+      required: false,
+      fallbackOptions: [...CHAPTER_POSITION_TYPES].slice(0, 4),
+    },
+  ]
+}
+
+/** 其余分项模块要素:由 OUTLINE_SECTION_GENERATION_CONFIGS 的 requestHint 与 outputMode 派生。 */
+function getSectionElements(module: string): OutlinePlanElementSpec[] {
+  const config = OUTLINE_SECTION_GENERATION_CONFIGS.find(
+    (item) => item.title === module.trim() || module.includes(item.title),
+  )
+  const scopeHint = config?.outputMode === "per_item"
+    ? "要覆盖哪些条目:全部缺失项、指定几项,还是最近范围。"
+    : "要覆盖的范围:整本、当前卷,还是指定部分。"
+  return [
+    {
+      key: "generationScope",
+      label: "生成范围",
+      hint: scopeHint,
+      required: true,
+      fallbackOptions: [
+        "全部缺失项一次补齐",
+        "只补最近范围内用得到的部分",
+        "我来指定具体条目",
+      ],
+    },
+    {
+      key: "existingBaseline",
+      label: "已有内容与缺口",
+      hint: "项目里已经写好的部分,以及确认缺失的部分。",
+      required: true,
+      fallbackOptions: GENERIC_FALLBACK_OPTIONS,
+    },
+    {
+      key: "moduleRequirement",
+      label: "本模块内容要求",
+      hint: config?.requestHint ?? `该模块「${module.trim() || "大纲"}」需要覆盖的具体内容要求。`,
+      required: true,
+      fallbackOptions: GENERIC_FALLBACK_OPTIONS,
+    },
+    {
+      key: "storyConstraints",
+      label: "上层约束",
+      hint: "总纲、卷纲和设定里必须遵守的既有约束。",
+      required: true,
+      fallbackOptions: GENERIC_FALLBACK_OPTIONS,
+    },
+    ...(config?.outputMode === "per_item"
+      ? [{
+        key: "itemPriority",
+        label: "条目优先级",
+        hint: "先生成哪些条目,每条写到多细。",
+        required: false,
+        fallbackOptions: [
+          "先做剧情用得最多的几条",
+          "按已有大纲出场顺序推进",
+          "我来指定优先级",
+        ],
+      }]
+      : []),
+  ]
+}
+
+/** 取某个大纲模块在计划模式下必须盘点的要素清单。 */
+export function getOutlinePlanRequiredElements(module: string): OutlinePlanElementSpec[] {
+  switch (resolveOutlinePlanModuleKind(module)) {
+    case "story":
+      return getStoryElements()
+    case "volume":
+      return getVolumeElements()
+    case "chapter":
+      return getChapterElements()
+    default:
+      return getSectionElements(module)
+  }
+}
+
+/** 按 key 找要素规格,供协议校验生成兜底追问时使用。 */
+export function findOutlinePlanElementSpec(
+  specs: OutlinePlanElementSpec[],
+  key: string,
+): OutlinePlanElementSpec | undefined {
+  const normalized = key.trim()
+  return specs.find((spec) => spec.key === normalized || spec.label === normalized)
+}

+ 408 - 0
src/lib/novel/outline-plan-protocol.spec.ts

@@ -0,0 +1,408 @@
+import { describe, expect, it } from "vitest"
+import { getOutlinePlanRequiredElements } from "./outline-plan-elements"
+import {
+  OUTLINE_PLAN_CUSTOM_OPTION_ID,
+  OUTLINE_PLAN_MARKER_CLOSE,
+  OUTLINE_PLAN_MARKER_OPEN,
+  buildOutlinePlanClarifyAnswerPrompt,
+  buildOutlinePlanElementCheckPrompt,
+  buildOutlinePlanExecutionPrompt,
+  buildOutlinePlanPhaseSystemRules,
+  findUnsatisfiedOutlinePlanElements,
+  formatOutlinePlanMarkdown,
+  parseOutlinePlanProtocol,
+  stripOutlinePlanMarkers,
+  validateOutlinePlanProtocol,
+  type OutlinePlanProtocol,
+} from "./outline-plan-protocol"
+
+function wrap(payload: unknown): string {
+  return `${OUTLINE_PLAN_MARKER_OPEN}\n${JSON.stringify(payload)}\n${OUTLINE_PLAN_MARKER_CLOSE}`
+}
+
+function threeOptions() {
+  return [
+    { id: "A", label: "选项一", description: "说明一" },
+    { id: "B", label: "选项二", description: "说明二" },
+    { id: "C", label: "选项三", description: "说明三" },
+  ]
+}
+
+const chapterElements = getOutlinePlanRequiredElements("章节细纲")
+
+function satisfyAll(): OutlinePlanProtocol["elements"] {
+  return chapterElements.map((spec) => ({
+    key: spec.key,
+    value: `已确认-${spec.label}`,
+    source: "user" as const,
+    satisfied: true,
+  }))
+}
+
+const readyPlan = {
+  summary: "先补第 11-15 章章纲",
+  steps: [{ id: "s1", title: "读取卷纲", detail: "确认本卷目标" }],
+  files: [{
+    targetFolder: "章纲",
+    fileName: "章纲_第11章.md",
+    fileType: "chapter-outline",
+    writeMode: "create",
+    elements: ["chapterGoal"],
+  }],
+  order: "先卷后章",
+  risks: ["时间线可能断裂"],
+  openQuestions: [],
+}
+
+describe("parseOutlinePlanProtocol", () => {
+  it("returns none when the marker is absent", () => {
+    expect(parseOutlinePlanProtocol("普通回复内容").kind).toBe("none")
+  })
+
+  it("parses a well formed protocol block", () => {
+    const outcome = parseOutlinePlanProtocol(wrap({
+      status: "ready",
+      module: "章节细纲",
+      elements: [{ key: "chapterRange", value: "第11-15章", source: "user", satisfied: true }],
+      missing: [],
+      questions: [],
+      plan: readyPlan,
+    }))
+
+    expect(outcome.kind).toBe("valid")
+    if (outcome.kind !== "valid") return
+    expect(outcome.protocol.status).toBe("ready")
+    expect(outcome.protocol.module).toBe("章节细纲")
+    expect(outcome.protocol.plan?.files[0].fileName).toBe("章纲_第11章.md")
+  })
+
+  it("recovers a truncated block that lost its closing marker", () => {
+    const outcome = parseOutlinePlanProtocol(
+      `${OUTLINE_PLAN_MARKER_OPEN}\n${JSON.stringify({ status: "needs_input", module: "卷纲" })}`,
+    )
+
+    expect(outcome.kind).toBe("valid")
+    if (outcome.kind !== "valid") return
+    expect(outcome.protocol.status).toBe("needs_input")
+  })
+
+  it("rejects an unclosed block that still has trailing content", () => {
+    const outcome = parseOutlinePlanProtocol(
+      `${OUTLINE_PLAN_MARKER_OPEN}\n{"status":"ready"}\n后面还有别的正文`,
+    )
+
+    expect(outcome).toEqual({
+      kind: "invalid",
+      error: "计划协议缺少闭合标记且 JSON 后仍有额外内容",
+    })
+  })
+
+  it("rejects unparsable json", () => {
+    const outcome = parseOutlinePlanProtocol(
+      `${OUTLINE_PLAN_MARKER_OPEN}\n{status: ready}\n${OUTLINE_PLAN_MARKER_CLOSE}`,
+    )
+
+    expect(outcome.kind).toBe("invalid")
+  })
+
+  it("rejects a missing or unknown status", () => {
+    expect(parseOutlinePlanProtocol(wrap({ module: "卷纲" })).kind).toBe("invalid")
+    expect(parseOutlinePlanProtocol(wrap({ status: "clear" })).kind).toBe("invalid")
+  })
+
+  it("appends a custom input option to every question", () => {
+    const outcome = parseOutlinePlanProtocol(wrap({
+      status: "needs_input",
+      module: "卷纲",
+      questions: [{ id: "q1", key: "volumeScope", question: "覆盖哪几章?", options: threeOptions() }],
+    }))
+
+    expect(outcome.kind).toBe("valid")
+    if (outcome.kind !== "valid") return
+    const options = outcome.protocol.questions[0].options
+    expect(options).toHaveLength(4)
+    expect(options[3].id).toBe(OUTLINE_PLAN_CUSTOM_OPTION_ID)
+    expect(options[3].label).toBe("其它(我来补充描述)")
+  })
+
+  it("keeps a model supplied custom option at the end without duplicating it", () => {
+    const outcome = parseOutlinePlanProtocol(wrap({
+      status: "needs_input",
+      module: "卷纲",
+      questions: [{
+        id: "q1",
+        key: "volumeScope",
+        question: "覆盖哪几章?",
+        options: [
+          { id: "CUSTOM", label: "自己写" },
+          ...threeOptions(),
+        ],
+      }],
+    }))
+
+    expect(outcome.kind).toBe("valid")
+    if (outcome.kind !== "valid") return
+    const options = outcome.protocol.questions[0].options
+    expect(options).toHaveLength(4)
+    expect(options.filter((option) => option.id === OUTLINE_PLAN_CUSTOM_OPTION_ID)).toHaveLength(1)
+    expect(options[3].label).toBe("自己写")
+  })
+
+  it("drops elements that claim to be satisfied without a value", () => {
+    const outcome = parseOutlinePlanProtocol(wrap({
+      status: "needs_input",
+      module: "卷纲",
+      elements: [{ key: "volumeScope", value: "", source: "project", satisfied: true }],
+    }))
+
+    expect(outcome.kind).toBe("valid")
+    if (outcome.kind !== "valid") return
+    expect(outcome.protocol.elements[0].satisfied).toBe(false)
+  })
+
+  it("falls back to inferred for unknown element sources", () => {
+    const outcome = parseOutlinePlanProtocol(wrap({
+      status: "needs_input",
+      module: "卷纲",
+      elements: [{ key: "volumeScope", value: "第二卷", source: "guess", satisfied: true }],
+    }))
+
+    expect(outcome.kind).toBe("valid")
+    if (outcome.kind !== "valid") return
+    expect(outcome.protocol.elements[0].source).toBe("inferred")
+  })
+})
+
+describe("validateOutlinePlanProtocol", () => {
+  it("rejects needs_input without any question", () => {
+    const result = validateOutlinePlanProtocol({
+      status: "needs_input",
+      module: "章节细纲",
+      elements: [],
+      missing: ["chapterRange"],
+      questions: [],
+    }, chapterElements)
+
+    expect(result.kind).toBe("invalid")
+  })
+
+  it("rejects a question with fewer than three real options", () => {
+    const result = validateOutlinePlanProtocol({
+      status: "needs_input",
+      module: "章节细纲",
+      elements: [],
+      missing: ["chapterRange"],
+      questions: [{
+        id: "q1",
+        key: "chapterRange",
+        question: "写到第几章?",
+        multiple: false,
+        options: [
+          { id: "A", label: "第 11 章", description: "" },
+          { id: "B", label: "第 12 章", description: "" },
+          { id: OUTLINE_PLAN_CUSTOM_OPTION_ID, label: "其它", description: "" },
+        ],
+      }],
+    }, chapterElements)
+
+    expect(result.kind).toBe("invalid")
+    if (result.kind !== "invalid") return
+    expect(result.error).toContain("少于 3 个")
+  })
+
+  it("accepts needs_input with three real options plus the custom entry", () => {
+    const parsed = parseOutlinePlanProtocol(wrap({
+      status: "needs_input",
+      module: "章节细纲",
+      questions: [{ id: "q1", key: "chapterRange", question: "写到第几章?", options: threeOptions() }],
+    }))
+    expect(parsed.kind).toBe("valid")
+    if (parsed.kind !== "valid") return
+
+    const result = validateOutlinePlanProtocol(parsed.protocol, chapterElements)
+
+    expect(result.kind).toBe("needs_input")
+    if (result.kind !== "needs_input") return
+    expect(result.downgraded).toBe(false)
+  })
+
+  it("rejects ready without steps or files", () => {
+    const base: OutlinePlanProtocol = {
+      status: "ready",
+      module: "章节细纲",
+      elements: satisfyAll(),
+      missing: [],
+      questions: [],
+      plan: { ...readyPlan, steps: [] },
+    }
+
+    expect(validateOutlinePlanProtocol(base, chapterElements).kind).toBe("invalid")
+    expect(validateOutlinePlanProtocol({
+      ...base,
+      plan: { ...readyPlan, files: [] },
+    }, chapterElements).kind).toBe("invalid")
+    expect(validateOutlinePlanProtocol({
+      ...base,
+      plan: undefined,
+    }, chapterElements).kind).toBe("invalid")
+  })
+
+  it("passes ready through when every required element is satisfied", () => {
+    const result = validateOutlinePlanProtocol({
+      status: "ready",
+      module: "章节细纲",
+      elements: satisfyAll(),
+      missing: [],
+      questions: [],
+      plan: readyPlan,
+    }, chapterElements)
+
+    expect(result.kind).toBe("ready")
+  })
+
+  it("downgrades ready to needs_input when required elements are still missing", () => {
+    const elements = satisfyAll().filter((element) => element.key !== "chapterGoal")
+    const result = validateOutlinePlanProtocol({
+      status: "ready",
+      module: "章节细纲",
+      elements,
+      missing: [],
+      questions: [],
+      plan: readyPlan,
+    }, chapterElements)
+
+    expect(result.kind).toBe("needs_input")
+    if (result.kind !== "needs_input") return
+    expect(result.downgraded).toBe(true)
+    expect(result.protocol.plan).toBeUndefined()
+    expect(result.protocol.missing).toContain("本章目标")
+    expect(result.protocol.questions).toHaveLength(1)
+    expect(result.protocol.questions[0].key).toBe("chapterGoal")
+    const options = result.protocol.questions[0].options
+    expect(options.filter((option) => option.id !== OUTLINE_PLAN_CUSTOM_OPTION_ID).length)
+      .toBeGreaterThanOrEqual(3)
+    expect(options.at(-1)?.id).toBe(OUTLINE_PLAN_CUSTOM_OPTION_ID)
+  })
+
+  it("caps a downgrade to at most four questions per round", () => {
+    const result = validateOutlinePlanProtocol({
+      status: "ready",
+      module: "章节细纲",
+      elements: [],
+      missing: [],
+      questions: [],
+      plan: readyPlan,
+    }, chapterElements)
+
+    expect(result.kind).toBe("needs_input")
+    if (result.kind !== "needs_input") return
+    expect(result.protocol.questions).toHaveLength(4)
+    expect(result.protocol.missing.length).toBeGreaterThan(4)
+  })
+
+  it("ignores optional elements when checking sufficiency", () => {
+    const elements = satisfyAll().filter((element) => element.key !== "chapterPosition")
+    const result = validateOutlinePlanProtocol({
+      status: "ready",
+      module: "章节细纲",
+      elements,
+      missing: [],
+      questions: [],
+      plan: readyPlan,
+    }, chapterElements)
+
+    expect(result.kind).toBe("ready")
+  })
+
+  it("matches elements by label as well as by key", () => {
+    const volumeElements = getOutlinePlanRequiredElements("卷纲")
+    const protocol: OutlinePlanProtocol = {
+      status: "ready",
+      module: "卷纲",
+      elements: volumeElements.map((spec) => ({
+        key: spec.label,
+        value: "已确认",
+        source: "project" as const,
+        satisfied: true,
+      })),
+      missing: [],
+      questions: [],
+      plan: readyPlan,
+    }
+
+    expect(findUnsatisfiedOutlinePlanElements(protocol, volumeElements)).toEqual([])
+    expect(validateOutlinePlanProtocol(protocol, volumeElements).kind).toBe("ready")
+  })
+})
+
+describe("outline plan prompts", () => {
+  it("lists every element in the phase system rules and forbids body generation", () => {
+    const rules = buildOutlinePlanPhaseSystemRules("章节细纲", chapterElements)
+
+    expect(rules).toContain("本轮禁止生成大纲正文")
+    expect(rules).toContain("禁止输出 intent_clarity")
+    expect(rules).toContain(OUTLINE_PLAN_MARKER_OPEN)
+    expect(rules).toContain(OUTLINE_PLAN_MARKER_CLOSE)
+    expect(rules).toContain("章纲")
+    for (const spec of chapterElements) {
+      expect(rules).toContain(spec.key)
+    }
+  })
+
+  it("builds the first round element check prompt from the module request hint", () => {
+    const prompt = buildOutlinePlanElementCheckPrompt({
+      module: "人物小传",
+      requestHint: "整理主要人物的小传",
+      originalRequest: "帮我补人物",
+    })
+
+    expect(prompt).toContain("人物小传")
+    expect(prompt).toContain("整理主要人物的小传")
+    expect(prompt).toContain("帮我补人物")
+  })
+
+  it("carries answers and previously confirmed elements into the follow-up prompt", () => {
+    const prompt = buildOutlinePlanClarifyAnswerPrompt({
+      module: "章节细纲",
+      answers: [{ key: "chapterRange", label: "章节范围", question: "写到第几章?", value: "第 11-15 章" }],
+      collected: [{ key: "pov", value: "第三人称", source: "project", satisfied: true }],
+    })
+
+    expect(prompt).toContain("章节范围:第 11-15 章")
+    expect(prompt).toContain("pov:第三人称(来源:project)")
+    expect(prompt).toContain("重新判断是否还有必填要素缺失")
+  })
+
+  it("renders the plan as markdown for the card and the edit box", () => {
+    const markdown = formatOutlinePlanMarkdown(readyPlan)
+
+    expect(markdown).toContain("## 方案概要")
+    expect(markdown).toContain("1. 读取卷纲:确认本卷目标")
+    expect(markdown).toContain("- 章纲/章纲_第11章.md(chapter-outline、create)")
+    expect(markdown).toContain("## 生成顺序")
+    expect(markdown).toContain("- 时间线可能断裂")
+    expect(markdown).not.toContain("## 遗留问题")
+  })
+
+  it("builds an execution prompt that blocks another planning round", () => {
+    const prompt = buildOutlinePlanExecutionPrompt({
+      module: "章节细纲",
+      planText: formatOutlinePlanMarkdown(readyPlan),
+      elements: [{ key: "chapterRange", value: "第 11-15 章", source: "user", satisfied: true }],
+    })
+
+    expect(prompt).toContain("生成计划已确认")
+    expect(prompt).toContain("=== 已确认的生成计划 ===")
+    expect(prompt).toContain("chapterRange:第 11-15 章")
+    expect(prompt).toContain(`禁止再输出 ${OUTLINE_PLAN_MARKER_OPEN}`)
+    expect(prompt).toContain("outlineSaveRequest")
+  })
+})
+
+describe("stripOutlinePlanMarkers", () => {
+  it("removes complete blocks, streaming leftovers and bare closing markers", () => {
+    expect(stripOutlinePlanMarkers(`前文${wrap({ status: "ready" })}后文`)).toBe("前文后文")
+    expect(stripOutlinePlanMarkers(`前文${OUTLINE_PLAN_MARKER_OPEN}\n{"status":"rea`)).toBe("前文")
+    expect(stripOutlinePlanMarkers(`前文${OUTLINE_PLAN_MARKER_CLOSE}后文`)).toBe("前文后文")
+  })
+})

+ 532 - 0
src/lib/novel/outline-plan-protocol.ts

@@ -0,0 +1,532 @@
+import { DEFAULT_OUTLINE_FOLDERS } from "./outline-workbench"
+import type { OutlinePlanElementSpec } from "./outline-plan-elements"
+
+export const OUTLINE_PLAN_MARKER_OPEN = "<!-- outline_plan -->"
+export const OUTLINE_PLAN_MARKER_CLOSE = "<!-- /outline_plan -->"
+/** 自定义输入项固定 id,卡片据此渲染手动输入框。 */
+export const OUTLINE_PLAN_CUSTOM_OPTION_ID = "CUSTOM"
+export const OUTLINE_PLAN_CUSTOM_OPTION_LABEL = "其它(我来补充描述)"
+/** 每个追问必须提供的真实选项数量下限。 */
+export const OUTLINE_PLAN_MIN_OPTIONS = 3
+/** 单轮兜底追问的问题数量上限,避免一次铺满整张卡片。 */
+export const OUTLINE_PLAN_MAX_FALLBACK_QUESTIONS = 4
+
+export type OutlinePlanStatus = "needs_input" | "ready"
+export type OutlinePlanElementSource = "user" | "project" | "inferred"
+
+export interface OutlinePlanOption {
+  id: string
+  label: string
+  description: string
+}
+
+export interface OutlinePlanQuestion {
+  id: string
+  key: string
+  question: string
+  multiple: boolean
+  options: OutlinePlanOption[]
+}
+
+export interface OutlinePlanElementState {
+  key: string
+  value: string
+  source: OutlinePlanElementSource
+  satisfied: boolean
+}
+
+export interface OutlinePlanStep {
+  id: string
+  title: string
+  detail: string
+}
+
+export interface OutlinePlanFile {
+  targetFolder: string
+  fileName: string
+  fileType: string
+  writeMode: string
+  elements: string[]
+}
+
+export interface OutlinePlanBlueprint {
+  summary: string
+  steps: OutlinePlanStep[]
+  files: OutlinePlanFile[]
+  order: string
+  risks: string[]
+  openQuestions: string[]
+}
+
+export interface OutlinePlanProtocol {
+  status: OutlinePlanStatus
+  module: string
+  elements: OutlinePlanElementState[]
+  missing: string[]
+  questions: OutlinePlanQuestion[]
+  plan?: OutlinePlanBlueprint
+}
+
+export type OutlinePlanParseOutcome =
+  | { kind: "none" }
+  | { kind: "valid"; protocol: OutlinePlanProtocol }
+  | { kind: "invalid"; error: string }
+
+export type OutlinePlanValidation =
+  | { kind: "invalid"; error: string }
+  | { kind: "ready"; protocol: OutlinePlanProtocol }
+  | { kind: "needs_input"; protocol: OutlinePlanProtocol; downgraded: boolean }
+
+export interface OutlinePlanAnswer {
+  key: string
+  label: string
+  question: string
+  value: string
+}
+
+/** 计划模式两个协议轮次;执行腿仍复用既有 generation 阶段。 */
+export type OutlinePlanPhase = "element_check" | "plan_proposal"
+/** 卡片已被使用过的标记,重载后据此置灰,防重复提交。 */
+export type OutlinePlanDecision = "confirmed" | "cancelled" | "answered"
+
+const OPEN_PATTERN = /<!--\s*outline_plan\s*-->/i
+const CLOSE_PATTERN = /<!--\s*\/outline_plan\s*-->/i
+const ELEMENT_SOURCES: OutlinePlanElementSource[] = ["user", "project", "inferred"]
+
+function extractLeadingJsonObject(text: string): { json: string; remainder: string } | null {
+  const start = text.indexOf("{")
+  if (start < 0) return null
+  let depth = 0
+  let inString = false
+  let escaped = false
+  for (let index = start; index < text.length; index += 1) {
+    const character = text[index]
+    if (inString) {
+      if (escaped) {
+        escaped = false
+      } else if (character === "\\") {
+        escaped = true
+      } else if (character === '"') {
+        inString = false
+      }
+      continue
+    }
+    if (character === '"') {
+      inString = true
+    } else if (character === "{") {
+      depth += 1
+    } else if (character === "}") {
+      depth -= 1
+      if (depth === 0) {
+        return { json: text.slice(start, index + 1), remainder: text.slice(index + 1) }
+      }
+    }
+  }
+  return null
+}
+
+function toStringArray(value: unknown): string[] {
+  return Array.isArray(value)
+    ? value.map((item) => String(item ?? "").trim()).filter(Boolean)
+    : []
+}
+
+function isPlainObject(value: unknown): value is Record<string, unknown> {
+  return Boolean(value) && typeof value === "object" && !Array.isArray(value)
+}
+
+function normalizeOptions(raw: unknown): OutlinePlanOption[] {
+  const options = Array.isArray(raw)
+    ? raw
+      .filter(isPlainObject)
+      .map((item, index) => ({
+        id: String(item.id ?? "").trim() || String.fromCharCode(65 + index),
+        label: String(item.label ?? "").trim(),
+        description: String(item.description ?? "").trim(),
+      }))
+      .filter((option) => option.label)
+    : []
+  const withoutCustom = options.filter(
+    (option) => option.id.toUpperCase() !== OUTLINE_PLAN_CUSTOM_OPTION_ID,
+  )
+  const custom = options.find(
+    (option) => option.id.toUpperCase() === OUTLINE_PLAN_CUSTOM_OPTION_ID,
+  )
+  return [
+    ...withoutCustom,
+    {
+      id: OUTLINE_PLAN_CUSTOM_OPTION_ID,
+      label: custom?.label || OUTLINE_PLAN_CUSTOM_OPTION_LABEL,
+      description: custom?.description ?? "",
+    },
+  ]
+}
+
+function normalizeQuestions(raw: unknown): OutlinePlanQuestion[] {
+  if (!Array.isArray(raw)) return []
+  return raw
+    .filter(isPlainObject)
+    .map((item, index) => ({
+      id: String(item.id ?? "").trim() || `q${index + 1}`,
+      key: String(item.key ?? "").trim(),
+      question: String(item.question ?? "").trim(),
+      multiple: item.multiple === true,
+      options: normalizeOptions(item.options),
+    }))
+    .filter((question) => question.question)
+}
+
+function normalizeElements(raw: unknown): OutlinePlanElementState[] {
+  if (!Array.isArray(raw)) return []
+  return raw
+    .filter(isPlainObject)
+    .map((item) => {
+      const source = String(item.source ?? "inferred").trim() as OutlinePlanElementSource
+      const value = String(item.value ?? "").trim()
+      return {
+        key: String(item.key ?? "").trim(),
+        value,
+        source: ELEMENT_SOURCES.includes(source) ? source : "inferred",
+        satisfied: item.satisfied === true && value !== "",
+      }
+    })
+    .filter((element) => element.key)
+}
+
+function normalizePlan(raw: unknown): OutlinePlanBlueprint | undefined {
+  if (!isPlainObject(raw)) return undefined
+  const steps = Array.isArray(raw.steps)
+    ? raw.steps
+      .filter(isPlainObject)
+      .map((item, index) => ({
+        id: String(item.id ?? "").trim() || `s${index + 1}`,
+        title: String(item.title ?? "").trim(),
+        detail: String(item.detail ?? "").trim(),
+      }))
+      .filter((step) => step.title)
+    : []
+  const files = Array.isArray(raw.files)
+    ? raw.files
+      .filter(isPlainObject)
+      .map((item) => ({
+        targetFolder: String(item.targetFolder ?? "").trim(),
+        fileName: String(item.fileName ?? "").trim(),
+        fileType: String(item.fileType ?? "").trim(),
+        writeMode: String(item.writeMode ?? "").trim(),
+        elements: toStringArray(item.elements),
+      }))
+      .filter((file) => file.fileName)
+    : []
+  return {
+    summary: String(raw.summary ?? "").trim(),
+    steps,
+    files,
+    order: String(raw.order ?? "").trim(),
+    risks: toStringArray(raw.risks),
+    openQuestions: toStringArray(raw.openQuestions),
+  }
+}
+
+/**
+ * 解析 outline_plan 协议块。
+ *
+ * 与 intent_clarity 对称:支持未闭合标记兜底(流式截断),
+ * 并在解析阶段就把每个追问补上自定义输入项。
+ */
+export function parseOutlinePlanProtocol(text: string): OutlinePlanParseOutcome {
+  const openMatch = OPEN_PATTERN.exec(text)
+  if (!openMatch) return { kind: "none" }
+
+  const afterOpen = text.slice(openMatch.index + openMatch[0].length)
+  const closeMatch = CLOSE_PATTERN.exec(afterOpen)
+  const unclosedPayload = closeMatch ? null : extractLeadingJsonObject(afterOpen)
+  const payloadText = closeMatch
+    ? afterOpen.slice(0, closeMatch.index).trim()
+    : unclosedPayload?.json
+  if (!payloadText) {
+    return { kind: "invalid", error: "计划协议 JSON 不完整或缺失" }
+  }
+  if (!closeMatch && unclosedPayload?.remainder.trim()) {
+    return { kind: "invalid", error: "计划协议缺少闭合标记且 JSON 后仍有额外内容" }
+  }
+
+  let payload: unknown
+  try {
+    payload = JSON.parse(payloadText)
+  } catch {
+    return { kind: "invalid", error: "计划协议 JSON 无法解析" }
+  }
+  if (!isPlainObject(payload)) {
+    return { kind: "invalid", error: "计划协议必须是 JSON 对象" }
+  }
+
+  const status = String(payload.status ?? "").trim()
+  if (status !== "needs_input" && status !== "ready") {
+    return { kind: "invalid", error: "计划协议缺少有效的 status 字段" }
+  }
+
+  return {
+    kind: "valid",
+    protocol: {
+      status,
+      module: String(payload.module ?? "").trim() || "大纲",
+      elements: normalizeElements(payload.elements),
+      missing: toStringArray(payload.missing),
+      questions: normalizeQuestions(payload.questions),
+      plan: normalizePlan(payload.plan),
+    },
+  }
+}
+
+function countRealOptions(question: OutlinePlanQuestion): number {
+  return question.options.filter(
+    (option) => option.id.toUpperCase() !== OUTLINE_PLAN_CUSTOM_OPTION_ID,
+  ).length
+}
+
+/** 找出必填要素里尚未满足的部分。 */
+export function findUnsatisfiedOutlinePlanElements(
+  protocol: OutlinePlanProtocol,
+  required: OutlinePlanElementSpec[],
+): OutlinePlanElementSpec[] {
+  return required.filter((spec) => {
+    if (!spec.required) return false
+    const matched = protocol.elements.find(
+      (element) => element.key === spec.key || element.key === spec.label,
+    )
+    return !matched?.satisfied
+  })
+}
+
+/** 按缺失要素生成兜底追问,每问至少 3 个真实选项 + 自定义输入项。 */
+export function buildFallbackClarifyQuestions(
+  specs: OutlinePlanElementSpec[],
+): OutlinePlanQuestion[] {
+  return specs.slice(0, OUTLINE_PLAN_MAX_FALLBACK_QUESTIONS).map((spec) => ({
+    id: `auto-${spec.key}`,
+    key: spec.key,
+    question: `请确认「${spec.label}」:${spec.hint}`,
+    multiple: false,
+    options: normalizeOptions(
+      spec.fallbackOptions.map((label, index) => ({
+        id: String.fromCharCode(65 + index),
+        label,
+        description: "",
+      })),
+    ),
+  }))
+}
+
+/**
+ * 计划协议的代码闸门。
+ *
+ * 1. needs_input 时每问真实选项少于 3 个判为非法,不放行。
+ * 2. ready 时缺少步骤或文件清单判为非法。
+ * 3. ready 但必填要素没齐,强制降级为 needs_input 并按缺口生成追问。
+ */
+export function validateOutlinePlanProtocol(
+  protocol: OutlinePlanProtocol,
+  required: OutlinePlanElementSpec[],
+): OutlinePlanValidation {
+  if (protocol.status === "needs_input") {
+    if (protocol.questions.length === 0) {
+      return { kind: "invalid", error: "计划协议标记为需要补充信息,但没有给出任何追问" }
+    }
+    const thin = protocol.questions.find(
+      (question) => countRealOptions(question) < OUTLINE_PLAN_MIN_OPTIONS,
+    )
+    if (thin) {
+      return {
+        kind: "invalid",
+        error: `追问「${thin.question}」的可选项少于 ${OUTLINE_PLAN_MIN_OPTIONS} 个,无法进入问答`,
+      }
+    }
+    return { kind: "needs_input", protocol, downgraded: false }
+  }
+
+  const plan = protocol.plan
+  if (!plan || plan.steps.length === 0) {
+    return { kind: "invalid", error: "计划协议标记为可执行,但缺少生成步骤" }
+  }
+  if (plan.files.length === 0) {
+    return { kind: "invalid", error: "计划协议标记为可执行,但缺少待写文件清单" }
+  }
+
+  const unsatisfied = findUnsatisfiedOutlinePlanElements(protocol, required)
+  if (unsatisfied.length > 0) {
+    return {
+      kind: "needs_input",
+      downgraded: true,
+      protocol: {
+        ...protocol,
+        status: "needs_input",
+        missing: unsatisfied.map((spec) => spec.label),
+        questions: buildFallbackClarifyQuestions(unsatisfied),
+        plan: undefined,
+      },
+    }
+  }
+
+  return { kind: "ready", protocol }
+}
+
+function formatElementChecklist(required: OutlinePlanElementSpec[]): string[] {
+  return required.map((spec) => {
+    const flag = spec.required ? "必填" : "可选"
+    return `- ${spec.key}(${spec.label},${flag}):${spec.hint}`
+  })
+}
+
+/** 计划模式的系统规则;替代标准模式的意图清晰度分析段。 */
+export function buildOutlinePlanPhaseSystemRules(
+  module: string,
+  required: OutlinePlanElementSpec[],
+): string {
+  const folderNames = DEFAULT_OUTLINE_FOLDERS.map((folder) => folder.name).join("、")
+  return [
+    "## 本轮阶段:计划模式要素盘点",
+    `本轮目标模块:${module || "大纲"}。本轮禁止生成大纲正文,禁止调用保存工具,禁止输出 intent_clarity。`,
+    "1. 先调用 list_outlines、list_chapters、list_memories 确认可用资料,再用 read_outline、read_chapter 读取相关正文。",
+    "2. 逐项盘点下面的要素清单:项目里已经写明的标 source 为 project 且 satisfied 为 true,用户已经说明的标 user,只有你自己推断的标 inferred 且 satisfied 必须为 false。",
+    "3. 只要还有必填要素没满足,status 必须是 needs_input,只输出追问,不要输出 plan。",
+    `4. 每个追问必须给出至少 ${OUTLINE_PLAN_MIN_OPTIONS} 个具体可选项(选项要来自已读取资料或题材惯例,不要写空泛的“其它”),系统会自动追加自定义输入项。`,
+    "5. 必填要素全部满足时,status 才能是 ready,并给出 plan:生成步骤、待写文件清单、生成顺序、风险和遗留问题。",
+    `6. plan.files 的 targetFolder 只能用这些文件夹名:${folderNames};fileType 只能用 outline、volume-outline、chapter-outline、character、setting、foreshadowing、organization、quality-report。`,
+    "7. 已经存在的文件必须用 append、patch 或 replace,只有新建文件才能用 create。",
+    "## 要素清单",
+    ...formatElementChecklist(required),
+    "## 输出格式(必须严格遵守)",
+    OUTLINE_PLAN_MARKER_OPEN,
+    '{"status":"needs_input|ready","module":"模块名","elements":[{"key":"要素key","value":"已确认内容","source":"user|project|inferred","satisfied":true}],"missing":["缺失要素"],"questions":[{"id":"q1","key":"要素key","question":"追问","multiple":false,"options":[{"id":"A","label":"选项","description":"说明"}]}],"plan":{"summary":"","steps":[{"id":"s1","title":"","detail":""}],"files":[{"targetFolder":"","fileName":"","fileType":"","writeMode":"","elements":[]}],"order":"","risks":[],"openQuestions":[]}}',
+    OUTLINE_PLAN_MARKER_CLOSE,
+    "开闭标记必须成对出现,JSON 必须完整可解析。最终回复只输出这一个协议块,不要输出其它正文。",
+  ].join("\n")
+}
+
+/** 计划模式首轮要素盘点的 user prompt。 */
+export function buildOutlinePlanElementCheckPrompt(input: {
+  module: string
+  requestHint: string
+  originalRequest?: string
+}): string {
+  return [
+    `请对以下大纲请求做计划模式要素盘点:「${input.module}」`,
+    input.originalRequest?.trim() ? `用户原话:${input.originalRequest.trim()}` : "",
+    "",
+    "## 本模块内容要求",
+    input.requestHint,
+    "",
+    "先读取项目已有资料,判断要素齐备情况,再按 outline_plan 协议输出结果。",
+    "要素没齐就只追问,不要生成正文;齐了就给出生成计划等我确认。",
+  ]
+    .filter(Boolean)
+    .join("\n")
+}
+
+function formatCollectedElements(elements: OutlinePlanElementState[]): string[] {
+  return elements
+    .filter((element) => element.satisfied && element.value)
+    .map((element) => `- ${element.key}:${element.value}(来源:${element.source})`)
+}
+
+/** 用户回答追问后的内部 user prompt;累计要素随对话历史传递,不依赖内存状态。 */
+export function buildOutlinePlanClarifyAnswerPrompt(input: {
+  module: string
+  answers: OutlinePlanAnswer[]
+  collected: OutlinePlanElementState[]
+}): string {
+  const collected = formatCollectedElements(input.collected)
+  return [
+    `我已补充「${input.module}」的缺失要素,请继续计划模式要素盘点。`,
+    "",
+    "## 本次补充",
+    ...input.answers.map((answer) => `- ${answer.label || answer.key}:${answer.value}`),
+    ...(collected.length
+      ? ["", "## 之前已确认的要素", ...collected]
+      : []),
+    "",
+    "请把本次补充并入 elements(source 标为 user,satisfied 标为 true),重新判断是否还有必填要素缺失。",
+    "仍有缺失就继续按 outline_plan 协议追问;已经齐备就输出 status 为 ready 的生成计划。",
+  ].join("\n")
+}
+
+/** 把计划渲染成 Markdown,供卡片展示、用户编辑和确认后回传模型。 */
+export function formatOutlinePlanMarkdown(plan: OutlinePlanBlueprint): string {
+  const lines: string[] = []
+  if (plan.summary) lines.push(`## 方案概要`, plan.summary, "")
+  if (plan.steps.length) {
+    lines.push("## 生成步骤")
+    plan.steps.forEach((step, index) => {
+      lines.push(`${index + 1}. ${step.title}${step.detail ? `:${step.detail}` : ""}`)
+    })
+    lines.push("")
+  }
+  if (plan.files.length) {
+    lines.push("## 待写文件")
+    for (const file of plan.files) {
+      const location = [file.targetFolder, file.fileName].filter(Boolean).join("/")
+      const meta = [file.fileType, file.writeMode].filter(Boolean).join("、")
+      lines.push(`- ${location}${meta ? `(${meta})` : ""}`)
+    }
+    lines.push("")
+  }
+  if (plan.order) lines.push("## 生成顺序", plan.order, "")
+  if (plan.risks.length) {
+    lines.push("## 风险", ...plan.risks.map((risk) => `- ${risk}`), "")
+  }
+  if (plan.openQuestions.length) {
+    lines.push("## 遗留问题", ...plan.openQuestions.map((item) => `- ${item}`), "")
+  }
+  return lines.join("\n").trim()
+}
+
+/** 用户确认计划后的执行 prompt;执行腿复用既有 generation 阶段。 */
+export function buildOutlinePlanExecutionPrompt(input: {
+  module: string
+  planText: string
+  elements: OutlinePlanElementState[]
+}): string {
+  const collected = formatCollectedElements(input.elements)
+  return [
+    `生成计划已确认,现在进入「${input.module}」的正文生成阶段。`,
+    "请严格按下面这份已确认的计划生成可保存的大纲正文,不要改写计划,不要再次输出计划或追问,不要再次等待确认。",
+    `禁止再输出 ${OUTLINE_PLAN_MARKER_OPEN} 协议块。生成完成后按 AI 大纲输出协议在末尾附加 outlineSaveRequest 或 outlineSaveRequests JSON 块。`,
+    ...(collected.length ? ["", "## 已确认要素", ...collected] : []),
+    "",
+    "=== 已确认的生成计划 ===",
+    input.planText.trim(),
+  ].join("\n")
+}
+
+/** 会话落盘后的形状校验,结构不完整的计划数据一律丢弃。 */
+export function isOutlinePlanProtocol(value: unknown): value is OutlinePlanProtocol {
+  if (!isPlainObject(value)) return false
+  if (value.status !== "needs_input" && value.status !== "ready") return false
+  if (typeof value.module !== "string" || !value.module.trim()) return false
+  if (!Array.isArray(value.elements) || !Array.isArray(value.questions)) return false
+  if (!Array.isArray(value.missing)) return false
+  if (!value.elements.every((element) => isPlainObject(element) && typeof element.key === "string")) {
+    return false
+  }
+  if (!value.questions.every((question) => (
+    isPlainObject(question)
+    && typeof question.question === "string"
+    && Array.isArray(question.options)
+    && question.options.every((option) => isPlainObject(option) && typeof option.label === "string")
+  ))) {
+    return false
+  }
+  if (value.plan !== undefined) {
+    if (!isPlainObject(value.plan)) return false
+    if (!Array.isArray(value.plan.steps) || !Array.isArray(value.plan.files)) return false
+  }
+  return true
+}
+
+/** 剥掉 outline_plan 协议标记,与 stripStructuredMarkers 对 intent_clarity 的处理对称。 */
+export function stripOutlinePlanMarkers(text: string): string {
+  return text
+    .replace(/<!--\s*outline_plan\s*-->[\s\S]*?<!--\s*\/outline_plan\s*-->/gi, "")
+    .replace(/<!--\s*outline_plan\s*-->[\s\S]*$/gi, "")
+    .replace(/<!--\s*\/outline_plan\s*-->/gi, "")
+}

+ 60 - 0
src/stores/outline-chat-store.spec.ts

@@ -295,4 +295,64 @@ describe("outline-chat-store", () => {
     expect(getOutlineMessageModelContent(message)).toBe("????")
   })
 
+  it("持久化计划模式的追问与确认状态", async () => {
+    useWikiStore.setState({ project: { name: "Novel", path: "C:/Book" } })
+    const stored = conversation("plan")
+    stored.messages = [{
+      id: "assistant",
+      role: "assistant",
+      content: "",
+      outlinePlanPhase: "element_check",
+      outlinePlanDecision: "answered",
+      outlinePlanProtocol: {
+        status: "needs_input",
+        module: "章节细纲",
+        elements: [{ key: "chapterRange", value: "第11-15章", source: "user", satisfied: true }],
+        missing: ["本章目标"],
+        questions: [{
+          id: "q1",
+          key: "chapterGoal",
+          question: "本章目标是什么?",
+          multiple: false,
+          options: [
+            { id: "A", label: "推进主线", description: "" },
+            { id: "B", label: "铺垫伏笔", description: "" },
+            { id: "C", label: "兑现爽点", description: "" },
+            { id: "CUSTOM", label: "其它(我来补充描述)", description: "" },
+          ],
+        }],
+      },
+    }]
+    fsMocks.readFile.mockResolvedValue(JSON.stringify({ conversations: [stored], activeConversationId: "plan" }))
+
+    await useOutlineChatStore.getState().loadFromDisk()
+
+    const message = useOutlineChatStore.getState().conversations[0].messages[0]
+    expect(message.outlinePlanPhase).toBe("element_check")
+    expect(message.outlinePlanDecision).toBe("answered")
+    expect(message.outlinePlanProtocol?.questions[0].options).toHaveLength(4)
+  })
+
+  it.each([
+    null,
+    { status: "clear", module: "章节细纲", elements: [], missing: [], questions: [] },
+    { status: "ready", module: "", elements: [], missing: [], questions: [] },
+    { status: "needs_input", module: "章节细纲", elements: [], missing: [], questions: [{ question: 1 }] },
+    { status: "ready", module: "章节细纲", elements: [], missing: [], questions: [], plan: { steps: [] } },
+  ])("丢弃结构不完整的计划协议 %#", async (invalidProtocol) => {
+    useWikiStore.setState({ project: { name: "Novel", path: "C:/Book" } })
+    const stored = conversation("invalid-plan")
+    stored.messages = [{
+      id: "assistant",
+      role: "assistant",
+      content: "",
+      outlinePlanProtocol: invalidProtocol as never,
+    }]
+    fsMocks.readFile.mockResolvedValue(JSON.stringify({ conversations: [stored], activeConversationId: "invalid-plan" }))
+
+    await useOutlineChatStore.getState().loadFromDisk()
+
+    expect(useOutlineChatStore.getState().conversations[0].messages[0].outlinePlanProtocol).toBeUndefined()
+  })
+
 })

+ 17 - 0
src/stores/outline-chat-store.ts

@@ -15,6 +15,12 @@ import {
 import { useWikiStore } from "@/stores/wiki-store"
 import type { IntentClarityResult } from "@/lib/novel/outline-intent-clarity"
 import type { NextStepRecommendation } from "@/lib/novel/outline-next-step"
+import {
+  isOutlinePlanProtocol,
+  type OutlinePlanDecision,
+  type OutlinePlanPhase,
+  type OutlinePlanProtocol,
+} from "@/lib/novel/outline-plan-protocol"
 import { isNovelGenerationRequestPackage, type NovelGenerationRequestPackage } from "@/lib/novel/novel-generation-request-package"
 import type { CharacterAgentResult } from "@/lib/novel/character-multi-agent"
 import {
@@ -101,6 +107,13 @@ export interface OutlineChatMessage {
   intentPhase?: "intent_analysis" | "generation" | "waiting_user_input"
   intentClarityResult?: IntentClarityResult | null
   intentProtocolError?: string
+  /** 计划模式:本轮是要素盘点还是方案提案。 */
+  outlinePlanPhase?: OutlinePlanPhase
+  /** 计划模式:追问或生成计划的协议结果。 */
+  outlinePlanProtocol?: OutlinePlanProtocol | null
+  outlinePlanError?: string
+  /** 计划模式:卡片已被使用,重载后保持置灰。 */
+  outlinePlanDecision?: OutlinePlanDecision
   nextStepRecommendation?: NextStepRecommendation | null
   novelGenerationRequest?: NovelGenerationRequestPackage
   contextHubSnapshot?: ContextHubSnapshotRef
@@ -422,6 +435,10 @@ export const useOutlineChatStore = create<OutlineChatState>((set, get) => {
           novelGenerationRequest: isNovelGenerationRequestPackage(message.novelGenerationRequest)
             ? message.novelGenerationRequest
             : undefined,
+          // 结构不完整的计划协议一律丢弃,避免重载后渲染出残缺卡片
+          outlinePlanProtocol: isOutlinePlanProtocol(message.outlinePlanProtocol)
+            ? message.outlinePlanProtocol
+            : undefined,
           // 验证 resumeablePlan 数据完整性,清除结构不完整的续传数据
           multiAgentRun: message.multiAgentRun
             ? {