ソースを参照

feat(outline): 共创模式补上决策点卡和定稿闸门

讨论轮强制输出 outline_discuss,点选后继续讨论,定稿才进入正文生成。

Co-authored-by: Cursor <cursoragent@cursor.com>
darknessomi 1 日 前
コミット
3a394c227d

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

@@ -1356,19 +1356,33 @@ describe("OutlineChatPanel controls", () => {
     expect(generationPrompt).toContain("outlineSaveRequest")
   })
 
-  it("共创模式系统提示要求主动抛决策点,且定稿前不出正文", () => {
+  it("共创讨论轮系统提示必须输出 outline_discuss,且不含计划/意图闸门", () => {
     const discussPrompt = buildOutlineAgentSystemPrompt({
       projectName: "测试项目",
       mode: "discuss",
+      discussModule: "章节细纲",
     })
 
     expect(discussPrompt).toContain("## 共创讨论模式总则")
     expect(discussPrompt).toContain("未经作者确认定稿前,不要输出完整大纲正文")
     expect(discussPrompt).toContain("1-3 个需要作者拍板的具体分歧点")
     expect(discussPrompt).toContain("禁止只抛开放式问题让作者自己想")
-    // 共创模式不能再被协议闸门接管
+    expect(discussPrompt).toContain("必须输出 outline_discuss")
+    expect(discussPrompt).toContain("## 本轮阶段:共创讨论")
+    expect(discussPrompt).toContain("禁止输出 intent_clarity 和 outline_plan")
     expect(discussPrompt).not.toContain("## 意图清晰度分析阶段")
     expect(discussPrompt).not.toContain("## 计划模式总则")
+    expect(discussPrompt).not.toContain("## 下一步推荐输出")
+    expect(discussPrompt).not.toContain("必须按 PRD 3.1 主流程执行")
+    expect(discussPrompt).not.toContain("## AI 大纲输出协议")
+
+    const generationPrompt = buildOutlineAgentSystemPrompt({
+      projectName: "测试项目",
+      mode: "discuss",
+    })
+    expect(generationPrompt).toContain("作者已经确认定稿")
+    expect(generationPrompt).toContain("outlineSaveRequest")
+    expect(generationPrompt).not.toContain("## 本轮阶段:共创讨论")
   })
 
   it("讨论轮不再被「只输出正文」规则压制,且质疑必须带替代方案", () => {
@@ -1390,11 +1404,127 @@ describe("OutlineChatPanel controls", () => {
   })
 
   it("共创模式三个入口都不进意图分析和多 Agent", () => {
-    expect(source).toContain('if (outlineMode === "fast" || outlineMode === "discuss") {')
     expect(source).toContain("function buildOutlineDiscussionPrompt(")
     expect(source).toContain('&& outlineMode !== "discuss"')
     expect(source).toMatch(/if \(outlineMode === "discuss"\) \{\s*\n\s*void handleSend\(buildOutlineDiscussionPrompt\(title, requestHint\)/)
     expect(source).toContain("// 共创模式把向导需求当讨论起点:先对齐方案再产出,不直接开写")
+    expect(source).toContain('outlineModeForBudget === "discuss" && options.intentPhase !== "generation"')
+    expect(source).toContain("isOutlineDiscussFinalizeRequest")
+  })
+
+  function outlineDiscussBlock(payload: unknown): string {
+    return `判断如下。\n<!-- outline_discuss -->\n${JSON.stringify(payload)}\n<!-- /outline_discuss -->\n下一步先定钩子。`
+  }
+
+  it("共创模式自由输入渲染决策点卡片,协议 JSON 不进气泡", async () => {
+    useWikiStore.setState({ outlineWorkflowMode: "discuss" })
+    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 = outlineDiscussBlock({
+        status: "needs_decision",
+        module: "章节细纲",
+        judgment: "第45章还缺一个开场选择",
+        nextStep: "先定钩子",
+        decisions: [{
+          id: "d1",
+          question: "这章用什么钩子?",
+          options: [
+            { id: "A", label: "仇人登门", description: "更狠" },
+            { id: "B", label: "旧信重现", description: "更慢" },
+          ],
+          preferenceId: "A",
+          preferenceReason: "冲突来得更快",
+        }],
+        agreed: [],
+      })
+      callbacks.onText(text)
+      callbacks.onDone()
+      return { toolCalls: [], roundsUsed: 1, finalText: text }
+    })
+    setOutlineConversations([conversation()], "outline-active")
+    const container = await renderOutlineChatPanel()
+
+    await submitOutlineInput(container, "我们来写第45章")
+
+    expect(calls).toHaveLength(1)
+    expect(calls[0].system).toContain("## 本轮阶段:共创讨论")
+    expect(calls[0].system).not.toContain("本轮阶段:意图分析")
+    expect(calls[0].system).not.toContain("## 本轮阶段:计划模式要素盘点")
+
+    const assistant = useOutlineChatStore.getState().conversations[0].messages
+      .findLast((message) => message.role === "assistant")
+    expect(assistant?.outlineDiscussPhase).toBe("decision")
+    expect(assistant?.outlineDiscussProtocol?.status).toBe("needs_decision")
+    expect(assistant?.outlineDiscussError).toBeUndefined()
+    expect(container.textContent).not.toContain("outline_discuss")
+    expect(container.textContent).not.toContain("needs_decision")
+    expect(container.textContent).toContain("需要你拍板")
+    expect(container.textContent).toContain("仇人登门")
+    expect(container.textContent).toContain("AI 倾向")
+    expect(container.textContent).toContain("等待拍板")
+    expect(container.textContent).not.toContain("缺少要素")
+    expect(document.body.textContent).not.toContain("请确认要保存的大纲文件")
+  })
+
+  it("共创模式定稿后才进入正文生成", async () => {
+    useWikiStore.setState({ outlineWorkflowMode: "discuss" })
+    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 = outlineDiscussBlock({
+        status: "ready",
+        module: "章节细纲",
+        judgment: "冲突和人物动机已经对齐",
+        nextStep: "确认后开写",
+        decisions: [],
+        agreed: [{ id: "a1", question: "开场钩子", value: "仇人登门" }],
+      })
+      callbacks.onText(text)
+      callbacks.onDone()
+      return { toolCalls: [], roundsUsed: 1, finalText: text }
+    })
+    setOutlineConversations([conversation()], "outline-active")
+    const container = await renderOutlineChatPanel()
+
+    await submitOutlineInput(container, "继续讨论第45章")
+
+    expect(container.textContent).toContain("可以定稿")
+    expect(container.textContent).toContain("开场钩子:仇人登门")
+    expect(container.textContent).toContain("等待定稿")
+
+    const confirm = container.querySelector<HTMLButtonElement>('[aria-label="定稿开始生成"]')
+    expect(confirm).not.toBeNull()
+
+    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 = "# 第45章\n\n章纲正文"
+      callbacks.onText(text)
+      callbacks.onDone()
+      return { toolCalls: [], roundsUsed: 1, finalText: text }
+    })
+
+    await act(async () => {
+      confirm?.click()
+      for (let attempt = 0; attempt < 200; attempt += 1) {
+        if (useOutlineChatStore.getState().runStates["outline-active"]?.status !== "running") break
+        await new Promise((resolve) => setTimeout(resolve, 5))
+      }
+    })
+
+    expect(calls.length).toBeGreaterThanOrEqual(2)
+    expect(calls[1].system).toContain("作者已经确认定稿")
+    expect(calls[1].system).not.toContain("## 本轮阶段:共创讨论")
+    expect(calls[1].user).toContain("已经定稿")
   })
 
   it("三个入口在计划模式下都走要素盘点,不直接进生成", () => {

+ 277 - 29
src/components/sources/outline-chat-panel.tsx

@@ -253,6 +253,18 @@ import {
   extractNextStep,
   buildNextStepPromptSuffix,
 } from "@/lib/novel/outline-next-step";
+import {
+  buildOutlineDiscussAnswerPrompt,
+  buildOutlineDiscussExecutionPrompt,
+  buildOutlineDiscussPhaseSystemRules,
+  findLatestOutlineDiscussProtocol,
+  isOutlineDiscussFinalizeRequest,
+  parseOutlineDiscussProtocol,
+  validateOutlineDiscussProtocol,
+  type OutlineDiscussAnswer,
+  type OutlineDiscussPhase,
+  type OutlineDiscussProtocol,
+} from "@/lib/novel/outline-discuss-protocol";
 import {
   buildOutlinePlanClarifyAnswerPrompt,
   buildOutlinePlanElementCheckPrompt,
@@ -267,6 +279,7 @@ import {
 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 { OutlineDiscussCard } from "@/components/sources/outline-discuss-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";
@@ -539,8 +552,12 @@ export function buildOutlineAgentSystemPrompt(options: {
   mode?: OutlineWorkflowMode;
   /** 计划模式的目标模块;只有在要素盘点轮才传,正文生成轮不传。 */
   planModule?: string;
+  /** 共创模式的目标模块;只有在讨论轮才传,定稿后的正文生成轮不传。 */
+  discussModule?: string;
 }): string {
   const mode = resolveOutlineWorkflowMode(options.mode);
+  const discussTurn = Boolean(options.discussModule);
+  const protocolTurn = Boolean(options.planModule || options.discussModule);
   const sharedAnalysisRules = [
     "你必须通过可用工具读取项目大纲、章节、记忆、推演结果和历史对话后,再进行分析、回答、生成或修改建议。",
     "不要假设引用内容已经注入上下文;不要跳过工具直接空泛回答。",
@@ -563,17 +580,25 @@ export function buildOutlineAgentSystemPrompt(options: {
       "用户要求生成或修改大纲时,直接输出可保存的大纲正文;不要先追问方案或等待确认才开始写。",
     ]
     : mode === "discuss"
-    ? [
-      ...sharedAnalysisRules,
-      "## 共创讨论模式总则",
-      "本模式是责编和作者围绕大纲来回讨论,不是一次性交付。未经作者确认定稿前,不要输出完整大纲正文,也不要输出 outlineSaveRequest 保存请求。",
-      "禁止输出 intent_clarity 和 outline_plan 协议块;本模式靠自然语言讨论推进,不靠协议闸门。",
-      "每轮回复必须包含三部分:1)你基于已读取资料得出的关键判断和依据;2)1-3 个需要作者拍板的具体分歧点,每个都要给出可选方案、你自己的倾向和理由;3)一句下一步建议。",
-      "抛出分歧点时必须给出具体可选方案,禁止只抛开放式问题让作者自己想;禁止用「你希望怎么写」这类空问题占位。",
-      "资料已经足够、确实没有需要拍板的分歧时,直接说明为什么可以开写,并请作者确认定稿,不要为了提问而提问。",
-      "只有当作者明确表示定稿、就按这个写、开始生成、可以了之类的确认时,才输出完整可保存正文并附加保存请求。",
-      "作者明确要求「先直接给我一版」时可以给出草案,但草案之后仍要列出你认为需要继续讨论的点。",
-    ]
+    ? discussTurn
+      ? [
+        ...sharedAnalysisRules,
+        "## 共创讨论模式总则",
+        "本模式是责编和作者围绕大纲来回讨论,不是一次性交付。未经作者确认定稿前,不要输出完整大纲正文,也不要输出 outlineSaveRequest 保存请求。",
+        "禁止输出 intent_clarity 和 outline_plan 协议块。",
+        "必须输出 outline_discuss 协议块;卡片认这个协议,正文里只留关键判断和下一步建议。",
+        "每轮回复必须包含三部分:1)你基于已读取资料得出的关键判断和依据;2)1-3 个需要作者拍板的具体分歧点,每个都要给出可选方案、你自己的倾向和理由;3)一句下一步建议。",
+        "抛出分歧点时必须给出具体可选方案,禁止只抛开放式问题让作者自己想;禁止用「你希望怎么写」这类空问题占位。",
+        "资料已经足够、确实没有需要拍板的分歧时,直接说明为什么可以开写,并请作者确认定稿,不要为了提问而提问。",
+        "作者明确要求「先直接给我一版」时可以给出草案,但草案之后仍要列出你认为需要继续讨论的点。",
+        buildOutlineDiscussPhaseSystemRules(options.discussModule || "大纲"),
+      ]
+      : [
+        ...sharedAnalysisRules,
+        "## 共创讨论模式总则",
+        "作者已经确认定稿。本轮只输出完整可保存正文并附加保存请求。",
+        "禁止输出 intent_clarity、outline_plan 和 outline_discuss 协议块。",
+      ]
     : mode === "plan"
     ? [
       ...sharedAnalysisRules,
@@ -609,27 +634,28 @@ export function buildOutlineAgentSystemPrompt(options: {
     "需要保存大纲时只输出 outlineSaveRequest 或 outlineSaveRequests JSON 块,禁止调用 write_outline_node;系统解析后弹出确认,用户确认后才写入文件。",
     ...workflowRules,
     "",
-    // 计划模式的要素盘点轮只允许输出协议块,这里不能再要求附加下一步推荐
-    ...(options.planModule ? [] : [
+    // 协议轮只允许输出对应协议块,不能再要求附加下一步推荐
+    ...(protocolTurn ? [] : [
       "## 下一步推荐输出",
       "生成完成后,在回复末尾附加 <!-- next_step --> JSON 标记块。",
       "推荐方向仅限大纲体系内(人物小传、组织势力、力量体系等),严禁推荐正文生成。",
       "必须包含一个 id 为 D 的自定义选项。",
     ]),
-    ...(mode === "fast" || options.planModule ? [] : [
+    ...(mode === "fast" || protocolTurn ? [] : [
       "## 主动性要求",
       "读完资料后如果发现剧情矛盾、人物动机站不住、卖点不足、伏笔无法回收或结构失衡,必须主动指出,不要沉默照做。",
       "每轮最多提出 1 条对用户已有设定的质疑,且必须同时给出替代方案和这样改的代价;没有发现真实问题时不要为了质疑而质疑。",
       "指出问题时先给结论,再给依据,再给你建议的改法;禁止只否定不给方案。",
       "如果系统标记本轮为意图分析或要素盘点,本节不适用,仍然只输出对应协议块。",
     ]),
-    ...(mode === "fast" ? [] : [
+    ...(mode === "fast" || discussTurn ? [] : [
       "当用户要求生成、完善或续写任何大纲分项时,必须按 PRD 3.1 主流程执行:提取请求关键词,识别用户意图,按意图读取资料,提取对小说创作有用的关键内容,结合用户要用的 skill + soul.md 约束生成内容,再做结果强约束收敛。",
     ]),
     "关键内容提取必须服务于小说创作:只保留能帮助用户继续写小说的信息,例如章节目标、冲突推进、人物动机、伏笔状态、设定限制、时间线承接和结尾钩子。",
     "生成章纲时必须使用章纲标准结构:基础信息、上层依据、本章目标、核心事件、场景顺序、结构节点、章首钩子、爽点设计、章尾钩子、执行约束、人物状态、伏笔与追踪、待写回设定、写作约束、AI写作提示。核心事件不少于6条,场景顺序为2-4个场景。",
     "结构节点必须包含 CBN、CPNs、CEN;CEN 必须能承接下一章 CBN。执行约束必须包含必须覆盖节点和本章禁区。基础信息必须包含时间锚点、章内时间跨度和与上章时间差。",
     "Markdown 格式约束:结构化资料使用一级标题,** 必须成对,不要用代码围栏包裹全文,已有表格必须保留合法分隔行。",
+    ...(discussTurn ? [] : [
     "## AI 大纲输出协议",
     "当本轮生成了可保存的大纲、卷纲、章纲、人物、设定、伏笔、组织或质量检查内容时,最终回复末尾必须附加一个 json 代码块,顶层字段为 outlineSaveRequest 或 outlineSaveRequests。",
     "保存请求必须包含 targetFolder、fileName、fileType、writeMode、referencedSkills、sourceIntent、content。fileName 必须是 .md 文件,targetFolder 必须是相对路径(仅文件夹名,如「大纲」「人物小传」「章纲」「设定」「伏笔」「组织」「质量检查」「卷纲」),禁止使用绝对路径(如 C:\\... 或 /Users/...)。",
@@ -639,6 +665,7 @@ export function buildOutlineAgentSystemPrompt(options: {
     "文件名规范:不同类型内容必须使用不同文件名,禁止多项内容写入同一文件。不同角色必须每人一个独立文件(如 角色-主角林风.md、角色-反官方傲.md),严禁将所有角色塞入「角色卡.md」或同一文件。不同势力、不同伏笔、不同卷纲、不同章纲也必须各自独立文件。",
     "内容完整性强制要求:你必须为每个生成的大纲模块都创建对应的保存请求(outlineSaveRequest),不能遗漏。如果生成了多个模块,使用 outlineSaveRequests 数组,每个模块一个请求对象。系统不会静默写入;用户确认后才会落盘。",
     "## Markdown 格式强制要求",
+    ]),
     "所有大纲正文必须使用标准 Markdown 格式输出,严格遵循以下标题层级规范:",
     "- 一级大标题(如全书核心设定、主要人物设定、分卷大纲等)使用 # 标记,独占一行",
     "- 二级分类标题(如核心主角、核心配角、第一卷、第二卷等)使用 ## 标记,独占一行",
@@ -1088,6 +1115,8 @@ function OutlineAssistantMessage({
   onSubmitPlanAnswers,
   onConfirmPlan,
   onCancelPlan,
+  onSubmitDiscussAnswers,
+  onConfirmDiscuss,
   onResumeMultiAgent,
   resumeMultiAgentDisabled,
   nextStepDisabled,
@@ -1120,6 +1149,12 @@ function OutlineAssistantMessage({
     planText: string,
   ) => Promise<boolean>;
   onCancelPlan: (messageId: string) => void;
+  onSubmitDiscussAnswers: (
+    messageId: string,
+    protocol: OutlineDiscussProtocol,
+    answers: OutlineDiscussAnswer[],
+  ) => Promise<boolean>;
+  onConfirmDiscuss: (messageId: string, protocol: OutlineDiscussProtocol) => Promise<boolean>;
   onResumeMultiAgent: (messageId: string) => Promise<void>;
   resumeMultiAgentDisabled: boolean;
   nextStepDisabled: boolean;
@@ -1160,9 +1195,13 @@ function OutlineAssistantMessage({
   const planProtocol = msg.outlinePlanProtocol ?? null;
   const planDecided = Boolean(msg.outlinePlanDecision);
   const isPlanProtocolMessage = Boolean(planProtocol) || Boolean(msg.outlinePlanPhase);
+  const discussProtocol = msg.outlineDiscussProtocol ?? null;
+  const discussDecided = Boolean(msg.outlineDiscussDecision);
+  const isDiscussProtocolMessage = Boolean(discussProtocol) || Boolean(msg.outlineDiscussPhase);
   const canUseAsOutlineContent = intentProtocol.kind === "none"
     && !intentProtocolError
-    && !isPlanProtocolMessage;
+    && !isPlanProtocolMessage
+    && !isDiscussProtocolMessage;
   const historicalClearIntent = !msg.intentClarityResult
     && !msg.intentProtocolError
     && msg.intentPhase !== "generation"
@@ -1180,11 +1219,11 @@ function OutlineAssistantMessage({
   const renderedMarkdownContent = useMemo(() => {
     const rawContent = parsed.textContent || answer;
     // 计划模式的协议块只用卡片渲染,气泡里不能漏出 JSON
-    if (isPlanProtocolMessage) return stripStructuredMarkers(rawContent);
+    if (isPlanProtocolMessage || isDiscussProtocolMessage) return stripStructuredMarkers(rawContent);
     if (intentProtocol.kind === "valid") return stripStructuredMarkers(rawContent);
     if (intentProtocol.kind === "invalid" || msg.intentProtocolError) return "";
     return prepareOutlineSaveSourceContent(rawContent);
-  }, [answer, intentProtocol, isPlanProtocolMessage, msg.intentProtocolError, parsed.textContent]);
+  }, [answer, intentProtocol, isDiscussProtocolMessage, isPlanProtocolMessage, msg.intentProtocolError, parsed.textContent]);
   useEffect(() => {
     if (!answer) {
       setParsed({ textContent: "", edits: [], hasEdits: false });
@@ -1261,6 +1300,11 @@ function OutlineAssistantMessage({
           {msg.outlinePlanError}
         </div>
       ) : null}
+      {msg.outlineDiscussError && !messageIsStreaming ? (
+        <div role="alert" className="mb-2 rounded border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive">
+          {msg.outlineDiscussError}
+        </div>
+      ) : null}
       <StreamingMarkdown
         content={renderedMarkdownContent}
         isStreaming={messageIsStreaming}
@@ -1356,6 +1400,18 @@ function OutlineAssistantMessage({
             : nextStepDisabledReason}
         />
       ) : null}
+      {discussProtocol && !isStreaming ? (
+        <OutlineDiscussCard
+          protocol={discussProtocol}
+          onSubmitAnswers={(answers) => onSubmitDiscussAnswers(msg.id, discussProtocol, answers)}
+          onConfirm={() => onConfirmDiscuss(msg.id, discussProtocol)}
+          onContinue={onFocusInput}
+          disabled={nextStepDisabled || discussDecided}
+          disabledReason={discussDecided
+            ? "该轮讨论已处理过,请在最新消息里继续。"
+            : nextStepDisabledReason}
+        />
+      ) : null}
       {/* 意图不清晰时的推荐选项 */}
       {msg.intentClarityResult?.clarity === "needs_input" && !isStreaming ? (
         <IntentOptionsCard
@@ -2205,8 +2261,14 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
       // content, so only they take the footer's thinking depth. Stamping it
       // here rather than at the request keeps the budget planner below in
       // sync, since it derives its output floor from `config.reasoning`.
+      const outlineModeForBudget = resolveOutlineWorkflowMode(useWikiStore.getState().outlineWorkflowMode);
+      const discussPhase: OutlineDiscussPhase | undefined =
+        outlineModeForBudget === "discuss" && options.intentPhase !== "generation"
+          ? "decision"
+          : undefined;
       const outlineBudgetStage: OutlineBudgetStage = options.intentPhase === "intent_analysis"
         || options.planPhase !== undefined
+        || discussPhase !== undefined
         ? "analysis"
         : "generation";
       if (outlineBudgetStage === "generation") {
@@ -2268,6 +2330,9 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           return setOutlineSessionValue(stages, capturedConvId, "idle");
         });
       };
+      if (discussPhase) {
+        advanceCapturedWorkflowStages(["collecting_requirements"]);
+      }
       if (shouldClearOutlineDraft({
         clearDraft: options.clearDraft !== false,
         invocationConversationId: capturedConvId,
@@ -2286,7 +2351,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
       const hasPriorAssistantAnswer = historyBeforeSend.some(
         (message) => message.role === "assistant" && message.content.trim(),
       );
-      const outlineMode = resolveOutlineWorkflowMode(useWikiStore.getState().outlineWorkflowMode);
+      const outlineMode = outlineModeForBudget;
       // 共创模式靠自然语言来回讨论推进,多 Agent 编排会直接产出成品,绕过讨论
       const enableMultiAgent = Boolean(options.enableMultiAgent)
         && outlineMode !== "fast"
@@ -2305,6 +2370,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         workflowMode: outlineMode,
         intentPhase: options.intentPhase,
         planPhase: options.planPhase,
+        discussPhase,
       });
       const cachedSummary =
         contextDecision.mode === "reuse"
@@ -2320,6 +2386,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         workflowMode: outlineMode,
         intentPhase: options.intentPhase,
         planPhase: options.planPhase,
+        discussPhase,
         enableMultiAgent,
       });
       if (forceRefreshNext) {
@@ -2357,6 +2424,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         isAgentRunning: true,
         intentPhase: options.intentPhase,
         outlinePlanPhase: options.planPhase,
+        outlineDiscussPhase: discussPhase,
       });
       clearStreamingContent(capturedConvId);
       userScrolledUpRef.current = false;
@@ -2420,6 +2488,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             summaryInSystem: true,
             workflowMode: outlineMode,
             intentPhase: options.intentPhase,
+            discussPhase,
             enableMultiAgent,
           });
         }
@@ -2446,10 +2515,12 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         );
         const soulDoc = await readSoulDoc(project.path).catch(() => "");
         const planModule = options.planPhase ? planTargetModule : undefined;
+        const discussModule = discussPhase ? planTargetModule : undefined;
         const baseSystemPrompt = buildOutlineAgentSystemPrompt({
           projectName: project.name,
           mode: outlineMode,
           planModule,
+          discussModule,
         });
         const legacySystemPrompt = buildOutlineAgentSystemPrompt({
           projectName: project.name,
@@ -2457,6 +2528,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           soulDoc,
           mode: outlineMode,
           planModule,
+          discussModule,
         }) + `\n\n## 本轮上下文策略\n${contextDecision.instruction}\n\n${historyPlan.instruction}`;
         const commonDynamicParts = [
           webResearchMarkdown ? `## 本轮联网资料\n${webResearchMarkdown}` : "",
@@ -3206,6 +3278,21 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
                 ? `计划协议不满足计划模式要求,尚未开始生成:${planValidation.error}`
                 : undefined
           : undefined;
+        const discussProtocolOutcome = discussPhase
+          ? parseOutlineDiscussProtocol(rawFinalContent)
+          : { kind: "none" as const };
+        const discussValidation = discussProtocolOutcome.kind === "valid"
+          ? validateOutlineDiscussProtocol(discussProtocolOutcome.protocol)
+          : null;
+        const discussProtocolError = discussPhase
+          ? discussProtocolOutcome.kind === "invalid"
+            ? `共创协议格式无效,尚未开始生成:${discussProtocolOutcome.error}`
+            : discussProtocolOutcome.kind === "none"
+              ? "共创协议格式无效,尚未开始生成:模型未返回 outline_discuss 协议块"
+              : discussValidation?.kind === "invalid"
+                ? `共创协议不满足共创模式要求,尚未开始生成:${discussValidation.error}`
+                : undefined
+          : undefined;
         const rawIntentProtocol = parseIntentClarityProtocol(rawFinalContent);
         const nextStepExtraction = extractNextStep(rawFinalContent, {
           allowFallback: options.intentPhase === "generation",
@@ -3269,7 +3356,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
               )
             : [],
           isAgentRunning: false,
-          nextStepRecommendation: intentProtocolError || options.planPhase
+          nextStepRecommendation: intentProtocolError || options.planPhase || discussPhase
             ? null
             : nextStepExtraction.recommendation,
           intentProtocolError,
@@ -3277,6 +3364,10 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             ? planValidation.protocol
             : null,
           outlinePlanError: planProtocolError,
+          outlineDiscussProtocol: discussValidation && discussValidation.kind !== "invalid"
+            ? discussValidation.protocol
+            : null,
+          outlineDiscussError: discussProtocolError,
         }));
         if (!isCurrentRun()) {
           void useOutlineChatStore.getState().saveToDisk();
@@ -3295,6 +3386,17 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             ]);
           }
         }
+        if (discussPhase && discussValidation) {
+          if (discussValidation.kind === "needs_decision") {
+            advanceCapturedWorkflowStages(["collecting_requirements"]);
+          } else if (discussValidation.kind === "ready") {
+            advanceCapturedWorkflowStages([
+              "sufficiency_check",
+              "generation_plan",
+              "waiting_user_confirm",
+            ]);
+          }
+        }
 
         // 解析意图清晰度结果
         const intentResult = intentProtocol.kind === "valid" && !intentProtocolError
@@ -3370,12 +3472,13 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             sessionKey: capturedConvId,
           });
         }
-        // 计划模式的盘点轮只产出协议块,没有可保存正文,不能进保存链路
+        // 计划盘点轮和共创讨论轮只产出协议块,没有可保存正文,不能进保存链路
         if (
           intentProtocol.kind === "none"
           && !intentProtocolError
           && !deliverableTruncated
           && !options.planPhase
+          && !discussPhase
         ) {
           await handleAutoSaveOutlineRequests(capturedConvId, finalContent, isCurrentRun);
         }
@@ -3593,8 +3696,34 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
   const handleDirectSubmit = useCallback(
     async (text: string, references: ReferenceToken[] = []) => {
       const outlineMode = resolveOutlineWorkflowMode(useWikiStore.getState().outlineWorkflowMode);
-      // 共创模式不进意图分析和要素盘点:讨论由 system 规则驱动,闸门只会把讨论压成协议块
-      if (outlineMode === "fast" || outlineMode === "discuss") {
+      // 共创模式不进意图分析和要素盘点:讨论轮自动上 discussPhase,短确认句走定稿生成
+      if (outlineMode === "fast") {
+        return handleSend(text, references);
+      }
+      if (outlineMode === "discuss") {
+        if (isOutlineDiscussFinalizeRequest(text)) {
+          const conversation = useOutlineChatStore.getState().conversations
+            .find((item) => item.id === (activeConversationId ?? ""));
+          const latest = conversation
+            ? findLatestOutlineDiscussProtocol(conversation.messages)
+            : null;
+          return handleSend(
+            latest
+              ? buildOutlineDiscussExecutionPrompt({
+                module: latest.module,
+                judgment: latest.judgment,
+                agreed: latest.agreed,
+              })
+              : text,
+            references,
+            {
+              intentPhase: "generation",
+              systemGenerated: Boolean(latest),
+              userDisplayText: text,
+              userMessageVisibility: latest ? "internal" : "visible",
+            },
+          );
+        }
         return handleSend(text, references);
       }
       // 非生成类输入在计划模式下也照旧直接问答,不进要素盘点
@@ -3767,6 +3896,84 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
     [activeConversationId, markOutlinePlanDecision],
   );
 
+  const markOutlineDiscussDecision = useCallback(
+    (
+      conversationId: string,
+      messageId: string,
+      decision: NonNullable<OutlineChatMessage["outlineDiscussDecision"]>,
+    ) => {
+      updateOutlineAssistantMessage(conversationId, messageId, (message) => ({
+        ...message,
+        outlineDiscussDecision: decision,
+      }));
+      void useOutlineChatStore.getState().saveToDisk();
+    },
+    [],
+  );
+
+  const handleSubmitOutlineDiscussAnswers = useCallback(
+    async (messageId: string, protocol: OutlineDiscussProtocol, answers: OutlineDiscussAnswer[]) => {
+      const capturedConvId = activeConversationId;
+      if (!capturedConvId || !canStartConversationRun(capturedConvId)) {
+        toast.info("当前会话正在生成,请等待生成完成后再拍板。", {
+          dedupeKey: "outline-discuss:busy",
+        });
+        return false;
+      }
+      markOutlineDiscussDecision(capturedConvId, messageId, "answered");
+      const result = await handleSend(
+        buildOutlineDiscussAnswerPrompt({
+          module: protocol.module,
+          answers,
+          agreed: protocol.agreed,
+        }),
+        [],
+        {
+          conversationId: capturedConvId,
+          systemGenerated: true,
+          clearDraft: false,
+          userMessageVisibility: "internal",
+        },
+      );
+      return result.sent;
+    },
+    [activeConversationId, canStartConversationRun, handleSend, markOutlineDiscussDecision],
+  );
+
+  const handleConfirmOutlineDiscuss = useCallback(
+    async (messageId: string, protocol: OutlineDiscussProtocol) => {
+      const capturedConvId = activeConversationId;
+      if (!capturedConvId || !canStartConversationRun(capturedConvId)) {
+        toast.info("当前会话正在生成,请等待生成完成后再确认定稿。", {
+          dedupeKey: "outline-discuss:busy",
+        });
+        return false;
+      }
+      markOutlineDiscussDecision(capturedConvId, messageId, "confirmed");
+      setOutlineWorkflowStages((stages) => setOutlineSessionValue(stages, capturedConvId, "idle"));
+      const intentContext = intentContextsRef.current[capturedConvId];
+      const result = await handleSend(
+        buildOutlineDiscussExecutionPrompt({
+          module: protocol.module,
+          judgment: protocol.judgment,
+          agreed: protocol.agreed,
+        }),
+        intentContext?.references ?? [],
+        {
+          conversationId: capturedConvId,
+          intentPhase: "generation",
+          systemGenerated: true,
+          clearDraft: false,
+          userMessageVisibility: "internal",
+          preferredSkillNames: intentContext?.skillNames
+            ?? getOutlineSkillNames(protocol.module),
+        },
+      );
+      return result.sent;
+    },
+    [activeConversationId, canStartConversationRun, handleSend, markOutlineDiscussDecision],
+  );
+
   const handleSendMessage = useCallback(
     async (text: string, options?: { intentPhase?: "intent_analysis" | "generation" | "waiting_user_input"; scope?: string }) => {
       const capturedConvId = activeConversationId;
@@ -4386,6 +4593,11 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         return;
       }
       const regenerationIntentPhase = targetAssistantMessage?.intentPhase;
+      const regenerationOutlineMode = resolveOutlineWorkflowMode(useWikiStore.getState().outlineWorkflowMode);
+      const regenerationDiscussPhase: OutlineDiscussPhase | undefined =
+        regenerationOutlineMode === "discuss" && regenerationIntentPhase !== "generation"
+          ? "decision"
+          : undefined;
       const runId = crypto.randomUUID();
       if (!startConversationRun(capturedConvId, runId)) return;
       const controller = new AbortController();
@@ -4460,6 +4672,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           isAgentRunning: true,
           contextHubSnapshot,
           intentPhase: regenerationIntentPhase,
+          outlineDiscussPhase: regenerationDiscussPhase,
         });
         assistantAdded = true;
 
@@ -4468,14 +4681,19 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         );
         const soulDoc = await readSoulDoc(project.path).catch(() => "");
         const registry = new ToolRegistry();
+        const regenerationDiscussModule = regenerationDiscussPhase
+          ? (intentContextsRef.current[capturedConvId]?.title || "大纲")
+          : undefined;
         const baseSystemPrompt = buildOutlineAgentSystemPrompt({
           projectName: project.name,
-          mode: resolveOutlineWorkflowMode(useWikiStore.getState().outlineWorkflowMode),
+          mode: regenerationOutlineMode,
+          discussModule: regenerationDiscussModule,
         });
         const legacySystemPrompt = buildOutlineAgentSystemPrompt({
           projectName: project.name,
-          mode: resolveOutlineWorkflowMode(useWikiStore.getState().outlineWorkflowMode),
+          mode: regenerationOutlineMode,
           soulDoc,
+          discussModule: regenerationDiscussModule,
         });
         const regenerationPhaseRules = buildIntentPhaseSystemRules(regenerationIntentPhase);
         const regenerationContext = intentContextsRef.current[capturedConvId];
@@ -4697,6 +4915,21 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           throw new Error(OUTLINE_REASONING_ONLY_ERROR_MESSAGE);
         }
         const rawRegenerationContent = filteredRegenerationContent.content || "AI大纲未返回内容。";
+        const regenerationDiscussOutcome = regenerationDiscussPhase
+          ? parseOutlineDiscussProtocol(rawRegenerationContent)
+          : { kind: "none" as const };
+        const regenerationDiscussValidation = regenerationDiscussOutcome.kind === "valid"
+          ? validateOutlineDiscussProtocol(regenerationDiscussOutcome.protocol)
+          : null;
+        const regenerationDiscussError = regenerationDiscussPhase
+          ? regenerationDiscussOutcome.kind === "invalid"
+            ? `共创协议格式无效,尚未开始生成:${regenerationDiscussOutcome.error}`
+            : regenerationDiscussOutcome.kind === "none"
+              ? "共创协议格式无效,尚未开始生成:模型未返回 outline_discuss 协议块"
+              : regenerationDiscussValidation?.kind === "invalid"
+                ? `共创协议不满足共创模式要求,尚未开始生成:${regenerationDiscussValidation.error}`
+                : undefined
+          : undefined;
         const rawRegenerationIntentProtocol = parseIntentClarityProtocol(rawRegenerationContent);
         const nextStepExtraction = extractNextStep(
           rawRegenerationContent,
@@ -4736,8 +4969,15 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             sources,
             agentToolCalls: settleRunningAgentToolCalls(record.toolCalls.length ? record.toolCalls : message.agentToolCalls),
             isAgentRunning: false,
-            nextStepRecommendation: regenerationIntentProtocolError ? null : nextStepExtraction.recommendation,
+            nextStepRecommendation: regenerationIntentProtocolError || regenerationDiscussPhase
+              ? null
+              : nextStepExtraction.recommendation,
             intentProtocolError: regenerationIntentProtocolError,
+            outlineDiscussPhase: regenerationDiscussPhase,
+            outlineDiscussProtocol: regenerationDiscussValidation && regenerationDiscussValidation.kind !== "invalid"
+              ? regenerationDiscussValidation.protocol
+              : null,
+            outlineDiscussError: regenerationDiscussError,
           }),
         );
         setConversationContextSummary(capturedConvId, buildSessionContextSummary({
@@ -4749,7 +4989,11 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           dependencyFingerprint: contextHubResult?.dependencyStamp.fingerprint ?? "",
         }));
         if (!isCurrentRun()) return;
-        if (regenerationIntentProtocol.kind === "none" && !regenerationIntentProtocolError) {
+        if (
+          regenerationIntentProtocol.kind === "none"
+          && !regenerationIntentProtocolError
+          && !regenerationDiscussPhase
+        ) {
           await handleAutoSaveOutlineRequests(capturedConvId, finalContent, isCurrentRun);
         }
         if (!isCurrentRun()) return;
@@ -5103,8 +5347,10 @@ 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 === "collecting_requirements"
+                     ? outlineWorkflowMode === "discuss" ? "等待拍板" : "收集要素" :
+                   outlineWorkflowStage === "waiting_user_confirm"
+                     ? outlineWorkflowMode === "discuss" ? "等待定稿" : "等待确认计划" :
                    outlineWorkflowStage === "sufficiency_check" ? "生成中" :
                    "处理中"}
                 </span>
@@ -5236,6 +5482,8 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
                   onSubmitPlanAnswers={handleSubmitOutlinePlanAnswers}
                   onConfirmPlan={handleConfirmOutlinePlan}
                   onCancelPlan={handleCancelOutlinePlan}
+                  onSubmitDiscussAnswers={handleSubmitOutlineDiscussAnswers}
+                  onConfirmDiscuss={handleConfirmOutlineDiscuss}
                   onResumeMultiAgent={handleResumeMultiAgent}
                   resumeMultiAgentDisabled={isStreaming}
                   nextStepDisabled={submitDisabled}

+ 187 - 0
src/components/sources/outline-discuss-card.spec.tsx

@@ -0,0 +1,187 @@
+// @vitest-environment jsdom
+import { act } from "react"
+import { createRoot } from "react-dom/client"
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
+
+import { OutlineDiscussCard } from "./outline-discuss-card"
+import {
+  OUTLINE_DISCUSS_CUSTOM_OPTION_ID,
+  type OutlineDiscussProtocol,
+} from "@/lib/novel/outline-discuss-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<OutlineDiscussProtocol> = {}): OutlineDiscussProtocol {
+  return {
+    status: "needs_decision",
+    module: "章节细纲",
+    judgment: "第45章还缺一个开场选择",
+    nextStep: "先定钩子",
+    decisions: [
+      {
+        id: "d1",
+        question: "这章用什么钩子?",
+        options: [
+          { id: "A", label: "仇人登门", description: "更狠" },
+          { id: "B", label: "旧信重现", description: "更慢" },
+          { id: OUTLINE_DISCUSS_CUSTOM_OPTION_ID, label: "其它(我来补充描述)", description: "" },
+        ],
+        preferenceId: "A",
+        preferenceReason: "冲突来得更快",
+      },
+    ],
+    agreed: [],
+    ...overrides,
+  }
+}
+
+async function renderCard(props: Partial<React.ComponentProps<typeof OutlineDiscussCard>> = {}) {
+  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)
+  const onConfirm = props.onConfirm ?? vi.fn(async () => true)
+  const onContinue = props.onContinue ?? vi.fn()
+  await act(async () => {
+    root.render(
+      <OutlineDiscussCard
+        protocol={protocol()}
+        onSubmitAnswers={onSubmitAnswers}
+        onConfirm={onConfirm}
+        onContinue={onContinue}
+        {...props}
+      />,
+    )
+  })
+  const options = () => Array.from(container.querySelectorAll('[role="option"]')) as HTMLButtonElement[]
+  const submit = () => container.querySelector('[aria-label="选定继续讨论"]') as HTMLButtonElement | null
+  const confirm = () => container.querySelector('[aria-label="定稿开始生成"]') as HTMLButtonElement | null
+  const continueButton = () => container.querySelector('[aria-label="继续讨论"]') as HTMLButtonElement | null
+  return { container, onSubmitAnswers, onConfirm, onContinue, options, submit, confirm, continueButton }
+}
+
+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("OutlineDiscussCard", () => {
+  it("渲染拍板卡,标出 AI 倾向,且文案不提要素或计划", async () => {
+    const { container, options } = await renderCard()
+
+    expect(container.textContent).toContain("需要你拍板")
+    expect(container.textContent).toContain("这章用什么钩子?")
+    expect(container.textContent).toContain("AI 倾向")
+    expect(container.textContent).toContain("冲突来得更快")
+    expect(container.textContent).not.toContain("缺少要素")
+    expect(container.textContent).not.toContain("生成计划")
+    expect(options()).toHaveLength(3)
+  })
+
+  it("没选完时禁用提交,选定后把答案交给回调", async () => {
+    const { options, submit, onSubmitAnswers } = await renderCard()
+
+    expect(submit()?.disabled).toBe(true)
+    await act(async () => options()[0].click())
+    expect(submit()?.disabled).toBe(false)
+
+    await act(async () => {
+      submit()?.click()
+      await Promise.resolve()
+    })
+
+    expect(onSubmitAnswers).toHaveBeenCalledWith([
+      { id: "d1", question: "这章用什么钩子?", value: "仇人登门" },
+    ])
+  })
+
+  it("选中自定义项后要求填写补充内容才能提交", async () => {
+    const { container, options, submit } = await renderCard()
+
+    await act(async () => options()[2].click())
+    const textarea = container.querySelector("textarea") as HTMLTextAreaElement
+    expect(textarea).not.toBeNull()
+    expect(submit()?.disabled).toBe(true)
+
+    await setTextareaValue(textarea, "先写梦境闪回")
+    expect(submit()?.disabled).toBe(false)
+  })
+
+  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 () => {
+      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("ready 时渲染定稿卡并列出已拍板项", async () => {
+    const { container, confirm, continueButton, onConfirm, onContinue } = await renderCard({
+      protocol: protocol({
+        status: "ready",
+        judgment: "冲突和人物动机已经对齐",
+        nextStep: "确认后开写",
+        decisions: [],
+        agreed: [{ id: "a1", question: "开场钩子", value: "仇人登门" }],
+      }),
+    })
+
+    expect(container.textContent).toContain("可以定稿")
+    expect(container.textContent).toContain("冲突和人物动机已经对齐")
+    expect(container.textContent).toContain("开场钩子:仇人登门")
+    expect(container.textContent).not.toContain("缺少要素")
+    expect(container.querySelector('[aria-label="选定继续讨论"]')).toBeNull()
+
+    await act(async () => {
+      continueButton()?.click()
+    })
+    expect(onContinue).toHaveBeenCalledTimes(1)
+
+    await act(async () => {
+      confirm()?.click()
+      await Promise.resolve()
+    })
+    expect(onConfirm).toHaveBeenCalledTimes(1)
+  })
+
+  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()
+  })
+})

+ 321 - 0
src/components/sources/outline-discuss-card.tsx

@@ -0,0 +1,321 @@
+import { useRef, useState, type Dispatch, type MutableRefObject, type SetStateAction } from "react"
+
+import {
+  OUTLINE_DISCUSS_CUSTOM_OPTION_ID,
+  type OutlineDiscussAnswer,
+  type OutlineDiscussDecision,
+  type OutlineDiscussProtocol,
+} from "@/lib/novel/outline-discuss-protocol"
+
+interface OutlineDiscussCardProps {
+  protocol: OutlineDiscussProtocol
+  onSubmitAnswers: (answers: OutlineDiscussAnswer[]) => Promise<boolean>
+  onConfirm: () => Promise<boolean>
+  onContinue: () => void
+  disabled?: boolean
+  disabledReason?: string
+}
+
+interface DecisionSelection {
+  optionIds: string[]
+  custom: string
+}
+
+function isCustomOption(optionId: string): boolean {
+  return optionId.toUpperCase() === OUTLINE_DISCUSS_CUSTOM_OPTION_ID
+}
+
+function toggleSelection(selection: DecisionSelection, optionId: string): DecisionSelection {
+  return {
+    optionIds: selection.optionIds.includes(optionId) ? [] : [optionId],
+    custom: selection.custom,
+  }
+}
+
+function buildAnswerValue(
+  decision: OutlineDiscussDecision,
+  selection: DecisionSelection,
+): string {
+  const labels = selection.optionIds
+    .filter((optionId) => !isCustomOption(optionId))
+    .map((optionId) => decision.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 OutlineDiscussCard({
+  protocol,
+  onSubmitAnswers,
+  onConfirm,
+  onContinue,
+  disabled = false,
+  disabledReason,
+}: OutlineDiscussCardProps) {
+  const [selections, setSelections] = useState<Record<string, DecisionSelection>>({})
+  const [submitting, setSubmitting] = useState(false)
+  const submittingRef = useRef(false)
+
+  if (protocol.status === "needs_decision") {
+    return (
+      <OutlineDiscussDecisionCard
+        protocol={protocol}
+        selections={selections}
+        setSelections={setSelections}
+        submitting={submitting}
+        submittingRef={submittingRef}
+        setSubmitting={setSubmitting}
+        onSubmitAnswers={onSubmitAnswers}
+        disabled={disabled}
+        disabledReason={disabledReason}
+      />
+    )
+  }
+
+  if (protocol.status === "ready") {
+    return (
+      <OutlineDiscussFinalizeCard
+        protocol={protocol}
+        submitting={submitting}
+        submittingRef={submittingRef}
+        setSubmitting={setSubmitting}
+        onConfirm={onConfirm}
+        onContinue={onContinue}
+        disabled={disabled}
+        disabledReason={disabledReason}
+      />
+    )
+  }
+
+  return null
+}
+
+function OutlineDiscussDecisionCard({
+  protocol,
+  selections,
+  setSelections,
+  submitting,
+  submittingRef,
+  setSubmitting,
+  onSubmitAnswers,
+  disabled,
+  disabledReason,
+}: {
+  protocol: OutlineDiscussProtocol
+  selections: Record<string, DecisionSelection>
+  setSelections: Dispatch<SetStateAction<Record<string, DecisionSelection>>>
+  submitting: boolean
+  submittingRef: MutableRefObject<boolean>
+  setSubmitting: (value: boolean) => void
+  onSubmitAnswers: (answers: OutlineDiscussAnswer[]) => Promise<boolean>
+  disabled: boolean
+  disabledReason?: string
+}) {
+  if (protocol.decisions.length === 0) return null
+
+  const getSelection = (decisionId: string): DecisionSelection =>
+    selections[decisionId] ?? { optionIds: [], custom: "" }
+
+  const answers = protocol.decisions.map((decision) => ({
+    id: decision.id,
+    question: decision.question,
+    value: buildAnswerValue(decision, getSelection(decision.id)),
+  }))
+  const answered = answers.every((answer) => answer.value.trim())
+  const interactionDisabled = disabled || submitting
+
+  const handleToggle = (decision: OutlineDiscussDecision, optionId: string) => {
+    if (interactionDisabled) return
+    setSelections((current) => ({
+      ...current,
+      [decision.id]: toggleSelection(
+        current[decision.id] ?? { optionIds: [], custom: "" },
+        optionId,
+      ),
+    }))
+  }
+
+  const handleCustomChange = (decisionId: string, value: string) => {
+    setSelections((current) => ({
+      ...current,
+      [decisionId]: { ...(current[decisionId] ?? { 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-sky-500/30 bg-sky-50/30 p-3 dark:bg-sky-950/10">
+      <div className="mb-2 text-sm font-medium">需要你拍板「{protocol.module}」的分歧</div>
+      {protocol.judgment ? (
+        <div className="mb-3 text-xs text-muted-foreground">{protocol.judgment}</div>
+      ) : null}
+      <div className="space-y-4">
+        {protocol.decisions.map((decision) => {
+          const selection = getSelection(decision.id)
+          const customSelected = selection.optionIds.some(isCustomOption)
+          const preferred = decision.options.find((option) => option.id === decision.preferenceId)
+          return (
+            <div key={decision.id} className="space-y-2">
+              <div className="text-sm font-medium">{decision.question}</div>
+              {preferred && decision.preferenceReason ? (
+                <div className="text-xs text-muted-foreground">
+                  倾向「{preferred.label}」:{decision.preferenceReason}
+                </div>
+              ) : null}
+              <div className="space-y-2">
+                {decision.options.map((option) => {
+                  const selected = selection.optionIds.includes(option.id)
+                  const isPreference = option.id === decision.preferenceId
+                  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-sky-500 bg-sky-100/60 dark:bg-sky-900/30" : ""
+                      }`}
+                      onClick={() => handleToggle(decision, option.id)}
+                      disabled={interactionDisabled}
+                      title={disabled ? disabledReason : undefined}
+                    >
+                      <div className="flex items-center gap-2 font-medium">
+                        <span>{option.label}</span>
+                        {isPreference ? (
+                          <span className="rounded border border-sky-500/40 px-1 text-[10px] font-normal text-sky-700 dark:text-sky-300">
+                            AI 倾向
+                          </span>
+                        ) : null}
+                      </div>
+                      {option.description ? (
+                        <div className="text-xs text-muted-foreground">{option.description}</div>
+                      ) : null}
+                    </button>
+                  )
+                })}
+              </div>
+              {customSelected ? (
+                <textarea
+                  value={selection.custom}
+                  onChange={(event) => handleCustomChange(decision.id, event.target.value)}
+                  disabled={interactionDisabled}
+                  aria-label={`${decision.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-sky-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-sky-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>
+  )
+}
+
+function OutlineDiscussFinalizeCard({
+  protocol,
+  submitting,
+  submittingRef,
+  setSubmitting,
+  onConfirm,
+  onContinue,
+  disabled,
+  disabledReason,
+}: {
+  protocol: OutlineDiscussProtocol
+  submitting: boolean
+  submittingRef: MutableRefObject<boolean>
+  setSubmitting: (value: boolean) => void
+  onConfirm: () => Promise<boolean>
+  onContinue: () => void
+  disabled: boolean
+  disabledReason?: string
+}) {
+  const interactionDisabled = disabled || submitting
+
+  const handleConfirm = async () => {
+    if (interactionDisabled || submittingRef.current) return
+    submittingRef.current = true
+    setSubmitting(true)
+    try {
+      await onConfirm()
+    } finally {
+      submittingRef.current = false
+      setSubmitting(false)
+    }
+  }
+
+  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.judgment ? (
+        <div className="mb-3 text-sm">{protocol.judgment}</div>
+      ) : null}
+      {protocol.agreed.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.agreed.map((item) => (
+              <li key={item.id || item.question}>
+                <span className="font-medium">{item.question}</span>:{item.value}
+              </li>
+            ))}
+          </ul>
+        </div>
+      ) : null}
+      {protocol.nextStep ? (
+        <div className="mb-3 text-xs text-muted-foreground">{protocol.nextStep}</div>
+      ) : null}
+      <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}
+        >
+          {submitting ? "生成中..." : "定稿,开始生成"}
+        </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={onContinue}
+          disabled={interactionDisabled}
+          title={disabled ? disabledReason : undefined}
+        >
+          继续讨论
+        </button>
+      </div>
+    </div>
+  )
+}

+ 32 - 0
src/lib/novel/outline-context-reuse.spec.ts

@@ -275,6 +275,10 @@ describe("AI 大纲上下文复用策略", () => {
       workflowMode: "plan",
       planPhase: "element_check",
     })).toBe(true)
+    expect(shouldShowOutlineWorkflowProcess({
+      workflowMode: "discuss",
+      discussPhase: "decision",
+    })).toBe(true)
     expect(shouldShowOutlineWorkflowProcess({
       workflowMode: "plan",
       intentPhase: "generation",
@@ -354,4 +358,32 @@ describe("AI 大纲上下文复用策略", () => {
     expect(plan.showToolProcess).toBe(true)
     expect(plan.showThinkingProcess).toBe(true)
   })
+
+  it("共创讨论轮强制刷新上下文并说明原因", () => {
+    const decision = planOutlineContextReuse({
+      hasPriorAssistantAnswer: true,
+      attachedReferenceCount: 0,
+      inputText: "本轮进入共创讨论",
+      enableMultiAgent: false,
+      systemGenerated: true,
+      workflowMode: "discuss",
+      discussPhase: "decision",
+    })
+
+    expect(decision.mode).toBe("refresh")
+    expect(decision.reason).toBe("共创讨论需要读取项目已有大纲再抛决策点。")
+
+    const plan = planOutlineAgentHistory({
+      history: [
+        { role: "user", content: "讨论章纲" },
+        { role: "assistant", content: "先对齐钩子" },
+      ],
+      contextDecision: decision,
+      workflowMode: "discuss",
+      discussPhase: "decision",
+    })
+
+    expect(plan.showToolProcess).toBe(true)
+    expect(plan.showThinkingProcess).toBe(true)
+  })
 })

+ 6 - 0
src/lib/novel/outline-context-reuse.ts

@@ -2,6 +2,7 @@ import {
   resolveOutlineWorkflowMode,
   type OutlineWorkflowMode,
 } from "@/lib/agent/workflow-mode"
+import type { OutlineDiscussPhase } from "./outline-discuss-protocol"
 import type { OutlinePlanPhase } from "./outline-plan-protocol"
 
 export const OUTLINE_CONTEXT_REUSE_DISABLED_TOOLS = [
@@ -40,6 +41,7 @@ interface OutlineContextReuseInput {
   workflowMode?: OutlineWorkflowMode | null
   intentPhase?: OutlineIntentPhase
   planPhase?: OutlinePlanPhase
+  discussPhase?: OutlineDiscussPhase
 }
 
 interface OutlineContextReuseDecision {
@@ -58,6 +60,7 @@ interface OutlineAgentHistoryInput {
   workflowMode?: OutlineWorkflowMode | null
   intentPhase?: OutlineIntentPhase
   planPhase?: OutlinePlanPhase
+  discussPhase?: OutlineDiscussPhase
   enableMultiAgent?: boolean
 }
 
@@ -65,12 +68,14 @@ export function shouldShowOutlineWorkflowProcess(input: {
   workflowMode?: OutlineWorkflowMode | null
   intentPhase?: OutlineIntentPhase
   planPhase?: OutlinePlanPhase
+  discussPhase?: OutlineDiscussPhase
   enableMultiAgent?: boolean
 }): boolean {
   if (resolveOutlineWorkflowMode(input.workflowMode) === "fast") return false
   return input.intentPhase === "intent_analysis"
     || input.intentPhase === "generation"
     || input.planPhase !== undefined
+    || input.discussPhase !== undefined
     || input.enableMultiAgent === true
 }
 
@@ -228,6 +233,7 @@ function refreshReason(input: OutlineContextReuseInput): string {
   if (input.forceRefresh) return "用户手动要求强制刷新上下文。"
   if (input.enableMultiAgent) return "固定生成向导或多 Agent 任务需要完整上下文。"
   if (input.planPhase) return "计划模式要素盘点需要读取项目已有大纲。"
+  if (input.discussPhase) return "共创讨论需要读取项目已有大纲再抛决策点。"
   if (shouldShowOutlineWorkflowProcess(input)) return "标准大纲工作流需要完整上下文。"
   if (input.attachedReferenceCount > 0) return "本轮带有新的引用资料。"
   if (REFRESH_KEYWORD_PATTERN.test(input.inputText.trim())) {

+ 367 - 0
src/lib/novel/outline-discuss-protocol.spec.ts

@@ -0,0 +1,367 @@
+import { describe, expect, it } from "vitest"
+import {
+  OUTLINE_DISCUSS_CUSTOM_OPTION_ID,
+  OUTLINE_DISCUSS_MARKER_CLOSE,
+  OUTLINE_DISCUSS_MARKER_OPEN,
+  buildOutlineDiscussAnswerPrompt,
+  buildOutlineDiscussExecutionPrompt,
+  buildOutlineDiscussPhaseSystemRules,
+  findLatestOutlineDiscussProtocol,
+  isOutlineDiscussFinalizeRequest,
+  isOutlineDiscussProtocol,
+  parseOutlineDiscussProtocol,
+  stripOutlineDiscussMarkers,
+  validateOutlineDiscussProtocol,
+} from "./outline-discuss-protocol"
+
+function wrap(payload: unknown): string {
+  return `${OUTLINE_DISCUSS_MARKER_OPEN}\n${JSON.stringify(payload)}\n${OUTLINE_DISCUSS_MARKER_CLOSE}`
+}
+
+function twoOptions() {
+  return [
+    { id: "A", label: "明线复仇", description: "更狠" },
+    { id: "B", label: "暗线布局", description: "更稳" },
+  ]
+}
+
+describe("parseOutlineDiscussProtocol", () => {
+  it("returns none when the marker is absent", () => {
+    expect(parseOutlineDiscussProtocol("普通回复内容").kind).toBe("none")
+  })
+
+  it("parses a well formed protocol block", () => {
+    const outcome = parseOutlineDiscussProtocol(wrap({
+      status: "ready",
+      module: "章节细纲",
+      judgment: "第45章冲突已经够用",
+      nextStep: "确认后开写",
+      decisions: [],
+      agreed: [{ id: "a1", question: "开场钩子", value: "仇人登门" }],
+    }))
+
+    expect(outcome.kind).toBe("valid")
+    if (outcome.kind !== "valid") return
+    expect(outcome.protocol.status).toBe("ready")
+    expect(outcome.protocol.module).toBe("章节细纲")
+    expect(outcome.protocol.agreed[0].value).toBe("仇人登门")
+  })
+
+  it("recovers a truncated block that lost its closing marker", () => {
+    const outcome = parseOutlineDiscussProtocol(
+      `${OUTLINE_DISCUSS_MARKER_OPEN}\n${JSON.stringify({
+        status: "needs_decision",
+        module: "卷纲",
+        judgment: "第二卷节奏偏慢",
+      })}`,
+    )
+
+    expect(outcome.kind).toBe("valid")
+    if (outcome.kind !== "valid") return
+    expect(outcome.protocol.status).toBe("needs_decision")
+  })
+
+  it("rejects an unclosed block that still has trailing content", () => {
+    expect(parseOutlineDiscussProtocol(
+      `${OUTLINE_DISCUSS_MARKER_OPEN}\n{"status":"ready"}\n后面还有别的正文`,
+    )).toEqual({
+      kind: "invalid",
+      error: "共创协议缺少闭合标记且 JSON 后仍有额外内容",
+    })
+  })
+
+  it("rejects unparsable json", () => {
+    expect(parseOutlineDiscussProtocol(
+      `${OUTLINE_DISCUSS_MARKER_OPEN}\n{status: ready}\n${OUTLINE_DISCUSS_MARKER_CLOSE}`,
+    ).kind).toBe("invalid")
+  })
+
+  it("rejects a missing or unknown status", () => {
+    expect(parseOutlineDiscussProtocol(wrap({ module: "卷纲" })).kind).toBe("invalid")
+    expect(parseOutlineDiscussProtocol(wrap({ status: "needs_input" })).kind).toBe("invalid")
+  })
+
+  it("appends a custom input option to every decision", () => {
+    const outcome = parseOutlineDiscussProtocol(wrap({
+      status: "needs_decision",
+      module: "章节细纲",
+      decisions: [{
+        id: "d1",
+        question: "这章用什么钩子?",
+        options: twoOptions(),
+        preferenceId: "A",
+        preferenceReason: "更有冲突",
+      }],
+    }))
+
+    expect(outcome.kind).toBe("valid")
+    if (outcome.kind !== "valid") return
+    const options = outcome.protocol.decisions[0].options
+    expect(options).toHaveLength(3)
+    expect(options[2].id).toBe(OUTLINE_DISCUSS_CUSTOM_OPTION_ID)
+    expect(options[2].label).toBe("其它(我来补充描述)")
+  })
+
+  it("keeps a model supplied custom option at the end without duplicating it", () => {
+    const outcome = parseOutlineDiscussProtocol(wrap({
+      status: "needs_decision",
+      module: "章节细纲",
+      decisions: [{
+        id: "d1",
+        question: "这章用什么钩子?",
+        options: [
+          { id: "CUSTOM", label: "自己写" },
+          ...twoOptions(),
+        ],
+        preferenceId: "A",
+        preferenceReason: "更有冲突",
+      }],
+    }))
+
+    expect(outcome.kind).toBe("valid")
+    if (outcome.kind !== "valid") return
+    const options = outcome.protocol.decisions[0].options
+    expect(options).toHaveLength(3)
+    expect(options.filter((option) => option.id === OUTLINE_DISCUSS_CUSTOM_OPTION_ID)).toHaveLength(1)
+    expect(options[2].label).toBe("自己写")
+  })
+})
+
+describe("validateOutlineDiscussProtocol", () => {
+  it("rejects needs_decision without any decision", () => {
+    expect(validateOutlineDiscussProtocol({
+      status: "needs_decision",
+      module: "章节细纲",
+      judgment: "还缺拍板",
+      nextStep: "先选钩子",
+      decisions: [],
+      agreed: [],
+    }).kind).toBe("invalid")
+  })
+
+  it("rejects a decision with fewer than two real options", () => {
+    const result = validateOutlineDiscussProtocol({
+      status: "needs_decision",
+      module: "章节细纲",
+      judgment: "还缺拍板",
+      nextStep: "先选钩子",
+      decisions: [{
+        id: "d1",
+        question: "这章用什么钩子?",
+        options: [
+          { id: "A", label: "仇人登门", description: "" },
+          { id: OUTLINE_DISCUSS_CUSTOM_OPTION_ID, label: "其它(我来补充描述)", description: "" },
+        ],
+        preferenceId: "A",
+        preferenceReason: "更狠",
+      }],
+      agreed: [],
+    })
+
+    expect(result.kind).toBe("invalid")
+    if (result.kind !== "invalid") return
+    expect(result.error).toContain("可选项少于 2")
+  })
+
+  it("rejects a decision without a valid preference", () => {
+    const result = validateOutlineDiscussProtocol({
+      status: "needs_decision",
+      module: "章节细纲",
+      judgment: "还缺拍板",
+      nextStep: "先选钩子",
+      decisions: [{
+        id: "d1",
+        question: "这章用什么钩子?",
+        options: [
+          ...twoOptions(),
+          { id: OUTLINE_DISCUSS_CUSTOM_OPTION_ID, label: "其它(我来补充描述)", description: "" },
+        ],
+        preferenceId: "CUSTOM",
+        preferenceReason: "让作者自己想",
+      }],
+      agreed: [],
+    })
+
+    expect(result.kind).toBe("invalid")
+    if (result.kind !== "invalid") return
+    expect(result.error).toContain("没有标出有效的 AI 倾向")
+  })
+
+  it("rejects ready without judgment", () => {
+    expect(validateOutlineDiscussProtocol({
+      status: "ready",
+      module: "章节细纲",
+      judgment: "  ",
+      nextStep: "可以开写",
+      decisions: [],
+      agreed: [],
+    }).kind).toBe("invalid")
+  })
+
+  it("accepts a valid needs_decision protocol", () => {
+    const result = validateOutlineDiscussProtocol({
+      status: "needs_decision",
+      module: "章节细纲",
+      judgment: "第45章缺一个开场选择",
+      nextStep: "先定钩子",
+      decisions: [{
+        id: "d1",
+        question: "这章用什么钩子?",
+        options: [
+          ...twoOptions(),
+          { id: OUTLINE_DISCUSS_CUSTOM_OPTION_ID, label: "其它(我来补充描述)", description: "" },
+        ],
+        preferenceId: "A",
+        preferenceReason: "冲突来得更快",
+      }],
+      agreed: [],
+    })
+
+    expect(result.kind).toBe("needs_decision")
+  })
+
+  it("accepts ready with judgment and no new decisions", () => {
+    const result = validateOutlineDiscussProtocol({
+      status: "ready",
+      module: "章节细纲",
+      judgment: "冲突和人物动机已经对齐",
+      nextStep: "确认后开写",
+      decisions: [],
+      agreed: [{ id: "a1", question: "开场钩子", value: "仇人登门" }],
+    })
+
+    expect(result.kind).toBe("ready")
+  })
+})
+
+describe("stripOutlineDiscussMarkers", () => {
+  it("strips complete, truncated and leftover markers", () => {
+    expect(stripOutlineDiscussMarkers(`前文${wrap({ status: "ready" })}后文`)).toBe("前文后文")
+    expect(stripOutlineDiscussMarkers(`前文${OUTLINE_DISCUSS_MARKER_OPEN}\n{"status":"rea`)).toBe("前文")
+    expect(stripOutlineDiscussMarkers(`前文${OUTLINE_DISCUSS_MARKER_CLOSE}后文`)).toBe("前文后文")
+  })
+})
+
+describe("isOutlineDiscussProtocol", () => {
+  it("keeps a structurally complete protocol", () => {
+    expect(isOutlineDiscussProtocol({
+      status: "needs_decision",
+      module: "章节细纲",
+      judgment: "判断",
+      nextStep: "下一步",
+      decisions: [{
+        id: "d1",
+        question: "钩子?",
+        options: [{ id: "A", label: "仇人登门" }],
+        preferenceId: "A",
+        preferenceReason: "狠",
+      }],
+      agreed: [{ id: "a1", question: "视角", value: "主角" }],
+    })).toBe(true)
+  })
+
+  it("rejects incomplete persisted payloads", () => {
+    expect(isOutlineDiscussProtocol(null)).toBe(false)
+    expect(isOutlineDiscussProtocol({
+      status: "needs_input",
+      module: "章节细纲",
+      judgment: "",
+      nextStep: "",
+      decisions: [],
+      agreed: [],
+    })).toBe(false)
+    expect(isOutlineDiscussProtocol({
+      status: "ready",
+      module: "",
+      judgment: "判断",
+      nextStep: "",
+      decisions: [],
+      agreed: [],
+    })).toBe(false)
+    expect(isOutlineDiscussProtocol({
+      status: "ready",
+      module: "章节细纲",
+      judgment: "判断",
+      nextStep: "",
+      decisions: [{ question: 1 }],
+      agreed: [],
+    })).toBe(false)
+  })
+})
+
+describe("discuss prompt builders", () => {
+  it("requires the discuss protocol and forbids plan or intent gates", () => {
+    const rules = buildOutlineDiscussPhaseSystemRules("章节细纲")
+    expect(rules).toContain("## 本轮阶段:共创讨论")
+    expect(rules).toContain("outline_discuss")
+    expect(rules).toContain("preferenceId")
+    expect(rules).not.toContain("intent_clarity -->")
+    expect(rules).not.toContain("<!-- outline_plan -->")
+  })
+
+  it("feeds selected answers back as agreed items", () => {
+    const prompt = buildOutlineDiscussAnswerPrompt({
+      module: "章节细纲",
+      answers: [{ id: "d1", question: "开场钩子", value: "仇人登门" }],
+      agreed: [{ id: "a1", question: "视角", value: "主角" }],
+    })
+    expect(prompt).toContain("本次拍板")
+    expect(prompt).toContain("开场钩子:仇人登门")
+    expect(prompt).toContain("之前已拍板")
+    expect(prompt).toContain("视角:主角")
+  })
+
+  it("turns finalized discussion into a generation prompt", () => {
+    const prompt = buildOutlineDiscussExecutionPrompt({
+      module: "章节细纲",
+      judgment: "冲突已经够用",
+      agreed: [{ id: "a1", question: "开场钩子", value: "仇人登门" }],
+    })
+    expect(prompt).toContain("已经定稿")
+    expect(prompt).toContain("禁止再输出")
+    expect(prompt).toContain("outlineSaveRequest")
+    expect(prompt).toContain("开场钩子:仇人登门")
+  })
+})
+
+describe("finalize request detection", () => {
+  it("treats short confirmation phrases as finalize", () => {
+    expect(isOutlineDiscussFinalizeRequest("定稿")).toBe(true)
+    expect(isOutlineDiscussFinalizeRequest("就按这个写")).toBe(true)
+    expect(isOutlineDiscussFinalizeRequest("可以了")).toBe(true)
+    expect(isOutlineDiscussFinalizeRequest("开始生成")).toBe(true)
+  })
+
+  it("does not treat ordinary discussion as finalize", () => {
+    expect(isOutlineDiscussFinalizeRequest("主角动机还是站不住,可以再讨论一下")).toBe(false)
+    expect(isOutlineDiscussFinalizeRequest("")).toBe(false)
+  })
+})
+
+describe("findLatestOutlineDiscussProtocol", () => {
+  it("returns the last attached discuss protocol", () => {
+    expect(findLatestOutlineDiscussProtocol([
+      { outlineDiscussProtocol: null },
+      {
+        outlineDiscussProtocol: {
+          status: "needs_decision",
+          module: "章节细纲",
+          judgment: "先定钩子",
+          nextStep: "拍板",
+          decisions: [],
+          agreed: [],
+        },
+      },
+      {
+        outlineDiscussProtocol: {
+          status: "ready",
+          module: "章节细纲",
+          judgment: "可以开写",
+          nextStep: "定稿",
+          decisions: [],
+          agreed: [],
+        },
+      },
+    ])?.status).toBe("ready")
+  })
+})

+ 373 - 0
src/lib/novel/outline-discuss-protocol.ts

@@ -0,0 +1,373 @@
+export const OUTLINE_DISCUSS_MARKER_OPEN = "<!-- outline_discuss -->"
+export const OUTLINE_DISCUSS_MARKER_CLOSE = "<!-- /outline_discuss -->"
+/** 自定义输入项固定 id,卡片据此渲染手动输入框。 */
+export const OUTLINE_DISCUSS_CUSTOM_OPTION_ID = "CUSTOM"
+export const OUTLINE_DISCUSS_CUSTOM_OPTION_LABEL = "其它(我来补充描述)"
+/** 每个分歧必须提供的真实选项数量下限。创意分叉允许二元对立。 */
+export const OUTLINE_DISCUSS_MIN_OPTIONS = 2
+/** 单轮决策点上限,避免一次铺满整张卡片。 */
+export const OUTLINE_DISCUSS_MAX_DECISIONS = 3
+
+export type OutlineDiscussStatus = "needs_decision" | "ready"
+/** 共创模式协议轮次;定稿后的执行腿复用既有 generation 阶段。 */
+export type OutlineDiscussPhase = "decision"
+/** 卡片已被使用过的标记,重载后据此置灰,防重复提交。 */
+export type OutlineDiscussDecisionState = "confirmed" | "cancelled" | "answered"
+
+export interface OutlineDiscussOption {
+  id: string
+  label: string
+  description: string
+}
+
+export interface OutlineDiscussDecision {
+  id: string
+  question: string
+  options: OutlineDiscussOption[]
+  preferenceId: string
+  preferenceReason: string
+}
+
+export interface OutlineDiscussAgreed {
+  id: string
+  question: string
+  value: string
+}
+
+export interface OutlineDiscussProtocol {
+  status: OutlineDiscussStatus
+  module: string
+  judgment: string
+  nextStep: string
+  decisions: OutlineDiscussDecision[]
+  agreed: OutlineDiscussAgreed[]
+}
+
+export type OutlineDiscussParseOutcome =
+  | { kind: "none" }
+  | { kind: "valid"; protocol: OutlineDiscussProtocol }
+  | { kind: "invalid"; error: string }
+
+export type OutlineDiscussValidation =
+  | { kind: "invalid"; error: string }
+  | { kind: "ready"; protocol: OutlineDiscussProtocol }
+  | { kind: "needs_decision"; protocol: OutlineDiscussProtocol }
+
+export interface OutlineDiscussAnswer {
+  id: string
+  question: string
+  value: string
+}
+
+const OPEN_PATTERN = /<!--\s*outline_discuss\s*-->/i
+const CLOSE_PATTERN = /<!--\s*\/outline_discuss\s*-->/i
+const FINALIZE_PATTERN = /定稿|就按这个写|按这个写|开始生成|可以了|开始写/
+
+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 isPlainObject(value: unknown): value is Record<string, unknown> {
+  return Boolean(value) && typeof value === "object" && !Array.isArray(value)
+}
+
+function normalizeOptions(raw: unknown): OutlineDiscussOption[] {
+  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_DISCUSS_CUSTOM_OPTION_ID,
+  )
+  const custom = options.find(
+    (option) => option.id.toUpperCase() === OUTLINE_DISCUSS_CUSTOM_OPTION_ID,
+  )
+  return [
+    ...withoutCustom,
+    {
+      id: OUTLINE_DISCUSS_CUSTOM_OPTION_ID,
+      label: custom?.label || OUTLINE_DISCUSS_CUSTOM_OPTION_LABEL,
+      description: custom?.description ?? "",
+    },
+  ]
+}
+
+function normalizeDecisions(raw: unknown): OutlineDiscussDecision[] {
+  if (!Array.isArray(raw)) return []
+  return raw
+    .filter(isPlainObject)
+    .map((item, index) => ({
+      id: String(item.id ?? "").trim() || `d${index + 1}`,
+      question: String(item.question ?? "").trim(),
+      options: normalizeOptions(item.options),
+      preferenceId: String(item.preferenceId ?? "").trim(),
+      preferenceReason: String(item.preferenceReason ?? "").trim(),
+    }))
+    .filter((decision) => decision.question)
+}
+
+function normalizeAgreed(raw: unknown): OutlineDiscussAgreed[] {
+  if (!Array.isArray(raw)) return []
+  return raw
+    .filter(isPlainObject)
+    .map((item, index) => ({
+      id: String(item.id ?? "").trim() || `a${index + 1}`,
+      question: String(item.question ?? "").trim(),
+      value: String(item.value ?? "").trim(),
+    }))
+    .filter((item) => item.question && item.value)
+}
+
+/**
+ * 解析 outline_discuss 协议块。
+ *
+ * 与 outline_plan 对称:支持未闭合标记兜底(流式截断),
+ * 并在解析阶段就把每个分歧补上自定义输入项。
+ */
+export function parseOutlineDiscussProtocol(text: string): OutlineDiscussParseOutcome {
+  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_decision" && status !== "ready") {
+    return { kind: "invalid", error: "共创协议缺少有效的 status 字段" }
+  }
+
+  return {
+    kind: "valid",
+    protocol: {
+      status,
+      module: String(payload.module ?? "").trim() || "大纲",
+      judgment: String(payload.judgment ?? "").trim(),
+      nextStep: String(payload.nextStep ?? "").trim(),
+      decisions: normalizeDecisions(payload.decisions),
+      agreed: normalizeAgreed(payload.agreed),
+    },
+  }
+}
+
+function countRealOptions(decision: OutlineDiscussDecision): number {
+  return decision.options.filter(
+    (option) => option.id.toUpperCase() !== OUTLINE_DISCUSS_CUSTOM_OPTION_ID,
+  ).length
+}
+
+function isRealOptionId(decision: OutlineDiscussDecision, optionId: string): boolean {
+  const normalized = optionId.trim().toUpperCase()
+  if (!normalized || normalized === OUTLINE_DISCUSS_CUSTOM_OPTION_ID) return false
+  return decision.options.some((option) => option.id.toUpperCase() === normalized)
+}
+
+/**
+ * 共创协议的代码闸门。
+ *
+ * 1. needs_decision 时必须有 1-3 个分歧,每问真实选项不少于 2 个,倾向必须落在真实选项上。
+ * 2. ready 时必须有判断摘要;允许不再抛新分歧。
+ */
+export function validateOutlineDiscussProtocol(
+  protocol: OutlineDiscussProtocol,
+): OutlineDiscussValidation {
+  if (protocol.status === "needs_decision") {
+    if (protocol.decisions.length === 0) {
+      return { kind: "invalid", error: "共创协议标记为需要拍板,但没有给出任何分歧点" }
+    }
+    if (protocol.decisions.length > OUTLINE_DISCUSS_MAX_DECISIONS) {
+      return {
+        kind: "invalid",
+        error: `共创协议一次最多抛出 ${OUTLINE_DISCUSS_MAX_DECISIONS} 个分歧点`,
+      }
+    }
+    const thin = protocol.decisions.find(
+      (decision) => countRealOptions(decision) < OUTLINE_DISCUSS_MIN_OPTIONS,
+    )
+    if (thin) {
+      return {
+        kind: "invalid",
+        error: `分歧「${thin.question}」的可选项少于 ${OUTLINE_DISCUSS_MIN_OPTIONS} 个,无法进入拍板`,
+      }
+    }
+    const missingPreference = protocol.decisions.find(
+      (decision) => !isRealOptionId(decision, decision.preferenceId),
+    )
+    if (missingPreference) {
+      return {
+        kind: "invalid",
+        error: `分歧「${missingPreference.question}」没有标出有效的 AI 倾向`,
+      }
+    }
+    return { kind: "needs_decision", protocol }
+  }
+
+  if (!protocol.judgment.trim()) {
+    return { kind: "invalid", error: "共创协议标记为可以定稿,但缺少关键判断" }
+  }
+  return { kind: "ready", protocol }
+}
+
+/** 共创讨论轮的系统规则;替代计划模式的要素盘点段。 */
+export function buildOutlineDiscussPhaseSystemRules(module: string): string {
+  return [
+    "## 本轮阶段:共创讨论",
+    `本轮目标模块:${module || "大纲"}。本轮禁止生成完整大纲正文,禁止调用保存工具,禁止输出 intent_clarity 和 outline_plan。`,
+    "1. 先调用 list_outlines、list_chapters、list_memories 确认可用资料,再用 read_outline、read_chapter 读取相关正文。",
+    "2. 先用自然语言写出关键判断和下一步建议,再输出一个 outline_discuss 协议块。",
+    "3. 还有需要作者拍板的创意分歧时,status 必须是 needs_decision,decisions 只放 1-3 个具体分歧。",
+    `4. 每个分歧必须给出至少 ${OUTLINE_DISCUSS_MIN_OPTIONS} 个具体可选方案,并填写 preferenceId 和 preferenceReason;系统会自动追加自定义输入项。`,
+    "5. 资料已经足够、没有新的拍板点时,status 才能是 ready,judgment 必须说明为什么可以开写。",
+    "6. 已经拍板的内容写入 agreed,不要把同一分歧再问一遍。",
+    "## 输出格式(必须严格遵守)",
+    OUTLINE_DISCUSS_MARKER_OPEN,
+    '{"status":"needs_decision|ready","module":"模块名","judgment":"关键判断","nextStep":"下一步建议","decisions":[{"id":"d1","question":"分歧点","options":[{"id":"A","label":"方案","description":"说明"}],"preferenceId":"A","preferenceReason":"倾向理由"}],"agreed":[{"id":"a1","question":"已拍板问题","value":"已确认方案"}]}',
+    OUTLINE_DISCUSS_MARKER_CLOSE,
+    "开闭标记必须成对出现,JSON 必须完整可解析。正文里只留判断和下一步,不要输出完整大纲,也不要把协议 JSON 再抄进正文。",
+  ].join("\n")
+}
+
+function formatAgreed(agreed: OutlineDiscussAgreed[]): string[] {
+  return agreed
+    .filter((item) => item.question && item.value)
+    .map((item) => `- ${item.question}:${item.value}`)
+}
+
+/** 用户点选分歧后的内部 user prompt;已拍板项随对话历史传递。 */
+export function buildOutlineDiscussAnswerPrompt(input: {
+  module: string
+  answers: OutlineDiscussAnswer[]
+  agreed: OutlineDiscussAgreed[]
+}): string {
+  const previous = formatAgreed(input.agreed)
+  return [
+    `我已对「${input.module}」的分歧拍板,请继续共创讨论。`,
+    "",
+    "## 本次拍板",
+    ...input.answers.map((answer) => `- ${answer.question}:${answer.value}`),
+    ...(previous.length ? ["", "## 之前已拍板", ...previous] : []),
+    "",
+    "请把本次拍板并入 agreed,重新判断是否还有需要我拍板的创意分歧。",
+    "还有分歧就继续按 outline_discuss 协议抛决策点;没有新分歧就输出 status 为 ready,说明为什么可以定稿。",
+  ].join("\n")
+}
+
+/** 用户确认定稿后的执行 prompt;执行腿复用既有 generation 阶段。 */
+export function buildOutlineDiscussExecutionPrompt(input: {
+  module: string
+  judgment: string
+  agreed: OutlineDiscussAgreed[]
+}): string {
+  const collected = formatAgreed(input.agreed)
+  return [
+    `共创讨论已经定稿,现在进入「${input.module}」的正文生成阶段。`,
+    "请严格按下面已经拍板的判断和方案生成可保存的大纲正文,不要再抛分歧,不要再次等待确认。",
+    `禁止再输出 ${OUTLINE_DISCUSS_MARKER_OPEN} 协议块。生成完成后按 AI 大纲输出协议在末尾附加 outlineSaveRequest 或 outlineSaveRequests JSON 块。`,
+    ...(input.judgment.trim() ? ["", "## 已确认判断", input.judgment.trim()] : []),
+    ...(collected.length ? ["", "## 已拍板方案", ...collected] : []),
+  ].join("\n")
+}
+
+/** 短确认句视为定稿,走生成腿而不是再开一轮讨论。 */
+export function isOutlineDiscussFinalizeRequest(text: string): boolean {
+  const trimmed = text.trim()
+  if (!trimmed || trimmed.length > 40) return false
+  return FINALIZE_PATTERN.test(trimmed)
+}
+
+export function findLatestOutlineDiscussProtocol(
+  messages: Array<{ outlineDiscussProtocol?: OutlineDiscussProtocol | null }>,
+): OutlineDiscussProtocol | null {
+  for (let index = messages.length - 1; index >= 0; index -= 1) {
+    const protocol = messages[index]?.outlineDiscussProtocol
+    if (protocol) return protocol
+  }
+  return null
+}
+
+/** 会话落盘后的形状校验,结构不完整的共创数据一律丢弃。 */
+export function isOutlineDiscussProtocol(value: unknown): value is OutlineDiscussProtocol {
+  if (!isPlainObject(value)) return false
+  if (value.status !== "needs_decision" && value.status !== "ready") return false
+  if (typeof value.module !== "string" || !value.module.trim()) return false
+  if (typeof value.judgment !== "string") return false
+  if (typeof value.nextStep !== "string") return false
+  if (!Array.isArray(value.decisions) || !Array.isArray(value.agreed)) return false
+  if (!value.decisions.every((decision) => (
+    isPlainObject(decision)
+    && typeof decision.question === "string"
+    && Array.isArray(decision.options)
+    && decision.options.every((option) => isPlainObject(option) && typeof option.label === "string")
+  ))) {
+    return false
+  }
+  if (!value.agreed.every((item) => (
+    isPlainObject(item)
+    && typeof item.question === "string"
+    && typeof item.value === "string"
+  ))) {
+    return false
+  }
+  return true
+}
+
+/** 剥掉 outline_discuss 协议标记,与 stripStructuredMarkers 对 plan / intent 的处理对称。 */
+export function stripOutlineDiscussMarkers(text: string): string {
+  return text
+    .replace(/<!--\s*outline_discuss\s*-->[\s\S]*?<!--\s*\/outline_discuss\s*-->/gi, "")
+    .replace(/<!--\s*outline_discuss\s*-->[\s\S]*$/gi, "")
+    .replace(/<!--\s*\/outline_discuss\s*-->/gi, "")
+}

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

@@ -132,6 +132,11 @@ describe("stripStructuredMarkers", () => {
     expect(stripStructuredMarkers(text).trim()).toBe("正文")
   })
 
+  it("移除 outline_discuss 标记块", () => {
+    const text = `判断如下\n<!-- outline_discuss -->\n{"status":"ready"}\n<!-- /outline_discuss -->\n\n下一步建议`
+    expect(stripStructuredMarkers(text)).toBe("判断如下\n\n下一步建议")
+  })
+
   it("无标记块时原样返回", () => {
     const text = "纯文本内容"
     expect(stripStructuredMarkers(text)).toBe("纯文本内容")

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

@@ -1,3 +1,4 @@
+import { stripOutlineDiscussMarkers } from "./outline-discuss-protocol"
 import { stripOutlinePlanMarkers } from "./outline-plan-protocol"
 
 export type IntentClarity = "clear" | "needs_input"
@@ -242,7 +243,7 @@ export function buildIntentPhaseSystemRules(
 }
 
 export function stripStructuredMarkers(text: string): string {
-  return stripOutlinePlanMarkers(text)
+  return stripOutlineDiscussMarkers(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, "")

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

@@ -355,4 +355,63 @@ describe("outline-chat-store", () => {
     expect(useOutlineChatStore.getState().conversations[0].messages[0].outlinePlanProtocol).toBeUndefined()
   })
 
+  it("持久化共创模式的拍板与定稿状态", async () => {
+    useWikiStore.setState({ project: { name: "Novel", path: "C:/Book" } })
+    const stored = conversation("discuss")
+    stored.messages = [{
+      id: "assistant",
+      role: "assistant",
+      content: "",
+      outlineDiscussPhase: "decision",
+      outlineDiscussDecision: "answered",
+      outlineDiscussProtocol: {
+        status: "needs_decision",
+        module: "章节细纲",
+        judgment: "第45章还缺开场",
+        nextStep: "先定钩子",
+        decisions: [{
+          id: "d1",
+          question: "这章用什么钩子?",
+          options: [
+            { id: "A", label: "仇人登门", description: "" },
+            { id: "B", label: "旧信重现", description: "" },
+            { id: "CUSTOM", label: "其它(我来补充描述)", description: "" },
+          ],
+          preferenceId: "A",
+          preferenceReason: "冲突更快",
+        }],
+        agreed: [],
+      },
+    }]
+    fsMocks.readFile.mockResolvedValue(JSON.stringify({ conversations: [stored], activeConversationId: "discuss" }))
+
+    await useOutlineChatStore.getState().loadFromDisk()
+
+    const message = useOutlineChatStore.getState().conversations[0].messages[0]
+    expect(message.outlineDiscussPhase).toBe("decision")
+    expect(message.outlineDiscussDecision).toBe("answered")
+    expect(message.outlineDiscussProtocol?.decisions[0].options).toHaveLength(3)
+  })
+
+  it.each([
+    null,
+    { status: "needs_input", module: "章节细纲", judgment: "", nextStep: "", decisions: [], agreed: [] },
+    { status: "ready", module: "", judgment: "判断", nextStep: "", decisions: [], agreed: [] },
+    { status: "needs_decision", module: "章节细纲", judgment: "", nextStep: "", decisions: [{ question: 1 }], agreed: [] },
+  ])("丢弃结构不完整的共创协议 %#", async (invalidProtocol) => {
+    useWikiStore.setState({ project: { name: "Novel", path: "C:/Book" } })
+    const stored = conversation("invalid-discuss")
+    stored.messages = [{
+      id: "assistant",
+      role: "assistant",
+      content: "",
+      outlineDiscussProtocol: invalidProtocol as never,
+    }]
+    fsMocks.readFile.mockResolvedValue(JSON.stringify({ conversations: [stored], activeConversationId: "invalid-discuss" }))
+
+    await useOutlineChatStore.getState().loadFromDisk()
+
+    expect(useOutlineChatStore.getState().conversations[0].messages[0].outlineDiscussProtocol).toBeUndefined()
+  })
+
 })

+ 16 - 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 {
+  isOutlineDiscussProtocol,
+  type OutlineDiscussDecisionState,
+  type OutlineDiscussPhase,
+  type OutlineDiscussProtocol,
+} from "@/lib/novel/outline-discuss-protocol"
 import {
   isOutlinePlanProtocol,
   type OutlinePlanDecision,
@@ -114,6 +120,13 @@ export interface OutlineChatMessage {
   outlinePlanError?: string
   /** 计划模式:卡片已被使用,重载后保持置灰。 */
   outlinePlanDecision?: OutlinePlanDecision
+  /** 共创模式:本轮是决策讨论。 */
+  outlineDiscussPhase?: OutlineDiscussPhase
+  /** 共创模式:决策点或定稿协议结果。 */
+  outlineDiscussProtocol?: OutlineDiscussProtocol | null
+  outlineDiscussError?: string
+  /** 共创模式:卡片已被使用,重载后保持置灰。 */
+  outlineDiscussDecision?: OutlineDiscussDecisionState
   nextStepRecommendation?: NextStepRecommendation | null
   novelGenerationRequest?: NovelGenerationRequestPackage
   contextHubSnapshot?: ContextHubSnapshotRef
@@ -439,6 +452,9 @@ export const useOutlineChatStore = create<OutlineChatState>((set, get) => {
           outlinePlanProtocol: isOutlinePlanProtocol(message.outlinePlanProtocol)
             ? message.outlinePlanProtocol
             : undefined,
+          outlineDiscussProtocol: isOutlineDiscussProtocol(message.outlineDiscussProtocol)
+            ? message.outlineDiscussProtocol
+            : undefined,
           // 验证 resumeablePlan 数据完整性,清除结构不完整的续传数据
           multiAgentRun: message.multiAgentRun
             ? {